@@ -4007,6 +4007,42 @@ 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+ map. insert ( key. to_string ( ) , value) ;
4041+ }
4042+
4043+ Ok ( map)
4044+ }
4045+
40104046fn is_valid_env_name ( key : & str ) -> bool {
40114047 let mut bytes = key. bytes ( ) ;
40124048 let Some ( first) = bytes. next ( ) else {
@@ -5282,6 +5318,7 @@ pub struct ProviderRefreshConfigInput<'a> {
52825318 pub credential_key : & ' a str ,
52835319 pub strategy : & ' a str ,
52845320 pub material : & ' a [ String ] ,
5321+ pub secret_material_env : & ' a [ String ] ,
52855322 pub secret_material_keys : & ' a [ String ] ,
52865323 pub credential_expires_at_ms : Option < i64 > ,
52875324}
@@ -5292,15 +5329,23 @@ pub async fn provider_refresh_config(
52925329 tls : & TlsOptions ,
52935330) -> Result < ( ) > {
52945331 let strategy = provider_refresh_strategy ( input. strategy ) ?;
5295- let material = parse_key_value_pairs ( input. material , "--material" ) ?;
5332+ let mut material = parse_key_value_pairs ( input. material , "--material" ) ?;
5333+ let mut secret_material_keys = input. secret_material_keys . to_vec ( ) ;
5334+ // Env-resolved secrets override `--material` duplicates and are auto-marked secret.
5335+ for ( key, value) in parse_secret_material_env_pairs ( input. secret_material_env ) ? {
5336+ if !secret_material_keys. contains ( & key) {
5337+ secret_material_keys. push ( key. clone ( ) ) ;
5338+ }
5339+ material. insert ( key, value) ;
5340+ }
52965341 let mut client = grpc_client ( server, tls) . await ?;
52975342 let status = client
52985343 . configure_provider_refresh ( ConfigureProviderRefreshRequest {
52995344 provider : input. name . to_string ( ) ,
53005345 credential_key : input. credential_key . to_string ( ) ,
53015346 strategy : strategy as i32 ,
53025347 material,
5303- secret_material_keys : input . secret_material_keys . to_vec ( ) ,
5348+ secret_material_keys,
53045349 expires_at_ms : input. credential_expires_at_ms ,
53055350 } )
53065351 . await
@@ -7845,11 +7890,12 @@ mod tests {
78457890 gateway_type_label, git_sync_files, http_health_check, import_local_package_mtls_bundle,
78467891 inferred_provider_type, mtls_certs_exist_for_gateway, package_managed_tls_dirs,
78477892 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,
7893+ parse_credential_pairs, parse_driver_config_json, parse_secret_material_env_pairs,
7894+ plaintext_gateway_is_remote, progress_step_from_metadata,
7895+ provider_profile_allows_empty_credentials, provisioning_timeout_message,
7896+ ready_false_condition_message, refresh_status_header, refresh_status_row, resolve_from,
7897+ sandbox_should_persist, sandbox_upload_plan, service_expose_status_error,
7898+ service_url_for_gateway,
78537899 } ;
78547900 use crate :: TEST_ENV_LOCK ;
78557901 use hyper:: StatusCode ;
@@ -7993,6 +8039,58 @@ mod tests {
79938039 ) ) ;
79948040 }
79958041
8042+ #[ test]
8043+ fn parse_secret_material_env_pairs_reads_value_from_named_environment_variable ( ) {
8044+ let _guard = EnvVarGuard :: set ( "NAV_PARSE_SME_NAMED" , "pem-material" ) ;
8045+
8046+ let parsed =
8047+ parse_secret_material_env_pairs ( & [ "private_key=NAV_PARSE_SME_NAMED" . to_string ( ) ] )
8048+ . expect ( "parse" ) ;
8049+ assert_eq ! ( parsed. get( "private_key" ) , Some ( & "pem-material" . to_string( ) ) ) ;
8050+ }
8051+
8052+ #[ test]
8053+ fn parse_secret_material_env_pairs_defaults_env_name_to_key ( ) {
8054+ let _guard = EnvVarGuard :: set ( "NAV_PARSE_SME_KEY_ONLY" , "key-only-material" ) ;
8055+
8056+ let parsed = parse_secret_material_env_pairs ( & [ "NAV_PARSE_SME_KEY_ONLY" . to_string ( ) ] )
8057+ . expect ( "parse" ) ;
8058+ assert_eq ! (
8059+ parsed. get( "NAV_PARSE_SME_KEY_ONLY" ) ,
8060+ Some ( & "key-only-material" . to_string( ) )
8061+ ) ;
8062+ }
8063+
8064+ #[ test]
8065+ fn parse_secret_material_env_pairs_rejects_missing_environment ( ) {
8066+ let _guard = EnvVarGuard :: unset ( "NAV_PARSE_SME_MISSING" ) ;
8067+
8068+ let err =
8069+ parse_secret_material_env_pairs ( & [ "private_key=NAV_PARSE_SME_MISSING" . to_string ( ) ] )
8070+ . expect_err ( "missing env should error" ) ;
8071+ assert ! ( err. to_string( ) . contains(
8072+ "requires local env var 'NAV_PARSE_SME_MISSING' to be set to a non-empty value"
8073+ ) ) ;
8074+ }
8075+
8076+ #[ test]
8077+ fn parse_secret_material_env_pairs_rejects_empty_environment_value ( ) {
8078+ let _guard = EnvVarGuard :: set ( "NAV_PARSE_SME_EMPTY" , " " ) ;
8079+
8080+ let err = parse_secret_material_env_pairs ( & [ "private_key=NAV_PARSE_SME_EMPTY" . to_string ( ) ] )
8081+ . expect_err ( "blank env should error" ) ;
8082+ assert ! ( err. to_string( ) . contains(
8083+ "requires local env var 'NAV_PARSE_SME_EMPTY' to be set to a non-empty value"
8084+ ) ) ;
8085+ }
8086+
8087+ #[ test]
8088+ fn parse_secret_material_env_pairs_rejects_empty_key ( ) {
8089+ let err = parse_secret_material_env_pairs ( & [ "=NAV_PARSE_SME_NO_KEY" . to_string ( ) ] )
8090+ . expect_err ( "empty key should error" ) ;
8091+ assert ! ( err. to_string( ) . contains( "key cannot be empty" ) ) ;
8092+ }
8093+
79968094 #[ test]
79978095 fn parse_credential_expiry_pairs_accepts_epoch_millis_and_rfc3339 ( ) {
79988096 let parsed = parse_credential_expiry_pairs ( & [
0 commit comments