| 1 | //! Model-bound redaction opt-out (`[redaction] model_bound`). |
| 2 | //! |
| 3 | //! Codewhale masks credential-looking values in tool output before it is sent |
| 4 | //! to an upstream model (the "model boundary"). That masking is a security |
| 5 | //! backstop: a file read by a tool can contain a configured API key, a bare |
| 6 | //! provider token, or a credential-shaped opaque string, and the model must |
| 7 | //! never see those bytes. |
| 8 | //! |
| 9 | //! This module adds a deliberate, documented way to turn that masking off for |
| 10 | //! users who must edit files that contain real credentials. Because it lowers |
| 11 | //! a security boundary, it is not a plain boolean: |
| 12 | //! |
| 13 | //! * Setting `[redaction] model_bound = "disabled"` in `config.toml` only |
| 14 | //! records a *request*. |
| 15 | //! * The request takes effect only after a restart of the interactive TUI and |
| 16 | //! an explicit confirmation on the startup gate screen, which persists a |
| 17 | //! receipt next to the config file actually loaded by that launch. |
| 18 | //! * Non-interactive entry points (`codewhale exec`, hooks, automation) never |
| 19 | //! confirm anything; as long as no confirmation receipt exists they resolve |
| 20 | //! to the safe default (`Enabled`), whatever the config file says. |
| 21 | //! * Dismissing the gate (choosing "keep masking on") leaves the config field |
| 22 | //! and the receipt untouched, so the next launch asks again until the user |
| 23 | //! confirms or edits the field back to `"enabled"`. |
| 24 | //! |
| 25 | //! A confirmation receipt is bound to the loaded config file, including an |
| 26 | //! explicit `--config` or `CODEWHALE_CONFIG_PATH`. Different config filenames |
| 27 | //! have independent receipts. A receipt is honored only while the canonical |
| 28 | //! path, contents and modification time still match and the config requests |
| 29 | //! `"disabled"`. |
| 30 | //! Editing the field back to `"enabled"` - or changing `config.toml` in any |
| 31 | //! way - and later re-requesting `"disabled"` always asks for a fresh |
| 32 | //! confirmation, even when no process ran in between. |
| 33 | |
| 34 | use serde::{Deserialize, Serialize}; |
| 35 | use sha2::{Digest, Sha256}; |
| 36 | use std::fs; |
| 37 | use std::io; |
| 38 | use std::path::{Path, PathBuf}; |
| 39 | |
| 40 | /// Name of the confirmation-receipt file, stored next to `config.toml` in the |
| 41 | /// Codewhale home directory. |
| 42 | pub const MODEL_BOUND_STATE_FILE_NAME: &str = "redaction-state.json"; |
| 43 | |
| 44 | /// Whether credential-shaped values are masked at the model boundary. |
| 45 | /// |
| 46 | /// Parsing is deliberately forgiving on the way in — the config value is a |
| 47 | /// security switch and users reach for boolean spellings — so `true`/`false`, |
| 48 | /// `"on"`/`"off"`, and `"enabled"`/`"disabled"` (any casing) all resolve to |
| 49 | /// the same two states. Serialization always writes the canonical |
| 50 | /// `"enabled"` / `"disabled"` words. |
| 51 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)] |
| 52 | #[serde(rename_all = "kebab-case")] |
| 53 | pub enum ModelBoundMasking { |
| 54 | /// Mask credential-shaped tool output before it reaches the model (default). |
| 55 | #[default] |
| 56 | Enabled, |
| 57 | /// Let the model see the raw bytes of tool output, credentials included. |
| 58 | /// Only effective after an explicit startup confirmation (see the module |
| 59 | /// docs); until then it resolves to [`ModelBoundMasking::Enabled`]. |
| 60 | Disabled, |
| 61 | } |
| 62 | |
| 63 | impl ModelBoundMasking { |
| 64 | pub fn is_disabled(self) -> bool { |
| 65 | self == Self::Disabled |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | impl<'de> serde::Deserialize<'de> for ModelBoundMasking { |
| 70 | fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> |
| 71 | where |
| 72 | D: serde::Deserializer<'de>, |
| 73 | { |
| 74 | #[derive(serde::Deserialize)] |
| 75 | #[serde(untagged)] |
| 76 | enum Raw { |
| 77 | Flag(bool), |
| 78 | Word(String), |
| 79 | } |
| 80 | match Raw::deserialize(deserializer)? { |
| 81 | Raw::Flag(true) => Ok(ModelBoundMasking::Enabled), |
| 82 | Raw::Flag(false) => Ok(ModelBoundMasking::Disabled), |
| 83 | Raw::Word(word) => match word.to_ascii_lowercase().as_str() { |
| 84 | "enabled" | "on" | "true" => Ok(ModelBoundMasking::Enabled), |
| 85 | "disabled" | "off" | "false" => Ok(ModelBoundMasking::Disabled), |
| 86 | other => Err(serde::de::Error::unknown_variant( |
| 87 | other, |
| 88 | &["enabled", "disabled", "on", "off", "true", "false"], |
| 89 | )), |
| 90 | }, |
| 91 | } |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | /// The `[redaction]` table of `config.toml`. |
| 96 | #[derive(Debug, Clone, Default, Serialize, Deserialize)] |
| 97 | #[serde(default)] |
| 98 | pub struct RedactionToml { |
| 99 | /// Model-bound masking policy: `"enabled"` (default) or `"disabled"`. |
| 100 | /// Boolean spellings are also accepted: `false` / `"off"` mean the same |
| 101 | /// as `"disabled"`, and `true` / `"on"` mean `"enabled"`. |
| 102 | /// |
| 103 | /// A `"disabled"` request is honored only after a TUI restart and a one-time |
| 104 | /// confirmation on the startup gate; see the module documentation. |
| 105 | #[serde(skip_serializing_if = "Option::is_none")] |
| 106 | pub model_bound: Option<ModelBoundMasking>, |
| 107 | } |
| 108 | |
| 109 | impl RedactionToml { |
| 110 | /// The requested masking mode, defaulting to [`ModelBoundMasking::Enabled`]. |
| 111 | pub fn model_bound_masking(&self) -> ModelBoundMasking { |
| 112 | self.model_bound.unwrap_or_default() |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | /// Receipt belonging to this exact config file. Keep the established name |
| 117 | /// for config.toml; other filenames get independent receipts even when they |
| 118 | /// live in the same directory and contain identical configuration. |
| 119 | pub fn model_bound_state_path(config_path: &Path) -> PathBuf { |
| 120 | // --config and environment resolution may spell the same file through |
| 121 | // different symlinks (for example /var and /private/var on macOS). |
| 122 | let resolved = config_path.canonicalize().ok(); |
| 123 | let config_path = resolved.as_deref().unwrap_or(config_path); |
| 124 | if config_path.file_name() == Some(std::ffi::OsStr::new(crate::CONFIG_FILE_NAME)) { |
| 125 | config_path.with_file_name(MODEL_BOUND_STATE_FILE_NAME) |
| 126 | } else { |
| 127 | let identity: String = Sha256::digest(config_path.as_os_str().as_encoded_bytes()) |
| 128 | .iter() |
| 129 | .map(|byte| format!("{byte:02x}")) |
| 130 | .collect(); |
| 131 | config_path.with_file_name(format!("redaction-state-{identity}.json")) |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | /// Clear only this config's confirmation. A stale receipt is rejected even |
| 136 | /// when filesystem errors prevent this best-effort sweep. |
| 137 | pub fn clear_model_bound_disabled_confirmation(config_path: &Path) -> io::Result<()> { |
| 138 | let path = model_bound_state_path(config_path); |
| 139 | for attempt in 0..6 { |
| 140 | match write_state(&path, config_path, false) { |
| 141 | Ok(()) => { |
| 142 | let _ = fs::remove_file(&path); |
| 143 | return Ok(()); |
| 144 | } |
| 145 | Err(_) if attempt < 5 => { |
| 146 | std::thread::sleep(std::time::Duration::from_millis(100)); |
| 147 | } |
| 148 | Err(err) => return Err(err), |
| 149 | } |
| 150 | } |
| 151 | unreachable!() |
| 152 | } |
| 153 | |
| 154 | /// Persist consent for the config that was actually loaded by the caller. |
| 155 | /// Never infer that source from the receipt directory or the process home. |
| 156 | pub fn record_model_bound_disabled_confirmation(config_path: &Path) -> io::Result<PathBuf> { |
| 157 | let path = model_bound_state_path(config_path); |
| 158 | write_state(&path, config_path, true)?; |
| 159 | Ok(path) |
| 160 | } |
| 161 | |
| 162 | pub fn confirmation_required(desired: ModelBoundMasking, config_path: Option<&Path>) -> bool { |
| 163 | desired.is_disabled() && !confirmed_for_current_request(desired, config_path) |
| 164 | } |
| 165 | |
| 166 | /// Unloaded, unreadable, changed and unconfirmed requests remain masked. |
| 167 | pub fn effective_masking( |
| 168 | desired: ModelBoundMasking, |
| 169 | config_path: Option<&Path>, |
| 170 | ) -> ModelBoundMasking { |
| 171 | if confirmed_for_current_request(desired, config_path) { |
| 172 | ModelBoundMasking::Disabled |
| 173 | } else { |
| 174 | ModelBoundMasking::Enabled |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | fn confirmed_for_current_request(desired: ModelBoundMasking, config_path: Option<&Path>) -> bool { |
| 179 | let Some(config_path) = config_path else { |
| 180 | return false; |
| 181 | }; |
| 182 | let receipt = read_state(&model_bound_state_path(config_path)); |
| 183 | if !receipt.model_bound_disabled_confirmed { |
| 184 | return false; |
| 185 | } |
| 186 | let current = config_binding(config_path).ok(); |
| 187 | if !desired.is_disabled() || current.is_none() || current != receipt.config_binding { |
| 188 | let _ = clear_model_bound_disabled_confirmation(config_path); |
| 189 | return false; |
| 190 | } |
| 191 | true |
| 192 | } |
| 193 | |
| 194 | // === State-file plumbing (path-parameterized so tests stay hermetic) === |
| 195 | |
| 196 | #[derive(Debug, Default, Serialize, Deserialize)] |
| 197 | #[serde(default)] |
| 198 | struct StateFile { |
| 199 | model_bound_disabled_confirmed: bool, |
| 200 | config_binding: Option<ConfigBinding>, |
| 201 | } |
| 202 | |
| 203 | #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] |
| 204 | struct ConfigBinding { |
| 205 | config_path: PathBuf, |
| 206 | sha256: [u8; 32], |
| 207 | modified: std::time::SystemTime, |
| 208 | } |
| 209 | |
| 210 | fn config_binding(config_path: &Path) -> io::Result<ConfigBinding> { |
| 211 | let config_path = config_path.canonicalize()?; |
| 212 | let file = fs::File::open(&config_path)?; |
| 213 | let modified = file.metadata()?.modified()?; |
| 214 | let mut body = String::new(); |
| 215 | std::io::Read::read_to_string(&mut &file, &mut body)?; |
| 216 | let config: crate::ConfigToml = toml::from_str(&body) |
| 217 | .map_err(|_| io::Error::other("cannot confirm an unreadable redaction config"))?; |
| 218 | if !config.redaction_model_bound_masking().is_disabled() { |
| 219 | return Err(io::Error::other( |
| 220 | "config does not request disabling model-bound masking", |
| 221 | )); |
| 222 | } |
| 223 | Ok(ConfigBinding { |
| 224 | config_path, |
| 225 | sha256: Sha256::digest(body.as_bytes()).into(), |
| 226 | modified, |
| 227 | }) |
| 228 | } |
| 229 | |
| 230 | fn read_state(path: &std::path::Path) -> StateFile { |
| 231 | fs::read_to_string(path) |
| 232 | .ok() |
| 233 | .and_then(|body| serde_json::from_str(&body).ok()) |
| 234 | .unwrap_or_default() |
| 235 | } |
| 236 | |
| 237 | fn write_state(path: &Path, config_path: &Path, confirmed: bool) -> io::Result<()> { |
| 238 | if let Some(parent) = path.parent() |
| 239 | && !parent.as_os_str().is_empty() |
| 240 | { |
| 241 | fs::create_dir_all(parent)?; |
| 242 | } |
| 243 | let body = serde_json::to_string_pretty(&StateFile { |
| 244 | model_bound_disabled_confirmed: confirmed, |
| 245 | config_binding: if confirmed { |
| 246 | Some(config_binding(config_path)?) |
| 247 | } else { |
| 248 | None |
| 249 | }, |
| 250 | }) |
| 251 | .map_err(io::Error::other)?; |
| 252 | fs::write(path, body) |
| 253 | } |
| 254 | |
| 255 | #[cfg(test)] |
| 256 | mod tests { |
| 257 | use super::*; |
| 258 | use std::path::Path; |
| 259 | |
| 260 | fn state_path(tmp: &Path) -> PathBuf { |
| 261 | tmp.join(MODEL_BOUND_STATE_FILE_NAME) |
| 262 | } |
| 263 | |
| 264 | #[test] |
| 265 | fn absent_state_is_not_confirmed() { |
| 266 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 267 | assert!(!read_state(&state_path(tmp.path())).model_bound_disabled_confirmed); |
| 268 | } |
| 269 | |
| 270 | #[test] |
| 271 | fn confirmation_round_trips_through_the_state_file() { |
| 272 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 273 | let path = state_path(tmp.path()); |
| 274 | fs::write( |
| 275 | tmp.path().join(crate::CONFIG_FILE_NAME), |
| 276 | "[redaction]\nmodel_bound = \"disabled\"\n", |
| 277 | ) |
| 278 | .expect("write config"); |
| 279 | write_state(&path, &tmp.path().join(crate::CONFIG_FILE_NAME), true).expect("write state"); |
| 280 | assert!(read_state(&path).model_bound_disabled_confirmed); |
| 281 | } |
| 282 | |
| 283 | /// The disable-and-confirm lifecycle belongs to one explicit config. |
| 284 | #[test] |
| 285 | fn loaded_path_lifecycle_requires_confirmation_before_disabling() { |
| 286 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 287 | let config_path = tmp.path().join(crate::CONFIG_FILE_NAME); |
| 288 | assert!(!confirmed_for_current_request( |
| 289 | ModelBoundMasking::Disabled, |
| 290 | Some(&config_path) |
| 291 | )); |
| 292 | |
| 293 | let desired = ModelBoundMasking::Disabled; |
| 294 | assert!(confirmation_required(desired, Some(&config_path))); |
| 295 | assert_eq!( |
| 296 | effective_masking(desired, Some(&config_path)), |
| 297 | ModelBoundMasking::Enabled |
| 298 | ); |
| 299 | |
| 300 | assert!( |
| 301 | record_model_bound_disabled_confirmation(&config_path).is_err(), |
| 302 | "missing config cannot authorize an opt-out" |
| 303 | ); |
| 304 | let config_path = tmp.path().join(crate::CONFIG_FILE_NAME); |
| 305 | let disabled_config = "[redaction]\nmodel_bound = \"disabled\"\n"; |
| 306 | fs::write(&config_path, disabled_config).expect("write disabled config"); |
| 307 | let written = record_model_bound_disabled_confirmation(&config_path).expect("record"); |
| 308 | assert_eq!( |
| 309 | written, |
| 310 | tmp.path() |
| 311 | .canonicalize() |
| 312 | .unwrap() |
| 313 | .join(MODEL_BOUND_STATE_FILE_NAME) |
| 314 | ); |
| 315 | assert!(confirmed_for_current_request( |
| 316 | ModelBoundMasking::Disabled, |
| 317 | Some(&config_path) |
| 318 | )); |
| 319 | assert!(!confirmation_required(desired, Some(&config_path))); |
| 320 | assert_eq!( |
| 321 | effective_masking(desired, Some(&config_path)), |
| 322 | ModelBoundMasking::Disabled |
| 323 | ); |
| 324 | |
| 325 | // Windows real-time AV scanning can hold a short exclusive lock on a |
| 326 | // file we just wrote; the confirm -> re-enable sweep below rewrites |
| 327 | // that same file back-to-back, which is exactly the lock window. The |
| 328 | // product flow never does this (record and sweep happen on different |
| 329 | // launches), so back off briefly here to keep the test deterministic |
| 330 | // on Defender-equipped machines. |
| 331 | std::thread::sleep(std::time::Duration::from_millis(300)); |
| 332 | |
| 333 | // An enabled request never disables, even with a receipt on disk - |
| 334 | // and going back to enabled invalidates the receipt, so the next |
| 335 | // disabled request must be confirmed again. |
| 336 | let enabled = ModelBoundMasking::Enabled; |
| 337 | assert!(!confirmation_required(enabled, Some(&config_path))); |
| 338 | assert_eq!( |
| 339 | effective_masking(enabled, Some(&config_path)), |
| 340 | ModelBoundMasking::Enabled |
| 341 | ); |
| 342 | clear_model_bound_disabled_confirmation(&config_path) |
| 343 | .expect("explicit clear must succeed after re-enabling"); |
| 344 | assert!( |
| 345 | !confirmed_for_current_request(ModelBoundMasking::Disabled, Some(&config_path)), |
| 346 | "returning to enabled must clear the confirmation receipt" |
| 347 | ); |
| 348 | |
| 349 | // Re-disabling after an enabled period asks again from scratch. |
| 350 | assert!(confirmation_required(desired, Some(&config_path))); |
| 351 | assert_eq!( |
| 352 | effective_masking(desired, Some(&config_path)), |
| 353 | ModelBoundMasking::Enabled |
| 354 | ); |
| 355 | |
| 356 | // The receipt is bound to the config it was made against: rewriting |
| 357 | // config.toml after a fresh confirmation (an enabled -> disabled |
| 358 | // round trip with zero processes in between) must invalidate it too. |
| 359 | record_model_bound_disabled_confirmation(&config_path).expect("record again"); |
| 360 | assert!(!confirmation_required(desired, Some(&config_path))); |
| 361 | // Ensure config.toml is strictly newer than the receipt before the |
| 362 | // rewrite check runs. |
| 363 | std::thread::sleep(std::time::Duration::from_millis(30)); |
| 364 | std::fs::write( |
| 365 | tmp.path().join(crate::CONFIG_FILE_NAME), |
| 366 | "[redaction]\nmodel_bound = \"disabled\"\n", |
| 367 | ) |
| 368 | .expect("touch config after receipt"); |
| 369 | assert!( |
| 370 | confirmation_required(desired, Some(&config_path)), |
| 371 | "a config rewritten after the receipt must force a fresh confirmation" |
| 372 | ); |
| 373 | assert_eq!( |
| 374 | effective_masking(desired, Some(&config_path)), |
| 375 | ModelBoundMasking::Enabled |
| 376 | ); |
| 377 | |
| 378 | record_model_bound_disabled_confirmation(&config_path).expect("confirm readable config"); |
| 379 | let original_mtime = fs::metadata(&config_path).unwrap().modified().unwrap(); |
| 380 | fs::write(&config_path, format!("{disabled_config}# changed\n")).unwrap(); |
| 381 | fs::File::options() |
| 382 | .write(true) |
| 383 | .open(&config_path) |
| 384 | .unwrap() |
| 385 | .set_times(fs::FileTimes::new().set_modified(original_mtime)) |
| 386 | .unwrap(); |
| 387 | assert_eq!( |
| 388 | effective_masking(desired, Some(&config_path)), |
| 389 | ModelBoundMasking::Enabled, |
| 390 | "changed bytes with preserved timestamps must invalidate confirmation" |
| 391 | ); |
| 392 | |
| 393 | record_model_bound_disabled_confirmation(&config_path).expect("confirm updated config"); |
| 394 | fs::remove_file(&config_path).unwrap(); |
| 395 | assert_eq!( |
| 396 | effective_masking(desired, Some(&config_path)), |
| 397 | ModelBoundMasking::Enabled, |
| 398 | "missing config metadata must fail closed" |
| 399 | ); |
| 400 | fs::write(&config_path, disabled_config).unwrap(); |
| 401 | fs::write(&written, r#"{"model_bound_disabled_confirmed":true}"#).unwrap(); |
| 402 | assert_eq!( |
| 403 | effective_masking(desired, Some(&config_path)), |
| 404 | ModelBoundMasking::Enabled, |
| 405 | "legacy receipts without a config binding cannot authorize the opt-out" |
| 406 | ); |
| 407 | fs::write(&config_path, "[redaction]\nmodel_bound = \"enabled\"\n").unwrap(); |
| 408 | assert!( |
| 409 | record_model_bound_disabled_confirmation(&config_path).is_err(), |
| 410 | "a loaded disabled request cannot confirm an enabled file on disk" |
| 411 | ); |
| 412 | } |
| 413 | |
| 414 | #[test] |
| 415 | fn custom_configs_cannot_borrow_or_erase_each_others_confirmation() { |
| 416 | let temp = tempfile::tempdir().unwrap(); |
| 417 | let default = temp.path().join("config.toml"); |
| 418 | let custom = temp.path().join("work.toml"); |
| 419 | let other = temp.path().join("personal.toml"); |
| 420 | for path in [&default, &custom, &other] { |
| 421 | fs::write(path, "[redaction]\nmodel_bound = \"disabled\"\n").unwrap(); |
| 422 | } |
| 423 | let desired = ModelBoundMasking::Disabled; |
| 424 | record_model_bound_disabled_confirmation(&default).unwrap(); |
| 425 | assert!(confirmation_required(desired, Some(&custom))); |
| 426 | record_model_bound_disabled_confirmation(&custom).unwrap(); |
| 427 | assert!(!confirmation_required(desired, Some(&custom))); |
| 428 | assert!(confirmation_required(desired, Some(&other))); |
| 429 | assert_ne!( |
| 430 | model_bound_state_path(&custom), |
| 431 | model_bound_state_path(&other) |
| 432 | ); |
| 433 | assert_ne!( |
| 434 | model_bound_state_path(&custom), |
| 435 | model_bound_state_path(&default) |
| 436 | ); |
| 437 | |
| 438 | // Re-enabling one config must not revoke either of the other files. |
| 439 | record_model_bound_disabled_confirmation(&other).unwrap(); |
| 440 | assert_eq!( |
| 441 | effective_masking(ModelBoundMasking::Enabled, Some(&custom)), |
| 442 | ModelBoundMasking::Enabled |
| 443 | ); |
| 444 | assert!(confirmation_required(desired, Some(&custom))); |
| 445 | assert!(!confirmation_required(desired, Some(&default))); |
| 446 | assert!(!confirmation_required(desired, Some(&other))); |
| 447 | assert!(confirmation_required(desired, None)); |
| 448 | assert_eq!(effective_masking(desired, None), ModelBoundMasking::Enabled); |
| 449 | } |
| 450 | |
| 451 | #[test] |
| 452 | fn copied_receipt_cannot_confirm_identical_file_with_identical_timestamp() { |
| 453 | let temp = tempfile::tempdir().unwrap(); |
| 454 | let source = temp.path().join("first.toml"); |
| 455 | let destination = temp.path().join("second.toml"); |
| 456 | fs::write(&source, "[redaction]\nmodel_bound = \"disabled\"\n").unwrap(); |
| 457 | fs::copy(&source, &destination).unwrap(); |
| 458 | let modified = fs::metadata(&source).unwrap().modified().unwrap(); |
| 459 | fs::File::options() |
| 460 | .write(true) |
| 461 | .open(&destination) |
| 462 | .unwrap() |
| 463 | .set_times(fs::FileTimes::new().set_modified(modified)) |
| 464 | .unwrap(); |
| 465 | let receipt = record_model_bound_disabled_confirmation(&source).unwrap(); |
| 466 | fs::copy(receipt, model_bound_state_path(&destination)).unwrap(); |
| 467 | assert!(confirmation_required( |
| 468 | ModelBoundMasking::Disabled, |
| 469 | Some(&destination) |
| 470 | )); |
| 471 | assert!(!confirmation_required( |
| 472 | ModelBoundMasking::Disabled, |
| 473 | Some(&source) |
| 474 | )); |
| 475 | } |
| 476 | |
| 477 | #[test] |
| 478 | fn custom_confirmation_reads_loaded_file_instead_of_sibling_config_toml() { |
| 479 | let temp = tempfile::tempdir().unwrap(); |
| 480 | let custom = temp.path().join("selected.toml"); |
| 481 | let default = temp.path().join("config.toml"); |
| 482 | fs::write(&default, "[redaction]\nmodel_bound = \"disabled\"\n").unwrap(); |
| 483 | assert!(record_model_bound_disabled_confirmation(&custom).is_err()); |
| 484 | fs::write(&custom, "[redaction]\nmodel_bound = \"enabled\"\n").unwrap(); |
| 485 | assert!(record_model_bound_disabled_confirmation(&custom).is_err()); |
| 486 | fs::write(&custom, "[redaction]\nmodel_bound = \"disabled\"\n").unwrap(); |
| 487 | fs::write(&default, "[redaction]\nmodel_bound = \"enabled\"\n").unwrap(); |
| 488 | record_model_bound_disabled_confirmation(&custom).unwrap(); |
| 489 | assert!(!confirmation_required( |
| 490 | ModelBoundMasking::Disabled, |
| 491 | Some(&custom) |
| 492 | )); |
| 493 | assert!(!model_bound_state_path(&default).exists()); |
| 494 | } |
| 495 | |
| 496 | #[test] |
| 497 | fn corrupt_state_reads_as_unconfirmed() { |
| 498 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 499 | let path = state_path(tmp.path()); |
| 500 | std::fs::write(&path, "not json at all").expect("write corrupt state"); |
| 501 | assert!(!read_state(&path).model_bound_disabled_confirmed); |
| 502 | } |
| 503 | |
| 504 | #[test] |
| 505 | fn toml_table_parses_and_round_trips() { |
| 506 | let parsed: crate::ConfigToml = |
| 507 | toml::from_str("[redaction]\nmodel_bound = \"disabled\"\n").expect("parse"); |
| 508 | assert_eq!( |
| 509 | parsed |
| 510 | .redaction |
| 511 | .as_ref() |
| 512 | .expect("redaction table") |
| 513 | .model_bound_masking(), |
| 514 | ModelBoundMasking::Disabled |
| 515 | ); |
| 516 | |
| 517 | let absent: crate::ConfigToml = toml::from_str("").expect("parse empty"); |
| 518 | assert_eq!( |
| 519 | absent.redaction_model_bound_masking(), |
| 520 | ModelBoundMasking::Enabled |
| 521 | ); |
| 522 | |
| 523 | let serialized = toml::to_string(&parsed).expect("serialize"); |
| 524 | assert!( |
| 525 | serialized.contains("model_bound = \"disabled\""), |
| 526 | "{serialized}" |
| 527 | ); |
| 528 | } |
| 529 | |
| 530 | /// The switch reads like a boolean to most people (`model_bound = false` |
| 531 | /// is the natural way to ask "don't mask"). Accept boolean and on/off |
| 532 | /// spellings so a plain `false` cannot hard-fail config parsing. |
| 533 | #[test] |
| 534 | fn boolean_and_on_off_spellings_parse_to_the_same_states() { |
| 535 | for (body, expected) in [ |
| 536 | ("model_bound = false", ModelBoundMasking::Disabled), |
| 537 | ("model_bound = true", ModelBoundMasking::Enabled), |
| 538 | ("model_bound = \"false\"", ModelBoundMasking::Disabled), |
| 539 | ("model_bound = \"off\"", ModelBoundMasking::Disabled), |
| 540 | ("model_bound = \"OFF\"", ModelBoundMasking::Disabled), |
| 541 | ("model_bound = \"on\"", ModelBoundMasking::Enabled), |
| 542 | ("model_bound = \"disabled\"", ModelBoundMasking::Disabled), |
| 543 | ("model_bound = \"ENABLED\"", ModelBoundMasking::Enabled), |
| 544 | ] { |
| 545 | let parsed: crate::ConfigToml = |
| 546 | toml::from_str(&format!("[redaction]\n{body}\n")).expect("parse"); |
| 547 | assert_eq!(parsed.redaction_model_bound_masking(), expected, "{body}"); |
| 548 | } |
| 549 | |
| 550 | // Garbage stays a hard error with a useful message, not a silent default. |
| 551 | let err = toml::from_str::<crate::ConfigToml>("[redaction]\nmodel_bound = \"maybe\"\n") |
| 552 | .expect_err("unknown variant must fail"); |
| 553 | assert!(err.to_string().contains("enabled"), "{err}"); |
| 554 | } |
| 555 | } |
| 556 |