@@ -2917,6 +2917,18 @@ pub(crate) fn load_raw_profile_from_path(path: &Path) -> Result<Profile> {
29172917#[ allow( deprecated) ]
29182918pub ( crate ) fn finalize_profile ( mut profile : Profile ) -> Result < Profile > {
29192919 profile = apply_platform_overrides ( profile) ?;
2920+ // apply_platform_overrides merges platform-specific custom_credentials,
2921+ // env_credentials, and environment.set_vars into the profile, so those
2922+ // merged values must be re-validated here — parse_profile_bytes only
2923+ // validated the pre-merge, top-level values.
2924+ validate_profile_custom_credentials ( & profile) ?;
2925+ validate_env_credential_keys ( & profile) ?;
2926+ if let Some ( env_config) = profile. environment . as_ref ( )
2927+ && !env_config. set_vars . is_empty ( )
2928+ && let Some ( err) = crate :: exec_strategy:: validate_set_vars ( & env_config. set_vars )
2929+ {
2930+ return Err ( NonoError :: ProfileParse ( err) ) ;
2931+ }
29202932 merge_implicit_default_groups ( & mut profile) ?;
29212933 validate_credential_capture_resolved ( & profile) ?;
29222934 validate_credential_provider_resolved ( & profile) ?;
@@ -3298,11 +3310,50 @@ fn apply_platform_overrides(mut profile: Profile) -> Result<Profile> {
32983310 Ok ( merge_profiles ( profile, override_profile) )
32993311}
33003312
3313+ /// Must preserve a base's overrides, not just a leaf's — `merge_profiles`
3314+ /// previously nulled this field unconditionally, so any override on a base
3315+ /// profile was silently lost. Same-OS conflicts deep-merge via
3316+ /// `merge_platform_override_slot` rather than one side replacing the other,
3317+ /// so a child override touching one field can't wipe out unrelated fields
3318+ /// the base's override for that OS set.
3319+ fn merge_platform_overrides (
3320+ base : Option < PlatformOverrides > ,
3321+ child : Option < PlatformOverrides > ,
3322+ ) -> Option < PlatformOverrides > {
3323+ match ( base, child) {
3324+ ( None , None ) => None ,
3325+ ( Some ( b) , None ) => Some ( b) ,
3326+ ( None , Some ( c) ) => Some ( c) ,
3327+ ( Some ( b) , Some ( c) ) => Some ( PlatformOverrides {
3328+ macos : merge_platform_override_slot ( b. macos , c. macos ) ,
3329+ linux : merge_platform_override_slot ( b. linux , c. linux ) ,
3330+ windows : merge_platform_override_slot ( b. windows , c. windows ) ,
3331+ } ) ,
3332+ }
3333+ }
3334+
3335+ /// Uses `merge_profiles` itself so a same-OS override composes with the same
3336+ /// child-wins/dedup-append/deny-union rules as everything else in a profile.
3337+ fn merge_platform_override_slot (
3338+ base : Option < Box < PlatformOverride > > ,
3339+ child : Option < Box < PlatformOverride > > ,
3340+ ) -> Option < Box < PlatformOverride > > {
3341+ match ( base, child) {
3342+ ( None , None ) => None ,
3343+ ( Some ( b) , None ) => Some ( b) ,
3344+ ( None , Some ( c) ) => Some ( c) ,
3345+ ( Some ( b) , Some ( c) ) => Some ( Box :: new ( PlatformOverride ( merge_profiles ( b. 0 , c. 0 ) ) ) ) ,
3346+ }
3347+ }
3348+
33013349#[ allow( deprecated) ] // reads/writes commands.{allow,deny} (deprecated v0.33.0)
33023350fn merge_profiles ( base : Profile , child : Profile ) -> Profile {
33033351 Profile {
33043352 extends : None ,
3305- platform_overrides : None ,
3353+ platform_overrides : merge_platform_overrides (
3354+ base. platform_overrides ,
3355+ child. platform_overrides ,
3356+ ) ,
33063357 meta : child. meta ,
33073358 security : SecurityConfig {
33083359 signal_mode : child. security . signal_mode . or ( base. security . signal_mode ) ,
@@ -9772,4 +9823,246 @@ mod tests {
97729823 "other platform's paths must not be present"
97739824 ) ;
97749825 }
9826+
9827+ #[ test]
9828+ fn platform_overrides_valid_set_vars_merged_and_accepted ( ) {
9829+ // Positive counterpart to `platform_overrides_set_vars_validated_after_merge`:
9830+ // a well-formed set_vars entry declared only inside platform_overrides must
9831+ // survive the merge and finalize successfully, not just get rejected.
9832+ let current_os = crate :: platform:: current_os_name ( ) ;
9833+ let json = format ! (
9834+ r#"{{
9835+ "meta": {{"name": "test"}},
9836+ "platform_overrides": {{
9837+ "{current_os}": {{ "environment": {{ "set_vars": {{"MY_VAR": "value"}} }} }}
9838+ }}
9839+ }}"#
9840+ ) ;
9841+ let profile: Profile = serde_json:: from_str ( & json) . expect ( "parse" ) ;
9842+ let finalized = finalize_profile ( profile) . expect ( "valid set_vars must be accepted" ) ;
9843+ assert_eq ! (
9844+ finalized
9845+ . environment
9846+ . as_ref( )
9847+ . expect( "environment must be merged in" )
9848+ . set_vars
9849+ . get( "MY_VAR" )
9850+ . map( String :: as_str) ,
9851+ Some ( "value" ) ,
9852+ "merged set_vars entry must be present after finalize"
9853+ ) ;
9854+ }
9855+
9856+ #[ test]
9857+ fn platform_overrides_set_vars_validated_after_merge ( ) {
9858+ // Regression: finalize_profile used to skip re-validating
9859+ // custom_credentials/env_credentials/set_vars after apply_platform_overrides
9860+ // merged them in, letting an override sneak in a reserved set_vars key.
9861+ let current_os = crate :: platform:: current_os_name ( ) ;
9862+ let json = format ! (
9863+ r#"{{
9864+ "meta": {{"name": "test"}},
9865+ "platform_overrides": {{
9866+ "{current_os}": {{ "environment": {{ "set_vars": {{"PATH": "/evil"}} }} }}
9867+ }}
9868+ }}"#
9869+ ) ;
9870+ let profile: Profile = serde_json:: from_str ( & json) . expect ( "parse" ) ;
9871+ let err = finalize_profile ( profile) . expect_err ( "reserved set_vars key must be rejected" ) ;
9872+ assert ! (
9873+ err. to_string( ) . contains( "PATH" ) ,
9874+ "expected error about reserved PATH key, got: {}" ,
9875+ err
9876+ ) ;
9877+ }
9878+
9879+ #[ test]
9880+ fn platform_overrides_on_base_survive_extends_resolution ( ) {
9881+ // Regression: merge_profiles used to null platform_overrides
9882+ // unconditionally, dropping an override defined on a base before
9883+ // finalize_profile ever got to apply it.
9884+ let current_os = crate :: platform:: current_os_name ( ) ;
9885+ let dir = tempdir ( ) . expect ( "tmpdir" ) ;
9886+ std:: fs:: write (
9887+ dir. path ( ) . join ( "shared.json" ) ,
9888+ format ! (
9889+ r#"{{
9890+ "meta": {{ "name": "shared" }},
9891+ "filesystem": {{ "read": ["/base/path"] }},
9892+ "platform_overrides": {{
9893+ "{current_os}": {{ "filesystem": {{ "read": ["/platform/path"] }} }}
9894+ }}
9895+ }}"#
9896+ ) ,
9897+ )
9898+ . expect ( "write" ) ;
9899+ let child_path = dir. path ( ) . join ( "child.json" ) ;
9900+ std:: fs:: write (
9901+ & child_path,
9902+ r#"{ "extends": "shared", "meta": { "name": "child" } }"# ,
9903+ )
9904+ . expect ( "write" ) ;
9905+
9906+ let merged = match load_from_file ( & child_path, & [ ] ) {
9907+ Ok ( profile) => profile,
9908+ Err ( err) => panic ! ( "resolve: {err}" ) ,
9909+ } ;
9910+ assert ! (
9911+ merged. platform_overrides. is_some( ) ,
9912+ "base profile's platform_overrides must survive extends resolution"
9913+ ) ;
9914+
9915+ let finalized = load_profile_from_path ( & child_path) . expect ( "load+finalize" ) ;
9916+ assert ! (
9917+ finalized. platform_overrides. is_none( ) ,
9918+ "platform_overrides should be consumed after finalization"
9919+ ) ;
9920+ assert ! (
9921+ finalized
9922+ . filesystem
9923+ . read
9924+ . contains( & "/base/path" . to_string( ) ) ,
9925+ "base path must be present"
9926+ ) ;
9927+ assert ! (
9928+ finalized
9929+ . filesystem
9930+ . read
9931+ . contains( & "/platform/path" . to_string( ) ) ,
9932+ "platform override declared on the base profile must apply through extends"
9933+ ) ;
9934+ }
9935+
9936+ #[ test]
9937+ fn platform_overrides_merge_per_os_key_child_wins ( ) {
9938+ let current_os = crate :: platform:: current_os_name ( ) ;
9939+ let base = Profile {
9940+ filesystem : FilesystemConfig {
9941+ read : vec ! [ "/base/path" . to_string( ) ] ,
9942+ ..Default :: default ( )
9943+ } ,
9944+ platform_overrides : Some ( PlatformOverrides {
9945+ macos : if current_os == "macos" {
9946+ Some ( Box :: new ( PlatformOverride ( Profile {
9947+ filesystem : FilesystemConfig {
9948+ read : vec ! [ "/base/override/current" . to_string( ) ] ,
9949+ ..Default :: default ( )
9950+ } ,
9951+ ..Default :: default ( )
9952+ } ) ) )
9953+ } else {
9954+ None
9955+ } ,
9956+ linux : if current_os == "linux" {
9957+ Some ( Box :: new ( PlatformOverride ( Profile {
9958+ filesystem : FilesystemConfig {
9959+ read : vec ! [ "/base/override/current" . to_string( ) ] ,
9960+ ..Default :: default ( )
9961+ } ,
9962+ ..Default :: default ( )
9963+ } ) ) )
9964+ } else {
9965+ None
9966+ } ,
9967+ windows : None ,
9968+ } ) ,
9969+ ..Default :: default ( )
9970+ } ;
9971+ let child = Profile {
9972+ platform_overrides : Some ( PlatformOverrides {
9973+ macos : None ,
9974+ linux : None ,
9975+ windows : Some ( Box :: new ( PlatformOverride ( Profile {
9976+ filesystem : FilesystemConfig {
9977+ read : vec ! [ "/child/override/windows" . to_string( ) ] ,
9978+ ..Default :: default ( )
9979+ } ,
9980+ ..Default :: default ( )
9981+ } ) ) ) ,
9982+ } ) ,
9983+ ..Default :: default ( )
9984+ } ;
9985+
9986+ let merged = merge_profiles ( base, child) ;
9987+ let po = merged
9988+ . platform_overrides
9989+ . expect ( "merged overrides must survive" ) ;
9990+ assert ! (
9991+ po. windows. is_some( ) ,
9992+ "child-only override key must be preserved"
9993+ ) ;
9994+ assert ! (
9995+ ( current_os == "macos" && po. macos. is_some( ) )
9996+ || ( current_os == "linux" && po. linux. is_some( ) ) ,
9997+ "base-only override key for the current platform must be preserved"
9998+ ) ;
9999+ }
10000+
10001+ #[ test]
10002+ fn platform_overrides_same_os_key_deep_merges_not_clobbers ( ) {
10003+ let current_os = crate :: platform:: current_os_name ( ) ;
10004+ let base_override = Profile {
10005+ network : NetworkConfig {
10006+ open_port : vec ! [ 1234 ] ,
10007+ ..Default :: default ( )
10008+ } ,
10009+ filesystem : FilesystemConfig {
10010+ read : vec ! [ "/base/override/path" . to_string( ) ] ,
10011+ ..Default :: default ( )
10012+ } ,
10013+ ..Default :: default ( )
10014+ } ;
10015+ let child_override = Profile {
10016+ open_urls : Some ( OpenUrlConfig {
10017+ allow_origins : vec ! [ "https://example.com" . to_string( ) ] ,
10018+ allow_localhost : false ,
10019+ } ) ,
10020+ ..Default :: default ( )
10021+ } ;
10022+ let mk_overrides = |o : Profile | -> PlatformOverrides {
10023+ let wrapped = Some ( Box :: new ( PlatformOverride ( o) ) ) ;
10024+ if current_os == "macos" {
10025+ PlatformOverrides {
10026+ macos : wrapped,
10027+ linux : None ,
10028+ windows : None ,
10029+ }
10030+ } else {
10031+ PlatformOverrides {
10032+ macos : None ,
10033+ linux : wrapped,
10034+ windows : None ,
10035+ }
10036+ }
10037+ } ;
10038+ let base = Profile {
10039+ platform_overrides : Some ( mk_overrides ( base_override) ) ,
10040+ ..Default :: default ( )
10041+ } ;
10042+ let child = Profile {
10043+ platform_overrides : Some ( mk_overrides ( child_override) ) ,
10044+ ..Default :: default ( )
10045+ } ;
10046+
10047+ let merged = merge_profiles ( base, child) ;
10048+ let finalized = finalize_profile ( merged) . expect ( "finalize" ) ;
10049+ assert ! (
10050+ finalized. network. open_port. contains( & 1234 ) ,
10051+ "base override's open_port must survive a same-OS deep merge with child's override"
10052+ ) ;
10053+ assert ! (
10054+ finalized
10055+ . filesystem
10056+ . read
10057+ . contains( & "/base/override/path" . to_string( ) ) ,
10058+ "base override's filesystem grant must survive a same-OS deep merge with child's override"
10059+ ) ;
10060+ assert ! (
10061+ finalized
10062+ . open_urls
10063+ . as_ref( )
10064+ . is_some_and( |u| u. allow_origins. contains( & "https://example.com" . to_string( ) ) ) ,
10065+ "child override's own field must still apply"
10066+ ) ;
10067+ }
977510068}
0 commit comments