Jump to…
snowfix: 500 error because of database is patchedyuzpxzopsouq1mo
1//! `GitStore` against the real fixture corpus.
2//!
3//! Fixtures are copied into the store's own sharded layout, so these exercise
4//! the same code path a pushed repository takes — including `repo_path`
5//! sharding, not just the Git plumbing.
6//!
7//! Skipped with a warning when the corpus is absent; `DF_REQUIRE_FIXTURES=1`
8//! turns that into a failure so CI cannot silently skip everything.
9
10use std::path::{Path, PathBuf};
11
12use df_store::{EntryKind, GitStore, RepoId, RepoStore, RevId, StoreError};
13use uuid::Uuid;
14
15struct Fixture {
16 _tmp: tempfile::TempDir,
17 store: GitStore,
18 id: RepoId,
19}
20
21fn corpus() -> PathBuf {
22 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/repos")
23}
24
25/// Copy a fixture repository into a fresh store and return a handle to it.
26fn load(case: &str) -> Option<Fixture> {
27 let src = corpus().join(format!("{case}.git"));
28 if !src.is_dir() {
29 if std::env::var_os("DF_REQUIRE_FIXTURES").is_some() {
30 panic!("DF_REQUIRE_FIXTURES is set but {case}.git is missing — run fixtures/gen.sh");
31 }
32 eprintln!("warning: fixture {case}.git absent, test skipped");
33 return None;
34 }
35
36 let tmp = tempfile::tempdir().expect("tempdir");
37 let store = GitStore::new(tmp.path());
38 let id = RepoId(Uuid::now_v7());
39
40 let dest = store.repo_path(id);
41 std::fs::create_dir_all(dest.parent().unwrap()).expect("shard dir");
42
43 let status = std::process::Command::new("cp")
44 .arg("-r")
45 .arg(&src)
46 .arg(&dest)
47 .status()
48 .expect("cp");
49 assert!(status.success(), "copying fixture {case} failed");
50
51 Some(Fixture { _tmp: tmp, store, id })
52}
53
54async fn head(f: &Fixture) -> RevId {
55 f.store
56 .resolve(f.id, "main")
57 .await
58 .expect("resolving main")
59}
60
61#[tokio::test]
62async fn creates_and_deletes_a_repository() {
63 let tmp = tempfile::tempdir().unwrap();
64 let store = GitStore::new(tmp.path());
65 let id = RepoId(Uuid::now_v7());
66
67 assert!(!store.exists(id).await);
68 store.create(id, "main").await.expect("create");
69 assert!(store.exists(id).await);
70 assert!(store.is_empty(id).await.expect("is_empty"), "a fresh repo has no refs");
71
72 // HEAD must point at the configured default bookmark, or a fresh clone
73 // checks out nothing.
74 let head = std::fs::read_to_string(store.repo_path(id).join("HEAD")).unwrap();
75 assert_eq!(head.trim(), "ref: refs/heads/main");
76
77 store.delete(id).await.expect("delete");
78 assert!(!store.exists(id).await);
79}
80
81#[tokio::test]
82async fn operations_on_a_missing_repo_report_no_such_repo() {
83 let tmp = tempfile::tempdir().unwrap();
84 let store = GitStore::new(tmp.path());
85 let id = RepoId(Uuid::now_v7());
86
87 assert!(matches!(
88 store.bookmarks(id).await,
89 Err(StoreError::NoSuchRepo)
90 ));
91}
92
93#[tokio::test]
94async fn lists_bookmarks() {
95 let Some(f) = load("stack") else { return };
96 let marks = f.store.bookmarks(f.id).await.expect("bookmarks");
97 let names: Vec<&str> = marks.iter().map(|b| b.name.as_str()).collect();
98 assert!(names.contains(&"main"), "got {names:?}");
99 assert!(names.contains(&"top"), "got {names:?}");
100}
101
102#[tokio::test]
103async fn lists_a_tree_and_reads_a_blob() {
104 let Some(f) = load("basic") else { return };
105 let rev = head(&f).await;
106
107 let entries = f
108 .store
109 .list_tree(f.id, &rev, Path::new(""))
110 .await
111 .expect("list_tree");
112 assert!(!entries.is_empty());
113
114 let file = entries
115 .iter()
116 .find(|e| e.kind == EntryKind::File)
117 .expect("at least one file");
118
119 let blob = f
120 .store
121 .read_blob(f.id, &rev, Path::new(&file.path))
122 .await
123 .expect("read_blob");
124 assert!(blob.text().is_some(), "fixture files are text");
125 assert_eq!(blob.size, blob.content.len() as u64);
126}
127
128/// `basic` is two commits, each adding one file — "add a" then "add b" — so a
129/// correct per-entry walk must resolve them to *different* commits rather
130/// than both landing on the tip, which is the failure mode a naive
131/// "just report the directory's last commit for everything" implementation
132/// would have.
133#[tokio::test]
134async fn last_commits_in_dir_resolves_each_entry_to_the_commit_that_touched_it() {
135 let Some(f) = load("basic") else { return };
136 let rev = head(&f).await;
137
138 let entries = f.store.list_tree(f.id, &rev, Path::new("")).await.unwrap();
139 let names: Vec<String> = entries.iter().map(|e| e.name.clone()).collect();
140 assert!(names.contains(&"a.txt".to_string()));
141 assert!(names.contains(&"b.txt".to_string()));
142
143 let history = f
144 .store
145 .last_commits_in_dir(f.id, &rev, Path::new(""), &names)
146 .await
147 .expect("last_commits_in_dir");
148
149 let a = history.get("a.txt").expect("a.txt has history");
150 let b = history.get("b.txt").expect("b.txt has history");
151
152 assert_eq!(a.summary(), "add a");
153 assert_eq!(b.summary(), "add b");
154 assert_ne!(
155 a.rev, b.rev,
156 "each file must resolve to the commit that actually touched it, not both to the tip"
157 );
158}
159
160#[tokio::test]
161async fn last_commits_in_dir_reports_nothing_for_an_unknown_entry() {
162 let Some(f) = load("basic") else { return };
163 let rev = head(&f).await;
164
165 let history = f
166 .store
167 .last_commits_in_dir(f.id, &rev, Path::new(""), &["nonexistent.txt".to_string()])
168 .await
169 .expect("last_commits_in_dir");
170
171 assert!(history.is_empty());
172}
173
174#[tokio::test]
175async fn directories_sort_before_files() {
176 let Some(f) = load("hostile") else { return };
177 let rev = f.store.resolve(f.id, "main").await.expect("resolve");
178 let entries = f.store.list_tree(f.id, &rev, Path::new("")).await.unwrap();
179
180 let first_file = entries.iter().position(|e| !e.is_dir());
181 let last_dir = entries.iter().rposition(|e| e.is_dir());
182 if let (Some(ff), Some(ld)) = (first_file, last_dir) {
183 assert!(ld < ff, "directories must sort before files: {entries:#?}");
184 }
185}
186
187// ─── path safety (spec §9) ───────────────────────────────────────────────────
188
189#[tokio::test]
190async fn traversal_out_of_the_repo_is_refused() {
191 let Some(f) = load("basic") else { return };
192 let rev = head(&f).await;
193
194 for bad in ["../../../etc/passwd", "/etc/passwd", "a/../../../etc/passwd"] {
195 let r = f.store.read_blob(f.id, &rev, Path::new(bad)).await;
196 assert!(
197 matches!(r, Err(StoreError::Path(_))),
198 "path {bad:?} must be refused, got {r:?}"
199 );
200 }
201}
202
203#[tokio::test]
204async fn a_symlink_escaping_the_repo_serves_its_target_text_not_the_file() {
205 // "A repository can contain a symlink to /etc/passwd and a naive blob
206 // handler will happily serve it" (spec §9). We must return the *link text*,
207 // never the contents of the file it points at.
208 let Some(f) = load("hostile") else { return };
209 let rev = f.store.resolve(f.id, "main").await.expect("resolve");
210
211 let entries = f.store.list_tree(f.id, &rev, Path::new("")).await.unwrap();
212 let links: Vec<_> = entries
213 .iter()
214 .filter(|e| e.kind == EntryKind::Symlink)
215 .collect();
216 assert!(!links.is_empty(), "the hostile fixture must contain symlinks");
217
218 for link in links {
219 let blob = f
220 .store
221 .read_blob(f.id, &rev, Path::new(&link.path))
222 .await
223 .expect("reading a symlink must succeed, returning its target text");
224
225 let text = String::from_utf8_lossy(&blob.content);
226 // The blob holds the *target path*, which is short. If we had followed
227 // the link we would be holding /etc/passwd or /etc/shadow.
228 assert!(
229 text == "/etc/passwd" || text == "../../../../etc/shadow",
230 "symlink {} yielded unexpected content {text:?}",
231 link.path
232 );
233 assert!(
234 !text.contains("root:"),
235 "SECURITY: symlink was followed off-repo — served {text:?}"
236 );
237 }
238}
239
240#[tokio::test]
241async fn reading_a_directory_as_a_blob_is_refused() {
242 let Some(f) = load("hostile") else { return };
243 let rev = f.store.resolve(f.id, "main").await.unwrap();
244 let r = f.store.read_blob(f.id, &rev, Path::new("a")).await;
245 assert!(matches!(r, Err(StoreError::IsDirectory)), "got {r:?}");
246}
247
248#[tokio::test]
249async fn a_missing_path_is_reported_as_missing() {
250 let Some(f) = load("basic") else { return };
251 let rev = head(&f).await;
252 let r = f
253 .store
254 .read_blob(f.id, &rev, Path::new("does/not/exist.txt"))
255 .await;
256 assert!(matches!(r, Err(StoreError::NoSuchPath)), "got {r:?}");
257}
258
259// ─── jj semantics ────────────────────────────────────────────────────────────
260
261#[tokio::test]
262async fn revisions_carry_the_jj_change_id() {
263 let Some(f) = load("basic") else { return };
264 let rev = head(&f).await;
265 let r = f.store.revision(f.id, &rev).await.expect("revision");
266
267 let change = r.change_id.expect("a jj commit must expose its change id");
268 assert_eq!(change.len(), 32);
269 assert!(change.bytes().all(|b| (b'k'..=b'z').contains(&b)));
270 assert!(!r.conflicted);
271}
272
273#[tokio::test]
274async fn plain_git_revisions_have_no_change_id() {
275 let Some(f) = load("plain-git") else { return };
276 let rev = f.store.resolve(f.id, "main").await.expect("resolve");
277 let r = f.store.revision(f.id, &rev).await.expect("revision");
278 assert_eq!(r.change_id, None);
279}
280
281#[tokio::test]
282async fn conflicted_revisions_expose_their_sides_and_hide_the_artifacts() {
283 let Some(f) = load("conflict") else { return };
284 let rev = f.store.resolve(f.id, "main").await.expect("resolve");
285
286 let r = f.store.revision(f.id, &rev).await.expect("revision");
287 assert!(r.conflicted, "the fixture head is a conflicted merge");
288 assert_eq!(
289 r.conflict_sides.len(),
290 r.conflict_bases.len() + 1,
291 "sides == bases + 1"
292 );
293
294 // The .jjconflict-* trees are storage detail and must not surface.
295 let entries = f.store.list_tree(f.id, &rev, Path::new("")).await.unwrap();
296 for e in &entries {
297 assert!(
298 !e.name.starts_with(".jjconflict-") && e.name != "JJ-CONFLICT-README",
299 "conflict artefact leaked into the listing: {}",
300 e.name
301 );
302 }
303 assert!(
304 entries.iter().any(|e| e.name == "c.txt"),
305 "the real file must still be listed: {entries:#?}"
306 );
307}
308
309#[tokio::test]
310async fn log_walks_history_and_respects_its_limit() {
311 let Some(f) = load("stack") else { return };
312 let rev = f.store.resolve(f.id, "top").await.expect("resolve top");
313
314 let all = f.store.log(f.id, &rev, 100).await.expect("log");
315 assert!(all.len() >= 5, "stack fixture has a base plus four changes");
316
317 let two = f.store.log(f.id, &rev, 2).await.expect("log");
318 assert_eq!(two.len(), 2);
319 assert_eq!(two[0].rev, rev, "log starts at the requested revision");
320}
321
322#[tokio::test]
323async fn ancestry_and_merge_base() {
324 let Some(f) = load("stack") else { return };
325 let bottom = f.store.resolve(f.id, "main").await.unwrap();
326 let top = f.store.resolve(f.id, "top").await.unwrap();
327
328 assert!(f.store.is_ancestor(f.id, &bottom, &top).await.unwrap());
329 assert!(
330 !f.store.is_ancestor(f.id, &top, &bottom).await.unwrap(),
331 "ancestry is directional"
332 );
333 assert!(
334 f.store.is_ancestor(f.id, &top, &top).await.unwrap(),
335 "a revision is its own ancestor"
336 );
337
338 let base = f.store.merge_base(f.id, &bottom, &top).await.unwrap();
339 assert_eq!(base, Some(bottom));
340}
341
342#[tokio::test]
343async fn diff_reports_changed_files_with_line_counts() {
344 let Some(f) = load("whitespace") else { return };
345 let before = f.store.resolve(f.id, "main").await.unwrap();
346 let after = f.store.resolve(f.id, "reindented").await.unwrap();
347
348 let d = f
349 .store
350 .diff(f.id, &before, &after, Default::default())
351 .await
352 .expect("diff");
353
354 assert_eq!(d.files.len(), 1, "one file changed: {:#?}", d.files);
355 assert_eq!(d.files[0].path, "m.rs");
356 assert!(d.total_additions > 0 && d.total_deletions > 0);
357 assert!(!d.truncated);
358}
359
360/// The tree walk reports the directories it descends through as well as the
361/// files inside them. A directory is not a changed file: it has no content, so
362/// it reached the renderer as a hunkless "binary file" and padded the file
363/// count of every change that touched a new subdirectory.
364#[tokio::test]
365async fn a_diff_lists_files_and_not_the_directories_holding_them() {
366 for case in ["basic", "stack", "renames", "mixed", "hostile"] {
367 let Some(f) = load(case) else { continue };
368 let rev = head(&f).await;
369 let d = f
370 .store
371 .diff_from_parent(f.id, &rev, Default::default())
372 .await
373 .expect("diff");
374
375 let paths: Vec<&str> = d.files.iter().map(|x| x.path.as_str()).collect();
376 for p in &paths {
377 assert!(
378 !paths.iter().any(|q| q.starts_with(&format!("{p}/"))),
379 "{case}: {p:?} is a directory, not a changed file: {paths:#?}"
380 );
381 }
382 }
383}
384
385#[tokio::test]
386async fn diff_of_a_revision_against_itself_is_empty() {
387 let Some(f) = load("basic") else { return };
388 let rev = head(&f).await;
389 let d = f
390 .store
391 .diff(f.id, &rev, &rev, Default::default())
392 .await
393 .expect("diff");
394 assert!(d.files.is_empty());
395 assert_eq!((d.total_additions, d.total_deletions), (0, 0));
396}
397
398#[tokio::test]
399async fn an_unknown_revision_is_reported_not_panicked() {
400 let Some(f) = load("basic") else { return };
401 let bogus = RevId::from_stored("0000000000000000000000000000000000000000");
402 assert!(matches!(
403 f.store.revision(f.id, &bogus).await,
404 Err(StoreError::NoSuchRevision)
405 ));
406 assert!(matches!(
407 f.store.resolve(f.id, "no-such-bookmark").await,
408 Err(StoreError::NoSuchRevision)
409 ));
410}
411
412#[tokio::test]
413async fn revision_specs_containing_control_characters_are_refused() {
414 let Some(f) = load("basic") else { return };
415 for bad in ["main\n", "main\0", "\x1b[31m"] {
416 assert!(
417 matches!(f.store.resolve(f.id, bad).await, Err(StoreError::NoSuchRevision)),
418 "spec {bad:?} must be refused"
419 );
420 }
421}
422
423#[tokio::test]
424async fn size_bytes_reports_something_plausible() {
425 let Some(f) = load("basic") else { return };
426 let n = f.store.size_bytes(f.id).await.expect("size");
427 assert!(n > 0, "a populated repository occupies disk");
428}
429
430// ─── conflicts (spec §4, M4) ─────────────────────────────────────────────────
431
432/// Read a manifest value for a fixture case.
433fn manifest(case: &str, key: &str) -> Option<String> {
434 let raw = std::fs::read_to_string(corpus().join("manifest.json")).ok()?;
435 let v: serde_json::Value = serde_json::from_str(&raw).ok()?;
436 v.get("cases")?.get(case)?.get(key)?.as_str().map(str::to_owned)
437}
438
439#[tokio::test]
440async fn a_conflicted_revision_reports_its_sides() {
441 let Some(f) = load("conflict") else { return };
442 let Some(rev) = manifest("conflict", "conflicted_commit") else {
443 panic!("the conflict fixture must record its conflicted commit");
444 };
445 let rev = RevId::from_stored(rev);
446
447 let revision = f.store.revision(f.id, &rev).await.expect("revision");
448 assert!(revision.conflicted, "the fixture's commit is supposed to be conflicted");
449
450 let files = f.store.conflicts(f.id, &rev).await.expect("conflicts");
451 assert!(!files.is_empty(), "a conflicted revision must report at least one file");
452
453 for file in &files {
454 // jj's representation is side/base/side…, so there is always an odd
455 // number of columns and at least one base to compare against.
456 assert!(
457 file.sides.len() >= 3,
458 "{}: expected at least side/base/side, got {}",
459 file.path,
460 file.sides.len()
461 );
462 assert!(
463 file.sides.iter().any(|(l, _)| l.is_base()),
464 "{}: a conflict without a base cannot be read by a reviewer",
465 file.path
466 );
467 // jj's own bookkeeping must never surface as a conflicted file.
468 assert!(
469 !file.path.contains(".jjconflict"),
470 "storage detail leaked into the conflict view: {}",
471 file.path
472 );
473 }
474}
475
476#[tokio::test]
477async fn an_unconflicted_revision_has_no_conflicts_rather_than_an_error() {
478 let Some(f) = load("basic") else { return };
479 let head = head(&f).await;
480 let files = f.store.conflicts(f.id, &head).await.expect("conflicts");
481 assert!(files.is_empty());
482}
483
484// ─── word-level diffing (spec §8) ────────────────────────────────────────────
485
486#[tokio::test]
487async fn changed_lines_carry_word_level_spans() {
488 let Some(f) = load("basic") else { return };
489 let head = head(&f).await;
490
491 let diff = f
492 .store
493 .diff_from_parent(f.id, &head, df_store::DiffOpts::default())
494 .await
495 .expect("diff");
496
497 let mut saw_span = false;
498 for file in &diff.files {
499 for hunk in &file.hunks {
500 for line in &hunk.lines {
501 // The invariant every renderer relies on: spans reconstruct the
502 // line exactly, so a view can render spans and never content.
503 let rebuilt: String = line.spans.iter().map(|s| s.text.as_str()).collect();
504 assert_eq!(
505 rebuilt, line.content,
506 "spans must reconstruct the line exactly"
507 );
508
509 if matches!(line.kind, df_store::DiffLineKind::Context) {
510 assert!(
511 line.spans.iter().all(|s| !s.emphasis),
512 "a context line has nothing to emphasise"
513 );
514 }
515 if line.spans.iter().any(|s| s.emphasis) {
516 saw_span = true;
517 }
518 }
519 }
520 }
521
522 // Not asserted unconditionally: whether any line in the fixture is a
523 // *modification* rather than a pure add depends on the fixture. The
524 // reconstruction invariant above is the one that must always hold.
525 if !saw_span {
526 eprintln!("note: no intra-line changes in this fixture's head diff");
527 }
528}
529
530// ─── push validation (spec §4, §9) ───────────────────────────────────────────
531
532/// A repository without this hook accepts anything: no ref-name allowlist and
533/// no protected bookmarks. Git only runs `hooks/pre-receive`, and only if it is
534/// executable — a hook that is present but not `+x` is silently skipped, which
535/// is the same failure with a more confusing cause.
536#[tokio::test]
537async fn receive_validation_installs_an_executable_pre_receive_hook() {
538 let tmp = tempfile::tempdir().unwrap();
539 let store = GitStore::new(tmp.path());
540 let id = RepoId(Uuid::now_v7());
541 store.create(id, "main").await.expect("create");
542
543 store
544 .configure_receive_validation(id, "/usr/local/bin/dogfood-hook")
545 .await
546 .expect("installing the hook");
547
548 let hook = store.repo_path(id).join("hooks").join("pre-receive");
549 assert!(hook.is_file(), "git only runs hooks/pre-receive");
550
551 #[cfg(unix)]
552 {
553 use std::os::unix::fs::PermissionsExt;
554 let mode = std::fs::metadata(&hook).unwrap().permissions().mode();
555 assert!(
556 mode & 0o111 != 0,
557 "a non-executable hook is skipped silently: mode {mode:o}"
558 );
559 }
560
561 let body = std::fs::read_to_string(&hook).unwrap();
562 assert!(
563 body.contains("/usr/local/bin/dogfood-hook"),
564 "the hook must exec the compiled binary, not implement checks in shell: {body}"
565 );
566 assert!(
567 body.contains(&id.0.to_string()),
568 "the binary needs the repository id and git does not pass one: {body}"
569 );
570}
571
572/// Installing twice must be a no-op, because it runs on every boot.
573#[tokio::test]
574async fn receive_validation_is_idempotent() {
575 let tmp = tempfile::tempdir().unwrap();
576 let store = GitStore::new(tmp.path());
577 let id = RepoId(Uuid::now_v7());
578 store.create(id, "main").await.expect("create");
579
580 store.configure_receive_validation(id, "/hook").await.unwrap();
581 let first = std::fs::read_to_string(store.repo_path(id).join("hooks/pre-receive")).unwrap();
582
583 store.configure_receive_validation(id, "/hook").await.unwrap();
584 let second = std::fs::read_to_string(store.repo_path(id).join("hooks/pre-receive")).unwrap();
585
586 assert_eq!(first, second);
587}
588
589// ─── in-browser editing ──────────────────────────────────────────────────────
590
591async fn tip(f: &Fixture) -> RevId {
592 f.store.resolve(f.id, "main").await.expect("resolving main")
593}
594
595#[tokio::test]
596async fn committing_an_edited_file_advances_the_bookmark() {
597 let Some(f) = load("basic") else { return };
598 let before = tip(&f).await;
599
600 let author = df_store::Signature {
601 name: "Alice".into(),
602 email: "alice@example.com".into(),
603 when: chrono::Utc::now(),
604 };
605
606 // Pick a file that exists, so this exercises replacement rather than
607 // creation.
608 let entries = f
609 .store
610 .list_tree(f.id, &before, Path::new(""))
611 .await
612 .expect("tree");
613 let target = entries
614 .iter()
615 .find(|e| !e.is_dir())
616 .expect("the fixture has a file")
617 .path
618 .clone();
619
620 let outcome = f
621 .store
622 .commit_file(
623 f.id,
624 "main",
625 &before,
626 &target,
627 b"edited by the web editor\n".to_vec(),
628 "edit: change a file",
629 &author,
630 )
631 .await
632 .expect("commit_file");
633
634 let rev = match outcome {
635 df_store::EditOutcome::Committed { rev } => rev,
636 other => panic!("expected a commit, got {other:?}"),
637 };
638
639 assert_ne!(rev, before, "a new commit must be written");
640 assert_eq!(tip(&f).await, rev, "the bookmark must point at it");
641
642 // The content is what we wrote…
643 let blob = f
644 .store
645 .read_blob(f.id, &rev, Path::new(&target))
646 .await
647 .expect("read back");
648 assert_eq!(blob.text(), Some("edited by the web editor\n"));
649
650 // …and the parent is the old tip, so this is a fast-forward and the
651 // protected-bookmark rule is satisfied by construction.
652 let revision = f.store.revision(f.id, &rev).await.expect("revision");
653 assert_eq!(revision.parents, vec![before]);
654
655 // A web edit is a plain-git commit: no invented jj change id (spec §4).
656 assert_eq!(revision.change_id, None);
657}
658
659#[tokio::test]
660async fn committing_an_unchanged_file_writes_nothing() {
661 let Some(f) = load("basic") else { return };
662 let before = tip(&f).await;
663
664 let entries = f.store.list_tree(f.id, &before, Path::new("")).await.unwrap();
665 let target = entries.iter().find(|e| !e.is_dir()).unwrap().path.clone();
666 let existing = f
667 .store
668 .read_blob(f.id, &before, Path::new(&target))
669 .await
670 .unwrap()
671 .content;
672
673 let author = df_store::Signature {
674 name: "A".into(),
675 email: "a@b.c".into(),
676 when: chrono::Utc::now(),
677 };
678
679 let outcome = f
680 .store
681 .commit_file(f.id, "main", &before, &target, existing, "noop", &author)
682 .await
683 .expect("commit_file");
684
685 assert_eq!(outcome, df_store::EditOutcome::Unchanged);
686 assert_eq!(tip(&f).await, before, "the bookmark must not move");
687}
688
689/// The concurrent-edit case. Without this the second writer silently discards
690/// the first one's commit.
691#[tokio::test]
692async fn an_edit_against_a_stale_revision_is_refused() {
693 let Some(f) = load("basic") else { return };
694 let original = tip(&f).await;
695
696 let author = df_store::Signature {
697 name: "A".into(),
698 email: "a@b.c".into(),
699 when: chrono::Utc::now(),
700 };
701
702 // Somebody else lands a commit first.
703 f.store
704 .commit_file(
705 f.id,
706 "main",
707 &original,
708 "shared.txt",
709 b"first writer\n".to_vec(),
710 "first",
711 &author,
712 )
713 .await
714 .expect("first edit");
715
716 let moved = tip(&f).await;
717 assert_ne!(moved, original);
718
719 // The second writer composed their edit against the original revision.
720 let outcome = f
721 .store
722 .commit_file(
723 f.id,
724 "main",
725 &original,
726 "shared.txt",
727 b"second writer\n".to_vec(),
728 "second",
729 &author,
730 )
731 .await
732 .expect("commit_file");
733
734 match outcome {
735 df_store::EditOutcome::Stale { current } => assert_eq!(current, moved),
736 other => panic!("a stale edit must be refused, got {other:?}"),
737 }
738
739 assert_eq!(tip(&f).await, moved, "nothing may be written");
740 assert_eq!(
741 f.store
742 .read_blob(f.id, &moved, Path::new("shared.txt"))
743 .await
744 .unwrap()
745 .text(),
746 Some("first writer\n"),
747 "the first writer's content must survive"
748 );
749}
750
751#[tokio::test]
752async fn the_editor_write_path_refuses_traversal_and_oversized_content() {
753 let Some(f) = load("basic") else { return };
754 let before = tip(&f).await;
755 let author = df_store::Signature {
756 name: "A".into(),
757 email: "a@b.c".into(),
758 when: chrono::Utc::now(),
759 };
760
761 for path in ["../escape.txt", "/etc/passwd", "a/../../b.txt", ""] {
762 let e = f
763 .store
764 .commit_file(f.id, "main", &before, path, b"x".to_vec(), "m", &author)
765 .await;
766 assert!(e.is_err(), "{path} was accepted by the write path");
767 }
768
769 let huge = vec![b'a'; 2 * 1024 * 1024];
770 assert!(matches!(
771 f.store
772 .commit_file(f.id, "main", &before, "big.txt", huge, "m", &author)
773 .await,
774 Err(StoreError::TooLarge { .. })
775 ));
776
777 assert_eq!(tip(&f).await, before, "no refused edit may move the bookmark");
778}

778 lines · Rust