Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_sbj/tests/conformance.rs

19.3 KiB, 45 runs

created by r1870400018:22234, 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//! The conformance suite of `SPEC.md` §7.
2//!
3//! The fixtures are the specification's teeth. They are what stops the binary quietly becoming the
4//! definition of the format: every acceptance fixture is rebuilt here from its JDAT text and the
5//! committed key, and the bytes that come out must be the bytes that were committed, so a change to
6//! the encoder that nobody meant shows up as a different file rather than as nothing at all.
7//!
8//! Every rejection fixture declares what must go wrong with it, and where: the rule broken, the step
9//! of §2 that must catch it, what the error must say, and the node or the byte it must name. A
10//! reader that refused everything would pass a suite that only checked for an `Err`, so this checks
11//! the reason. It also checks the ordering that §2 exists for: a fixture refused at step 6 or 7 must
12//! pass steps 1 to 5 first, and a fixture refused before that must never reach a decoder at all.
13//!
14//! A fixture directory the suite does not know how to run is a failure rather than a skip, so a
15//! fixture cannot be added and silently ignored, and the fixtures §7 requires by name are required
16//! here by name, so one cannot be deleted and silently missed.
17
18mod common;
19
20use common::{
21 Keys,
22 Meta,
23 Reject,
24 DOC_JDAT,
25 DOC_SBJ,
26 KEY_FILE,
27 META_JDAT,
28 README_FILE,
29 REJECT_JDAT,
30};
31
32use oxedyne_fe2o3_sbj::{
33 card::Card,
34 doc::{
35 self,
36 Payload,
37 },
38 envelope,
39 index,
40 post::Post,
41 share::Share,
42 validate,
43 HEADER_LEN,
44 SCHEMA_CARD,
45 SCHEMA_POST,
46 SCHEMA_SHARE,
47};
48
49use oxedyne_fe2o3_core::prelude::*;
50use oxedyne_fe2o3_crypto::sign::SignatureScheme;
51use oxedyne_fe2o3_jdat::prelude::*;
52
53use std::{
54 collections::BTreeSet,
55 fs,
56 path::Path,
57};
58
59/// The fixtures `SPEC.md` §7 requires by name.
60///
61/// A fixture that is missing is a failure, so that the suite cannot be quietly hollowed out by
62/// deleting the ones that fail.
63const REQUIRED: [&'static str; 30] = [
64 // The list at the end of §7, in its order.
65 "empty", // An empty document.
66 "one_para", // One paragraph.
67 "every_kind", // Every node kind once.
68 "depth_at_limit", // Nesting at the depth limit,
69 "depth_over_limit", // and one past it.
70 "size_at_limit", // A tree at the size limit,
71 "size_over_limit", // and one past it.
72 "canon_rule1_undeclared_field", // Each canonicalisation rule of §3,
73 "canon_rule2_ordmap", // violated
74 "canon_rule3_uppercase_key", // exactly
75 "canon_rule3_duplicate_key", // once.
76 "canon_rule4_empty_children",
77 "canon_rule4_opt_none",
78 "canon_rule5_control_char",
79 "canon_rule6_int_width",
80 "canon_rule7_vek_children",
81 "truncated_tree", // A truncated tree.
82 "tree_longer_than_tree_len", // A tree one byte longer than tree_len.
83 "corrupt_tree_byte", // A corrupted hash, from the tree's side,
84 "bad_hash", // and from the envelope's.
85 "bad_sig", // A corrupted signature.
86 "wrong_key", // A signature by the wrong key.
87 "unknown_kind", // An unknown node kind.
88 "reserved_edit_in_doc", // A document carrying an edit node,
89 "reserved_surface_in_doc", // one carrying a surface node,
90 "reserved_surface_with_fallback_still_refused", // and one whose surface carries a fallback.
91 "para_in_para", // A forbidden child.
92 "heading_level_0", // A heading with level 0,
93 "heading_level_7", // and one with level 7.
94 "bdat_not_sbj", // Valid BDAT, and not valid SBJ.
95];
96
97#[test]
98fn test_conformance_suite() -> Outcome<()> {
99 // A node costs three daticle levels, so the deepest legal document nests daticles 770 deep, and
100 // a recursive decoder spends a frame on each. The limit is the format's, and it does not move to
101 // suit a test, so the test moves instead.
102 let thread = match std::thread::Builder::new()
103 .name("sbj_conformance".to_string())
104 .stack_size(common::STACK_BYTES)
105 .spawn(suite)
106 {
107 Ok(thread) => thread,
108 Err(e) => return Err(err!(e,
109 "Could not spawn the thread the deepest fixture is read on.";
110 Test, Init)),
111 };
112 match thread.join() {
113 Ok(outcome) => outcome,
114 Err(_) => Err(err!(
115 "The thread running the conformance suite did not return.";
116 Test, Panic)),
117 }
118}
119
120/// Walks the fixture directory and runs everything in it.
121fn suite() -> Outcome<()> {
122
123 let root = common::fixtures_dir();
124 if !root.is_dir() {
125 return Err(err!(
126 "There is no fixture directory at {}. The fixtures are written by \
127 `cargo run -p sbj --example gen_fixtures`.", root.display();
128 Test, Missing));
129 }
130 let keys = res!(Keys::load(&root));
131
132 let mut names: BTreeSet<String> = BTreeSet::new();
133 for entry in res!(fs::read_dir(&root), IO, File) {
134 let entry = res!(entry, IO, File);
135 let path = entry.path();
136 let name = match path.file_name().and_then(|s| s.to_str()) {
137 Some(name) => name.to_string(),
138 None => return Err(err!(
139 "The fixture directory holds {}, whose name is not UTF-8.", path.display();
140 Test, Invalid)),
141 };
142 if path.is_file() {
143 // The only files beside the fixtures are the key they are signed with and the note
144 // saying what they are. Anything else is a fixture nobody runs.
145 if name != KEY_FILE && name != README_FILE {
146 return Err(err!(
147 "The fixture directory holds the file '{}', which is neither the key '{}' nor \
148 the note '{}'. A file the suite does not know what to do with is a failure, \
149 not a skip.", name, KEY_FILE, README_FILE;
150 Test, Invalid, Unexpected));
151 }
152 continue;
153 }
154 if !path.is_dir() {
155 return Err(err!(
156 "The fixture directory holds '{}', which is neither a file nor a directory.", name;
157 Test, Invalid, Unexpected));
158 }
159 res!(fixture(&path, &name, &keys));
160 names.insert(name);
161 }
162
163 if names.is_empty() {
164 return Err(err!(
165 "The fixture directory {} holds no fixtures.", root.display();
166 Test, Missing));
167 }
168 for req in REQUIRED {
169 if !names.contains(req) {
170 return Err(err!(
171 "The fixture '{}', which SPEC.md §7 requires, is not in {}.", req, root.display();
172 Test, Missing));
173 }
174 }
175 Ok(())
176}
177
178/// Runs one fixture, refusing to skip one it does not understand.
179fn fixture(
180 dir: &Path,
181 name: &str,
182 keys: &Keys,
183)
184 -> Outcome<()>
185{
186 // Every file of a fixture is one of four, so that a fixture cannot carry something the suite
187 // silently ignores.
188 for entry in res!(fs::read_dir(dir), IO, File) {
189 let entry = res!(entry, IO, File);
190 let file = match entry.file_name().to_str() {
191 Some(file) => file.to_string(),
192 None => return Err(err!(
193 "The fixture '{}' holds a file whose name is not UTF-8.", name;
194 Test, Invalid)),
195 };
196 match file.as_str() {
197 DOC_JDAT | DOC_SBJ | META_JDAT | REJECT_JDAT => (),
198 _ => return Err(err!(
199 "The fixture '{}' holds the file '{}'. A fixture carries '{}', and then either \
200 '{}' or '{}'.", name, file, DOC_SBJ, META_JDAT, REJECT_JDAT;
201 Test, Invalid, Unexpected)),
202 }
203 }
204
205 let has_meta = dir.join(META_JDAT).is_file();
206 let has_reject = dir.join(REJECT_JDAT).is_file();
207 if !dir.join(DOC_SBJ).is_file() {
208 return Err(err!(
209 "The fixture '{}' carries no '{}'. Every fixture is an artefact, whether it is one a \
210 reader must accept or one it must refuse.", name, DOC_SBJ;
211 Test, Missing));
212 }
213 match (has_meta, has_reject) {
214 (true, false) => accept(dir, name, keys),
215 (false, true) => reject(dir, name),
216 (true, true) => Err(err!(
217 "The fixture '{}' carries both '{}' and '{}'. A document is either accepted or \
218 refused.", name, META_JDAT, REJECT_JDAT;
219 Test, Invalid, Conflict)),
220 (false, false) => Err(err!(
221 "The suite does not know how to run the fixture '{}': it carries neither '{}' nor \
222 '{}'. A fixture the suite cannot run is a failure, not a skip, so that a fixture \
223 cannot be added and quietly ignored.", name, META_JDAT, REJECT_JDAT;
224 Test, Invalid, Unknown)),
225 }
226}
227
228/// Runs an acceptance fixture: the artefact reads, and it is what `meta.jdat` says it is.
229///
230/// The artefact is then rebuilt from `doc.jdat` and the committed key, and must come out byte for
231/// byte the file that was committed. That is what makes `doc.jdat` the source of truth rather than a
232/// comment: a change to the encoder that nobody meant shows up here as a different file.
233fn accept(
234 dir: &Path,
235 name: &str,
236 keys: &Keys,
237)
238 -> Outcome<()>
239{
240 let buf = res!(common::read_bytes(&dir.join(DOC_SBJ)));
241 let meta = res!(Meta::from_dat(&res!(common::from_jdat_plain(
242 &res!(common::read_text(&dir.join(META_JDAT)))
243 ))));
244
245 // Steps 1 to 5 touch no content, and a caller may run them and stop.
246 let env = match doc::verify_only(&buf) {
247 Ok(env) => env,
248 Err(e) => return Err(err!(e,
249 "The fixture '{}' does not verify.", name;
250 Test, Invalid)),
251 };
252 let art = match doc::read_artefact(&buf) {
253 Ok(art) => art,
254 Err(e) => return Err(err!(e,
255 "The fixture '{}' does not read.", name;
256 Test, Invalid)),
257 };
258 res!(req(name, "the envelope of verify_only is the envelope of read", &env, art.env()));
259
260 // The envelope says what `meta.jdat` says it says.
261 res!(req(name, "schema", &art.env().schema, &meta.schema));
262 res!(req(name, "time", &art.env().time, &meta.time));
263 res!(req(name, "hash", &art.env().hash, &meta.hash));
264 res!(req(name, "tree_len", &art.env().tree_len, &meta.tree_len));
265 res!(req(name, "author", &art.env().author, &keys.author.pk));
266
267 // The hash in the envelope is the hash of the region, and the region is the length declared.
268 let (_, region) = res!(doc::verify(&buf));
269 res!(req(name, "tree region length", &try_into!(u64, region.len()), &meta.tree_len));
270 let hash = res!(doc::hash_tree(art.env().hash_scheme, region));
271 res!(req(name, "the hash of the payload region", &hash, &meta.hash));
272
273 // `doc.jdat` is the payload, and the payload is `doc.sbj`. What the payload IS decides how it
274 // is read back and how it is rebuilt: a node tree carries `usr` nodes and is written by
275 // `doc::write`, while a post and a card are flat maps with no node labels in them at all.
276 let signer = res!(keys.author.signer());
277 let rebuilt = match art.payload() {
278 Payload::Tree { tree, .. } => {
279 // The tree is the shape `meta.jdat` says it is.
280 let stats = res!(validate::validate(tree, &art.env().schema));
281 res!(req(name, "node count", &Some(try_into!(u64, stats.nodes)), &meta.nodes));
282 res!(req(name, "depth", &Some(try_into!(u64, stats.depth)), &meta.depth));
283 let from_text = res!(read_tree(&dir.join(DOC_JDAT), name));
284 res!(req(name, "the tree of doc.jdat against the tree of doc.sbj", &from_text, tree));
285 res!(rewrite(&from_text, &meta, &signer))
286 },
287 Payload::Post(post) => {
288 res!(req(name, "node count", &None, &meta.nodes));
289 let from_text = res!(read_payload(&dir.join(DOC_JDAT), name));
290 let back = res!(Post::from_dat(&from_text));
291 res!(req(name, "the post of doc.jdat against the post of doc.sbj", &back, post));
292 res!(doc::write_artefact(&Payload::Post(back), &signer, meta.time))
293 },
294 Payload::Card(card) => {
295 res!(req(name, "node count", &None, &meta.nodes));
296 let from_text = res!(read_payload(&dir.join(DOC_JDAT), name));
297 let back = res!(Card::from_dat(&from_text));
298 res!(req(name, "the card of doc.jdat against the card of doc.sbj", &back, card));
299 res!(doc::write_artefact(&Payload::Card(back), &signer, meta.time))
300 },
301 Payload::Share(share) => {
302 res!(req(name, "node count", &None, &meta.nodes));
303 let from_text = res!(read_payload(&dir.join(DOC_JDAT), name));
304 let back = res!(Share::from_dat(&from_text));
305 res!(req(name, "the share of doc.jdat against the share of doc.sbj", &back, share));
306 res!(doc::write_artefact(&Payload::Share(back), &signer, meta.time))
307 },
308 };
309 if rebuilt != buf {
310 return Err(err!(
311 "The fixture '{}' does not rebuild: writing the document of '{}' with the committed \
312 key gives {} bytes, and the committed '{}' is {} bytes{}. A document written twice is \
313 the same document, so either the encoder has changed or the fixture is stale; \
314 regenerate the fixtures if the change was meant.",
315 name, DOC_JDAT, rebuilt.len(), DOC_SBJ, buf.len(),
316 match common::first_diff(&rebuilt, &buf) {
317 Some(at) => fmt!(", first differing at byte {}", at),
318 None => String::new(),
319 };
320 Test, Invalid, Mismatch));
321 }
322
323 // The index of §1.4 is derived data lying outside the hash, and is never trusted: whatever it
324 // says is checked against the tree it claims to describe.
325 let rest = res!(doc::index_region(&buf));
326 if meta.index {
327 if rest.is_empty() {
328 return Err(err!(
329 "The fixture '{}' declares an index, and carries none.", name;
330 Test, Missing));
331 }
332 let idx = res!(index::parse(rest));
333 res!(index::check(region, &idx));
334 res!(req(name, "the entries of the index", &Some(try_into!(u64, idx.len())), &meta.nodes));
335 } else if !rest.is_empty() {
336 return Err(err!(
337 "The fixture '{}' declares no index, and carries {} trailing bytes.",
338 name, rest.len();
339 Test, Invalid, Unexpected));
340 }
341 Ok(())
342}
343
344/// Writes a document as its fixture declares it was written.
345fn rewrite(
346 tree: &Dat,
347 meta: &Meta,
348 signer: &SignatureScheme,
349)
350 -> Outcome<Vec<u8>>
351{
352 if meta.index {
353 doc::write_with_index(tree, &meta.schema, signer, meta.time)
354 } else {
355 doc::write(tree, &meta.schema, signer, meta.time)
356 }
357}
358
359/// Runs a rejection fixture: the artefact is refused, at the step declared, for the reason declared.
360///
361/// "It was rejected" is not the claim, since a reader that refused every document would satisfy it.
362/// The claim is that the rule named in `reject.jdat` is the rule that caught it, that the failure
363/// names the node or the byte it declares, and that it happened at the step of §2 it declares, which
364/// is what holds the implementation to verifying before it parses.
365fn reject(
366 dir: &Path,
367 name: &str,
368)
369 -> Outcome<()>
370{
371 let buf = res!(common::read_bytes(&dir.join(DOC_SBJ)));
372 let dec = res!(Reject::from_dat(&res!(common::from_jdat_plain(
373 &res!(common::read_text(&dir.join(REJECT_JDAT)))
374 ))));
375
376 // A fixture whose content is wrong must verify first: steps 1 to 5 touch no content, and a
377 // document that fails them is never decoded. A fixture whose container is wrong must fail them.
378 match (dec.stage.verifies(), doc::verify_only(&buf)) {
379 (true, Err(e)) => return Err(err!(e,
380 "The fixture '{}' declares that it is refused at the '{}' step of SPEC.md §2, which \
381 comes after verification, but it does not verify. Either the fixture is wrong about \
382 what is wrong with it, or the reader is refusing it for a reason that is not the \
383 fixture's.", name, dec.stage.label();
384 Test, Invalid)),
385 (false, Ok(_)) => return Err(err!(
386 "The fixture '{}' declares that it is refused at the '{}' step of SPEC.md §2, which is \
387 one of the steps that touch no content, and it verified. A document that fails \
388 verification must never be decoded, so a reader that verified this one has already \
389 gone further than the format allows.", name, dec.stage.label();
390 Test, Invalid)),
391 _ => (),
392 }
393
394 let msg = match doc::read_artefact(&buf) {
395 Ok(_) => return Err(err!(
396 "The fixture '{}' was read. It breaks {} A document that fails at any step renders as \
397 an error card and is never partially displayed.", name, dec.rule;
398 Test, Invalid)),
399 Err(e) => fmt!("{}", e),
400 };
401
402 // SPEC.md §6: every rejection names the failing thing and the rule broken. "Invalid document" is
403 // not an error message.
404 if !msg.contains(&dec.says) {
405 return Err(err!(
406 "The fixture '{}' was refused, and not for the reason it declares. It breaks {} The \
407 rejection must say '{}', and says: {}", name, dec.rule, dec.says, msg;
408 Test, Invalid, Mismatch));
409 }
410 if let Some(id) = dec.node {
411 let names_it = fmt!("Node {}", id);
412 if !msg.contains(&names_it) {
413 return Err(err!(
414 "The fixture '{}' was refused for the right reason, and did not name the node it \
415 is: SPEC.md §6 requires the rejection to name '{}', and it says: {}",
416 name, names_it, msg;
417 Test, Invalid, Mismatch));
418 }
419 }
420 if let Some(off) = dec.offset {
421 let names_it = fmt!("byte {}", off);
422 if !msg.contains(&names_it) {
423 return Err(err!(
424 "The fixture '{}' was refused for the right reason, and did not name the byte it \
425 is at: SPEC.md §6 requires the rejection to name '{}', and it says: {}",
426 name, names_it, msg;
427 Test, Invalid, Mismatch));
428 }
429 }
430
431 // Where the fault is in the tree rather than in the bytes, the fixture carries the tree, and the
432 // tree it carries is the one in the artefact.
433 let jdat = dir.join(DOC_JDAT);
434 if jdat.is_file() {
435 // A node tree is written with the `sbj_` node labels and a record without them, so which
436 // codec reads it back is decided by what the envelope says the payload is -- read here
437 // WITHOUT verifying, since a fixture that fails at the header has no readable envelope and
438 // carries no `doc.jdat` either.
439 let payload = match envelope_schema(&buf) {
440 Some(schema) if schema == SCHEMA_POST || schema == SCHEMA_CARD
441 || schema == SCHEMA_SHARE =>
442 res!(read_payload(&jdat, name)),
443 _ => res!(read_tree(&jdat, name)),
444 };
445 let bytes = res!(common::encode_unchecked(&payload));
446 let start = res!(common::tree_start(&buf));
447 if bytes.as_slice() != &buf[start..] {
448 return Err(err!(
449 "The fixture '{}' carries a '{}' that is not the tree region of its '{}': the tree \
450 encodes to {} bytes and the region is {} bytes{}.",
451 name, DOC_JDAT, DOC_SBJ, bytes.len(), buf.len() - start,
452 match common::first_diff(&bytes, &buf[start..]) {
453 Some(at) => fmt!(", first differing at byte {}", at),
454 None => String::new(),
455 };
456 Test, Invalid, Mismatch));
457 }
458 }
459 Ok(())
460}
461
462/// Reads the flat payload a fixture's `doc.jdat` carries, for a schema that is not a node tree.
463///
464/// Plain JDAT, with none of the `sbj_` node labels: a post and a card carry no `usr` daticles, so
465/// the codec that knows the node vocabulary has nothing to do here and reading with it would only
466/// make a fixture depend on a table it does not use.
467fn read_payload(
468 path: &Path,
469 name: &str,
470)
471 -> Outcome<Dat>
472{
473 let s = res!(common::read_text(path));
474 match common::from_jdat_plain(&s) {
475 Ok(d) => Ok(d),
476 Err(e) => Err(err!(e,
477 "The '{}' of the fixture '{}' is not readable JDAT.", DOC_JDAT, name;
478 Test, Invalid)),
479 }
480}
481
482/// The schema a file's envelope declares, or `None` if the file has no readable envelope.
483///
484/// Deliberately UNVERIFIED, and used for nothing but choosing which text codec reads a fixture's
485/// `doc.jdat`. A rejection fixture is a file with something wrong with it, so the envelope may be
486/// the wrong thing about it; nothing here believes what it says beyond picking a reader.
487fn envelope_schema(buf: &[u8]) -> Option<String> {
488 let hdr = match envelope::read_header(buf) {
489 Ok(h) => h,
490 Err(_) => return None,
491 };
492 let end = HEADER_LEN + hdr.env_len as usize;
493 if buf.len() < end {
494 return None;
495 }
496 match envelope::Envelope::decode(&buf[HEADER_LEN..end]) {
497 Ok(env) => Some(env.schema),
498 Err(_) => None,
499 }
500}
501
502/// Reads the tree a fixture's `doc.jdat` carries, naming the fixture if it will not read.
503fn read_tree(
504 path: &Path,
505 name: &str,
506)
507 -> Outcome<Dat>
508{
509 let s = res!(common::read_text(path));
510 match common::from_jdat(&s) {
511 Ok(tree) => Ok(tree),
512 Err(e) => Err(err!(e,
513 "The '{}' of the fixture '{}' is not readable JDAT.", DOC_JDAT, name;
514 Test, Invalid)),
515 }
516}
517
518/// Requires two things to be equal, naming the fixture and what was compared.
519///
520/// A fixture that fails says which fixture, what was compared, what was declared and what was found,
521/// since a suite that reports "assertion failed" of a fixture nobody named is as much use as the
522/// error message §6 forbids.
523fn req<T: PartialEq + std::fmt::Debug>(
524 name: &str,
525 what: &str,
526 got: &T,
527 want: &T,
528)
529 -> Outcome<()>
530{
531 if got == want {
532 Ok(())
533 } else {
534 Err(err!(
535 "The fixture '{}' declares {} to be {:?}, and it is {:?}.", name, what, want, got;
536 Test, Invalid, Mismatch))
537 }
538}