| 1 | use std::path::{Component, Path, PathBuf}; |
| 2 | |
| 3 | use anyhow::{Result, bail}; |
| 4 | use serde::{Deserialize, Serialize}; |
| 5 | |
| 6 | use crate::ProviderKind; |
| 7 | |
| 8 | /// Schema version for informed consent to another CLI's credential file. |
| 9 | pub const EXTERNAL_CREDENTIAL_CONSENT_VERSION: u32 = 1; |
| 10 | |
| 11 | /// The complete side-effect contract for read-only external credentials. |
| 12 | pub const EXTERNAL_CREDENTIAL_READ_ONLY_SEMANTICS: &str = "read this exact file; no refresh, identity-provider or discovery requests, external-file writes, or rewrites; normal requests to the explicitly selected provider may use the token"; |
| 13 | |
| 14 | /// Quote an OS path for terminals, logs, JSON display fields, and errors. |
| 15 | /// |
| 16 | /// The result is always one line. Terminal controls, line separators, bidi |
| 17 | /// formatting controls, quotes, and backslashes are escaped. Unix paths keep |
| 18 | /// non-UTF-8 bytes exact as `\xNN`; Windows preserves unpaired UTF-16 units as |
| 19 | /// `\u{NNNN}`. |
| 20 | #[must_use] |
| 21 | pub fn quote_os_path(path: &Path) -> String { |
| 22 | quote_os_path_inner(path) |
| 23 | } |
| 24 | |
| 25 | #[cfg(unix)] |
| 26 | fn quote_os_path_inner(path: &Path) -> String { |
| 27 | use std::os::unix::ffi::OsStrExt as _; |
| 28 | let bytes = path.as_os_str().as_bytes(); |
| 29 | if let Ok(text) = std::str::from_utf8(bytes) { |
| 30 | return quote_path_text(text); |
| 31 | } |
| 32 | let mut out = String::from("\""); |
| 33 | for byte in bytes { |
| 34 | match byte { |
| 35 | b'"' => out.push_str("\\\""), |
| 36 | b'\\' => out.push_str("\\\\"), |
| 37 | 0x20..=0x7e => out.push(char::from(*byte)), |
| 38 | _ => out.push_str(&format!("\\x{byte:02x}")), |
| 39 | } |
| 40 | } |
| 41 | out.push('"'); |
| 42 | out |
| 43 | } |
| 44 | |
| 45 | #[cfg(windows)] |
| 46 | fn quote_os_path_inner(path: &Path) -> String { |
| 47 | use std::os::windows::ffi::OsStrExt as _; |
| 48 | let mut out = String::from("\""); |
| 49 | for decoded in char::decode_utf16(path.as_os_str().encode_wide()) { |
| 50 | match decoded { |
| 51 | Ok(character) => push_escaped_path_character(&mut out, character), |
| 52 | Err(error) => out.push_str(&format!("\\u{{{:04x}}}", error.unpaired_surrogate())), |
| 53 | } |
| 54 | } |
| 55 | out.push('"'); |
| 56 | out |
| 57 | } |
| 58 | |
| 59 | #[cfg(not(any(unix, windows)))] |
| 60 | fn quote_os_path_inner(path: &Path) -> String { |
| 61 | quote_path_text(&path.to_string_lossy()) |
| 62 | } |
| 63 | |
| 64 | #[cfg(not(windows))] |
| 65 | fn quote_path_text(text: &str) -> String { |
| 66 | let mut out = String::with_capacity(text.len() + 2); |
| 67 | out.push('"'); |
| 68 | for character in text.chars() { |
| 69 | push_escaped_path_character(&mut out, character); |
| 70 | } |
| 71 | out.push('"'); |
| 72 | out |
| 73 | } |
| 74 | |
| 75 | fn push_escaped_path_character(out: &mut String, character: char) { |
| 76 | match character { |
| 77 | '"' => out.push_str("\\\""), |
| 78 | '\\' => out.push_str("\\\\"), |
| 79 | '\n' => out.push_str("\\n"), |
| 80 | '\r' => out.push_str("\\r"), |
| 81 | '\t' => out.push_str("\\t"), |
| 82 | '\u{1b}' => out.push_str("\\x1b"), |
| 83 | character if character.is_control() || is_bidi_format_control(character) => { |
| 84 | out.extend(character.escape_unicode()); |
| 85 | } |
| 86 | character => out.push(character), |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | fn is_bidi_format_control(character: char) -> bool { |
| 91 | matches!( |
| 92 | character, |
| 93 | '\u{061c}' |
| 94 | | '\u{200e}' |
| 95 | | '\u{200f}' |
| 96 | | '\u{2028}' |
| 97 | | '\u{2029}' |
| 98 | | '\u{202a}'..='\u{202e}' |
| 99 | | '\u{2066}'..='\u{2069}' |
| 100 | ) |
| 101 | } |
| 102 | |
| 103 | /// Resolve a user-selected path without touching the filesystem. |
| 104 | /// |
| 105 | /// Consent is bound to the exact logical path, so this intentionally avoids |
| 106 | /// canonicalization (which would stat the candidate before consent exists). |
| 107 | pub fn resolve_external_credential_path(path: impl AsRef<Path>) -> Result<PathBuf> { |
| 108 | let path = path.as_ref(); |
| 109 | let absolute = if path.is_absolute() { |
| 110 | path.to_path_buf() |
| 111 | } else { |
| 112 | std::env::current_dir() |
| 113 | .map_err(|err| anyhow::anyhow!("resolving external credential path: {err}"))? |
| 114 | .join(path) |
| 115 | }; |
| 116 | |
| 117 | // Normalize only lexical `.` / `..` components. Canonicalization would |
| 118 | // inspect a credential path before consent exists and would also silently |
| 119 | // bless a symlink target. The secure reader rejects symlink/reparse-point |
| 120 | // components when the granted capability is actually consumed. |
| 121 | let mut normalized = PathBuf::new(); |
| 122 | for component in absolute.components() { |
| 123 | match component { |
| 124 | Component::Prefix(prefix) => normalized.push(prefix.as_os_str()), |
| 125 | Component::RootDir => normalized.push(component.as_os_str()), |
| 126 | Component::CurDir => {} |
| 127 | Component::ParentDir => { |
| 128 | if !normalized.pop() { |
| 129 | bail!( |
| 130 | "external credential path escapes its absolute root: {}", |
| 131 | quote_os_path(&absolute) |
| 132 | ); |
| 133 | } |
| 134 | } |
| 135 | Component::Normal(part) => normalized.push(part), |
| 136 | } |
| 137 | } |
| 138 | if !normalized.is_absolute() { |
| 139 | bail!( |
| 140 | "external credential path must resolve to an absolute path: {}", |
| 141 | quote_os_path(&normalized) |
| 142 | ); |
| 143 | } |
| 144 | Ok(normalized) |
| 145 | } |
| 146 | |
| 147 | /// The side-effect envelope Codewhale may use for an external credential. |
| 148 | #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] |
| 149 | #[serde(rename_all = "snake_case")] |
| 150 | pub enum ExternalCredentialAccess { |
| 151 | /// Do not inspect or access the external credential store. |
| 152 | #[default] |
| 153 | Disabled, |
| 154 | /// Read the exact selected file without refreshing or rewriting it. |
| 155 | ReadOnly, |
| 156 | /// Permit a documented preservation adapter to refresh and rewrite it. |
| 157 | Managed, |
| 158 | } |
| 159 | |
| 160 | impl ExternalCredentialAccess { |
| 161 | #[must_use] |
| 162 | pub const fn as_str(self) -> &'static str { |
| 163 | match self { |
| 164 | Self::Disabled => "disabled", |
| 165 | Self::ReadOnly => "read_only", |
| 166 | Self::Managed => "managed", |
| 167 | } |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | /// External credential owners supported by the consent schema. |
| 172 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| 173 | #[serde(rename_all = "snake_case")] |
| 174 | pub enum ExternalCredentialSource { |
| 175 | CodexCli, |
| 176 | KimiCodeCli, |
| 177 | GrokCli, |
| 178 | } |
| 179 | |
| 180 | impl ExternalCredentialSource { |
| 181 | #[must_use] |
| 182 | pub const fn as_str(self) -> &'static str { |
| 183 | match self { |
| 184 | Self::CodexCli => "codex_cli", |
| 185 | Self::KimiCodeCli => "kimi_code_cli", |
| 186 | Self::GrokCli => "grok_cli", |
| 187 | } |
| 188 | } |
| 189 | |
| 190 | /// Human-facing owner name used in informed-consent disclosures. |
| 191 | #[must_use] |
| 192 | pub const fn owner_label(self) -> &'static str { |
| 193 | match self { |
| 194 | Self::CodexCli => "Codex CLI", |
| 195 | Self::KimiCodeCli => "Kimi Code CLI", |
| 196 | Self::GrokCli => "Grok CLI", |
| 197 | } |
| 198 | } |
| 199 | } |
| 200 | |
| 201 | /// Side-effect-free projection used by picker, config, and doctor surfaces. |
| 202 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 203 | pub struct ExternalCredentialConsentStatus { |
| 204 | pub access: ExternalCredentialAccess, |
| 205 | pub provider: String, |
| 206 | pub source: ExternalCredentialSource, |
| 207 | pub owner: &'static str, |
| 208 | pub path: PathBuf, |
| 209 | pub consent_version: u32, |
| 210 | pub configured: bool, |
| 211 | pub scope_valid: bool, |
| 212 | /// True when the ambient CLI path now differs from the persisted pinned |
| 213 | /// path. This is informational; it never redirects or deactivates consent. |
| 214 | pub ambient_path_changed: bool, |
| 215 | pub route_state: &'static str, |
| 216 | pub semantics: &'static str, |
| 217 | pub revoke_command: String, |
| 218 | } |
| 219 | |
| 220 | impl ExternalCredentialConsentStatus { |
| 221 | /// Warn without displaying the untrusted ambient replacement. The |
| 222 | /// persisted path remains authoritative and is escaped for one line. |
| 223 | #[must_use] |
| 224 | pub fn ambient_path_warning(&self) -> Option<String> { |
| 225 | self.ambient_path_changed.then(|| { |
| 226 | format!( |
| 227 | "warning: ambient {} credential path changed; consent remains pinned to {} and was not redirected", |
| 228 | self.owner, |
| 229 | quote_os_path(&self.path) |
| 230 | ) |
| 231 | }) |
| 232 | } |
| 233 | } |
| 234 | |
| 235 | /// Describe persisted external-credential policy without filesystem or network |
| 236 | /// access. `expected_path` is resolved lexically by the caller. |
| 237 | #[must_use] |
| 238 | pub fn external_credential_consent_status( |
| 239 | consent: Option<&ExternalCredentialConsentToml>, |
| 240 | provider: ProviderKind, |
| 241 | source: ExternalCredentialSource, |
| 242 | expected_path: &Path, |
| 243 | active_provider: ProviderKind, |
| 244 | ) -> ExternalCredentialConsentStatus { |
| 245 | let configured = consent.is_some(); |
| 246 | let access = consent.map_or(ExternalCredentialAccess::Disabled, |value| value.access); |
| 247 | // User-facing status identifies the route being inspected. Persisted |
| 248 | // provider/source fields are untrusted config input and are represented by |
| 249 | // `scope_valid` rather than echoed into a terminal surface. |
| 250 | let reported_provider = provider.as_str().to_string(); |
| 251 | let reported_source = source; |
| 252 | let reported_path = consent |
| 253 | .map(|value| value.path.clone()) |
| 254 | .unwrap_or_else(|| expected_path.to_path_buf()); |
| 255 | let consent_version = consent.map_or(EXTERNAL_CREDENTIAL_CONSENT_VERSION, |value| { |
| 256 | value.consent_version |
| 257 | }); |
| 258 | let scope_valid = consent.is_some_and(|value| { |
| 259 | value |
| 260 | .validate_read_scope(provider, source, &value.path) |
| 261 | .is_ok() |
| 262 | }); |
| 263 | let ambient_path_changed = consent.is_some_and(|value| value.path != expected_path); |
| 264 | let active = |
| 265 | provider == active_provider && access == ExternalCredentialAccess::ReadOnly && scope_valid; |
| 266 | let route_state = if active { "active" } else { "dormant" }; |
| 267 | let semantics = match access { |
| 268 | ExternalCredentialAccess::Disabled => { |
| 269 | "disabled; no external-credential probing, reading, refresh, discovery, identity-provider or network acquisition, writes, or rewrites; normal requests to the explicitly selected provider may use Codewhale-owned credentials" |
| 270 | } |
| 271 | ExternalCredentialAccess::ReadOnly => EXTERNAL_CREDENTIAL_READ_ONLY_SEMANTICS, |
| 272 | ExternalCredentialAccess::Managed => { |
| 273 | "managed access unavailable; no schema-safe preservation adapter" |
| 274 | } |
| 275 | }; |
| 276 | |
| 277 | ExternalCredentialConsentStatus { |
| 278 | access, |
| 279 | provider: reported_provider, |
| 280 | source: reported_source, |
| 281 | owner: reported_source.owner_label(), |
| 282 | path: reported_path, |
| 283 | consent_version, |
| 284 | configured, |
| 285 | scope_valid, |
| 286 | ambient_path_changed, |
| 287 | route_state, |
| 288 | semantics, |
| 289 | revoke_command: format!( |
| 290 | "codewhale auth external-revoke --provider {}", |
| 291 | provider.as_str() |
| 292 | ), |
| 293 | } |
| 294 | } |
| 295 | |
| 296 | /// Persisted, provider-scoped consent for one exact external credential file. |
| 297 | /// |
| 298 | /// Provider and source are repeated intentionally. A copied provider table or |
| 299 | /// a future source-path remap must fail closed instead of inheriting authority. |
| 300 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 301 | #[serde(deny_unknown_fields)] |
| 302 | pub struct ExternalCredentialConsentToml { |
| 303 | pub access: ExternalCredentialAccess, |
| 304 | pub provider: String, |
| 305 | pub source: ExternalCredentialSource, |
| 306 | pub path: PathBuf, |
| 307 | pub consent_version: u32, |
| 308 | } |
| 309 | |
| 310 | impl ExternalCredentialConsentToml { |
| 311 | #[must_use] |
| 312 | pub fn read_only( |
| 313 | provider: ProviderKind, |
| 314 | source: ExternalCredentialSource, |
| 315 | path: PathBuf, |
| 316 | ) -> Self { |
| 317 | Self { |
| 318 | access: ExternalCredentialAccess::ReadOnly, |
| 319 | provider: provider.as_str().to_string(), |
| 320 | source, |
| 321 | path, |
| 322 | consent_version: EXTERNAL_CREDENTIAL_CONSENT_VERSION, |
| 323 | } |
| 324 | } |
| 325 | |
| 326 | /// Validate that this record is a current read-only consent for one exact |
| 327 | /// provider/source/path tuple without minting an I/O capability. |
| 328 | /// |
| 329 | /// This is intentionally side-effect free so inventory and picker surfaces |
| 330 | /// can acknowledge dormant consent without inspecting the external file. |
| 331 | pub fn validate_read_scope( |
| 332 | &self, |
| 333 | provider: ProviderKind, |
| 334 | source: ExternalCredentialSource, |
| 335 | resolved_path: &Path, |
| 336 | ) -> Result<()> { |
| 337 | if self.access == ExternalCredentialAccess::Disabled { |
| 338 | bail!( |
| 339 | "external credential access is disabled for {}", |
| 340 | provider.as_str() |
| 341 | ); |
| 342 | } |
| 343 | if self.access == ExternalCredentialAccess::Managed { |
| 344 | bail!( |
| 345 | "managed external credential access is unsupported for {}; no schema-safe preservation adapter is available", |
| 346 | provider.as_str() |
| 347 | ); |
| 348 | } |
| 349 | if self.consent_version != EXTERNAL_CREDENTIAL_CONSENT_VERSION { |
| 350 | bail!( |
| 351 | "external credential consent for {} uses unsupported version {}; revoke and consent again", |
| 352 | provider.as_str(), |
| 353 | self.consent_version |
| 354 | ); |
| 355 | } |
| 356 | if self.provider != provider.as_str() { |
| 357 | bail!( |
| 358 | "external credential consent is scoped to provider {:?}, not {}", |
| 359 | self.provider, |
| 360 | provider.as_str() |
| 361 | ); |
| 362 | } |
| 363 | if self.source != source { |
| 364 | bail!( |
| 365 | "external credential consent source mismatch for {} (expected {})", |
| 366 | provider.as_str(), |
| 367 | source.as_str() |
| 368 | ); |
| 369 | } |
| 370 | if !self.path.is_absolute() { |
| 371 | bail!( |
| 372 | "external credential consent path for {} must be absolute", |
| 373 | provider.as_str() |
| 374 | ); |
| 375 | } |
| 376 | let normalized = resolve_external_credential_path(&self.path)?; |
| 377 | if normalized != self.path { |
| 378 | bail!( |
| 379 | "external credential consent path for {} must be lexically normalized: {}", |
| 380 | provider.as_str(), |
| 381 | quote_os_path(&self.path) |
| 382 | ); |
| 383 | } |
| 384 | if self.path != resolved_path { |
| 385 | bail!( |
| 386 | "external credential path changed for {}; consent covers {}, current path is {}", |
| 387 | provider.as_str(), |
| 388 | quote_os_path(&self.path), |
| 389 | quote_os_path(resolved_path) |
| 390 | ); |
| 391 | } |
| 392 | Ok(()) |
| 393 | } |
| 394 | |
| 395 | /// Validate and mint the read capability consumed by credential adapters. |
| 396 | /// No filesystem operation occurs while validating the policy. |
| 397 | pub fn read_grant( |
| 398 | &self, |
| 399 | provider: ProviderKind, |
| 400 | source: ExternalCredentialSource, |
| 401 | resolved_path: &Path, |
| 402 | ) -> Result<ExternalCredentialReadGrant> { |
| 403 | self.validate_read_scope(provider, source, resolved_path)?; |
| 404 | Ok(ExternalCredentialReadGrant { |
| 405 | provider, |
| 406 | source, |
| 407 | path: resolved_path.to_path_buf(), |
| 408 | consent_version: self.consent_version, |
| 409 | }) |
| 410 | } |
| 411 | } |
| 412 | |
| 413 | /// Opaque proof that one exact provider/source/path tuple may be read. |
| 414 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 415 | pub struct ExternalCredentialReadGrant { |
| 416 | provider: ProviderKind, |
| 417 | source: ExternalCredentialSource, |
| 418 | path: PathBuf, |
| 419 | consent_version: u32, |
| 420 | } |
| 421 | |
| 422 | impl ExternalCredentialReadGrant { |
| 423 | #[must_use] |
| 424 | pub fn provider(&self) -> ProviderKind { |
| 425 | self.provider |
| 426 | } |
| 427 | |
| 428 | #[must_use] |
| 429 | pub fn source(&self) -> ExternalCredentialSource { |
| 430 | self.source |
| 431 | } |
| 432 | |
| 433 | #[must_use] |
| 434 | pub fn path(&self) -> &Path { |
| 435 | &self.path |
| 436 | } |
| 437 | |
| 438 | #[must_use] |
| 439 | pub fn consent_version(&self) -> u32 { |
| 440 | self.consent_version |
| 441 | } |
| 442 | } |
| 443 | |
| 444 | #[cfg(test)] |
| 445 | mod tests { |
| 446 | use super::*; |
| 447 | |
| 448 | fn absolute_test_path(file: &str) -> PathBuf { |
| 449 | if cfg!(windows) { |
| 450 | PathBuf::from(format!(r"C:\Users\test\{file}")) |
| 451 | } else { |
| 452 | PathBuf::from(format!("/tmp/{file}")) |
| 453 | } |
| 454 | } |
| 455 | |
| 456 | #[test] |
| 457 | fn disclosed_paths_are_absolute_and_lexically_normalized_without_io() { |
| 458 | let resolved = |
| 459 | resolve_external_credential_path("one/./two/../auth.json").expect("lexical resolution"); |
| 460 | assert!(resolved.is_absolute()); |
| 461 | assert!( |
| 462 | resolved.ends_with(Path::new("one/auth.json")), |
| 463 | "{}", |
| 464 | resolved.display() |
| 465 | ); |
| 466 | assert!(!resolved.to_string_lossy().contains("/./")); |
| 467 | assert!(!resolved.to_string_lossy().contains("/../")); |
| 468 | } |
| 469 | |
| 470 | #[test] |
| 471 | fn structural_status_reports_full_scope_without_io() { |
| 472 | let path = absolute_test_path("codex-auth.json"); |
| 473 | let consent = ExternalCredentialConsentToml::read_only( |
| 474 | ProviderKind::OpenaiCodex, |
| 475 | ExternalCredentialSource::CodexCli, |
| 476 | path.clone(), |
| 477 | ); |
| 478 | let active = external_credential_consent_status( |
| 479 | Some(&consent), |
| 480 | ProviderKind::OpenaiCodex, |
| 481 | ExternalCredentialSource::CodexCli, |
| 482 | &path, |
| 483 | ProviderKind::OpenaiCodex, |
| 484 | ); |
| 485 | assert_eq!(active.access, ExternalCredentialAccess::ReadOnly); |
| 486 | assert_eq!(active.owner, "Codex CLI"); |
| 487 | assert_eq!(active.path, path); |
| 488 | assert_eq!(active.route_state, "active"); |
| 489 | assert!(active.scope_valid); |
| 490 | assert!(active.semantics.contains("no refresh")); |
| 491 | assert_eq!( |
| 492 | active.revoke_command, |
| 493 | "codewhale auth external-revoke --provider openai-codex" |
| 494 | ); |
| 495 | |
| 496 | let changed_path = absolute_test_path("moved-auth.json"); |
| 497 | let pinned = external_credential_consent_status( |
| 498 | Some(&consent), |
| 499 | ProviderKind::OpenaiCodex, |
| 500 | ExternalCredentialSource::CodexCli, |
| 501 | &changed_path, |
| 502 | ProviderKind::OpenaiCodex, |
| 503 | ); |
| 504 | assert!(pinned.scope_valid); |
| 505 | assert_eq!(pinned.route_state, "active"); |
| 506 | assert!(pinned.ambient_path_changed); |
| 507 | assert_eq!(pinned.path, path, "report the pinned persisted grant path"); |
| 508 | let warning = pinned |
| 509 | .ambient_path_warning() |
| 510 | .expect("ambient mismatch warning"); |
| 511 | assert!(warning.contains("remains pinned"), "{warning}"); |
| 512 | assert!(warning.contains("e_os_path(&path)), "{warning}"); |
| 513 | } |
| 514 | |
| 515 | #[test] |
| 516 | fn displayed_paths_escape_terminal_and_bidi_controls_on_one_line() { |
| 517 | let path = PathBuf::from( |
| 518 | "/safe/line\nmanaged\u{1b}[2J\u{2028}first\u{2029}second\u{202e}name.json", |
| 519 | ); |
| 520 | let quoted = quote_os_path(&path); |
| 521 | assert!(quoted.starts_with('"') && quoted.ends_with('"')); |
| 522 | assert!(quoted.contains("\\n"), "{quoted}"); |
| 523 | assert!(quoted.contains("\\x1b"), "{quoted}"); |
| 524 | assert!(quoted.contains("\\u{2028}"), "{quoted}"); |
| 525 | assert!(quoted.contains("\\u{2029}"), "{quoted}"); |
| 526 | assert!(quoted.contains("\\u{202e}"), "{quoted}"); |
| 527 | assert!(!quoted.contains('\n')); |
| 528 | assert!(!quoted.contains('\u{1b}')); |
| 529 | assert!(!quoted.contains('\u{2028}')); |
| 530 | assert!(!quoted.contains('\u{2029}')); |
| 531 | assert!(!quoted.contains('\u{202e}')); |
| 532 | } |
| 533 | |
| 534 | #[test] |
| 535 | fn disabled_disclosure_does_not_imply_normal_provider_network_is_disabled() { |
| 536 | let path = absolute_test_path("codex-auth.json"); |
| 537 | let status = external_credential_consent_status( |
| 538 | None, |
| 539 | ProviderKind::OpenaiCodex, |
| 540 | ExternalCredentialSource::CodexCli, |
| 541 | &path, |
| 542 | ProviderKind::OpenaiCodex, |
| 543 | ); |
| 544 | assert!(status.semantics.contains("no external-credential")); |
| 545 | assert!(status.semantics.contains("normal requests")); |
| 546 | assert!(!status.semantics.contains("no network requests")); |
| 547 | } |
| 548 | |
| 549 | #[test] |
| 550 | fn read_grant_requires_exact_provider_source_path_and_version() { |
| 551 | let path = absolute_test_path("codex-auth.json"); |
| 552 | let consent = ExternalCredentialConsentToml::read_only( |
| 553 | ProviderKind::OpenaiCodex, |
| 554 | ExternalCredentialSource::CodexCli, |
| 555 | path.clone(), |
| 556 | ); |
| 557 | |
| 558 | let grant = consent |
| 559 | .read_grant( |
| 560 | ProviderKind::OpenaiCodex, |
| 561 | ExternalCredentialSource::CodexCli, |
| 562 | &path, |
| 563 | ) |
| 564 | .expect("exact consent tuple"); |
| 565 | assert_eq!(grant.path(), path); |
| 566 | |
| 567 | assert!( |
| 568 | consent |
| 569 | .read_grant(ProviderKind::Xai, ExternalCredentialSource::CodexCli, &path) |
| 570 | .is_err() |
| 571 | ); |
| 572 | assert!( |
| 573 | consent |
| 574 | .read_grant( |
| 575 | ProviderKind::OpenaiCodex, |
| 576 | ExternalCredentialSource::GrokCli, |
| 577 | &path |
| 578 | ) |
| 579 | .is_err() |
| 580 | ); |
| 581 | assert!( |
| 582 | consent |
| 583 | .read_grant( |
| 584 | ProviderKind::OpenaiCodex, |
| 585 | ExternalCredentialSource::CodexCli, |
| 586 | &path.with_file_name("other.json") |
| 587 | ) |
| 588 | .is_err() |
| 589 | ); |
| 590 | } |
| 591 | |
| 592 | #[test] |
| 593 | fn persisted_consent_path_must_be_lexically_normalized() { |
| 594 | let raw_path = if cfg!(windows) { |
| 595 | PathBuf::from(r"C:\Users\test\credentials\..\auth.json") |
| 596 | } else { |
| 597 | PathBuf::from("/tmp/credentials/../auth.json") |
| 598 | }; |
| 599 | let consent = ExternalCredentialConsentToml::read_only( |
| 600 | ProviderKind::Xai, |
| 601 | ExternalCredentialSource::GrokCli, |
| 602 | raw_path.clone(), |
| 603 | ); |
| 604 | assert!( |
| 605 | consent |
| 606 | .read_grant( |
| 607 | ProviderKind::Xai, |
| 608 | ExternalCredentialSource::GrokCli, |
| 609 | &raw_path |
| 610 | ) |
| 611 | .is_err() |
| 612 | ); |
| 613 | } |
| 614 | |
| 615 | #[test] |
| 616 | fn managed_consent_is_explicitly_unsupported_without_an_adapter() { |
| 617 | let path = absolute_test_path("grok-auth.json"); |
| 618 | let mut consent = ExternalCredentialConsentToml::read_only( |
| 619 | ProviderKind::Xai, |
| 620 | ExternalCredentialSource::GrokCli, |
| 621 | path.clone(), |
| 622 | ); |
| 623 | consent.access = ExternalCredentialAccess::Managed; |
| 624 | |
| 625 | let error = consent |
| 626 | .read_grant(ProviderKind::Xai, ExternalCredentialSource::GrokCli, &path) |
| 627 | .expect_err("managed access must fail closed"); |
| 628 | assert!( |
| 629 | error |
| 630 | .to_string() |
| 631 | .contains("schema-safe preservation adapter") |
| 632 | ); |
| 633 | } |
| 634 | |
| 635 | #[test] |
| 636 | fn consent_round_trips_every_scope_field() { |
| 637 | let path = absolute_test_path("codex-auth.json"); |
| 638 | let consent = ExternalCredentialConsentToml::read_only( |
| 639 | ProviderKind::OpenaiCodex, |
| 640 | ExternalCredentialSource::CodexCli, |
| 641 | path, |
| 642 | ); |
| 643 | |
| 644 | let encoded = toml::to_string(&consent).expect("serialize consent"); |
| 645 | let decoded: ExternalCredentialConsentToml = |
| 646 | toml::from_str(&encoded).expect("deserialize consent"); |
| 647 | assert_eq!(decoded, consent); |
| 648 | assert!(encoded.contains("access = \"read_only\"")); |
| 649 | assert!(encoded.contains("provider = \"openai-codex\"")); |
| 650 | assert!(encoded.contains("source = \"codex_cli\"")); |
| 651 | assert!(encoded.contains("consent_version = 1")); |
| 652 | } |
| 653 | |
| 654 | #[test] |
| 655 | fn disabled_stale_and_relative_consent_fail_before_a_grant() { |
| 656 | let path = absolute_test_path("grok-auth.json"); |
| 657 | let mut consent = ExternalCredentialConsentToml::read_only( |
| 658 | ProviderKind::Xai, |
| 659 | ExternalCredentialSource::GrokCli, |
| 660 | path.clone(), |
| 661 | ); |
| 662 | |
| 663 | consent.access = ExternalCredentialAccess::Disabled; |
| 664 | assert!( |
| 665 | consent |
| 666 | .read_grant(ProviderKind::Xai, ExternalCredentialSource::GrokCli, &path) |
| 667 | .expect_err("disabled consent") |
| 668 | .to_string() |
| 669 | .contains("disabled") |
| 670 | ); |
| 671 | |
| 672 | consent.access = ExternalCredentialAccess::ReadOnly; |
| 673 | consent.consent_version = EXTERNAL_CREDENTIAL_CONSENT_VERSION + 1; |
| 674 | assert!( |
| 675 | consent |
| 676 | .read_grant(ProviderKind::Xai, ExternalCredentialSource::GrokCli, &path) |
| 677 | .expect_err("stale consent") |
| 678 | .to_string() |
| 679 | .contains("unsupported version") |
| 680 | ); |
| 681 | |
| 682 | consent.consent_version = EXTERNAL_CREDENTIAL_CONSENT_VERSION; |
| 683 | consent.path = PathBuf::from("relative/auth.json"); |
| 684 | assert!( |
| 685 | consent |
| 686 | .read_grant( |
| 687 | ProviderKind::Xai, |
| 688 | ExternalCredentialSource::GrokCli, |
| 689 | Path::new("relative/auth.json"), |
| 690 | ) |
| 691 | .expect_err("relative path") |
| 692 | .to_string() |
| 693 | .contains("must be absolute") |
| 694 | ); |
| 695 | } |
| 696 | } |
| 697 |