Skip to main content

git_closure/
lib.rs

1//! git-closure — Deterministic S-expression source snapshots.
2//!
3//! # Public API
4//!
5//! | Function | Description |
6//! |---|---|
7//! | [`build_snapshot`] | Build a snapshot from a local directory |
8//! | [`build_snapshot_with_options`] | Build with explicit options |
9//! | [`build_snapshot_from_source`] | Build from a URL / source specifier |
10//! | [`build_snapshot_from_provider`] | Build via a custom [`providers::Provider`] |
11//! | [`verify_snapshot`] | Verify snapshot integrity |
12//! | [`materialize_snapshot`] | Restore a snapshot to a directory |
13//! | [`materialize_snapshot_with_options`] | Restore with explicit policy options |
14//! | [`diff_snapshots`] | Compare two snapshots and return structured differences |
15//! | [`diff_snapshot_to_source`] | Compare a snapshot against a live source directory |
16//! | [`render_snapshot`] | Render a snapshot as Markdown, HTML, or JSON |
17//! | [`fmt_snapshot`] | Canonicalize snapshot formatting |
18//! | [`fmt_snapshot_with_options`] | Canonicalize formatting with explicit options |
19//! | [`list_snapshot`] | List snapshot entries from a file path |
20//! | [`list_snapshot_str`] | List snapshot entries from in-memory text |
21//! | [`parse_snapshot`] | Parse in-memory snapshot text into header + entries |
22//! | [`summarize_snapshot`] | Compute aggregate snapshot metadata |
23//!
24//! | Type | Description |
25//! |---|---|
26//! | [`GitClosureError`] | Typed error taxonomy for build/verify/materialize operations |
27//! | [`BuildOptions`] | Build-mode toggles (`include_untracked`, `require_clean`) |
28//! | [`VerifyReport`] | Summary returned by [`verify_snapshot`] |
29//! | [`MaterializeOptions`] | Options controlling materialization behavior |
30//! | [`MaterializePolicy`] | Policy profile for materialization safety/compatibility |
31//! | [`ListEntry`] | Structured row returned by listing operations |
32//! | [`SnapshotHeader`] | Parsed `;;` metadata header block |
33//! | [`SnapshotFile`] | Parsed file/symlink record from a snapshot |
34//! | [`DiffEntry`] | One change record emitted by [`diff_snapshots`] |
35//! | [`DiffResult`] | Deterministic diff output container |
36//! | [`RenderFormat`] | Output selector for [`render_snapshot`] |
37//! | [`FmtOptions`] | Formatting behavior options |
38//! | [`SnapshotSummary`] | Compact snapshot metadata summary |
39
40// ── Module declarations ───────────────────────────────────────────────────────
41
42pub mod error;
43pub mod providers;
44
45pub(crate) mod git;
46pub(crate) mod materialize;
47pub(crate) mod snapshot;
48pub(crate) mod utils;
49
50// ── Public re-exports ─────────────────────────────────────────────────────────
51
52pub use error::GitClosureError;
53pub use materialize::{
54    materialize_snapshot, materialize_snapshot_with_options, verify_snapshot, MaterializeOptions,
55    MaterializePolicy,
56};
57pub use snapshot::build::{
58    build_snapshot, build_snapshot_from_provider, build_snapshot_from_source,
59    build_snapshot_with_options,
60};
61pub use snapshot::diff::{diff_snapshot_to_source, diff_snapshots, DiffEntry, DiffResult};
62pub use snapshot::render::{render_snapshot, RenderFormat};
63pub use snapshot::serial::{
64    fmt_snapshot, fmt_snapshot_with_options, list_snapshot, list_snapshot_str, parse_snapshot,
65    FmtOptions,
66};
67pub use snapshot::summary::summarize_snapshot;
68pub use snapshot::{
69    BuildOptions, ListEntry, SnapshotFile, SnapshotHeader, SnapshotSummary, VerifyReport,
70};
71
72#[doc(hidden)]
73pub fn fuzz_parse_snapshot(input: &str) {
74    let _ = snapshot::serial::parse_snapshot(input);
75}
76
77#[doc(hidden)]
78pub fn fuzz_sanitized_relative_path(path: &str) {
79    let _ = materialize::sanitized_relative_path(path);
80}
81
82#[doc(hidden)]
83pub fn fuzz_lexical_normalize(path: &str) {
84    let _ = utils::lexical_normalize(std::path::Path::new(path));
85}
86
87// ── Integration test suite ────────────────────────────────────────────────────
88
89#[cfg(test)]
90mod tests {
91    use crate::error::GitClosureError;
92    use crate::git::{
93        ensure_git_source_is_clean, evaluate_git_status_porcelain, git_ls_files,
94        parse_porcelain_entry, GitRepoContext,
95    };
96    use crate::materialize::{materialize_snapshot, verify_snapshot};
97    use crate::providers::{FetchedSource, Provider};
98    use crate::snapshot::build::{
99        build_snapshot, build_snapshot_from_provider, build_snapshot_with_options,
100    };
101    use crate::snapshot::hash::compute_snapshot_hash;
102    use crate::snapshot::{BuildOptions, SnapshotFile};
103    use std::fs;
104    use std::io::Write;
105    use std::path::{Path, PathBuf};
106    use std::process::Command;
107
108    use tempfile::TempDir;
109
110    #[cfg(unix)]
111    use std::os::unix::fs::symlink;
112    #[cfg(unix)]
113    use std::os::unix::fs::PermissionsExt;
114
115    #[test]
116    fn round_trip_is_byte_identical() {
117        let source = TempDir::new().expect("create source tempdir");
118        let restored = TempDir::new().expect("create restored tempdir");
119
120        let alpha_path = source.path().join("alpha.txt");
121        fs::write(&alpha_path, b"alpha\n").expect("write alpha.txt");
122
123        #[cfg(unix)]
124        symlink("alpha.txt", source.path().join("link-to-alpha")).expect("create fixture symlink");
125
126        let nested_dir = source.path().join("nested");
127        fs::create_dir_all(&nested_dir).expect("create nested directory");
128        let script_path = nested_dir.join("script.sh");
129        fs::write(&script_path, b"#!/usr/bin/env sh\necho hi\n").expect("write script.sh");
130
131        #[cfg(unix)]
132        {
133            let perms = fs::Permissions::from_mode(0o755);
134            fs::set_permissions(&script_path, perms).expect("set script permissions");
135        }
136
137        let binary_path = source.path().join("payload.bin");
138        let mut binary_file = fs::File::create(&binary_path).expect("create payload.bin");
139        binary_file
140            .write_all(&[0, 159, 255, 1, 2, 3])
141            .expect("write payload.bin bytes");
142
143        let snapshot_a = source.path().join("snapshot-a.gcl");
144        let snapshot_b = source.path().join("snapshot-b.gcl");
145
146        build_snapshot(source.path(), &snapshot_a).expect("build first snapshot");
147        materialize_snapshot(&snapshot_a, restored.path()).expect("materialize snapshot");
148        build_snapshot(restored.path(), &snapshot_b).expect("build second snapshot");
149
150        #[cfg(unix)]
151        {
152            let restored_link = restored.path().join("link-to-alpha");
153            assert!(
154                restored_link.exists(),
155                "round-trip fixture must include a materialized symlink"
156            );
157            let target = fs::read_link(&restored_link).expect("read materialized fixture symlink");
158            assert_eq!(target, std::path::PathBuf::from("alpha.txt"));
159        }
160
161        let a = fs::read(&snapshot_a).expect("read snapshot-a");
162        let b = fs::read(&snapshot_b).expect("read snapshot-b");
163        assert_eq!(a, b, "round trip snapshots differ");
164    }
165
166    #[cfg(unix)]
167    #[test]
168    fn round_trip_includes_symlink() {
169        let source = TempDir::new().expect("create source tempdir");
170        let restored = TempDir::new().expect("create restored tempdir");
171
172        fs::write(source.path().join("alpha.txt"), b"alpha\n").expect("write alpha");
173        std::os::unix::fs::symlink("alpha.txt", source.path().join("link-to-alpha"))
174            .expect("create symlink");
175
176        let snapshot_a = source.path().join("snap-a.gcl");
177        let snapshot_b = source.path().join("snap-b.gcl");
178
179        build_snapshot(source.path(), &snapshot_a).expect("build snapshot");
180        materialize_snapshot(&snapshot_a, restored.path()).expect("materialize");
181        build_snapshot(restored.path(), &snapshot_b).expect("rebuild");
182
183        assert_eq!(
184            fs::read(&snapshot_a).expect("read snap-a"),
185            fs::read(&snapshot_b).expect("read snap-b"),
186            "symlink round-trip must be byte-identical"
187        );
188
189        let link = restored.path().join("link-to-alpha");
190        assert!(link.exists(), "symlink must exist after materialize");
191        assert_eq!(
192            fs::read_link(&link).expect("read link"),
193            std::path::PathBuf::from("alpha.txt")
194        );
195    }
196
197    #[test]
198    fn materialize_rejects_parent_traversal_path() {
199        let temp = TempDir::new().expect("create tempdir");
200        let snapshot = temp.path().join("evil.gcl");
201        let output = temp.path().join("out");
202
203        let content = "x";
204        let digest = {
205            use sha2::{Digest, Sha256};
206            let mut hasher = Sha256::new();
207            hasher.update(content.as_bytes());
208            format!("{:x}", hasher.finalize())
209        };
210
211        let snapshot_text = format!(
212            ";; git-closure snapshot v0.1\n;; snapshot-hash: {digest}\n;; file-count: 1\n\n(\n  ((:path \"../escape.txt\" :sha256 \"{digest}\" :mode \"644\" :size 1) \"x\")\n)\n"
213        );
214        fs::write(&snapshot, snapshot_text).expect("write malicious snapshot");
215
216        let result = materialize_snapshot(&snapshot, &output);
217        assert!(result.is_err(), "materialize should reject traversal path");
218    }
219
220    #[test]
221    fn verify_accepts_valid_snapshot() {
222        let source = TempDir::new().expect("create source tempdir");
223        fs::write(source.path().join("ok.txt"), b"ok\n").expect("write source file");
224
225        let snapshot = source.path().join("snapshot.gcl");
226        build_snapshot(source.path(), &snapshot).expect("build snapshot");
227
228        let report = verify_snapshot(&snapshot).expect("verify should pass");
229        assert_eq!(report.file_count, 1);
230    }
231
232    #[test]
233    fn verify_rejects_absolute_symlink_target_outside_root() {
234        let temp = TempDir::new().expect("create tempdir");
235        let snapshot = temp.path().join("abs-link.gcl");
236
237        let files = vec![SnapshotFile {
238            path: "link".to_string(),
239            sha256: String::new(),
240            mode: "120000".to_string(),
241            size: 0,
242            encoding: None,
243            symlink_target: Some("/etc/passwd".to_string()),
244            content: Vec::new(),
245        }];
246        let snapshot_hash = compute_snapshot_hash(&files);
247        let text = format!(
248            ";; git-closure snapshot v0.1\n;; snapshot-hash: {snapshot_hash}\n;; file-count: 1\n\n(\n  ((:path \"link\" :type \"symlink\" :target \"/etc/passwd\") \"\")\n)\n"
249        );
250        fs::write(&snapshot, text).expect("write snapshot");
251
252        let err = verify_snapshot(&snapshot)
253            .expect_err("verify must reject absolute symlink targets outside synthetic root");
254        assert!(matches!(err, GitClosureError::UnsafePath(_)));
255    }
256
257    #[test]
258    fn verify_rejects_relative_symlink_target_traversal() {
259        let temp = TempDir::new().expect("create tempdir");
260        let snapshot = temp.path().join("rel-escape.gcl");
261
262        let files = vec![SnapshotFile {
263            path: "subdir/link".to_string(),
264            sha256: String::new(),
265            mode: "120000".to_string(),
266            size: 0,
267            encoding: None,
268            symlink_target: Some("../../escape".to_string()),
269            content: Vec::new(),
270        }];
271        let snapshot_hash = compute_snapshot_hash(&files);
272        let text = format!(
273            ";; git-closure snapshot v0.1\n;; snapshot-hash: {snapshot_hash}\n;; file-count: 1\n\n(\n  ((:path \"subdir/link\" :type \"symlink\" :target \"../../escape\") \"\")\n)\n"
274        );
275        fs::write(&snapshot, text).expect("write snapshot");
276
277        let err = verify_snapshot(&snapshot)
278            .expect_err("verify must reject relative symlink traversal targets");
279        assert!(matches!(err, GitClosureError::UnsafePath(_)));
280    }
281
282    #[test]
283    fn verify_accepts_safe_relative_symlink_target() {
284        let temp = TempDir::new().expect("create tempdir");
285        let snapshot = temp.path().join("rel-safe.gcl");
286
287        let files = vec![SnapshotFile {
288            path: "subdir/link".to_string(),
289            sha256: String::new(),
290            mode: "120000".to_string(),
291            size: 0,
292            encoding: None,
293            symlink_target: Some("../sibling".to_string()),
294            content: Vec::new(),
295        }];
296        let snapshot_hash = compute_snapshot_hash(&files);
297        let text = format!(
298            ";; git-closure snapshot v0.1\n;; snapshot-hash: {snapshot_hash}\n;; file-count: 1\n\n(\n  ((:path \"subdir/link\" :type \"symlink\" :target \"../sibling\") \"\")\n)\n"
299        );
300        fs::write(&snapshot, text).expect("write snapshot");
301
302        verify_snapshot(&snapshot).expect("safe relative symlink target should verify");
303    }
304
305    #[test]
306    fn verify_missing_file_returns_io_error_variant() {
307        let path = Path::new("/nonexistent/path/snapshot.gcl");
308        let err = verify_snapshot(path).expect_err("verify should fail for missing file");
309        assert!(
310            matches!(err, GitClosureError::Io(_)),
311            "expected Io variant, got: {err:?}"
312        );
313    }
314
315    #[test]
316    fn materialize_missing_output_parent_returns_io_error_variant() {
317        let temp = TempDir::new().expect("create tempdir");
318        let snapshot = temp.path().join("empty.gcl");
319        let blocking_parent = temp.path().join("not-a-directory");
320        fs::write(&blocking_parent, b"file").expect("create blocking file");
321
322        fs::write(
323            &snapshot,
324            ";; git-closure snapshot v0.1\n;; snapshot-hash: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n;; file-count: 0\n\n()\n",
325        )
326        .expect("write empty snapshot");
327
328        let output = blocking_parent.join("child");
329        let err = materialize_snapshot(&snapshot, &output)
330            .expect_err("materialize should fail when output parent is not a directory");
331        assert!(
332            matches!(err, GitClosureError::Io(_)),
333            "expected Io variant, got: {err:?}"
334        );
335    }
336
337    #[test]
338    fn io_error_display_includes_snapshot_path() {
339        let path = std::path::Path::new("/nonexistent/path/my-snapshot.gcl");
340        let err = verify_snapshot(path).expect_err("should fail on missing file");
341
342        assert!(
343            matches!(err, GitClosureError::Io(_)),
344            "expected Io variant, got: {err:?}"
345        );
346
347        let msg = err.to_string();
348        assert!(
349            msg.contains("my-snapshot.gcl") || msg.contains("nonexistent"),
350            "error message must contain path context, got: {msg:?}"
351        );
352    }
353
354    #[test]
355    fn io_error_display_includes_output_path_on_missing_dir() {
356        let source = TempDir::new().expect("create source tempdir");
357        fs::write(source.path().join("ok.txt"), b"ok\n").expect("write file");
358        let snapshot = source.path().join("snap.gcl");
359        build_snapshot(source.path(), &snapshot).expect("build snapshot");
360
361        let blocked_parent = source.path().join("blocked-parent");
362        fs::write(&blocked_parent, b"not a directory").expect("create blocking file");
363        let bad_output = blocked_parent.join("output-dir");
364        let err = materialize_snapshot(&snapshot, &bad_output)
365            .expect_err("should fail on non-directory parent");
366
367        assert!(
368            matches!(err, GitClosureError::Io(_)),
369            "expected Io variant, got: {err:?}"
370        );
371        let msg = err.to_string();
372        assert!(
373            msg.contains("output-dir") || msg.contains("blocked-parent"),
374            "error message must contain output path context, got: {msg:?}"
375        );
376    }
377
378    #[test]
379    fn io_error_display_includes_build_output_path() {
380        let source = TempDir::new().expect("create source tempdir");
381        fs::write(source.path().join("ok.txt"), b"ok\n").expect("write source file");
382
383        let blocked_parent = source.path().join("blocked-parent");
384        fs::write(&blocked_parent, b"not a directory").expect("create blocking file");
385
386        let output = blocked_parent.join("child").join("snap.gcl");
387        let err = build_snapshot(source.path(), &output).expect_err("build should fail");
388
389        assert!(
390            matches!(err, GitClosureError::Io(_)),
391            "expected Io variant, got: {err:?}"
392        );
393
394        let msg = err.to_string();
395        assert!(
396            msg.contains("blocked-parent") || msg.contains("child"),
397            "error message must include failing output path context, got: {msg:?}"
398        );
399    }
400
401    #[test]
402    fn io_error_display_includes_build_source_path_on_canonicalize_failure() {
403        let missing = Path::new("/nonexistent/path/missing-source-dir");
404        let output = Path::new("/tmp/unused-output.gcl");
405        let err =
406            build_snapshot(missing, output).expect_err("build should fail for missing source");
407
408        assert!(
409            matches!(err, GitClosureError::Io(_)),
410            "expected Io variant, got: {err:?}"
411        );
412
413        let msg = err.to_string();
414        assert!(
415            msg.contains("missing-source-dir") || msg.contains("nonexistent"),
416            "error message must include source path context, got: {msg:?}"
417        );
418    }
419
420    #[test]
421    fn verify_rejects_bad_format_hash() {
422        let temp = TempDir::new().expect("create tempdir");
423        let snapshot = temp.path().join("invalid.gcl");
424
425        let digest = {
426            use sha2::{Digest, Sha256};
427            let mut hasher = Sha256::new();
428            hasher.update(b"x");
429            format!("{:x}", hasher.finalize())
430        };
431
432        let snapshot_text = format!(
433            ";; git-closure snapshot v0.1\n;; snapshot-hash: deadbeef\n;; file-count: 1\n\n(\n  ((:path \"x.txt\" :sha256 \"{digest}\" :mode \"644\" :size 1) \"x\")\n)\n"
434        );
435        fs::write(&snapshot, snapshot_text).expect("write invalid snapshot");
436
437        let result = verify_snapshot(&snapshot);
438        assert!(result.is_err(), "verify should reject bad format hash");
439    }
440
441    #[test]
442    fn verify_odd_length_plist_returns_parse_error() {
443        let temp = TempDir::new().expect("create tempdir");
444        let snapshot = temp.path().join("malformed-plist.gcl");
445
446        let snapshot_text = ";; git-closure snapshot v0.1\n;; snapshot-hash: deadbeef\n;; file-count: 1\n\n(\n  ((:path \"x.txt\" :sha256) \"x\")\n)\n";
447        fs::write(&snapshot, snapshot_text).expect("write malformed snapshot");
448
449        let err = verify_snapshot(&snapshot).expect_err("odd-length plist should fail parse");
450        assert!(matches!(err, GitClosureError::Parse(_)));
451        let msg = err.to_string();
452        assert!(
453            msg.contains("plist")
454                || msg.contains("malformed")
455                || msg.contains("parse")
456                || msg.contains("x.txt"),
457            "parse error should include contextual detail, got: {msg:?}"
458        );
459    }
460
461    #[test]
462    fn collision_regression_same_content_different_path() {
463        let left = TempDir::new().expect("create left tempdir");
464        let right = TempDir::new().expect("create right tempdir");
465
466        fs::write(left.path().join("a.txt"), b"same\n").expect("write left file");
467        fs::write(right.path().join("b.txt"), b"same\n").expect("write right file");
468
469        let left_snapshot = left.path().join("left.gcl");
470        let right_snapshot = right.path().join("right.gcl");
471
472        build_snapshot(left.path(), &left_snapshot).expect("build left snapshot");
473        build_snapshot(right.path(), &right_snapshot).expect("build right snapshot");
474
475        let left_hash = read_snapshot_hash(&left_snapshot);
476        let right_hash = read_snapshot_hash(&right_snapshot);
477
478        assert_ne!(
479            left_hash, right_hash,
480            "snapshot hash must differ when path differs"
481        );
482    }
483
484    #[cfg(unix)]
485    #[test]
486    fn snapshot_hash_protocol_is_consistent_across_entry_types() {
487        let source = TempDir::new().expect("create source tempdir");
488        fs::write(source.path().join("regular.txt"), b"hello\n").expect("write regular file");
489        symlink("regular.txt", source.path().join("link")).expect("create symlink");
490
491        let snapshot = source.path().join("mixed.gcl");
492        build_snapshot(source.path(), &snapshot).expect("build mixed snapshot");
493
494        let hash = read_snapshot_hash(&snapshot);
495        assert_eq!(hash.len(), 64, "snapshot hash should be 64 hex chars");
496        assert!(
497            hash.chars().all(|c| c.is_ascii_hexdigit()),
498            "snapshot hash should be lowercase hex"
499        );
500
501        verify_snapshot(&snapshot).expect("verify should accept mixed entry types");
502    }
503
504    #[test]
505    fn snapshot_hash_uses_length_prefix_not_null_termination() {
506        let files = vec![
507            SnapshotFile {
508                path: "alpha.txt".to_string(),
509                sha256: "a".repeat(64),
510                mode: "644".to_string(),
511                size: 1,
512                encoding: None,
513                symlink_target: None,
514                content: vec![b'x'],
515            },
516            SnapshotFile {
517                path: "sym".to_string(),
518                sha256: String::new(),
519                mode: "120000".to_string(),
520                size: 0,
521                encoding: None,
522                symlink_target: Some("../target.txt".to_string()),
523                content: Vec::new(),
524            },
525        ];
526
527        let actual = compute_snapshot_hash(&files);
528        let expected = manual_snapshot_hash_with_length_prefix(&files);
529        assert_eq!(
530            actual, expected,
531            "snapshot hash must match documented length-prefixed protocol"
532        );
533    }
534
535    #[cfg(unix)]
536    #[test]
537    fn collision_regression_same_path_different_mode() {
538        let left = TempDir::new().expect("create left tempdir");
539        let right = TempDir::new().expect("create right tempdir");
540
541        let left_file = left.path().join("run.sh");
542        let right_file = right.path().join("run.sh");
543
544        fs::write(&left_file, b"echo hi\n").expect("write left file");
545        fs::write(&right_file, b"echo hi\n").expect("write right file");
546
547        fs::set_permissions(&left_file, fs::Permissions::from_mode(0o644))
548            .expect("set left permissions");
549        fs::set_permissions(&right_file, fs::Permissions::from_mode(0o755))
550            .expect("set right permissions");
551
552        let left_snapshot = left.path().join("left.gcl");
553        let right_snapshot = right.path().join("right.gcl");
554
555        build_snapshot(left.path(), &left_snapshot).expect("build left snapshot");
556        build_snapshot(right.path(), &right_snapshot).expect("build right snapshot");
557
558        let left_hash = read_snapshot_hash(&left_snapshot);
559        let right_hash = read_snapshot_hash(&right_snapshot);
560
561        assert_ne!(
562            left_hash, right_hash,
563            "snapshot hash must differ when mode differs"
564        );
565    }
566
567    #[test]
568    fn verify_rejects_legacy_format_hash_header() {
569        let temp = TempDir::new().expect("create tempdir");
570        let snapshot = temp.path().join("legacy.gcl");
571
572        let digest = {
573            use sha2::{Digest, Sha256};
574            let mut hasher = Sha256::new();
575            hasher.update(b"x");
576            format!("{:x}", hasher.finalize())
577        };
578
579        let snapshot_text = format!(
580            ";; git-closure snapshot v0.1\n;; format-hash: deadbeef\n;; file-count: 1\n\n(\n  ((:path \"x.txt\" :sha256 \"{digest}\" :mode \"644\" :size 1) \"x\")\n)\n"
581        );
582        fs::write(&snapshot, snapshot_text).expect("write legacy snapshot");
583
584        let err = verify_snapshot(&snapshot).expect_err("legacy format hash must be rejected");
585        let message = format!("{err:#}");
586        assert!(
587            (message.contains("format-hash") || message.contains("snapshot-hash"))
588                && message.contains("re-snapshot"),
589            "error should mention legacy header migration: {message}"
590        );
591    }
592
593    #[test]
594    fn verify_legacy_header_maps_to_typed_error() {
595        let temp = TempDir::new().expect("create tempdir");
596        let snapshot = temp.path().join("legacy.gcl");
597        fs::write(
598            &snapshot,
599            ";; git-closure snapshot v0.1\n;; format-hash: deadbeef\n;; file-count: 0\n\n()\n",
600        )
601        .expect("write legacy snapshot");
602
603        let err = verify_snapshot(&snapshot).expect_err("legacy header should fail");
604        assert!(matches!(err, GitClosureError::LegacyHeader));
605    }
606
607    #[test]
608    fn materialize_path_traversal_maps_to_typed_error() {
609        let temp = TempDir::new().expect("create tempdir");
610        let snapshot = temp.path().join("evil.gcl");
611        let output = temp.path().join("out");
612
613        let content = "x";
614        let digest = {
615            use sha2::{Digest, Sha256};
616            let mut hasher = Sha256::new();
617            hasher.update(content.as_bytes());
618            format!("{:x}", hasher.finalize())
619        };
620
621        let snapshot_hash = {
622            use sha2::{Digest, Sha256};
623            let mut hasher = Sha256::new();
624            hasher.update((b"regular".len() as u64).to_be_bytes());
625            hasher.update(b"regular");
626            hasher.update(("../escape.txt".len() as u64).to_be_bytes());
627            hasher.update(b"../escape.txt");
628            hasher.update((b"644".len() as u64).to_be_bytes());
629            hasher.update(b"644");
630            hasher.update((digest.len() as u64).to_be_bytes());
631            hasher.update(digest.as_bytes());
632            format!("{:x}", hasher.finalize())
633        };
634
635        let snapshot_text = format!(
636            ";; git-closure snapshot v0.1\n;; snapshot-hash: {snapshot_hash}\n;; file-count: 1\n\n(\n  ((:path \"../escape.txt\" :sha256 \"{digest}\" :mode \"644\" :size 1) \"x\")\n)\n"
637        );
638        fs::write(&snapshot, snapshot_text).expect("write malicious snapshot");
639
640        let err = materialize_snapshot(&snapshot, &output).expect_err("materialize should fail");
641        assert!(matches!(err, GitClosureError::UnsafePath(_)));
642    }
643
644    #[test]
645    fn collision_regression_rebuild_is_byte_identical() {
646        let source = TempDir::new().expect("create source tempdir");
647        let snapshots = TempDir::new().expect("create snapshot tempdir");
648        fs::write(source.path().join("a.txt"), b"alpha\n").expect("write a.txt");
649        fs::create_dir_all(source.path().join("bin")).expect("create bin directory");
650        let script = source.path().join("bin").join("run.sh");
651        fs::write(&script, b"#!/bin/sh\necho hi\n").expect("write script");
652
653        #[cfg(unix)]
654        fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).expect("set script mode");
655
656        let first = snapshots.path().join("first.gcl");
657        let second = snapshots.path().join("second.gcl");
658        build_snapshot(source.path(), &first).expect("build first snapshot");
659        build_snapshot(source.path(), &second).expect("build second snapshot");
660
661        let a = fs::read(first).expect("read first snapshot");
662        let b = fs::read(second).expect("read second snapshot");
663        assert_eq!(a, b, "snapshot output must be deterministic");
664    }
665
666    #[cfg(unix)]
667    #[test]
668    fn symlink_survives_round_trip() {
669        let source = TempDir::new().expect("create source tempdir");
670        let restored = TempDir::new().expect("create restored tempdir");
671
672        fs::write(source.path().join("target.txt"), b"payload\n").expect("write target file");
673        symlink("target.txt", source.path().join("result")).expect("create source symlink");
674
675        let snapshot = source.path().join("snapshot.gcl");
676        build_snapshot(source.path(), &snapshot).expect("build snapshot");
677        materialize_snapshot(&snapshot, restored.path()).expect("materialize snapshot");
678
679        let restored_link = restored.path().join("result");
680        assert!(
681            restored_link.exists(),
682            "materialized symlink path should exist"
683        );
684        let target = fs::read_link(&restored_link).expect("read materialized symlink target");
685        assert_eq!(target, std::path::PathBuf::from("target.txt"));
686
687        let snapshot_b = restored.path().join("snapshot-b.gcl");
688        build_snapshot(restored.path(), &snapshot_b).expect("rebuild from materialized snapshot");
689
690        let a_bytes = fs::read(&snapshot).expect("read original snapshot");
691        let b_bytes = fs::read(&snapshot_b).expect("read rebuilt snapshot");
692        assert_eq!(
693            a_bytes, b_bytes,
694            "rebuild from materialized symlink snapshot must be byte-identical"
695        );
696    }
697
698    #[cfg(unix)]
699    #[test]
700    fn symlink_target_changes_snapshot_hash() {
701        let left = TempDir::new().expect("create left tempdir");
702        let right = TempDir::new().expect("create right tempdir");
703
704        symlink("one.txt", left.path().join("result")).expect("create left symlink");
705        symlink("two.txt", right.path().join("result")).expect("create right symlink");
706
707        let left_snapshot = left.path().join("left.gcl");
708        let right_snapshot = right.path().join("right.gcl");
709
710        build_snapshot(left.path(), &left_snapshot).expect("build left snapshot");
711        build_snapshot(right.path(), &right_snapshot).expect("build right snapshot");
712
713        let left_hash = read_snapshot_hash(&left_snapshot);
714        let right_hash = read_snapshot_hash(&right_snapshot);
715        assert_ne!(
716            left_hash, right_hash,
717            "symlink target must affect snapshot hash"
718        );
719    }
720
721    #[cfg(unix)]
722    #[test]
723    fn materialize_rejects_symlink_pivot_escape() {
724        let temp = TempDir::new().expect("create tempdir");
725        let snapshot = temp.path().join("symlink-pivot.gcl");
726        let output = temp.path().join("out");
727
728        let payload = b"owned\n";
729        let payload_sha = crate::snapshot::hash::sha256_hex(payload);
730        let files = vec![
731            SnapshotFile {
732                path: "a".to_string(),
733                sha256: String::new(),
734                mode: "120000".to_string(),
735                size: 0,
736                encoding: None,
737                symlink_target: Some("nested".to_string()),
738                content: Vec::new(),
739            },
740            SnapshotFile {
741                path: "a/payload.txt".to_string(),
742                sha256: payload_sha.clone(),
743                mode: "644".to_string(),
744                size: payload.len() as u64,
745                encoding: None,
746                symlink_target: None,
747                content: payload.to_vec(),
748            },
749        ];
750        let snapshot_hash = compute_snapshot_hash(&files);
751        let snapshot_text = format!(
752            ";; git-closure snapshot v0.1\n;; snapshot-hash: {snapshot_hash}\n;; file-count: 2\n\n(\n  ((:path \"a\" :type \"symlink\" :target \"nested\") \"\")\n  ((:path \"a/payload.txt\" :sha256 \"{payload_sha}\" :mode \"644\" :size {}) \"owned\\n\")\n)\n",
753            payload.len()
754        );
755        fs::write(&snapshot, snapshot_text).expect("write snapshot");
756
757        let err = materialize_snapshot(&snapshot, &output)
758            .expect_err("materialize must reject writing through snapshot-created symlink");
759        assert!(
760            matches!(err, GitClosureError::UnsafePath(_)),
761            "expected UnsafePath, got {err:?}"
762        );
763    }
764
765    #[cfg(unix)]
766    #[test]
767    fn materialize_rejects_absolute_symlink_target_outside_output() {
768        let temp = TempDir::new().expect("create tempdir");
769        let snapshot = temp.path().join("escape.gcl");
770        let output = temp.path().join("out");
771
772        let path = "result";
773        let target = "/etc/passwd";
774        let snapshot_hash = symlink_snapshot_hash(path, target);
775
776        let snapshot_text = format!(
777            ";; git-closure snapshot v0.1\n;; snapshot-hash: {snapshot_hash}\n;; file-count: 1\n\n(\n  ((:path \"{path}\" :type \"symlink\" :target \"{target}\") \"\")\n)\n"
778        );
779        fs::write(&snapshot, snapshot_text).expect("write symlink snapshot");
780
781        let err = materialize_snapshot(&snapshot, &output)
782            .expect_err("absolute symlink target outside output must fail");
783        let message = format!("{err:#}");
784        assert!(
785            message.contains("symlink") && message.contains("escapes output directory"),
786            "error should explain unsafe absolute symlink target: {message}"
787        );
788    }
789
790    #[cfg(unix)]
791    #[test]
792    fn materialize_rejects_relative_symlink_traversal() {
793        let temp = TempDir::new().expect("create tempdir");
794        let snapshot = temp.path().join("escape-relative.gcl");
795        let output = temp.path().join("out");
796
797        let path = "foo/link";
798        let target = "../../etc/passwd";
799        let snapshot_hash = symlink_snapshot_hash(path, target);
800        let snapshot_text = format!(
801            ";; git-closure snapshot v0.1\n;; snapshot-hash: {snapshot_hash}\n;; file-count: 1\n\n(\n  ((:path \"{path}\" :type \"symlink\" :target \"{target}\") \"\")\n)\n"
802        );
803        fs::write(&snapshot, snapshot_text).expect("write symlink snapshot");
804
805        let err = materialize_snapshot(&snapshot, &output)
806            .expect_err("relative traversal symlink must be rejected");
807        assert!(matches!(err, GitClosureError::UnsafePath(_)));
808    }
809
810    #[test]
811    fn lexical_normalize_posix_root_parent_stays_at_root() {
812        let normalized =
813            crate::utils::lexical_normalize(Path::new("/../..")).expect("normalize root");
814        assert_eq!(normalized, std::path::PathBuf::from("/"));
815    }
816
817    #[cfg(unix)]
818    #[test]
819    fn materialize_rejects_symlink_whose_effective_target_is_root() {
820        let temp = TempDir::new().expect("create tempdir");
821        let snapshot = temp.path().join("root-target.gcl");
822        let output = temp.path().join("out");
823
824        let path = "link";
825        let target = "/../..";
826        let snapshot_hash = symlink_snapshot_hash(path, target);
827        let snapshot_text = format!(
828            ";; git-closure snapshot v0.1\n;; snapshot-hash: {snapshot_hash}\n;; file-count: 1\n\n(\n  ((:path \"{path}\" :type \"symlink\" :target \"{target}\") \"\")\n)\n"
829        );
830        fs::write(&snapshot, snapshot_text).expect("write symlink snapshot");
831
832        let err = materialize_snapshot(&snapshot, &output)
833            .expect_err("symlink resolving to root must be rejected");
834        assert!(matches!(err, GitClosureError::UnsafePath(_)));
835    }
836
837    #[cfg(unix)]
838    #[test]
839    fn materialize_accepts_valid_relative_symlink() {
840        let temp = TempDir::new().expect("create tempdir");
841        let snapshot = temp.path().join("valid-relative.gcl");
842        let output = temp.path().join("out");
843
844        let path = "subdir/link";
845        let target = "../sibling.txt";
846        let snapshot_hash = symlink_snapshot_hash(path, target);
847        let snapshot_text = format!(
848            ";; git-closure snapshot v0.1\n;; snapshot-hash: {snapshot_hash}\n;; file-count: 1\n\n(\n  ((:path \"{path}\" :type \"symlink\" :target \"{target}\") \"\")\n)\n"
849        );
850        fs::write(&snapshot, snapshot_text).expect("write symlink snapshot");
851
852        materialize_snapshot(&snapshot, &output).expect("safe relative symlink should materialize");
853
854        let link = output.join("subdir/link");
855        let actual_target = fs::read_link(&link).expect("read materialized symlink");
856        assert_eq!(actual_target, std::path::PathBuf::from(target));
857    }
858
859    #[cfg(unix)]
860    #[test]
861    fn materialize_accepts_deeply_nested_relative_symlink() {
862        let temp = TempDir::new().expect("create tempdir");
863        let snapshot = temp.path().join("valid-deep-relative.gcl");
864        let output = temp.path().join("out");
865
866        let path = "a/b/c/link";
867        let target = "../../d/target.txt";
868        let snapshot_hash = symlink_snapshot_hash(path, target);
869        let snapshot_text = format!(
870            ";; git-closure snapshot v0.1\n;; snapshot-hash: {snapshot_hash}\n;; file-count: 1\n\n(\n  ((:path \"{path}\" :type \"symlink\" :target \"{target}\") \"\")\n)\n"
871        );
872        fs::write(&snapshot, snapshot_text).expect("write symlink snapshot");
873
874        materialize_snapshot(&snapshot, &output)
875            .expect("nested safe relative symlink should materialize");
876
877        let link = output.join("a/b/c/link");
878        let actual_target = fs::read_link(&link).expect("read materialized symlink");
879        assert_eq!(actual_target, std::path::PathBuf::from(target));
880    }
881
882    #[test]
883    fn remote_build_round_trip_with_mock_provider() {
884        let fixture = TempDir::new().expect("create fixture tempdir");
885        fs::write(fixture.path().join("a.txt"), b"hello\n").expect("write fixture file");
886        fs::create_dir_all(fixture.path().join("nested")).expect("create nested fixture dir");
887        fs::write(fixture.path().join("nested").join("b.txt"), b"world\n")
888            .expect("write nested fixture file");
889
890        let provider = MockProvider {
891            root: fixture.path().to_path_buf(),
892        };
893
894        let work = TempDir::new().expect("create working tempdir");
895        let restored = TempDir::new().expect("create restored tempdir");
896
897        let snapshot_a = work.path().join("remote-a.gcl");
898        let snapshot_b = work.path().join("remote-b.gcl");
899
900        build_snapshot_from_provider(
901            &provider,
902            "mock://example/repo",
903            &snapshot_a,
904            &BuildOptions::default(),
905        )
906        .expect("build snapshot from mock provider");
907        materialize_snapshot(&snapshot_a, restored.path()).expect("materialize mock snapshot");
908        build_snapshot(restored.path(), &snapshot_b)
909            .expect("build local snapshot after materialize");
910
911        let a = fs::read(&snapshot_a).expect("read remote snapshot");
912        let b = fs::read(&snapshot_b).expect("read rebuilt local snapshot");
913        assert_eq!(a, b, "remote->materialize->local snapshots differ");
914    }
915
916    #[test]
917    fn build_snapshot_from_source_local_path_succeeds() {
918        let source = TempDir::new().expect("create source tempdir");
919        fs::write(source.path().join("x.txt"), b"hello\n").expect("write source file");
920
921        let output_dir = TempDir::new().expect("create output tempdir");
922        let output = output_dir.path().join("snapshot.gcl");
923
924        crate::snapshot::build::build_snapshot_from_source(
925            source.path().to_str().expect("source path utf-8"),
926            &output,
927            &BuildOptions::default(),
928            crate::providers::ProviderKind::Local,
929        )
930        .expect("build from local source must succeed");
931
932        verify_snapshot(&output).expect("snapshot built from source should verify");
933    }
934
935    #[test]
936    fn summarize_snapshot_reports_expected_counts() {
937        let source = TempDir::new().expect("create source tempdir");
938        fs::write(source.path().join("a.txt"), b"alpha\n").expect("write a.txt");
939        fs::create_dir_all(source.path().join("sub")).expect("create subdir");
940        fs::write(source.path().join("sub/b.txt"), b"beta\n").expect("write b.txt");
941
942        #[cfg(unix)]
943        std::os::unix::fs::symlink("a.txt", source.path().join("link")).expect("create symlink");
944
945        let snapshot = source.path().join("snapshot.gcl");
946        build_snapshot(source.path(), &snapshot).expect("build snapshot");
947
948        let summary = crate::summarize_snapshot(&snapshot).expect("summarize snapshot");
949        assert_eq!(summary.file_count, 3);
950        assert_eq!(summary.regular_count, 2);
951        assert_eq!(summary.symlink_count, 1);
952        assert_eq!(summary.total_bytes, 11);
953        assert_eq!(summary.largest_files.len(), 2);
954        assert_eq!(summary.largest_files[0].0, "a.txt");
955        assert_eq!(summary.largest_files[0].1, 6);
956    }
957
958    #[test]
959    fn git_mode_excludes_untracked_by_default() {
960        let repo = TempDir::new().expect("create temp repo");
961        init_git_repo(repo.path());
962
963        fs::write(repo.path().join("tracked.txt"), b"tracked\n").expect("write tracked");
964        run_git(repo.path(), &["add", "tracked.txt"]);
965        run_git(repo.path(), &["commit", "-m", "initial"]);
966
967        fs::write(repo.path().join("untracked.txt"), b"untracked\n").expect("write untracked");
968
969        let snapshot = repo.path().join("snapshot.gcl");
970        build_snapshot(repo.path(), &snapshot).expect("build snapshot");
971
972        let text = fs::read_to_string(snapshot).expect("read snapshot");
973        assert!(text.contains("\"tracked.txt\""));
974        assert!(!text.contains("\"untracked.txt\""));
975    }
976
977    #[test]
978    fn git_mode_include_untracked_respects_gitignore() {
979        let repo = TempDir::new().expect("create temp repo");
980        init_git_repo(repo.path());
981
982        fs::write(repo.path().join("tracked.txt"), b"tracked\n").expect("write tracked");
983        fs::write(repo.path().join(".gitignore"), b"ignored.txt\n").expect("write gitignore");
984        run_git(repo.path(), &["add", "tracked.txt", ".gitignore"]);
985        run_git(repo.path(), &["commit", "-m", "initial"]);
986
987        fs::write(repo.path().join("ignored.txt"), b"ignored\n").expect("write ignored");
988        fs::write(repo.path().join("new.txt"), b"new\n").expect("write new");
989
990        let snapshot = repo.path().join("snapshot.gcl");
991        build_snapshot_with_options(
992            repo.path(),
993            &snapshot,
994            &BuildOptions {
995                include_untracked: true,
996                require_clean: false,
997                source_annotation: None,
998            },
999        )
1000        .expect("build snapshot");
1001
1002        let text = fs::read_to_string(snapshot).expect("read snapshot");
1003        assert!(text.contains("\"tracked.txt\""));
1004        assert!(text.contains("\"new.txt\""));
1005        assert!(!text.contains("\"ignored.txt\""));
1006    }
1007
1008    #[test]
1009    fn git_mode_require_clean_rejects_dirty_tree() {
1010        let repo = TempDir::new().expect("create temp repo");
1011        init_git_repo(repo.path());
1012
1013        fs::write(repo.path().join("tracked.txt"), b"tracked\n").expect("write tracked");
1014        run_git(repo.path(), &["add", "tracked.txt"]);
1015        run_git(repo.path(), &["commit", "-m", "initial"]);
1016
1017        fs::write(repo.path().join("tracked.txt"), b"changed\n").expect("modify tracked");
1018
1019        let snapshot = repo.path().join("snapshot.gcl");
1020        let result = build_snapshot_with_options(
1021            repo.path(),
1022            &snapshot,
1023            &BuildOptions {
1024                include_untracked: false,
1025                require_clean: true,
1026                source_annotation: None,
1027            },
1028        );
1029        assert!(
1030            result.is_err(),
1031            "dirty tree should fail with --require-clean"
1032        );
1033    }
1034
1035    #[test]
1036    fn git_mode_require_clean_rejects_staged_changes() {
1037        let repo = TempDir::new().expect("create temp repo");
1038        init_git_repo(repo.path());
1039
1040        fs::write(repo.path().join("tracked.txt"), b"tracked\n").expect("write tracked");
1041        run_git(repo.path(), &["add", "tracked.txt"]);
1042        run_git(repo.path(), &["commit", "-m", "initial"]);
1043
1044        fs::write(repo.path().join("staged.txt"), b"staged\n").expect("write staged");
1045        run_git(repo.path(), &["add", "staged.txt"]);
1046
1047        let snapshot = repo.path().join("snapshot.gcl");
1048        let result = build_snapshot_with_options(
1049            repo.path(),
1050            &snapshot,
1051            &BuildOptions {
1052                include_untracked: false,
1053                require_clean: true,
1054                source_annotation: None,
1055            },
1056        );
1057        assert!(result.is_err(), "staged change should fail require_clean");
1058    }
1059
1060    #[test]
1061    fn git_mode_require_clean_rejects_rename_inside_source_to_outside() {
1062        let repo = TempDir::new().expect("create temp repo");
1063        init_git_repo(repo.path());
1064
1065        let source_dir = repo.path().join("src");
1066        fs::create_dir_all(&source_dir).expect("create source dir");
1067        fs::write(source_dir.join("tracked.txt"), b"tracked\n").expect("write tracked");
1068        run_git(repo.path(), &["add", "src/tracked.txt"]);
1069        run_git(repo.path(), &["commit", "-m", "initial"]);
1070
1071        run_git(repo.path(), &["mv", "src/tracked.txt", "moved.txt"]);
1072
1073        let snapshot = repo.path().join("snapshot.gcl");
1074        let result = build_snapshot_with_options(
1075            &source_dir,
1076            &snapshot,
1077            &BuildOptions {
1078                include_untracked: false,
1079                require_clean: true,
1080                source_annotation: None,
1081            },
1082        );
1083        assert!(
1084            result.is_err(),
1085            "rename moving file out of source prefix should fail require_clean"
1086        );
1087    }
1088
1089    #[test]
1090    fn git_mode_require_clean_ignores_untracked_outside_source_prefix() {
1091        let repo = TempDir::new().expect("create temp repo");
1092        init_git_repo(repo.path());
1093
1094        let source_dir = repo.path().join("src");
1095        fs::create_dir_all(&source_dir).expect("create source dir");
1096        fs::write(source_dir.join("tracked.txt"), b"tracked\n").expect("write tracked");
1097        run_git(repo.path(), &["add", "src/tracked.txt"]);
1098        run_git(repo.path(), &["commit", "-m", "initial"]);
1099
1100        fs::write(repo.path().join("outside.txt"), b"outside\n").expect("write outside file");
1101
1102        let snapshot = repo.path().join("snapshot.gcl");
1103        let result = build_snapshot_with_options(
1104            &source_dir,
1105            &snapshot,
1106            &BuildOptions {
1107                include_untracked: false,
1108                require_clean: true,
1109                source_annotation: None,
1110            },
1111        );
1112        assert!(
1113            result.is_ok(),
1114            "untracked file outside source prefix should not fail require_clean"
1115        );
1116    }
1117
1118    #[test]
1119    fn git_mode_require_clean_rejects_unmerged_conflict() {
1120        let repo = TempDir::new().expect("create temp repo");
1121        init_git_repo(repo.path());
1122        let base_branch = current_git_branch(repo.path());
1123
1124        fs::write(repo.path().join("conflict.txt"), b"base\n").expect("write base");
1125        run_git(repo.path(), &["add", "conflict.txt"]);
1126        run_git(repo.path(), &["commit", "-m", "base"]);
1127
1128        run_git(repo.path(), &["checkout", "-b", "feature"]);
1129        fs::write(repo.path().join("conflict.txt"), b"feature\n").expect("write feature");
1130        run_git(repo.path(), &["commit", "-am", "feature"]);
1131
1132        run_git(repo.path(), &["checkout", &base_branch]);
1133        fs::write(repo.path().join("conflict.txt"), b"main\n").expect("write main");
1134        run_git(repo.path(), &["commit", "-am", "main"]);
1135
1136        let merge_status = Command::new("git")
1137            .args(["merge", "feature"])
1138            .current_dir(repo.path())
1139            .status()
1140            .expect("run merge");
1141        assert!(!merge_status.success(), "merge should produce conflict");
1142
1143        let snapshot = repo.path().join("snapshot.gcl");
1144        let result = build_snapshot_with_options(
1145            repo.path(),
1146            &snapshot,
1147            &BuildOptions {
1148                include_untracked: false,
1149                require_clean: true,
1150                source_annotation: None,
1151            },
1152        );
1153        assert!(
1154            result.is_err(),
1155            "unmerged conflict should fail require_clean"
1156        );
1157    }
1158
1159    #[test]
1160    fn parse_porcelain_entry_rejects_too_short() {
1161        let err = parse_porcelain_entry(b"M").expect_err("short entry should fail");
1162        assert!(matches!(err, GitClosureError::Parse(_)));
1163    }
1164
1165    #[test]
1166    fn parse_porcelain_entry_rejects_missing_xy_separator() {
1167        let err = parse_porcelain_entry(b"MMfile.txt").expect_err("missing separator should fail");
1168        assert!(matches!(err, GitClosureError::Parse(_)));
1169    }
1170
1171    #[test]
1172    fn parse_porcelain_entry_accepts_valid_record() {
1173        let (xy, path) = parse_porcelain_entry(b" M file.txt").expect("valid entry");
1174        assert_eq!(xy, [b' ', b'M']);
1175        assert_eq!(path, "file.txt");
1176    }
1177
1178    #[test]
1179    fn evaluate_git_status_porcelain_rejects_copy_source_within_prefix() {
1180        let stdout = b"C  copied.txt\0src/original.txt\0";
1181        let err = evaluate_git_status_porcelain(stdout, Path::new("src"))
1182            .expect_err("copy source under prefix should fail");
1183        assert!(matches!(err, GitClosureError::Parse(_)));
1184    }
1185
1186    #[test]
1187    fn evaluate_git_status_porcelain_consumes_copy_source_chunk() {
1188        let stdout = b"C  outside/new.txt\0outside/original.txt\0";
1189        evaluate_git_status_porcelain(stdout, Path::new("src"))
1190            .expect("copy outside prefix should not fail and source chunk must be consumed");
1191    }
1192
1193    #[test]
1194    fn ensure_git_source_is_clean_non_repo_returns_command_exit_failure() {
1195        let temp = TempDir::new().expect("create tempdir");
1196        let context = GitRepoContext {
1197            workdir: temp.path().to_path_buf(),
1198            source_prefix: PathBuf::new(),
1199        };
1200
1201        let err =
1202            ensure_git_source_is_clean(&context).expect_err("git status in non-repo should fail");
1203        match err {
1204            GitClosureError::CommandExitFailure {
1205                command, stderr, ..
1206            } => {
1207                assert_eq!(command, "git");
1208                assert!(!stderr.is_empty(), "stderr should be captured");
1209            }
1210            other => panic!("expected CommandExitFailure, got {other:?}"),
1211        }
1212    }
1213
1214    #[test]
1215    fn git_ls_files_non_repo_returns_command_exit_failure() {
1216        let temp = TempDir::new().expect("create tempdir");
1217        let context = GitRepoContext {
1218            workdir: temp.path().to_path_buf(),
1219            source_prefix: PathBuf::new(),
1220        };
1221
1222        let err = git_ls_files(&context, false).expect_err("git ls-files in non-repo should fail");
1223        match err {
1224            GitClosureError::CommandExitFailure {
1225                command, stderr, ..
1226            } => {
1227                assert_eq!(command, "git");
1228                assert!(!stderr.is_empty(), "stderr should be captured");
1229            }
1230            other => panic!("expected CommandExitFailure, got {other:?}"),
1231        }
1232    }
1233
1234    fn init_git_repo(path: &Path) {
1235        run_git(path, &["init"]);
1236        run_git(path, &["config", "user.name", "git-closure-test"]);
1237        run_git(
1238            path,
1239            &["config", "user.email", "git-closure-test@example.com"],
1240        );
1241    }
1242
1243    fn run_git(path: &Path, args: &[&str]) {
1244        let status = Command::new("git")
1245            .args(args)
1246            .current_dir(path)
1247            .status()
1248            .expect("failed to run git command");
1249        assert!(status.success(), "git command failed: git {:?}", args);
1250    }
1251
1252    fn current_git_branch(path: &Path) -> String {
1253        let output = Command::new("git")
1254            .args(["symbolic-ref", "--short", "HEAD"])
1255            .current_dir(path)
1256            .output()
1257            .expect("failed to read current git branch");
1258        assert!(output.status.success(), "failed to resolve current branch");
1259        String::from_utf8(output.stdout)
1260            .expect("branch output should be UTF-8")
1261            .trim()
1262            .to_string()
1263    }
1264
1265    fn read_snapshot_hash(snapshot: &Path) -> String {
1266        let text = fs::read_to_string(snapshot).expect("read snapshot text");
1267        for line in text.lines() {
1268            if let Some(value) = line.strip_prefix(";; snapshot-hash:") {
1269                return value.trim().to_string();
1270            }
1271            if let Some(value) = line.strip_prefix(";; format-hash:") {
1272                return value.trim().to_string();
1273            }
1274        }
1275        panic!("missing snapshot hash header");
1276    }
1277
1278    #[cfg(unix)]
1279    fn symlink_snapshot_hash(path: &str, target: &str) -> String {
1280        use sha2::{Digest, Sha256};
1281
1282        let mut hasher = Sha256::new();
1283        hasher.update((b"symlink".len() as u64).to_be_bytes());
1284        hasher.update(b"symlink");
1285        hasher.update((path.len() as u64).to_be_bytes());
1286        hasher.update(path.as_bytes());
1287        hasher.update((target.len() as u64).to_be_bytes());
1288        hasher.update(target.as_bytes());
1289        format!("{:x}", hasher.finalize())
1290    }
1291
1292    fn manual_snapshot_hash_with_length_prefix(files: &[SnapshotFile]) -> String {
1293        use sha2::{Digest, Sha256};
1294
1295        let mut hasher = Sha256::new();
1296        for file in files {
1297            if let Some(target) = &file.symlink_target {
1298                update_length_prefixed(&mut hasher, b"symlink");
1299                update_length_prefixed(&mut hasher, file.path.as_bytes());
1300                update_length_prefixed(&mut hasher, target.as_bytes());
1301            } else {
1302                update_length_prefixed(&mut hasher, b"regular");
1303                update_length_prefixed(&mut hasher, file.path.as_bytes());
1304                update_length_prefixed(&mut hasher, file.mode.as_bytes());
1305                update_length_prefixed(&mut hasher, file.sha256.as_bytes());
1306            }
1307        }
1308
1309        format!("{:x}", hasher.finalize())
1310    }
1311
1312    fn update_length_prefixed(hasher: &mut sha2::Sha256, bytes: &[u8]) {
1313        use sha2::Digest;
1314        hasher.update((bytes.len() as u64).to_be_bytes());
1315        hasher.update(bytes);
1316    }
1317
1318    #[test]
1319    fn serialization_round_trips_all_byte_values() {
1320        let source = TempDir::new().expect("create source tempdir");
1321        let restored = TempDir::new().expect("create restored tempdir");
1322
1323        let payload: Vec<u8> = (0u8..=255u8).collect();
1324        fs::write(source.path().join("all-bytes.bin"), &payload).expect("write all-bytes file");
1325
1326        let snapshot = source.path().join("snapshot.gcl");
1327        build_snapshot(source.path(), &snapshot).expect("build snapshot");
1328        verify_snapshot(&snapshot).expect("verify snapshot");
1329        materialize_snapshot(&snapshot, restored.path()).expect("materialize snapshot");
1330
1331        let restored_payload =
1332            fs::read(restored.path().join("all-bytes.bin")).expect("read restored all-bytes file");
1333        assert_eq!(restored_payload, payload);
1334    }
1335
1336    #[test]
1337    fn serialization_round_trips_unicode_edge_cases() {
1338        let source = TempDir::new().expect("create source tempdir");
1339        let restored = TempDir::new().expect("create restored tempdir");
1340
1341        let content = ["", "\u{feff}", "\u{0000}", "\u{fffd}", "\u{1f642}"].join("|");
1342        let expected = content.as_bytes().to_vec();
1343        fs::write(source.path().join("unicode.txt"), expected.clone()).expect("write unicode file");
1344
1345        let snapshot = source.path().join("snapshot.gcl");
1346        build_snapshot(source.path(), &snapshot).expect("build snapshot");
1347        verify_snapshot(&snapshot).expect("verify snapshot");
1348        materialize_snapshot(&snapshot, restored.path()).expect("materialize snapshot");
1349
1350        let restored_bytes =
1351            fs::read(restored.path().join("unicode.txt")).expect("read restored unicode file");
1352        assert_eq!(restored_bytes, expected);
1353    }
1354
1355    #[test]
1356    fn serialization_round_trips_special_character_paths() {
1357        let source = TempDir::new().expect("create source tempdir");
1358        let restored = TempDir::new().expect("create restored tempdir");
1359
1360        let special_dir = source.path().join("dir with spaces");
1361        fs::create_dir_all(&special_dir).expect("create special directory");
1362        let special_path = special_dir.join("file \"quoted\" [x].txt");
1363        let expected = b"special path content\n";
1364        fs::write(&special_path, expected).expect("write special path file");
1365
1366        let snapshot = source.path().join("snapshot.gcl");
1367        build_snapshot(source.path(), &snapshot).expect("build snapshot");
1368        verify_snapshot(&snapshot).expect("verify snapshot");
1369        materialize_snapshot(&snapshot, restored.path()).expect("materialize snapshot");
1370
1371        let restored_bytes = fs::read(
1372            restored
1373                .path()
1374                .join("dir with spaces/file \"quoted\" [x].txt"),
1375        )
1376        .expect("read restored special path file");
1377        assert_eq!(restored_bytes, expected);
1378    }
1379
1380    #[test]
1381    fn quote_string_matches_lexpr_printer() {
1382        let sample = "line1\nline2\u{0000}\u{fffd}\u{1f642}\\\"";
1383        let expected = lexpr::to_string(&lexpr::Value::string(sample)).expect("print with lexpr");
1384        assert_eq!(crate::snapshot::serial::quote_string(sample), expected);
1385    }
1386
1387    #[test]
1388    fn crate_api_table_lists_public_exports() {
1389        let source = include_str!("lib.rs");
1390        let crate_docs = source
1391            .lines()
1392            .take_while(|line| line.starts_with("//!") || line.trim().is_empty())
1393            .collect::<Vec<_>>()
1394            .join("\n");
1395        for symbol in [
1396            "[`build_snapshot`]",
1397            "[`build_snapshot_with_options`]",
1398            "[`build_snapshot_from_source`]",
1399            "[`build_snapshot_from_provider`]",
1400            "[`verify_snapshot`]",
1401            "[`materialize_snapshot`]",
1402            "[`materialize_snapshot_with_options`]",
1403            "[`diff_snapshots`]",
1404            "[`diff_snapshot_to_source`]",
1405            "[`render_snapshot`]",
1406            "[`fmt_snapshot`]",
1407            "[`fmt_snapshot_with_options`]",
1408            "[`list_snapshot`]",
1409            "[`DiffEntry`]",
1410            "[`DiffResult`]",
1411            "[`RenderFormat`]",
1412            "[`FmtOptions`]",
1413            "[`parse_snapshot`]",
1414            "[`list_snapshot_str`]",
1415            "[`summarize_snapshot`]",
1416            "[`GitClosureError`]",
1417            "[`BuildOptions`]",
1418            "[`VerifyReport`]",
1419            "[`MaterializeOptions`]",
1420            "[`MaterializePolicy`]",
1421            "[`ListEntry`]",
1422            "[`SnapshotHeader`]",
1423            "[`SnapshotFile`]",
1424            "[`SnapshotSummary`]",
1425        ] {
1426            assert!(
1427                crate_docs.contains(symbol),
1428                "crate-level Public API table should include {symbol}"
1429            );
1430        }
1431    }
1432
1433    #[test]
1434    fn serialize_symlink_type_field_uses_quote_string() {
1435        assert_eq!(
1436            crate::snapshot::serial::quote_string("symlink"),
1437            "\"symlink\""
1438        );
1439
1440        let source = TempDir::new().expect("create tempdir");
1441        let target_path = source.path().join("target.txt");
1442        fs::write(&target_path, b"payload\n").expect("write target");
1443
1444        #[cfg(unix)]
1445        std::os::unix::fs::symlink("target.txt", source.path().join("link"))
1446            .expect("create symlink");
1447
1448        let snapshot = source.path().join("snap.gcl");
1449        build_snapshot(source.path(), &snapshot).expect("build snapshot");
1450
1451        let text = fs::read_to_string(&snapshot).expect("read snapshot");
1452        assert!(
1453            text.contains(":type \"symlink\""),
1454            "serialized snapshot must contain :type with quoted string, got:\n{}",
1455            text
1456        );
1457
1458        verify_snapshot(&snapshot).expect("verify must pass after serialization fix");
1459    }
1460
1461    #[test]
1462    #[should_panic(expected = "MockProvider called with unexpected source")]
1463    fn mock_provider_panics_on_wrong_source() {
1464        let provider = MockProvider {
1465            root: std::path::PathBuf::new(),
1466        };
1467        let _ = provider.fetch("wrong://source");
1468    }
1469
1470    struct MockProvider {
1471        root: std::path::PathBuf,
1472    }
1473
1474    impl Provider for MockProvider {
1475        fn fetch(&self, source: &str) -> std::result::Result<FetchedSource, GitClosureError> {
1476            if source != "mock://example/repo" {
1477                panic!("MockProvider called with unexpected source: {source}");
1478            }
1479            Ok(FetchedSource::local(self.root.clone()))
1480        }
1481    }
1482
1483    // ── T-19: Forward compatibility — unknown plist keys ──────────────────────
1484
1485    #[test]
1486    fn parse_snapshot_silently_ignores_unknown_plist_key() {
1487        let source = TempDir::new().expect("create source tempdir");
1488        fs::write(source.path().join("hello.txt"), b"hello\n").expect("write hello.txt");
1489
1490        let snapshot = source.path().join("snap.gcl");
1491        build_snapshot(source.path(), &snapshot).expect("build snapshot");
1492
1493        let text = fs::read_to_string(&snapshot).expect("read snapshot");
1494        let modified = text.replace(":mode ", ":mtime \"1234567890\"\n     :mode ");
1495
1496        let modified_snap = source.path().join("modified.gcl");
1497        fs::write(&modified_snap, modified).expect("write modified snapshot");
1498
1499        verify_snapshot(&modified_snap)
1500            .expect("snapshot with unknown plist key must verify successfully");
1501    }
1502
1503    #[test]
1504    fn materialize_snapshot_silently_ignores_unknown_plist_key() {
1505        let source = TempDir::new().expect("create source tempdir");
1506        fs::write(source.path().join("data.txt"), b"payload\n").expect("write data.txt");
1507
1508        let snapshot = source.path().join("snap.gcl");
1509        build_snapshot(source.path(), &snapshot).expect("build snapshot");
1510
1511        let text = fs::read_to_string(&snapshot).expect("read snapshot");
1512        let modified = text
1513            .replace(":path ", ":x-future-key \"v\"\n     :path ")
1514            .replace(":sha256 ", ":x-other \"42\"\n     :sha256 ");
1515
1516        let modified_snap = source.path().join("modified.gcl");
1517        fs::write(&modified_snap, modified).expect("write modified snapshot");
1518
1519        let restored = TempDir::new().expect("create restored tempdir");
1520        materialize_snapshot(&modified_snap, restored.path())
1521            .expect("materialize with unknown keys must succeed");
1522
1523        let bytes = fs::read(restored.path().join("data.txt")).expect("read restored data.txt");
1524        assert_eq!(bytes, b"payload\n");
1525    }
1526
1527    #[test]
1528    fn snapshot_with_unknown_key_roundtrip_preserves_hash() {
1529        let source = TempDir::new().expect("create source tempdir");
1530        fs::write(source.path().join("a.txt"), b"round\n").expect("write a.txt");
1531
1532        let snap_orig = source.path().join("orig.gcl");
1533        build_snapshot(source.path(), &snap_orig).expect("build original snapshot");
1534
1535        let text = fs::read_to_string(&snap_orig).expect("read original snapshot");
1536        let modified = text.replace(":size ", ":git-object-id \"deadbeef\"\n     :size ");
1537
1538        let snap_future = source.path().join("future.gcl");
1539        fs::write(&snap_future, modified).expect("write future snapshot");
1540
1541        let restored = TempDir::new().expect("create restored tempdir");
1542        materialize_snapshot(&snap_future, restored.path()).expect("materialize future snapshot");
1543
1544        let snap_rebuilt = source.path().join("rebuilt.gcl");
1545        build_snapshot(restored.path(), &snap_rebuilt).expect("rebuild snapshot");
1546
1547        let hash_orig = read_snapshot_hash(&snap_orig);
1548        let hash_rebuilt = read_snapshot_hash(&snap_rebuilt);
1549        assert_eq!(
1550            hash_orig, hash_rebuilt,
1551            "snapshot-hash must be identical after round-trip through future-format snapshot"
1552        );
1553    }
1554
1555    // ── T-26b: materialize must reject non-empty output directories ───────────
1556
1557    #[test]
1558    fn materialize_into_non_empty_directory_fails_with_clear_error() {
1559        let source = TempDir::new().expect("create source tempdir");
1560        fs::write(source.path().join("a.txt"), b"content\n").expect("write a.txt");
1561
1562        let snapshot = source.path().join("snap.gcl");
1563        build_snapshot(source.path(), &snapshot).expect("build snapshot");
1564
1565        let output = TempDir::new().expect("create output tempdir");
1566        fs::write(
1567            output.path().join("existing_file.txt"),
1568            b"I was here first\n",
1569        )
1570        .expect("write pre-existing file");
1571
1572        let err = materialize_snapshot(&snapshot, output.path())
1573            .expect_err("materialize into non-empty directory must fail");
1574
1575        match err {
1576            GitClosureError::Parse(msg) => {
1577                assert!(
1578                    msg.contains("empty"),
1579                    "error message should mention 'empty', got: {msg}"
1580                );
1581            }
1582            other => panic!("expected Parse error, got {other:?}"),
1583        }
1584    }
1585
1586    #[test]
1587    fn materialize_into_existing_empty_directory_succeeds() {
1588        let source = TempDir::new().expect("create source tempdir");
1589        fs::write(source.path().join("a.txt"), b"content\n").expect("write a.txt");
1590
1591        let snapshot = source.path().join("snap.gcl");
1592        build_snapshot(source.path(), &snapshot).expect("build snapshot");
1593
1594        let output = TempDir::new().expect("create output tempdir");
1595        materialize_snapshot(&snapshot, output.path())
1596            .expect("materialize into existing empty directory must succeed");
1597
1598        let bytes = fs::read(output.path().join("a.txt")).expect("read materialized a.txt");
1599        assert_eq!(bytes, b"content\n");
1600    }
1601
1602    #[test]
1603    fn materialize_trusted_nonempty_policy_allows_existing_files() {
1604        let source = TempDir::new().expect("create source tempdir");
1605        fs::write(source.path().join("a.txt"), b"content\n").expect("write a.txt");
1606
1607        let snapshot = source.path().join("snap.gcl");
1608        build_snapshot(source.path(), &snapshot).expect("build snapshot");
1609
1610        let output = TempDir::new().expect("create output tempdir");
1611        fs::write(output.path().join("existing.txt"), b"keep\n").expect("write existing file");
1612
1613        crate::materialize_snapshot_with_options(
1614            &snapshot,
1615            output.path(),
1616            &crate::MaterializeOptions {
1617                policy: crate::MaterializePolicy::TrustedNonempty,
1618            },
1619        )
1620        .expect("trusted nonempty policy should allow overlay materialization");
1621
1622        let bytes = fs::read(output.path().join("a.txt")).expect("read materialized file");
1623        assert_eq!(bytes, b"content\n");
1624    }
1625
1626    #[cfg(unix)]
1627    #[test]
1628    fn materialize_no_symlink_policy_rejects_symlink_entries() {
1629        let source = TempDir::new().expect("create source tempdir");
1630        fs::write(source.path().join("target.txt"), b"payload\n").expect("write target");
1631        std::os::unix::fs::symlink("target.txt", source.path().join("link"))
1632            .expect("create symlink");
1633
1634        let snapshot = source.path().join("snap.gcl");
1635        build_snapshot(source.path(), &snapshot).expect("build snapshot");
1636
1637        let output = TempDir::new().expect("create output tempdir");
1638        let err = crate::materialize_snapshot_with_options(
1639            &snapshot,
1640            output.path(),
1641            &crate::MaterializeOptions {
1642                policy: crate::MaterializePolicy::NoSymlink,
1643            },
1644        )
1645        .expect_err("no-symlink policy must reject symlink entries");
1646        assert!(matches!(err, GitClosureError::Parse(_)));
1647    }
1648
1649    #[test]
1650    fn materialize_into_directory_with_subdirectory_fails() {
1651        let source = TempDir::new().expect("create source tempdir");
1652        fs::write(source.path().join("a.txt"), b"content\n").expect("write a.txt");
1653
1654        let snapshot = source.path().join("snap.gcl");
1655        build_snapshot(source.path(), &snapshot).expect("build snapshot");
1656
1657        let output = TempDir::new().expect("create output tempdir");
1658        fs::create_dir(output.path().join("subdir")).expect("create subdir");
1659
1660        let err = materialize_snapshot(&snapshot, output.path())
1661            .expect_err("materialize into directory with subdir must fail");
1662        assert!(
1663            matches!(err, GitClosureError::Parse(_)),
1664            "expected Parse error, got {err:?}"
1665        );
1666    }
1667}