@@ -53,6 +53,20 @@ impl DefaultConfigLoader {
5353 None
5454 }
5555
56+ /// Determine which local override config file to use.
57+ /// `runok.local.yml` is preferred; `runok.local.yaml` is a fallback.
58+ fn local_override_config_path ( cwd : & Path ) -> Option < PathBuf > {
59+ let yml = cwd. join ( "runok.local.yml" ) ;
60+ if yml. exists ( ) {
61+ return Some ( yml) ;
62+ }
63+ let yaml = cwd. join ( "runok.local.yaml" ) ;
64+ if yaml. exists ( ) {
65+ return Some ( yaml) ;
66+ }
67+ None
68+ }
69+
5670 fn read_and_parse ( path : & Path ) -> Result < Config , ConfigError > {
5771 let yaml = std:: fs:: read_to_string ( path) ?;
5872 parse_config ( & yaml)
@@ -72,7 +86,14 @@ impl ConfigLoader for DefaultConfigLoader {
7286 . map ( |p| Self :: read_and_parse ( & p) )
7387 . transpose ( ) ?;
7488
75- let mut config = global. unwrap_or_default ( ) . merge ( local. unwrap_or_default ( ) ) ;
89+ let local_override = Self :: local_override_config_path ( cwd)
90+ . map ( |p| Self :: read_and_parse ( & p) )
91+ . transpose ( ) ?;
92+
93+ let mut config = global
94+ . unwrap_or_default ( )
95+ . merge ( local. unwrap_or_default ( ) )
96+ . merge ( local_override. unwrap_or_default ( ) ) ;
7697
7798 config. validate ( ) ?;
7899 Ok ( config)
@@ -322,4 +343,129 @@ mod tests {
322343 assert_eq ! ( loader. global_config_path, Some ( expected) ) ;
323344 }
324345 }
346+
347+ #[ rstest]
348+ #[ case:: local_yml( "runok.local.yml" ) ]
349+ #[ case:: local_yaml_fallback( "runok.local.yaml" ) ]
350+ fn load_local_override ( #[ case] filename : & str ) {
351+ let env = TestEnv :: new ( ) ;
352+ env. write_local (
353+ "runok.yml" ,
354+ indoc ! { "
355+ defaults:
356+ action: deny
357+ " } ,
358+ ) ;
359+ env. write_local (
360+ filename,
361+ indoc ! { "
362+ defaults:
363+ action: allow
364+ " } ,
365+ ) ;
366+
367+ let config = env. load_without_global ( ) . unwrap ( ) ;
368+ // local override takes priority over project config
369+ assert_eq ! (
370+ config. defaults. unwrap( ) . action,
371+ Some ( crate :: config:: ActionKind :: Allow )
372+ ) ;
373+ }
374+
375+ #[ test]
376+ fn load_local_override_yml_takes_priority_over_yaml ( ) {
377+ let env = TestEnv :: new ( ) ;
378+ env. write_local (
379+ "runok.local.yml" ,
380+ indoc ! { "
381+ defaults:
382+ action: deny
383+ " } ,
384+ ) ;
385+ env. write_local (
386+ "runok.local.yaml" ,
387+ indoc ! { "
388+ defaults:
389+ action: allow
390+ " } ,
391+ ) ;
392+
393+ let config = env. load_without_global ( ) . unwrap ( ) ;
394+ assert_eq ! (
395+ config. defaults. unwrap( ) . action,
396+ Some ( crate :: config:: ActionKind :: Deny )
397+ ) ;
398+ }
399+
400+ #[ test]
401+ fn load_merges_all_three_layers ( ) {
402+ let env = TestEnv :: new ( ) ;
403+ env. write_global ( indoc ! { "
404+ defaults:
405+ action: deny
406+ sandbox: global-sandbox
407+ rules:
408+ - deny: 'rm -rf /'
409+ " } ) ;
410+ env. write_local (
411+ "runok.yml" ,
412+ indoc ! { "
413+ defaults:
414+ action: ask
415+ rules:
416+ - allow: 'git status'
417+ " } ,
418+ ) ;
419+ env. write_local (
420+ "runok.local.yml" ,
421+ indoc ! { "
422+ defaults:
423+ action: allow
424+ rules:
425+ - allow: 'cargo test'
426+ " } ,
427+ ) ;
428+
429+ let config = env. load ( ) . unwrap ( ) ;
430+
431+ let defaults = config. defaults . unwrap ( ) ;
432+ // local override wins over project and global
433+ assert_eq ! ( defaults. action, Some ( crate :: config:: ActionKind :: Allow ) ) ;
434+ // sandbox from global is preserved (not overridden by layers without it)
435+ assert_eq ! ( defaults. sandbox. as_deref( ) , Some ( "global-sandbox" ) ) ;
436+
437+ // rules are appended: global + project + local override
438+ let rules = config. rules . unwrap ( ) ;
439+ assert_eq ! ( rules. len( ) , 3 ) ;
440+ assert_eq ! ( rules[ 0 ] . deny. as_deref( ) , Some ( "rm -rf /" ) ) ;
441+ assert_eq ! ( rules[ 1 ] . allow. as_deref( ) , Some ( "git status" ) ) ;
442+ assert_eq ! ( rules[ 2 ] . allow. as_deref( ) , Some ( "cargo test" ) ) ;
443+ }
444+
445+ #[ test]
446+ fn load_local_override_parse_error ( ) {
447+ let env = TestEnv :: new ( ) ;
448+ env. write_local ( "runok.local.yml" , "rules: [invalid yaml\n broken:" ) ;
449+
450+ let result = env. load_without_global ( ) ;
451+ assert ! ( matches!( result. unwrap_err( ) , ConfigError :: Yaml ( _) ) ) ;
452+ }
453+
454+ #[ test]
455+ fn load_local_override_only_without_project_config ( ) {
456+ let env = TestEnv :: new ( ) ;
457+ // no runok.yml, only runok.local.yml
458+ env. write_local (
459+ "runok.local.yml" ,
460+ indoc ! { "
461+ rules:
462+ - allow: 'echo hello'
463+ " } ,
464+ ) ;
465+
466+ let config = env. load_without_global ( ) . unwrap ( ) ;
467+ let rules = config. rules . unwrap ( ) ;
468+ assert_eq ! ( rules. len( ) , 1 ) ;
469+ assert_eq ! ( rules[ 0 ] . allow. as_deref( ) , Some ( "echo hello" ) ) ;
470+ }
325471}
0 commit comments