oxedyne/fe2o3/fe2o3_sbj/src/share.rs
49.5 KiB, 25 runs
created by r1870400018:22689, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | //! `daimond/share/0` — one person sending another a copy of something they own. |
| 2 | //! |
| 3 | //! A share **carries** what it sends. The files travel inside the payload, sealed to the |
| 4 | //! recipient, and what lands is theirs: their copy, in their workspace, under their own key. They |
| 5 | //! may change it, and the sender never sees the change; the sender may change theirs, and the |
| 6 | //! receiver never sees that either. There is no shared content key that outlives an edit, nothing |
| 7 | //! to revoke, and nobody's storage but the receiver's own. That is the whole design, and every |
| 8 | //! field below follows from it. |
| 9 | //! |
| 10 | //! It is a schema rather than a fifth [`crate::post::Target`] for the reason written beside |
| 11 | //! [`crate::SCHEMA_SHARE`], and the argument that settles it is the last one: a share must carry a |
| 12 | //! consent bit the signature covers, and a `Reference` carries exactly two keys and refuses a |
| 13 | //! third. |
| 14 | //! |
| 15 | //! # The consent bit |
| 16 | //! |
| 17 | //! Data travels freely. Code does not. A shared Diamond that carries a page is carrying **a |
| 18 | //! program written by another person**, and the receiver decides whether to run it — so the |
| 19 | //! artefact says, in the part the author signed, whether there is anything to decide. A flag a |
| 20 | //! relay could add or strip is not a consent flag, which is why [`KEY_CODE`] is inside the payload |
| 21 | //! and not in a wrapper around it. |
| 22 | //! |
| 23 | //! [`KEY_CODE`] is **required and always written**, never omitted when false. An omitted false |
| 24 | //! would be indistinguishable from a sender whose build had never heard of the field, and the one |
| 25 | //! thing a receiver must be able to tell apart is "they said there is no code" from "they did not |
| 26 | //! say". |
| 27 | //! |
| 28 | //! And the claim is **checked against the files**, both ways (see [`code_file`]). A payload |
| 29 | //! carrying a page under `code: false` is refused, so the bit cannot hide a program; a payload |
| 30 | //! claiming code and carrying none is refused too, so a sender cannot cry wolf and teach people to |
| 31 | //! wave the question away. The bit is not therefore redundant with the files, which is the obvious |
| 32 | //! objection to it: it is the SENDER's reading of [`CODE_SUFFIXES`], pinned at signing time, so a |
| 33 | //! later build that learns of a suffix this one does not know will disagree with an old artefact |
| 34 | //! rather than quietly decide for the receiver. |
| 35 | //! |
| 36 | //! # What a share may not carry |
| 37 | //! |
| 38 | //! Five paths are refused outright, and each is refused here rather than left to a client, so |
| 39 | //! that every implementation refuses the same things: |
| 40 | //! |
| 41 | //! - `.daimond/` — the meta, the append-only log, the link sidecar. The log is a record of what |
| 42 | //! agents did in the SENDER's Diamond, and nobody sending a recipe means to send that. |
| 43 | //! - `versions/` — the sender's own history, which is theirs and which would multiply the size of |
| 44 | //! the share by the length of it. |
| 45 | //! - `capp.json` — the delivery record, which says which bytes were delivered and at what template |
| 46 | //! version. It is a record of a delivery that never happened to the receiver, and one doctored |
| 47 | //! by the sender would pin the receiver's copy against every future template fix on THEIR |
| 48 | //! machine, which they never chose. A copy that arrives without one is a case the receiving |
| 49 | //! client already knows how to handle: it asks. |
| 50 | //! - `triggers.json` — automation that fires with nobody pressing anything. It arms on the |
| 51 | //! RECEIVER's money, and a client cannot save them from it by leaving it switched off: `on: |
| 52 | //! false` does not disarm a trigger, and a leaf that appears in a pause tree plays. Refused in |
| 53 | //! the FORMAT rather than by the sending client, because a receiver's exposure must not depend |
| 54 | //! on which build the sender was running. |
| 55 | //! - `STATE.md` — folder marks and build commands on the SENDER's disk. It is the one standing |
| 56 | //! file that is about a machine rather than about the work, it is rebuilt on the first turn in |
| 57 | //! the copy, and a path on somebody's disk is exactly what does not leave their device. |
| 58 | //! |
| 59 | //! The last two are the ones that are not about tidiness, and a template has refused both since it |
| 60 | //! existed (`daimond` `src/protocol.rs`, `TEMPLATE_DROP_EXACT`). A share carries the same files to |
| 61 | //! the same people, so the two lists agreeing is the point rather than a coincidence. |
| 62 | //! |
| 63 | //! The canonical rules of `SPEC.md` §3 apply unchanged. Two of them do real work here that they do |
| 64 | //! not do for a message: [`KEY_FILES`] is ordered by path and refuses a duplicate, since a set of |
| 65 | //! files written in two orders would be two addresses for one Diamond; and a path is refused |
| 66 | //! rather than normalised, since normalising is exactly how one file comes to have two spellings. |
| 67 | |
| 68 | use crate::{ |
| 69 | canon, |
| 70 | limit as sbj_limit, |
| 71 | }; |
| 72 | |
| 73 | use oxedyne_fe2o3_core::prelude::*; |
| 74 | use oxedyne_fe2o3_jdat::{ |
| 75 | prelude::*, |
| 76 | bdat::DecodeLimits, |
| 77 | }; |
| 78 | |
| 79 | |
| 80 | // ┌───────────────────────────────────────────────────────────────────────────┐ |
| 81 | // │ KEYS │ |
| 82 | // └───────────────────────────────────────────────────────────────────────────┘ |
| 83 | |
| 84 | /// Whether the files include executable page code. See the module note. |
| 85 | pub const KEY_CODE: &'static str = "code"; |
| 86 | /// The files, ordered by path. |
| 87 | pub const KEY_FILES: &'static str = "files"; |
| 88 | /// The shared thing's display name. |
| 89 | pub const KEY_NAME: &'static str = "name"; |
| 90 | /// Per-share randomness, so that two identical shares are two addresses. |
| 91 | pub const KEY_NONCE: &'static str = "nonce"; |
| 92 | /// The sender's covering sentence, if they wrote one. |
| 93 | pub const KEY_NOTE: &'static str = "note"; |
| 94 | /// The recipient's public key. |
| 95 | pub const KEY_TO: &'static str = "to"; |
| 96 | |
| 97 | /// One file's contents. |
| 98 | pub const KEY_BODY: &'static str = "body"; |
| 99 | /// One file's path, relative to the Diamond's own folder. |
| 100 | pub const KEY_PATH: &'static str = "path"; |
| 101 | |
| 102 | |
| 103 | /// The path prefixes a share may not carry, and why. |
| 104 | /// |
| 105 | /// Checked as a prefix here and as the whole path in [`REFUSED_EXACT`], which is what `capp.json` |
| 106 | /// needs: a file called `capp.json` inside a folder of the receiver's own making is ordinary data, |
| 107 | /// and the delivery record is the one at the root. |
| 108 | pub const REFUSED_PREFIXES: &[&'static str] = &[".daimond/", "versions/"]; |
| 109 | |
| 110 | /// The exact paths a share may not carry, each with the reason it is refused. |
| 111 | /// |
| 112 | /// Whole paths rather than prefixes, and at the ROOT: a `triggers.json` a receiver writes inside a |
| 113 | /// folder of their own is ordinary data, and only the one the app arms from is automation. |
| 114 | /// |
| 115 | /// It was one path and it is three. The two that joined it are the ones that are not about |
| 116 | /// tidiness, and the module header argues both; the reason they are HERE rather than in the |
| 117 | /// sending client is that a receiver's exposure must not depend on which build the sender ran. |
| 118 | pub const REFUSED_EXACT: &[(&'static str, &'static str)] = &[ |
| 119 | ("capp.json", |
| 120 | "It is a DELIVERY record: it says which bytes were delivered to that instance and at what template version, and it decides which files a future template fix may replace. The receiver was not delivered to; they were given a copy by a person. One carried across from somebody else's machine would pin their copy against updates they never chose, and a doctored one would do it on purpose. A copy with no record is a case the receiving client already knows: it asks."), |
| 121 | ("triggers.json", |
| 122 | "It is ARMED AUTOMATION. A trigger fires with nobody pressing anything, and it would fire on the receiver's account and spend the receiver's money because they accepted a gift. Sending it switched off is not an answer: `on: false` does not disarm a trigger -- the pause tree is the authority and a leaf that appears in it plays -- so a share that carried one and said it was off would be worse than one that carries none. The receiver sets up their own."), |
| 123 | ("STATE.md", |
| 124 | "It names the SENDER's own machine: the folders they marked and the command they build with. A path on somebody's disk does not leave their device, and this file is the one standing file that is about a machine rather than about the work. The copy rebuilds it on its first turn, from the receiver's own folders."), |
| 125 | ]; |
| 126 | |
| 127 | /// Is this exact path one a share may not carry? Answers the reason where it is. |
| 128 | pub fn refused_exact(path: &str) -> Option<&'static str> { |
| 129 | REFUSED_EXACT.iter().find(|(name, _)| *name == path).map(|(_, why)| *why) |
| 130 | } |
| 131 | |
| 132 | /// The suffixes that make a file code rather than data. |
| 133 | /// |
| 134 | /// A closed set, matched case-insensitively on ASCII. It is closed for the same reason the icon |
| 135 | /// names of `SPEC.md` §4.2 are: a reader knows exactly which files it will hand to an engine, and |
| 136 | /// a set that grew by guessing would be a set that admitted the first thing nobody thought of. |
| 137 | /// |
| 138 | /// Case-insensitively because a suffix check that is not is one `.HTML` away from being no check |
| 139 | /// at all, and the receiving side stores a file under the name it was sent under. |
| 140 | pub const CODE_SUFFIXES: &[&'static str] = &[".htm", ".html", ".js", ".mjs", ".svg", ".wasm"]; |
| 141 | |
| 142 | |
| 143 | /// Limits this schema enforces. Every one is a rejection, never a truncation. |
| 144 | pub mod limit { |
| 145 | /// The most files one share may carry. |
| 146 | /// |
| 147 | /// Sixty-four. A capp is a page, a memory, an index and a handful of seeded tables — under ten |
| 148 | /// — and each file in a share is examined and written on arrival, so the number bounds what |
| 149 | /// opening one costs. The figure is revisable on evidence, as `SPEC.md` §5's are; that there is |
| 150 | /// one is not. |
| 151 | pub const FILES: usize = 64; |
| 152 | /// The most all the file bodies together may carry, in bytes. |
| 153 | /// |
| 154 | /// Two mebibytes, and the reason is the RECEIVER's, not the format's. A Daimond sync parcel |
| 155 | /// carries at most six mebibytes across every Diamond an account holds, and a Diamond that does |
| 156 | /// not fit is left out of the parcel entirely rather than trimmed. A share larger than a third |
| 157 | /// of that budget is a share that would stop travelling between the receiver's own devices the |
| 158 | /// day it arrived, which is a worse failure than being refused now. |
| 159 | pub const TOTAL_BYTES: usize = 2 * 1024 * 1024; |
| 160 | /// The most one file's path may carry, in bytes of UTF-8. |
| 161 | pub const PATH_BYTES: usize = 256; |
| 162 | /// The most the display name may carry, in bytes of UTF-8. |
| 163 | pub const NAME_BYTES: usize = 128; |
| 164 | /// The most the covering note may carry, in bytes of UTF-8. |
| 165 | /// |
| 166 | /// A sentence, not a letter. A letter is a `daimond/post/0` message, which carries eight |
| 167 | /// kibibytes and is the thing built for prose; this is the line that says what the gift is. |
| 168 | pub const NOTE_BYTES: usize = 512; |
| 169 | /// The exact width of the per-share nonce. |
| 170 | pub const NONCE_BYTES: usize = 16; |
| 171 | /// The exact width of a public key. |
| 172 | pub const KEY_BYTES: usize = 32; |
| 173 | /// Decoding depth for a payload of this schema. |
| 174 | /// |
| 175 | /// A share is a flat record holding one list of flat maps, so four levels is its whole shape |
| 176 | /// and eight is already past anything it can reach. Far below `SPEC.md` §5's tree limit because |
| 177 | /// nothing here recurses, and a limit set to what the shape needs refuses a nested value before |
| 178 | /// it is looked at. |
| 179 | pub const DEPTH: usize = 8; |
| 180 | } |
| 181 | |
| 182 | |
| 183 | // ┌───────────────────────────────────────────────────────────────────────────┐ |
| 184 | // │ CODE │ |
| 185 | // └───────────────────────────────────────────────────────────────────────────┘ |
| 186 | |
| 187 | /// Whether a path names a file this version considers code. |
| 188 | /// |
| 189 | /// The suffix and nothing else. What a file contains is not consulted, deliberately: a rule about |
| 190 | /// contents would be a rule a reader had to run over every byte of every share before it could say |
| 191 | /// whether there was a question to ask, and it would answer differently for the same file on two |
| 192 | /// builds. A suffix is a fact about the name, and the name is what the receiving side stores. |
| 193 | pub fn is_code_path(path: &str) -> bool { |
| 194 | let lower = path.to_ascii_lowercase(); |
| 195 | CODE_SUFFIXES.iter().any(|s| lower.ends_with(s)) |
| 196 | } |
| 197 | |
| 198 | /// The first file in a list that is code, or `None` when none of them is. |
| 199 | /// |
| 200 | /// The FIRST rather than a count, because the error names it: "this share says it carries no code |
| 201 | /// and carries `crystal.html`" is a sentence a person can act on, and "1 code file" is not. |
| 202 | pub fn code_file(files: &[File]) -> Option<&File> { |
| 203 | files.iter().find(|f| is_code_path(&f.path)) |
| 204 | } |
| 205 | |
| 206 | |
| 207 | // ┌───────────────────────────────────────────────────────────────────────────┐ |
| 208 | // │ ONE FILE │ |
| 209 | // └───────────────────────────────────────────────────────────────────────────┘ |
| 210 | |
| 211 | /// One file of a share: where it goes, and what is in it. |
| 212 | /// |
| 213 | /// The body is bytes and is held to no text rule, because a Diamond holds pictures as well as |
| 214 | /// prose and a canonical encoding of bytes is the bytes. The PATH is a string and is held to every |
| 215 | /// rule `SPEC.md` §3 has for one, since two spellings of one path would be two addresses for one |
| 216 | /// Diamond. |
| 217 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 218 | pub struct File { |
| 219 | /// Where the file goes, relative to the receiver's copy of the Diamond. |
| 220 | pub path: String, |
| 221 | /// What is in it. |
| 222 | pub body: Vec<u8>, |
| 223 | } |
| 224 | |
| 225 | impl File { |
| 226 | /// Encodes this file as a canonical daticle. |
| 227 | pub fn to_dat(&self) -> Outcome<Dat> { |
| 228 | let mut map = DaticleMap::new(); |
| 229 | map.insert(dat!(KEY_BODY), Dat::BU32(self.body.clone())); |
| 230 | map.insert(dat!(KEY_PATH), Dat::Str(self.path.clone())); |
| 231 | Ok(Dat::Map(map)) |
| 232 | } |
| 233 | |
| 234 | /// Reads a file, refusing anything this schema does not admit. |
| 235 | pub fn from_dat(d: &Dat) -> Outcome<Self> { |
| 236 | let map = match d { |
| 237 | Dat::Map(m) => m, |
| 238 | other => return Err(err!( |
| 239 | "A shared file must be a Dat::Map, found a {:?}.", other.kind(); |
| 240 | Invalid, Input, Mismatch)), |
| 241 | }; |
| 242 | res!(exact_keys(map, &[KEY_BODY, KEY_PATH], "shared file")); |
| 243 | |
| 244 | let path = match res!(get(map, KEY_PATH)) { |
| 245 | Dat::Str(s) => s.clone(), |
| 246 | other => return Err(err!( |
| 247 | "A shared file's \"{}\" must be a string, found a {:?}.", KEY_PATH, other.kind(); |
| 248 | Invalid, Input, Mismatch)), |
| 249 | }; |
| 250 | res!(check_path(&path)); |
| 251 | |
| 252 | let body = match res!(get(map, KEY_BODY)) { |
| 253 | Dat::BU32(b) => b.clone(), |
| 254 | Dat::BU8(_) | Dat::BU16(_) | Dat::BU64(_) => return Err(err!( |
| 255 | "The shared file \"{}\" carries its contents in a byte string that is not a BU32. A \ |
| 256 | narrower one truncates silently past its width, and a wider one is a second \ |
| 257 | encoding of the same value.", path; |
| 258 | Invalid, Input, Mismatch)), |
| 259 | other => return Err(err!( |
| 260 | "The shared file \"{}\" must carry its contents in a BU32, found a {:?}.", |
| 261 | path, other.kind(); |
| 262 | Invalid, Input, Mismatch)), |
| 263 | }; |
| 264 | Ok(Self { path, body }) |
| 265 | } |
| 266 | } |
| 267 | |
| 268 | /// Checks a path against every rule this schema has for one. |
| 269 | /// |
| 270 | /// **Refused rather than normalised**, which is where this parts company with the client-side |
| 271 | /// `safePath` it otherwise matches. That function is handed an untrusted request and drops an |
| 272 | /// empty or `.` segment on the way to a real file; this is deciding what a signed artefact means, |
| 273 | /// and there a path that needed tidying is a path with two spellings and so a Diamond with two |
| 274 | /// addresses. Every check below is a rejection. |
| 275 | pub fn check_path(path: &str) -> Outcome<()> { |
| 276 | if path.is_empty() { |
| 277 | return Err(err!( |
| 278 | "A shared file carries an empty path."; Invalid, Input, Missing)); |
| 279 | } |
| 280 | if path.len() > limit::PATH_BYTES { |
| 281 | return Err(err!( |
| 282 | "The shared path \"{}\" is {} bytes, exceeding the limit of {}.", |
| 283 | path, path.len(), limit::PATH_BYTES; |
| 284 | Invalid, Input, LimitReached)); |
| 285 | } |
| 286 | // The §3 rule 5 string rules: UTF-8 already, and now NFC, no control characters, no carriage |
| 287 | // return. A path spelled with a combining accent displays as the composed one and hashes |
| 288 | // differently, which for a file name is two files that look like one. |
| 289 | res!(canon::check_string(path)); |
| 290 | |
| 291 | if path.contains('\\') { |
| 292 | return Err(err!( |
| 293 | "The shared path \"{}\" carries a backslash. A path is joined with \"/\" and nothing \ |
| 294 | else, so a backslash is either a separator this format does not have or a character in \ |
| 295 | a name that will not survive being written down.", path; |
| 296 | Invalid, Input)); |
| 297 | } |
| 298 | if path.starts_with('/') { |
| 299 | return Err(err!( |
| 300 | "The shared path \"{}\" is absolute. Every path in a share is relative to the \ |
| 301 | receiver's own copy of the Diamond, and an absolute one names a place on their machine \ |
| 302 | that the sender cannot know and must not reach.", path; |
| 303 | Invalid, Input)); |
| 304 | } |
| 305 | // A scheme, by the same rule `safePath` uses: a letter, then letters, digits, `+`, `.` or `-`, |
| 306 | // then a colon. `c:/x` and `data:…` are both caught, and neither is a relative path. |
| 307 | if let Some(colon) = path.find(':') { |
| 308 | let head = &path[..colon]; |
| 309 | if !head.is_empty() |
| 310 | && head.starts_with(|c: char| c.is_ascii_alphabetic()) |
| 311 | && head.chars().all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '.' || c == '-') |
| 312 | { |
| 313 | return Err(err!( |
| 314 | "The shared path \"{}\" begins with what reads as a scheme, \"{}:\". A share \ |
| 315 | carries files, never locations.", path, head; |
| 316 | Invalid, Input)); |
| 317 | } |
| 318 | } |
| 319 | for seg in path.split('/') { |
| 320 | if seg.is_empty() { |
| 321 | return Err(err!( |
| 322 | "The shared path \"{}\" carries an empty segment. It is refused rather than \ |
| 323 | tidied: a path that needs tidying has two spellings, and two spellings of one file \ |
| 324 | are two addresses for one Diamond.", path; |
| 325 | Invalid, Input)); |
| 326 | } |
| 327 | if seg == "." || seg == ".." { |
| 328 | return Err(err!( |
| 329 | "The shared path \"{}\" carries a \"{}\" segment. A share reaches nothing outside \ |
| 330 | the Diamond it is a copy of, and a path that walks is refused rather than \ |
| 331 | resolved.", path, seg; |
| 332 | Invalid, Input)); |
| 333 | } |
| 334 | } |
| 335 | |
| 336 | for prefix in REFUSED_PREFIXES { |
| 337 | if path.starts_with(prefix) { |
| 338 | return Err(err!( |
| 339 | "The shared path \"{}\" is under \"{}\", which a share may not carry. That folder \ |
| 340 | holds the SENDER's own record — the stamps the sync merge decides on, the link \ |
| 341 | sidecar, and the append-only log of what agents did in their copy. A person \ |
| 342 | sending a recipe does not mean to send that, and the receiver's copy is new: its \ |
| 343 | record starts empty because nothing has happened in it yet.", path, prefix; |
| 344 | Invalid, Input)); |
| 345 | } |
| 346 | } |
| 347 | if let Some(why) = refused_exact(path) { |
| 348 | return Err(err!("A share may not carry \"{}\". {}", path, why; Invalid, Input)); |
| 349 | } |
| 350 | Ok(()) |
| 351 | } |
| 352 | |
| 353 | |
| 354 | // ┌───────────────────────────────────────────────────────────────────────────┐ |
| 355 | // │ THE SHARE │ |
| 356 | // └───────────────────────────────────────────────────────────────────────────┘ |
| 357 | |
| 358 | /// A `daimond/share/0` payload. |
| 359 | /// |
| 360 | /// Every field is inside the payload region, so every field is covered by the envelope's `hash` |
| 361 | /// and therefore by its signature. A relay handling this artefact can add nothing to it, remove |
| 362 | /// nothing from it, and rewrite nothing in it — including [`Share::code`] — without the signature |
| 363 | /// ceasing to verify. |
| 364 | /// |
| 365 | /// There is no sender field and no timestamp, for the reasons `crate::post` gives: the author is |
| 366 | /// the envelope's `author`, and the time is the envelope's and advisory. There is also **no |
| 367 | /// identifier of the sender's Diamond**, which is particular to this schema. The receiver's copy |
| 368 | /// is a new Diamond with an identifier of their own making, so an identifier that travelled would |
| 369 | /// either be a field nobody read or a way for one person's share to land on top of another |
| 370 | /// person's Diamond. |
| 371 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 372 | pub struct Share { |
| 373 | /// The shared thing's display name. Advisory, exactly as a card's label is. |
| 374 | pub name: String, |
| 375 | /// The recipient's public key. |
| 376 | pub to: Vec<u8>, |
| 377 | /// Per-share randomness, so two identical shares are two addresses. |
| 378 | pub nonce: Vec<u8>, |
| 379 | /// The sender's covering sentence, if they wrote one. |
| 380 | pub note: Option<String>, |
| 381 | /// The files, ordered by path and each path carried once. |
| 382 | pub files: Vec<File>, |
| 383 | /// Whether the files include executable page code. |
| 384 | /// |
| 385 | /// The sender's own claim, signed, and checked against the files both ways. See the module |
| 386 | /// note for why it is here rather than derived, and why it is always written. |
| 387 | pub code: bool, |
| 388 | } |
| 389 | |
| 390 | impl Share { |
| 391 | |
| 392 | /// Builds a share, putting the files in canonical order and stating the code claim for the |
| 393 | /// caller. |
| 394 | /// |
| 395 | /// The claim is computed here rather than taken as an argument because a caller who could |
| 396 | /// supply it could supply the wrong one, and the only honest value at composition time is what |
| 397 | /// the files say. [`Share::code`] remains a field, and remains signed, because it is the value |
| 398 | /// THIS build computed and a later one may disagree with; what this constructor removes is the |
| 399 | /// chance to disagree with it on purpose. |
| 400 | pub fn new( |
| 401 | name: String, |
| 402 | to: Vec<u8>, |
| 403 | nonce: Vec<u8>, |
| 404 | note: Option<String>, |
| 405 | files: Vec<File>, |
| 406 | ) |
| 407 | -> Self |
| 408 | { |
| 409 | let mut files = files; |
| 410 | files.sort_by(|a, b| a.path.as_bytes().cmp(b.path.as_bytes())); |
| 411 | let code = code_file(&files).is_some(); |
| 412 | Self { name, to, nonce, note, files, code } |
| 413 | } |
| 414 | |
| 415 | /// Encodes this share as a canonical daticle. |
| 416 | pub fn to_dat(&self) -> Outcome<Dat> { |
| 417 | let mut map = DaticleMap::new(); |
| 418 | // Always written, never omitted when false. An omitted false and a sender whose build had |
| 419 | // never heard of the field are the same bytes, and those are the two things a receiver must |
| 420 | // be able to tell apart. |
| 421 | map.insert(dat!(KEY_CODE), Dat::Bool(self.code)); |
| 422 | let mut list = Vec::with_capacity(self.files.len()); |
| 423 | for f in &self.files { |
| 424 | list.push(res!(f.to_dat())); |
| 425 | } |
| 426 | map.insert(dat!(KEY_FILES), Dat::List(list)); |
| 427 | map.insert(dat!(KEY_NAME), Dat::Str(self.name.clone())); |
| 428 | map.insert(dat!(KEY_NONCE), Dat::BU8(self.nonce.clone())); |
| 429 | // An absent note is OMITTED, never encoded as `none` or as an empty string: SPEC.md §3 |
| 430 | // rules 4 and 8, so that one share has one encoding. |
| 431 | if let Some(n) = &self.note { |
| 432 | map.insert(dat!(KEY_NOTE), Dat::Str(n.clone())); |
| 433 | } |
| 434 | map.insert(dat!(KEY_TO), Dat::BU8(self.to.clone())); |
| 435 | Ok(Dat::Map(map)) |
| 436 | } |
| 437 | |
| 438 | /// Reads a share, enforcing every rule this schema declares. |
| 439 | pub fn from_dat(d: &Dat) -> Outcome<Self> { |
| 440 | let map = match d { |
| 441 | Dat::Map(m) => m, |
| 442 | Dat::OrdMap(_) => return Err(err!( |
| 443 | "SPEC.md §3 rule 2: a share payload is a Dat::Map, never a Dat::OrdMap. An OrdMap \ |
| 444 | follows the author's typing rather than the keys, so the same share would have as \ |
| 445 | many addresses as there are orders to write it in."; |
| 446 | Invalid, Input, Mismatch)), |
| 447 | other => return Err(err!( |
| 448 | "A share payload must be a Dat::Map, found a {:?}.", other.kind(); |
| 449 | Invalid, Input, Mismatch)), |
| 450 | }; |
| 451 | let allowed: Vec<&str> = { |
| 452 | let mut v = vec![KEY_CODE, KEY_FILES, KEY_NAME, KEY_NONCE, KEY_TO]; |
| 453 | if map.contains_key(&dat!(KEY_NOTE)) { v.push(KEY_NOTE); } |
| 454 | v |
| 455 | }; |
| 456 | res!(exact_keys(map, &allowed, "share")); |
| 457 | |
| 458 | let name = match res!(get(map, KEY_NAME)) { |
| 459 | Dat::Str(s) => s.clone(), |
| 460 | other => return Err(err!( |
| 461 | "The share key \"{}\" must be a string, found a {:?}.", KEY_NAME, other.kind(); |
| 462 | Invalid, Input, Mismatch)), |
| 463 | }; |
| 464 | res!(check_text(&name, KEY_NAME, limit::NAME_BYTES)); |
| 465 | |
| 466 | let to = res!(get_bytes(map, KEY_TO, limit::KEY_BYTES)); |
| 467 | let nonce = res!(get_bytes(map, KEY_NONCE, limit::NONCE_BYTES)); |
| 468 | |
| 469 | let note = match map.get(&dat!(KEY_NOTE)) { |
| 470 | Some(Dat::Str(s)) => { |
| 471 | if s.is_empty() { |
| 472 | return Err(err!( |
| 473 | "SPEC.md §3 rule 8: the share carries an empty \"{}\". A note a reader \ |
| 474 | would draw identically whether present or absent gives one share two \ |
| 475 | encodings, and so two addresses. Omit the key.", KEY_NOTE; |
| 476 | Invalid, Input)); |
| 477 | } |
| 478 | res!(check_text(s, KEY_NOTE, limit::NOTE_BYTES)); |
| 479 | Some(s.clone()) |
| 480 | }, |
| 481 | Some(other) => return Err(err!( |
| 482 | "The share key \"{}\" must be a string, found a {:?}.", KEY_NOTE, other.kind(); |
| 483 | Invalid, Input, Mismatch)), |
| 484 | None => None, |
| 485 | }; |
| 486 | |
| 487 | let code = match res!(get(map, KEY_CODE)) { |
| 488 | Dat::Bool(b) => *b, |
| 489 | other => return Err(err!( |
| 490 | "The share key \"{}\" must be a bool, found a {:?}. It is the sender's signed \ |
| 491 | statement about whether this share carries a program, and a reader that could not \ |
| 492 | read it would be asking a person to consent to something nobody described.", |
| 493 | KEY_CODE, other.kind(); |
| 494 | Invalid, Input, Mismatch)), |
| 495 | }; |
| 496 | |
| 497 | let files = match res!(get(map, KEY_FILES)) { |
| 498 | Dat::List(items) => { |
| 499 | if items.is_empty() { |
| 500 | return Err(err!( |
| 501 | "The share carries no files. A share is a copy of something, and a copy of \ |
| 502 | nothing is not a smaller share; it is not one."; |
| 503 | Invalid, Input, Missing)); |
| 504 | } |
| 505 | if items.len() > limit::FILES { |
| 506 | return Err(err!( |
| 507 | "The share carries {} files, exceeding the limit of {}. Each is examined \ |
| 508 | and written on the RECEIVER's machine when the share is opened.", |
| 509 | items.len(), limit::FILES; |
| 510 | Invalid, Input, LimitReached)); |
| 511 | } |
| 512 | let mut out: Vec<File> = Vec::with_capacity(items.len()); |
| 513 | let mut total: usize = 0; |
| 514 | for (i, item) in items.iter().enumerate() { |
| 515 | let f = res!(File::from_dat(item).map_err(|e| err!(e, |
| 516 | "File {} of {} is not one this schema admits.", i, items.len(); |
| 517 | Invalid, Input))); |
| 518 | // Ordered by path, and each path once. A set of files written in two orders |
| 519 | // would be two addresses for one Diamond, and the same file twice is a share |
| 520 | // whose meaning depends on which entry the receiver writes last. |
| 521 | if let Some(prev) = out.last() { |
| 522 | if f.path.as_bytes() == prev.path.as_bytes() { |
| 523 | return Err(err!( |
| 524 | "The share carries the path \"{}\" twice. Which copy the receiver \ |
| 525 | ends up with would then depend on the order they were written in.", |
| 526 | f.path; |
| 527 | Invalid, Input)); |
| 528 | } |
| 529 | if f.path.as_bytes() < prev.path.as_bytes() { |
| 530 | return Err(err!( |
| 531 | "The share's files are not in path order: \"{}\" follows \"{}\". \ |
| 532 | The order is fixed so that one set of files has one encoding, and \ |
| 533 | so one address; it is refused rather than sorted, because sorting \ |
| 534 | it would be accepting a second encoding and quietly rewriting it.", |
| 535 | f.path, prev.path; |
| 536 | Invalid, Input)); |
| 537 | } |
| 538 | } |
| 539 | total = total.saturating_add(f.body.len()); |
| 540 | out.push(f); |
| 541 | } |
| 542 | if total > limit::TOTAL_BYTES { |
| 543 | return Err(err!( |
| 544 | "The share's files carry {} bytes together, exceeding the limit of {}. It \ |
| 545 | is refused rather than trimmed: a share missing a file is not a smaller \ |
| 546 | share, and the ceiling is the receiver's sync budget rather than this \ |
| 547 | format's.", total, limit::TOTAL_BYTES; |
| 548 | Invalid, Input, LimitReached)); |
| 549 | } |
| 550 | out |
| 551 | }, |
| 552 | Dat::Vek(_) => return Err(err!( |
| 553 | "SPEC.md §3 rule 7: \"{}\" is a Dat::List, never a Dat::Vek, even where every \ |
| 554 | element shares a kind.", KEY_FILES; |
| 555 | Invalid, Input, Mismatch)), |
| 556 | other => return Err(err!( |
| 557 | "The share key \"{}\" must be a list, found a {:?}.", KEY_FILES, other.kind(); |
| 558 | Invalid, Input, Mismatch)), |
| 559 | }; |
| 560 | |
| 561 | // The consent bit against the files, both ways. Neither direction is a formality: one stops |
| 562 | // a program arriving under a claim that there is none, and the other stops a sender asking |
| 563 | // for consent they do not need, which is how a person learns to wave the question away. |
| 564 | match (code, code_file(&files)) { |
| 565 | (false, Some(f)) => return Err(err!( |
| 566 | "The share states that it carries no code, and carries \"{}\". The claim is the \ |
| 567 | sender's, it is signed, and it is what a receiver is asked to consent to before \ |
| 568 | anything runs, so a share that contradicts its own claim is refused rather than \ |
| 569 | corrected.", f.path; |
| 570 | Invalid, Input, Mismatch)), |
| 571 | (true, None) => return Err(err!( |
| 572 | "The share states that it carries code, and carries none. It is refused rather \ |
| 573 | than accepted as harmless caution: a receiver asked to consent to a program that \ |
| 574 | is not there is a receiver being taught that the question does not mean anything."; |
| 575 | Invalid, Input, Mismatch)), |
| 576 | _ => {}, |
| 577 | } |
| 578 | |
| 579 | Ok(Self { name, to, nonce, note, files, code }) |
| 580 | } |
| 581 | |
| 582 | /// Encodes this share to the canonical bytes that become the payload region. |
| 583 | pub fn encode(&self) -> Outcome<Vec<u8>> { |
| 584 | let d = res!(self.to_dat()); |
| 585 | // Read straight back, so that a share which cannot be decoded can never be signed. Signing |
| 586 | // bytes no reader will accept produces an artefact that is valid to its author and refused |
| 587 | // by everybody else. |
| 588 | res!(Self::from_dat(&d)); |
| 589 | let bytes = res!(d.to_bytes(Vec::new())); |
| 590 | if bytes.len() > sbj_limit::TREE_BYTES { |
| 591 | return Err(err!( |
| 592 | "The encoded share is {} bytes, exceeding the payload region limit of {}.", |
| 593 | bytes.len(), sbj_limit::TREE_BYTES; |
| 594 | Invalid, Input, LimitReached)); |
| 595 | } |
| 596 | Ok(bytes) |
| 597 | } |
| 598 | |
| 599 | /// Decodes a share from the bytes of a payload region, which must be consumed exactly. |
| 600 | /// |
| 601 | /// The bytes are re-encoded and compared with what came in, which is what enforces the |
| 602 | /// byte-level rules a decoded value can no longer show: a duplicate key collapses into one |
| 603 | /// entry when BDAT builds its map, and a length written in more bytes than it needs decodes to |
| 604 | /// the same number. Both survive only in the bytes. |
| 605 | pub fn decode(buf: &[u8]) -> Outcome<Self> { |
| 606 | let lims = DecodeLimits::new(limit::DEPTH, sbj_limit::TREE_BYTES); |
| 607 | let (d, n) = res!(Dat::from_bytes_limited(buf, &lims)); |
| 608 | if n != buf.len() { |
| 609 | return Err(err!( |
| 610 | "The share payload occupies {} of the {} bytes supplied, leaving {} trailing.", |
| 611 | n, buf.len(), buf.len() - n; |
| 612 | Invalid, Input, Decode)); |
| 613 | } |
| 614 | let re = res!(d.to_bytes(Vec::new())); |
| 615 | if re != buf { |
| 616 | return Err(err!( |
| 617 | "The share payload is not in canonical form: it re-encodes to {} bytes against the \ |
| 618 | {} supplied, so it carries a duplicate key, a non-minimal length, or a \ |
| 619 | non-canonical map. See SPEC.md §3.", re.len(), buf.len(); |
| 620 | Invalid, Input, Decode)); |
| 621 | } |
| 622 | Self::from_dat(&d) |
| 623 | } |
| 624 | } |
| 625 | |
| 626 | |
| 627 | // ┌───────────────────────────────────────────────────────────────────────────┐ |
| 628 | // │ FIELD READERS │ |
| 629 | // └───────────────────────────────────────────────────────────────────────────┘ |
| 630 | |
| 631 | /// Returns a required key's value, or an error naming the key that is missing. |
| 632 | fn get<'a>(map: &'a DaticleMap, key: &str) -> Outcome<&'a Dat> { |
| 633 | match map.get(&dat!(key)) { |
| 634 | Some(d) => Ok(d), |
| 635 | None => Err(err!( |
| 636 | "The share is missing the required key \"{}\".", key; |
| 637 | Invalid, Input, Missing)), |
| 638 | } |
| 639 | } |
| 640 | |
| 641 | /// Reads a required `BU8` key of an exact width. |
| 642 | /// |
| 643 | /// Exact rather than bounded because every one of these is a key or a nonce, and each has one |
| 644 | /// size. A short one is not a smaller key; it is a different thing. |
| 645 | fn get_bytes(map: &DaticleMap, key: &str, width: usize) -> Outcome<Vec<u8>> { |
| 646 | let b = match res!(get(map, key)) { |
| 647 | Dat::BU8(b) => b.clone(), |
| 648 | other => return Err(err!( |
| 649 | "The share key \"{}\" must carry a BU8, found a {:?}.", key, other.kind(); |
| 650 | Invalid, Input, Mismatch)), |
| 651 | }; |
| 652 | if b.len() != width { |
| 653 | return Err(err!( |
| 654 | "The share key \"{}\" carries {} bytes and must carry exactly {}.", key, b.len(), width; |
| 655 | Invalid, Input, Mismatch)); |
| 656 | } |
| 657 | Ok(b) |
| 658 | } |
| 659 | |
| 660 | /// Checks a string field against the canonical text rules and a byte ceiling. |
| 661 | fn check_text(s: &str, key: &str, max: usize) -> Outcome<()> { |
| 662 | if s.len() > max { |
| 663 | return Err(err!( |
| 664 | "The share's \"{}\" is {} bytes, exceeding the limit of {}.", key, s.len(), max; |
| 665 | Invalid, Input, LimitReached)); |
| 666 | } |
| 667 | res!(canon::check_string(s)); |
| 668 | Ok(()) |
| 669 | } |
| 670 | |
| 671 | /// Requires a map to carry exactly the named keys — no more, and no fewer. |
| 672 | /// |
| 673 | /// Both directions, because they catch different faults. A missing key is a share that does not |
| 674 | /// say something it must. An unknown key is a field the sender signed and no reader will ever |
| 675 | /// draw, which is worse than useless: it is covered by the signature, so it looks like meaning. |
| 676 | fn exact_keys(map: &DaticleMap, allowed: &[&str], what: &str) -> Outcome<()> { |
| 677 | for k in allowed { |
| 678 | if !map.contains_key(&dat!(*k)) { |
| 679 | return Err(err!( |
| 680 | "The {} is missing the required key \"{}\".", what, k; |
| 681 | Invalid, Input, Missing)); |
| 682 | } |
| 683 | } |
| 684 | for k in map.keys() { |
| 685 | let name = match k { |
| 686 | Dat::Str(s) => s.clone(), |
| 687 | other => return Err(err!( |
| 688 | "SPEC.md §3 rule 3: a map key must be a string, found a {:?}.", other.kind(); |
| 689 | Invalid, Input, Mismatch)), |
| 690 | }; |
| 691 | res!(canon::check_key_string(&name)); |
| 692 | if !allowed.iter().any(|a| *a == name.as_str()) { |
| 693 | return Err(err!( |
| 694 | "The {} carries the key \"{}\", which this schema does not admit. The admitted \ |
| 695 | keys are: {}.", what, name, allowed.join(", "); |
| 696 | Invalid, Input, Unknown)); |
| 697 | } |
| 698 | } |
| 699 | Ok(()) |
| 700 | } |
| 701 | |
| 702 | |
| 703 | #[cfg(test)] |
| 704 | mod tests { |
| 705 | use super::*; |
| 706 | |
| 707 | /// A plausible share of data alone, with fixed contents. |
| 708 | fn sample() -> Share { |
| 709 | Share::new( |
| 710 | fmt!("Sourdough"), |
| 711 | vec![0xA1; limit::KEY_BYTES], |
| 712 | vec![0xB2; limit::NONCE_BYTES], |
| 713 | None, |
| 714 | vec![ |
| 715 | File { path: fmt!("crystal.json"), body: b"{\"loaves\":3}".to_vec() }, |
| 716 | File { path: fmt!("bakes/2026.jsonl"), body: b"{\"day\":1}\n".to_vec() }, |
| 717 | ], |
| 718 | ) |
| 719 | } |
| 720 | |
| 721 | /// The same share, carrying a page. |
| 722 | fn sample_capp() -> Share { |
| 723 | let mut files = sample().files; |
| 724 | files.push(File { path: fmt!("crystal.html"), body: b"<p>hello</p>".to_vec() }); |
| 725 | Share::new( |
| 726 | fmt!("Life log"), |
| 727 | vec![0xA1; limit::KEY_BYTES], |
| 728 | vec![0xB2; limit::NONCE_BYTES], |
| 729 | Some(fmt!("The food log we talked about.")), |
| 730 | files, |
| 731 | ) |
| 732 | } |
| 733 | |
| 734 | #[test] |
| 735 | fn test_round_trip_data_only() -> Outcome<()> { |
| 736 | let s = sample(); |
| 737 | assert!(!s.code, "A share of two data files claims to carry code."); |
| 738 | let bytes = res!(s.encode()); |
| 739 | let back = res!(Share::decode(&bytes)); |
| 740 | assert_eq!(s, back); |
| 741 | Ok(()) |
| 742 | } |
| 743 | |
| 744 | #[test] |
| 745 | fn test_round_trip_with_a_capp() -> Outcome<()> { |
| 746 | let s = sample_capp(); |
| 747 | assert!(s.code, "A share carrying crystal.html does not claim to carry code."); |
| 748 | let bytes = res!(s.encode()); |
| 749 | let back = res!(Share::decode(&bytes)); |
| 750 | assert_eq!(s, back); |
| 751 | assert!(back.code); |
| 752 | Ok(()) |
| 753 | } |
| 754 | |
| 755 | /// The property the whole schema exists for: a program cannot travel under a claim of none. |
| 756 | #[test] |
| 757 | fn test_a_page_under_a_false_code_claim_is_refused() -> Outcome<()> { |
| 758 | let mut s = sample_capp(); |
| 759 | s.code = false; // the sender lies, or a build computes it differently |
| 760 | match s.encode() { |
| 761 | Ok(_) => Err(err!( |
| 762 | "A share carrying a page under `code: false` was encoded, so a program could \ |
| 763 | arrive as data."; Test, Invalid)), |
| 764 | Err(e) => { |
| 765 | let msg = fmt!("{}", e); |
| 766 | assert!(msg.contains("crystal.html"), |
| 767 | "The refusal does not name the file that is code: {}", msg); |
| 768 | Ok(()) |
| 769 | }, |
| 770 | } |
| 771 | } |
| 772 | |
| 773 | /// And the other way, so the bit cannot be set for effect. |
| 774 | #[test] |
| 775 | fn test_a_code_claim_with_no_code_is_refused() -> Outcome<()> { |
| 776 | let mut s = sample(); |
| 777 | s.code = true; |
| 778 | match s.encode() { |
| 779 | Ok(_) => Err(err!( |
| 780 | "A share claiming code and carrying none was encoded."; Test, Invalid)), |
| 781 | Err(_) => Ok(()), |
| 782 | } |
| 783 | } |
| 784 | |
| 785 | /// The claim survives the wire, which is the point of it being signed rather than derived. |
| 786 | #[test] |
| 787 | fn test_the_code_bit_is_in_the_bytes() -> Outcome<()> { |
| 788 | let data = res!(sample().encode()); |
| 789 | let capp = res!(sample_capp().encode()); |
| 790 | assert_ne!(data, capp); |
| 791 | // And a payload with the bit flipped is not a payload this schema reads. |
| 792 | let mut m = match res!(sample().to_dat()) { |
| 793 | Dat::Map(m) => m, |
| 794 | other => return Err(err!( |
| 795 | "A share encodes as a map, and this is a {:?}.", other.kind(); Test, Bug)), |
| 796 | }; |
| 797 | m.insert(dat!(KEY_CODE), Dat::Bool(true)); |
| 798 | match Share::from_dat(&Dat::Map(m)) { |
| 799 | Ok(_) => Err(err!( |
| 800 | "A share whose code bit was flipped on the wire was read."; Test, Invalid)), |
| 801 | Err(_) => Ok(()), |
| 802 | } |
| 803 | } |
| 804 | |
| 805 | /// The bit is IN THE BYTES the address is taken over, so changing it changes the address. |
| 806 | /// |
| 807 | /// This is the payload half of "a flag a relay could add or strip is not a consent flag". The |
| 808 | /// artefact half is in `doc.rs`: the signature covers the address, so a carrier that changed |
| 809 | /// the bit would have to forge a signature to go with it. |
| 810 | /// |
| 811 | /// The bytes are built by hand rather than through `encode`, because the whole point is a |
| 812 | /// payload this schema would refuse to write: a `code` that disagrees with the files. A |
| 813 | /// carrier is not held to the schema, so the test must not be either. |
| 814 | #[test] |
| 815 | fn test_flipping_the_code_bit_changes_the_address() -> Outcome<()> { |
| 816 | let honest = res!(sample().encode()); |
| 817 | let mut m = match res!(sample().to_dat()) { |
| 818 | Dat::Map(m) => m, |
| 819 | other => return Err(err!( |
| 820 | "A share encodes as a map, and this is a {:?}.", other.kind(); Test, Bug)), |
| 821 | }; |
| 822 | m.insert(dat!(KEY_CODE), Dat::Bool(true)); |
| 823 | let tampered = res!(Dat::Map(m).to_bytes(Vec::new())); |
| 824 | assert_ne!(tampered, honest, |
| 825 | "Setting the consent bit did not change a byte, so nothing signed covers it."); |
| 826 | // And the tampered bytes are refused on the way in, so a carrier gains nothing even where |
| 827 | // the container is not consulted. |
| 828 | assert!(Share::decode(&tampered).is_err(), |
| 829 | "A share whose consent bit was set by somebody other than its author was read."); |
| 830 | Ok(()) |
| 831 | } |
| 832 | |
| 833 | /// A code suffix in capitals is still a code suffix. |
| 834 | #[test] |
| 835 | fn test_the_suffix_check_ignores_case() -> Outcome<()> { |
| 836 | assert!(is_code_path("Crystal.HTML")); |
| 837 | assert!(is_code_path("a/b/PAGE.Js")); |
| 838 | assert!(!is_code_path("crystal.json")); |
| 839 | assert!(!is_code_path("notes.html.md")); |
| 840 | let s = Share::new( |
| 841 | fmt!("Shouting"), |
| 842 | vec![0xA1; limit::KEY_BYTES], |
| 843 | vec![0xB2; limit::NONCE_BYTES], |
| 844 | None, |
| 845 | vec![File { path: fmt!("PAGE.HTML"), body: b"<p>x</p>".to_vec() }], |
| 846 | ); |
| 847 | assert!(s.code, "A file called PAGE.HTML was not counted as code."); |
| 848 | Ok(()) |
| 849 | } |
| 850 | |
| 851 | #[test] |
| 852 | fn test_files_must_be_in_path_order() -> Outcome<()> { |
| 853 | let s = sample(); |
| 854 | let mut m = match res!(s.to_dat()) { |
| 855 | Dat::Map(m) => m, |
| 856 | other => return Err(err!( |
| 857 | "A share encodes as a map, and this is a {:?}.", other.kind(); Test, Bug)), |
| 858 | }; |
| 859 | let mut list = Vec::new(); |
| 860 | for f in s.files.iter().rev() { |
| 861 | list.push(res!(f.to_dat())); |
| 862 | } |
| 863 | m.insert(dat!(KEY_FILES), Dat::List(list)); |
| 864 | match Share::from_dat(&Dat::Map(m)) { |
| 865 | Ok(_) => Err(err!( |
| 866 | "Files out of path order were accepted, so one Diamond has as many addresses as \ |
| 867 | there are orders to list its files in."; Test, Invalid)), |
| 868 | Err(_) => Ok(()), |
| 869 | } |
| 870 | } |
| 871 | |
| 872 | #[test] |
| 873 | fn test_a_duplicate_path_is_refused() -> Outcome<()> { |
| 874 | let s = Share::new( |
| 875 | fmt!("Twice"), |
| 876 | vec![0xA1; limit::KEY_BYTES], |
| 877 | vec![0xB2; limit::NONCE_BYTES], |
| 878 | None, |
| 879 | vec![ |
| 880 | File { path: fmt!("a.json"), body: b"1".to_vec() }, |
| 881 | File { path: fmt!("a.json"), body: b"2".to_vec() }, |
| 882 | ], |
| 883 | ); |
| 884 | match s.encode() { |
| 885 | Ok(_) => Err(err!("Two files at one path were accepted."; Test, Invalid)), |
| 886 | Err(_) => Ok(()), |
| 887 | } |
| 888 | } |
| 889 | |
| 890 | /// The constructor puts the files in order, so a caller cannot mint a second address by |
| 891 | /// listing them differently. |
| 892 | #[test] |
| 893 | fn test_new_orders_the_files() -> Outcome<()> { |
| 894 | let a = Share::new(fmt!("N"), vec![0xA1; 32], vec![0xB2; 16], None, vec![ |
| 895 | File { path: fmt!("b.json"), body: b"2".to_vec() }, |
| 896 | File { path: fmt!("a.json"), body: b"1".to_vec() }, |
| 897 | ]); |
| 898 | let b = Share::new(fmt!("N"), vec![0xA1; 32], vec![0xB2; 16], None, vec![ |
| 899 | File { path: fmt!("a.json"), body: b"1".to_vec() }, |
| 900 | File { path: fmt!("b.json"), body: b"2".to_vec() }, |
| 901 | ]); |
| 902 | assert_eq!(res!(a.encode()), res!(b.encode())); |
| 903 | Ok(()) |
| 904 | } |
| 905 | |
| 906 | /// Each refused path, refused, and each saying which rule it broke. |
| 907 | #[test] |
| 908 | fn test_the_refused_paths() -> Outcome<()> { |
| 909 | for (path, says) in [ |
| 910 | (".daimond/log.jsonl", ".daimond/"), |
| 911 | ("versions/3/crystal.json", "versions/"), |
| 912 | ("capp.json", "capp.json"), |
| 913 | ("triggers.json", "triggers.json"), |
| 914 | ("STATE.md", "STATE.md"), |
| 915 | ] { |
| 916 | match check_path(path) { |
| 917 | Ok(()) => return Err(err!( |
| 918 | "The path \"{}\" was accepted into a share.", path; Test, Invalid)), |
| 919 | Err(e) => { |
| 920 | let msg = fmt!("{}", e); |
| 921 | assert!(msg.contains(says), |
| 922 | "The refusal of \"{}\" does not name what it broke: {}", path, msg); |
| 923 | }, |
| 924 | } |
| 925 | } |
| 926 | // And each is refused only where it means what it says: the delivery record is the one at |
| 927 | // the root, and a folder of the receiver's own making may hold anything. |
| 928 | res!(check_path("recipes/capp.json")); |
| 929 | res!(check_path("notes/versions/old.md")); |
| 930 | res!(check_path("saved/triggers.json")); |
| 931 | res!(check_path("docs/STATE.md")); |
| 932 | Ok(()) |
| 933 | } |
| 934 | |
| 935 | /// A share may not carry armed automation, and it is the FORMAT that says so. |
| 936 | /// |
| 937 | /// REMOVE THE `triggers.json` ENTRY FROM [`REFUSED_EXACT`] AND THIS GOES RED, which is the |
| 938 | /// whole of what it is for: the sending client refuses the file too, and a check that drove |
| 939 | /// only the client would pass on a build whose sender was somebody else's. |
| 940 | /// |
| 941 | /// A trigger fires with nobody pressing anything. It would arm on the receiver's account, be |
| 942 | /// governed by the receiver's pause tree -- where a leaf that appears PLAYS -- and spend the |
| 943 | /// receiver's money, because they accepted a gift. Switching it off in the file is not the |
| 944 | /// answer and the reason is in the constant. |
| 945 | #[test] |
| 946 | fn test_a_share_may_not_carry_a_trigger() -> Outcome<()> { |
| 947 | match check_path("triggers.json") { |
| 948 | Ok(()) => return Err(err!( |
| 949 | "A share accepted \"triggers.json\": automation that fires with nobody pressing \ |
| 950 | anything, on the receiver's account and at the receiver's expense."; Test, Invalid)), |
| 951 | Err(e) => { |
| 952 | let msg = fmt!("{}", e); |
| 953 | // The refusal has to say WHY, because the sender reads it and the only thing they |
| 954 | // can do about it is understand it. |
| 955 | assert!(msg.contains("fires with nobody pressing anything"), |
| 956 | "The refusal does not say what a trigger does: {}", msg); |
| 957 | }, |
| 958 | } |
| 959 | // And it is refused where it is ENCODED, not merely where a path is checked -- so a caller |
| 960 | // that built the payload by hand is refused as well. |
| 961 | let s = Share::new(fmt!("N"), vec![0xA1; 32], vec![0xB2; 16], None, vec![ |
| 962 | File { path: fmt!("triggers.json"), body: b"[{\"on\":true}]".to_vec() }, |
| 963 | ]); |
| 964 | match s.encode() { |
| 965 | Ok(_) => Err(err!( |
| 966 | "A share carrying \"triggers.json\" encoded."; Test, Invalid)), |
| 967 | Err(_) => Ok(()), |
| 968 | } |
| 969 | } |
| 970 | |
| 971 | /// A share may not carry the sender's own machine. |
| 972 | /// |
| 973 | /// REMOVE THE `STATE.md` ENTRY FROM [`REFUSED_EXACT`] AND THIS GOES RED. `STATE.md` holds the |
| 974 | /// folders the sender marked and the command they build with -- paths on their disk, which do |
| 975 | /// not leave their device. The copy rebuilds it on its first turn from the receiver's own. |
| 976 | #[test] |
| 977 | fn test_a_share_may_not_carry_the_senders_machine() -> Outcome<()> { |
| 978 | match check_path("STATE.md") { |
| 979 | Ok(()) => return Err(err!( |
| 980 | "A share accepted \"STATE.md\", which names folders on the sender's own disk."; |
| 981 | Test, Invalid)), |
| 982 | Err(e) => { |
| 983 | let msg = fmt!("{}", e); |
| 984 | assert!(msg.contains("SENDER's own machine"), |
| 985 | "The refusal does not say whose machine it names: {}", msg); |
| 986 | }, |
| 987 | } |
| 988 | let s = Share::new(fmt!("N"), vec![0xA1; 32], vec![0xB2; 16], None, vec![ |
| 989 | File { path: fmt!("STATE.md"), body: b"Marked: /home/somebody/work\n".to_vec() }, |
| 990 | ]); |
| 991 | match s.encode() { |
| 992 | Ok(_) => Err(err!("A share carrying \"STATE.md\" encoded."; Test, Invalid)), |
| 993 | Err(_) => Ok(()), |
| 994 | } |
| 995 | } |
| 996 | |
| 997 | #[test] |
| 998 | fn test_a_walking_path_is_refused() -> Outcome<()> { |
| 999 | for path in ["../secrets.json", "a/../../b.json", "a/./b.json", "/etc/passwd", |
| 1000 | "a//b.json", "a\\b.json", "data:text/plain,x", "c:/notes.md"] |
| 1001 | { |
| 1002 | if check_path(path).is_ok() { |
| 1003 | return Err(err!( |
| 1004 | "The path \"{}\" was accepted into a share.", path; Test, Invalid)); |
| 1005 | } |
| 1006 | } |
| 1007 | Ok(()) |
| 1008 | } |
| 1009 | |
| 1010 | /// A path is refused rather than tidied, which is what makes one file one address. |
| 1011 | #[test] |
| 1012 | fn test_a_path_is_not_normalised() -> Outcome<()> { |
| 1013 | // `a/./b.json` and `a/b.json` would be the same file after tidying and are different bytes, |
| 1014 | // so accepting the first would give one Diamond two addresses. |
| 1015 | assert!(check_path("a/./b.json").is_err()); |
| 1016 | res!(check_path("a/b.json")); |
| 1017 | Ok(()) |
| 1018 | } |
| 1019 | |
| 1020 | #[test] |
| 1021 | fn test_an_empty_share_is_refused() -> Outcome<()> { |
| 1022 | let mut m = match res!(sample().to_dat()) { |
| 1023 | Dat::Map(m) => m, |
| 1024 | other => return Err(err!( |
| 1025 | "A share encodes as a map, and this is a {:?}.", other.kind(); Test, Bug)), |
| 1026 | }; |
| 1027 | m.insert(dat!(KEY_FILES), Dat::List(Vec::new())); |
| 1028 | match Share::from_dat(&Dat::Map(m)) { |
| 1029 | Ok(_) => Err(err!("A share of no files was accepted."; Test, Invalid)), |
| 1030 | Err(_) => Ok(()), |
| 1031 | } |
| 1032 | } |
| 1033 | |
| 1034 | #[test] |
| 1035 | fn test_an_empty_note_is_refused() -> Outcome<()> { |
| 1036 | let mut s = sample(); |
| 1037 | s.note = Some(String::new()); |
| 1038 | match s.encode() { |
| 1039 | Ok(_) => Err(err!( |
| 1040 | "An empty note was accepted, so a share with nothing to say has two encodings."; |
| 1041 | Test, Invalid)), |
| 1042 | Err(_) => Ok(()), |
| 1043 | } |
| 1044 | } |
| 1045 | |
| 1046 | /// An absent note is omitted, and the two shapes are different bytes. |
| 1047 | #[test] |
| 1048 | fn test_the_note_is_omitted_not_none() -> Outcome<()> { |
| 1049 | let bare = res!(sample().encode()); |
| 1050 | let mut s = sample(); |
| 1051 | s.note = Some(fmt!("Here you are.")); |
| 1052 | assert_ne!(res!(s.encode()), bare); |
| 1053 | assert_eq!(res!(Share::decode(&bare)).note, None); |
| 1054 | Ok(()) |
| 1055 | } |
| 1056 | |
| 1057 | #[test] |
| 1058 | fn test_trailing_bytes_refused() -> Outcome<()> { |
| 1059 | let mut bytes = res!(sample().encode()); |
| 1060 | bytes.push(0x00); |
| 1061 | match Share::decode(&bytes) { |
| 1062 | Ok(_) => Err(err!("A payload with a trailing byte was accepted."; Test, Invalid)), |
| 1063 | Err(_) => Ok(()), |
| 1064 | } |
| 1065 | } |
| 1066 | |
| 1067 | #[test] |
| 1068 | fn test_ordmap_refused() -> Outcome<()> { |
| 1069 | let ord = oxedyne_fe2o3_jdat::map::create_dat_ordmap(vec![ |
| 1070 | (dat!(KEY_CODE), Dat::Bool(false)), |
| 1071 | (dat!(KEY_NAME), Dat::Str(fmt!("N"))), |
| 1072 | ]); |
| 1073 | match Share::from_dat(&ord) { |
| 1074 | Ok(_) => Err(err!("An OrdMap payload was accepted."; Test, Invalid)), |
| 1075 | Err(_) => Ok(()), |
| 1076 | } |
| 1077 | } |
| 1078 | |
| 1079 | #[test] |
| 1080 | fn test_unknown_key_refused() -> Outcome<()> { |
| 1081 | let mut m = match res!(sample().to_dat()) { |
| 1082 | Dat::Map(m) => m, |
| 1083 | other => return Err(err!( |
| 1084 | "A share encodes as a map, and this is a {:?}.", other.kind(); Test, Bug)), |
| 1085 | }; |
| 1086 | m.insert(dat!("from"), Dat::Str(fmt!("somebody else"))); |
| 1087 | match Share::from_dat(&Dat::Map(m)) { |
| 1088 | Ok(_) => Err(err!("A share carrying a `from` field was accepted."; Test, Invalid)), |
| 1089 | Err(_) => Ok(()), |
| 1090 | } |
| 1091 | } |
| 1092 | |
| 1093 | /// The code bit is required, not optional-and-false-by-default. |
| 1094 | #[test] |
| 1095 | fn test_a_missing_code_bit_is_refused() -> Outcome<()> { |
| 1096 | let mut m = match res!(sample().to_dat()) { |
| 1097 | Dat::Map(m) => m, |
| 1098 | other => return Err(err!( |
| 1099 | "A share encodes as a map, and this is a {:?}.", other.kind(); Test, Bug)), |
| 1100 | }; |
| 1101 | m.remove(&dat!(KEY_CODE)); |
| 1102 | match Share::from_dat(&Dat::Map(m)) { |
| 1103 | Ok(_) => Err(err!( |
| 1104 | "A share with no code claim was read, so 'they said no' and 'they did not say' \ |
| 1105 | are the same artefact."; Test, Invalid)), |
| 1106 | Err(_) => Ok(()), |
| 1107 | } |
| 1108 | } |
| 1109 | |
| 1110 | #[test] |
| 1111 | fn test_nonce_and_key_widths_are_exact() -> Outcome<()> { |
| 1112 | let mut s = sample(); |
| 1113 | s.nonce = vec![0xB2; limit::NONCE_BYTES - 1]; |
| 1114 | assert!(s.encode().is_err(), "A short nonce was accepted."); |
| 1115 | let mut s = sample(); |
| 1116 | s.to = vec![0xA1; limit::KEY_BYTES + 1]; |
| 1117 | assert!(s.encode().is_err(), "An overlong recipient key was accepted."); |
| 1118 | Ok(()) |
| 1119 | } |
| 1120 | |
| 1121 | /// Two identical shares to one recipient are two addresses, because the nonce is signed. |
| 1122 | #[test] |
| 1123 | fn test_the_nonce_separates_identical_shares() -> Outcome<()> { |
| 1124 | let a = sample(); |
| 1125 | let mut b = sample(); |
| 1126 | b.nonce = vec![0xB3; limit::NONCE_BYTES]; |
| 1127 | assert_eq!(a.files, b.files); |
| 1128 | assert_ne!(res!(a.encode()), res!(b.encode())); |
| 1129 | Ok(()) |
| 1130 | } |
| 1131 | |
| 1132 | #[test] |
| 1133 | fn test_too_many_files_refused() -> Outcome<()> { |
| 1134 | let mut files = Vec::new(); |
| 1135 | for i in 0..(limit::FILES + 1) { |
| 1136 | files.push(File { path: fmt!("f{:04}.json", i), body: b"{}".to_vec() }); |
| 1137 | } |
| 1138 | let s = Share::new(fmt!("Many"), vec![0xA1; 32], vec![0xB2; 16], None, files); |
| 1139 | match s.encode() { |
| 1140 | Ok(_) => Err(err!("More files than the limit were accepted."; Test, Invalid)), |
| 1141 | Err(_) => Ok(()), |
| 1142 | } |
| 1143 | } |
| 1144 | |
| 1145 | /// The ceiling is a boundary and not a scare: exactly the limit is accepted. |
| 1146 | #[test] |
| 1147 | fn test_files_at_the_limit_accepted() -> Outcome<()> { |
| 1148 | let mut files = Vec::new(); |
| 1149 | for i in 0..limit::FILES { |
| 1150 | files.push(File { path: fmt!("f{:04}.json", i), body: b"{}".to_vec() }); |
| 1151 | } |
| 1152 | let s = Share::new(fmt!("Many"), vec![0xA1; 32], vec![0xB2; 16], None, files); |
| 1153 | let bytes = res!(s.encode()); |
| 1154 | assert_eq!(res!(Share::decode(&bytes)).files.len(), limit::FILES); |
| 1155 | Ok(()) |
| 1156 | } |
| 1157 | |
| 1158 | #[test] |
| 1159 | fn test_total_bytes_over_the_limit_refused() -> Outcome<()> { |
| 1160 | let s = Share::new(fmt!("Heavy"), vec![0xA1; 32], vec![0xB2; 16], None, vec![ |
| 1161 | File { path: fmt!("a.bin"), body: vec![0u8; limit::TOTAL_BYTES / 2] }, |
| 1162 | File { path: fmt!("b.bin"), body: vec![0u8; limit::TOTAL_BYTES / 2 + 1] }, |
| 1163 | ]); |
| 1164 | match s.encode() { |
| 1165 | Ok(_) => Err(err!("A share over the byte ceiling was accepted."; Test, Invalid)), |
| 1166 | Err(_) => Ok(()), |
| 1167 | } |
| 1168 | } |
| 1169 | |
| 1170 | /// A file body is BYTES and is held to no text rule: a Diamond holds pictures too. |
| 1171 | #[test] |
| 1172 | fn test_a_body_may_be_arbitrary_bytes() -> Outcome<()> { |
| 1173 | let s = Share::new(fmt!("Picture"), vec![0xA1; 32], vec![0xB2; 16], None, vec![ |
| 1174 | File { path: fmt!("shot.png"), body: vec![0x89, 0x50, 0x4E, 0x47, 0x00, 0xFF] }, |
| 1175 | ]); |
| 1176 | let bytes = res!(s.encode()); |
| 1177 | assert_eq!(res!(Share::decode(&bytes)).files[0].body, s.files[0].body); |
| 1178 | Ok(()) |
| 1179 | } |
| 1180 | |
| 1181 | /// A path, unlike a body, is text and is held to §3 rule 5. |
| 1182 | #[test] |
| 1183 | fn test_a_path_must_be_nfc() -> Outcome<()> { |
| 1184 | assert!(check_path("cafe\u{0301}/notes.md").is_err(), |
| 1185 | "A path with a combining accent was accepted, so one file has two spellings."); |
| 1186 | res!(check_path("caf\u{e9}/notes.md")); |
| 1187 | Ok(()) |
| 1188 | } |
| 1189 | } |