11use std:: env;
22use std:: fs;
3+ use std:: path:: Component ;
34use std:: path:: { Path , PathBuf } ;
45use std:: process:: Command ;
56use std:: time:: { SystemTime , UNIX_EPOCH } ;
@@ -9,7 +10,7 @@ use eframe::egui;
910use rfd:: FileDialog ;
1011use serde_json:: Value ;
1112
12- use crate :: config:: { AppConfig , AppPaths , JobConfig , default_config_template, load_config, rel_path_string } ;
13+ use crate :: config:: { AppConfig , AppPaths , JobConfig , default_config_template, load_config} ;
1314use crate :: platform;
1415
1516const WIZARD_FLAG : & str = "--wizard" ;
@@ -280,7 +281,7 @@ impl WizardApp {
280281
281282 fn validate_and_save ( & mut self ) -> Result < ( ) > {
282283 append_wizard_log ( & self . paths , "Validating wizard configuration" ) ;
283- let config = self . normalized_config_for_save ( ) ;
284+ let config = self . normalized_config_for_save ( ) ? ;
284285 let serialized = serde_json:: to_string_pretty ( & config)
285286 . context ( "failed to serialize config.json" ) ?;
286287
@@ -309,19 +310,21 @@ impl WizardApp {
309310 Ok ( ( ) )
310311 }
311312
312- fn normalized_config_for_save ( & self ) -> AppConfig {
313+ fn normalized_config_for_save ( & self ) -> Result < AppConfig > {
313314 let mut config = self . config . clone ( ) ;
314315 normalize_optional_string ( & mut config. drive . letter ) ;
315316 normalize_optional_string ( & mut config. drive . path ) ;
316317 normalize_optional_string ( & mut config. cache . root ) ;
318+ let drive_root = drive_root_from_config ( & config) ;
317319
318320 for job in & mut config. jobs {
319321 job. name = job. name . trim ( ) . to_string ( ) ;
320- job. source = job. source . trim ( ) . to_string ( ) ;
322+ job. source = normalize_job_source_text ( & job. source , drive_root. as_deref ( ) )
323+ . with_context ( || format ! ( "job '{}' source is invalid" , job. name. trim( ) ) ) ?;
321324 job. target = job. target . trim ( ) . to_string ( ) ;
322325 }
323326
324- config
327+ Ok ( config)
325328 }
326329
327330 fn current_drive_root ( & self ) -> Option < PathBuf > {
@@ -372,16 +375,13 @@ impl WizardApp {
372375
373376 let picker = FileDialog :: new ( ) . set_directory ( & root) ;
374377 if let Some ( folder) = picker. pick_folder ( ) {
375- match folder. strip_prefix ( & root) {
376- Ok ( relative) => match rel_path_string ( relative) {
377- Ok ( value) => self . config . jobs [ index] . source = value,
378- Err ( error) => self . status = format ! ( "Source browse failed: {error}" ) ,
379- } ,
380- Err ( _) => {
381- self . status = format ! (
382- "The selected folder must stay inside the configured drive root {}" ,
383- root. display( )
384- ) ;
378+ match normalize_job_source_from_path ( & folder, & root) {
379+ Ok ( value) => {
380+ self . config . jobs [ index] . source = value;
381+ self . status = format ! ( "Selected {}" , folder. display( ) ) ;
382+ }
383+ Err ( error) => {
384+ self . status = format ! ( "Source browse failed: {error}" ) ;
385385 }
386386 }
387387 }
@@ -487,6 +487,131 @@ impl WizardApp {
487487 }
488488}
489489
490+ fn drive_root_from_config ( config : & AppConfig ) -> Option < PathBuf > {
491+ if let Some ( path) = config
492+ . drive
493+ . path
494+ . as_deref ( )
495+ . map ( str:: trim)
496+ . filter ( |value| !value. is_empty ( ) )
497+ {
498+ return Some ( PathBuf :: from ( path) ) ;
499+ }
500+
501+ #[ cfg( target_os = "windows" ) ]
502+ {
503+ let letter = config
504+ . drive
505+ . letter
506+ . as_deref ( )
507+ . map ( str:: trim)
508+ . filter ( |value| !value. is_empty ( ) ) ?;
509+ return Some ( PathBuf :: from ( format ! ( "{}:\\ " , letter. trim_end_matches( ':' ) ) ) ) ;
510+ }
511+
512+ #[ allow( unreachable_code) ]
513+ None
514+ }
515+
516+ fn normalize_job_source_from_path ( path : & Path , root : & Path ) -> Result < String > {
517+ let relative = strip_root_prefix_normalized ( path, root) . ok_or_else ( || {
518+ anyhow:: anyhow!(
519+ "the selected folder must stay inside the configured drive root {}" ,
520+ root. display( )
521+ )
522+ } ) ?;
523+ native_relative_path_string ( & relative)
524+ }
525+
526+ fn normalize_job_source_text ( value : & str , root : Option < & Path > ) -> Result < String > {
527+ let trimmed = value. trim ( ) ;
528+ anyhow:: ensure!( !trimmed. is_empty( ) , "source path must not be empty" ) ;
529+
530+ let candidate = PathBuf :: from ( trimmed) ;
531+ if candidate. is_absolute ( ) {
532+ let root = root. ok_or_else ( || {
533+ anyhow:: anyhow!( "set the USB root first before using an absolute source path" )
534+ } ) ?;
535+ return normalize_job_source_from_path ( & candidate, root) ;
536+ }
537+
538+ let mut normalized = PathBuf :: new ( ) ;
539+ for part in trimmed. split ( [ '/' , '\\' ] ) . filter ( |part| !part. is_empty ( ) ) {
540+ match part {
541+ "." => { }
542+ ".." => anyhow:: bail!( "source path must stay inside the USB root" ) ,
543+ _ => normalized. push ( part) ,
544+ }
545+ }
546+
547+ anyhow:: ensure!(
548+ !normalized. as_os_str( ) . is_empty( ) ,
549+ "source path must not collapse to an empty value"
550+ ) ;
551+ native_relative_path_string ( & normalized)
552+ }
553+
554+ fn native_relative_path_string ( path : & Path ) -> Result < String > {
555+ let mut parts = Vec :: new ( ) ;
556+ for component in path. components ( ) {
557+ match component {
558+ Component :: Normal ( part) => parts. push ( part. to_string_lossy ( ) . to_string ( ) ) ,
559+ _ => anyhow:: bail!( "path contains a non-normal component: {}" , path. display( ) ) ,
560+ }
561+ }
562+
563+ anyhow:: ensure!( !parts. is_empty( ) , "path must not be empty" ) ;
564+ Ok ( parts. join ( std:: path:: MAIN_SEPARATOR_STR ) )
565+ }
566+
567+ fn strip_root_prefix_normalized ( path : & Path , root : & Path ) -> Option < PathBuf > {
568+ let path_parts = normalized_path_parts ( path) ?;
569+ let root_parts = normalized_path_parts ( root) ?;
570+ if path_parts. len ( ) < root_parts. len ( ) {
571+ return None ;
572+ }
573+ if !path_parts
574+ . iter ( )
575+ . zip ( root_parts. iter ( ) )
576+ . all ( |( path_part, root_part) | path_part_matches ( path_part, root_part) )
577+ {
578+ return None ;
579+ }
580+
581+ Some (
582+ path_parts[ root_parts. len ( ) ..]
583+ . iter ( )
584+ . fold ( PathBuf :: new ( ) , |mut path, part| {
585+ path. push ( part) ;
586+ path
587+ } ) ,
588+ )
589+ }
590+
591+ fn normalized_path_parts ( path : & Path ) -> Option < Vec < String > > {
592+ let mut parts = Vec :: new ( ) ;
593+ for component in path. components ( ) {
594+ match component {
595+ Component :: Prefix ( prefix) => parts. push ( prefix. as_os_str ( ) . to_string_lossy ( ) . to_string ( ) ) ,
596+ Component :: RootDir => { }
597+ Component :: CurDir => { }
598+ Component :: Normal ( part) => parts. push ( part. to_string_lossy ( ) . to_string ( ) ) ,
599+ Component :: ParentDir => return None ,
600+ }
601+ }
602+ Some ( parts)
603+ }
604+
605+ #[ cfg( target_os = "windows" ) ]
606+ fn path_part_matches ( path_part : & str , root_part : & str ) -> bool {
607+ path_part. eq_ignore_ascii_case ( root_part)
608+ }
609+
610+ #[ cfg( not( target_os = "windows" ) ) ]
611+ fn path_part_matches ( path_part : & str , root_part : & str ) -> bool {
612+ path_part == root_part
613+ }
614+
490615impl eframe:: App for WizardApp {
491616 fn update ( & mut self , ctx : & egui:: Context , _frame : & mut eframe:: Frame ) {
492617 self . clear_startup_signal_if_needed ( ) ;
@@ -1434,4 +1559,25 @@ mod tests {
14341559
14351560 assert ! ( first_missing_target_prompt( & config) . is_none( ) ) ;
14361561 }
1562+
1563+ #[ test]
1564+ fn normalize_job_source_text_accepts_nested_relative_windows_path ( ) {
1565+ let value =
1566+ normalize_job_source_text ( r"Projects\Folder_Alpha" , Some ( Path :: new ( r"S:\" ) ) ) . unwrap ( ) ;
1567+ assert_eq ! ( value, r"Projects\Folder_Alpha" ) ;
1568+ }
1569+
1570+ #[ test]
1571+ fn normalize_job_source_text_converts_absolute_path_under_root ( ) {
1572+ let value =
1573+ normalize_job_source_text ( r"S:\Projects\Folder_Alpha" , Some ( Path :: new ( r"S:\" ) ) )
1574+ . unwrap ( ) ;
1575+ assert_eq ! ( value, r"Projects\Folder_Alpha" ) ;
1576+ }
1577+
1578+ #[ test]
1579+ fn normalize_job_source_from_path_rejects_folder_outside_root ( ) {
1580+ let result = normalize_job_source_from_path ( Path :: new ( r"T:\Other\Folder" ) , Path :: new ( r"S:\" ) ) ;
1581+ assert ! ( result. is_err( ) ) ;
1582+ }
14371583}
0 commit comments