oxedyne/fe2o3/fe2o3_pearlite/tests/collab_roundtrip.rs
9.1 KiB, 74 runs
created by r1870400018:58606, 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 collaboration backend end to end: two different authors each seal an annotation operation with |
| 2 | //! a real P-256 key, both are stored in an o3db hub under the document's identity, and a scan then a |
| 3 | //! fold reproduces both annotations in deterministic order and materialises them into a local `.prl`. |
| 4 | //! Both signatures verify, a tampered envelope does not, the displayed author comes from the signer by |
| 5 | //! way of the document's keyring rather than from the body, and a `.prl` written without a doc_id still |
| 6 | //! reads -- the back-compatibility the additive header field promises. |
| 7 | |
| 8 | use oxedyne_fe2o3_austenite::{ |
| 9 | emit::pearl::{ |
| 10 | Annotation, |
| 11 | AnnotationKind, |
| 12 | PearlBuilder, |
| 13 | PearlDoc, |
| 14 | }, |
| 15 | ir::{ |
| 16 | Dims, |
| 17 | DrawOp, |
| 18 | Graphic, |
| 19 | Sp, |
| 20 | }, |
| 21 | ledger::Ledger, |
| 22 | page::{ |
| 23 | Frame, |
| 24 | Page, |
| 25 | PageGeometry, |
| 26 | Placed, |
| 27 | PlacedKind, |
| 28 | }, |
| 29 | }; |
| 30 | use oxedyne_fe2o3_core::{ |
| 31 | prelude::*, |
| 32 | rand::Rand, |
| 33 | }; |
| 34 | use oxedyne_fe2o3_crypto::enc::EncryptionScheme; |
| 35 | use oxedyne_fe2o3_graphics::{ |
| 36 | colour::Rgba, |
| 37 | path::{ |
| 38 | Bounds, |
| 39 | Path, |
| 40 | }, |
| 41 | }; |
| 42 | use oxedyne_fe2o3_hash::{ |
| 43 | csum::ChecksumScheme, |
| 44 | hash::HashScheme, |
| 45 | }; |
| 46 | use oxedyne_fe2o3_jdat::prelude::*; |
| 47 | use oxedyne_fe2o3_net::ecdsa::P256KeyPair; |
| 48 | use oxedyne_fe2o3_o3db_sync::{ |
| 49 | data::core::RestSchemesInput, |
| 50 | test::setup, |
| 51 | }; |
| 52 | use oxedyne_fe2o3_ore::{ |
| 53 | envelope::Envelope, |
| 54 | id::OpId, |
| 55 | op::Record, |
| 56 | }; |
| 57 | use oxedyne_fe2o3_pearlite::collab::{ |
| 58 | fold, |
| 59 | hub::{ |
| 60 | Hub, |
| 61 | O3dbHub, |
| 62 | }, |
| 63 | op, |
| 64 | replica_of, |
| 65 | sign, |
| 66 | DocId, |
| 67 | Keyring, |
| 68 | }; |
| 69 | |
| 70 | use std::sync::Arc; |
| 71 | |
| 72 | |
| 73 | /// A one-graphic page whose block hash is distinct per page number, so a two-page document has two |
| 74 | /// real anchors to attach annotations to. |
| 75 | fn dot_page(number: u32, geom: PageGeometry) -> Outcome<Page> { |
| 76 | let fill = res!(Path::rect(Bounds::new(0.0, 0.0, 20.0, 20.0))); |
| 77 | let graphic = Graphic::new( |
| 78 | vec![DrawOp::Fill { path: fill, colour: Rgba::BLACK }], |
| 79 | Dims::new(Sp::from_pt(20.0), Sp::from_pt(20.0), Sp::ZERO)); |
| 80 | let mut frame = Frame::new(); |
| 81 | frame.push(Placed::new( |
| 82 | Sp::from_pt(40.0), Sp::from_pt(40.0), graphic.dims, PlacedKind::Graphic(Arc::new(graphic)))); |
| 83 | Ok(Page::new(number, geom, frame)) |
| 84 | } |
| 85 | |
| 86 | /// Seals one author's creation of an annotation: an Ore proposal, identified under the replica the |
| 87 | /// author's own key derives, signed with a real P-256 key in the encodings a browser's WebCrypto key |
| 88 | /// uses, into an envelope the hub can store. |
| 89 | fn sealed_create( |
| 90 | doc: &DocId, |
| 91 | key: &P256KeyPair, |
| 92 | counter: u64, |
| 93 | ann: &Annotation, |
| 94 | time: u64, |
| 95 | ) |
| 96 | -> Outcome<Envelope> |
| 97 | { |
| 98 | let op = res!(op::create(doc, ann, time)); |
| 99 | let replica = replica_of(&key.public_key(), doc); |
| 100 | let rec = Record::root(OpId::new(replica, counter), op); |
| 101 | let sig = res!(key.sign(&res!(sign::signing_bytes(&rec)))); |
| 102 | sign::seal(&rec, key.public_key(), sig, doc) |
| 103 | } |
| 104 | |
| 105 | #[test] |
| 106 | fn collaboration_backend_round_trips_two_authors_through_the_hub() -> Outcome<()> { |
| 107 | let geom = PageGeometry::a4(); |
| 108 | |
| 109 | // A two-page document with a stable identity, and its two real block hashes. |
| 110 | let mut builder = res!(PearlBuilder::new(&Ledger::new(), geom)).with_doc_id("prl-collab-test"); |
| 111 | res!(builder.add_page(&res!(dot_page(1, geom)))); |
| 112 | res!(builder.add_page(&res!(dot_page(2, geom)))); |
| 113 | let prl_text = res!(builder.to_string()); |
| 114 | |
| 115 | let doc = res!(PearlDoc::from_string(prl_text.clone())); |
| 116 | let doc_id = DocId::new(res!(res!(doc.doc_id()).ok_or_else(|| err!( |
| 117 | "the test document was built with a doc_id but reports none"; Test, Missing)))); |
| 118 | let hashes = res!(doc.block_hashes()); |
| 119 | assert_eq!(hashes.len(), 2, "a two-page document has two block hashes to anchor to"); |
| 120 | |
| 121 | // Two different authors, each annotating a different page. |
| 122 | let key_a = res!(P256KeyPair::generate()); |
| 123 | let key_b = res!(P256KeyPair::generate()); |
| 124 | assert_ne!(key_a.public_key(), key_b.public_key(), "the two authors are distinct signers"); |
| 125 | |
| 126 | // The bodies claim friendly author strings, but the fold takes the author from the signer via the |
| 127 | // keyring, so what the body says is only advisory. |
| 128 | let ann_a = Annotation::new( |
| 129 | hashes[0].as_str(), AnnotationKind::Highlight, "author A on page one", "whoever-a-claims", |
| 130 | "2026-09-20T10:00:00Z"); |
| 131 | let ann_b = Annotation::new( |
| 132 | hashes[1].as_str(), AnnotationKind::Note, "author B on page two", "whoever-b-claims", |
| 133 | "2026-09-20T11:00:00Z"); |
| 134 | |
| 135 | let mut keyring = Keyring::new(); |
| 136 | keyring.insert(&key_a.public_key(), "author-a"); |
| 137 | keyring.insert(&key_b.public_key(), "author-b"); |
| 138 | |
| 139 | // Author A's operation carries the earlier clock reading, so a deterministic fold must place it |
| 140 | // first whatever order the store hands the two back in. |
| 141 | let env_a = res!(sealed_create(&doc_id, &key_a, 1, &ann_a, 1000)); |
| 142 | let env_b = res!(sealed_create(&doc_id, &key_b, 1, &ann_b, 1001)); |
| 143 | |
| 144 | // A signature presented against the wrong author's key is refused at the seal, so nothing |
| 145 | // unattributable is ever built. |
| 146 | let rec_a = Record::root( |
| 147 | OpId::new(replica_of(&key_a.public_key(), &doc_id), 1), res!(op::create(&doc_id, &ann_a, 1000))); |
| 148 | let cross_sig = res!(key_a.sign(&res!(sign::signing_bytes(&rec_a)))); |
| 149 | assert!(sign::seal(&rec_a, key_b.public_key(), cross_sig, &doc_id).is_err(), |
| 150 | "a record signed by A but presented with B's public key must not seal"); |
| 151 | |
| 152 | // Start a throwaway o3db instance under a process-unique root, with every zone inside it. |
| 153 | let root = std::env::temp_dir().join(fmt!("pearlite_collab_hub_{}", std::process::id())); |
| 154 | let db_root = root.join("db"); |
| 155 | res!(std::fs::create_dir_all(&db_root)); |
| 156 | |
| 157 | let mut enckey = [0u8; 32]; |
| 158 | Rand::fill_u8(&mut enckey); |
| 159 | let aes = res!(EncryptionScheme::new_aes_256_gcm_with_key(&enckey[..])); |
| 160 | let crc = ChecksumScheme::new_crc32(); |
| 161 | let schms_input = RestSchemesInput::new( |
| 162 | Some(aes.clone()), None::<HashScheme>, None::<HashScheme>, Some(crc.clone())); |
| 163 | |
| 164 | let mut cfg = res!(setup::default_cfg()); |
| 165 | cfg.zone_overrides = DaticleMap::new(); // keep every zone under db_root |
| 166 | cfg.data_file_max_bytes = 1_000_000; |
| 167 | cfg.rest_chunk_threshold = 500_000; |
| 168 | cfg.cache_size_limit_bytes = 10_000_000; |
| 169 | |
| 170 | let user = setup::Uid::default(); |
| 171 | let db = res!(setup::start_db(db_root.clone(), Some(cfg), schms_input, None, true, true)); |
| 172 | |
| 173 | // Store both operations -- the hub verifies each and takes its identity from the record -- then |
| 174 | // read the whole log back. |
| 175 | let hub = O3dbHub::new(&db, user); |
| 176 | let id_a = res!(hub.put(&doc_id, &env_a)); |
| 177 | let id_b = res!(hub.put(&doc_id, &env_b)); |
| 178 | assert_ne!(id_a, id_b, "the two authors' operations have distinct identities"); |
| 179 | |
| 180 | let loaded = res!(hub.scan(&doc_id)); |
| 181 | req!(2, loaded.len(), "both operations are in the document's log"); |
| 182 | |
| 183 | // Every stored envelope verifies, and opening it yields its record bound to its signer. |
| 184 | let mut opened = Vec::new(); |
| 185 | for (_id, env) in &loaded { |
| 186 | assert!(res!(sign::verify(env)), "a stored envelope must verify against its own key"); |
| 187 | opened.push(res!(sign::open(env, &doc_id))); |
| 188 | } |
| 189 | |
| 190 | // Fold the log: both annotations materialise, in (time, id) order -- A before B -- and the author |
| 191 | // is the keyring's name for the signer, not the string the body carried. |
| 192 | let report = fold::fold(&opened, &doc_id, &keyring); |
| 193 | assert!(report.skipped.is_empty(), "no operation is skipped in the honest case"); |
| 194 | let anns = report.annotations; |
| 195 | req!(2, anns.len(), "two proposals fold to two annotations"); |
| 196 | assert_eq!(anns[0].author, "author-a", "the earlier operation folds first, attributed by key"); |
| 197 | assert_eq!(anns[1].author, "author-b", "the later operation folds second, attributed by key"); |
| 198 | assert_ne!(anns[0].author, "whoever-a-claims", "the body's author string does not win"); |
| 199 | assert_eq!(anns[0].anchor, hashes[0]); |
| 200 | assert_eq!(anns[1].anchor, hashes[1]); |
| 201 | |
| 202 | // Materialise into a fresh local copy of the .prl. |
| 203 | let mut local = res!(PearlDoc::from_string(prl_text.clone())); |
| 204 | assert!(res!(local.annotations()).is_empty(), "the local copy opens with no annotations"); |
| 205 | req!(2, res!(fold::materialise(&mut local, &anns))); |
| 206 | let got = res!(local.annotations()); |
| 207 | req!(2, got.len(), "both annotations are attached to the local .prl"); |
| 208 | assert_eq!(got[0].payload, "author A on page one"); |
| 209 | assert_eq!(got[1].payload, "author B on page two"); |
| 210 | |
| 211 | // Shut the database down and clear its files before the checks that do not need it. |
| 212 | res!(db.shutdown()); |
| 213 | let _ = std::fs::remove_dir_all(&root); |
| 214 | |
| 215 | // A tampered envelope does not verify: the signature is bound to the payload and the key. |
| 216 | let mut bad_sig = env_a.signature().to_vec(); |
| 217 | bad_sig[10] ^= 0x01; |
| 218 | let tampered_sig = Envelope::new(env_a.payload().to_vec(), env_a.signer().to_vec(), bad_sig); |
| 219 | assert!(!res!(sign::verify(&tampered_sig)), "a tampered signature must not verify"); |
| 220 | assert!(sign::open(&tampered_sig, &doc_id).is_err(), "opening a tampered envelope must error"); |
| 221 | |
| 222 | let mut bad_payload = env_a.payload().to_vec(); |
| 223 | bad_payload[0] ^= 0x01; |
| 224 | let tampered_payload = Envelope::new(bad_payload, env_a.signer().to_vec(), env_a.signature().to_vec()); |
| 225 | assert!(!res!(sign::verify(&tampered_payload)), "a tampered payload must not verify"); |
| 226 | |
| 227 | // Back-compatibility: a checked-in .prl written before the doc_id field reads, and reports none. |
| 228 | let sample = concat!( |
| 229 | env!("CARGO_MANIFEST_DIR"), "/../fe2o3_austenite/web/pearl-reader/samples/demo.prl"); |
| 230 | let old = res!(PearlDoc::read_file(sample)); |
| 231 | assert_eq!(res!(old.doc_id()), None, "a .prl written without a doc_id reports none"); |
| 232 | assert!(res!(old.page_count()) >= 1, "and it still reads as a document"); |
| 233 | |
| 234 | Ok(()) |
| 235 | } |