Oregami
Repositories/oxedyne/fe2o3

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
8use 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};
30use oxedyne_fe2o3_core::{
31 prelude::*,
32 rand::Rand,
33};
34use oxedyne_fe2o3_crypto::enc::EncryptionScheme;
35use oxedyne_fe2o3_graphics::{
36 colour::Rgba,
37 path::{
38 Bounds,
39 Path,
40 },
41};
42use oxedyne_fe2o3_hash::{
43 csum::ChecksumScheme,
44 hash::HashScheme,
45};
46use oxedyne_fe2o3_jdat::prelude::*;
47use oxedyne_fe2o3_net::ecdsa::P256KeyPair;
48use oxedyne_fe2o3_o3db_sync::{
49 data::core::RestSchemesInput,
50 test::setup,
51};
52use oxedyne_fe2o3_ore::{
53 envelope::Envelope,
54 id::OpId,
55 op::Record,
56};
57use 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
70use 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.
75fn 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.
89fn 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]
106fn 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}