1use std::collections::HashMap;
20
21use anyhow::{Context, Result, anyhow, ensure};
22use serde::{Deserialize, Serialize};
23
24use crate::fs::RemoteFs;
25
26pub 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#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct Record {
49 pub op: Op,
50 pub id: String,
51 pub at: u64,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub body: Option<String>,
60 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
82#[serde(tag = "state", rename_all = "lowercase")]
83pub enum Attribution {
84 Owned,
86 Mismatched { owner: String },
88 Unchecked,
93}
94
95pub fn attribution(author: &str, owner: Option<&str>) -> Attribution {
97 match owner {
98 None => Attribution::Unchecked,
99 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#[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 pub attribution: Attribution,
133}
134
135pub struct AuthorLog {
136 pub author: String,
137 pub records: Vec<Record>,
138 pub attribution: Attribution,
140}
141
142#[derive(Debug)]
144pub struct Loaded {
145 pub annotations: Vec<Annotation>,
146 pub skipped: usize,
152}
153
154pub 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
177fn owns(author: &str, id: &str) -> bool {
179 id.split_once(':').is_some_and(|(owner, _)| owner == author)
180}
181
182pub(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
195pub 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
206pub 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 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 out.sort_by(|a, b| (a.at, &a.id).cmp(&(b.at, &b.id)));
258 out
259}
260
261pub 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
281fn 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 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 Err(e) if crate::fs::is_absent(&e) => {
311 return Ok(Loaded {
312 annotations: Vec::new(),
313 skipped: 0,
314 });
315 }
316 Err(e) => return Err(e.context(format!("listing {dir}"))),
321 };
322
323 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 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 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 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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
729 fn no_legible_owner_means_unchecked_rather_than_fine() {
730 assert_eq!(attribution("souta", None), Attribution::Unchecked);
731 assert_eq!(attribution("souta", Some("1000")), Attribution::Unchecked);
734 }
735
736 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}