88
99use std:: collections:: BTreeMap ;
1010use std:: env;
11- use std:: fs:: { self , File } ;
11+ use std:: fs:: { self , File , OpenOptions } ;
1212use std:: io:: { Read , Write } ;
1313use std:: path:: { Path , PathBuf } ;
1414
@@ -57,10 +57,21 @@ fn file_name(path: &Path) -> Option<&str> {
5757 path. file_name ( ) . and_then ( |name| name. to_str ( ) )
5858}
5959
60- fn matching_children ( parent : & Path , current : & Path , marker : & str , directories : bool ) -> Result < Vec < PathBuf > > {
60+ fn ensure_confined ( root : & Path , path : & Path , label : & str ) -> Result < ( ) > {
61+ let canonical_root = fs:: canonicalize ( root) . with_context ( || format ! ( "failed to resolve evidence root {}" , root. display( ) ) ) ?;
62+ let canonical_path = fs:: canonicalize ( path) . with_context ( || format ! ( "failed to resolve {label} {}" , path. display( ) ) ) ?;
63+ if !canonical_path. starts_with ( & canonical_root) {
64+ bail ! ( "refusing to access {label} outside evidence root {}: {}" , canonical_root. display( ) , canonical_path. display( ) ) ;
65+ }
66+ Ok ( ( ) )
67+ }
68+
69+ fn matching_children ( root : & Path , parent : & Path , current : & Path , marker : & str , directories : bool ) -> Result < Vec < PathBuf > > {
6170 if !parent. is_dir ( ) {
6271 return Ok ( Vec :: new ( ) ) ;
6372 }
73+ ensure_confined ( root, parent, "evidence directory" ) ?;
74+ ensure_confined ( root, current, "current evidence path" ) ?;
6475 let mut paths = Vec :: new ( ) ;
6576 for entry in fs:: read_dir ( parent) . with_context ( || format ! ( "failed to read evidence directory {}" , parent. display( ) ) ) ? {
6677 let entry = entry?;
@@ -80,15 +91,15 @@ fn matching_children(parent: &Path, current: &Path, marker: &str, directories: b
8091 Ok ( paths)
8192}
8293
83- pub ( crate ) fn prune_run_directories ( parent : & Path , current : & Path , marker : & str ) -> Result < Vec < PathBuf > > {
94+ pub ( crate ) fn prune_run_directories ( root : & Path , parent : & Path , current : & Path , marker : & str ) -> Result < Vec < PathBuf > > {
8495 let Some ( keep) = configured_keep_runs ( ) ? else {
8596 return Ok ( Vec :: new ( ) ) ;
8697 } ;
87- prune_run_directories_with_limit ( parent, current, marker, keep)
98+ prune_run_directories_with_limit ( root , parent, current, marker, keep)
8899}
89100
90- fn prune_run_directories_with_limit ( parent : & Path , current : & Path , marker : & str , keep : usize ) -> Result < Vec < PathBuf > > {
91- let mut candidates = matching_children ( parent, current, marker, true ) ?;
101+ fn prune_run_directories_with_limit ( root : & Path , parent : & Path , current : & Path , marker : & str , keep : usize ) -> Result < Vec < PathBuf > > {
102+ let mut candidates = matching_children ( root , parent, current, marker, true ) ?;
92103 let remove_from = keep. saturating_sub ( 1 ) . min ( candidates. len ( ) ) ;
93104 let removed = candidates. split_off ( remove_from) ;
94105 for path in & removed {
@@ -97,11 +108,11 @@ fn prune_run_directories_with_limit(parent: &Path, current: &Path, marker: &str,
97108 Ok ( removed)
98109}
99110
100- pub ( crate ) fn prune_report_files ( parent : & Path , current : & Path , marker : & str ) -> Result < Vec < PathBuf > > {
111+ pub ( crate ) fn prune_report_files ( root : & Path , parent : & Path , current : & Path , marker : & str ) -> Result < Vec < PathBuf > > {
101112 let Some ( keep) = configured_keep_runs ( ) ? else {
102113 return Ok ( Vec :: new ( ) ) ;
103114 } ;
104- let mut candidates = matching_children ( parent, current, marker, false ) ?;
115+ let mut candidates = matching_children ( root , parent, current, marker, false ) ?;
105116 let remove_from = keep. saturating_sub ( 1 ) . min ( candidates. len ( ) ) ;
106117 let removed = candidates. split_off ( remove_from) ;
107118 for path in & removed {
@@ -169,8 +180,8 @@ fn replace_with_hardlink(source: &Path, destination: &Path, expected_hash: &[u8;
169180/// Hardlink immutable files in `current` to byte-identical files in sibling
170181/// evidence runs. Paths and bytes stay unchanged; unsupported filesystems
171182/// simply retain separate copies.
172- pub ( crate ) fn deduplicate_run ( parent : & Path , current : & Path , marker : & str ) -> Result < DedupStats > {
173- let siblings = matching_children ( parent, current, marker, true ) ?;
183+ pub ( crate ) fn deduplicate_run ( root : & Path , parent : & Path , current : & Path , marker : & str ) -> Result < DedupStats > {
184+ let siblings = matching_children ( root , parent, current, marker, true ) ?;
174185 if !current. is_dir ( ) {
175186 return Ok ( DedupStats :: default ( ) ) ;
176187 }
@@ -207,12 +218,13 @@ pub(crate) fn deduplicate_run(parent: &Path, current: &Path, marker: &str) -> Re
207218 Ok ( stats)
208219}
209220
210- pub ( crate ) fn remove_directory_if_present ( path : & Path ) -> Result < bool > {
221+ pub ( crate ) fn remove_directory_if_present ( root : & Path , path : & Path ) -> Result < bool > {
211222 match fs:: symlink_metadata ( path) {
212223 Ok ( metadata) if metadata. file_type ( ) . is_symlink ( ) || !metadata. is_dir ( ) => {
213224 bail ! ( "refusing to remove non-directory gate work path {}" , path. display( ) )
214225 }
215226 Ok ( _) => {
227+ ensure_confined ( root, path, "gate work directory" ) ?;
216228 fs:: remove_dir_all ( path) . with_context ( || format ! ( "failed to remove gate work directory {}" , path. display( ) ) ) ?;
217229 Ok ( true )
218230 }
@@ -221,10 +233,13 @@ pub(crate) fn remove_directory_if_present(path: &Path) -> Result<bool> {
221233 }
222234}
223235
224- pub ( crate ) fn write_latest_index ( path : & Path , report_path : & Path , kind : & str , mode : & str , status : & str ) -> Result < ( ) > {
236+ pub ( crate ) fn write_latest_index ( root : & Path , path : & Path , report_path : & Path , kind : & str , mode : & str , status : & str ) -> Result < ( ) > {
237+ let parent = path. parent ( ) . context ( "latest evidence index has no parent" ) ?;
238+ ensure_confined ( root, parent, "latest evidence index directory" ) ?;
239+ ensure_confined ( root, report_path, "evidence report" ) ?;
225240 let report_bytes = fs:: read ( report_path) . with_context ( || format ! ( "failed to read {}" , report_path. display( ) ) ) ?;
226241 let report_sha256 = hex:: encode ( Sha256 :: digest ( & report_bytes) ) ;
227- let relative = report_path. strip_prefix ( path . parent ( ) . unwrap_or_else ( || Path :: new ( "." ) ) ) . unwrap_or ( report_path) ;
242+ let relative = report_path. strip_prefix ( parent) . unwrap_or ( report_path) ;
228243 let index = serde_json:: json!( {
229244 "schema" : "cellscript-local-evidence-index-v1" ,
230245 "kind" : kind,
@@ -236,10 +251,27 @@ pub(crate) fn write_latest_index(path: &Path, report_path: &Path, kind: &str, mo
236251 "size_bytes" : report_bytes. len( ) ,
237252 } ,
238253 } ) ;
239- let mut output = File :: create ( path) . with_context ( || format ! ( "failed to write {}" , path. display( ) ) ) ?;
240- serde_json:: to_writer_pretty ( & mut output, & index) ?;
241- output. write_all ( b"\n " ) ?;
242- Ok ( ( ) )
254+ let temporary = parent. join ( format ! (
255+ ".cellscript-latest-{}-{}-{}.tmp" ,
256+ std:: process:: id( ) ,
257+ kind. replace( |character: char | !character. is_ascii_alphanumeric( ) , "_" ) ,
258+ mode. replace( |character: char | !character. is_ascii_alphanumeric( ) , "_" )
259+ ) ) ;
260+ let write_result = ( || -> Result < ( ) > {
261+ let mut output = OpenOptions :: new ( )
262+ . write ( true )
263+ . create_new ( true )
264+ . open ( & temporary)
265+ . with_context ( || format ! ( "failed to create temporary latest index {}" , temporary. display( ) ) ) ?;
266+ serde_json:: to_writer_pretty ( & mut output, & index) ?;
267+ output. write_all ( b"\n " ) ?;
268+ output. sync_all ( ) ?;
269+ fs:: rename ( & temporary, path) . with_context ( || format ! ( "failed to publish latest evidence index {}" , path. display( ) ) )
270+ } ) ( ) ;
271+ if write_result. is_err ( ) {
272+ let _ = fs:: remove_file ( & temporary) ;
273+ }
274+ write_result
243275}
244276
245277#[ cfg( test) ]
@@ -262,7 +294,7 @@ mod tests {
262294 let current = parent. join ( "run-5-quick" ) ;
263295 fs:: create_dir ( & current) . unwrap ( ) ;
264296
265- let removed = prune_run_directories_with_limit ( & parent, & current, "-quick" , 3 ) . unwrap ( ) ;
297+ let removed = prune_run_directories_with_limit ( & parent, & parent , & current, "-quick" , 3 ) . unwrap ( ) ;
266298 assert_eq ! ( removed. len( ) , 1 ) ;
267299 assert ! ( current. is_dir( ) ) ;
268300 assert ! ( parent. join( "run-3-quick" ) . is_dir( ) ) ;
@@ -272,6 +304,33 @@ mod tests {
272304 fs:: remove_dir_all ( parent) . unwrap ( ) ;
273305 }
274306
307+ #[ cfg( unix) ]
308+ #[ test]
309+ fn pruning_refuses_a_symlinked_evidence_root ( ) {
310+ use std:: os:: unix:: fs:: symlink;
311+
312+ let parent = test_root ( "confined-prune" ) ;
313+ let repository = parent. join ( "repository" ) ;
314+ let outside = parent. join ( "outside" ) ;
315+ fs:: create_dir ( & repository) . unwrap ( ) ;
316+ fs:: create_dir ( & outside) . unwrap ( ) ;
317+ fs:: create_dir ( outside. join ( "1-quick-old" ) ) . unwrap ( ) ;
318+ fs:: create_dir ( outside. join ( "2-quick-current" ) ) . unwrap ( ) ;
319+ symlink ( & outside, repository. join ( "evidence" ) ) . unwrap ( ) ;
320+
321+ let error = prune_run_directories_with_limit (
322+ & repository,
323+ & repository. join ( "evidence" ) ,
324+ & repository. join ( "evidence/2-quick-current" ) ,
325+ "-quick-" ,
326+ 1 ,
327+ )
328+ . unwrap_err ( ) ;
329+ assert ! ( error. to_string( ) . contains( "outside evidence root" ) ) ;
330+ assert ! ( outside. join( "1-quick-old" ) . is_dir( ) ) ;
331+ fs:: remove_dir_all ( parent) . unwrap ( ) ;
332+ }
333+
275334 #[ cfg( unix) ]
276335 #[ test]
277336 fn deduplication_preserves_paths_and_bytes ( ) {
@@ -286,7 +345,7 @@ mod tests {
286345 fs:: write ( previous. join ( "artifact.elf" ) , & bytes) . unwrap ( ) ;
287346 fs:: write ( current. join ( "artifact.elf" ) , & bytes) . unwrap ( ) ;
288347
289- let stats = deduplicate_run ( & parent, & current, "-production" ) . unwrap ( ) ;
348+ let stats = deduplicate_run ( & parent, & parent , & current, "-production" ) . unwrap ( ) ;
290349 assert_eq ! ( stats, DedupStats { files: 1 , bytes: DEDUP_MIN_BYTES } ) ;
291350 assert_eq ! ( fs:: read( current. join( "artifact.elf" ) ) . unwrap( ) , bytes) ;
292351 assert_eq ! (
@@ -308,7 +367,7 @@ mod tests {
308367 fs:: write ( current. join ( "left.json" ) , & bytes) . unwrap ( ) ;
309368 fs:: write ( current. join ( "right.json" ) , & bytes) . unwrap ( ) ;
310369
311- let stats = deduplicate_run ( & parent, & current, "-bounded" ) . unwrap ( ) ;
370+ let stats = deduplicate_run ( & parent, & parent , & current, "-bounded" ) . unwrap ( ) ;
312371 assert_eq ! ( stats, DedupStats { files: 1 , bytes: DEDUP_MIN_BYTES } ) ;
313372 assert_eq ! ( fs:: metadata( current. join( "left.json" ) ) . unwrap( ) . ino( ) , fs:: metadata( current. join( "right.json" ) ) . unwrap( ) . ino( ) ) ;
314373 fs:: remove_dir_all ( parent) . unwrap ( ) ;
0 commit comments