@@ -4007,6 +4007,47 @@ pub fn parse_env_pairs(items: &[String]) -> Result<HashMap<String, String>> {
40074007 Ok ( map)
40084008}
40094009
4010+ /// Resolve `--secret-material-env KEY[=ENVVAR]` values from the CLI process
4011+ /// environment (`ENVVAR` defaults to `KEY`) so secrets never transit argv.
4012+ pub fn parse_secret_material_env_pairs ( items : & [ String ] ) -> Result < HashMap < String , String > > {
4013+ let mut map = HashMap :: new ( ) ;
4014+
4015+ for item in items {
4016+ let ( key, env_name) = match item. split_once ( '=' ) {
4017+ Some ( ( key, env_name) ) => ( key. trim ( ) , env_name. trim ( ) ) ,
4018+ None => ( item. trim ( ) , item. trim ( ) ) ,
4019+ } ;
4020+ if key. is_empty ( ) {
4021+ return Err ( miette:: miette!( "--secret-material-env key cannot be empty" ) ) ;
4022+ }
4023+ if env_name. is_empty ( ) {
4024+ return Err ( miette:: miette!(
4025+ "--secret-material-env {key} names an empty environment variable"
4026+ ) ) ;
4027+ }
4028+
4029+ let value = std:: env:: var ( env_name) . map_err ( |_| {
4030+ miette:: miette!(
4031+ "--secret-material-env {key} requires local env var '{env_name}' to be set to a non-empty value"
4032+ )
4033+ } ) ?;
4034+ if value. trim ( ) . is_empty ( ) {
4035+ return Err ( miette:: miette!(
4036+ "--secret-material-env {key} requires local env var '{env_name}' to be set to a non-empty value"
4037+ ) ) ;
4038+ }
4039+
4040+ if map. contains_key ( key) {
4041+ return Err ( miette:: miette!(
4042+ "--secret-material-env key '{key}' supplied more than once"
4043+ ) ) ;
4044+ }
4045+ map. insert ( key. to_string ( ) , value) ;
4046+ }
4047+
4048+ Ok ( map)
4049+ }
4050+
40104051fn is_valid_env_name ( key : & str ) -> bool {
40114052 let mut bytes = key. bytes ( ) ;
40124053 let Some ( first) = bytes. next ( ) else {
@@ -5282,6 +5323,7 @@ pub struct ProviderRefreshConfigInput<'a> {
52825323 pub credential_key : & ' a str ,
52835324 pub strategy : & ' a str ,
52845325 pub material : & ' a [ String ] ,
5326+ pub secret_material_env : & ' a [ String ] ,
52855327 pub secret_material_keys : & ' a [ String ] ,
52865328 pub credential_expires_at_ms : Option < i64 > ,
52875329}
@@ -5292,15 +5334,29 @@ pub async fn provider_refresh_config(
52925334 tls : & TlsOptions ,
52935335) -> Result < ( ) > {
52945336 let strategy = provider_refresh_strategy ( input. strategy ) ?;
5295- let material = parse_key_value_pairs ( input. material , "--material" ) ?;
5337+ let mut material = parse_key_value_pairs ( input. material , "--material" ) ?;
5338+ let mut secret_material_keys = input. secret_material_keys . to_vec ( ) ;
5339+ // Env-resolved secrets are auto-marked secret; duplicate keys are an
5340+ // error rather than a precedence order.
5341+ for ( key, value) in parse_secret_material_env_pairs ( input. secret_material_env ) ? {
5342+ if material. contains_key ( & key) {
5343+ return Err ( miette ! (
5344+ "duplicate material key '{key}': supplied via both --material and --secret-material-env"
5345+ ) ) ;
5346+ }
5347+ if !secret_material_keys. contains ( & key) {
5348+ secret_material_keys. push ( key. clone ( ) ) ;
5349+ }
5350+ material. insert ( key, value) ;
5351+ }
52965352 let mut client = grpc_client ( server, tls) . await ?;
52975353 let status = client
52985354 . configure_provider_refresh ( ConfigureProviderRefreshRequest {
52995355 provider : input. name . to_string ( ) ,
53005356 credential_key : input. credential_key . to_string ( ) ,
53015357 strategy : strategy as i32 ,
53025358 material,
5303- secret_material_keys : input . secret_material_keys . to_vec ( ) ,
5359+ secret_material_keys,
53045360 expires_at_ms : input. credential_expires_at_ms ,
53055361 } )
53065362 . await
@@ -7845,11 +7901,12 @@ mod tests {
78457901 gateway_type_label, git_sync_files, http_health_check, import_local_package_mtls_bundle,
78467902 inferred_provider_type, mtls_certs_exist_for_gateway, package_managed_tls_dirs,
78477903 parse_cli_setting_value, parse_credential_expiry_cli_value, parse_credential_expiry_pairs,
7848- parse_credential_pairs, parse_driver_config_json, plaintext_gateway_is_remote,
7849- progress_step_from_metadata, provider_profile_allows_empty_credentials,
7850- provisioning_timeout_message, ready_false_condition_message, refresh_status_header,
7851- refresh_status_row, resolve_from, sandbox_should_persist, sandbox_upload_plan,
7852- service_expose_status_error, service_url_for_gateway,
7904+ parse_credential_pairs, parse_driver_config_json, parse_secret_material_env_pairs,
7905+ plaintext_gateway_is_remote, progress_step_from_metadata,
7906+ provider_profile_allows_empty_credentials, provisioning_timeout_message,
7907+ ready_false_condition_message, refresh_status_header, refresh_status_row, resolve_from,
7908+ sandbox_should_persist, sandbox_upload_plan, service_expose_status_error,
7909+ service_url_for_gateway,
78537910 } ;
78547911 use crate :: TEST_ENV_LOCK ;
78557912 use hyper:: StatusCode ;
@@ -7993,6 +8050,73 @@ mod tests {
79938050 ) ) ;
79948051 }
79958052
8053+ #[ test]
8054+ fn parse_secret_material_env_pairs_reads_value_from_named_environment_variable ( ) {
8055+ let _guard = EnvVarGuard :: set ( "NAV_PARSE_SME_NAMED" , "pem-material" ) ;
8056+
8057+ let parsed =
8058+ parse_secret_material_env_pairs ( & [ "private_key=NAV_PARSE_SME_NAMED" . to_string ( ) ] )
8059+ . expect ( "parse" ) ;
8060+ assert_eq ! ( parsed. get( "private_key" ) , Some ( & "pem-material" . to_string( ) ) ) ;
8061+ }
8062+
8063+ #[ test]
8064+ fn parse_secret_material_env_pairs_defaults_env_name_to_key ( ) {
8065+ let _guard = EnvVarGuard :: set ( "NAV_PARSE_SME_KEY_ONLY" , "key-only-material" ) ;
8066+
8067+ let parsed = parse_secret_material_env_pairs ( & [ "NAV_PARSE_SME_KEY_ONLY" . to_string ( ) ] )
8068+ . expect ( "parse" ) ;
8069+ assert_eq ! (
8070+ parsed. get( "NAV_PARSE_SME_KEY_ONLY" ) ,
8071+ Some ( & "key-only-material" . to_string( ) )
8072+ ) ;
8073+ }
8074+
8075+ #[ test]
8076+ fn parse_secret_material_env_pairs_rejects_missing_environment ( ) {
8077+ let _guard = EnvVarGuard :: unset ( "NAV_PARSE_SME_MISSING" ) ;
8078+
8079+ let err =
8080+ parse_secret_material_env_pairs ( & [ "private_key=NAV_PARSE_SME_MISSING" . to_string ( ) ] )
8081+ . expect_err ( "missing env should error" ) ;
8082+ assert ! ( err. to_string( ) . contains(
8083+ "requires local env var 'NAV_PARSE_SME_MISSING' to be set to a non-empty value"
8084+ ) ) ;
8085+ }
8086+
8087+ #[ test]
8088+ fn parse_secret_material_env_pairs_rejects_empty_environment_value ( ) {
8089+ let _guard = EnvVarGuard :: set ( "NAV_PARSE_SME_EMPTY" , " " ) ;
8090+
8091+ let err = parse_secret_material_env_pairs ( & [ "private_key=NAV_PARSE_SME_EMPTY" . to_string ( ) ] )
8092+ . expect_err ( "blank env should error" ) ;
8093+ assert ! ( err. to_string( ) . contains(
8094+ "requires local env var 'NAV_PARSE_SME_EMPTY' to be set to a non-empty value"
8095+ ) ) ;
8096+ }
8097+
8098+ #[ test]
8099+ fn parse_secret_material_env_pairs_rejects_empty_key ( ) {
8100+ let err = parse_secret_material_env_pairs ( & [ "=NAV_PARSE_SME_NO_KEY" . to_string ( ) ] )
8101+ . expect_err ( "empty key should error" ) ;
8102+ assert ! ( err. to_string( ) . contains( "key cannot be empty" ) ) ;
8103+ }
8104+
8105+ #[ test]
8106+ fn parse_secret_material_env_pairs_rejects_duplicate_keys ( ) {
8107+ let _guard = EnvVarGuard :: set ( "NAV_PARSE_SME_DUP" , "value" ) ;
8108+
8109+ let err = parse_secret_material_env_pairs ( & [
8110+ "private_key=NAV_PARSE_SME_DUP" . to_string ( ) ,
8111+ "private_key=NAV_PARSE_SME_DUP" . to_string ( ) ,
8112+ ] )
8113+ . expect_err ( "duplicate key should error" ) ;
8114+ assert ! (
8115+ err. to_string( )
8116+ . contains( "key 'private_key' supplied more than once" )
8117+ ) ;
8118+ }
8119+
79968120 #[ test]
79978121 fn parse_credential_expiry_pairs_accepts_epoch_millis_and_rfc3339 ( ) {
79988122 let parsed = parse_credential_expiry_pairs ( & [
0 commit comments