oxedyne/fe2o3/fe2o3_sbj/examples/gen_fixtures.rs
67.9 KiB, 57 runs
created by r1870400018:21944, 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 | //! Writes the conformance fixtures of `SPEC.md` §7. |
| 2 | //! |
| 3 | //! Run it with `cargo run -p sbj --example gen_fixtures`. Every artefact under `fixtures/` comes |
| 4 | //! from here, so that when the format changes the fixtures are rebuilt rather than patched, and no |
| 5 | //! byte of them is a byte nobody can reproduce. |
| 6 | //! |
| 7 | //! Two things this deliberately does not do. It does not sign with a fresh key, since a fixture that |
| 8 | //! changes on every run is not a fixture: the key is committed at `fixtures/key.jdat`, and is |
| 9 | //! generated once, the first time this runs against an empty directory. And it does not ask the |
| 10 | //! implementation what it thinks of a bad file: every `reject.jdat` is written from `SPEC.md`, by |
| 11 | //! hand, so that the suite tests the code against the specification rather than against itself. |
| 12 | |
| 13 | #[path = "../tests/common/mod.rs"] |
| 14 | mod common; |
| 15 | |
| 16 | use common::{ |
| 17 | tree, |
| 18 | Keys, |
| 19 | Meta, |
| 20 | Reject, |
| 21 | Stage, |
| 22 | ALIEN_CODE, |
| 23 | DOC_JDAT, |
| 24 | DOC_SBJ, |
| 25 | KEY_FILE, |
| 26 | META_JDAT, |
| 27 | README_FILE, |
| 28 | REJECT_JDAT, |
| 29 | TIME, |
| 30 | }; |
| 31 | |
| 32 | use oxedyne_fe2o3_sbj::{ |
| 33 | canon, |
| 34 | card::{ |
| 35 | self, |
| 36 | Card, |
| 37 | Role, |
| 38 | }, |
| 39 | doc::{ |
| 40 | self, |
| 41 | Payload, |
| 42 | }, |
| 43 | envelope, |
| 44 | kinds::{ |
| 45 | NodeKind, |
| 46 | ReservedKind, |
| 47 | }, |
| 48 | post::{ |
| 49 | self, |
| 50 | Post, |
| 51 | Reference, |
| 52 | Target, |
| 53 | }, |
| 54 | share::{ |
| 55 | self, |
| 56 | Share, |
| 57 | }, |
| 58 | validate, |
| 59 | SCHEMA_CARD, |
| 60 | SCHEMA_DOC, |
| 61 | SCHEMA_POST, |
| 62 | SCHEMA_SHARE, |
| 63 | }; |
| 64 | |
| 65 | use oxedyne_fe2o3_core::prelude::*; |
| 66 | use oxedyne_fe2o3_crypto::sign::SignatureScheme; |
| 67 | use oxedyne_fe2o3_jdat::prelude::*; |
| 68 | |
| 69 | use std::{ |
| 70 | fs, |
| 71 | path::{ |
| 72 | Path, |
| 73 | PathBuf, |
| 74 | }, |
| 75 | }; |
| 76 | |
| 77 | /// A schema no validator in this build reads, for the fixture that declares one. |
| 78 | const SCHEMA_FOREIGN: &'static str = "oxeweb/cmd/0"; |
| 79 | |
| 80 | fn main() { |
| 81 | // The deepest legal document nests daticles 770 deep, and encoding one costs a stack frame at |
| 82 | // every level, so the work runs on a thread with a stack that can hold it. |
| 83 | let thread = match std::thread::Builder::new() |
| 84 | .name("gen_fixtures".to_string()) |
| 85 | .stack_size(common::STACK_BYTES) |
| 86 | .spawn(generate) |
| 87 | { |
| 88 | Ok(thread) => thread, |
| 89 | Err(e) => { |
| 90 | println!("Could not spawn the generator thread: {}", e); |
| 91 | std::process::exit(1); |
| 92 | }, |
| 93 | }; |
| 94 | match thread.join() { |
| 95 | Ok(Ok(n)) => println!("Wrote {} fixtures.", n), |
| 96 | Ok(Err(e)) => { |
| 97 | println!("{}", e); |
| 98 | std::process::exit(1); |
| 99 | }, |
| 100 | Err(_) => { |
| 101 | println!("The generator thread did not return."); |
| 102 | std::process::exit(1); |
| 103 | }, |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | /// Writes every fixture, and returns how many were written. |
| 108 | fn generate() -> Outcome<usize> { |
| 109 | |
| 110 | let root = common::fixtures_dir(); |
| 111 | res!(fs::create_dir_all(&root), IO, File); |
| 112 | |
| 113 | // The key is generated once and committed. A fixture signed by a key that changed would carry a |
| 114 | // different signature every run, and a suite that had to be regenerated to pass would test |
| 115 | // nothing. |
| 116 | let keys = if root.join(KEY_FILE).exists() { |
| 117 | res!(Keys::load(&root)) |
| 118 | } else { |
| 119 | let keys = res!(Keys::generate()); |
| 120 | res!(keys.save(&root)); |
| 121 | keys |
| 122 | }; |
| 123 | |
| 124 | res!(common::write_bytes(&root.join(README_FILE), readme().as_bytes())); |
| 125 | |
| 126 | let n = res!(acceptance(&root, &keys)) + res!(rejection(&root, &keys)) |
| 127 | + res!(payloads(&root, &keys)) + res!(shares(&root, &keys)); |
| 128 | |
| 129 | // A fixture directory nothing above wrote is a fixture nobody can regenerate, and the suite will |
| 130 | // refuse to skip it, so it is caught here rather than there. |
| 131 | let mut dirs = 0; |
| 132 | for entry in res!(fs::read_dir(&root), IO, File) { |
| 133 | if res!(entry, IO, File).path().is_dir() { |
| 134 | dirs += 1; |
| 135 | } |
| 136 | } |
| 137 | if dirs != n { |
| 138 | return Err(err!( |
| 139 | "{} fixtures were written, and {} directories are in {}. A directory nothing here wrote \ |
| 140 | is stale: delete it, or write the fixture that belongs in it.", n, dirs, root.display(); |
| 141 | Invalid, Mismatch)); |
| 142 | } |
| 143 | Ok(n) |
| 144 | } |
| 145 | |
| 146 | /// The fixtures a reader must accept. |
| 147 | fn acceptance( |
| 148 | root: &Path, |
| 149 | keys: &Keys, |
| 150 | ) |
| 151 | -> Outcome<usize> |
| 152 | { |
| 153 | let author = res!(keys.author.signer()); |
| 154 | |
| 155 | res!(accept(root, &author, "empty", &tree::empty(), false, |
| 156 | "A document with no content at all: the smallest thing that is still a document.")); |
| 157 | |
| 158 | res!(accept(root, &author, "one_para", &tree::one_para(), false, |
| 159 | "One paragraph of one text run.")); |
| 160 | |
| 161 | res!(accept(root, &author, "every_kind", &tree::every_kind(), false, |
| 162 | "Every v0 node kind once, including code and quote, a link by the typed address form and an \ |
| 163 | image by content hash, an optional field present, another absent, and a node carrying no \ |
| 164 | children.")); |
| 165 | |
| 166 | res!(accept(root, &author, "styled", &tree::styled(), false, |
| 167 | "A document exercising the style table of §4.4: an inherited property (size) and a self-only \ |
| 168 | property (bg), a box naming one style and a paragraph within it naming another, each \ |
| 169 | resolving to a table entry.")); |
| 170 | |
| 171 | res!(accept(root, &author, "align_is_local", &tree::align_is_local(), false, |
| 172 | "A box naming `align: justify`, holding a paragraph that names nothing. `align` is self-only \ |
| 173 | (§4.4), so it aligns the lines of the node that named it, and the box has none: the \ |
| 174 | paragraph inside must be set exactly as it would be with no style at all. The property \ |
| 175 | inherits in CSS and does not in the format, so a reader built on a browser will justify the \ |
| 176 | paragraph unless it is stopped, and then one document says two things.")); |
| 177 | |
| 178 | res!(accept(root, &author, "link_by_name", &tree::link_by_name(), false, |
| 179 | "A link whose typed address (§4.3) is a NAMES name.")); |
| 180 | |
| 181 | res!(accept(root, &author, "link_by_hash", &tree::link_by_hash(), false, |
| 182 | "A link whose typed address (§4.3) is a b32 content hash.")); |
| 183 | |
| 184 | res!(accept(root, &author, "unknown_kind_fallback", &tree::unknown_fallback(), false, |
| 185 | "An unknown node kind carrying a non-empty fallback of known nodes (§4.5), which a reader \ |
| 186 | that does not implement the kind renders and validates in its place, so the document is \ |
| 187 | accepted. The unknown node also carries an uninterpreted field, held only to §3.")); |
| 188 | |
| 189 | res!(accept(root, &author, "indexed", &tree::one_para(), true, |
| 190 | "The document of the one_para fixture, written with the optional index of §1.4 appended. \ |
| 191 | The index lies outside the hash, so this is the same document at the same address, and its \ |
| 192 | hash is the hash of one_para.")); |
| 193 | |
| 194 | res!(accept(root, &author, "depth_at_limit", &res!(tree::chain(common::depth_limit())), false, |
| 195 | "Nesting at the depth limit of §5: a doc at the head of a chain of boxes, 256 nodes deep.")); |
| 196 | |
| 197 | res!(accept(root, &author, "size_at_limit", &res!(tree::sized(common::tree_limit())), false, |
| 198 | "A tree region of exactly the size limit of §5, to the byte.")); |
| 199 | |
| 200 | Ok(11) |
| 201 | } |
| 202 | |
| 203 | /// Writes an acceptance fixture: the tree, the artefact, and what the artefact must turn out to be. |
| 204 | fn accept( |
| 205 | root: &Path, |
| 206 | author: &SignatureScheme, |
| 207 | name: &str, |
| 208 | tree: &Dat, |
| 209 | index: bool, |
| 210 | note: &str, |
| 211 | ) |
| 212 | -> Outcome<()> |
| 213 | { |
| 214 | let dir = res!(fresh_dir(root, name)); |
| 215 | let buf = if index { |
| 216 | res!(doc::write_with_index(tree, SCHEMA_DOC, author, TIME)) |
| 217 | } else { |
| 218 | res!(doc::write(tree, SCHEMA_DOC, author, TIME)) |
| 219 | }; |
| 220 | let env = res!(doc::verify_only(&buf)); |
| 221 | let stats = res!(validate::validate(tree, SCHEMA_DOC)); |
| 222 | let meta = Meta { |
| 223 | schema: env.schema.clone(), |
| 224 | time: env.time, |
| 225 | hash: env.hash.clone(), |
| 226 | tree_len: env.tree_len, |
| 227 | nodes: Some(try_into!(u64, stats.nodes)), |
| 228 | depth: Some(try_into!(u64, stats.depth)), |
| 229 | index, |
| 230 | note: note.to_string(), |
| 231 | }; |
| 232 | res!(common::write_bytes(&dir.join(DOC_JDAT), res!(common::to_jdat(tree)).as_bytes())); |
| 233 | res!(common::write_bytes(&dir.join(DOC_SBJ), &buf)); |
| 234 | res!(common::write_bytes( |
| 235 | &dir.join(META_JDAT), |
| 236 | res!(common::to_jdat_plain(&meta.to_dat())).as_bytes(), |
| 237 | )); |
| 238 | Ok(()) |
| 239 | } |
| 240 | |
| 241 | /// The fixtures a reader must refuse, and the reason each must be refused for. |
| 242 | fn rejection( |
| 243 | root: &Path, |
| 244 | keys: &Keys, |
| 245 | ) |
| 246 | -> Outcome<usize> |
| 247 | { |
| 248 | let author = res!(keys.author.signer()); |
| 249 | let impostor = res!(keys.impostor.signer()); |
| 250 | |
| 251 | // A good document, and the file that carries it. Every fixture below is this file with one |
| 252 | // thing wrong with it, or a file assembled around one tree that is wrong in one way. |
| 253 | let good = res!(canon::encode(&tree::every_kind())); |
| 254 | let file = res!(common::assemble(&res!(common::seal(&good, SCHEMA_DOC, &author, TIME)), &good)); |
| 255 | |
| 256 | // -- Step 1: the header. ------------------------------------------------------------------ |
| 257 | |
| 258 | // A file that is perfectly good BDAT and is not an SBJ file at all: the encoded tree of a real |
| 259 | // document, with nothing in front of it. |
| 260 | res!(reject(root, "bdat_not_sbj", &good, None, Reject { |
| 261 | stage: Stage::Header, |
| 262 | rule: "SPEC.md §1.1: a reader that does not recognise the magic stops.".to_string(), |
| 263 | says: "Not an SBJ file".to_string(), |
| 264 | node: None, |
| 265 | offset: None, |
| 266 | note: "Valid BDAT: the encoded tree of a real document, with no SBJ header in front of \ |
| 267 | it. Nothing about a bare daticle says which format it belongs to, so the magic is \ |
| 268 | what says so, and its absence is a rejection rather than a guess.".to_string(), |
| 269 | })); |
| 270 | |
| 271 | let mut bad = file.clone(); |
| 272 | bad[1] ^= 0xFF; |
| 273 | res!(reject(root, "bad_magic", &bad, None, Reject { |
| 274 | stage: Stage::Header, |
| 275 | rule: "SPEC.md §1.1: the magic is 'SBJ\\0'.".to_string(), |
| 276 | says: "Not an SBJ file".to_string(), |
| 277 | node: None, |
| 278 | offset: None, |
| 279 | note: "Byte 1 of the magic is flipped. Everything after it is a valid document." |
| 280 | .to_string(), |
| 281 | })); |
| 282 | |
| 283 | let mut bad = file.clone(); |
| 284 | bad[5] = 1; |
| 285 | res!(reject(root, "bad_version", &bad, None, Reject { |
| 286 | stage: Stage::Header, |
| 287 | rule: "SPEC.md §1.1: a reader that reads a major version it does not implement stops." |
| 288 | .to_string(), |
| 289 | says: "not implemented here".to_string(), |
| 290 | node: None, |
| 291 | offset: None, |
| 292 | note: "The major version is 1, and this reads version 0. It does not guess.".to_string(), |
| 293 | })); |
| 294 | |
| 295 | // -- Step 2: the envelope. ---------------------------------------------------------------- |
| 296 | |
| 297 | let env = res!(common::seal(&good, SCHEMA_DOC, &author, TIME)); |
| 298 | let mut map = match res!(env.to_dat()) { |
| 299 | Dat::Map(map) => map, |
| 300 | d => return Err(err!("An envelope is a map, found a {:?}.", d.kind(); Bug, Invalid)), |
| 301 | }; |
| 302 | map.remove(&dat!(envelope::KEY_TIME)); |
| 303 | let bad = res!(common::assemble_raw(&Dat::Map(map), &good)); |
| 304 | res!(reject(root, "envelope_missing_key", &bad, Some(&tree::every_kind()), Reject { |
| 305 | stage: Stage::Envelope, |
| 306 | rule: "SPEC.md §1.2: the envelope carries exactly these keys, all required.".to_string(), |
| 307 | says: "missing the required key \"time\"".to_string(), |
| 308 | node: None, |
| 309 | offset: None, |
| 310 | note: "The 'time' key is gone from the envelope map. The tree behind it is a good \ |
| 311 | document, and is never reached.".to_string(), |
| 312 | })); |
| 313 | |
| 314 | // -- Step 3: the tree region. ------------------------------------------------------------- |
| 315 | |
| 316 | let bad = file[..file.len() - 1].to_vec(); |
| 317 | res!(reject(root, "truncated_tree", &bad, None, Reject { |
| 318 | stage: Stage::Region, |
| 319 | rule: "SPEC.md §2 step 3: a tree region shorter than declared is a rejection, not a \ |
| 320 | truncation.".to_string(), |
| 321 | says: "shorter than declared".to_string(), |
| 322 | node: None, |
| 323 | offset: None, |
| 324 | note: "The last byte of the tree region is gone. The envelope still declares the length \ |
| 325 | the region had, and the reader believes neither the bytes nor the envelope: it \ |
| 326 | refuses the file.".to_string(), |
| 327 | })); |
| 328 | |
| 329 | let over = res!(tree::sized(common::tree_limit() + 1)); |
| 330 | let over_bytes = res!(canon::encode(&over)); |
| 331 | let bad = res!(common::assemble( |
| 332 | &res!(common::seal(&over_bytes, SCHEMA_DOC, &author, TIME)), |
| 333 | &over_bytes, |
| 334 | )); |
| 335 | res!(reject(root, "size_over_limit", &bad, None, Reject { |
| 336 | stage: Stage::Region, |
| 337 | rule: "SPEC.md §5: the tree region size limit is 4 MiB, enforced before decoding." |
| 338 | .to_string(), |
| 339 | says: "exceeding the limit".to_string(), |
| 340 | node: None, |
| 341 | offset: None, |
| 342 | note: "A tree region of 4 MiB and one byte, correctly hashed and correctly signed. The \ |
| 343 | limit is enforced on the envelope's word alone, before a byte of the region is \ |
| 344 | hashed, let alone decoded.".to_string(), |
| 345 | })); |
| 346 | |
| 347 | // -- Step 4: the hash. -------------------------------------------------------------------- |
| 348 | |
| 349 | let start = res!(common::tree_start(&file)); |
| 350 | let mut bad = file.clone(); |
| 351 | bad[start + 3] ^= 0x01; |
| 352 | res!(reject(root, "corrupt_tree_byte", &bad, None, Reject { |
| 353 | stage: Stage::Hash, |
| 354 | rule: "SPEC.md §2 step 4: the tree region hashes to what the envelope declares." |
| 355 | .to_string(), |
| 356 | says: "hashes to".to_string(), |
| 357 | node: None, |
| 358 | offset: None, |
| 359 | note: "One bit of one byte of the tree region is flipped. The hash is the document's \ |
| 360 | address, so a tree that does not hash to it is not this document.".to_string(), |
| 361 | })); |
| 362 | |
| 363 | // The author signs the corrupted hash, so the signature is sound and the hash alone is wrong. |
| 364 | let mut env = res!(common::seal(&good, SCHEMA_DOC, &author, TIME)); |
| 365 | env.hash[0] ^= 0x01; |
| 366 | res!(common::resign(&mut env, &author)); |
| 367 | let bad = res!(common::assemble(&env, &good)); |
| 368 | res!(reject(root, "bad_hash", &bad, Some(&tree::every_kind()), Reject { |
| 369 | stage: Stage::Hash, |
| 370 | rule: "SPEC.md §2 step 4: a hash that is not the hash of the tree region is a rejection." |
| 371 | .to_string(), |
| 372 | says: "hashes to".to_string(), |
| 373 | node: None, |
| 374 | offset: None, |
| 375 | note: "The hash in the envelope is corrupted, and the author has signed the corrupted \ |
| 376 | hash, so the signature is sound and only the hash is wrong. The reader hashes the \ |
| 377 | region itself rather than taking the envelope's word for it.".to_string(), |
| 378 | })); |
| 379 | |
| 380 | // -- Step 5: the signature. --------------------------------------------------------------- |
| 381 | |
| 382 | let mut env = res!(common::seal(&good, SCHEMA_DOC, &author, TIME)); |
| 383 | env.sig[0] ^= 0x01; |
| 384 | let bad = res!(common::assemble(&env, &good)); |
| 385 | res!(reject(root, "bad_sig", &bad, Some(&tree::every_kind()), Reject { |
| 386 | stage: Stage::Sig, |
| 387 | rule: "SPEC.md §2 step 5: the signature is verified over the signing input, under the \ |
| 388 | author's key.".to_string(), |
| 389 | says: "not a signature by the author".to_string(), |
| 390 | node: None, |
| 391 | offset: None, |
| 392 | note: "One bit of the signature is flipped. The hash is right, so the document is at the \ |
| 393 | address it claims to be at; nobody has vouched for it.".to_string(), |
| 394 | })); |
| 395 | |
| 396 | // A perfectly good signature, made by a key that is not the author the envelope names. |
| 397 | let mut env = res!(common::seal(&good, SCHEMA_DOC, &impostor, TIME)); |
| 398 | env.author = keys.author.pk.clone(); |
| 399 | let bad = res!(common::assemble(&env, &good)); |
| 400 | res!(reject(root, "wrong_key", &bad, Some(&tree::every_kind()), Reject { |
| 401 | stage: Stage::Sig, |
| 402 | rule: "SPEC.md §2 step 5: the signature must be the author's.".to_string(), |
| 403 | says: "not a signature by the author".to_string(), |
| 404 | node: None, |
| 405 | offset: None, |
| 406 | note: "A sound signature over the right signing input, made by a key that is not the \ |
| 407 | author the envelope names. A signature nobody checks against a key is not a \ |
| 408 | signature.".to_string(), |
| 409 | })); |
| 410 | |
| 411 | // -- Step 6: decoding, and the canonical encoding rules of §3. ---------------------------- |
| 412 | |
| 413 | // The tree region is one byte short of the tree it holds, and the author has hashed and signed |
| 414 | // the short region, so every step that touches no content passes. The tree is cut off. |
| 415 | let short = &good[..good.len() - 1]; |
| 416 | let mut env = res!(common::seal(short, SCHEMA_DOC, &author, TIME)); |
| 417 | env.tree_len = try_into!(u64, short.len()); |
| 418 | res!(common::resign(&mut env, &author)); |
| 419 | let bad = res!(common::assemble(&env, short)); |
| 420 | res!(reject(root, "tree_longer_than_tree_len", &bad, None, Reject { |
| 421 | stage: Stage::Decode, |
| 422 | rule: "SPEC.md §2: a tree region that does not hold a whole tree is a rejection." |
| 423 | .to_string(), |
| 424 | says: SAYS_TRUNCATED.to_string(), |
| 425 | node: None, |
| 426 | offset: None, |
| 427 | note: "'tree_len' understates the tree by one byte, and the author has hashed and signed \ |
| 428 | the region it declares, so the file verifies. The declared region holds a tree cut \ |
| 429 | off one byte from its end, and the decoder says so.".to_string(), |
| 430 | })); |
| 431 | |
| 432 | let mut region = good.clone(); |
| 433 | region.push(0x00); |
| 434 | let bad = res!(common::assemble( |
| 435 | &res!(common::seal(®ion, SCHEMA_DOC, &author, TIME)), |
| 436 | ®ion, |
| 437 | )); |
| 438 | res!(reject(root, "bytes_trailing_the_tree", &bad, None, Reject { |
| 439 | stage: Stage::Decode, |
| 440 | rule: "SPEC.md §3: a document encodes to exactly one byte string.".to_string(), |
| 441 | says: "canonical".to_string(), |
| 442 | node: None, |
| 443 | offset: None, |
| 444 | note: "The tree region carries the tree and one byte more, all of it hashed and signed. \ |
| 445 | A byte the tree does not need gives the document a second address, so it is no \ |
| 446 | part of a canonical encoding.".to_string(), |
| 447 | })); |
| 448 | |
| 449 | res!(canon_reject(root, &author, "canon_rule1_undeclared_field", Some(1), "rule 1", |
| 450 | "SPEC.md §3 rule 1: field types are fixed by the schema.", |
| 451 | "The heading carries a field the schema does not declare. A reader that ignored it would \ |
| 452 | accept two byte strings for one document.", |
| 453 | tree::doc_with_heading(common::map(vec![ |
| 454 | ("colour", Dat::Str("red".to_string())), |
| 455 | ("level", Dat::U8(2)), |
| 456 | ("children", Dat::List(vec![common::text("A heading")])), |
| 457 | ])), |
| 458 | )); |
| 459 | |
| 460 | res!(canon_reject(root, &author, "canon_rule2_ordmap", Some(1), "rule 2", |
| 461 | "SPEC.md §3 rule 2: maps are Dat::Map, never Dat::OrdMap.", |
| 462 | "The heading's payload is an OrdMap, whose order follows the author's typing rather than \ |
| 463 | its keys, so the same heading typed in another order is a different byte string.", |
| 464 | tree::doc_with_heading(create_dat_ordmap(vec![ |
| 465 | (Dat::Str("level".to_string()), Dat::U8(2)), |
| 466 | (Dat::Str("children".to_string()), Dat::List(vec![common::text("A heading")])), |
| 467 | ])), |
| 468 | )); |
| 469 | |
| 470 | res!(canon_reject(root, &author, "canon_rule3_uppercase_key", Some(1), "rule 3", |
| 471 | "SPEC.md §3 rule 3: map keys are strings, lowercase ASCII.", |
| 472 | "The heading's level is spelled 'Level'. A key that may be spelled two ways is a document \ |
| 473 | with two addresses.", |
| 474 | tree::doc_with_heading(common::map(vec![ |
| 475 | ("Level", Dat::U8(2)), |
| 476 | ("children", Dat::List(vec![common::text("A heading")])), |
| 477 | ])), |
| 478 | )); |
| 479 | |
| 480 | // A duplicate key cannot be built as a tree at all: BDAT decodes a map into a BTreeMap, which |
| 481 | // collapses the duplicate, so the tree that comes out is perfectly canonical. It survives only |
| 482 | // in the bytes, so the bytes are built by hand here, and the first byte at which they differ |
| 483 | // from the canonical encoding of what they decode to is computed here too, rather than read out |
| 484 | // of the error the implementation happens to give. |
| 485 | let dup = res!(duplicate_key_bytes()); |
| 486 | let (decoded, n) = res!(Dat::from_bytes(&dup)); |
| 487 | if n != dup.len() { |
| 488 | return Err(err!( |
| 489 | "The hand-built bytes of the duplicate key fixture do not decode whole."; Bug, Invalid)); |
| 490 | } |
| 491 | let canonical = res!(canon::encode(&decoded)); |
| 492 | let at = match common::first_diff(&canonical, &dup) { |
| 493 | Some(at) => at, |
| 494 | None => return Err(err!("The duplicate key did not change the bytes."; Bug, Invalid)), |
| 495 | }; |
| 496 | let bad = res!(common::assemble( |
| 497 | &res!(common::seal(&dup, SCHEMA_DOC, &author, TIME)), |
| 498 | &dup, |
| 499 | )); |
| 500 | res!(reject(root, "canon_rule3_duplicate_key", &bad, None, Reject { |
| 501 | stage: Stage::Decode, |
| 502 | rule: "SPEC.md §3 rule 3: no map key may appear twice.".to_string(), |
| 503 | says: "rule 3".to_string(), |
| 504 | node: None, |
| 505 | offset: Some(try_into!(u64, at)), |
| 506 | note: "The doc's payload map carries the key 'lang' twice. The tree it decodes to is \ |
| 507 | perfectly canonical, since a BTreeMap cannot hold a duplicate, so the duplicate \ |
| 508 | survives only in the bytes: one tree, two byte strings, two addresses. The offset \ |
| 509 | is the first byte at which the bytes differ from the canonical encoding of the \ |
| 510 | tree they decode to.".to_string(), |
| 511 | })); |
| 512 | |
| 513 | res!(canon_reject(root, &author, "canon_rule4_empty_children", Some(1), "rule 4", |
| 514 | "SPEC.md §3 rule 4: no redundant wrappers, and a node with no children omits the key.", |
| 515 | "The heading carries an empty children list rather than omitting the key, which gives a \ |
| 516 | childless heading two encodings.", |
| 517 | tree::doc_with_heading(common::map(vec![ |
| 518 | ("level", Dat::U8(2)), |
| 519 | ("children", Dat::List(Vec::new())), |
| 520 | ])), |
| 521 | )); |
| 522 | |
| 523 | res!(canon_reject(root, &author, "canon_rule4_opt_none", Some(1), "rule 4", |
| 524 | "SPEC.md §3 rule 4: an absent optional field is omitted, not encoded as a none.", |
| 525 | "The section's optional title is written as a none rather than left out, so an untitled \ |
| 526 | section has two encodings.", |
| 527 | tree::doc_of(vec![ |
| 528 | common::node(NodeKind::Section, common::map(vec![ |
| 529 | ("title", Dat::Opt(Box::new(None))), |
| 530 | ("children", Dat::List(vec![ |
| 531 | common::node(NodeKind::Para, common::map(vec![ |
| 532 | ("children", Dat::List(vec![common::text("A paragraph.")])), |
| 533 | ])), |
| 534 | ])), |
| 535 | ])), |
| 536 | ]), |
| 537 | )); |
| 538 | |
| 539 | res!(canon_reject(root, &author, "canon_rule5_control_char", Some(2), "rule 5", |
| 540 | "SPEC.md §3 rule 5: strings carry no C0 or C1 control characters other than tab and \ |
| 541 | newline.", |
| 542 | "The text run carries a carriage return, so one line ending would have two encodings.", |
| 543 | tree::doc_with_heading(common::map(vec![ |
| 544 | ("level", Dat::U8(2)), |
| 545 | ("children", Dat::List(vec![common::text("a carriage\rreturn")])), |
| 546 | ])), |
| 547 | )); |
| 548 | |
| 549 | res!(canon_reject(root, &author, "canon_rule5_not_nfc", Some(2), "rule 5", |
| 550 | "SPEC.md §3 rule 5: strings are in Unicode NFC.", |
| 551 | "The text run spells café with a combining acute accent rather than the composed letter. \ |
| 552 | It displays identically to the composed form and hashes differently, so the one document \ |
| 553 | would have two addresses.", |
| 554 | tree::doc_with_heading(common::map(vec![ |
| 555 | ("level", Dat::U8(2)), |
| 556 | ("children", Dat::List(vec![common::text("cafe\u{0301}")])), |
| 557 | ])), |
| 558 | )); |
| 559 | |
| 560 | res!(canon_reject(root, &author, "canon_rule6_int_width", Some(1), "rules 1 and 6", |
| 561 | "SPEC.md §3 rule 6: integers are exactly the declared width, with no promotion and no \ |
| 562 | demotion.", |
| 563 | "The heading's level is a u32 where the schema declares a u8. Both decode to the number 2, \ |
| 564 | and they are different byte strings, so they are two addresses for one document.", |
| 565 | tree::doc_with_heading(common::map(vec![ |
| 566 | ("level", Dat::U32(2)), |
| 567 | ("children", Dat::List(vec![common::text("A heading")])), |
| 568 | ])), |
| 569 | )); |
| 570 | |
| 571 | let vek = match Vek::try_from(vec![common::text("A heading")]) { |
| 572 | Ok(vek) => vek, |
| 573 | Err(e) => return Err(err!(e, "Could not build a Vek of one text run."; Bug, Invalid)), |
| 574 | }; |
| 575 | res!(canon_reject(root, &author, "canon_rule7_vek_children", Some(1), "rule 7", |
| 576 | "SPEC.md §3 rule 7: lists are Dat::List, not Dat::Vek, even where every element shares a \ |
| 577 | kind.", |
| 578 | "The heading's children sit in a Vek. Every child of a heading is a node, so a Vek is \ |
| 579 | always available, and always a second encoding of the same list.", |
| 580 | tree::doc_with_heading(common::map(vec![ |
| 581 | ("level", Dat::U8(2)), |
| 582 | ("children", Dat::Vek(vek)), |
| 583 | ])), |
| 584 | )); |
| 585 | |
| 586 | // SPEC §4.5: an unknown kind is canonical enough to decode, so canon accepts it, and the |
| 587 | // validator is what refuses one whose payload carries no non-empty fallback of known nodes. The |
| 588 | // alien here carries an ordinary map with an uninterpreted field but no fallback, so it verifies, |
| 589 | // decodes canonically, and is refused at validation. |
| 590 | res!(schema_reject(root, &author, "unknown_kind", 1, |
| 591 | "fallback", |
| 592 | &fmt!("SPEC.md §4.5: an unknown kind code {} is permitted only with a non-empty fallback of \ |
| 593 | known nodes.", ALIEN_CODE), |
| 594 | "The document's one child declares a kind code that names no v0 node kind and carries no \ |
| 595 | fallback. The bytes are canonical, so the file verifies and decodes; the vocabulary rule of \ |
| 596 | §4.5 is what refuses it, naming the node and the missing fallback.", |
| 597 | tree::doc_of(vec![ |
| 598 | common::alien(common::map(vec![ |
| 599 | ("rows", Dat::Str("no fallback here".to_string())), |
| 600 | ])), |
| 601 | ]), |
| 602 | )); |
| 603 | |
| 604 | let deep = res!(tree::chain(common::depth_limit() + 1)); |
| 605 | let deep_bytes = res!(common::encode_unchecked(&deep)); |
| 606 | let bad = res!(common::assemble( |
| 607 | &res!(common::seal(&deep_bytes, SCHEMA_DOC, &author, TIME)), |
| 608 | &deep_bytes, |
| 609 | )); |
| 610 | res!(reject(root, "depth_over_limit", &bad, None, Reject { |
| 611 | stage: Stage::Decode, |
| 612 | rule: fmt!("SPEC.md §5: the nesting depth limit is {}, enforced during decoding.", |
| 613 | common::depth_limit()), |
| 614 | says: fmt!("past the limit of {}", common::depth_limit()), |
| 615 | node: Some(try_into!(u64, common::depth_limit())), |
| 616 | offset: None, |
| 617 | note: fmt!("A doc at the head of a chain of boxes {} nodes deep, correctly hashed and \ |
| 618 | correctly signed. A tiny file describing a deep nest is the cheapest attack there \ |
| 619 | is against a recursive decoder, so the depth limit is the decoder's rather than \ |
| 620 | the validator's.", common::depth_limit() + 1), |
| 621 | })); |
| 622 | |
| 623 | // -- Step 7: the schema, and the limits that are not the decoder's. ----------------------- |
| 624 | |
| 625 | let foreign = res!(canon::encode(&tree::one_para())); |
| 626 | let bad = res!(common::assemble( |
| 627 | &res!(common::seal(&foreign, SCHEMA_FOREIGN, &author, TIME)), |
| 628 | &foreign, |
| 629 | )); |
| 630 | res!(reject(root, "foreign_schema", &bad, Some(&tree::one_para()), Reject { |
| 631 | stage: Stage::Validate, |
| 632 | rule: "SPEC.md §1.2: the envelope declares the schema of its payload.".to_string(), |
| 633 | says: SCHEMA_FOREIGN.to_string(), |
| 634 | node: None, |
| 635 | offset: None, |
| 636 | note: "A sound envelope over a sound tree, declaring a schema this build does not \ |
| 637 | validate. The container carries any schema, and a reader that validates one \ |
| 638 | refuses the others rather than reading them as though they were documents." |
| 639 | .to_string(), |
| 640 | })); |
| 641 | |
| 642 | res!(schema_reject(root, &author, "para_in_para", 2, |
| 643 | "admits inline content only", |
| 644 | "SPEC.md §4.2: a para takes inline content.", |
| 645 | "A paragraph inside a paragraph. The tree is canonical and the file verifies; the \ |
| 646 | vocabulary is what refuses it.", |
| 647 | tree::doc_of(vec![ |
| 648 | common::node(NodeKind::Para, common::map(vec![ |
| 649 | ("children", Dat::List(vec![ |
| 650 | common::node(NodeKind::Para, common::map(vec![ |
| 651 | ("children", Dat::List(vec![common::text("Inner")])), |
| 652 | ])), |
| 653 | ])), |
| 654 | ])), |
| 655 | ]), |
| 656 | )); |
| 657 | |
| 658 | res!(schema_reject(root, &author, "heading_level_0", 1, |
| 659 | "1..=6", |
| 660 | "SPEC.md §4.2: a heading's level runs from 1 to 6.", |
| 661 | "A heading of level 0. The field is a u8, exactly as the schema declares, and 0 is not a \ |
| 662 | heading level.", |
| 663 | tree::doc_with_heading(common::map(vec![ |
| 664 | ("level", Dat::U8(0)), |
| 665 | ("children", Dat::List(vec![common::text("A heading")])), |
| 666 | ])), |
| 667 | )); |
| 668 | |
| 669 | res!(schema_reject(root, &author, "heading_level_7", 1, |
| 670 | "1..=6", |
| 671 | "SPEC.md §4.2: a heading's level runs from 1 to 6.", |
| 672 | "A heading of level 7, one past the deepest heading the format has.", |
| 673 | tree::doc_with_heading(common::map(vec![ |
| 674 | ("level", Dat::U8(7)), |
| 675 | ("children", Dat::List(vec![common::text("A heading")])), |
| 676 | ])), |
| 677 | )); |
| 678 | |
| 679 | res!(schema_reject(root, &author, "empty_list", 1, |
| 680 | "must carry at least one", |
| 681 | "SPEC.md §4.2: a list is marked `+` and carries at least one item.", |
| 682 | "A list with an ordered field but no items. A document or section may be empty, but an \ |
| 683 | empty list is a construction error rather than intent.", |
| 684 | tree::doc_of(vec![ |
| 685 | common::node(NodeKind::List, common::map(vec![ |
| 686 | ("ordered", Dat::Bool(false)), |
| 687 | ])), |
| 688 | ]), |
| 689 | )); |
| 690 | |
| 691 | // A style field naming an entry the table does not define (§4.4). The table defines 'callout', |
| 692 | // and the box names 'ghost'. The bytes are canonical, so the fault is the validator's: a style |
| 693 | // name must resolve to a table entry, named at the node that made the reference. |
| 694 | res!(schema_reject(root, &author, "style_missing_entry", 1, |
| 695 | "ghost", |
| 696 | "SPEC.md §4.4: a node's style field must name an entry the document's style table defines.", |
| 697 | "A box names the style 'ghost', which the document's style table, defining only 'callout', \ |
| 698 | does not. A style error cannot escape the node that made it, and this one is named at the \ |
| 699 | box.", |
| 700 | common::node(NodeKind::Doc, common::map(vec![ |
| 701 | ("title", Dat::Str("A style with no entry".to_string())), |
| 702 | ("lang", Dat::Str("en".to_string())), |
| 703 | ("styles", common::map(vec![ |
| 704 | ("callout", common::map(vec![ |
| 705 | ("bg", Dat::Str("muted".to_string())), |
| 706 | ])), |
| 707 | ])), |
| 708 | ("children", Dat::List(vec![ |
| 709 | common::node(NodeKind::Boxx, common::map(vec![ |
| 710 | ("style", Dat::Str("ghost".to_string())), |
| 711 | ("children", Dat::List(vec![ |
| 712 | common::node(NodeKind::Para, common::map(vec![ |
| 713 | ("children", Dat::List(vec![common::text("in a box")])), |
| 714 | ])), |
| 715 | ])), |
| 716 | ])), |
| 717 | ])), |
| 718 | ])), |
| 719 | )); |
| 720 | |
| 721 | // A style record whose value is out of its enumeration (§4.4). The 'bg' property is a palette |
| 722 | // name, and 'purple' is not one. The bytes are canonical, since canon pins only the type of a |
| 723 | // palette value and not its membership, so the validator is what refuses it, at the doc where the |
| 724 | // table is validated. |
| 725 | res!(schema_reject(root, &author, "style_out_of_enum", 0, |
| 726 | "purple", |
| 727 | "SPEC.md §4.4: a palette property carries a palette name; the palette is ink, muted, accent, \ |
| 728 | bg.", |
| 729 | "The style 'callout' declares a background of 'purple', which is not a palette name. The \ |
| 730 | style table is validated at the doc, so the rejection names node 0 and the offending style.", |
| 731 | common::node(NodeKind::Doc, common::map(vec![ |
| 732 | ("title", Dat::Str("A colour off the palette".to_string())), |
| 733 | ("lang", Dat::Str("en".to_string())), |
| 734 | ("styles", common::map(vec![ |
| 735 | ("callout", common::map(vec![ |
| 736 | ("bg", Dat::Str("purple".to_string())), |
| 737 | ])), |
| 738 | ])), |
| 739 | ("children", Dat::List(vec![ |
| 740 | common::node(NodeKind::Para, common::map(vec![ |
| 741 | ("children", Dat::List(vec![common::text("a paragraph")])), |
| 742 | ])), |
| 743 | ])), |
| 744 | ])), |
| 745 | )); |
| 746 | |
| 747 | // A malformed link address carrying two entries (§4.3). An address is a single-entry map, and |
| 748 | // this one names both a name and a hash. Canon pins each entry's type but not the count, so the |
| 749 | // bytes are canonical and the validator refuses the address, naming the link node. |
| 750 | res!(schema_reject(root, &author, "link_two_entries", 2, |
| 751 | "not a valid link address", |
| 752 | "SPEC.md §4.3: a link address is a map with exactly one entry.", |
| 753 | "The link's 'to' address carries both a name and a hash. A typed address selects one kind, \ |
| 754 | so two entries is not an address at all, and the reader refuses it at the door rather than \ |
| 755 | letting the renderer choose.", |
| 756 | tree::doc_of(vec![ |
| 757 | common::node(NodeKind::Para, common::map(vec![ |
| 758 | ("children", Dat::List(vec![ |
| 759 | common::node(NodeKind::Link, common::map(vec![ |
| 760 | ("to", common::map(vec![ |
| 761 | ("name", Dat::Str("news.cricket".to_string())), |
| 762 | ("hash", Dat::from([0x9fu8; 32])), |
| 763 | ])), |
| 764 | ("children", Dat::List(vec![common::text("a link")])), |
| 765 | ])), |
| 766 | ])), |
| 767 | ])), |
| 768 | ]), |
| 769 | )); |
| 770 | |
| 771 | // SPEC §4.2: the codes 14 and 15 are reserved to the chrome and to applications, and the document |
| 772 | // schema admits them nowhere. The bytes of all three fixtures below are canonical, since canon has |
| 773 | // no schema for a code outside the vocabulary and holds such a node only to §3; the validator is |
| 774 | // what refuses them, and it refuses them by name. |
| 775 | |
| 776 | res!(schema_reject(root, &author, "reserved_edit_in_doc", 1, |
| 777 | "may not carry an edit node", |
| 778 | "SPEC.md §4.2: the kind code 14, `edit`, is reserved to the chrome and to applications, and \ |
| 779 | the document schema admits the kinds 1 to 13 and no others.", |
| 780 | "A document carrying an editable text field. An `edit` is a facility of the engine, reached \ |
| 781 | by the chrome's address bar and by an application's own form fields, and a document may not \ |
| 782 | ask for one. The refusal names the kind and says that a document may not carry it.", |
| 783 | tree::doc_of(vec![ |
| 784 | common::reserved(ReservedKind::Edit, common::map(vec![ |
| 785 | ("placeholder", Dat::Str("Search the oxeweb".to_string())), |
| 786 | ])), |
| 787 | ]), |
| 788 | )); |
| 789 | |
| 790 | res!(schema_reject(root, &author, "reserved_surface_in_doc", 1, |
| 791 | "may not carry a surface node", |
| 792 | "SPEC.md §4.2: the kind code 15, `surface`, is reserved to applications, and the document \ |
| 793 | schema admits the kinds 1 to 13 and no others.", |
| 794 | "A document carrying a pane for an application to paint. A `surface` is the one place \ |
| 795 | anything but the author's own data reaches the screen, so a document that could name one \ |
| 796 | would be a program, and the whole design turns on a document never being one.", |
| 797 | tree::doc_of(vec![ |
| 798 | common::reserved(ReservedKind::Surface, common::map(vec![ |
| 799 | ("app", Dat::Str("app.modeller".to_string())), |
| 800 | ])), |
| 801 | ]), |
| 802 | )); |
| 803 | |
| 804 | // The hole that §4.5 would leave open if a reserved code were treated as merely unknown. The |
| 805 | // fallback here is everything §4.5 asks of one -- a non-empty list of known nodes, which validate |
| 806 | // in full -- and it buys the surface nothing. Were it otherwise, an author could put a surface in |
| 807 | // a document today, under a fallback that renders innocently, and every reader that later learned |
| 808 | // what code 15 meant would begin honouring it: a document that became a program by waiting. |
| 809 | res!(schema_reject(root, &author, "reserved_surface_with_fallback_still_refused", 1, |
| 810 | "whether or not it carries a fallback", |
| 811 | "SPEC.md §4.5: a fallback admits a kind the reader has never heard of, and never one the \ |
| 812 | reader knows the document schema does not admit.", |
| 813 | "A document carrying a `surface` that also carries a valid, non-empty fallback of known \ |
| 814 | nodes. A fallback is forward compatibility for an unknown code, and code 15 is not unknown: \ |
| 815 | the reader knows exactly what it has been handed, and knows a document may not carry it, so \ |
| 816 | it refuses it with the fallback and without.", |
| 817 | tree::doc_of(vec![ |
| 818 | common::reserved(ReservedKind::Surface, common::map(vec![ |
| 819 | ("app", Dat::Str("app.modeller".to_string())), |
| 820 | ("fallback", Dat::List(vec![ |
| 821 | common::node(NodeKind::Para, common::map(vec![ |
| 822 | ("children", Dat::List(vec![ |
| 823 | common::text("A picture of a teapot, rendered by nobody."), |
| 824 | ])), |
| 825 | ])), |
| 826 | ])), |
| 827 | ])), |
| 828 | ]), |
| 829 | )); |
| 830 | |
| 831 | Ok(35) |
| 832 | } |
| 833 | |
| 834 | /// What the decoder says of a tree region that ends before the tree in it does. |
| 835 | const SAYS_TRUNCATED: &'static str = "Not enough bytes"; |
| 836 | |
| 837 | /// Writes a fixture whose tree breaks a canonical encoding rule of §3. |
| 838 | /// |
| 839 | /// The tree is encoded without being checked, since `canon::encode` refuses to give an address to a |
| 840 | /// document that has no canonical form, which is exactly what the rule being broken means. The bytes |
| 841 | /// are then hashed and signed like any other, so that the rejection can only be the rule's. |
| 842 | fn canon_reject( |
| 843 | root: &Path, |
| 844 | author: &SignatureScheme, |
| 845 | name: &str, |
| 846 | node: Option<u64>, |
| 847 | says: &str, |
| 848 | rule: &str, |
| 849 | note: &str, |
| 850 | tree: Dat, |
| 851 | ) |
| 852 | -> Outcome<()> |
| 853 | { |
| 854 | let bytes = res!(common::encode_unchecked(&tree)); |
| 855 | let bad = res!(common::assemble( |
| 856 | &res!(common::seal(&bytes, SCHEMA_DOC, author, TIME)), |
| 857 | &bytes, |
| 858 | )); |
| 859 | reject(root, name, &bad, Some(&tree), Reject { |
| 860 | stage: Stage::Decode, |
| 861 | rule: rule.to_string(), |
| 862 | says: says.to_string(), |
| 863 | node, |
| 864 | offset: None, |
| 865 | note: note.to_string(), |
| 866 | }) |
| 867 | } |
| 868 | |
| 869 | /// Writes a fixture whose tree is canonical and whose vocabulary is wrong. |
| 870 | /// |
| 871 | /// The bytes are canonical, so the fixture isolates the schema: everything up to and including the |
| 872 | /// decoding of the tree succeeds, and validation is what refuses it. |
| 873 | fn schema_reject( |
| 874 | root: &Path, |
| 875 | author: &SignatureScheme, |
| 876 | name: &str, |
| 877 | node: u64, |
| 878 | says: &str, |
| 879 | rule: &str, |
| 880 | note: &str, |
| 881 | tree: Dat, |
| 882 | ) |
| 883 | -> Outcome<()> |
| 884 | { |
| 885 | let bytes = res!(canon::encode(&tree)); |
| 886 | let bad = res!(common::assemble( |
| 887 | &res!(common::seal(&bytes, SCHEMA_DOC, author, TIME)), |
| 888 | &bytes, |
| 889 | )); |
| 890 | reject(root, name, &bad, Some(&tree), Reject { |
| 891 | stage: Stage::Validate, |
| 892 | rule: rule.to_string(), |
| 893 | says: says.to_string(), |
| 894 | node: Some(node), |
| 895 | offset: None, |
| 896 | note: note.to_string(), |
| 897 | }) |
| 898 | } |
| 899 | |
| 900 | /// Writes a rejection fixture: the bad artefact, the tree behind it where there is one, and the |
| 901 | /// failure the reader must produce. |
| 902 | fn reject( |
| 903 | root: &Path, |
| 904 | name: &str, |
| 905 | buf: &[u8], |
| 906 | tree: Option<&Dat>, |
| 907 | dec: Reject, |
| 908 | ) |
| 909 | -> Outcome<()> |
| 910 | { |
| 911 | let dir = res!(fresh_dir(root, name)); |
| 912 | res!(common::write_bytes(&dir.join(DOC_SBJ), buf)); |
| 913 | res!(common::write_bytes( |
| 914 | &dir.join(REJECT_JDAT), |
| 915 | res!(common::to_jdat_plain(&dec.to_dat())).as_bytes(), |
| 916 | )); |
| 917 | // A rejection fixture carries the tree in text form only where the tree region of the artefact |
| 918 | // is exactly the encoding of that tree. Where the fault is in the bytes rather than in the tree, |
| 919 | // there is no tree to carry, and the suite requires none. |
| 920 | if let Some(tree) = tree { |
| 921 | res!(common::write_bytes(&dir.join(DOC_JDAT), res!(common::to_jdat(tree)).as_bytes())); |
| 922 | } |
| 923 | Ok(()) |
| 924 | } |
| 925 | |
| 926 | /// Empties and returns a fixture's directory, so that a regeneration leaves nothing stale behind. |
| 927 | fn fresh_dir(root: &Path, name: &str) -> Outcome<PathBuf> { |
| 928 | let dir = root.join(name); |
| 929 | if dir.exists() { |
| 930 | res!(fs::remove_dir_all(&dir), IO, File); |
| 931 | } |
| 932 | res!(fs::create_dir_all(&dir), IO, File); |
| 933 | Ok(dir) |
| 934 | } |
| 935 | |
| 936 | /// The bytes of a doc whose payload map carries the key `lang` twice. |
| 937 | /// |
| 938 | /// A map is a `BTreeMap` once decoded, so a duplicate key exists only on the wire. The bytes are |
| 939 | /// therefore written entry by entry, in the order a `BTreeMap` puts them in, with one entry written |
| 940 | /// twice. |
| 941 | fn duplicate_key_bytes() -> Outcome<Vec<u8>> { |
| 942 | |
| 943 | let kids = Dat::List(vec![ |
| 944 | common::node(NodeKind::Para, common::map(vec![ |
| 945 | ("children", Dat::List(vec![common::text("One paragraph.")])), |
| 946 | ])), |
| 947 | ]); |
| 948 | |
| 949 | let mut inner = Vec::new(); |
| 950 | inner = res!(Dat::Str("children".to_string()).to_bytes(inner)); |
| 951 | inner = res!(kids.to_bytes(inner)); |
| 952 | for _ in 0..2 { // The duplicate. |
| 953 | inner = res!(Dat::Str("lang".to_string()).to_bytes(inner)); |
| 954 | inner = res!(Dat::Str("en".to_string()).to_bytes(inner)); |
| 955 | } |
| 956 | inner = res!(Dat::Str("title".to_string()).to_bytes(inner)); |
| 957 | inner = res!(Dat::Str("A document".to_string()).to_bytes(inner)); |
| 958 | |
| 959 | let mut payload = vec![Dat::MAP_CODE]; |
| 960 | payload = res!(Dat::C64(try_into!(u64, inner.len())).to_bytes(payload)); |
| 961 | payload.extend_from_slice(&inner); |
| 962 | |
| 963 | let mut buf = vec![Dat::USR_CODE]; |
| 964 | buf.extend_from_slice(&NodeKind::Doc.code().to_be_bytes()); |
| 965 | buf.push(Dat::OPT_SOME_CODE); |
| 966 | buf.extend_from_slice(&payload); |
| 967 | Ok(buf) |
| 968 | } |
| 969 | |
| 970 | /// What `fixtures/README.md` says. |
| 971 | fn readme() -> String { |
| 972 | fmt!("\ |
| 973 | # SBJ conformance fixtures |
| 974 | |
| 975 | The teeth of `SPEC.md` §7. Written by `examples/gen_fixtures.rs`, run by `tests/conformance.rs`, and |
| 976 | regenerated rather than patched: |
| 977 | |
| 978 | cargo run -p oxedyne_fe2o3_sbj --example gen_fixtures |
| 979 | cargo test -p oxedyne_fe2o3_sbj |
| 980 | |
| 981 | Each fixture is a directory. |
| 982 | |
| 983 | **Acceptance** fixtures carry `doc.jdat`, the payload in JDAT text form and the source of truth; |
| 984 | `doc.sbj`, the canonical signed artefact; and `meta.jdat`, what the artefact must turn out to be: |
| 985 | its address, the length of its payload region, and -- where the payload is a node tree -- its node |
| 986 | count and its depth. The suite reads `doc.jdat`, signs it with the committed key, and requires the |
| 987 | bytes it gets back to be `doc.sbj`, byte for byte. |
| 988 | |
| 989 | **Not every payload is a node tree.** The container carries any schema (§1.2), and the fixtures |
| 990 | named `post_*`, `card_*` and `share_*` carry `daimond/post/0`, `daimond/card/0` and |
| 991 | `daimond/share/0`, which are flat canonical maps rather than trees. Those declare no node count and |
| 992 | no depth, because they have neither, and their `doc.jdat` is written in plain JDAT with none of the |
| 993 | `sbj_` node labels below. Everything else about them is identical: the same header, the same |
| 994 | envelope, the same address, the same signature, and every rule of §3. |
| 995 | |
| 996 | The `share_*` fixtures carry one rule the others do not, and it is the reason that schema exists: |
| 997 | `code` is the sender's SIGNED statement about whether the share carries a program, and it is |
| 998 | checked against the files both ways. `share_code_hidden` is a page under a claim of no code, and |
| 999 | `share_code_claimed_without_code` is the opposite. A share is a COPY the receiver comes to own, so |
| 1000 | there is no live view, nothing to revoke, and no third party in the middle of it. |
| 1001 | |
| 1002 | **Rejection** fixtures carry `doc.sbj`, the bad artefact, and `reject.jdat`, which declares the rule |
| 1003 | broken, the step of §2 that must catch it, what the error must say, and the node or the byte it must |
| 1004 | name. \"It was rejected\" is not the claim: the claim is that it was rejected for the right reason. |
| 1005 | A rejection fixture also carries `doc.jdat` where the tree region is the encoding of a tree that can |
| 1006 | be written down; where the fault is in the bytes themselves, there is no tree to write. |
| 1007 | |
| 1008 | Every rejection fixture past the header is correctly hashed and correctly signed, so that the |
| 1009 | rejection can only have come from the rule the fixture breaks, and never from a signature that |
| 1010 | happened not to check out. |
| 1011 | |
| 1012 | `{}` holds the fixed key every fixture is signed with, and a second key that signs nothing but the |
| 1013 | fixture of a signature by the wrong hand. It is committed on purpose: a fixture signed by a fresh |
| 1014 | key would be a different file on every run, and a suite that has to be regenerated to pass tests |
| 1015 | nothing. It is a test key, published here, and signs nothing else. |
| 1016 | |
| 1017 | Node labels in `doc.jdat` carry an `sbj_` prefix, because two of the v0 kind labels, `box` and |
| 1018 | `list`, are JDAT's own kind labels as well: `(box|{{..}})` would read back as a `Dat::Box`. None of |
| 1019 | this reaches the wire, where BDAT carries the `u16` kind code and no label at all. |
| 1020 | |
| 1021 | The kind code {} appears in the `unknown_kind` and `unknown_kind_fallback` fixtures. It names no v0 |
| 1022 | node kind, which is the point of it: the first carries no fallback and is refused, and the second |
| 1023 | carries a fallback of known nodes and is accepted (§4.5). |
| 1024 | |
| 1025 | The kind codes {} (`edit`) and {} (`surface`) appear in the three `reserved_*` fixtures. They are |
| 1026 | not unknown: §4.2 reserves them to the chrome and to applications, and `oxeweb/doc/0` admits the |
| 1027 | kinds 1 to 13 and no others. All three are refused, and the third carries a valid fallback and is |
| 1028 | refused anyway, which is the point of it: a fallback admits a code the reader has never heard of, |
| 1029 | and never one the reader knows a document may not carry. |
| 1030 | ", KEY_FILE, ALIEN_CODE, ReservedKind::Edit.code(), ReservedKind::Surface.code()) |
| 1031 | } |
| 1032 | |
| 1033 | |
| 1034 | // ┌───────────────────────────────────────────────────────────────────────────┐ |
| 1035 | // │ THE SCHEMAS THAT ARE NOT NODE TREES │ |
| 1036 | // └───────────────────────────────────────────────────────────────────────────┘ |
| 1037 | // |
| 1038 | // A post and a card are flat canonical maps, so none of §4 applies to them and the fixtures below |
| 1039 | // carry no node count and no depth. Everything else is the same file in the same container, which |
| 1040 | // is the point: the envelope, the address, the signature and every rule of §3 are the container's |
| 1041 | // and do not change with the payload. |
| 1042 | |
| 1043 | /// A recipient's key, fixed so a fixture written twice is written the same. |
| 1044 | const TO_KEY: [u8; post::limit::KEY_BYTES] = [0xA1; post::limit::KEY_BYTES]; |
| 1045 | |
| 1046 | /// A message's nonce, fixed for the same reason. |
| 1047 | const NONCE: [u8; post::limit::NONCE_BYTES] = [0xB2; post::limit::NONCE_BYTES]; |
| 1048 | |
| 1049 | /// A sealing subkey, fixed for the same reason. |
| 1050 | const ENC_KEY: [u8; card::limit::KEY_BYTES] = [0xE1; card::limit::KEY_BYTES]; |
| 1051 | |
| 1052 | /// The smallest message that is still a message. |
| 1053 | fn post_minimal() -> Post { |
| 1054 | Post { |
| 1055 | body: "The crop is in, and the second field can wait.".to_string(), |
| 1056 | to: TO_KEY.to_vec(), |
| 1057 | nonce: NONCE.to_vec(), |
| 1058 | reply_to: None, |
| 1059 | refs: Vec::new(), |
| 1060 | } |
| 1061 | } |
| 1062 | |
| 1063 | /// The fixtures of the post and card schemas, and the count of them. |
| 1064 | fn payloads( |
| 1065 | root: &Path, |
| 1066 | keys: &Keys, |
| 1067 | ) |
| 1068 | -> Outcome<usize> |
| 1069 | { |
| 1070 | let author = res!(keys.author.signer()); |
| 1071 | |
| 1072 | // -- What a reader must accept. ------------------------------------------------------------- |
| 1073 | |
| 1074 | res!(accept_payload(root, &author, "post_minimal", &Payload::Post(post_minimal()), |
| 1075 | "The smallest message that is still one: a body, a recipient and a nonce. Neither optional \ |
| 1076 | field is present, and neither is encoded as `none` — an absent field is omitted (§3 rule \ |
| 1077 | 4), because a message written two ways would be a message with two addresses.")); |
| 1078 | |
| 1079 | res!(accept_payload(root, &author, "post_every_target", &Payload::Post(Post { |
| 1080 | body: "All four of the things a message may point at, once each.".to_string(), |
| 1081 | to: TO_KEY.to_vec(), |
| 1082 | nonce: NONCE.to_vec(), |
| 1083 | reply_to: Some(vec![0xC3; post::limit::ADDR_BYTES]), |
| 1084 | refs: vec![ |
| 1085 | Reference { |
| 1086 | target: Target::Proposal { |
| 1087 | account: "oxedyne".to_string(), |
| 1088 | repo: "daimond".to_string(), |
| 1089 | number: 17, |
| 1090 | }, |
| 1091 | fallback: "the proposal about the panel showing nothing when signed out" |
| 1092 | .to_string(), |
| 1093 | }, |
| 1094 | Reference { |
| 1095 | target: Target::Build { id: "f9f68b75c73b".to_string() }, |
| 1096 | fallback: "the build this was fixed in".to_string(), |
| 1097 | }, |
| 1098 | Reference { |
| 1099 | target: Target::Panel { name: "spend".to_string() }, |
| 1100 | fallback: "the Spending panel".to_string(), |
| 1101 | }, |
| 1102 | Reference { |
| 1103 | target: Target::Guide { |
| 1104 | page: "improve".to_string(), |
| 1105 | anchor: Some("voices".to_string()), |
| 1106 | }, |
| 1107 | fallback: "the guide section on voices".to_string(), |
| 1108 | }, |
| 1109 | ], |
| 1110 | }), |
| 1111 | "Every kind of reference once, at the limit of four, and a reply. All four referents are \ |
| 1112 | PUBLIC anchors: each is named globally and can be resolved by anybody holding a session. A \ |
| 1113 | reference to something only the sender can reach would draw a pressable chip that always \ |
| 1114 | fails, which is why no such kind exists.")); |
| 1115 | |
| 1116 | res!(accept_payload(root, &author, "card_first", &Payload::Card(Card { |
| 1117 | label: "Jason".to_string(), |
| 1118 | enc: ENC_KEY.to_vec(), |
| 1119 | role: Role::Root, |
| 1120 | prev: None, |
| 1121 | }), |
| 1122 | "A first identity card: a display label, the sealing subkey, and the role. The SIGNING key \ |
| 1123 | is not a field here — it is the envelope's `author`, so a card has exactly one place that \ |
| 1124 | says which key composed it. Self-signed, which proves the holder of that key composed it \ |
| 1125 | and proves nothing whatever about who the holder is.")); |
| 1126 | |
| 1127 | res!(accept_payload(root, &author, "card_rotated", &Payload::Card(Card { |
| 1128 | label: "Jason".to_string(), |
| 1129 | enc: ENC_KEY.to_vec(), |
| 1130 | role: Role::Root, |
| 1131 | prev: Some(vec![0xD4; card::limit::KEY_BYTES]), |
| 1132 | }), |
| 1133 | "A card naming the key it supersedes. It must not encode as `card_first` does: a rotated \ |
| 1134 | key and a first key are different facts, and a reader that could not tell them apart could \ |
| 1135 | not tell a replacement from a stranger.")); |
| 1136 | |
| 1137 | // -- What a reader must refuse. ------------------------------------------------------------- |
| 1138 | |
| 1139 | // The re-labelling attack, which is the whole reason §1.3 length-prefixes the schema. The bytes, |
| 1140 | // the hash and the signature are untouched; one word of the envelope is changed. Both payloads |
| 1141 | // are flat maps, so the card's decoder would happily be handed the post's bytes -- the envelope |
| 1142 | // is the only thing that says which this is, and the envelope is signed. |
| 1143 | let bytes = res!(post_minimal().encode()); |
| 1144 | let mut env = res!(common::seal(&bytes, SCHEMA_POST, &author, TIME)); |
| 1145 | env.schema = SCHEMA_CARD.to_string(); |
| 1146 | res!(reject(root, "post_relabelled_as_card", &res!(common::assemble(&env, &bytes)), None, |
| 1147 | Reject { |
| 1148 | stage: Stage::Sig, |
| 1149 | rule: "SPEC.md §1.3: the schema is inside the signing input, and is preceded by its \ |
| 1150 | length.".to_string(), |
| 1151 | says: "not a signature by the author".to_string(), |
| 1152 | node: None, |
| 1153 | offset: None, |
| 1154 | note: "A signed post whose envelope has been re-labelled `daimond/card/0` after \ |
| 1155 | signing. Everything else is untouched: the payload bytes, the hash of them, and \ |
| 1156 | the signature over that hash. The signature covers the schema as well as the \ |
| 1157 | address, so the re-labelling is what breaks it. Without that, a payload could be \ |
| 1158 | presented to whichever validator would accept it, and an author's signature would \ |
| 1159 | vouch for a claim they never made.".to_string(), |
| 1160 | })); |
| 1161 | |
| 1162 | // A body one byte past the limit. A rejection and never a truncation: a message silently cut |
| 1163 | // short is a message whose sender and reader disagree about what was said. |
| 1164 | let mut long = post_minimal(); |
| 1165 | long.body = "x".repeat(post::limit::BODY_BYTES + 1); |
| 1166 | res!(payload_reject(root, &author, "post_body_over_limit", SCHEMA_POST, |
| 1167 | &res!(long.to_dat()), |
| 1168 | "exceeding the limit", |
| 1169 | "SPEC.md §5 and the post schema's own limits: a body is at most 8 KiB of UTF-8." |
| 1170 | .to_string(), |
| 1171 | "A body one byte past the ceiling. The number is revisable on evidence; that there is one \ |
| 1172 | is not, since a body with no ceiling is a body that sets the relay's storage.".to_string())); |
| 1173 | |
| 1174 | // A nonce of the wrong width. Not a shorter nonce: a different thing. |
| 1175 | let short_nonce = { |
| 1176 | let mut m = DaticleMap::new(); |
| 1177 | m.insert(dat!(post::KEY_BODY), Dat::BU32(post_minimal().body.into_bytes())); |
| 1178 | m.insert(dat!(post::KEY_NONCE), Dat::BU8(vec![0xB2; post::limit::NONCE_BYTES - 1])); |
| 1179 | m.insert(dat!(post::KEY_TO), Dat::BU8(TO_KEY.to_vec())); |
| 1180 | Dat::Map(m) |
| 1181 | }; |
| 1182 | res!(payload_reject(root, &author, "post_nonce_width", SCHEMA_POST, &short_nonce, |
| 1183 | "must carry exactly", |
| 1184 | "The post schema fixes the nonce at 16 bytes.".to_string(), |
| 1185 | "A nonce of fifteen bytes. A key or a nonce of the wrong width is not a shorter one; it \ |
| 1186 | is a different thing, and admitting it would let a sender choose how much randomness a \ |
| 1187 | message carried.".to_string())); |
| 1188 | |
| 1189 | // A sealing subkey of the wrong width, for the same reason on the card's side. |
| 1190 | let short_enc = { |
| 1191 | let mut m = DaticleMap::new(); |
| 1192 | m.insert(dat!(card::KEY_ENC), Dat::BU8(vec![0xE1; card::limit::KEY_BYTES - 1])); |
| 1193 | m.insert(dat!(card::KEY_LABEL), Dat::Str("Jason".to_string())); |
| 1194 | m.insert(dat!(card::KEY_ROLE), Dat::Str(Role::Root.as_str().to_string())); |
| 1195 | Dat::Map(m) |
| 1196 | }; |
| 1197 | res!(payload_reject(root, &author, "card_enc_width", SCHEMA_CARD, &short_enc, |
| 1198 | "must carry exactly", |
| 1199 | "The card schema fixes the sealing subkey at 32 bytes.".to_string(), |
| 1200 | "A sealing subkey of thirty-one bytes. A card is what a correspondent reads a sealing key \ |
| 1201 | OFF, so a key of the wrong width here is a key nothing can seal to.".to_string())); |
| 1202 | |
| 1203 | // A list that is present and empty. Two encodings of one message, and so two addresses. |
| 1204 | let empty_refs = { |
| 1205 | let mut m = DaticleMap::new(); |
| 1206 | m.insert(dat!(post::KEY_BODY), Dat::BU32(post_minimal().body.into_bytes())); |
| 1207 | m.insert(dat!(post::KEY_NONCE), Dat::BU8(NONCE.to_vec())); |
| 1208 | m.insert(dat!(post::KEY_REFS), Dat::List(Vec::new())); |
| 1209 | m.insert(dat!(post::KEY_TO), Dat::BU8(TO_KEY.to_vec())); |
| 1210 | Dat::Map(m) |
| 1211 | }; |
| 1212 | res!(payload_reject(root, &author, "post_refs_empty_list", SCHEMA_POST, &empty_refs, |
| 1213 | "carries an empty \"refs\" list", |
| 1214 | "SPEC.md §3 rules 4 and 8: an absent optional field is omitted, never encoded as an empty \ |
| 1215 | one.".to_string(), |
| 1216 | "A message carrying `refs` as an empty list. A message with no references and a message \ |
| 1217 | with an empty list of them are the same message, so admitting both would give it two \ |
| 1218 | encodings and therefore two addresses.".to_string())); |
| 1219 | |
| 1220 | // A duplicate key, which survives only in the bytes: a decoding map collapses it into one entry, |
| 1221 | // so nothing but re-encoding and comparing can catch it. |
| 1222 | res!(reject(root, "post_duplicate_key", |
| 1223 | &res!(payload_file(&author, SCHEMA_POST, &res!(post_duplicate_key_bytes()))), None, |
| 1224 | Reject { |
| 1225 | stage: Stage::Decode, |
| 1226 | rule: "SPEC.md §3: a map carries each key once. A duplicate survives only in the \ |
| 1227 | bytes.".to_string(), |
| 1228 | says: "not in canonical form".to_string(), |
| 1229 | node: None, |
| 1230 | offset: None, |
| 1231 | note: "A post whose map carries `to` twice on the wire. Both entries decode, and the \ |
| 1232 | second overwrites the first, so the decoded value is indistinguishable from a \ |
| 1233 | sound one: the fault exists in the bytes alone. It is caught by re-encoding what \ |
| 1234 | was decoded and requiring the same bytes back, which is why that comparison is \ |
| 1235 | not an optimisation to skip.".to_string(), |
| 1236 | })); |
| 1237 | |
| 1238 | // A non-minimal length in the ENVELOPE, over a post. The envelope obeys §3 like everything else |
| 1239 | // the hash is read from, and `tree_len` is the one field written as a variable-width c64. |
| 1240 | res!(reject(root, "envelope_nonminimal_c64", |
| 1241 | &res!(nonminimal_tree_len(&author)), None, |
| 1242 | Reject { |
| 1243 | stage: Stage::Envelope, |
| 1244 | rule: "SPEC.md §1.2 and §3: the envelope is canonical, so a length is written in as \ |
| 1245 | few bytes as it needs.".to_string(), |
| 1246 | says: "minimally encoded".to_string(), |
| 1247 | node: None, |
| 1248 | offset: None, |
| 1249 | note: "An envelope whose `tree_len` is written as a wider c64 than the value needs. \ |
| 1250 | It decodes to the same number, so nothing about where the payload is or what it \ |
| 1251 | says would change; only the bytes differ. Admitted, it would give one artefact \ |
| 1252 | more than one envelope encoding, and an envelope is what a reader identifies an \ |
| 1253 | artefact from. Caught by the BDAT decoder as it reads the length, which is earlier \ |
| 1254 | and more precise than the envelope's own re-encode comparison -- that comparison \ |
| 1255 | is the backstop for the faults a decode survives, such as a duplicate key, and \ |
| 1256 | this is not one of them.".to_string(), |
| 1257 | })); |
| 1258 | |
| 1259 | Ok(11) |
| 1260 | } |
| 1261 | |
| 1262 | /// Writes an acceptance fixture for a payload that is not a node tree. |
| 1263 | fn accept_payload( |
| 1264 | root: &Path, |
| 1265 | author: &SignatureScheme, |
| 1266 | name: &str, |
| 1267 | payload: &Payload, |
| 1268 | note: &str, |
| 1269 | ) |
| 1270 | -> Outcome<()> |
| 1271 | { |
| 1272 | let dir = res!(fresh_dir(root, name)); |
| 1273 | let buf = res!(doc::write_artefact(payload, author, TIME)); |
| 1274 | let env = res!(doc::verify_only(&buf)); |
| 1275 | let meta = Meta { |
| 1276 | schema: env.schema.clone(), |
| 1277 | time: env.time, |
| 1278 | hash: env.hash.clone(), |
| 1279 | tree_len: env.tree_len, |
| 1280 | // A flat record has no nodes and no depth, so it declares neither. |
| 1281 | nodes: None, |
| 1282 | depth: None, |
| 1283 | index: false, |
| 1284 | note: note.to_string(), |
| 1285 | }; |
| 1286 | let as_dat = match payload { |
| 1287 | Payload::Post(p) => res!(p.to_dat()), |
| 1288 | Payload::Card(c) => res!(c.to_dat()), |
| 1289 | Payload::Share(s) => res!(s.to_dat()), |
| 1290 | Payload::Tree { .. } => return Err(err!( |
| 1291 | "The fixture '{}' is a node tree, which `accept` writes and this does not.", name; |
| 1292 | Bug, Invalid)), |
| 1293 | }; |
| 1294 | res!(common::write_bytes(&dir.join(DOC_JDAT), res!(common::to_jdat_plain(&as_dat)).as_bytes())); |
| 1295 | res!(common::write_bytes(&dir.join(DOC_SBJ), &buf)); |
| 1296 | res!(common::write_bytes( |
| 1297 | &dir.join(META_JDAT), |
| 1298 | res!(common::to_jdat_plain(&meta.to_dat())).as_bytes(), |
| 1299 | )); |
| 1300 | Ok(()) |
| 1301 | } |
| 1302 | |
| 1303 | /// Writes a rejection fixture whose payload is a canonical map breaking one of its schema's rules. |
| 1304 | /// |
| 1305 | /// The map is encoded without being checked, so the fixture isolates the rule: the container is |
| 1306 | /// sound, the bytes hash to what the envelope says, the signature verifies, and the payload's own |
| 1307 | /// decoder is what refuses it. |
| 1308 | fn payload_reject( |
| 1309 | root: &Path, |
| 1310 | author: &SignatureScheme, |
| 1311 | name: &str, |
| 1312 | schema: &str, |
| 1313 | payload: &Dat, |
| 1314 | says: &str, |
| 1315 | rule: String, |
| 1316 | note: String, |
| 1317 | ) |
| 1318 | -> Outcome<()> |
| 1319 | { |
| 1320 | let bytes = res!(payload.to_bytes(Vec::new())); |
| 1321 | let bad = res!(payload_file(author, schema, &bytes)); |
| 1322 | let dir = res!(fresh_dir(root, name)); |
| 1323 | res!(common::write_bytes(&dir.join(DOC_SBJ), &bad)); |
| 1324 | res!(common::write_bytes( |
| 1325 | &dir.join(REJECT_JDAT), |
| 1326 | res!(common::to_jdat_plain(&Reject { |
| 1327 | stage: Stage::Decode, |
| 1328 | rule, |
| 1329 | says: says.to_string(), |
| 1330 | node: None, |
| 1331 | offset: None, |
| 1332 | note, |
| 1333 | }.to_dat())).as_bytes(), |
| 1334 | )); |
| 1335 | // Written plain, with none of the `sbj_` node labels: there are no `usr` daticles in a record. |
| 1336 | res!(common::write_bytes(&dir.join(DOC_JDAT), res!(common::to_jdat_plain(payload)).as_bytes())); |
| 1337 | Ok(()) |
| 1338 | } |
| 1339 | |
| 1340 | /// A whole file around payload bytes, correctly hashed and correctly signed. |
| 1341 | fn payload_file( |
| 1342 | author: &SignatureScheme, |
| 1343 | schema: &str, |
| 1344 | bytes: &[u8], |
| 1345 | ) |
| 1346 | -> Outcome<Vec<u8>> |
| 1347 | { |
| 1348 | common::assemble(&res!(common::seal(bytes, schema, author, TIME)), bytes) |
| 1349 | } |
| 1350 | |
| 1351 | /// The bytes of a post whose map carries the key `to` twice. |
| 1352 | /// |
| 1353 | /// A map is a `BTreeMap` once decoded, so a duplicate key exists only on the wire. The bytes are |
| 1354 | /// therefore written entry by entry, in the order a `BTreeMap` puts them in, with one written twice. |
| 1355 | fn post_duplicate_key_bytes() -> Outcome<Vec<u8>> { |
| 1356 | let p = post_minimal(); |
| 1357 | let mut inner = Vec::new(); |
| 1358 | inner = res!(Dat::Str(post::KEY_BODY.to_string()).to_bytes(inner)); |
| 1359 | inner = res!(Dat::BU32(p.body.into_bytes()).to_bytes(inner)); |
| 1360 | inner = res!(Dat::Str(post::KEY_NONCE.to_string()).to_bytes(inner)); |
| 1361 | inner = res!(Dat::BU8(NONCE.to_vec()).to_bytes(inner)); |
| 1362 | for _ in 0..2 { // The duplicate. |
| 1363 | inner = res!(Dat::Str(post::KEY_TO.to_string()).to_bytes(inner)); |
| 1364 | inner = res!(Dat::BU8(TO_KEY.to_vec()).to_bytes(inner)); |
| 1365 | } |
| 1366 | map_bytes(&inner) |
| 1367 | } |
| 1368 | |
| 1369 | /// A sound post in a file whose envelope writes `tree_len` in more bytes than it needs. |
| 1370 | fn nonminimal_tree_len(author: &SignatureScheme) -> Outcome<Vec<u8>> { |
| 1371 | let bytes = res!(post_minimal().encode()); |
| 1372 | let env = res!(common::seal(&bytes, SCHEMA_POST, author, TIME)); |
| 1373 | let env_dat = res!(env.to_dat()); |
| 1374 | let map = match &env_dat { |
| 1375 | Dat::Map(m) => m, |
| 1376 | other => return Err(err!( |
| 1377 | "An envelope encodes as a map, and this is a {:?}.", other.kind(); Bug, Invalid)), |
| 1378 | }; |
| 1379 | // Every entry as the encoder writes it, except `tree_len`, which is written at a wider c64. |
| 1380 | let mut inner = Vec::new(); |
| 1381 | for (k, v) in map.iter() { |
| 1382 | inner = res!(k.to_bytes(inner)); |
| 1383 | if *k == dat!(envelope::KEY_TREE_LEN) { |
| 1384 | inner.extend_from_slice(&wide_c64(env.tree_len)); |
| 1385 | } else { |
| 1386 | inner = res!(v.to_bytes(inner)); |
| 1387 | } |
| 1388 | } |
| 1389 | let env_bytes = res!(map_bytes(&inner)); |
| 1390 | let mut buf = res!(envelope::write_header(env_bytes.len())); |
| 1391 | buf.extend_from_slice(&env_bytes); |
| 1392 | buf.extend_from_slice(&bytes); |
| 1393 | Ok(buf) |
| 1394 | } |
| 1395 | |
| 1396 | /// A BDAT map around already-encoded entries: the map code, the byte length, then the entries. |
| 1397 | /// |
| 1398 | /// Written by hand because every fixture that reaches for it is a fixture whose entries a `Dat` |
| 1399 | /// cannot hold -- a duplicate key, or a value written at a width the encoder would never choose. |
| 1400 | fn map_bytes(inner: &[u8]) -> Outcome<Vec<u8>> { |
| 1401 | let mut buf = vec![Dat::MAP_CODE]; |
| 1402 | buf = res!(Dat::C64(try_into!(u64, inner.len())).to_bytes(buf)); |
| 1403 | buf.extend_from_slice(inner); |
| 1404 | Ok(buf) |
| 1405 | } |
| 1406 | |
| 1407 | /// A `c64` written in one more byte than the value needs. |
| 1408 | /// |
| 1409 | /// A `c64` is a code byte carrying the number of value bytes that follow, so the same number has as |
| 1410 | /// many encodings as there are widths that hold it. Canonical form is the narrowest (§3); this is |
| 1411 | /// the next one up, which decodes to exactly the same number and differs only in the bytes. |
| 1412 | fn wide_c64(v: u64) -> Vec<u8> { |
| 1413 | let be = v.to_be_bytes(); |
| 1414 | // The minimal width, then one more. A zero needs no value bytes, so the wide form of it is one. |
| 1415 | let narrow = be.iter().position(|b| *b != 0).map_or(0, |i| 8 - i); |
| 1416 | // `min(8)` rather than a check: eight is the widest a c64 has, so a value already at it has no |
| 1417 | // wider form and is written as it stands. No caller reaches that, since `tree_len` is capped at |
| 1418 | // four mebibytes by §5. |
| 1419 | let width = (narrow + 1).min(8); |
| 1420 | let mut out = vec![Dat::C64_CODE_START + width as u8]; |
| 1421 | out.extend_from_slice(&be[8 - width..]); |
| 1422 | out |
| 1423 | } |
| 1424 | |
| 1425 | |
| 1426 | // ┌───────────────────────────────────────────────────────────────────────────┐ |
| 1427 | // │ THE SHARE SCHEMA │ |
| 1428 | // └───────────────────────────────────────────────────────────────────────────┘ |
| 1429 | // |
| 1430 | // `daimond/share/0` is one person sending another a COPY of something they own, and it is the |
| 1431 | // third flat record in this container. Its fixtures are here rather than among the post's because |
| 1432 | // what they are teeth for is different: a post's rules are about one message having one address, |
| 1433 | // and a share's are about that plus a consent bit, which is the one field in this format whose |
| 1434 | // whole purpose is that a receiver can check what the SENDER marked before anything runs. |
| 1435 | |
| 1436 | /// A capp's page, standing in for the real thing: what makes a share carry code is the NAME. |
| 1437 | const PAGE: &'static [u8] = b"<html><body><p>A page somebody else wrote.</p></body></html>"; |
| 1438 | |
| 1439 | /// A share of data alone, fixed so a fixture written twice is written the same. |
| 1440 | fn share_data() -> Share { |
| 1441 | Share::new( |
| 1442 | "Sourdough".to_string(), |
| 1443 | TO_KEY.to_vec(), |
| 1444 | NONCE.to_vec(), |
| 1445 | None, |
| 1446 | vec![ |
| 1447 | share::File { |
| 1448 | path: "bakes/2026.jsonl".to_string(), |
| 1449 | body: b"{\"day\":1,\"loaves\":2}\n".to_vec(), |
| 1450 | }, |
| 1451 | share::File { |
| 1452 | path: "crystal.json".to_string(), |
| 1453 | body: b"{\"starter\":\"fed Tuesday\"}".to_vec(), |
| 1454 | }, |
| 1455 | ], |
| 1456 | ) |
| 1457 | } |
| 1458 | |
| 1459 | /// The same share, carrying a page, and therefore carrying a program. |
| 1460 | fn share_capp() -> Share { |
| 1461 | let mut files = share_data().files; |
| 1462 | files.push(share::File { path: "crystal.html".to_string(), body: PAGE.to_vec() }); |
| 1463 | Share::new( |
| 1464 | "Life log".to_string(), |
| 1465 | TO_KEY.to_vec(), |
| 1466 | NONCE.to_vec(), |
| 1467 | Some("The food log we talked about.".to_string()), |
| 1468 | files, |
| 1469 | ) |
| 1470 | } |
| 1471 | |
| 1472 | /// The share fixtures, and the count of them. |
| 1473 | fn shares( |
| 1474 | root: &Path, |
| 1475 | keys: &Keys, |
| 1476 | ) |
| 1477 | -> Outcome<usize> |
| 1478 | { |
| 1479 | let author = res!(keys.author.signer()); |
| 1480 | |
| 1481 | // -- What a reader must accept. ------------------------------------------------------------- |
| 1482 | |
| 1483 | res!(accept_payload(root, &author, "share_data", &Payload::Share(share_data()), |
| 1484 | "A share of DATA alone: two files, a display name, a recipient and a nonce, and `code` \ |
| 1485 | written as false. The bit is present even though nothing here is code — an omitted false \ |
| 1486 | and a sender whose build had never heard of the field are the same bytes, and those are \ |
| 1487 | the two things a receiver must be able to tell apart. No note, and the absent one is \ |
| 1488 | omitted rather than written empty (§3 rules 4 and 8). The files are in path order, which \ |
| 1489 | is fixed, because a set of files written two ways would be one Diamond at two addresses.")); |
| 1490 | |
| 1491 | res!(accept_payload(root, &author, "share_capp", &Payload::Share(share_capp()), |
| 1492 | "A share carrying `crystal.html`, and therefore carrying a PROGRAM written by another \ |
| 1493 | person. `code` is true, it is inside the payload, and the payload is what the envelope's \ |
| 1494 | hash covers and the signature commits to — so a receiver can check that the SENDER marked \ |
| 1495 | it, which is the whole point: a flag a relay could add or strip is not a consent flag. It \ |
| 1496 | must not encode as `share_data` does, and it carries a covering note besides.")); |
| 1497 | |
| 1498 | // -- What a reader must refuse. ------------------------------------------------------------- |
| 1499 | |
| 1500 | // The central rejection of this schema. Everything else here is a canonicalisation rule; this |
| 1501 | // one is what the consent bit is for. |
| 1502 | let mut hidden = share_capp(); |
| 1503 | hidden.code = false; |
| 1504 | res!(payload_reject(root, &author, "share_code_hidden", SCHEMA_SHARE, |
| 1505 | &res!(hidden.to_dat()), |
| 1506 | "crystal.html", |
| 1507 | "The share schema: `code` is the sender's signed claim, and it is checked against the \ |
| 1508 | files.".to_string(), |
| 1509 | "A share carrying `crystal.html` under `code: false`. It is signed, correctly hashed and \ |
| 1510 | correctly addressed, so nothing in the container catches it: the payload's own decoder \ |
| 1511 | does, naming the file. Without that check the bit would be decoration — a sender could \ |
| 1512 | ship a page as data and the receiving client, believing the claim, would mount somebody \ |
| 1513 | else's program without asking. The refusal is what makes the claim worth reading." |
| 1514 | .to_string())); |
| 1515 | |
| 1516 | // And the other direction, which is not symmetry for its own sake. |
| 1517 | let mut crying = share_data(); |
| 1518 | crying.code = true; |
| 1519 | res!(payload_reject(root, &author, "share_code_claimed_without_code", SCHEMA_SHARE, |
| 1520 | &res!(crying.to_dat()), |
| 1521 | "carries none", |
| 1522 | "The share schema: `code` is checked against the files BOTH ways.".to_string(), |
| 1523 | "A share claiming code and carrying none. Refused rather than waved through as harmless \ |
| 1524 | caution: a receiver asked to consent to a program that is not there is a receiver being \ |
| 1525 | taught that the question does not mean anything, and the next time it is asked in earnest \ |
| 1526 | they will answer the same way.".to_string())); |
| 1527 | |
| 1528 | // The bit is REQUIRED. An absent one would be read as false by any reader generous enough to |
| 1529 | // default it, which is exactly the generosity a consent flag cannot afford. |
| 1530 | let no_bit = { |
| 1531 | let mut m = match res!(share_data().to_dat()) { |
| 1532 | Dat::Map(m) => m, |
| 1533 | other => return Err(err!( |
| 1534 | "A share encodes as a map, and this is a {:?}.", other.kind(); Bug, Invalid)), |
| 1535 | }; |
| 1536 | m.remove(&dat!(share::KEY_CODE)); |
| 1537 | Dat::Map(m) |
| 1538 | }; |
| 1539 | res!(payload_reject(root, &author, "share_missing_code_bit", SCHEMA_SHARE, &no_bit, |
| 1540 | "missing the required key", |
| 1541 | "The share schema: `code` is required, and is written even when it is false.".to_string(), |
| 1542 | "A share with no `code` key at all. A reader that defaulted it to false would be reading \ |
| 1543 | \"they did not say\" as \"they said there is nothing to worry about\", which is the one \ |
| 1544 | reading a consent bit must never be given.".to_string())); |
| 1545 | |
| 1546 | // One Diamond, two addresses: the rule a message's `refs` deliberately does not have, because |
| 1547 | // references are ordered by their author's meaning and a set of files is not. |
| 1548 | let unsorted = { |
| 1549 | let s = share_data(); |
| 1550 | let mut m = match res!(s.to_dat()) { |
| 1551 | Dat::Map(m) => m, |
| 1552 | other => return Err(err!( |
| 1553 | "A share encodes as a map, and this is a {:?}.", other.kind(); Bug, Invalid)), |
| 1554 | }; |
| 1555 | let mut list = Vec::new(); |
| 1556 | for f in s.files.iter().rev() { |
| 1557 | list.push(res!(f.to_dat())); |
| 1558 | } |
| 1559 | m.insert(dat!(share::KEY_FILES), Dat::List(list)); |
| 1560 | Dat::Map(m) |
| 1561 | }; |
| 1562 | res!(payload_reject(root, &author, "share_files_out_of_order", SCHEMA_SHARE, &unsorted, |
| 1563 | "not in path order", |
| 1564 | "The share schema: the files are ordered by path, so that one set of files has one \ |
| 1565 | encoding.".to_string(), |
| 1566 | "The same two files, listed the other way round. They decode to the same Diamond and hash \ |
| 1567 | to a different address, so admitting both would give one share two addresses. It is \ |
| 1568 | refused rather than sorted: sorting it would be accepting a second encoding and quietly \ |
| 1569 | rewriting it, which is what §3 exists to stop.".to_string())); |
| 1570 | |
| 1571 | // The three paths a share may not carry, one fixture each for the two that are about somebody |
| 1572 | // else's records. |
| 1573 | res!(payload_reject(root, &author, "share_carries_the_log", SCHEMA_SHARE, |
| 1574 | &res!(share_with_path(".daimond/log.jsonl")), |
| 1575 | ".daimond/", |
| 1576 | "The share schema: a share may not carry the sender's own `.daimond/` record." |
| 1577 | .to_string(), |
| 1578 | "A share carrying the sender's append-only log — the record of what agents did in THEIR \ |
| 1579 | copy. Refused in the format rather than in a client, so that every implementation refuses \ |
| 1580 | it: a person sending a recipe does not think to check what travels with it, and the \ |
| 1581 | receiver's copy is new, so its record starts empty because nothing has happened in it yet." |
| 1582 | .to_string())); |
| 1583 | |
| 1584 | res!(payload_reject(root, &author, "share_carries_capp_record", SCHEMA_SHARE, |
| 1585 | &res!(share_with_path("capp.json")), |
| 1586 | "capp.json", |
| 1587 | "The share schema: a share may not carry a capp delivery record.".to_string(), |
| 1588 | "A share carrying `capp.json`, which says which bytes were delivered to that instance and \ |
| 1589 | at what template version, and decides which files a future fix may replace. The receiver \ |
| 1590 | was never delivered to; they were handed a copy by a person. One carried across from \ |
| 1591 | somebody else's machine would pin their copy against updates they never chose, and a \ |
| 1592 | doctored one would do it on purpose. A copy with no record is a case the receiving client \ |
| 1593 | already knows: it asks.".to_string())); |
| 1594 | |
| 1595 | res!(payload_reject(root, &author, "share_path_walks", SCHEMA_SHARE, |
| 1596 | &res!(share_with_path("../../notes/private.md")), |
| 1597 | "segment", |
| 1598 | "The share schema: a path is refused rather than resolved, and never walks." |
| 1599 | .to_string(), |
| 1600 | "A share whose file path climbs out of the Diamond. Refused rather than normalised, which \ |
| 1601 | is where this parts company with the client-side path guard it otherwise matches: that one \ |
| 1602 | is handed an untrusted request and tidies it on the way to a real file, and this is \ |
| 1603 | deciding what a SIGNED artefact means, where a path that needed tidying is a path with two \ |
| 1604 | spellings.".to_string())); |
| 1605 | |
| 1606 | // The re-labelling attack over the third schema. The reserved name became a real one, and this |
| 1607 | // is the fixture that shows nothing already signed was weakened by it. |
| 1608 | let bytes = res!(share_data().encode()); |
| 1609 | let mut env = res!(common::seal(&bytes, SCHEMA_SHARE, &author, TIME)); |
| 1610 | env.schema = SCHEMA_POST.to_string(); |
| 1611 | res!(reject(root, "share_relabelled_as_post", &res!(common::assemble(&env, &bytes)), None, |
| 1612 | Reject { |
| 1613 | stage: Stage::Sig, |
| 1614 | rule: "SPEC.md §1.3: the schema is inside the signing input, and is preceded by its \ |
| 1615 | length.".to_string(), |
| 1616 | says: "not a signature by the author".to_string(), |
| 1617 | node: None, |
| 1618 | offset: None, |
| 1619 | note: "A signed share whose envelope has been re-labelled `daimond/post/0` after \ |
| 1620 | signing. The payload bytes, their hash and the signature over that hash are \ |
| 1621 | untouched. This is the same claim as `post_relabelled_as_card`, made over the \ |
| 1622 | schema name that was RESERVED when those were signed: the schema reaches the \ |
| 1623 | signing input length-prefixed, so a third name coming to exist re-addressed \ |
| 1624 | nothing, weakened nothing, and left every fixture already committed byte for byte \ |
| 1625 | the file it was.".to_string(), |
| 1626 | })); |
| 1627 | |
| 1628 | Ok(10) |
| 1629 | } |
| 1630 | |
| 1631 | /// A share whose one file sits at `path`, for the fixtures about paths a share may not carry. |
| 1632 | /// |
| 1633 | /// Built as a daticle rather than through `Share::new`, because the point of each is a path the |
| 1634 | /// constructor's own validator would refuse, and a fixture that could not be written would prove |
| 1635 | /// nothing about what a reader does with one that was. |
| 1636 | fn share_with_path(path: &str) -> Outcome<Dat> { |
| 1637 | let mut file = DaticleMap::new(); |
| 1638 | file.insert(dat!(share::KEY_BODY), Dat::BU32(b"whatever is in it".to_vec())); |
| 1639 | file.insert(dat!(share::KEY_PATH), Dat::Str(path.to_string())); |
| 1640 | |
| 1641 | let mut m = DaticleMap::new(); |
| 1642 | m.insert(dat!(share::KEY_CODE), Dat::Bool(false)); |
| 1643 | m.insert(dat!(share::KEY_FILES), Dat::List(vec![Dat::Map(file)])); |
| 1644 | m.insert(dat!(share::KEY_NAME), Dat::Str("A share reaching too far".to_string())); |
| 1645 | m.insert(dat!(share::KEY_NONCE), Dat::BU8(NONCE.to_vec())); |
| 1646 | m.insert(dat!(share::KEY_TO), Dat::BU8(TO_KEY.to_vec())); |
| 1647 | Ok(Dat::Map(m)) |
| 1648 | } |