@@ -66,6 +66,19 @@ pub struct ConfigOptions {
6666 /// decryptors that lack metastore write permission.
6767 #[ serde( rename = "SelfHealRecoveredKeys" ) ]
6868 pub self_heal_recovered_keys : Option < bool > ,
69+ /// Emergency escape hatch: run even if the persisted TOFU configuration
70+ /// drift guard conflicts with this resolved config. The guard is not
71+ /// rewritten. Env override: `ASHERAH_CONFIG_DRIFT_FORCE_RUN`.
72+ #[ serde( rename = "ConfigDriftForceRun" , alias = "ForceRunWithConfigDrift" ) ]
73+ pub config_drift_force_run : Option < bool > ,
74+ /// Repair escape hatch: replace the persisted TOFU configuration drift
75+ /// guard with this resolved config. Env override:
76+ /// `ASHERAH_CONFIG_DRIFT_FORCE_UPDATE`.
77+ #[ serde(
78+ rename = "ConfigDriftForceUpdate" ,
79+ alias = "ForceUpdateConfigDriftGuard"
80+ ) ]
81+ pub config_drift_force_update : Option < bool > ,
6982 #[ serde( rename = "EnableSessionCaching" ) ]
7083 pub enable_session_caching : Option < bool > ,
7184 #[ serde( rename = "Verbose" ) ]
@@ -169,7 +182,7 @@ pub struct AppliedConfig {
169182}
170183
171184use asherah:: builders:: {
172- KmsConfig , MetastoreConfig , PolicyConfig , PoolConfig , ResolvedConfig ,
185+ ConfigDriftGuardOptions , KmsConfig , MetastoreConfig , PolicyConfig , PoolConfig , ResolvedConfig ,
173186 TEST_DEBUG_STATIC_MASTER_KEY_HEX ,
174187} ;
175188
@@ -179,23 +192,23 @@ impl ConfigOptions {
179192 Ok ( cfg)
180193 }
181194
195+ pub fn config_drift_guard_options ( & self ) -> ConfigDriftGuardOptions {
196+ ConfigDriftGuardOptions {
197+ allow_mismatch : self . config_drift_force_run . unwrap_or ( false ) ,
198+ force_update : self . config_drift_force_update . unwrap_or ( false ) ,
199+ }
200+ }
201+
182202 /// Resolve into a structured config — no env var reads or writes.
183203 pub fn resolve ( & self ) -> Result < ( ResolvedConfig , AppliedConfig ) > {
184- // KMS aliases: `static` and `test-debug-static` are synonyms.
185- // `test-debug-static` is the preferred identifier because it
186- // makes the non-production nature obvious, but both must behave
187- // identically to preserve interop with the canonical Go
188- // implementation of Asherah (which accepts `static` and falls
189- // back to the well-known test key when no hex is provided).
190- // The fail-fast on empty `StaticMasterKeyHex` previously here
191- // diverged from Go-canonical behavior; the static-KMS path
192- // already logs a loud warning and tagged the test key as
193- // public, which is the right safety net.
204+ // `test-debug-static` is an explicit test-only alias that may use the
205+ // public test key. Legacy `static` remains supported, but it must
206+ // provide an explicit key so a missing KMS config cannot silently fall
207+ // back to static master-key material.
194208 fn normalize_alias ( value : & str ) -> String {
195209 match value. to_lowercase ( ) . as_str ( ) {
196210 "test-debug-memory" => "memory" . to_string ( ) ,
197211 "test-debug-sqlite" => "sqlite" . to_string ( ) ,
198- "test-debug-static" => "static" . to_string ( ) ,
199212 other => other. to_string ( ) ,
200213 }
201214 }
@@ -272,18 +285,24 @@ impl ConfigOptions {
272285
273286 let aws_profile_name = self . aws_profile_name . clone ( ) ;
274287
275- let kms_raw = self . kms . as_deref ( ) . unwrap_or ( "static" ) ;
288+ let kms_raw = self
289+ . kms
290+ . as_deref ( )
291+ . ok_or_else ( || anyhow ! ( "KMS is required" ) ) ?;
276292 let kms_kind = normalize_alias ( kms_raw) ;
277293 let kms = match kms_kind. as_str ( ) {
278- // `static` and `test-debug-static` collapse to the same
279- // arm via `normalize_alias` above. Falls back to the
280- // publicly-known test key when no hex is supplied (this
281- // matches Go-canonical asherah behavior); the static-KMS
282- // builder log-warns loudly that the key is non-production.
283294 "static" => KmsConfig :: Static {
284295 key_hex : self
285296 . static_master_key_hex
286297 . clone ( )
298+ . filter ( |key_hex| !key_hex. is_empty ( ) )
299+ . ok_or_else ( || anyhow ! ( "StaticMasterKeyHex is required when KMS=static" ) ) ?,
300+ } ,
301+ "test-debug-static" => KmsConfig :: Static {
302+ key_hex : self
303+ . static_master_key_hex
304+ . clone ( )
305+ . filter ( |key_hex| !key_hex. is_empty ( ) )
287306 . unwrap_or_else ( || TEST_DEBUG_STATIC_MASTER_KEY_HEX . to_string ( ) ) ,
288307 } ,
289308 "aws" => KmsConfig :: Aws {
@@ -454,16 +473,25 @@ fn resolve_rdbms_connection(
454473/// Build a factory from structured config — no env var side effects.
455474pub fn factory_from_config ( config : & ConfigOptions ) -> Result < ( Factory , AppliedConfig ) > {
456475 let ( mut resolved, applied) = config. resolve ( ) ?;
457- merge_recovery_region_suffixes_from_env ( & mut resolved) ;
458- let factory = asherah:: builders:: factory_from_resolved ( & resolved) ?;
476+ let mut config_drift_guard = config. config_drift_guard_options ( ) ;
477+ merge_runtime_env_overrides ( & mut resolved, & mut config_drift_guard) ;
478+ let factory = asherah:: builders:: factory_from_resolved_with_config_drift_guard (
479+ & resolved,
480+ config_drift_guard,
481+ ) ?;
459482 Ok ( ( factory, applied) )
460483}
461484
462485/// Async variant — safe for concurrent use since no env vars are written.
463486pub async fn factory_from_config_async ( config : & ConfigOptions ) -> Result < ( Factory , AppliedConfig ) > {
464487 let ( mut resolved, applied) = config. resolve ( ) ?;
465- merge_recovery_region_suffixes_from_env ( & mut resolved) ;
466- let factory = asherah:: builders:: factory_from_resolved_async ( & resolved) . await ?;
488+ let mut config_drift_guard = config. config_drift_guard_options ( ) ;
489+ merge_runtime_env_overrides ( & mut resolved, & mut config_drift_guard) ;
490+ let factory = asherah:: builders:: factory_from_resolved_with_config_drift_guard_async (
491+ & resolved,
492+ config_drift_guard,
493+ )
494+ . await ?;
467495 Ok ( ( factory, applied) )
468496}
469497
@@ -472,7 +500,18 @@ pub async fn factory_from_config_async(config: &ConfigOptions) -> Result<(Factor
472500/// while preserving order. This gives every language binding the env-based
473501/// recovery lever even when it has no first-class config field for it yet.
474502/// Only reads env (never writes), so it is safe for concurrent use.
475- fn merge_recovery_region_suffixes_from_env ( resolved : & mut ResolvedConfig ) {
503+ fn merge_runtime_env_overrides (
504+ resolved : & mut ResolvedConfig ,
505+ config_drift_guard : & mut ConfigDriftGuardOptions ,
506+ ) {
507+ fn parse_bool ( raw : & str ) -> Option < bool > {
508+ match raw. trim ( ) . to_lowercase ( ) . as_str ( ) {
509+ "1" | "true" | "yes" | "on" => Some ( true ) ,
510+ "0" | "false" | "no" | "off" => Some ( false ) ,
511+ _ => None ,
512+ }
513+ }
514+
476515 if let Ok ( raw) = std:: env:: var ( "RECOVERY_REGION_SUFFIXES" ) {
477516 for entry in raw. split ( ',' ) . map ( str:: trim) . filter ( |s| !s. is_empty ( ) ) {
478517 if !resolved. recovery_region_suffixes . iter ( ) . any ( |s| s == entry) {
@@ -488,6 +527,16 @@ fn merge_recovery_region_suffixes_from_env(resolved: &mut ResolvedConfig) {
488527 "0" | "false" | "no" | "off"
489528 ) ;
490529 }
530+ if let Ok ( raw) = std:: env:: var ( "ASHERAH_CONFIG_DRIFT_FORCE_RUN" ) {
531+ if let Some ( force_run) = parse_bool ( & raw ) {
532+ config_drift_guard. allow_mismatch = force_run;
533+ }
534+ }
535+ if let Ok ( raw) = std:: env:: var ( "ASHERAH_CONFIG_DRIFT_FORCE_UPDATE" ) {
536+ if let Some ( force_update) = parse_bool ( & raw ) {
537+ config_drift_guard. force_update = force_update;
538+ }
539+ }
491540}
492541
493542/// Resolve the DynamoDB endpoint URL given the user-supplied
0 commit comments