1pub mod error;
43pub mod providers;
44
45pub(crate) mod git;
46pub(crate) mod materialize;
47pub(crate) mod snapshot;
48pub(crate) mod utils;
49
50pub 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#[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 #[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 #[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}