Skip to main content

ssh_browser/annot/
mod.rs

1//! Annotations as per-author append-only logs.
2//!
3//! There is no central authority here, so the three things one would normally provide —
4//! a total order, an identity, and a notification — are replaced rather than reproduced.
5//!
6//! The order is not reproduced at all: it is made unnecessary. Every log has exactly one
7//! writer, so merging is a set union of lines, which is commutative, idempotent and
8//! associative. The order the logs are read in cannot change the result, and nobody has
9//! to agree on anything.
10//!
11//! The identity is the SSH account, which is what the log's filename says. A record's id
12//! carries its author too, so a log that tries to touch someone else's record is ignored
13//! rather than obeyed — and "you cannot delete someone else's annotation" stops being a
14//! rule anyone has to enforce and becomes a fact about the shape of the data.
15//!
16//! Notification is not this module's problem; it is a `readdir` of one directory, which
17//! returns every log's mtime and size in a single round trip.
18
19use std::collections::HashMap;
20
21use anyhow::{Context, Result, anyhow, ensure};
22use serde::{Deserialize, Serialize};
23
24use crate::fs::RemoteFs;
25
26/// The directory that holds a document's sidecar data.
27///
28/// Beside the document rather than in one central place, so that copying a tree copies
29/// its annotations along with it.
30pub const SIDECAR: &str = ".ssh-browser";
31
32const ID_BYTES: usize = 8;
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "lowercase")]
36pub enum Op {
37    Add,
38    Update,
39    Delete,
40}
41
42/// One line of one author's log.
43///
44/// No `author` field, deliberately. The author is the log's filename, which is a fact the
45/// filesystem owns; a field would be a second source for the same thing and the two could
46/// disagree.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct Record {
49    pub op: Op,
50    pub id: String,
51    /// Unix epoch seconds.
52    ///
53    /// A number rather than an RFC 3339 string: rendering one needs a calendar, and a
54    /// number cannot be ambiguous about its timezone. It is also not what orders the log —
55    /// position in the file does that, because the file is append-only and a clock is not
56    /// trustworthy.
57    pub at: u64,
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub body: Option<String>,
60    /// Carried through without interpretation.
61    ///
62    /// Anchoring belongs to the extension, because the extension is what has a DOM.
63    /// Storing selectors opaquely means a new selector type needs no daemon release, which
64    /// matters when the two halves ship through different channels at different speeds.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub selectors: Option<serde_json::Value>,
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub reply_to: Option<String>,
69}
70
71/// Whether the filesystem agrees with what a log's filename claims about its author.
72///
73/// The claim is worth checking because it is the only thing naming an author, and on a
74/// group-writable directory somebody else may get there first: a log called `alice.jsonl`
75/// that bob created lets bob write records with ids beginning `alice:`, which [`merge`] will
76/// accept and attribute to alice. Permissions are what should stop that. This is what
77/// notices when they did not.
78///
79/// Detection, not prevention. By the time a reader can see a mismatch the forged records are
80/// already on disk, so the useful thing is to say so rather than to imply otherwise.
81#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
82#[serde(tag = "state", rename_all = "lowercase")]
83pub enum Attribution {
84    /// The log is owned by the account its filename names.
85    Owned,
86    /// It is owned by somebody else, who is named here.
87    Mismatched { owner: String },
88    /// Nothing was checked, because the remote did not report an owner legibly.
89    ///
90    /// Kept apart from `Owned` deliberately. The whole value of the check is lost if "not
91    /// checked" and "checked and fine" look the same to a reader.
92    Unchecked,
93}
94
95/// Judge one log's filename against the owner a listing reported.
96pub fn attribution(author: &str, owner: Option<&str>) -> Attribution {
97    match owner {
98        None => Attribution::Unchecked,
99        // A remote that could not resolve a uid to a name prints the number instead. That is
100        // no evidence about a name, so it cannot be evidence of a mismatch — calling it one
101        // would accuse whoever the number turns out to belong to — and it is no evidence of
102        // agreement either.
103        //
104        // Tested before the names are compared, and that order is the whole point. An author
105        // configured as `1000`, which `is_safe_name` permits, would otherwise match an
106        // unresolved uid of 1000 and be reported as verified on the strength of two numerals
107        // coinciding. "Not checked" and "checked and fine" have to stay apart even when the
108        // strings happen to be equal.
109        Some(who) if who.bytes().all(|b| b.is_ascii_digit()) => Attribution::Unchecked,
110        Some(who) if who == author => Attribution::Owned,
111        Some(who) => Attribution::Mismatched {
112            owner: who.to_string(),
113        },
114    }
115}
116
117/// What a reader sees after the logs are folded together.
118#[derive(Debug, Clone, PartialEq, Serialize)]
119pub struct Annotation {
120    pub id: String,
121    pub author: String,
122    pub at: u64,
123    pub body: String,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub selectors: Option<serde_json::Value>,
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub reply_to: Option<String>,
128    /// Carried per annotation although the fact is per log.
129    ///
130    /// A reader renders annotations, so putting it here saves the half that ships through a
131    /// store review from having to join against a separate table of authors.
132    pub attribution: Attribution,
133}
134
135pub struct AuthorLog {
136    pub author: String,
137    pub records: Vec<Record>,
138    /// What the filesystem says about whether this log is really `author`'s.
139    pub attribution: Attribution,
140}
141
142/// What `load` found, including what it could not read.
143#[derive(Debug)]
144pub struct Loaded {
145    pub annotations: Vec<Annotation>,
146    /// Lines that did not parse.
147    ///
148    /// Counted and reported rather than hidden. One line written by some future version
149    /// must not make every other annotation quietly invisible, and a caller that knows how
150    /// many were skipped can say so.
151    pub skipped: usize,
152}
153
154/// Mint an id for a new record.
155///
156/// The author is part of the id, which is what makes ownership checkable without a
157/// registry: any reader can tell whose record it is by looking at it.
158pub fn new_id(author: &str) -> Result<String> {
159    ensure!(is_safe_name(author), "author {author:?} is not a safe name");
160    let mut bytes = [0u8; ID_BYTES];
161    getrandom::fill(&mut bytes).map_err(|e| anyhow!("reading OS entropy for an id: {e}"))?;
162    let mut hex = String::with_capacity(ID_BYTES * 2);
163    for b in bytes {
164        hex.push(nibble(b >> 4));
165        hex.push(nibble(b & 0x0f));
166    }
167    Ok(format!("{author}:{hex}"))
168}
169
170fn nibble(n: u8) -> char {
171    match n {
172        0..=9 => (b'0' + n) as char,
173        _ => (b'a' + n - 10) as char,
174    }
175}
176
177/// Does this id belong to this author?
178fn owns(author: &str, id: &str) -> bool {
179    id.split_once(':').is_some_and(|(owner, _)| owner == author)
180}
181
182/// Safe as a single path component.
183///
184/// An author name becomes a filename, so a name that could climb out of its directory is
185/// a path traversal with extra steps.
186pub(crate) fn is_safe_name(s: &str) -> bool {
187    !s.is_empty()
188        && s.len() <= 64
189        && !s.starts_with('.')
190        && !s.contains("..")
191        && s.bytes()
192            .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.')
193}
194
195/// Where a document's annotation logs live.
196///
197/// `/srv/docs/index.html` becomes `/srv/docs/.ssh-browser/index.html/ann`.
198pub fn ann_dir(doc: &str) -> String {
199    let (parent, name) = match doc.rsplit_once('/') {
200        Some((p, n)) => (p, n),
201        None => ("", doc),
202    };
203    format!("{parent}/{SIDECAR}/{name}/ann")
204}
205
206/// Fold every author's log into the annotations that survive.
207///
208/// Within one log, later lines win over earlier ones, and position in the file is the
209/// ordering. Across logs there is nothing to order: an id belongs to exactly one author, so
210/// two logs never describe the same record, which is why the union is commutative and why
211/// no lock is needed anywhere in this design.
212pub fn merge(logs: &[AuthorLog]) -> Vec<Annotation> {
213    let mut live: HashMap<String, Annotation> = HashMap::new();
214
215    for log in logs {
216        for record in &log.records {
217            // An id names its author, and a log naming someone else's record is ignored.
218            // File permissions should already have prevented it, but a shared account has
219            // no permissions to rely on and this check costs nothing.
220            if !owns(&log.author, &record.id) {
221                continue;
222            }
223
224            match record.op {
225                Op::Delete => {
226                    live.remove(&record.id);
227                }
228                Op::Add | Op::Update => {
229                    let entry = live.entry(record.id.clone()).or_insert_with(|| Annotation {
230                        id: record.id.clone(),
231                        author: log.author.clone(),
232                        at: record.at,
233                        body: String::new(),
234                        selectors: None,
235                        reply_to: None,
236                        attribution: log.attribution.clone(),
237                    });
238                    entry.at = record.at;
239                    if let Some(body) = &record.body {
240                        entry.body = body.clone();
241                    }
242                    if record.selectors.is_some() {
243                        entry.selectors = record.selectors.clone();
244                    }
245                    if record.reply_to.is_some() {
246                        entry.reply_to = record.reply_to.clone();
247                    }
248                }
249            }
250        }
251    }
252
253    let mut out: Vec<Annotation> = live.into_values().collect();
254    // Sorted so the same logs always produce the same order however the files were read.
255    // Without this the union would be commutative in content but not in presentation,
256    // which is a difference a caller would notice and come to depend on.
257    out.sort_by(|a, b| (a.at, &a.id).cmp(&(b.at, &b.id)));
258    out
259}
260
261/// Parse a log, reporting how many lines could not be read.
262///
263/// A malformed line is skipped rather than failing the load. One bad line — from a future
264/// version of the format, or a partial write — must not make every other annotation
265/// invisible.
266pub fn parse(body: &[u8]) -> (Vec<Record>, usize) {
267    let mut records = Vec::new();
268    let mut skipped = 0;
269    for line in body.split(|b| *b == b'\n') {
270        if line.iter().all(u8::is_ascii_whitespace) {
271            continue;
272        }
273        match serde_json::from_slice::<Record>(line) {
274            Ok(r) => records.push(r),
275            Err(_) => skipped += 1,
276        }
277    }
278    (records, skipped)
279}
280
281/// The author a log belongs to, taken from its filename.
282fn author_of(path: &str) -> Result<String> {
283    let name = path.rsplit('/').next().unwrap_or(path);
284    let author = name
285        .strip_suffix(".jsonl")
286        .with_context(|| format!("log file {name:?} does not end in .jsonl"))?;
287    ensure!(
288        is_safe_name(author),
289        "log file {name:?} is not a safe author name"
290    );
291    Ok(author.to_string())
292}
293
294pub struct Store<'a, F> {
295    fs: &'a F,
296}
297
298impl<'a, F: RemoteFs> Store<'a, F> {
299    pub fn new(fs: &'a F) -> Self {
300        Self { fs }
301    }
302
303    /// Read every author's annotations for one document.
304    pub async fn load(&self, doc: &str) -> Result<Loaded> {
305        let dir = ann_dir(doc);
306        let entries = match self.fs.list_dir(&dir).await {
307            Ok(entries) => entries,
308            // No annotation directory means no annotations. That is the ordinary case for
309            // every document nobody has annotated, so it is not an error.
310            Err(e) if crate::fs::is_absent(&e) => {
311                return Ok(Loaded {
312                    annotations: Vec::new(),
313                    skipped: 0,
314                });
315            }
316            // Anything else is a real failure and must not read as an empty page. A dropped
317            // session or a permission problem would otherwise hide every note anybody had
318            // written — including the mismatch warnings this module exists to surface — and
319            // would look exactly like a document nobody had annotated.
320            Err(e) => return Err(e.context(format!("listing {dir}"))),
321        };
322
323        // The owner travels with the path because the listing already reported it. That is
324        // what makes checking who wrote a log cost nothing: it rides the round trip this
325        // load was going to spend anyway, so there is no version of this that is cheaper by
326        // skipping the check.
327        let logs_found: Vec<(String, Option<String>)> = entries
328            .iter()
329            .filter(|e| !e.attrs.is_dir() && e.name.ends_with(".jsonl"))
330            .map(|e| (format!("{dir}/{}", e.name), e.owner.clone()))
331            .collect();
332        if logs_found.is_empty() {
333            return Ok(Loaded {
334                annotations: Vec::new(),
335                skipped: 0,
336            });
337        }
338        let paths: Vec<String> = logs_found.iter().map(|(p, _)| p.clone()).collect();
339
340        // Every author's log in one batch, so the cost does not grow with the number of
341        // people annotating.
342        let bodies = self.fs.read_batch(&paths).await;
343
344        let mut logs = Vec::new();
345        let mut skipped = 0;
346        for ((path, owner), body) in logs_found.iter().zip(bodies) {
347            let author = author_of(path)?;
348            let attribution = attribution(&author, owner.as_deref());
349            let body = body.with_context(|| format!("reading {path}"))?;
350            let (records, bad) = parse(&body);
351            skipped += bad;
352            logs.push(AuthorLog {
353                author,
354                records,
355                attribution,
356            });
357        }
358
359        Ok(Loaded {
360            annotations: merge(&logs),
361            skipped,
362        })
363    }
364
365    /// Append one record to one author's log.
366    pub async fn append(&self, doc: &str, author: &str, record: &Record) -> Result<()> {
367        ensure!(is_safe_name(author), "author {author:?} is not a safe name");
368        // Refused here as well as ignored at merge time. Writing a record that would then
369        // be discarded on read is a silent no-op, and a caller deserves to be told.
370        ensure!(
371            owns(author, &record.id),
372            "record {} does not belong to {author}",
373            record.id
374        );
375
376        let dir = ann_dir(doc);
377        self.fs
378            .mkdirs(&dir)
379            .await
380            .with_context(|| format!("creating {dir}"))?;
381
382        let mut line = serde_json::to_vec(record).context("serialising the record")?;
383        line.push(b'\n');
384        let path = format!("{dir}/{author}.jsonl");
385        self.fs
386            .append(&path, &line)
387            .await
388            .with_context(|| format!("appending to {path}"))
389    }
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395    use crate::testing::{FakeRemote, file_attrs};
396
397    const DOC: &str = "/srv/index.html";
398
399    async fn store_over_empty_tree() -> crate::fs::sftp::SftpFs {
400        FakeRemote::new().dir("/srv", vec![]).spawn().await
401    }
402
403    fn rec(op: Op, id: &str, at: u64, body: Option<&str>) -> Record {
404        Record {
405            op,
406            id: id.to_string(),
407            at,
408            body: body.map(str::to_string),
409            selectors: None,
410            reply_to: None,
411        }
412    }
413
414    fn log(author: &str, records: Vec<Record>) -> AuthorLog {
415        AuthorLog {
416            author: author.to_string(),
417            records,
418            attribution: Attribution::Owned,
419        }
420    }
421
422    #[test]
423    fn annotations_live_beside_their_document() {
424        assert_eq!(
425            ann_dir("/srv/docs/index.html"),
426            "/srv/docs/.ssh-browser/index.html/ann"
427        );
428        assert_eq!(ann_dir("/a.html"), "/.ssh-browser/a.html/ann");
429    }
430
431    #[test]
432    fn an_id_carries_its_author() {
433        let id = new_id("souta").expect("entropy");
434        assert!(id.starts_with("souta:"));
435        assert!(owns("souta", &id));
436        assert!(!owns("alice", &id));
437        assert!(new_id("../etc").is_err());
438    }
439
440    /// The whole reason the format is per-author logs: the order they are read in cannot
441    /// matter, so no lock and no agreement is needed.
442    #[test]
443    fn merging_is_commutative() {
444        let a = || {
445            log(
446                "alice",
447                vec![rec(Op::Add, "alice:1", 10, Some("from alice"))],
448            )
449        };
450        let b = || log("bob", vec![rec(Op::Add, "bob:1", 20, Some("from bob"))]);
451
452        let forward = merge(&[a(), b()]);
453        let backward = merge(&[b(), a()]);
454        assert_eq!(forward, backward);
455        assert_eq!(forward.len(), 2);
456    }
457
458    #[test]
459    fn merging_is_idempotent() {
460        let once = merge(&[log(
461            "alice",
462            vec![rec(Op::Add, "alice:1", 10, Some("hello"))],
463        )]);
464        let twice = merge(&[
465            log("alice", vec![rec(Op::Add, "alice:1", 10, Some("hello"))]),
466            log("alice", vec![rec(Op::Add, "alice:1", 10, Some("hello"))]),
467        ]);
468        assert_eq!(once, twice);
469    }
470
471    /// Position in the file is the ordering, not the clock: the second line wins even
472    /// though its timestamp is older.
473    #[test]
474    fn later_lines_win_over_earlier_ones_regardless_of_timestamp() {
475        let merged = merge(&[log(
476            "alice",
477            vec![
478                rec(Op::Add, "alice:1", 100, Some("first")),
479                rec(Op::Update, "alice:1", 50, Some("second")),
480            ],
481        )]);
482        assert_eq!(merged.len(), 1);
483        assert_eq!(merged[0].body, "second");
484        assert_eq!(merged[0].at, 50, "the later line's timestamp is kept");
485    }
486
487    #[test]
488    fn a_delete_removes_the_annotation() {
489        let merged = merge(&[log(
490            "alice",
491            vec![
492                rec(Op::Add, "alice:1", 10, Some("hello")),
493                rec(Op::Delete, "alice:1", 20, None),
494            ],
495        )]);
496        assert!(merged.is_empty());
497    }
498
499    /// Authorisation falling out of the shape of the data rather than out of a rule
500    /// someone has to remember to check.
501    #[test]
502    fn a_log_cannot_touch_another_authors_record() {
503        let merged = merge(&[
504            log("alice", vec![rec(Op::Add, "alice:1", 10, Some("mine"))]),
505            // Bob's log trying to delete Alice's annotation, and to edit it.
506            log(
507                "bob",
508                vec![
509                    rec(Op::Delete, "alice:1", 20, None),
510                    rec(Op::Update, "alice:1", 30, Some("vandalised")),
511                ],
512            ),
513        ]);
514        assert_eq!(merged.len(), 1);
515        assert_eq!(merged[0].body, "mine");
516        assert_eq!(merged[0].author, "alice");
517    }
518
519    #[test]
520    fn the_author_comes_from_the_filename() {
521        assert_eq!(
522            author_of("/srv/.ssh-browser/a.html/ann/souta.jsonl").unwrap(),
523            "souta"
524        );
525        assert!(author_of("/srv/ann/souta.txt").is_err());
526        assert!(author_of("/srv/ann/...jsonl").is_err());
527    }
528
529    #[test]
530    fn unsafe_author_names_are_refused() {
531        assert!(!is_safe_name(""));
532        assert!(!is_safe_name("../etc"));
533        assert!(!is_safe_name("a/b"));
534        assert!(!is_safe_name(".hidden"));
535        assert!(!is_safe_name(&"x".repeat(65)));
536        assert!(is_safe_name("souta"));
537        assert!(is_safe_name("first.last"));
538        assert!(is_safe_name("user_1-2"));
539    }
540
541    /// One unreadable line must not hide the rest, and the count must be reported rather
542    /// than swallowed.
543    #[test]
544    fn a_malformed_line_is_skipped_and_counted() {
545        let body = b"{\"op\":\"add\",\"id\":\"alice:1\",\"at\":10,\"body\":\"ok\"}\nnot json\n{\"op\":\"add\",\"id\":\"alice:2\",\"at\":20}\n";
546        let (records, skipped) = parse(body);
547        assert_eq!(records.len(), 2);
548        assert_eq!(skipped, 1);
549    }
550
551    #[test]
552    fn blank_lines_are_not_counted_as_damage() {
553        let (records, skipped) = parse(b"\n\n  \n");
554        assert!(records.is_empty());
555        assert_eq!(skipped, 0);
556    }
557
558    #[test]
559    fn a_record_round_trips_through_json() {
560        let record = Record {
561            op: Op::Add,
562            id: "souta:a1b2c3d4e5f6a7b8".to_string(),
563            at: 1_757_600_000,
564            body: Some("a note".to_string()),
565            selectors: Some(serde_json::json!([{"type": "TextQuoteSelector"}])),
566            reply_to: Some("alice:1".to_string()),
567        };
568        let line = serde_json::to_vec(&record).expect("serialises");
569        let (back, skipped) = parse(&line);
570        assert_eq!(skipped, 0);
571        assert_eq!(back.len(), 1);
572        assert_eq!(back[0].id, record.id);
573        assert_eq!(back[0].at, record.at);
574        assert_eq!(back[0].reply_to.as_deref(), Some("alice:1"));
575        assert!(back[0].selectors.is_some(), "selectors survive untouched");
576    }
577
578    /// A reply is an ordinary record in the replier's own log, which is what makes a thread
579    /// work without anyone writing to anyone else's file.
580    #[test]
581    fn a_reply_to_another_authors_annotation_is_just_a_record() {
582        let mut reply = rec(Op::Add, "bob:1", 20, Some("agreed"));
583        reply.reply_to = Some("alice:1".to_string());
584        let merged = merge(&[
585            log("alice", vec![rec(Op::Add, "alice:1", 10, Some("a claim"))]),
586            log("bob", vec![reply]),
587        ]);
588        assert_eq!(merged.len(), 2);
589        assert_eq!(merged[1].reply_to.as_deref(), Some("alice:1"));
590        assert_eq!(merged[1].author, "bob");
591    }
592
593    #[tokio::test]
594    async fn a_record_written_comes_back_out() {
595        let fs = store_over_empty_tree().await;
596        let store = Store::new(&fs);
597        let id = new_id("souta").expect("entropy");
598
599        store
600            .append(DOC, "souta", &rec(Op::Add, &id, 100, Some("a note")))
601            .await
602            .expect("append");
603
604        let loaded = store.load(DOC).await.expect("load");
605        assert_eq!(loaded.skipped, 0);
606        assert_eq!(loaded.annotations.len(), 1);
607        assert_eq!(loaded.annotations[0].body, "a note");
608        assert_eq!(
609            loaded.annotations[0].author, "souta",
610            "the author comes from the filename the daemon chose, not from the record"
611        );
612    }
613
614    /// Two people annotating one document touch different files, so neither can lose the
615    /// other's work. This is the property the whole format exists for.
616    #[tokio::test]
617    async fn two_authors_do_not_overwrite_each_other() {
618        let fs = store_over_empty_tree().await;
619        let store = Store::new(&fs);
620        let souta = new_id("souta").expect("entropy");
621        let alice = new_id("alice").expect("entropy");
622
623        store
624            .append(DOC, "souta", &rec(Op::Add, &souta, 100, Some("from souta")))
625            .await
626            .expect("souta appends");
627        store
628            .append(DOC, "alice", &rec(Op::Add, &alice, 200, Some("from alice")))
629            .await
630            .expect("alice appends");
631
632        let loaded = store.load(DOC).await.expect("load");
633        assert_eq!(loaded.annotations.len(), 2);
634        let bodies: Vec<&str> = loaded.annotations.iter().map(|a| a.body.as_str()).collect();
635        assert!(bodies.contains(&"from souta"));
636        assert!(bodies.contains(&"from alice"));
637    }
638
639    /// An edit is another line, not a rewrite of the file. That is what makes it safe
640    /// without a lock.
641    #[tokio::test]
642    async fn an_update_appends_rather_than_rewriting() {
643        let fs = store_over_empty_tree().await;
644        let store = Store::new(&fs);
645        let id = new_id("souta").expect("entropy");
646
647        store
648            .append(DOC, "souta", &rec(Op::Add, &id, 100, Some("first")))
649            .await
650            .expect("add");
651        store
652            .append(DOC, "souta", &rec(Op::Update, &id, 200, Some("edited")))
653            .await
654            .expect("update");
655
656        let loaded = store.load(DOC).await.expect("load");
657        assert_eq!(
658            loaded.annotations.len(),
659            1,
660            "an update is not a second record"
661        );
662        assert_eq!(loaded.annotations[0].body, "edited");
663    }
664
665    #[tokio::test]
666    async fn a_delete_survives_a_round_trip() {
667        let fs = store_over_empty_tree().await;
668        let store = Store::new(&fs);
669        let id = new_id("souta").expect("entropy");
670
671        store
672            .append(DOC, "souta", &rec(Op::Add, &id, 100, Some("doomed")))
673            .await
674            .expect("add");
675        store
676            .append(DOC, "souta", &rec(Op::Delete, &id, 200, None))
677            .await
678            .expect("delete");
679
680        assert!(store.load(DOC).await.expect("load").annotations.is_empty());
681    }
682
683    /// The ordinary case for every document nobody has annotated: no directory, no error.
684    #[tokio::test]
685    async fn a_document_with_no_annotations_loads_empty() {
686        let fs = store_over_empty_tree().await;
687        let store = Store::new(&fs);
688        let loaded = store.load(DOC).await.expect("load");
689        assert!(loaded.annotations.is_empty());
690        assert_eq!(loaded.skipped, 0);
691    }
692
693    /// Refused at write time as well as ignored at merge time. Writing a record that would
694    /// be silently discarded on read is worse than an error.
695    #[tokio::test]
696    async fn appending_someone_elses_record_is_refused() {
697        let fs = store_over_empty_tree().await;
698        let store = Store::new(&fs);
699        let alice = new_id("alice").expect("entropy");
700
701        let result = store
702            .append(DOC, "souta", &rec(Op::Add, &alice, 100, Some("vandalism")))
703            .await;
704        assert!(
705            result.is_err(),
706            "souta must not be able to write a record owned by alice"
707        );
708    }
709
710    #[test]
711    fn a_log_owned_by_the_account_it_names_is_owned() {
712        assert_eq!(attribution("souta", Some("souta")), Attribution::Owned);
713    }
714
715    /// The forgery this exists to catch: bob got to `alice.jsonl` first, so records with
716    /// ids beginning `alice:` are bob's and would otherwise read as alice's.
717    #[test]
718    fn a_log_owned_by_somebody_else_is_a_mismatch() {
719        assert_eq!(
720            attribution("alice", Some("bob")),
721            Attribution::Mismatched {
722                owner: "bob".to_string()
723            }
724        );
725    }
726
727    /// Two different reasons to conclude nothing, and neither may be reported as agreement.
728    #[test]
729    fn no_legible_owner_means_unchecked_rather_than_fine() {
730        assert_eq!(attribution("souta", None), Attribution::Unchecked);
731        // A remote that could not resolve the uid prints the number. Calling that a mismatch
732        // would accuse whoever the number belongs to, which may well be souta.
733        assert_eq!(attribution("souta", Some("1000")), Attribution::Unchecked);
734    }
735
736    /// An unresolved uid is not evidence of agreement either, even when the author happens to
737    /// be spelled the same way. `is_safe_name` permits a numeric author, so this is reachable
738    /// by configuration rather than only in theory, and reporting it as verified would be the
739    /// one thing the three-valued answer exists to prevent.
740    #[test]
741    fn a_numeric_author_matching_an_unresolved_uid_is_still_unchecked() {
742        assert_eq!(attribution("1000", Some("1000")), Attribution::Unchecked);
743        assert!(
744            is_safe_name("1000"),
745            "the case is reachable by configuration"
746        );
747    }
748
749    /// Free, and that is the design claim: the owner arrives on the listing `load` already
750    /// spends. Measured as a comparison rather than against a fixed number, because the
751    /// claim is about the difference — a later "improvement" that stat'ed each log to find
752    /// its owner would break this while every other test kept passing.
753    #[tokio::test]
754    async fn checking_who_wrote_each_log_costs_no_extra_round_trips() {
755        async fn cost_of_load(remote: FakeRemote) -> (u64, Attribution) {
756            let fs = remote.spawn().await;
757            let store = Store::new(&fs);
758            let id = new_id("souta").expect("entropy");
759            store
760                .append(DOC, "souta", &rec(Op::Add, &id, 100, Some("mine")))
761                .await
762                .expect("append");
763
764            let before = fs.round_trips();
765            let loaded = store.load(DOC).await.expect("load");
766            let attribution = loaded.annotations[0].attribution.clone();
767            (fs.round_trips() - before, attribution)
768        }
769
770        let (with_owner, checked) =
771            cost_of_load(FakeRemote::new().reached_as("souta").dir("/srv", vec![])).await;
772        let (without_owner, unchecked) = cost_of_load(FakeRemote::new().dir("/srv", vec![])).await;
773
774        assert_eq!(checked, Attribution::Owned, "an owner was reported");
775        assert_eq!(unchecked, Attribution::Unchecked, "none was");
776        assert_eq!(
777            with_owner, without_owner,
778            "the check rides the listing rather than adding to it"
779        );
780    }
781
782    /// Invariant 1 on the annotation path: ten authors cost what one does. This is what the
783    /// per-author format is for — reading them is one batch, not one request each.
784    #[tokio::test]
785    async fn a_document_with_ten_authors_costs_what_one_author_costs() {
786        fn tree(authors: usize) -> FakeRemote {
787            let dir = ann_dir(DOC);
788            let logs: Vec<(String, String)> = (0..authors)
789                .map(|i| {
790                    (
791                        format!("author{i}.jsonl"),
792                        format!(
793                            "{{\"op\":\"add\",\"id\":\"author{i}:1\",\"at\":10,\"body\":\"x\"}}\n"
794                        ),
795                    )
796                })
797                .collect();
798
799            let mut remote = FakeRemote::new().dir(
800                &dir,
801                logs.iter()
802                    .map(|(name, line)| (name.as_str(), file_attrs(line.len() as u64, 1)))
803                    .collect(),
804            );
805            for (name, line) in &logs {
806                remote = remote.file(&format!("{dir}/{name}"), line.as_bytes());
807            }
808            remote
809        }
810
811        async fn cost_of_load(remote: FakeRemote, expected: usize) -> u64 {
812            let fs = remote.spawn().await;
813            let before = fs.round_trips();
814            let loaded = Store::new(&fs).load(DOC).await.expect("load");
815            assert_eq!(loaded.annotations.len(), expected);
816            fs.round_trips() - before
817        }
818
819        let one = cost_of_load(tree(1), 1).await;
820        let ten = cost_of_load(tree(10), 10).await;
821        assert_eq!(
822            one, ten,
823            "the cost must not grow with the number of authors"
824        );
825    }
826
827    /// The question `SECURITY.md` used to leave open: this daemon is configured to write as
828    /// souta, but the account it actually reaches the remote as is somebody else.
829    #[tokio::test]
830    async fn a_configured_author_the_remote_does_not_write_as_is_caught() {
831        let fs = FakeRemote::new()
832            .reached_as("sshimozono")
833            .dir("/srv", vec![])
834            .spawn()
835            .await;
836        let store = Store::new(&fs);
837        let id = new_id("souta").expect("entropy");
838        store
839            .append(DOC, "souta", &rec(Op::Add, &id, 100, Some("a note")))
840            .await
841            .expect("append");
842
843        let loaded = store.load(DOC).await.expect("load");
844        assert_eq!(loaded.annotations.len(), 1, "the note is still shown");
845        assert_eq!(
846            loaded.annotations[0].attribution,
847            Attribution::Mismatched {
848                owner: "sshimozono".to_string()
849            },
850            "the log says souta, the filesystem says sshimozono"
851        );
852    }
853
854    /// A mismatch is surfaced, never censored. Dropping the records would hide somebody's
855    /// annotations on the strength of a heuristic parse of an `ls -l` line.
856    #[tokio::test]
857    async fn a_mismatched_log_still_yields_its_annotations() {
858        let body = b"{\"op\":\"add\",\"id\":\"alice:1\",\"at\":10,\"body\":\"is this alice?\"}\n";
859        let dir = ann_dir(DOC);
860        let fs = FakeRemote::new()
861            .dir(
862                &dir,
863                vec![("alice.jsonl", file_attrs(body.len() as u64, 1))],
864            )
865            .owner(&format!("{dir}/alice.jsonl"), "bob")
866            .file(&format!("{dir}/alice.jsonl"), body)
867            .spawn()
868            .await;
869
870        let loaded = Store::new(&fs).load(DOC).await.expect("load");
871        assert_eq!(loaded.annotations.len(), 1);
872        assert_eq!(loaded.annotations[0].author, "alice");
873        assert_eq!(loaded.annotations[0].body, "is this alice?");
874        assert_eq!(
875            loaded.annotations[0].attribution,
876            Attribution::Mismatched {
877                owner: "bob".to_string()
878            }
879        );
880    }
881
882    /// The one refusal that is an ordinary answer. Every document nobody has annotated has
883    /// no sidecar directory, and saying so is not an error.
884    #[tokio::test]
885    async fn a_missing_annotation_directory_is_not_an_error() {
886        let fs = store_over_empty_tree().await;
887        let loaded = Store::new(&fs).load(DOC).await.expect("load");
888        assert!(loaded.annotations.is_empty());
889    }
890
891    /// And every other refusal is. A listing that fails for any reason other than absence
892    /// must not come back as "nobody has annotated this": that hides whatever anybody wrote,
893    /// including the mismatch warnings this module exists to raise, behind a page that looks
894    /// perfectly normal. This is the failure `CONTRIBUTING.md` names, one layer up from where
895    /// it was found the first time.
896    #[tokio::test]
897    async fn a_refused_listing_is_an_error_rather_than_an_empty_page() {
898        const PERMISSION_DENIED: u32 = 3;
899        let dir = ann_dir(DOC);
900        let fs = FakeRemote::new()
901            .dir("/srv", vec![])
902            .dir(&dir, vec![("souta.jsonl", file_attrs(10, 1))])
903            .refuses_listing(&dir, PERMISSION_DENIED)
904            .spawn()
905            .await;
906
907        let e = Store::new(&fs)
908            .load(DOC)
909            .await
910            .expect_err("a refused listing must not read as an empty page");
911        let text = format!("{e:#}");
912        assert!(
913            text.contains("permission denied"),
914            "the error has to say what the remote said, got: {text}"
915        );
916    }
917
918    /// A remote that reports nothing legible must not make every annotation look verified.
919    #[tokio::test]
920    async fn a_remote_that_reports_no_owner_reads_as_unchecked() {
921        let fs = store_over_empty_tree().await;
922        let store = Store::new(&fs);
923        let id = new_id("souta").expect("entropy");
924        store
925            .append(DOC, "souta", &rec(Op::Add, &id, 100, Some("a note")))
926            .await
927            .expect("append");
928
929        let loaded = store.load(DOC).await.expect("load");
930        assert_eq!(loaded.annotations[0].attribution, Attribution::Unchecked);
931    }
932
933    #[tokio::test]
934    async fn an_unsafe_author_name_never_reaches_the_filesystem() {
935        let fs = store_over_empty_tree().await;
936        let store = Store::new(&fs);
937        let result = store
938            .append(DOC, "../etc", &rec(Op::Add, "../etc:1", 100, Some("x")))
939            .await;
940        assert!(result.is_err());
941    }
942}