返回 CodeWhale
config_bundles.rs
根目录 / crates / cli / src / config_bundles.rs
1 //! Portable config bundles: `codewhale config import` / `config export --portable`.
2 //!
3 //! A bundle is a TOML or JSON document carrying a portable subset of a
4 //! CodeWhale configuration (preferences, harness profiles, provider
5 //! non-secret settings, project/global sections) between machines. The
6 //! envelope is versioned and strict (`deny_unknown_fields`), secrets are
7 //! rejected by key name and value shape (never echoed), parsing is bounded,
8 //! and application is transactional with a timestamped backup and rollback.
9 //!
10 //! Security contract:
11 //! - No secret ever round-trips: fields whose key matches
12 //! [`codewhale_config::is_sensitive_config_key`] are rejected on import and
13 //! dropped on export, and bare credential-shaped values are rejected by
14 //! value shape. Rejection messages name the field, never the value.
15 //! - Input size is capped (5 MiB, matching the skill installer's cap).
16 //! - HTTPS only for remote fetch, except plain `http` on loopback; redirects
17 //! are followed at most a bounded number of times within the same scheme.
18 //! - Bundle-declared file paths must resolve inside the target config
19 //! directory; traversal and symlink escapes are refused.
20 //! - Project scope never mutates the user-global document and vice versa.
21
22 use std::io::{IsTerminal, Read};
23 use std::path::{Path, PathBuf};
24
25 use anyhow::{Context, Result, anyhow, bail};
26 use serde::{Deserialize, Serialize};
27
28 use codewhale_config::{ConfigToml, is_sensitive_config_key};
29
30 /// Maximum accepted bundle size, both for reads and remote fetches.
31 /// Matches the skill installer's 5 MiB cap.
32 pub const MAX_BUNDLE_BYTES: u64 = 5 * 1024 * 1024;
33
34 /// Envelope `kind` value required by every bundle.
35 pub const BUNDLE_KIND: &str = "codewhale.portable-config";
36
37 /// Envelope `schema_version` accepted by this build.
38 pub const BUNDLE_SCHEMA_VERSION: u64 = 1;
39
40 /// Maximum number of HTTP redirects followed during a remote fetch.
41 const MAX_REDIRECTS: usize = 5;
42
43 /// Timeout for the remote fetch, in seconds.
44 const FETCH_TIMEOUT_SECS: u64 = 30;
45
46 /// Credential-shaped value prefixes rejected even under a benign key name.
47 /// Conservative on purpose: only well-known provider token shapes.
48 const SECRET_VALUE_PREFIXES: [&str; 6] = ["sk-", "Bearer ", "ghp_", "xoxb-", "AKIA", "eyJ"];
49
50 // ---------------------------------------------------------------------------
51 // Envelope
52 // ---------------------------------------------------------------------------
53
54 /// Strict portable-bundle envelope. Unknown fields fail the parse: a bundle
55 /// written by a newer schema must not be silently half-applied.
56 #[derive(Debug, Clone, Deserialize, Serialize)]
57 #[serde(deny_unknown_fields)]
58 pub struct PortableBundle {
59 pub schema_version: u64,
60 pub kind: String,
61 #[serde(default)]
62 pub metadata: BundleMetadata,
63 #[serde(default)]
64 pub preferences: BundleTable,
65 #[serde(default)]
66 pub profiles: BundleTable,
67 #[serde(default)]
68 pub plugins: BundleTable,
69 #[serde(default)]
70 pub project: BundleTable,
71 #[serde(default)]
72 pub global: BundleTable,
73 }
74
75 #[derive(Debug, Clone, Default, Deserialize, Serialize)]
76 #[serde(deny_unknown_fields)]
77 pub struct BundleMetadata {
78 #[serde(default)]
79 pub name: Option<String>,
80 #[serde(default)]
81 pub created_at: Option<String>,
82 #[serde(default)]
83 pub generator: Option<String>,
84 }
85
86 /// One bundle section: a flat table of config keys to values. Keys inside a
87 /// section are data, not schema, so unknown keys parse here — credential
88 /// rejection happens at plan time by name and value shape.
89 #[derive(Debug, Clone, Default, Deserialize, Serialize)]
90 pub struct BundleTable {
91 #[serde(flatten)]
92 pub entries: std::collections::BTreeMap<String, toml::Value>,
93 }
94
95 // ---------------------------------------------------------------------------
96 // Parsing (bounded)
97 // ---------------------------------------------------------------------------
98
99 /// Parse a bundle from raw bytes, rejecting oversize input before parse.
100 pub fn parse_bundle_bytes(raw: &[u8], source: &str) -> Result<PortableBundle> {
101 if raw.len() as u64 > MAX_BUNDLE_BYTES {
102 bail!(
103 "bundle at {source} is {} bytes; the limit is {MAX_BUNDLE_BYTES} bytes",
104 raw.len()
105 );
106 }
107 let text = std::str::from_utf8(raw)
108 .with_context(|| format!("bundle at {source} is not valid UTF-8"))?;
109 parse_bundle_str(text, source)
110 }
111
112 /// Parse a bundle document: TOML by default, JSON when the source ends in
113 /// `.json` or the document starts with `{`.
114 pub fn parse_bundle_str(text: &str, source: &str) -> Result<PortableBundle> {
115 let trimmed = text.trim_start();
116 let bundle = if trimmed.starts_with('{') || source.ends_with(".json") {
117 // serde_json keeps the last of two identical object keys. A bundle is
118 // a reviewed plan, so a repeated key must fail before anything is
119 // planned or written, exactly as TOML already refuses duplicates.
120 reject_duplicate_json_keys(text)
121 .with_context(|| format!("bundle at {source} is not valid JSON"))?;
122 serde_json::from_str::<PortableBundle>(text)
123 .with_context(|| format!("bundle at {source} is not valid JSON"))?
124 } else {
125 toml::from_str::<PortableBundle>(text)
126 .with_context(|| format!("bundle at {source} is not valid TOML"))?
127 };
128 validate_bundle(&bundle, source)?;
129 Ok(bundle)
130 }
131
132 /// Fail closed on a JSON document that repeats an object key at any depth.
133 ///
134 /// The document is walked with a deserializer seed that never materializes
135 /// values, so the check costs one pass and reports the first offending key
136 /// path without echoing any value.
137 fn reject_duplicate_json_keys(text: &str) -> Result<()> {
138 use serde::de::{DeserializeSeed, Error as _, MapAccess, SeqAccess, Visitor};
139 use std::fmt;
140
141 struct NoDuplicates<'a> {
142 path: &'a mut Vec<String>,
143 }
144
145 impl<'de> DeserializeSeed<'de> for NoDuplicates<'_> {
146 type Value = ();
147
148 fn deserialize<D>(self, deserializer: D) -> Result<(), D::Error>
149 where
150 D: serde::Deserializer<'de>,
151 {
152 deserializer.deserialize_any(self)
153 }
154 }
155
156 impl<'de> Visitor<'de> for NoDuplicates<'_> {
157 type Value = ();
158
159 fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
160 f.write_str("a JSON value without duplicate object keys")
161 }
162
163 fn visit_bool<E: serde::de::Error>(self, _: bool) -> Result<(), E> {
164 Ok(())
165 }
166 fn visit_i64<E: serde::de::Error>(self, _: i64) -> Result<(), E> {
167 Ok(())
168 }
169 fn visit_u64<E: serde::de::Error>(self, _: u64) -> Result<(), E> {
170 Ok(())
171 }
172 fn visit_f64<E: serde::de::Error>(self, _: f64) -> Result<(), E> {
173 Ok(())
174 }
175 fn visit_str<E: serde::de::Error>(self, _: &str) -> Result<(), E> {
176 Ok(())
177 }
178 fn visit_unit<E: serde::de::Error>(self) -> Result<(), E> {
179 Ok(())
180 }
181 fn visit_none<E: serde::de::Error>(self) -> Result<(), E> {
182 Ok(())
183 }
184
185 fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<(), A::Error> {
186 let mut index = 0usize;
187 loop {
188 self.path.push(format!("[{index}]"));
189 let next = seq.next_element_seed(NoDuplicates { path: self.path });
190 self.path.pop();
191 if next?.is_none() {
192 return Ok(());
193 }
194 index += 1;
195 }
196 }
197
198 fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<(), A::Error> {
199 let mut seen = std::collections::BTreeSet::new();
200 while let Some(key) = map.next_key::<String>()? {
201 if !seen.insert(key.clone()) {
202 let mut path = self.path.clone();
203 path.push(key);
204 return Err(A::Error::custom(format!(
205 "duplicate key {:?}",
206 path.join(".")
207 )));
208 }
209 self.path.push(key);
210 let nested = map.next_value_seed(NoDuplicates { path: self.path });
211 self.path.pop();
212 nested?;
213 }
214 Ok(())
215 }
216 }
217
218 let mut deserializer = serde_json::Deserializer::from_str(text);
219 let mut path = Vec::new();
220 NoDuplicates { path: &mut path }
221 .deserialize(&mut deserializer)
222 .map_err(|error| anyhow::anyhow!("{error}"))?;
223 deserializer
224 .end()
225 .map_err(|error| anyhow::anyhow!("{error}"))?;
226 Ok(())
227 }
228
229 fn validate_bundle(bundle: &PortableBundle, source: &str) -> Result<()> {
230 if bundle.kind != BUNDLE_KIND {
231 bail!(
232 "bundle at {source} has kind {:?}; expected {BUNDLE_KIND:?}",
233 bundle.kind
234 );
235 }
236 if bundle.schema_version != BUNDLE_SCHEMA_VERSION {
237 bail!(
238 "bundle at {source} has schema_version {}; this build understands {BUNDLE_SCHEMA_VERSION}",
239 bundle.schema_version
240 );
241 }
242 Ok(())
243 }
244
245 // ---------------------------------------------------------------------------
246 // Secret rejection
247 // ---------------------------------------------------------------------------
248
249 /// One rejected entry: the dotted key path and the reason. Values are never
250 /// included — the reason and path are all a reviewer needs.
251 #[derive(Debug, Clone, PartialEq, Eq)]
252 pub struct RejectedEntry {
253 pub key: String,
254 pub reason: String,
255 }
256
257 /// Scan every section of the bundle for non-portable entries. Import and
258 /// export use the same path predicate, so machine-local route/execution/trust
259 /// authority cannot be stripped in one direction but accepted in the other.
260 /// String leaves are additionally rejected by credential shape.
261 pub fn find_rejected_entries(bundle: &PortableBundle) -> Vec<RejectedEntry> {
262 let mut rejected = Vec::new();
263 for (section, table) in [
264 ("preferences", &bundle.preferences),
265 ("profiles", &bundle.profiles),
266 ("plugins", &bundle.plugins),
267 ("project", &bundle.project),
268 ("global", &bundle.global),
269 ] {
270 for (key, value) in &table.entries {
271 let dotted = format!("{section}.{key}");
272 if let Some(reason) = nonportable_path_reason(key) {
273 rejected.push(RejectedEntry {
274 key: dotted,
275 reason: reason.to_string(),
276 });
277 continue;
278 }
279 if let Some(reason) = value_rejection_reason(key, value) {
280 rejected.push(RejectedEntry {
281 key: dotted,
282 reason,
283 });
284 }
285 }
286 }
287 rejected
288 }
289
290 /// Maximum nesting depth the export walkers descend. Config files are
291 /// shallow; anything deeper is pathological and fails closed.
292 const MAX_EXPORT_WALK_DEPTH: usize = 64;
293
294 /// Why a value carries nested non-portable authority or looks like a bare
295 /// credential, or `None` when it is safe to move between machines.
296 fn value_rejection_reason(path: &str, value: &toml::Value) -> Option<String> {
297 value_rejection_reason_at(path, value, 0)
298 }
299
300 fn value_rejection_reason_at(path: &str, value: &toml::Value, depth: usize) -> Option<String> {
301 if depth > MAX_EXPORT_WALK_DEPTH {
302 return Some(format!(
303 "nested more than {MAX_EXPORT_WALK_DEPTH} levels deep"
304 ));
305 }
306 if let Some(reason) = nonportable_value_reason(path, value) {
307 return Some(reason.to_string());
308 }
309 match value {
310 toml::Value::String(text) => string_secret_reason(text),
311 toml::Value::Array(items) => items
312 .iter()
313 .find_map(|value| value_rejection_reason_at(path, value, depth + 1))
314 .map(|reason| format!("array contains an entry where {reason}")),
315 toml::Value::Table(map) => {
316 for (key, nested_value) in map {
317 let child_path = if path.is_empty() {
318 key.clone()
319 } else {
320 format!("{path}.{key}")
321 };
322 if let Some(reason) = nonportable_path_reason(&child_path) {
323 return Some(format!("nested key {key:?} {reason}"));
324 }
325 if let Some(reason) =
326 value_rejection_reason_at(&child_path, nested_value, depth + 1)
327 {
328 return Some(format!("nested under {key:?}, {reason}"));
329 }
330 }
331 None
332 }
333 _ => None,
334 }
335 }
336
337 fn string_secret_reason(text: &str) -> Option<String> {
338 if let Some(prefix) = SECRET_VALUE_PREFIXES
339 .iter()
340 .find(|prefix| text.trim().starts_with(*prefix))
341 {
342 return Some(format!(
343 "value has the shape of a credential (prefix {prefix:?} redacted)"
344 ));
345 }
346 if text.contains(codewhale_config::persistence::REDACTED) {
347 // A placeholder is the residue of redaction, never a real setting;
348 // exporting it would carry nothing and importing it would write the
349 // placeholder into the live document.
350 return Some("value contains a redaction placeholder".to_string());
351 }
352 if codewhale_config::persistence::redact_secrets(text) != text {
353 return Some("value contains credential-shaped text".to_string());
354 }
355 if url_carries_credential(text.trim()) {
356 return Some("value is a URL that carries a credential".to_string());
357 }
358 None
359 }
360
361 /// Webhook endpoints whose path is itself the bearer credential.
362 const CREDENTIAL_PATH_WEBHOOKS: [(&str, &str); 4] = [
363 ("hooks.slack.com", "/services/"),
364 ("discord.com", "/api/webhooks/"),
365 ("discordapp.com", "/api/webhooks/"),
366 ("webhook.office.com", "/webhookb2/"),
367 ];
368
369 /// True for a URL with userinfo, a credential-named query parameter, or a
370 /// known webhook host whose path is the secret.
371 fn url_carries_credential(text: &str) -> bool {
372 let Ok(url) = reqwest::Url::parse(text) else {
373 return false;
374 };
375 if !url.username().is_empty() || url.password().is_some() {
376 return true;
377 }
378 // The shared display redactor masks credential-named query values with
379 // `***`; count masks rather than compare bytes, because it re-encodes the
380 // query and would otherwise flag benign URLs.
381 let masks = |value: &str| value.matches("***").count();
382 if masks(&codewhale_secrets::sanitize::redact_url_for_display(text)) > masks(text) {
383 return true;
384 }
385 let host = url.host_str().unwrap_or_default().to_ascii_lowercase();
386 CREDENTIAL_PATH_WEBHOOKS.iter().any(|(webhook_host, path)| {
387 (host == *webhook_host || host.ends_with(&format!(".{webhook_host}")))
388 && url.path().starts_with(path)
389 })
390 }
391
392 fn is_sensitive_bundle_key(key: &str) -> bool {
393 if is_sensitive_config_key(key) || is_credential_authority_key(key) {
394 return true;
395 }
396 // Normalize the complete dotted path, not only its final component. A
397 // quoted TOML key such as `"api.key"` reaches us without its quotes and
398 // is otherwise indistinguishable from two structural components. Either
399 // representation names credential material and must fail closed.
400 let normalized = normalize_bundle_key(key);
401
402 matches!(
403 normalized.as_str(),
404 "access_key"
405 | "access_token"
406 | "api_key"
407 | "api_keys"
408 | "apikey"
409 | "authorization"
410 | "bearer"
411 | "client_secret"
412 | "cookie"
413 | "credential"
414 | "credentials"
415 | "id_token"
416 | "password"
417 | "passwords"
418 | "passwd"
419 | "private_key"
420 | "proxy_authorization"
421 | "refresh_token"
422 | "secret"
423 | "secrets"
424 | "set_cookie"
425 | "token"
426 | "tokens"
427 ) || normalized.ends_with("_access_key")
428 || normalized.ends_with("_api_key")
429 || normalized.ends_with("_authorization")
430 || normalized.ends_with("_cookie")
431 || normalized.ends_with("_password")
432 || normalized.ends_with("_private_key")
433 || normalized.ends_with("_secret")
434 || normalized.ends_with("_token")
435 }
436
437 fn normalize_bundle_key(key: &str) -> String {
438 let segment = key.trim().trim_matches('"');
439 let chars: Vec<char> = segment.chars().collect();
440 let mut normalized = String::with_capacity(segment.len());
441 for (index, character) in chars.iter().copied().enumerate() {
442 if !character.is_ascii_alphanumeric() {
443 if !normalized.ends_with('_') {
444 normalized.push('_');
445 }
446 continue;
447 }
448 if character.is_ascii_uppercase() {
449 let previous = index.checked_sub(1).and_then(|index| chars.get(index));
450 let next = chars.get(index + 1);
451 let starts_word = previous.is_some_and(|character| {
452 character.is_ascii_lowercase() || character.is_ascii_digit()
453 }) || (previous
454 .is_some_and(|character| character.is_ascii_uppercase())
455 && next.is_some_and(|character| character.is_ascii_lowercase()));
456 if starts_word && !normalized.ends_with('_') {
457 normalized.push('_');
458 }
459 normalized.push(character.to_ascii_lowercase());
460 } else {
461 normalized.push(character.to_ascii_lowercase());
462 }
463 }
464 normalized.trim_matches('_').to_string()
465 }
466
467 fn is_credential_authority_key(key: &str) -> bool {
468 let normalized = normalize_bundle_key(key);
469 if normalized == "external_credentials"
470 || normalized.ends_with("_external_credentials")
471 || normalized == "oauth_credential_generation"
472 || normalized.ends_with("_oauth_credential_generation")
473 {
474 return true;
475 }
476 // `auth_mode` is a declarative protocol selection; an `auth` table is
477 // executable or secret-store authority and is intentionally non-portable.
478 key.split('.')
479 .map(normalize_bundle_key)
480 .any(|segment| segment == "auth")
481 }
482
483 fn is_machine_bound_top_level_key(key: &str) -> bool {
484 key.split('.')
485 .next()
486 .map(normalize_bundle_key)
487 .is_some_and(|root| {
488 matches!(
489 root.as_str(),
490 // Trust posture: a bundle must not widen what the agent may
491 // run or approve on the receiving machine.
492 "allow_shell"
493 | "approval_policy"
494 | "sandbox_mode"
495 | "sandbox_network_access"
496 | "yolo"
497 // Machine-local read-denylist paths.
498 | "sandbox_denied_read_paths"
499 | "sandbox_read_denylist_defaults"
500 | "sandbox_read_denylist_exempt"
501 // Local executables, outbound event sinks, and control
502 // endpoints.
503 | "control_socket"
504 | "extension_host"
505 | "lifecycle_outbox"
506 | "auto_review"
507 | "hooks"
508 | "instructions"
509 | "managed_config_path"
510 | "project_instruction_imports"
511 | "projects"
512 | "requirements_path"
513 | "route_preferences_version"
514 | "route_preferences_migration"
515 | "runtime_api"
516 | "workspace"
517 )
518 })
519 }
520
521 fn is_nonportable_lsp_authority_key(key: &str) -> bool {
522 let mut segments = key.split('.').map(normalize_bundle_key);
523 matches!(segments.next().as_deref(), Some("lsp"))
524 && matches!(segments.next().as_deref(), Some("custom" | "servers"))
525 }
526
527 fn is_nonportable_nested_authority_key(key: &str) -> bool {
528 let segments = key.split('.').map(normalize_bundle_key).collect::<Vec<_>>();
529 match segments.as_slice() {
530 [root, field, ..]
531 if root == "tools" && matches!(field.as_str(), "overrides" | "plugin_dir") =>
532 {
533 true
534 }
535 [root, field, ..] if root == "update" && field == "update_uri" => true,
536 [root, field, ..] if root == "notifications" && field == "sound_file" => true,
537 [root, field, ..] if root == "speech" && field == "output_dir" => true,
538 [root, .., field] if root == "providers" && field == "api_key_env" => true,
539 _ => false,
540 }
541 }
542
543 fn is_machine_specific_config_path(path: &str) -> bool {
544 let path = normalize_bundle_key(path);
545 MACHINE_SPECIFIC_KEYS.iter().any(|key| {
546 let key = normalize_bundle_key(key);
547 path == key
548 || path
549 .strip_suffix(&key)
550 .is_some_and(|prefix| prefix.ends_with('_'))
551 })
552 }
553
554 fn nonportable_path_reason(path: &str) -> Option<&'static str> {
555 if is_machine_bound_top_level_key(path) {
556 return Some("carries machine-bound execution or trust authority");
557 }
558 if is_nonportable_lsp_authority_key(path) {
559 return Some("carries executable LSP authority");
560 }
561 if is_nonportable_nested_authority_key(path) {
562 return Some("carries machine-local route or execution authority");
563 }
564 if is_machine_specific_config_path(path) {
565 return Some("carries machine-local route or filesystem authority");
566 }
567 if is_credential_authority_key(path) {
568 return Some("carries machine-local credential authority");
569 }
570 if is_sensitive_bundle_key(path) {
571 return Some("names credential material");
572 }
573 None
574 }
575
576 /// Telemetry opt-out is safe to move between machines, but opt-in is durable
577 /// user consent coupled to SetupState. A portable bundle may tighten that
578 /// consent (`false`); it must never manufacture or transfer `true`.
579 fn nonportable_value_reason(path: &str, value: &toml::Value) -> Option<&'static str> {
580 let top_level_telemetry = !path.contains('.') && normalize_bundle_key(path) == "telemetry";
581 (top_level_telemetry && matches!(value, toml::Value::Boolean(true)))
582 .then_some("would port telemetry opt-in consent between machines")
583 }
584
585 // ---------------------------------------------------------------------------
586 // Import plan
587 // ---------------------------------------------------------------------------
588
589 /// What applying the bundle would do, computed before anything is written.
590 #[derive(Debug, Clone, Default, PartialEq, Eq)]
591 pub struct ImportPlan {
592 pub added: Vec<String>,
593 pub changed: Vec<String>,
594 pub skipped: Vec<String>,
595 pub conflicting: Vec<String>,
596 pub rejected: Vec<RejectedEntry>,
597 }
598
599 impl ImportPlan {
600 #[must_use]
601 pub fn is_no_op(&self) -> bool {
602 self.added.is_empty() && self.changed.is_empty()
603 }
604 }
605
606 /// Compute the deterministic import plan for `bundle` against `config`.
607 ///
608 /// `section` selects the target document mapping: bundle `project` entries
609 /// apply only to a project-scope document, `global` entries only to a
610 /// user-global one; `preferences`, `profiles`, and `plugins` apply to both.
611 /// Entries that would not touch the target document are `skipped`, so the
612 /// same bundle imports cleanly at either scope.
613 pub fn plan_import(bundle: &PortableBundle, config: &ConfigToml, scope: BundleScope) -> ImportPlan {
614 let mut plan = ImportPlan {
615 rejected: find_rejected_entries(bundle),
616 ..ImportPlan::default()
617 };
618 let rejected_keys: std::collections::BTreeSet<&str> = plan
619 .rejected
620 .iter()
621 .map(|entry| entry.key.as_str())
622 .collect();
623 // Sections are presentation and scope labels over one flat ConfigToml
624 // keyspace. Two applicable sections naming the same key would otherwise
625 // make apply order decide which value wins. Detect that ambiguity before
626 // classifying or writing any entry.
627 let mut applicable_key_counts = std::collections::BTreeMap::<&str, usize>::new();
628 for (section, table) in [
629 ("preferences", &bundle.preferences),
630 ("profiles", &bundle.profiles),
631 ("plugins", &bundle.plugins),
632 ("project", &bundle.project),
633 ("global", &bundle.global),
634 ] {
635 if section_applies(section, scope) {
636 for key in table.entries.keys() {
637 *applicable_key_counts.entry(key.as_str()).or_default() += 1;
638 }
639 }
640 }
641 let colliding_keys: std::collections::BTreeSet<&str> = applicable_key_counts
642 .into_iter()
643 .filter_map(|(key, count)| (count > 1).then_some(key))
644 .collect();
645
646 for (section, table) in [
647 ("preferences", &bundle.preferences),
648 ("profiles", &bundle.profiles),
649 ("plugins", &bundle.plugins),
650 ("project", &bundle.project),
651 ("global", &bundle.global),
652 ] {
653 let dotted = |key: &str| format!("{section}.{key}");
654 let applies = section_applies(section, scope);
655 for (key, value) in &table.entries {
656 let dotted = dotted(key);
657 if rejected_keys.contains(dotted.as_str())
658 || (applies && colliding_keys.contains(key.as_str()))
659 {
660 plan.conflicting.push(dotted);
661 continue;
662 }
663 if !applies {
664 plan.skipped.push(dotted);
665 continue;
666 }
667 if config_value_matches(config, key, value) {
668 plan.skipped.push(dotted);
669 } else if config_has_value(config, key) {
670 plan.changed.push(dotted);
671 } else {
672 plan.added.push(dotted);
673 }
674 }
675 }
676 plan
677 }
678
679 fn config_value_matches(config: &ConfigToml, key: &str, value: &toml::Value) -> bool {
680 let semantically_equal = (|| {
681 let current = config_document(config).ok()?;
682 let mut candidate = config.clone();
683 apply_config_value(&mut candidate, key, value).ok()?;
684 Some(config_document(&candidate).ok()? == current)
685 })()
686 .unwrap_or(false);
687 semantically_equal
688 || config.get_value(key).is_some_and(|current| {
689 render_toml_value(value).ok().as_deref() == Some(current.as_str())
690 })
691 }
692
693 fn config_has_value(config: &ConfigToml, key: &str) -> bool {
694 config_document(config)
695 .ok()
696 .is_some_and(|table| table.contains_key(key))
697 || config.get_value(key).is_some()
698 }
699
700 fn section_applies(section: &str, scope: BundleScope) -> bool {
701 match section {
702 "project" => scope == BundleScope::Project,
703 "global" => scope == BundleScope::Global,
704 _ => true,
705 }
706 }
707
708 // ---------------------------------------------------------------------------
709 // Scope
710 // ---------------------------------------------------------------------------
711
712 /// Which document an import/export targets.
713 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
714 pub enum BundleScope {
715 /// The user-global config (`~/.codewhale/config.toml` by default).
716 Global,
717 /// The workspace-scoped config (`<repo>/.codewhale/config.toml`).
718 Project,
719 }
720
721 impl BundleScope {
722 #[must_use]
723 pub fn label(self) -> &'static str {
724 match self {
725 Self::Global => "global",
726 Self::Project => "project",
727 }
728 }
729 }
730
731 fn validate_scope_target(scope: BundleScope, target: &Path) -> Result<()> {
732 let workspace_scoped = codewhale_config::config_path_is_workspace_scoped(target);
733 match (scope, workspace_scoped) {
734 (BundleScope::Project, false) => bail!(
735 "--project requires a workspace config ({} is the user-global document)",
736 target.display()
737 ),
738 (BundleScope::Global, true) => bail!(
739 "global bundle operations cannot target workspace config {}; rerun with --project or select the user-global config",
740 target.display()
741 ),
742 _ => {}
743 }
744 Ok(())
745 }
746
747 // ---------------------------------------------------------------------------
748 // Path safety
749 // ---------------------------------------------------------------------------
750
751 /// Resolve `candidate` inside `base_dir`, refusing traversal and symlink
752 /// escapes. Returns the resolved path or an error naming the refusal — the
753 /// candidate string itself is safe to echo (it is config data, not a secret).
754 /// Resolve `candidate` inside `base_dir`, refusing traversal and symlink
755 /// escapes. Returns the joined path or an error naming the refusal.
756 /// Reserved for path-carrying bundle sections (none shipped yet); exercised
757 /// by the traversal tests so the contract cannot silently rot.
758 #[cfg_attr(
759 not(test),
760 expect(dead_code, reason = "path-carrying sections land with the next schema")
761 )]
762 pub fn resolve_bounded_path(base_dir: &Path, candidate: &str) -> Result<PathBuf> {
763 if candidate.contains('\0') {
764 bail!("bundle path contains a NUL byte; refused");
765 }
766 let candidate_path = Path::new(candidate);
767 if candidate_path.is_absolute() {
768 bail!(
769 "bundle path {candidate:?} is absolute; only paths inside the config directory are accepted"
770 );
771 }
772 let canonical_base = base_dir
773 .canonicalize()
774 .with_context(|| format!("config directory {} is unavailable", base_dir.display()))?;
775 let joined = base_dir.join(candidate_path);
776 // Walk the joined path's ancestors from the deepest existing component up:
777 // every existing component must canonicalize inside the base, so a symlink
778 // pointing outside the config directory is refused even when the final
779 // target does not exist yet.
780 let deepest_existing = joined
781 .ancestors()
782 .find(|ancestor| ancestor.symlink_metadata().is_ok())
783 .context("bundle path has no existing ancestor inside the config directory")?;
784 let resolved = deepest_existing.canonicalize().with_context(|| {
785 format!(
786 "could not resolve bundle path component {}",
787 deepest_existing.display()
788 )
789 })?;
790 if !resolved.starts_with(&canonical_base) {
791 bail!("bundle path {candidate:?} escapes the config directory via a symlink; refused");
792 }
793 Ok(joined)
794 }
795
796 // ---------------------------------------------------------------------------
797 // Remote fetch
798 // ---------------------------------------------------------------------------
799
800 /// Fetch a bundle over HTTPS (or plain http on loopback only) with a hard
801 /// size cap, a timeout, and bounded redirects. Mirrors the skill installer's
802 /// fetch bounds.
803 pub fn fetch_bundle(url: &str) -> Result<Vec<u8>> {
804 let mut current_url = reqwest::Url::parse(url).map_err(|_| anyhow!("invalid bundle URL"))?;
805 validate_bundle_url(&current_url)?;
806 let initial_scheme = current_url.scheme().to_string();
807
808 let client = codewhale_release::platform_blocking_http_client_builder()
809 .timeout(std::time::Duration::from_secs(FETCH_TIMEOUT_SECS))
810 // Redirect targets must pass the same scheme/host policy as the
811 // initial request, so redirects are followed explicitly below.
812 .redirect(reqwest::redirect::Policy::none())
813 .build()
814 .map_err(|_| anyhow!("building bundle fetch client failed"))?;
815 let mut redirects = 0usize;
816 let response = loop {
817 let response = client
818 .get(current_url.clone())
819 .send()
820 // reqwest errors can include the full URL (including its query or
821 // userinfo), so keep transport failures deliberately URL-free.
822 .map_err(|_| anyhow!("bundle fetch request failed"))?;
823
824 if !response.status().is_redirection() {
825 break response;
826 }
827 if redirects >= MAX_REDIRECTS {
828 bail!("bundle fetch exceeded the five-redirect limit");
829 }
830 let location = response
831 .headers()
832 .get(reqwest::header::LOCATION)
833 .ok_or_else(|| anyhow!("bundle redirect is missing a valid Location header"))?
834 .to_str()
835 .map_err(|_| anyhow!("bundle redirect is missing a valid Location header"))?;
836 let next_url = current_url
837 .join(location)
838 .map_err(|_| anyhow!("bundle redirect Location is invalid"))?;
839 validate_bundle_redirect(&initial_scheme, &next_url)?;
840 current_url = next_url;
841 redirects += 1;
842 };
843
844 if !response.status().is_success() {
845 bail!(
846 "bundle fetch failed with HTTP status {}",
847 response.status().as_u16()
848 );
849 }
850
851 // Read at most MAX_BUNDLE_BYTES + 1 so an oversize body is detected
852 // rather than silently truncated.
853 let mut buffer = Vec::new();
854 let body = response;
855 body.take(MAX_BUNDLE_BYTES + 1)
856 .read_to_end(&mut buffer)
857 .map_err(|_| anyhow!("reading remote bundle failed"))?;
858 if buffer.len() as u64 > MAX_BUNDLE_BYTES {
859 bail!("remote bundle exceeds the {MAX_BUNDLE_BYTES} byte limit; refused");
860 }
861 Ok(buffer)
862 }
863
864 fn validate_bundle_url(url: &reqwest::Url) -> Result<()> {
865 if !matches!(url.scheme(), "http" | "https") {
866 bail!("unsupported bundle URL scheme; use https");
867 }
868 if !url.username().is_empty() || url.password().is_some() {
869 bail!("bundle URLs may not include credentials");
870 }
871 let host = url.host_str().context("bundle URL must include a host")?;
872 match url.scheme() {
873 "https" => Ok(()),
874 "http" if is_loopback_bundle_host(host) => Ok(()),
875 "http" => bail!("plain http is only allowed for loopback hosts; use https"),
876 _ => unreachable!("scheme was validated above"),
877 }
878 }
879
880 fn validate_bundle_redirect(initial_scheme: &str, next_url: &reqwest::Url) -> Result<()> {
881 validate_bundle_url(next_url)?;
882 if next_url.scheme() != initial_scheme {
883 bail!("bundle redirects may not change URL scheme");
884 }
885 Ok(())
886 }
887
888 fn is_loopback_bundle_host(host: &str) -> bool {
889 let normalized = host
890 .strip_prefix('[')
891 .and_then(|value| value.strip_suffix(']'))
892 .unwrap_or(host);
893 normalized.eq_ignore_ascii_case("localhost")
894 || normalized.to_ascii_lowercase().ends_with(".localhost")
895 || normalized
896 .parse::<std::net::IpAddr>()
897 .is_ok_and(|address| address.is_loopback())
898 }
899
900 // ---------------------------------------------------------------------------
901 // Export
902 // ---------------------------------------------------------------------------
903
904 /// Build a deterministic, secret-free export from `config`.
905 ///
906 /// Keys are sorted, machine-specific absolute paths and credential fields are
907 /// dropped, and the same section mapping as import is used so an exported
908 /// bundle re-imports at the same scope.
909 pub fn export_bundle(
910 config: &ConfigToml,
911 scope: BundleScope,
912 metadata: BundleMetadata,
913 ) -> Result<PortableBundle> {
914 let mut preferences = BundleTable::default();
915 let profiles = BundleTable::default();
916 let mut global = BundleTable::default();
917 let mut project = BundleTable::default();
918
919 let mut document = config_document(config)?;
920 if scope == BundleScope::Global {
921 // Root model aliases are route-relative on import; reconcile them with
922 // the canonical provider slots so the bundle never conflicts with
923 // itself, without reparsing unrelated preserved extras as `Config`.
924 codewhale_tui::route_preferences::scrub_root_model_aliases_for_export(&mut document)?;
925 }
926 for (key, value) in document {
927 if let Some(value) = sanitize_export_value(&key, &value) {
928 match export_section_for(&key, scope) {
929 ExportSection::Preferences => {
930 preferences.entries.insert(key, value);
931 }
932 ExportSection::Global => {
933 global.entries.insert(key, value);
934 }
935 ExportSection::Project => {
936 project.entries.insert(key, value);
937 }
938 ExportSection::Drop => {}
939 }
940 }
941 }
942
943 let bundle = PortableBundle {
944 schema_version: BUNDLE_SCHEMA_VERSION,
945 kind: BUNDLE_KIND.to_string(),
946 metadata,
947 preferences,
948 profiles,
949 plugins: BundleTable::default(),
950 project,
951 global,
952 };
953 let rejected = find_rejected_entries(&bundle);
954 if !rejected.is_empty() {
955 bail!(
956 "portable export refused credential-bearing config paths: {}",
957 rejected
958 .iter()
959 .map(|entry| entry.key.as_str())
960 .collect::<Vec<_>>()
961 .join(", ")
962 );
963 }
964 Ok(bundle)
965 }
966
967 /// Serialize a bundle deterministically (sorted keys, TOML).
968 pub fn serialize_bundle(bundle: &PortableBundle) -> Result<String> {
969 toml::to_string_pretty(bundle).context("serializing portable bundle")
970 }
971
972 /// Config keys that name a machine-local location and must never be exported.
973 const MACHINE_SPECIFIC_KEYS: [&str; 14] = [
974 "base_url",
975 "bwrap_dev_roots",
976 "bwrap_ro_roots",
977 "hook_sinks.unix_socket_path",
978 "mcp_config_path",
979 "mcp_oauth_callback_port",
980 "mcp_oauth_callback_url",
981 "memory_path",
982 "network.proxy",
983 "notes_path",
984 "sandbox_backend",
985 "sandbox_url",
986 "skills_dir",
987 "telemetry_endpoint",
988 ];
989
990 enum ExportSection {
991 Preferences,
992 Global,
993 Project,
994 Drop,
995 }
996
997 fn export_section_for(key: &str, scope: BundleScope) -> ExportSection {
998 if key.starts_with("skills") || key.starts_with("tools") || key.starts_with("snapshots") {
999 return ExportSection::Preferences;
1000 }
1001 if key.starts_with("auth.") {
1002 return ExportSection::Drop;
1003 }
1004 match scope {
1005 BundleScope::Global => ExportSection::Global,
1006 BundleScope::Project => ExportSection::Project,
1007 }
1008 }
1009
1010 fn config_document(config: &ConfigToml) -> Result<toml::map::Map<String, toml::Value>> {
1011 // Serialize through TOML text before parsing to Value. Direct
1012 // `Value::try_from` double-encodes datetime values held inside flattened
1013 // `toml::Value` extras as the serializer's private marker table.
1014 let text = toml::to_string(config).context("serializing typed config for bundle")?;
1015 let value: toml::Value =
1016 toml::from_str(&text).map_err(|_| anyhow!("serialized typed config was not valid TOML"))?;
1017 let toml::Value::Table(mut table) = value else {
1018 bail!("typed config did not serialize to a TOML table");
1019 };
1020 // `selected_provider_id` is runtime parse state and is skipped by serde;
1021 // restore the exact named-provider identity that ConfigStore writes.
1022 table.insert(
1023 "provider".to_string(),
1024 toml::Value::String(config.provider_id().to_string()),
1025 );
1026 Ok(table)
1027 }
1028
1029 /// Return a recursively scrubbed export value. Secret-bearing leaves and
1030 /// machine-local paths are omitted rather than replaced with a placeholder,
1031 /// because a placeholder would become literal config on re-import.
1032 fn sanitize_export_value(path: &str, value: &toml::Value) -> Option<toml::Value> {
1033 sanitize_export_value_at(path, value, 0)
1034 }
1035
1036 fn sanitize_export_value_at(path: &str, value: &toml::Value, depth: usize) -> Option<toml::Value> {
1037 if depth > MAX_EXPORT_WALK_DEPTH {
1038 return None;
1039 }
1040 if nonportable_path_reason(path).is_some() || nonportable_value_reason(path, value).is_some() {
1041 return None;
1042 }
1043 match value {
1044 toml::Value::String(text) if string_secret_reason(text).is_some() => None,
1045 toml::Value::Array(values) => Some(toml::Value::Array(
1046 values
1047 .iter()
1048 .filter_map(|value| sanitize_export_value_at(path, value, depth + 1))
1049 .collect(),
1050 )),
1051 toml::Value::Table(table) => {
1052 let mut scrubbed = toml::map::Map::new();
1053 for (key, value) in table {
1054 let child_path = format!("{path}.{key}");
1055 if let Some(value) = sanitize_export_value_at(&child_path, value, depth + 1) {
1056 scrubbed.insert(key.clone(), value);
1057 }
1058 }
1059 Some(toml::Value::Table(scrubbed))
1060 }
1061 _ => Some(value.clone()),
1062 }
1063 }
1064
1065 // ---------------------------------------------------------------------------
1066 // Transactional apply
1067 // ---------------------------------------------------------------------------
1068
1069 /// Outcome of a committed import.
1070 #[derive(Debug)]
1071 pub struct ImportReceipt {
1072 pub plan: ImportPlan,
1073 pub backup_path: Option<PathBuf>,
1074 pub target: PathBuf,
1075 }
1076
1077 /// Apply a validated bundle to `store` transactionally.
1078 ///
1079 /// The current document is backed up to `<target>.bundle-backup-<timestamp>-<random>`,
1080 /// the prepared candidate is committed through one `ConfigStore::save`, and any failure
1081 /// restores the backup before returning the error. The receipt redacts by
1082 /// construction: it carries only key paths and counts, never values.
1083 #[cfg(test)]
1084 pub fn apply_bundle(
1085 bundle: &PortableBundle,
1086 store: &mut codewhale_config::ConfigStore,
1087 scope: BundleScope,
1088 _workspace: &Path,
1089 ) -> Result<ImportReceipt> {
1090 let prepared = prepare_import(bundle, store, scope)?;
1091 apply_prepared_bundle(prepared, store, save_candidate)
1092 }
1093
1094 struct PreparedImport {
1095 plan: ImportPlan,
1096 candidate: ConfigToml,
1097 }
1098
1099 fn apply_prepared_bundle<F>(
1100 prepared: PreparedImport,
1101 store: &mut codewhale_config::ConfigStore,
1102 apply: F,
1103 ) -> Result<ImportReceipt>
1104 where
1105 F: FnOnce(ConfigToml, &mut codewhale_config::ConfigStore, &mut bool) -> Result<()>,
1106 {
1107 let PreparedImport { plan, candidate } = prepared;
1108 if !plan.conflicting.is_empty() {
1109 bail!(
1110 "bundle contains conflicting or rejected entries: {}; remove duplicate keys or credential-shaped entries and re-export",
1111 plan.conflicting.join(", ")
1112 );
1113 }
1114 if plan.is_no_op() {
1115 return Ok(ImportReceipt {
1116 plan,
1117 backup_path: None,
1118 target: store.path().to_path_buf(),
1119 });
1120 }
1121
1122 let target = store.path().to_path_buf();
1123 let original_config = store.config.clone();
1124 let backup_path = if target
1125 .try_exists()
1126 .with_context(|| format!("checking config target {}", target.display()))?
1127 {
1128 Some(create_collision_safe_backup(&target)?)
1129 } else {
1130 None
1131 };
1132
1133 let mut target_written = false;
1134 let apply_result = apply(candidate, store, &mut target_written);
1135 if let Err(error) = apply_result {
1136 store.config = original_config;
1137 let rollback = rollback_import_target(&target, backup_path.as_deref(), target_written)
1138 .and_then(|()| store.reload());
1139 match rollback {
1140 Ok(()) => bail!("{error:#}; rolled back to the pre-import document"),
1141 Err(_) => bail!(
1142 "{error:#}; ROLLBACK FAILED — the pre-import document is preserved at {}",
1143 backup_path
1144 .as_deref()
1145 .map(Path::display)
1146 .map(|path| path.to_string())
1147 .unwrap_or_else(
1148 || "<no prior file; remove the new target manually>".to_string()
1149 )
1150 ),
1151 }
1152 }
1153
1154 Ok(ImportReceipt {
1155 plan,
1156 backup_path,
1157 target,
1158 })
1159 }
1160
1161 fn rollback_import_target(
1162 target: &Path,
1163 backup_path: Option<&Path>,
1164 target_written: bool,
1165 ) -> Result<()> {
1166 // ConfigStore fails closed before replacing a stale target. If it did not
1167 // report a successful write, leave a concurrently-created or edited file
1168 // alone instead of mistaking somebody else's bytes for ours.
1169 if !target_written {
1170 return Ok(());
1171 }
1172 if let Some(backup_path) = backup_path {
1173 let bytes = std::fs::read(backup_path)
1174 .with_context(|| format!("reading pre-import backup {}", backup_path.display()))?;
1175 // Replace the name by rename (owner-only), never write through a link
1176 // that has appeared at it since the import wrote the file.
1177 codewhale_config::persistence::atomic_write(target, &bytes)
1178 .with_context(|| format!("restoring pre-import config {}", target.display()))?;
1179 return Ok(());
1180 }
1181
1182 match std::fs::remove_file(target) {
1183 Ok(()) => Ok(()),
1184 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1185 Err(error) => Err(error)
1186 .with_context(|| format!("removing newly-created config {}", target.display())),
1187 }
1188 }
1189
1190 /// Build the exact candidate used for both preview and commit. Legacy route
1191 /// migration remains in memory until the existing ConfigStore CAS save.
1192 fn prepare_import(
1193 bundle: &PortableBundle,
1194 store: &codewhale_config::ConfigStore,
1195 scope: BundleScope,
1196 ) -> Result<PreparedImport> {
1197 match scope {
1198 BundleScope::Global if !bundle.project.entries.is_empty() => bail!(
1199 "bundle carries [project] entries; import it with --project from the workspace instead"
1200 ),
1201 BundleScope::Project if !bundle.global.entries.is_empty() => bail!(
1202 "bundle carries [global] entries; importing them into a project document would leak machine state"
1203 ),
1204 _ => {}
1205 }
1206 validate_scope_target(scope, store.path())?;
1207 let mut plan = plan_import(bundle, &store.config, scope);
1208 if !plan.conflicting.is_empty() || (plan.is_no_op() && plan.skipped.is_empty()) {
1209 return Ok(PreparedImport {
1210 plan,
1211 candidate: store.config.clone(),
1212 });
1213 }
1214 let rendered;
1215 let original = if let Some(original) = store.original_body() {
1216 original
1217 } else {
1218 rendered = store.rendered_body()?;
1219 &rendered
1220 };
1221 let mut document = codewhale_tui::route_preferences::prepare_document(store.path(), original)?;
1222 let mut candidate = config_from_document(&document.to_string())?;
1223 let mut model_edits = Vec::<(String, String, String)>::new();
1224 let mut selected_provider = None;
1225 for (section, table) in [
1226 ("preferences", &bundle.preferences),
1227 ("profiles", &bundle.profiles),
1228 ("plugins", &bundle.plugins),
1229 ("project", &bundle.project),
1230 ("global", &bundle.global),
1231 ] {
1232 if !section_applies(section, scope) {
1233 continue;
1234 }
1235 for (key, value) in &table.entries {
1236 let dotted = format!("{section}.{key}");
1237 if nonportable_path_reason(key).is_some()
1238 || value_rejection_reason(key, value).is_some()
1239 {
1240 bail!("refusing to import non-portable config path {dotted}");
1241 }
1242 if key == "provider" {
1243 selected_provider = Some(
1244 value
1245 .as_str()
1246 .ok_or_else(|| anyhow!("config entry {dotted:?} must be a string"))?,
1247 );
1248 continue;
1249 }
1250 collect_model_edits(&dotted, key, value, &mut model_edits)?;
1251 if codewhale_tui::route_preferences::is_route_key(key) {
1252 continue;
1253 }
1254 apply_config_value(&mut candidate, key, value)?;
1255 }
1256 }
1257 document = toml::to_string(&toml::Value::Table(config_document(&candidate)?))?
1258 .parse()
1259 .map_err(|_| anyhow!("could not prepare imported configuration; contents omitted"))?;
1260 // Definitions precede the exact final selector. Root aliases then target
1261 // that selected route, so an old provider slot cannot mask an imported model.
1262 if let Some(provider) = selected_provider {
1263 codewhale_tui::route_preferences::set_document(
1264 store.path(),
1265 &mut document,
1266 "provider",
1267 provider,
1268 )?;
1269 }
1270 model_edits.sort_by_key(|(_, key, _)| !key.starts_with("providers."));
1271 for (_, key, value) in &model_edits {
1272 codewhale_tui::route_preferences::set_document(store.path(), &mut document, key, value)?;
1273 }
1274 let final_value: toml::Value = toml::from_str(&document.to_string())?;
1275 for (dotted, key, value) in &model_edits {
1276 let mut replay = document.clone();
1277 codewhale_tui::route_preferences::set_document(store.path(), &mut replay, key, value)?;
1278 if toml::from_str::<toml::Value>(&replay.to_string())? != final_value {
1279 plan.conflicting.push(dotted.clone());
1280 }
1281 }
1282 candidate = config_from_document(&document.to_string())?;
1283 // Run the same validation/serialization as the final save before consent
1284 // or backup creation. This clone never writes or replaces the CAS snapshot.
1285 let mut validation_store = store.clone();
1286 validation_store.config = candidate.clone();
1287 validation_store.rendered_body()?;
1288 if config_document(&candidate)? == config_document(&store.config)? {
1289 plan.skipped.append(&mut plan.added);
1290 plan.skipped.append(&mut plan.changed);
1291 } else {
1292 // A raw root alias may already match while its canonical provider
1293 // slot differs. Such an import is a real change, not a skipped write.
1294 if plan.is_no_op() {
1295 for (dotted, _, _) in &model_edits {
1296 plan.skipped.retain(|key| key != dotted);
1297 plan.changed.push(dotted.clone());
1298 }
1299 }
1300 let original_value: toml::Value = toml::from_str(original)?;
1301 if original_value.get("route_preferences_version").is_none()
1302 && final_value.get("route_preferences_version").is_some()
1303 {
1304 plan.added
1305 .push("global.route_preferences_version (local migration)".to_string());
1306 }
1307 }
1308 for keys in [
1309 &mut plan.added,
1310 &mut plan.changed,
1311 &mut plan.skipped,
1312 &mut plan.conflicting,
1313 ] {
1314 keys.sort();
1315 keys.dedup();
1316 }
1317 Ok(PreparedImport { plan, candidate })
1318 }
1319
1320 fn collect_model_edits(
1321 dotted: &str,
1322 key: &str,
1323 value: &toml::Value,
1324 edits: &mut Vec<(String, String, String)>,
1325 ) -> Result<()> {
1326 if key != "provider" && codewhale_tui::route_preferences::is_route_key(key) {
1327 let value = value
1328 .as_str()
1329 .ok_or_else(|| anyhow!("config entry {dotted:?} must be a string"))?;
1330 edits.push((dotted.to_string(), key.to_string(), value.to_string()));
1331 } else if (key == "providers" || key.starts_with("providers."))
1332 && let Some(table) = value.as_table()
1333 {
1334 for (child, value) in table {
1335 collect_model_edits(
1336 &format!("{dotted}.{child}"),
1337 &format!("{key}.{child}"),
1338 value,
1339 edits,
1340 )?;
1341 }
1342 }
1343 Ok(())
1344 }
1345
1346 fn config_from_document(body: &str) -> Result<ConfigToml> {
1347 let mut config = codewhale_config::parse_config_toml(body).map_err(|_| {
1348 anyhow!("imported configuration has an invalid TOML type; contents omitted")
1349 })?;
1350 let document: toml::Value = toml::from_str(body)?;
1351 if let Some(provider) = document.get("provider").and_then(toml::Value::as_str) {
1352 config.bind_persisted_provider_id(provider)?;
1353 }
1354 Ok(config)
1355 }
1356
1357 fn save_candidate(
1358 candidate: ConfigToml,
1359 store: &mut codewhale_config::ConfigStore,
1360 target_written: &mut bool,
1361 ) -> Result<()> {
1362 store.config = candidate;
1363 store.save().context("saving imported bundle")?;
1364 *target_written = true;
1365 Ok(())
1366 }
1367
1368 fn apply_config_value(config: &mut ConfigToml, key: &str, value: &toml::Value) -> Result<()> {
1369 if codewhale_config::config_toml_choices(key).is_some()
1370 || key == "auth.mode"
1371 || key == "hook_sinks.unix_socket_path"
1372 || key.starts_with("providers.")
1373 {
1374 return config.set_value(key, &render_toml_value(value)?);
1375 }
1376
1377 let mut document = config_document(config)?;
1378 if let Some(current) = document.get_mut(key) {
1379 deep_merge_toml_value(current, value);
1380 } else {
1381 document.insert(key.to_string(), value.clone());
1382 }
1383 // As in `config_document`, round-trip through TOML text so datetimes in
1384 // flattened extras stay TOML datetimes instead of serde-private marker
1385 // tables or strings.
1386 let text = toml::to_string(&toml::Value::Table(document))
1387 .with_context(|| format!("config entry {key:?} could not be serialized"))?;
1388 *config = config_from_document(&text)?;
1389 Ok(())
1390 }
1391
1392 /// Merge a portable value into the target document without treating omitted
1393 /// table leaves as deletions. Tables recurse; arrays and scalars represent an
1394 /// explicit portable choice and replace the corresponding target value.
1395 fn deep_merge_toml_value(target: &mut toml::Value, incoming: &toml::Value) {
1396 match (target, incoming) {
1397 (toml::Value::Table(target), toml::Value::Table(incoming)) => {
1398 for (key, value) in incoming {
1399 if let Some(current) = target.get_mut(key) {
1400 deep_merge_toml_value(current, value);
1401 } else {
1402 target.insert(key.clone(), value.clone());
1403 }
1404 }
1405 }
1406 (target, incoming) => *target = incoming.clone(),
1407 }
1408 }
1409
1410 /// Render a TOML value into the scalar text `config set` accepts.
1411 fn render_toml_value(value: &toml::Value) -> Result<String> {
1412 Ok(match value {
1413 toml::Value::String(text) => text.clone(),
1414 toml::Value::Integer(number) => number.to_string(),
1415 toml::Value::Float(number) => number.to_string(),
1416 toml::Value::Boolean(flag) => flag.to_string(),
1417 toml::Value::Datetime(text) => text.to_string(),
1418 toml::Value::Array(_) | toml::Value::Table(_) => {
1419 toml::to_string(value)?.trim_end().to_string()
1420 }
1421 })
1422 }
1423
1424 fn create_collision_safe_backup(target: &Path) -> Result<PathBuf> {
1425 let timestamp = std::time::SystemTime::now()
1426 .duration_since(std::time::UNIX_EPOCH)
1427 .map(|since| since.as_secs())
1428 .unwrap_or_default();
1429 let file_name = target
1430 .file_name()
1431 .map(|name| name.to_string_lossy().into_owned())
1432 .unwrap_or_else(|| "config.toml".to_string());
1433 let parent = target.parent().unwrap_or_else(|| Path::new("."));
1434 let prefix = format!("{file_name}.bundle-backup-{timestamp}-");
1435 // NamedTempFile uses exclusive creation and restrictive initial
1436 // permissions, so concurrent same-second imports cannot clobber an older
1437 // receipt or expose config bytes before target permissions are applied.
1438 let mut backup = tempfile::Builder::new()
1439 .prefix(&prefix)
1440 .tempfile_in(parent)
1441 .with_context(|| {
1442 format!(
1443 "creating a collision-safe backup beside {}",
1444 target.display()
1445 )
1446 })?;
1447 let mut source = std::fs::File::open(target)
1448 .with_context(|| format!("opening {} for bundle backup", target.display()))?;
1449 std::io::copy(&mut source, backup.as_file_mut())
1450 .with_context(|| format!("copying {} into its bundle backup", target.display()))?;
1451 use std::io::Write as _;
1452 backup
1453 .as_file_mut()
1454 .flush()
1455 .context("flushing bundle backup")?;
1456 let permissions = source
1457 .metadata()
1458 .with_context(|| format!("reading permissions for {}", target.display()))?
1459 .permissions();
1460 std::fs::set_permissions(backup.path(), permissions)
1461 .context("preserving config permissions on bundle backup")?;
1462 backup
1463 .as_file()
1464 .sync_all()
1465 .context("syncing bundle backup")?;
1466 let (_file, path) = backup
1467 .keep()
1468 .map_err(|error| error.error)
1469 .context("persisting bundle backup")?;
1470 Ok(path)
1471 }
1472
1473 // ---------------------------------------------------------------------------
1474 // Consent
1475 // ---------------------------------------------------------------------------
1476
1477 /// Require explicit consent before mutating: interactive sessions get a
1478 /// prompt; headless runs require `--yes`.
1479 pub fn require_import_consent(yes: bool, plan: &ImportPlan) -> Result<()> {
1480 if yes {
1481 return Ok(());
1482 }
1483 if !std::io::stdin().is_terminal() {
1484 bail!(
1485 "import refused: non-interactive use requires explicit --yes after reviewing the plan"
1486 );
1487 }
1488 print!(
1489 "Apply this bundle ({} added, {} changed)? Type 'yes': ",
1490 plan.added.len(),
1491 plan.changed.len()
1492 );
1493 use std::io::Write;
1494 std::io::stdout().flush()?;
1495 let mut answer = String::new();
1496 std::io::stdin()
1497 .read_line(&mut answer)
1498 .context("reading import consent")?;
1499 if answer.trim() != "yes" {
1500 bail!("import cancelled; no configuration was changed");
1501 }
1502 Ok(())
1503 }
1504
1505 // ---------------------------------------------------------------------------
1506 // CLI surface
1507 // ---------------------------------------------------------------------------
1508
1509 /// Arguments for `codewhale config import`.
1510 #[derive(Debug, clap::Args)]
1511 pub struct ImportArgs {
1512 /// Bundle source: a file path, an HTTPS URL, or `-` for stdin.
1513 pub source: String,
1514 /// Print the deterministic import plan without writing anything.
1515 #[arg(long, default_value_t = false)]
1516 dry_run: bool,
1517 /// Skip the interactive consent prompt (required for headless use).
1518 #[arg(long, default_value_t = false)]
1519 yes: bool,
1520 /// Target the project config instead of the user-global document.
1521 #[arg(long, default_value_t = false)]
1522 project: bool,
1523 }
1524
1525 /// Arguments for `codewhale config export --portable`.
1526 #[derive(Debug, clap::Args)]
1527 pub struct ExportArgs {
1528 /// Emit a portable, secret-free bundle (required flag; plain `export`
1529 /// is reserved so a future non-portable format cannot silently change
1530 /// what the command writes).
1531 #[arg(long, default_value_t = false)]
1532 portable: bool,
1533 /// Export the project config instead of the user-global document.
1534 #[arg(long, default_value_t = false)]
1535 project: bool,
1536 /// Write to this path instead of stdout.
1537 #[arg(long, value_name = "FILE")]
1538 out: Option<PathBuf>,
1539 }
1540
1541 /// Run `config import`.
1542 pub fn run_import(
1543 args: &ImportArgs,
1544 store: &mut codewhale_config::ConfigStore,
1545 _workspace: &Path,
1546 ) -> Result<()> {
1547 let scope = if args.project {
1548 BundleScope::Project
1549 } else {
1550 BundleScope::Global
1551 };
1552 validate_scope_target(scope, store.path())?;
1553 let remote_source = args.source.starts_with("https://") || args.source.starts_with("http://");
1554 let source_label = if args.source == "-" {
1555 "stdin"
1556 } else if remote_source {
1557 "remote bundle"
1558 } else {
1559 args.source.as_str()
1560 };
1561 let raw = if args.source == "-" {
1562 let mut buffer = Vec::new();
1563 std::io::stdin()
1564 .lock()
1565 .take(MAX_BUNDLE_BYTES + 1)
1566 .read_to_end(&mut buffer)
1567 .context("reading bundle from stdin")?;
1568 if buffer.len() as u64 > MAX_BUNDLE_BYTES {
1569 bail!("stdin bundle exceeds the {MAX_BUNDLE_BYTES} byte limit; refused");
1570 }
1571 buffer
1572 } else if remote_source {
1573 fetch_bundle(&args.source)?
1574 } else {
1575 let path = PathBuf::from(&args.source);
1576 let metadata = std::fs::metadata(&path)
1577 .with_context(|| format!("reading bundle at {}", path.display()))?;
1578 if metadata.len() > MAX_BUNDLE_BYTES {
1579 bail!(
1580 "bundle at {} is {} bytes; the limit is {MAX_BUNDLE_BYTES} bytes",
1581 path.display(),
1582 metadata.len()
1583 );
1584 }
1585 std::fs::read(&path).with_context(|| format!("reading bundle at {}", path.display()))?
1586 };
1587
1588 let bundle = parse_bundle_bytes(&raw, source_label)?;
1589 let prepared = prepare_import(&bundle, store, scope)?;
1590 let plan = &prepared.plan;
1591
1592 println!("import plan ({} scope, {source_label}):", scope.label());
1593 println!(" added: {}", plan.added.len());
1594 println!(" changed: {}", plan.changed.len());
1595 println!(" skipped: {}", plan.skipped.len());
1596 println!(" conflicting: {}", plan.conflicting.len());
1597 println!(" rejected: {}", plan.rejected.len());
1598 for entry in &plan.added {
1599 println!(" + {entry}");
1600 }
1601 for entry in &plan.changed {
1602 println!(" ~ {entry}");
1603 }
1604 for entry in &plan.rejected {
1605 println!(" ! {} — {}", entry.key, entry.reason);
1606 }
1607 for entry in &plan.conflicting {
1608 println!(" x {entry}");
1609 }
1610
1611 if args.dry_run {
1612 println!("dry run: nothing was written");
1613 return Ok(());
1614 }
1615
1616 require_import_consent(args.yes, plan)?;
1617 let receipt = apply_prepared_bundle(prepared, store, save_candidate)?;
1618 if receipt.plan.is_no_op() {
1619 println!("nothing to apply; config already matches the bundle (idempotent re-import)");
1620 return Ok(());
1621 }
1622 println!(
1623 "imported: {} added, {} changed into {}",
1624 receipt.plan.added.len(),
1625 receipt.plan.changed.len(),
1626 receipt.target.display()
1627 );
1628 if let Some(backup) = &receipt.backup_path {
1629 println!("pre-import backup: {}", backup.display());
1630 }
1631 Ok(())
1632 }
1633
1634 /// Run `config export --portable`.
1635 pub fn run_export(args: &ExportArgs, store: &codewhale_config::ConfigStore) -> Result<()> {
1636 if !args.portable {
1637 bail!("config export requires --portable; plain export is not defined yet");
1638 }
1639 let scope = if args.project {
1640 BundleScope::Project
1641 } else {
1642 BundleScope::Global
1643 };
1644 validate_scope_target(scope, store.path())?;
1645 let metadata = BundleMetadata {
1646 name: None,
1647 created_at: Some(chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)),
1648 generator: Some(format!("codewhale {}", env!("CARGO_PKG_VERSION"))),
1649 };
1650 let bundle = export_bundle(&store.config, scope, metadata)?;
1651 let body = serialize_bundle(&bundle)?;
1652 match &args.out {
1653 Some(path) => {
1654 codewhale_config::persistence::atomic_write(path, body.as_bytes())
1655 .with_context(|| format!("writing bundle to {}", path.display()))?;
1656 println!("wrote portable bundle to {}", path.display());
1657 }
1658 None => {
1659 use std::io::Write;
1660 std::io::stdout().write_all(body.as_bytes())?;
1661 }
1662 }
1663 Ok(())
1664 }
1665
1666 // ---------------------------------------------------------------------------
1667 // Tests
1668 // ---------------------------------------------------------------------------
1669
1670 #[cfg(test)]
1671 mod tests {
1672 use super::*;
1673 use codewhale_config::ConfigStore;
1674 use std::io::Write;
1675 use std::net::{Ipv4Addr, TcpListener};
1676
1677 const VALID_TOML: &str = r#"
1678 schema_version = 1
1679 kind = "codewhale.portable-config"
1680
1681 [metadata]
1682 name = "team-baseline"
1683
1684 [preferences]
1685 verbosity = "concise"
1686 telemetry = false
1687
1688 [global]
1689 log_level = "debug"
1690 "#;
1691
1692 fn sample_bundle() -> PortableBundle {
1693 parse_bundle_str(VALID_TOML, "test.toml").expect("valid bundle")
1694 }
1695
1696 #[test]
1697 fn valid_bundle_parses_and_validates() {
1698 let bundle = sample_bundle();
1699 assert_eq!(bundle.schema_version, 1);
1700 assert_eq!(bundle.kind, "codewhale.portable-config");
1701 assert_eq!(bundle.metadata.name.as_deref(), Some("team-baseline"));
1702 assert_eq!(bundle.preferences.entries.len(), 2);
1703 }
1704
1705 #[test]
1706 fn unknown_envelope_fields_fail_the_parse() {
1707 let text = r#"
1708 schema_version = 1
1709 kind = "codewhale.portable-config"
1710 sneaky_extra = true
1711 "#;
1712 let err = parse_bundle_str(text, "test.toml").expect_err("unknown field must fail");
1713 let rendered = format!("{err:#}");
1714 assert!(rendered.contains("unknown field"), "{rendered}");
1715 }
1716
1717 #[test]
1718 fn wrong_kind_or_schema_version_is_refused() {
1719 let bad_kind = "schema_version = 1
1720 kind = \"something-else\"\n";
1721 let err = parse_bundle_str(bad_kind, "t.toml").expect_err("kind must match");
1722 assert!(err.to_string().contains("kind"), "{err:#}");
1723
1724 let bad_version = "schema_version = 99\nkind = \"codewhale.portable-config\"\n";
1725 let err = parse_bundle_str(bad_version, "t.toml").expect_err("schema version must match");
1726 assert!(err.to_string().contains("schema_version"), "{err:#}");
1727 }
1728
1729 #[test]
1730 fn json_bundles_parse_when_the_document_is_json() {
1731 let json = r#"{"schema_version": 1, "kind": "codewhale.portable-config",
1732 "preferences": {"verbosity": "quiet"}}"#;
1733 let bundle = parse_bundle_str(json, "bundle.json").expect("json bundle");
1734 assert_eq!(bundle.preferences.entries.len(), 1);
1735 }
1736
1737 #[test]
1738 fn oversize_input_is_refused_before_parse() {
1739 let big = vec![b'#'; (MAX_BUNDLE_BYTES + 1) as usize];
1740 let err = parse_bundle_bytes(&big, "big.toml").expect_err("oversize must fail");
1741 assert!(err.to_string().contains("limit"), "{err:#}");
1742 }
1743
1744 #[test]
1745 fn credential_keys_are_rejected_by_name() {
1746 let text = r#"
1747 schema_version = 1
1748 kind = "codewhale.portable-config"
1749
1750 [global]
1751 api_key = "value-is-never-echoed"
1752
1753 [preferences]
1754 openai_api_key = "also-secret"
1755 "#;
1756 let bundle = parse_bundle_str(text, "t.toml").expect("parses");
1757 let rejected = find_rejected_entries(&bundle);
1758 assert_eq!(rejected.len(), 2, "{rejected:?}");
1759 assert!(rejected.iter().any(|r| r.key == "global.api_key"));
1760 assert!(
1761 rejected
1762 .iter()
1763 .all(|r| !r.reason.contains("value-is-never-echoed"))
1764 );
1765 }
1766
1767 #[test]
1768 fn credential_shaped_values_are_rejected_under_benign_names() {
1769 let text = r#"
1770 schema_version = 1
1771 kind = "codewhale.portable-config"
1772
1773 [preferences]
1774 note = "sk-abcdefghij0123456789"
1775 "#;
1776 let bundle = parse_bundle_str(text, "t.toml").expect("parses");
1777 let rejected = find_rejected_entries(&bundle);
1778 assert_eq!(rejected.len(), 1, "{rejected:?}");
1779 assert!(!rejected[0].reason.contains("sk-abcdefghij"));
1780 }
1781
1782 #[test]
1783 fn nested_secret_keys_and_values_are_rejected_without_echoing_values() {
1784 let shaped_value = ["Bear", "er nested-token-must-not-leak"].concat();
1785 let text = format!(
1786 r#"
1787 schema_version = 1
1788 kind = "codewhale.portable-config"
1789
1790 [preferences.with_key.nested]
1791 password = "nested-password-must-not-leak"
1792
1793 [preferences.with_value.nested]
1794 note = "{shaped_value}"
1795 "#
1796 );
1797 let bundle = parse_bundle_str(&text, "nested.toml").expect("bundle parses");
1798 let rejected = find_rejected_entries(&bundle);
1799 assert_eq!(rejected.len(), 2, "{rejected:?}");
1800 assert!(
1801 rejected
1802 .iter()
1803 .any(|entry| entry.key == "preferences.with_key")
1804 );
1805 assert!(
1806 rejected
1807 .iter()
1808 .any(|entry| entry.key == "preferences.with_value")
1809 );
1810 let rendered = format!("{rejected:?}");
1811 assert!(!rendered.contains("nested-password-must-not-leak"));
1812 assert!(!rendered.contains("nested-token-must-not-leak"));
1813 }
1814
1815 #[test]
1816 fn json_bundles_with_duplicate_keys_fail_before_parse() {
1817 let duplicate = r#"{"schema_version":1,"kind":"codewhale.portable-config","preferences":{"verbosity":"quiet","verbosity":"loud"}}"#;
1818 let error = parse_bundle_str(duplicate, "dup.json").expect_err("duplicate key must fail");
1819 let rendered = format!("{error:#}");
1820 assert!(rendered.contains("duplicate key"), "{rendered}");
1821 assert!(rendered.contains("preferences.verbosity"), "{rendered}");
1822 assert!(!rendered.contains("loud"), "{rendered}");
1823
1824 let nested_array = r#"{"schema_version":1,"kind":"codewhale.portable-config","preferences":{"list":[{"a":1,"a":2}]}}"#;
1825 let error = parse_bundle_str(nested_array, "dup-array.json")
1826 .expect_err("nested duplicate must fail");
1827 assert!(
1828 format!("{error:#}").contains("preferences.list.[0].a"),
1829 "{error:#}"
1830 );
1831
1832 let clean = r#"{"schema_version":1,"kind":"codewhale.portable-config","preferences":{"verbosity":"quiet","profiles":{"verbosity":"loud"}}}"#;
1833 parse_bundle_str(clean, "clean.json").expect("same key under different parents is fine");
1834 }
1835
1836 #[test]
1837 fn network_proxy_routes_are_rejected_on_import_and_scrubbed_on_export() {
1838 let proxy_url = ["http://proxy-user:proxy-", "pass@proxy.internal:3128"].concat();
1839 let text = format!(
1840 r#"
1841 schema_version = 1
1842 kind = "codewhale.portable-config"
1843
1844 [global.network]
1845 default = "prompt"
1846 allow = ["registry.example"]
1847 proxy = ["{proxy_url}"]
1848 "#
1849 );
1850 let bundle = parse_bundle_str(&text, "network-proxy.toml").expect("bundle parses");
1851 let rejected = find_rejected_entries(&bundle);
1852 assert_eq!(rejected.len(), 1, "{rejected:?}");
1853 assert_eq!(rejected[0].key, "global.network");
1854 let rendered = format!("{rejected:?}");
1855 assert!(!rendered.contains("proxy-pass"), "{rendered}");
1856 assert!(!rendered.contains("proxy.internal"), "{rendered}");
1857
1858 let config: ConfigToml = toml::from_str(&format!(
1859 r#"
1860 [network]
1861 default = "prompt"
1862 allow = ["registry.example"]
1863 proxy = ["{proxy_url}"]
1864 "#
1865 ))
1866 .expect("network config parses");
1867 let exported = export_bundle(&config, BundleScope::Global, BundleMetadata::default())
1868 .expect("network proxy is scrubbed");
1869 let body = serialize_bundle(&exported).expect("serialize network export");
1870 assert!(!body.contains("proxy-user"), "{body}");
1871 assert!(!body.contains("proxy.internal"), "{body}");
1872 let reparsed: toml::Value = toml::from_str(&body).expect("export reparses");
1873 let network = reparsed
1874 .get("global")
1875 .and_then(|global| global.get("network"))
1876 .expect("portable network policy is kept");
1877 assert!(network.get("proxy").is_none(), "{body}");
1878 assert_eq!(
1879 network.get("default").and_then(toml::Value::as_str),
1880 Some("prompt")
1881 );
1882 assert!(body.contains("registry.example"), "{body}");
1883 }
1884
1885 #[test]
1886 fn redaction_placeholders_are_rejected_on_import_and_export() {
1887 let placeholder = codewhale_config::persistence::REDACTED;
1888 let text = format!(
1889 r#"
1890 schema_version = 1
1891 kind = "codewhale.portable-config"
1892
1893 [preferences]
1894 verbosity = "quiet"
1895 note = "prefix {placeholder} suffix"
1896 "#
1897 );
1898 let bundle = parse_bundle_str(&text, "placeholder.toml").expect("bundle parses");
1899 let rejected = find_rejected_entries(&bundle);
1900 assert_eq!(rejected.len(), 1, "{rejected:?}");
1901 assert_eq!(rejected[0].key, "preferences.note");
1902 assert!(
1903 rejected[0].reason.contains("redaction placeholder"),
1904 "{rejected:?}"
1905 );
1906
1907 let config: ConfigToml = toml::from_str(&format!(
1908 "verbosity = \"quiet\"\nnote = \"prefix {placeholder} suffix\"\n"
1909 ))
1910 .expect("placeholder config parses");
1911 let exported = export_bundle(&config, BundleScope::Global, BundleMetadata::default())
1912 .expect("placeholder is scrubbed");
1913 let body = serialize_bundle(&exported).expect("serialize placeholder export");
1914 assert!(!body.contains(placeholder), "{body}");
1915 assert!(body.contains("quiet"), "{body}");
1916 }
1917
1918 #[test]
1919 fn camel_case_and_dotted_secret_keys_avoid_token_count_false_positives() {
1920 for key in [
1921 "accessToken",
1922 "refreshToken",
1923 "clientSecret",
1924 "apiKey",
1925 "api.key",
1926 "private.key",
1927 "accessKey",
1928 "aws_access_key",
1929 "awsSecretAccessKey",
1930 "Cookie",
1931 "Set-Cookie",
1932 "providers.xai.auth.command",
1933 "providers.xai.external_credentials",
1934 "providers.xai.oauth_credential_generation",
1935 "nested.service.accessToken",
1936 "nested.service.refreshToken",
1937 ] {
1938 assert!(is_sensitive_bundle_key(key), "must reject {key}");
1939 }
1940 for key in [
1941 "auth_mode",
1942 "maxTokens",
1943 "tokenizer",
1944 "tokenBudget",
1945 "max_tokens",
1946 ] {
1947 assert!(!is_sensitive_bundle_key(key), "must preserve {key}");
1948 }
1949 }
1950
1951 #[test]
1952 fn compound_and_access_keys_are_rejected_before_import_without_mutation() {
1953 let api_dot = ["api", ".key"].concat();
1954 let private_dot = ["private", ".key"].concat();
1955 let access_camel = ["access", "Key"].concat();
1956 let aws_snake = ["aws", "_access_key"].concat();
1957 let aws_camel = ["aws", "SecretAccessKey"].concat();
1958 let cookie = ["Coo", "kie"].concat();
1959 let set_cookie = ["Set-", "Cookie"].concat();
1960 let text = format!(
1961 r#"
1962 schema_version = 1
1963 kind = "codewhale.portable-config"
1964
1965 [preferences]
1966 "{api_dot}" = "opaque-api-value"
1967 "{private_dot}" = "opaque-private-value"
1968 {access_camel} = "opaque-access-value"
1969 {aws_snake} = "opaque-aws-access-value"
1970 {aws_camel} = "opaque-aws-secret-access-value"
1971 maxTokens = 8192
1972 tokenizer = "bpe"
1973
1974 [preferences.http_headers]
1975 {cookie} = "opaque-cookie-import-value"
1976 {set_cookie} = "opaque-set-cookie-import-value"
1977 "#
1978 );
1979 let bundle =
1980 parse_bundle_str(&text, "compound-secrets.toml").expect("compound-key bundle parses");
1981 let rejected = find_rejected_entries(&bundle);
1982 assert_eq!(rejected.len(), 6, "{rejected:?}");
1983 assert!(
1984 rejected
1985 .iter()
1986 .any(|entry| entry.key == "preferences.http_headers"),
1987 "{rejected:?}"
1988 );
1989 assert!(
1990 rejected
1991 .iter()
1992 .all(|entry| !entry.key.contains("maxTokens") && !entry.key.contains("tokenizer")),
1993 "{rejected:?}"
1994 );
1995
1996 let dir = tempfile::tempdir().expect("config dir");
1997 let path = dir.path().join("config.toml");
1998 std::fs::write(&path, "verbosity = \"quiet\"\n").expect("seed config");
1999 let before = std::fs::read(&path).expect("config before import");
2000 let mut store = ConfigStore::load(Some(path.clone())).expect("store loads");
2001 let error = apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path())
2002 .expect_err("credential keys must refuse the entire import");
2003 let rendered = format!("{error:#}");
2004 assert!(rendered.contains("conflicting or rejected"), "{rendered}");
2005 for secret in [
2006 "opaque-api-value",
2007 "opaque-private-value",
2008 "opaque-access-value",
2009 "opaque-aws-access-value",
2010 "opaque-aws-secret-access-value",
2011 "opaque-cookie-import-value",
2012 "opaque-set-cookie-import-value",
2013 ] {
2014 assert!(
2015 !rendered.contains(secret),
2016 "error leaked {secret}: {rendered}"
2017 );
2018 }
2019 assert_eq!(std::fs::read(path).expect("config after refusal"), before);
2020 assert_eq!(store.config.verbosity.as_deref(), Some("quiet"));
2021 }
2022
2023 #[test]
2024 fn plan_reports_added_changed_skipped_deterministically() {
2025 let store = isolated_store();
2026 let bundle_text = r#"
2027 schema_version = 1
2028 kind = "codewhale.portable-config"
2029
2030 [preferences]
2031 verbosity = "concise"
2032 log_level = "debug"
2033
2034 [global]
2035 telemetry = false
2036 "#;
2037 let bundle = parse_bundle_str(bundle_text, "t.toml").expect("bundle");
2038 // verbosity already matches; log_level is new; telemetry is global-scope.
2039 let plan_global = plan_import(&bundle, &store.config, BundleScope::Global);
2040 assert!(
2041 plan_global
2042 .added
2043 .contains(&"preferences.log_level".to_string())
2044 );
2045 // `verbosity` resolves to a shipped default even when the file key is
2046 // unset, so an equal value reads as changed-or-skipped by resolution;
2047 // what matters for determinism is that every entry lands in exactly
2048 // one bucket and nothing is dropped silently.
2049 let all: std::collections::BTreeSet<&String> = plan_global
2050 .added
2051 .iter()
2052 .chain(plan_global.changed.iter())
2053 .chain(plan_global.skipped.iter())
2054 .collect();
2055 assert_eq!(all.len(), 3, "{plan_global:?}");
2056 // Project scope skips global-section entries.
2057 let plan_project = plan_import(&bundle, &store.config, BundleScope::Project);
2058 assert!(
2059 plan_project
2060 .skipped
2061 .contains(&"global.telemetry".to_string())
2062 );
2063 }
2064
2065 #[test]
2066 fn rejected_entries_show_up_as_conflicting_in_the_plan() {
2067 let store = isolated_store();
2068 let text = r#"
2069 schema_version = 1
2070 kind = "codewhale.portable-config"
2071
2072 [global]
2073 api_key = "never-echoed"
2074 "#;
2075 let bundle = parse_bundle_str(text, "t.toml").expect("bundle");
2076 let plan = plan_import(&bundle, &store.config, BundleScope::Global);
2077 assert!(plan.conflicting.contains(&"global.api_key".to_string()));
2078 assert!(plan.added.is_empty());
2079 }
2080
2081 #[test]
2082 fn duplicate_flat_keys_across_applicable_sections_fail_before_apply() {
2083 let mut store = isolated_store();
2084 let before = std::fs::read(store.path()).expect("config before import");
2085 let text = r#"
2086 schema_version = 1
2087 kind = "codewhale.portable-config"
2088
2089 [preferences]
2090 verbosity = "concise"
2091
2092 [global]
2093 verbosity = "normal"
2094 "#;
2095 let bundle = parse_bundle_str(text, "collision.toml").expect("bundle parses");
2096 let plan = plan_import(&bundle, &store.config, BundleScope::Global);
2097
2098 assert_eq!(
2099 plan.conflicting,
2100 ["preferences.verbosity", "global.verbosity"]
2101 );
2102 assert!(plan.added.is_empty(), "{plan:?}");
2103 assert!(plan.changed.is_empty(), "{plan:?}");
2104 assert!(plan.skipped.is_empty(), "{plan:?}");
2105
2106 let workspace = tempfile::tempdir().expect("workspace");
2107 let error = apply_bundle(&bundle, &mut store, BundleScope::Global, workspace.path())
2108 .expect_err("ambiguous flat key must fail closed");
2109 let rendered = error.to_string();
2110 assert!(rendered.contains("conflicting"), "{error:#}");
2111 assert!(!rendered.contains("concise"), "{error:#}");
2112 assert!(!rendered.contains("normal"), "{error:#}");
2113 assert_eq!(
2114 std::fs::read(store.path()).expect("config after refused import"),
2115 before,
2116 "collision must be refused before any write"
2117 );
2118 }
2119
2120 #[test]
2121 fn dry_run_semantics_plan_never_mutates() {
2122 let store = isolated_store();
2123 let before = std::fs::read_to_string(store.path()).expect("read config");
2124 let bundle = sample_bundle();
2125 let _prepared = prepare_import(&bundle, &store, BundleScope::Global)
2126 .expect("prepare import without writing");
2127 let after = std::fs::read_to_string(store.path()).expect("read config");
2128 assert_eq!(before, after, "planning must not write");
2129 }
2130
2131 #[test]
2132 fn route_import_prepares_migration_once_and_overrides_a_masking_provider_slot() {
2133 use crate::tests::{ScopedEnvVar, env_lock};
2134 let _env = env_lock();
2135 let home = tempfile::tempdir().expect("isolated home");
2136 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.path().to_string_lossy());
2137 let _config_override = ScopedEnvVar::remove("CODEWHALE_CONFIG_PATH");
2138 let _legacy_override = ScopedEnvVar::remove("DEEPSEEK_CONFIG_PATH");
2139 let path = home.path().join("config.toml");
2140 let original = "provider = 'zai'\ndefault_text_model = 'GLM-5.3'\n[providers.zai]\nmodel = 'GLM-5.2'\n";
2141 std::fs::write(&path, original).expect("seed config");
2142 let settings_path = home.path().join("settings.toml");
2143 let old_settings = "default_provider = 'deepseek'\n[provider_models]\ndeepseek = 'deepseek-v4-pro'\nzai = 'GLM-5.4'\n";
2144 std::fs::write(&settings_path, old_settings).expect("seed legacy choices");
2145 let bundle = parse_bundle_str(
2146 "schema_version = 1\nkind = 'codewhale.portable-config'\n[global]\nprovider = 'zai'\ndefault_text_model = 'GLM-5.3'\n",
2147 "route.toml",
2148 ).expect("route bundle");
2149 let mut store = ConfigStore::load(Some(path.clone())).expect("store");
2150 let prepared = prepare_import(&bundle, &store, BundleScope::Global).expect("preview");
2151 assert!(
2152 prepared
2153 .plan
2154 .changed
2155 .iter()
2156 .any(|key| key == "global.default_text_model")
2157 );
2158 assert!(
2159 !prepared.plan.is_no_op(),
2160 "the old slot still masks the root value"
2161 );
2162 assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
2163 assert_eq!(
2164 std::fs::read_to_string(&settings_path).unwrap(),
2165 old_settings
2166 );
2167
2168 // Consent commits exactly the preview even if the archived input moves.
2169 let later_settings = "default_provider = 'openai'\n";
2170 std::fs::write(&settings_path, later_settings).unwrap();
2171 apply_prepared_bundle(prepared, &mut store, save_candidate).expect("commit preview");
2172 let saved: toml::Value = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2173 assert_eq!(saved["provider"].as_str(), Some("zai"));
2174 assert_eq!(saved["providers"]["zai"]["model"].as_str(), Some("GLM-5.3"));
2175 assert_eq!(
2176 saved["providers"]["deepseek"]["model"].as_str(),
2177 Some("deepseek-v4-pro")
2178 );
2179 assert_eq!(saved["route_preferences_version"].as_integer(), Some(1));
2180 assert_eq!(
2181 std::fs::read_to_string(settings_path).unwrap(),
2182 later_settings
2183 );
2184 assert_eq!(
2185 codewhale_tui::route_preferences::get(&path, "provider")
2186 .unwrap()
2187 .as_deref(),
2188 Some("zai")
2189 );
2190 assert_eq!(
2191 codewhale_tui::route_preferences::get(&path, "model")
2192 .unwrap()
2193 .as_deref(),
2194 Some("GLM-5.3")
2195 );
2196 let again = prepare_import(&bundle, &store, BundleScope::Global).expect("repeat preview");
2197 assert!(again.plan.is_no_op(), "{:?}", again.plan);
2198 }
2199
2200 #[test]
2201 fn conflicting_import_model_aliases_fail_before_any_write() {
2202 let dir = tempfile::tempdir().expect("config dir");
2203 let path = dir.path().join("config.toml");
2204 let original = "provider = 'zai'\n[providers.zai]\nmodel = 'GLM-5.2'\n";
2205 std::fs::write(&path, original).unwrap();
2206 let mut store = ConfigStore::load(Some(path.clone())).unwrap();
2207 let bundle = parse_bundle_str(
2208 "schema_version = 1\nkind = 'codewhale.portable-config'\n[global]\ndefault_text_model = 'GLM-5.3'\n[global.providers.zai]\nmodel = 'GLM-5.4'\n",
2209 "conflict.toml",
2210 ).unwrap();
2211 let prepared = prepare_import(&bundle, &store, BundleScope::Global).unwrap();
2212 assert!(
2213 prepared
2214 .plan
2215 .conflicting
2216 .iter()
2217 .any(|key| key == "global.providers.zai.model")
2218 );
2219 let error = apply_prepared_bundle(prepared, &mut store, save_candidate)
2220 .expect_err("conflicting aliases must be refused");
2221 assert!(error.to_string().contains("conflicting"));
2222 assert!(!error.to_string().contains("GLM-5.4"));
2223 assert_eq!(std::fs::read_to_string(path).unwrap(), original);
2224 assert_eq!(
2225 std::fs::read_dir(dir.path()).unwrap().count(),
2226 1,
2227 "no backup or staged write before validation"
2228 );
2229 }
2230
2231 #[test]
2232 fn prepared_import_keeps_configstore_cas_against_concurrent_edits() {
2233 let dir = tempfile::tempdir().expect("config dir");
2234 let path = dir.path().join("config.toml");
2235 std::fs::write(&path, "provider = 'deepseek'\n").unwrap();
2236 let mut store = ConfigStore::load(Some(path.clone())).unwrap();
2237 let prepared = prepare_import(&sample_bundle(), &store, BundleScope::Global).unwrap();
2238 let concurrent = "provider = 'openai'\n# concurrent writer\n";
2239 std::fs::write(&path, concurrent).unwrap();
2240 apply_prepared_bundle(prepared, &mut store, save_candidate)
2241 .expect_err("stale preview must fail closed");
2242 assert_eq!(std::fs::read_to_string(path).unwrap(), concurrent);
2243 }
2244
2245 #[test]
2246 fn route_migration_receipts_are_local_and_not_portable() {
2247 let bundle = parse_bundle_str(
2248 "schema_version = 1\nkind = 'codewhale.portable-config'\n[global]\nroute_preferences_version = 1\n[global.route_preferences_migration]\nprevious_provider = 'zai'\n",
2249 "receipt.toml",
2250 ).unwrap();
2251 assert_eq!(find_rejected_entries(&bundle).len(), 2);
2252 let config: ConfigToml = toml::from_str(
2253 "route_preferences_version = 1\n[route_preferences_migration]\nprevious_provider = 'zai'\n",
2254 ).unwrap();
2255 let exported =
2256 export_bundle(&config, BundleScope::Global, BundleMetadata::default()).unwrap();
2257 assert!(
2258 !exported
2259 .global
2260 .entries
2261 .contains_key("route_preferences_version")
2262 );
2263 assert!(
2264 !exported
2265 .global
2266 .entries
2267 .contains_key("route_preferences_migration")
2268 );
2269 }
2270
2271 #[test]
2272 fn canonical_route_export_omits_shadowed_roots_and_round_trips_provider_slots() {
2273 let config: ConfigToml = toml::from_str(
2274 "provider = 'zai'\ndefault_text_model = 'deepseek-v4-pro'\nmodel = 'old-root-model'\n[providers.zai]\nmodel = 'GLM-5.3'\n[providers.deepseek]\nmodel = 'deepseek-v4-flash'\n[providers.openai]\nmodel = 'gpt-4.1'\n",
2275 ).unwrap();
2276 let bundle =
2277 export_bundle(&config, BundleScope::Global, BundleMetadata::default()).unwrap();
2278 assert!(!bundle.global.entries.contains_key("model"));
2279 assert!(!bundle.global.entries.contains_key("default_text_model"));
2280 let dir = tempfile::tempdir().expect("config dir");
2281 let mut store = ConfigStore::load(Some(dir.path().join("config.toml"))).unwrap();
2282 let receipt = apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path())
2283 .expect("canonical export must import without alias conflicts");
2284 assert!(receipt.plan.conflicting.is_empty());
2285 assert_eq!(store.config.provider_id(), "zai");
2286 assert_eq!(
2287 store.config.get_value("providers.zai.model").as_deref(),
2288 Some("GLM-5.3")
2289 );
2290 assert_eq!(
2291 store
2292 .config
2293 .get_value("providers.deepseek.model")
2294 .as_deref(),
2295 Some("deepseek-v4-flash")
2296 );
2297 assert_eq!(
2298 store.config.get_value("providers.openai.model").as_deref(),
2299 Some("gpt-4.1")
2300 );
2301 let again = export_bundle(
2302 &store.config,
2303 BundleScope::Global,
2304 BundleMetadata::default(),
2305 )
2306 .unwrap();
2307 assert_eq!(
2308 serialize_bundle(&again).unwrap(),
2309 serialize_bundle(&bundle).unwrap()
2310 );
2311
2312 let root_only: ConfigToml =
2313 toml::from_str("default_text_model = 'deepseek-v4-pro'\n").unwrap();
2314 let legacy =
2315 export_bundle(&root_only, BundleScope::Global, BundleMetadata::default()).unwrap();
2316 assert_eq!(
2317 legacy
2318 .global
2319 .entries
2320 .get("default_text_model")
2321 .and_then(toml::Value::as_str),
2322 Some("deepseek-v4-pro")
2323 );
2324 let literal_custom = config_from_document(
2325 "provider = 'custom'\nbase_url = 'https://literal.example.test/v1'\ndefault_text_model = 'LiteralRootModel'\n",
2326 ).unwrap();
2327 let literal_export = export_bundle(
2328 &literal_custom,
2329 BundleScope::Global,
2330 BundleMetadata::default(),
2331 )
2332 .unwrap();
2333 // The literal route's top-level fields became `[providers.custom]`
2334 // when parsed (#6394); its model survives the export.
2335 assert!(
2336 serialize_bundle(&literal_export)
2337 .unwrap()
2338 .contains("LiteralRootModel"),
2339 "{literal_export:#?}"
2340 );
2341 }
2342
2343 #[test]
2344 fn export_reconciles_a_legacy_root_default_model_with_the_deepseek_slot() {
2345 // Migration writes the canonical slot but never removes a legacy root
2346 // `default_model`; on import that alias also targets the DeepSeek slot,
2347 // so a raw export would conflict with itself.
2348 let config: ConfigToml = toml::from_str(
2349 "provider = 'deepseek'\ndefault_model = 'deepseek-v4-flash'\n[providers.deepseek]\nmodel = 'deepseek-v4-pro'\n",
2350 )
2351 .unwrap();
2352 let bundle =
2353 export_bundle(&config, BundleScope::Global, BundleMetadata::default()).unwrap();
2354 assert!(!bundle.global.entries.contains_key("default_model"));
2355 let providers = bundle
2356 .global
2357 .entries
2358 .get("providers")
2359 .and_then(toml::Value::as_table)
2360 .expect("providers table");
2361 assert_eq!(
2362 providers["deepseek"]["model"].as_str(),
2363 Some("deepseek-v4-pro")
2364 );
2365
2366 let dir = tempfile::tempdir().expect("config dir");
2367 let mut store = ConfigStore::load(Some(dir.path().join("config.toml"))).unwrap();
2368 let receipt = apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path())
2369 .expect("export must import without alias conflicts");
2370 assert!(receipt.plan.conflicting.is_empty(), "{:?}", receipt.plan);
2371 assert_eq!(
2372 store
2373 .config
2374 .get_value("providers.deepseek.model")
2375 .as_deref(),
2376 Some("deepseek-v4-pro")
2377 );
2378
2379 // Without a canonical slot the root alias folds into the DeepSeek slot.
2380 let config: ConfigToml = toml::from_str(
2381 "provider = 'zai'\ndefault_model = 'deepseek-v4-flash'\n[providers.zai]\nmodel = 'GLM-5.3'\n",
2382 )
2383 .unwrap();
2384 let bundle =
2385 export_bundle(&config, BundleScope::Global, BundleMetadata::default()).unwrap();
2386 assert!(!bundle.global.entries.contains_key("default_model"));
2387 let providers = bundle
2388 .global
2389 .entries
2390 .get("providers")
2391 .and_then(toml::Value::as_table)
2392 .expect("providers table");
2393 assert_eq!(
2394 providers["deepseek"]["model"].as_str(),
2395 Some("deepseek-v4-flash")
2396 );
2397 assert_eq!(providers["zai"]["model"].as_str(), Some("GLM-5.3"));
2398 }
2399
2400 #[test]
2401 fn export_preserves_the_deepseek_root_fallback_when_another_route_is_active() {
2402 // With Z.ai active, a DeepSeek-id root `default_text_model` is
2403 // DeepSeek's saved fallback, not shadowed state; the active route's
2404 // canonical slot must not cause it to be dropped.
2405 let config: ConfigToml = toml::from_str(
2406 "provider = 'zai'\ndefault_text_model = 'deepseek-v4-flash'\n[providers.zai]\nmodel = 'GLM-5.3'\n",
2407 )
2408 .unwrap();
2409 let bundle =
2410 export_bundle(&config, BundleScope::Global, BundleMetadata::default()).unwrap();
2411 assert!(!bundle.global.entries.contains_key("default_text_model"));
2412 let providers = bundle
2413 .global
2414 .entries
2415 .get("providers")
2416 .and_then(toml::Value::as_table)
2417 .expect("providers table");
2418 assert_eq!(
2419 providers["deepseek"]["model"].as_str(),
2420 Some("deepseek-v4-flash")
2421 );
2422 assert_eq!(providers["zai"]["model"].as_str(), Some("GLM-5.3"));
2423
2424 let dir = tempfile::tempdir().expect("config dir");
2425 let mut store = ConfigStore::load(Some(dir.path().join("config.toml"))).unwrap();
2426 let receipt = apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path())
2427 .expect("export must import without alias conflicts");
2428 assert!(receipt.plan.conflicting.is_empty(), "{:?}", receipt.plan);
2429 assert_eq!(
2430 store
2431 .config
2432 .get_value("providers.deepseek.model")
2433 .as_deref(),
2434 Some("deepseek-v4-flash")
2435 );
2436
2437 // A root alias the active route still consumes stays shadowed state and
2438 // is dropped; a canonical DeepSeek leaf wins over a duplicate root
2439 // fallback.
2440 let config: ConfigToml = toml::from_str(
2441 "provider = 'zai'\ndefault_text_model = 'deepseek-v4-pro'\nmodel = 'GLM-5.1'\n[providers.zai]\nmodel = 'GLM-5.3'\n[providers.deepseek]\nmodel = 'deepseek-v4-flash'\n",
2442 )
2443 .unwrap();
2444 let bundle =
2445 export_bundle(&config, BundleScope::Global, BundleMetadata::default()).unwrap();
2446 assert!(!bundle.global.entries.contains_key("model"));
2447 assert!(!bundle.global.entries.contains_key("default_text_model"));
2448 let providers = bundle
2449 .global
2450 .entries
2451 .get("providers")
2452 .and_then(toml::Value::as_table)
2453 .expect("providers table");
2454 assert_eq!(
2455 providers["deepseek"]["model"].as_str(),
2456 Some("deepseek-v4-flash")
2457 );
2458 }
2459
2460 #[test]
2461 fn imports_preserve_exact_builtin_shadowing_custom_provider_identity() {
2462 let dir = tempfile::tempdir().expect("config dir");
2463 let path = dir.path().join("config.toml");
2464 std::fs::write(
2465 &path,
2466 "provider = 'OpenAI'\n[providers.OpenAI]\nkind = 'openai-compatible'\nbase_url = 'https://custom.example.test/v1'\nmodel = 'LiteralOldModel'\n[providers.openai]\nmodel = 'gpt-4.1'\n",
2467 ).unwrap();
2468 let mut store = ConfigStore::load(Some(path.clone())).unwrap();
2469 for (entries, expected_model) in [
2470 ("verbosity = 'concise'\n", "LiteralOldModel"),
2471 (
2472 "provider = 'OpenAI'\nmodel = 'LiteralNewModel'\nlog_level = 'debug'\n",
2473 "LiteralNewModel",
2474 ),
2475 ] {
2476 let bundle = parse_bundle_str(
2477 &format!(
2478 "schema_version = 1\nkind = 'codewhale.portable-config'\n[global]\n{entries}"
2479 ),
2480 "custom.toml",
2481 )
2482 .unwrap();
2483 apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path()).unwrap();
2484 let saved: toml::Value =
2485 toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2486 assert_eq!(saved["provider"].as_str(), Some("OpenAI"));
2487 assert_eq!(
2488 saved["providers"]["OpenAI"]["model"].as_str(),
2489 Some(expected_model)
2490 );
2491 assert_eq!(
2492 saved["providers"]["OpenAI"]["base_url"].as_str(),
2493 Some("https://custom.example.test/v1")
2494 );
2495 assert_eq!(
2496 saved["providers"]["openai"]["model"].as_str(),
2497 Some("gpt-4.1")
2498 );
2499 store.reload().unwrap();
2500 assert_eq!(
2501 store.config.provider,
2502 codewhale_config::ProviderKind::Custom
2503 );
2504 assert_eq!(store.config.provider_id(), "OpenAI");
2505 assert_eq!(
2506 codewhale_tui::route_preferences::get(&path, "provider")
2507 .unwrap()
2508 .as_deref(),
2509 Some("OpenAI")
2510 );
2511 }
2512 }
2513
2514 #[test]
2515 fn imports_preserve_regional_selector_and_canonical_model_slot() {
2516 let dir = tempfile::tempdir().expect("config dir");
2517 let path = dir.path().join("config.toml");
2518 std::fs::write(
2519 &path,
2520 "provider = 'deepseek-cn'\n[providers.deepseek_cn]\nmodel = 'deepseek-v4-pro'\n[providers.deepseek]\nmodel = 'deepseek-v4-pro'\n",
2521 ).unwrap();
2522 let mut store = ConfigStore::load(Some(path.clone())).unwrap();
2523 for (entries, expected_model) in [
2524 ("verbosity = 'concise'\n", "deepseek-v4-pro"),
2525 (
2526 "'providers.deepseek_cn.model' = 'deepseek-v4-flash'\n",
2527 "deepseek-v4-flash",
2528 ),
2529 ] {
2530 let bundle = parse_bundle_str(
2531 &format!(
2532 "schema_version = 1\nkind = 'codewhale.portable-config'\n[global]\n{entries}"
2533 ),
2534 "regional.toml",
2535 )
2536 .unwrap();
2537 apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path()).unwrap();
2538 let saved: toml::Value =
2539 toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2540 assert_eq!(saved["provider"].as_str(), Some("deepseek-cn"));
2541 assert_eq!(
2542 saved["providers"]["deepseek_cn"]["model"].as_str(),
2543 Some(expected_model)
2544 );
2545 assert_eq!(
2546 saved["providers"]["deepseek"]["model"].as_str(),
2547 Some("deepseek-v4-pro")
2548 );
2549 store.reload().unwrap();
2550 assert_eq!(store.config.provider_id(), "deepseek-cn");
2551 assert_eq!(
2552 codewhale_tui::route_preferences::get(&path, "model")
2553 .unwrap()
2554 .as_deref(),
2555 Some(expected_model)
2556 );
2557 let mut export_config = store.config.clone();
2558 export_config.default_text_model = Some("stale-regional-root".to_string());
2559 let exported = export_bundle(
2560 &export_config,
2561 BundleScope::Global,
2562 BundleMetadata::default(),
2563 )
2564 .unwrap();
2565 assert!(!exported.global.entries.contains_key("default_text_model"));
2566 assert_eq!(
2567 exported
2568 .global
2569 .entries
2570 .get("provider")
2571 .and_then(toml::Value::as_str),
2572 Some("deepseek-cn")
2573 );
2574 }
2575 }
2576
2577 #[test]
2578 fn apply_is_idempotent_on_reimport() {
2579 let mut store = isolated_store();
2580 let workspace = tempfile::tempdir().expect("workspace");
2581 let bundle = sample_bundle();
2582
2583 let first = apply_bundle(&bundle, &mut store, BundleScope::Global, workspace.path())
2584 .expect("first import");
2585 assert!(first.plan.added.len() + first.plan.changed.len() > 0);
2586
2587 let second = apply_bundle(&bundle, &mut store, BundleScope::Global, workspace.path())
2588 .expect("second import");
2589 assert!(
2590 second.plan.is_no_op(),
2591 "re-import must be a no-op: {:?}",
2592 second.plan
2593 );
2594 assert!(second.backup_path.is_none());
2595 }
2596
2597 #[test]
2598 fn immediate_mutating_imports_create_distinct_no_clobber_backups() {
2599 let dir = tempfile::tempdir().expect("config dir");
2600 let path = dir.path().join("config.toml");
2601 let original = b"verbosity = \"concise\"\n";
2602 std::fs::write(&path, original).expect("seed config");
2603 #[cfg(unix)]
2604 {
2605 use std::os::unix::fs::PermissionsExt as _;
2606 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
2607 .expect("restrict target permissions");
2608 }
2609 let mut store = ConfigStore::load(Some(path.clone())).expect("store loads");
2610 let first_bundle = parse_bundle_str(
2611 r#"
2612 schema_version = 1
2613 kind = "codewhale.portable-config"
2614
2615 [global]
2616 verbosity = "normal"
2617 "#,
2618 "first.toml",
2619 )
2620 .expect("first bundle parses");
2621 let first = apply_bundle(&first_bundle, &mut store, BundleScope::Global, dir.path())
2622 .expect("first import");
2623 let first_backup = first.backup_path.expect("first backup receipt");
2624 assert_eq!(
2625 std::fs::read(&first_backup).expect("first backup"),
2626 original
2627 );
2628 let after_first = std::fs::read(&path).expect("target after first import");
2629
2630 let second_bundle = parse_bundle_str(
2631 r#"
2632 schema_version = 1
2633 kind = "codewhale.portable-config"
2634
2635 [global]
2636 log_level = "trace"
2637 "#,
2638 "second.toml",
2639 )
2640 .expect("second bundle parses");
2641 let second = apply_bundle(&second_bundle, &mut store, BundleScope::Global, dir.path())
2642 .expect("second import");
2643 let second_backup = second.backup_path.expect("second backup receipt");
2644 assert_ne!(first_backup, second_backup, "backups must never collide");
2645 assert_eq!(
2646 std::fs::read(&second_backup).expect("second backup"),
2647 after_first,
2648 "second receipt must preserve its exact pre-import document"
2649 );
2650 assert_eq!(
2651 std::fs::read(&first_backup).expect("first backup remains"),
2652 original,
2653 "second import must not overwrite the first receipt"
2654 );
2655 for backup in [&first_backup, &second_backup] {
2656 assert!(
2657 backup
2658 .file_name()
2659 .and_then(|name| name.to_str())
2660 .is_some_and(|name| name.contains(".bundle-backup-")),
2661 "unexpected backup name: {}",
2662 backup.display()
2663 );
2664 #[cfg(unix)]
2665 {
2666 use std::os::unix::fs::PermissionsExt as _;
2667 assert_eq!(
2668 std::fs::metadata(backup)
2669 .expect("backup metadata")
2670 .permissions()
2671 .mode()
2672 & 0o777,
2673 0o600,
2674 "backup must preserve restrictive target permissions"
2675 );
2676 }
2677 }
2678 }
2679
2680 #[test]
2681 fn closed_choice_imports_refuse_before_backup_or_write() {
2682 for key in ["approval_policy", "sandbox_mode", "verbosity"] {
2683 for value in [
2684 toml::Value::String("misspelled-choice".into()),
2685 toml::Value::Boolean(true),
2686 ] {
2687 for existing in [false, true] {
2688 let dir = tempfile::tempdir().unwrap();
2689 let path = dir.path().join("config.toml");
2690 let original = b"# Preserve this document exactly\nverbosity = 'normal'\n";
2691 if existing {
2692 std::fs::write(&path, original).unwrap();
2693 }
2694 let mut store = ConfigStore::load(Some(path.clone())).unwrap();
2695 let before = toml::to_string(&store.config).unwrap();
2696 let mut bundle = sample_bundle();
2697 bundle.preferences.entries.clear();
2698 bundle.global.entries.clear();
2699 bundle.preferences.entries.insert(key.into(), value.clone());
2700 // Even an earlier valid candidate edit must not reach the store.
2701 bundle
2702 .preferences
2703 .entries
2704 .insert("log_level".into(), toml::Value::String("info".into()));
2705 let error = apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path())
2706 .expect_err("closed choice must fail before the transaction");
2707 assert!(error.to_string().contains(key), "{error:#}");
2708 assert_eq!(toml::to_string(&store.config).unwrap(), before);
2709 assert_eq!(path.exists(), existing);
2710 if existing {
2711 assert_eq!(std::fs::read(&path).unwrap(), original);
2712 }
2713 assert!(
2714 std::fs::read_dir(dir.path())
2715 .unwrap()
2716 .all(|entry| { entry.unwrap().path() == path }),
2717 "refusal must not create a backup or another file"
2718 );
2719 }
2720 }
2721 }
2722 }
2723
2724 #[test]
2725 fn closed_choice_imports_accept_reader_values_without_revalidating_old_fields() {
2726 // Trust posture is intentionally machine-bound and rejected by the
2727 // bundle boundary even when its value is otherwise valid. Exercise
2728 // the portable closed choice here; authority rejection has its own
2729 // no-write and export-scrubbing regressions below.
2730 for value in [" CONCISE ", "normal"] {
2731 let key = "verbosity";
2732 let dir = tempfile::tempdir().unwrap();
2733 let path = dir.path().join("config.toml");
2734 // Loading and unrelated round trips still preserve legacy text; only
2735 // the value being written gets the shared closed-choice validation.
2736 let original =
2737 "# Preserve old fields\napproval_policy = 'legacy-unknown'\nverbosity = 'quiet'\n";
2738 std::fs::write(&path, original).unwrap();
2739 let mut store = ConfigStore::load(Some(path.clone())).unwrap();
2740 assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
2741 let mut bundle = sample_bundle();
2742 bundle.preferences.entries.clear();
2743 bundle.global.entries.clear();
2744 bundle
2745 .preferences
2746 .entries
2747 .insert(key.into(), toml::Value::String(value.into()));
2748 let receipt =
2749 apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path()).unwrap();
2750 assert_eq!(
2751 std::fs::read_to_string(receipt.backup_path.unwrap()).unwrap(),
2752 original
2753 );
2754 let reloaded = ConfigStore::load(Some(path)).unwrap();
2755 assert_eq!(reloaded.config.get_value(key).as_deref(), Some(value));
2756 assert_eq!(
2757 reloaded.config.approval_policy.as_deref(),
2758 Some("legacy-unknown")
2759 );
2760 }
2761 }
2762
2763 #[test]
2764 fn non_no_op_import_creates_a_missing_config_without_a_backup() {
2765 let dir = tempfile::tempdir().expect("config dir");
2766 let path = dir.path().join("config.toml");
2767 let mut store = ConfigStore::load(Some(path.clone())).expect("missing config loads");
2768 assert!(!path.exists(), "load must not create the config");
2769
2770 let receipt = apply_bundle(
2771 &sample_bundle(),
2772 &mut store,
2773 BundleScope::Global,
2774 dir.path(),
2775 )
2776 .expect("import creates config");
2777
2778 assert!(path.is_file(), "non-no-op import must create the config");
2779 assert!(
2780 receipt.backup_path.is_none(),
2781 "no prior file means no backup"
2782 );
2783 let reloaded = ConfigStore::load(Some(path)).expect("created config reloads");
2784 assert_eq!(reloaded.config.verbosity.as_deref(), Some("concise"));
2785 assert_eq!(reloaded.config.log_level.as_deref(), Some("debug"));
2786 }
2787
2788 #[test]
2789 fn failed_import_removes_a_config_created_during_the_transaction() {
2790 let dir = tempfile::tempdir().expect("config dir");
2791 let path = dir.path().join("config.toml");
2792 let mut store = ConfigStore::load(Some(path.clone())).expect("missing config loads");
2793
2794 let prepared =
2795 prepare_import(&sample_bundle(), &store, BundleScope::Global).expect("prepare import");
2796 let error =
2797 apply_prepared_bundle(prepared, &mut store, |candidate, store, target_written| {
2798 save_candidate(candidate, store, target_written)?;
2799 bail!("forced failure after the new document was saved")
2800 })
2801 .expect_err("forced post-save failure must roll back");
2802
2803 assert!(error.to_string().contains("rolled back"), "{error:#}");
2804 assert!(
2805 !path.exists(),
2806 "rollback must remove the newly-created file"
2807 );
2808 assert_eq!(
2809 store.config.verbosity, None,
2810 "in-memory state also rolls back"
2811 );
2812 }
2813
2814 #[test]
2815 fn project_scope_never_touches_the_global_document() {
2816 let mut store = isolated_store();
2817 let global_before = std::fs::read_to_string(store.path()).expect("global doc");
2818
2819 let text = r#"
2820 schema_version = 1
2821 kind = "codewhale.portable-config"
2822
2823 [project]
2824 verbosity = "quiet"
2825 "#;
2826 let bundle = parse_bundle_str(text, "t.toml").expect("bundle");
2827 assert!(find_rejected_entries(&bundle).is_empty());
2828 let ws = tempfile::tempdir().expect("ws");
2829 apply_bundle(&bundle, &mut store, BundleScope::Project, ws.path())
2830 .expect_err("project entries cannot land in a global-scoped store");
2831 let global_after = std::fs::read_to_string(store.path()).expect("global doc");
2832 assert_eq!(global_before, global_after);
2833 }
2834
2835 #[cfg(unix)]
2836 #[test]
2837 fn failed_apply_rolls_back_to_the_prior_document() {
2838 let mut store = isolated_store();
2839 let original = std::fs::read_to_string(store.path()).expect("config");
2840
2841 // A bundle whose entry fails mid-apply: `providers.deepseek.wire` is a
2842 // real key path but an invalid value for it, so set_value errors after
2843 // earlier entries were applied.
2844 let text = r#"
2845 schema_version = 1
2846 kind = "codewhale.portable-config"
2847
2848 [preferences]
2849 log_level = "debug"
2850
2851 [global]
2852 providers_deepseek_wire = "not-a-real-key-so-this-errors"
2853 "#;
2854 let _ = text;
2855 // Simpler deterministic failure: make the target file read-only.
2856 let text_ok = r#"
2857 schema_version = 1
2858 kind = "codewhale.portable-config"
2859
2860 [preferences]
2861 log_level = "debug"
2862 "#;
2863 let bundle = parse_bundle_str(text_ok, "t.toml").expect("bundle");
2864 let path = store.path().to_path_buf();
2865 // Atomic saves replace the file via rename, so the *directory* must
2866 // be made unwritable to force the write failure.
2867 use std::os::unix::fs::PermissionsExt;
2868 let dir = path.parent().expect("config dir").to_path_buf();
2869 let mut perms = std::fs::metadata(&dir).expect("dir meta").permissions();
2870 perms.set_mode(0o555);
2871 std::fs::set_permissions(&dir, perms).expect("chmod dir");
2872
2873 let result = apply_bundle(&bundle, &mut store, BundleScope::Global, Path::new("."));
2874 // Restore permissions so the tempdir can be cleaned up.
2875 let mut perms = std::fs::metadata(&dir).expect("dir meta").permissions();
2876 perms.set_mode(0o755);
2877 std::fs::set_permissions(&dir, perms).expect("chmod restore");
2878
2879 assert!(result.is_err(), "apply must fail on a read-only document");
2880 let restored = std::fs::read_to_string(&path).expect("config after rollback");
2881 assert_eq!(restored, original, "rollback must preserve the prior bytes");
2882 }
2883
2884 #[test]
2885 fn export_is_deterministic_and_secret_free() {
2886 let mut store = isolated_store();
2887 store
2888 .config
2889 .set_value("verbosity", "concise")
2890 .expect("set verbosity");
2891 store
2892 .config
2893 .set_value("default_text_model", "deepseek-v4-pro")
2894 .expect("set model");
2895 store.save().expect("save");
2896
2897 let metadata = BundleMetadata::default();
2898 let one = export_bundle(&store.config, BundleScope::Global, metadata.clone())
2899 .and_then(|b| serialize_bundle(&b))
2900 .expect("export one");
2901 let two = export_bundle(&store.config, BundleScope::Global, metadata)
2902 .and_then(|b| serialize_bundle(&b))
2903 .expect("export two");
2904 assert_eq!(one, two, "export must be deterministic");
2905
2906 // No machine-specific absolute paths in the body.
2907 assert!(!one.contains("/Users/"), "{one}");
2908 assert!(!one.contains("/home/"), "{one}");
2909 }
2910
2911 #[test]
2912 fn export_preserves_typed_structured_config_and_toml_value_kinds() {
2913 let config: ConfigToml = toml::from_str(
2914 r#"
2915 provider = "deepseek"
2916 telemetry = false
2917 retry_count = 3
2918 ratio = 1.25
2919 started_at = 1979-05-27T07:32:00Z
2920 labels = ["alpha", "beta"]
2921
2922 [skills]
2923 registry_url = "https://registry.example/skills.json"
2924 max_install_size_bytes = 12345
2925
2926 [snapshots]
2927 enabled = false
2928 max_age_days = 11
2929
2930 [portable_table]
2931 enabled = true
2932 count = 4
2933 "#,
2934 )
2935 .expect("typed config parses");
2936
2937 let bundle = export_bundle(&config, BundleScope::Global, BundleMetadata::default())
2938 .expect("typed export");
2939 assert!(matches!(
2940 bundle.preferences.entries.get("skills"),
2941 Some(toml::Value::Table(_))
2942 ));
2943 assert!(matches!(
2944 bundle.preferences.entries.get("snapshots"),
2945 Some(toml::Value::Table(_))
2946 ));
2947 assert!(matches!(
2948 bundle.global.entries.get("telemetry"),
2949 Some(toml::Value::Boolean(false))
2950 ));
2951 assert!(matches!(
2952 bundle.global.entries.get("retry_count"),
2953 Some(toml::Value::Integer(3))
2954 ));
2955 assert!(matches!(
2956 bundle.global.entries.get("ratio"),
2957 Some(toml::Value::Float(value)) if *value == 1.25
2958 ));
2959 assert!(
2960 matches!(
2961 bundle.global.entries.get("started_at"),
2962 Some(toml::Value::Datetime(_))
2963 ),
2964 "{bundle:#?}"
2965 );
2966
2967 let dir = tempfile::tempdir().expect("round-trip dir");
2968 let path = dir.path().join("config.toml");
2969 let mut store = ConfigStore::load(Some(path.clone())).expect("fresh store");
2970 apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path())
2971 .expect("typed bundle imports");
2972 let reloaded = ConfigStore::load(Some(path)).expect("typed config reloads");
2973 let reexported = export_bundle(
2974 &reloaded.config,
2975 BundleScope::Global,
2976 BundleMetadata::default(),
2977 )
2978 .expect("round-trip export");
2979 assert_eq!(
2980 serialize_bundle(&reexported).expect("serialize round trip"),
2981 serialize_bundle(&bundle).expect("serialize original"),
2982 "typed portable config must round-trip without stringification or loss"
2983 );
2984 let plan = plan_import(&bundle, &reloaded.config, BundleScope::Global);
2985 assert!(
2986 plan.is_no_op(),
2987 "typed re-import must be idempotent: {plan:?}"
2988 );
2989 }
2990
2991 #[test]
2992 fn typed_reimport_normalizes_omitted_serde_defaults_before_comparison() {
2993 let bundle = parse_bundle_str(
2994 r#"
2995 schema_version = 1
2996 kind = "codewhale.portable-config"
2997
2998 [preferences.snapshots]
2999 enabled = false
3000 "#,
3001 "defaults.toml",
3002 )
3003 .expect("bundle with omitted typed default");
3004 let dir = tempfile::tempdir().expect("config dir");
3005 let path = dir.path().join("config.toml");
3006 let mut store = ConfigStore::load(Some(path)).expect("fresh store");
3007 apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path())
3008 .expect("first typed import");
3009
3010 assert_eq!(
3011 store
3012 .config
3013 .snapshots
3014 .as_ref()
3015 .expect("snapshots configured")
3016 .max_age_days,
3017 7,
3018 "serde default must be materialized"
3019 );
3020 let plan = plan_import(&bundle, &store.config, BundleScope::Global);
3021 assert!(
3022 plan.is_no_op(),
3023 "normalized re-import must be a no-op: {plan:?}"
3024 );
3025 }
3026
3027 #[test]
3028 fn telemetry_opt_out_round_trips_but_opt_in_consent_never_does() {
3029 let opted_in = ConfigToml {
3030 telemetry: Some(true),
3031 ..ConfigToml::default()
3032 };
3033 let exported = export_bundle(&opted_in, BundleScope::Global, BundleMetadata::default())
3034 .expect("opt-in export is safely omitted");
3035 assert!(
3036 !exported.global.entries.contains_key("telemetry"),
3037 "opt-in consent must not be portable: {exported:?}"
3038 );
3039
3040 let opt_in_bundle = parse_bundle_str(
3041 r#"
3042 schema_version = 1
3043 kind = "codewhale.portable-config"
3044
3045 [global]
3046 telemetry = true
3047 "#,
3048 "telemetry-opt-in.toml",
3049 )
3050 .expect("opt-in bundle parses before policy validation");
3051 let rejected = find_rejected_entries(&opt_in_bundle);
3052 assert_eq!(rejected.len(), 1, "{rejected:?}");
3053 assert!(
3054 rejected[0].reason.contains("opt-in consent"),
3055 "{rejected:?}"
3056 );
3057
3058 let dir = tempfile::tempdir().expect("config dir");
3059 let path = dir.path().join("config.toml");
3060 std::fs::write(&path, "verbosity = \"concise\"\n").expect("seed config");
3061 let before = std::fs::read(&path).expect("config before refusal");
3062 let mut store = ConfigStore::load(Some(path.clone())).expect("store loads");
3063 apply_bundle(&opt_in_bundle, &mut store, BundleScope::Global, dir.path())
3064 .expect_err("portable opt-in consent must be refused");
3065 assert_eq!(std::fs::read(&path).expect("config after refusal"), before);
3066 assert_eq!(store.config.telemetry, None);
3067
3068 let opt_out_bundle = parse_bundle_str(
3069 r#"
3070 schema_version = 1
3071 kind = "codewhale.portable-config"
3072
3073 [global]
3074 telemetry = false
3075 "#,
3076 "telemetry-opt-out.toml",
3077 )
3078 .expect("opt-out bundle parses");
3079 assert!(find_rejected_entries(&opt_out_bundle).is_empty());
3080 apply_bundle(&opt_out_bundle, &mut store, BundleScope::Global, dir.path())
3081 .expect("portable opt-out applies");
3082 assert_eq!(store.config.telemetry, Some(false));
3083 let reloaded = ConfigStore::load(Some(path)).expect("opt-out config reloads");
3084 let plan = plan_import(&opt_out_bundle, &reloaded.config, BundleScope::Global);
3085 assert!(
3086 plan.is_no_op(),
3087 "opt-out re-import must be idempotent: {plan:?}"
3088 );
3089 let reexported = export_bundle(
3090 &reloaded.config,
3091 BundleScope::Global,
3092 BundleMetadata::default(),
3093 )
3094 .expect("opt-out re-exports");
3095 assert_eq!(
3096 reexported.global.entries.get("telemetry"),
3097 Some(&toml::Value::Boolean(false))
3098 );
3099 }
3100
3101 #[test]
3102 fn structured_import_deep_merges_without_erasing_local_authority() {
3103 let dir = tempfile::tempdir().expect("config dir");
3104 let path = dir.path().join("config.toml");
3105 let api_key_name = ["api", "_key"].concat();
3106 let api_key_env_name = ["api", "_key_env"].concat();
3107 let target = format!(
3108 r#"
3109 provider = "acme_gateway"
3110
3111 [providers.acme_gateway]
3112 kind = "openai-compatible"
3113 base_url = "https://local-only.invalid/v1"
3114 model = "old-model"
3115 {api_key_name} = "opaque-local-api-value"
3116 {api_key_env_name} = "LOCAL_ACME_GATEWAY_KEY"
3117
3118 [providers.acme_gateway.auth]
3119 source = "command"
3120 command = ["/synthetic/local-credential-helper"]
3121
3122 [lsp]
3123 enabled = true
3124 include_warnings = false
3125
3126 [lsp.servers]
3127 rust = ["/synthetic/local-rust-analyzer", "--stdio"]
3128
3129 [lsp.custom.foo]
3130 language_id = "foo-language"
3131 command = "/synthetic/local-foo-lsp"
3132 args = ["--stdio"]
3133
3134 [hook_sinks]
3135 unix_socket_path = "/synthetic/local-codewhale.sock"
3136 "#
3137 );
3138 std::fs::write(&path, target).expect("seed local-authority config");
3139 let mut store = ConfigStore::load(Some(path.clone())).expect("target config loads");
3140 let bundle = parse_bundle_str(
3141 r#"
3142 schema_version = 1
3143 kind = "codewhale.portable-config"
3144
3145 [global]
3146 provider = "acme_gateway"
3147
3148 [global.providers.acme_gateway]
3149 kind = "openai-compatible"
3150 model = "new-portable-model"
3151
3152 [global.lsp]
3153 enabled = false
3154 include_warnings = true
3155 "#,
3156 "deep-merge.toml",
3157 )
3158 .expect("portable update parses");
3159 assert!(find_rejected_entries(&bundle).is_empty(), "{bundle:?}");
3160
3161 let receipt = apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path())
3162 .expect("portable values merge into target");
3163 assert_eq!(
3164 receipt.plan.changed,
3165 ["global.lsp", "global.providers"],
3166 "{:?}",
3167 receipt.plan
3168 );
3169 assert_eq!(
3170 receipt.plan.skipped,
3171 ["global.provider"],
3172 "{:?}",
3173 receipt.plan
3174 );
3175 assert_eq!(store.config.provider_id(), "acme_gateway");
3176 let document = config_document(&store.config).expect("merged typed document");
3177 let acme = document
3178 .get("providers")
3179 .and_then(toml::Value::as_table)
3180 .and_then(|providers| providers.get("acme_gateway"))
3181 .and_then(toml::Value::as_table)
3182 .expect("custom provider survives");
3183 assert_eq!(
3184 acme.get("model").and_then(toml::Value::as_str),
3185 Some("new-portable-model")
3186 );
3187 assert_eq!(
3188 acme.get("base_url").and_then(toml::Value::as_str),
3189 Some("https://local-only.invalid/v1")
3190 );
3191 assert_eq!(
3192 acme.get("api_key").and_then(toml::Value::as_str),
3193 Some("opaque-local-api-value")
3194 );
3195 assert_eq!(
3196 acme.get("api_key_env").and_then(toml::Value::as_str),
3197 Some("LOCAL_ACME_GATEWAY_KEY")
3198 );
3199 assert_eq!(
3200 acme.get("auth")
3201 .and_then(toml::Value::as_table)
3202 .and_then(|auth| auth.get("command"))
3203 .and_then(toml::Value::as_array)
3204 .and_then(|command| command.first())
3205 .and_then(toml::Value::as_str),
3206 Some("/synthetic/local-credential-helper")
3207 );
3208 let lsp = store.config.lsp.as_ref().expect("LSP config survives");
3209 assert_eq!(lsp.enabled, Some(false));
3210 assert_eq!(lsp.include_warnings, Some(true));
3211 assert!(lsp.servers.as_ref().is_some_and(|servers| {
3212 servers
3213 .get("rust")
3214 .is_some_and(|command| command.first().is_some_and(|part| part.contains("rust")))
3215 }));
3216 assert!(
3217 lsp.custom
3218 .as_ref()
3219 .is_some_and(|custom| custom.contains_key("foo"))
3220 );
3221 assert_eq!(
3222 store
3223 .config
3224 .hook_sinks
3225 .as_ref()
3226 .and_then(|sinks| sinks.unix_socket_path.as_deref()),
3227 Some(Path::new("/synthetic/local-codewhale.sock"))
3228 );
3229
3230 let reloaded = ConfigStore::load(Some(path)).expect("merged config reloads");
3231 assert_eq!(reloaded.config.provider_id(), "acme_gateway");
3232 let plan = plan_import(&bundle, &reloaded.config, BundleScope::Global);
3233 assert!(
3234 plan.is_no_op(),
3235 "deep-merged re-import must be idempotent: {plan:?}"
3236 );
3237 }
3238
3239 #[test]
3240 fn export_recursively_drops_nested_secrets_but_keeps_safe_typed_siblings() {
3241 let provider_prefix = ["s", "k-"].concat();
3242 let bearer_prefix = ["Bear", "er "].concat();
3243 let access_key = ["access", "Token"].concat();
3244 let dotted_refresh_key = ["service.refresh", "Token"].concat();
3245 let refresh_key = ["refresh", "Token"].concat();
3246 let fixture = format!(
3247 r#"
3248 [tools]
3249 always_load = ["read_file", "{provider_prefix}nested-tool-value-must-not-leak", "write_file"]
3250
3251 [portable]
3252 safe_count = 7
3253 note = "{bearer_prefix}nested-export-value-must-not-leak"
3254 values = ["plain", "{provider_prefix}nested-array-value-must-not-leak"]
3255
3256 [portable.nested]
3257 {access_key} = "nested-export-key-must-not-leak"
3258 "{dotted_refresh_key}" = "nested-dotted-value-must-not-leak"
3259 label = "keep-me"
3260
3261 [[portable.records]]
3262 {refresh_key} = "nested-record-value-must-not-leak"
3263 count = 2
3264
3265 [[portable.records]]
3266 label = "safe-record"
3267 "#
3268 );
3269 let config: ConfigToml = toml::from_str(&fixture).expect("secret-bearing config parses");
3270 let bundle = export_bundle(&config, BundleScope::Global, BundleMetadata::default())
3271 .expect("safe export");
3272 let body = serialize_bundle(&bundle).expect("serialize export");
3273 for secret in [
3274 "nested-export-value-must-not-leak",
3275 "nested-array-value-must-not-leak",
3276 "nested-tool-value-must-not-leak",
3277 "nested-export-key-must-not-leak",
3278 "nested-dotted-value-must-not-leak",
3279 "nested-record-value-must-not-leak",
3280 ] {
3281 assert!(!body.contains(secret), "export leaked {secret}: {body}");
3282 }
3283 assert!(body.contains("safe_count = 7"), "{body}");
3284 assert!(body.contains("label = \"keep-me\""), "{body}");
3285 assert!(body.contains("label = \"safe-record\""), "{body}");
3286 assert!(body.contains("values = [\"plain\"]"), "{body}");
3287 assert!(find_rejected_entries(&bundle).is_empty(), "{bundle:?}");
3288
3289 let dir = tempfile::tempdir().expect("sanitized import dir");
3290 let path = dir.path().join("config.toml");
3291 let mut store = ConfigStore::load(Some(path)).expect("fresh store");
3292 apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path())
3293 .expect("sanitized typed arrays re-import");
3294 assert_eq!(
3295 store
3296 .config
3297 .tools
3298 .as_ref()
3299 .expect("tools preserved")
3300 .always_load
3301 .as_slice(),
3302 ["read_file", "write_file"],
3303 "dropping a secret array element must preserve a valid typed array"
3304 );
3305 let plan = plan_import(&bundle, &store.config, BundleScope::Global);
3306 assert!(
3307 plan.is_no_op(),
3308 "sanitized re-import must be idempotent: {plan:?}"
3309 );
3310 }
3311
3312 #[test]
3313 fn compound_access_and_cookie_fields_are_scrubbed_on_export() {
3314 let cookie = ["Coo", "kie"].concat();
3315 let set_cookie = ["Set-", "Cookie"].concat();
3316 let api_dot = ["api", ".key"].concat();
3317 let private_dot = ["private", ".key"].concat();
3318 let access_camel = ["access", "Key"].concat();
3319 let aws_snake = ["aws", "_access_key"].concat();
3320 let aws_camel = ["aws", "SecretAccessKey"].concat();
3321 let fixture = format!(
3322 r#"
3323 [http_headers]
3324 {cookie} = "opaque-cookie-value"
3325 {set_cookie} = "opaque-set-cookie-value"
3326 X-Safe = "portable-header"
3327
3328 [portable]
3329 "{api_dot}" = "opaque-api-value"
3330 "{private_dot}" = "opaque-private-value"
3331 {access_camel} = "opaque-access-value"
3332 {aws_snake} = "opaque-aws-access-value"
3333 {aws_camel} = "opaque-aws-secret-access-value"
3334 maxTokens = 8192
3335 tokenizer = "bpe"
3336 "#
3337 );
3338 let config: ConfigToml = toml::from_str(&fixture).expect("credential-key config parses");
3339 let bundle = export_bundle(&config, BundleScope::Global, BundleMetadata::default())
3340 .expect("credential fields are scrubbed");
3341 let body = serialize_bundle(&bundle).expect("serialize scrubbed export");
3342 for forbidden in [
3343 "opaque-cookie-value",
3344 "opaque-set-cookie-value",
3345 "opaque-api-value",
3346 "opaque-private-value",
3347 "opaque-access-value",
3348 "opaque-aws-access-value",
3349 "opaque-aws-secret-access-value",
3350 "api.key",
3351 "private.key",
3352 "accessKey",
3353 "aws_access_key",
3354 "awsSecretAccessKey",
3355 "Cookie",
3356 "Set-Cookie",
3357 ] {
3358 assert!(
3359 !body.contains(forbidden),
3360 "export retained {forbidden}: {body}"
3361 );
3362 }
3363 assert!(body.contains("X-Safe = \"portable-header\""), "{body}");
3364 assert!(body.contains("maxTokens = 8192"), "{body}");
3365 assert!(body.contains("tokenizer = \"bpe\""), "{body}");
3366 assert!(find_rejected_entries(&bundle).is_empty(), "{bundle:?}");
3367 }
3368
3369 #[test]
3370 fn provider_credential_authority_is_rejected_on_import_and_scrubbed_on_export() {
3371 let dir = tempfile::tempdir().expect("config dir");
3372 let path = dir.path().join("config.toml");
3373 std::fs::write(&path, "verbosity = \"quiet\"\n").expect("seed config");
3374 let before = std::fs::read(&path).expect("config before imports");
3375 let mut store = ConfigStore::load(Some(path.clone())).expect("store loads");
3376 let api_key_env_name = ["api", "_key_env"].concat();
3377
3378 for (name, body) in [
3379 (
3380 "auth",
3381 r#"
3382 [global.providers.xai.auth]
3383 source = "command"
3384 command = ["synthetic-credential-helper"]
3385 "#
3386 .to_string(),
3387 ),
3388 (
3389 "external",
3390 r#"
3391 [global.providers.xai.external_credentials]
3392 access = "read_only"
3393 provider = "xai"
3394 source = "grok_cli"
3395 path = "/synthetic/external/auth.json"
3396 consent_version = 1
3397 "#
3398 .to_string(),
3399 ),
3400 (
3401 "oauth-generation",
3402 r#"
3403 [global.providers.xai]
3404 oauth_credential_generation = "synthetic-owned-generation.toml"
3405 "#
3406 .to_string(),
3407 ),
3408 (
3409 "api-key-env",
3410 format!(
3411 r#"
3412 [global.providers.xai]
3413 {api_key_env_name} = "SYNTHETIC_RANDOM_PROVIDER_KEY"
3414 "#
3415 ),
3416 ),
3417 ] {
3418 let text = format!("schema_version = 1\nkind = \"codewhale.portable-config\"\n{body}");
3419 let bundle = parse_bundle_str(&text, name).expect("authority bundle parses");
3420 assert_eq!(find_rejected_entries(&bundle).len(), 1, "{name}");
3421 let error = apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path())
3422 .expect_err("authority-bearing import must fail");
3423 assert!(
3424 error.to_string().contains("conflicting or rejected"),
3425 "{name}: {error:#}"
3426 );
3427 assert_eq!(std::fs::read(&path).expect("config after refusal"), before);
3428 assert_eq!(store.config.verbosity.as_deref(), Some("quiet"));
3429 }
3430
3431 let fixture = format!(
3432 r#"
3433 provider = "xai"
3434
3435 [providers.xai]
3436 model = "grok-safe-model"
3437 oauth_credential_generation = "synthetic-owned-generation.toml"
3438 {api_key_env_name} = "SYNTHETIC_RANDOM_PROVIDER_KEY"
3439
3440 [providers.xai.auth]
3441 source = "command"
3442 command = ["synthetic-credential-helper"]
3443
3444 [providers.xai.external_credentials]
3445 access = "read_only"
3446 provider = "xai"
3447 source = "grok_cli"
3448 path = "/synthetic/external/auth.json"
3449 consent_version = 1
3450 "#
3451 );
3452 let config: ConfigToml =
3453 toml::from_str(&fixture).expect("provider authority config parses");
3454 let exported = export_bundle(&config, BundleScope::Global, BundleMetadata::default())
3455 .expect("provider authority is scrubbed");
3456 let body = serialize_bundle(&exported).expect("serialize provider export");
3457 for forbidden in [
3458 "external_credentials",
3459 "oauth_credential_generation",
3460 "synthetic-credential-helper",
3461 "synthetic-owned-generation.toml",
3462 "SYNTHETIC_RANDOM_PROVIDER_KEY",
3463 api_key_env_name.as_str(),
3464 "/synthetic/external/auth.json",
3465 ] {
3466 assert!(
3467 !body.contains(forbidden),
3468 "export retained {forbidden}: {body}"
3469 );
3470 }
3471 assert!(body.contains("model = \"grok-safe-model\""), "{body}");
3472 assert!(find_rejected_entries(&exported).is_empty(), "{exported:?}");
3473 }
3474
3475 #[test]
3476 fn machine_local_route_and_path_fields_are_rejected_and_scrubbed_symmetrically() {
3477 let bundle = parse_bundle_str(
3478 r#"
3479 schema_version = 1
3480 kind = "codewhale.portable-config"
3481
3482 [global]
3483 telemetry_endpoint = "https://synthetic.invalid/telemetry"
3484 mcpConfigPath = "/synthetic/import-mcp.json"
3485
3486 [global.providers.deepseek]
3487 baseUrl = "https://synthetic.invalid/provider/v1"
3488 model = "safe-model"
3489
3490 [global.hook_sinks]
3491 unix_socket_path = "/synthetic/import-codewhale.sock"
3492 "#,
3493 "machine-local-paths.toml",
3494 )
3495 .expect("machine-local bundle parses");
3496 let rejected = find_rejected_entries(&bundle);
3497 assert_eq!(rejected.len(), 4, "{rejected:?}");
3498 for key in [
3499 "global.telemetry_endpoint",
3500 "global.mcpConfigPath",
3501 "global.providers",
3502 "global.hook_sinks",
3503 ] {
3504 assert!(
3505 rejected.iter().any(|entry| entry.key == key),
3506 "{rejected:?}"
3507 );
3508 }
3509
3510 let dir = tempfile::tempdir().expect("config dir");
3511 let path = dir.path().join("config.toml");
3512 std::fs::write(&path, "verbosity = \"quiet\"\n").expect("seed config");
3513 let before = std::fs::read(&path).expect("config before import");
3514 let mut store = ConfigStore::load(Some(path.clone())).expect("store loads");
3515 let error = apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path())
3516 .expect_err("machine-local paths must refuse the entire import");
3517 assert!(
3518 error.to_string().contains("conflicting or rejected"),
3519 "{error:#}"
3520 );
3521 assert_eq!(std::fs::read(&path).expect("config after refusal"), before);
3522 assert_eq!(store.config.verbosity.as_deref(), Some("quiet"));
3523
3524 let config: ConfigToml = toml::from_str(
3525 r#"
3526 telemetry_endpoint = "https://synthetic.invalid/telemetry"
3527 mcp_config_path = "/synthetic/export-mcp.json"
3528
3529 [providers.deepseek]
3530 base_url = "https://synthetic.invalid/provider/v1"
3531 model = "safe-model"
3532
3533 [hook_sinks]
3534 unix_socket_path = "/synthetic/export-codewhale.sock"
3535 "#,
3536 )
3537 .expect("machine-local config parses");
3538 let exported = export_bundle(&config, BundleScope::Global, BundleMetadata::default())
3539 .expect("machine-local paths are scrubbed");
3540 let body = serialize_bundle(&exported).expect("serialize machine-local export");
3541 for forbidden in [
3542 "synthetic.invalid",
3543 "/synthetic/export-mcp.json",
3544 "/synthetic/export-codewhale.sock",
3545 "telemetry_endpoint",
3546 "mcp_config_path",
3547 "unix_socket_path",
3548 "base_url",
3549 ] {
3550 assert!(
3551 !body.contains(forbidden),
3552 "export retained {forbidden}: {body}"
3553 );
3554 }
3555 assert!(body.contains("model = \"safe-model\""), "{body}");
3556 assert!(find_rejected_entries(&exported).is_empty(), "{exported:?}");
3557 }
3558
3559 #[test]
3560 fn remaining_local_authority_is_rejected_while_safe_policy_stays_portable() {
3561 for safe in [
3562 "databaseUrl",
3563 "baseUrlTemplate",
3564 "memoryPathology",
3565 "sandboxUrlTemplate",
3566 "skills.registry_url",
3567 "network.allow",
3568 "workflow.automatic",
3569 "fleet.exec.allowed_tools",
3570 ] {
3571 assert_eq!(nonportable_path_reason(safe), None, "must preserve {safe}");
3572 }
3573
3574 let bundle = parse_bundle_str(
3575 r#"
3576 schema_version = 1
3577 kind = "codewhale.portable-config"
3578
3579 [global]
3580 instructions = "/synthetic/import-instructions.md"
3581 project_instruction_imports = "all"
3582 projectInstructionImports = "all"
3583 sandbox_backend = "synthetic-local-backend"
3584 sandboxUrl = "http://127.0.0.1:47891"
3585 bwrapRoRoots = ["/synthetic/import-ro"]
3586 bwrap_dev_roots = ["/synthetic/import-dev"]
3587 skills_dir = "/synthetic/import-skills"
3588 memoryPath = "/synthetic/import-memory.md"
3589 mcpOauthCallbackUrl = "http://127.0.0.1:47892/callback"
3590 mcp_oauth_callback_port = 47892
3591 notes_path = "/synthetic/import-notes.md"
3592
3593 [global.runtime_api]
3594 bind = "127.0.0.1:47893"
3595
3596 [global.auto_review]
3597 allow = ["synthetic-shell-action"]
3598
3599 [global.tools]
3600 always_load = ["read_file"]
3601 plugin_dir = "/synthetic/import-plugins"
3602
3603 [global.tools.overrides.shell]
3604 command = "/synthetic/import-tool-override"
3605
3606 [global.update]
3607 channel = "stable"
3608 update_uri = "file:///synthetic/import-update"
3609
3610 [global.notifications]
3611 enabled = true
3612 sound_file = "/synthetic/import-sound.wav"
3613
3614 [global.speech]
3615 enabled = true
3616 output_dir = "/synthetic/import-speech"
3617
3618 [global.skills]
3619 registry_url = "https://registry.example/skills.json"
3620
3621 [global.network]
3622 default = "prompt"
3623 allow = ["registry.example"]
3624
3625 [global.workflow]
3626 automatic = false
3627
3628 [global.fleet.exec]
3629 allowed_tools = ["read_file"]
3630 "#,
3631 "remaining-local-authority.toml",
3632 )
3633 .expect("remaining authority bundle parses");
3634 let rejected = find_rejected_entries(&bundle);
3635 assert_eq!(rejected.len(), 18, "{rejected:?}");
3636 for safe in [
3637 "global.skills",
3638 "global.network",
3639 "global.workflow",
3640 "global.fleet",
3641 ] {
3642 assert!(
3643 rejected.iter().all(|entry| entry.key != safe),
3644 "safe entry {safe} was rejected: {rejected:?}"
3645 );
3646 }
3647
3648 let dir = tempfile::tempdir().expect("config dir");
3649 let path = dir.path().join("config.toml");
3650 std::fs::write(&path, "verbosity = \"concise\"\n").expect("seed config");
3651 let before = std::fs::read(&path).expect("config before import");
3652 let mut store = ConfigStore::load(Some(path.clone())).expect("store loads");
3653 apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path())
3654 .expect_err("remaining local authority import must fail");
3655 assert_eq!(std::fs::read(&path).expect("config after refusal"), before);
3656 assert_eq!(store.config.verbosity.as_deref(), Some("concise"));
3657
3658 let config: ConfigToml = toml::from_str(
3659 r#"
3660 instructions = "/synthetic/export-instructions.md"
3661 project_instruction_imports = "all"
3662 projectInstructionImports = "all"
3663 sandbox_backend = "synthetic-local-backend"
3664 sandboxUrl = "http://127.0.0.1:47894"
3665 bwrapRoRoots = ["/synthetic/export-ro"]
3666 bwrap_dev_roots = ["/synthetic/export-dev"]
3667 skills_dir = "/synthetic/export-skills"
3668 memoryPath = "/synthetic/export-memory.md"
3669 mcpOauthCallbackUrl = "http://127.0.0.1:47895/callback"
3670 mcp_oauth_callback_port = 47895
3671 notes_path = "/synthetic/export-notes.md"
3672
3673 [runtime_api]
3674 bind = "127.0.0.1:47896"
3675
3676 [auto_review]
3677 allow = ["synthetic-shell-action"]
3678
3679 [tools]
3680 always_load = ["read_file"]
3681 plugin_dir = "/synthetic/export-plugins"
3682
3683 [tools.overrides.shell]
3684 command = "/synthetic/export-tool-override"
3685
3686 [update]
3687 channel = "stable"
3688 update_uri = "file:///synthetic/export-update"
3689
3690 [notifications]
3691 enabled = true
3692 sound_file = "/synthetic/export-sound.wav"
3693
3694 [speech]
3695 enabled = true
3696 output_dir = "/synthetic/export-speech"
3697
3698 [skills]
3699 registry_url = "https://registry.example/skills.json"
3700 max_install_size_bytes = 12345
3701
3702 [network]
3703 default = "prompt"
3704 allow = ["registry.example"]
3705
3706 [workflow]
3707 automatic = false
3708
3709 [fleet.exec]
3710 allowed_tools = ["read_file"]
3711 "#,
3712 )
3713 .expect("remaining authority config parses");
3714 let exported = export_bundle(&config, BundleScope::Global, BundleMetadata::default())
3715 .expect("remaining authority is scrubbed");
3716 for key in [
3717 "instructions",
3718 "project_instruction_imports",
3719 "projectInstructionImports",
3720 "sandbox_backend",
3721 "sandboxUrl",
3722 "bwrapRoRoots",
3723 "bwrap_dev_roots",
3724 "skills_dir",
3725 "memoryPath",
3726 "mcpOauthCallbackUrl",
3727 "mcp_oauth_callback_port",
3728 "notes_path",
3729 "runtime_api",
3730 "auto_review",
3731 ] {
3732 assert!(!exported.global.entries.contains_key(key), "retained {key}");
3733 }
3734 let body = serialize_bundle(&exported).expect("serialize safe policy export");
3735 for forbidden in [
3736 "/synthetic/export-instructions.md",
3737 "/synthetic/export-ro",
3738 "/synthetic/export-dev",
3739 "/synthetic/export-skills",
3740 "/synthetic/export-memory.md",
3741 "/synthetic/export-notes.md",
3742 "/synthetic/export-sound.wav",
3743 "/synthetic/export-speech",
3744 "file:///synthetic/export-update",
3745 "127.0.0.1:47896",
3746 ] {
3747 assert!(
3748 !body.contains(forbidden),
3749 "export retained {forbidden}: {body}"
3750 );
3751 }
3752 for safe in [
3753 "https://registry.example/skills.json",
3754 "registry.example",
3755 "automatic = false",
3756 "allowed_tools = [\"read_file\"]",
3757 "channel = \"stable\"",
3758 "enabled = true",
3759 ] {
3760 assert!(body.contains(safe), "export lost {safe}: {body}");
3761 }
3762
3763 // ToolsToml currently ignores these legacy fields while parsing, so
3764 // exercise the recursive export sanitizer directly as defense in depth.
3765 let raw: toml::Value = toml::from_str(
3766 r#"
3767 [tools]
3768 always_load = ["read_file"]
3769 plugin_dir = "/synthetic/direct-plugin-dir"
3770
3771 [tools.overrides.shell]
3772 command = "/synthetic/direct-tool-override"
3773 "#,
3774 )
3775 .expect("raw tools table parses");
3776 let scrubbed =
3777 sanitize_export_value("tools", raw.get("tools").expect("raw tools table exists"))
3778 .expect("safe tools sibling remains");
3779 let scrubbed = scrubbed.to_string();
3780 assert!(scrubbed.contains("read_file"), "{scrubbed}");
3781 assert!(!scrubbed.contains("plugin_dir"), "{scrubbed}");
3782 assert!(!scrubbed.contains("overrides"), "{scrubbed}");
3783 assert!(find_rejected_entries(&exported).is_empty(), "{exported:?}");
3784 }
3785
3786 #[test]
3787 fn deep_nesting_fails_closed_for_rejection_and_sanitize() {
3788 fn deep_toml(depth: usize) -> toml::Value {
3789 let mut value = toml::Value::String("leaf".to_string());
3790 for _ in 0..depth {
3791 let mut map = toml::map::Map::new();
3792 map.insert("t".to_string(), value);
3793 value = toml::Value::Table(map);
3794 }
3795 value
3796 }
3797
3798 let deep = deep_toml(70);
3799 let reason = value_rejection_reason("t", &deep).expect("over-deep value must be rejected");
3800 assert!(reason.contains("levels deep"), "{reason}");
3801 assert!(
3802 sanitize_export_value("t", &deep)
3803 .is_some_and(|scrubbed| !scrubbed.to_string().contains("leaf")),
3804 "over-deep branch must be omitted, not exported"
3805 );
3806 }
3807
3808 #[test]
3809 fn lsp_executable_authority_is_rejected_while_inert_settings_remain_portable() {
3810 let config: ConfigToml = toml::from_str(
3811 r#"
3812 [lsp]
3813 enabled = true
3814 poll_after_edit_ms = 250
3815 max_diagnostics_per_file = 12
3816 include_warnings = true
3817
3818 [lsp.servers]
3819 rust = ["/synthetic/rust-analyzer", "--stdio"]
3820
3821 [lsp.custom.foo]
3822 language_id = "foo-language"
3823 command = "/synthetic/foo-language-server"
3824 args = ["--stdio", "--synthetic"]
3825 "#,
3826 )
3827 .expect("LSP config parses");
3828 let exported = export_bundle(&config, BundleScope::Global, BundleMetadata::default())
3829 .expect("LSP executable authority is scrubbed");
3830 let body = serialize_bundle(&exported).expect("serialize LSP export");
3831 for forbidden in [
3832 "/synthetic/rust-analyzer",
3833 "/synthetic/foo-language-server",
3834 "foo-language",
3835 "--stdio",
3836 "--synthetic",
3837 ] {
3838 assert!(
3839 !body.contains(forbidden),
3840 "export retained {forbidden}: {body}"
3841 );
3842 }
3843 for inert in [
3844 "enabled = true",
3845 "poll_after_edit_ms = 250",
3846 "max_diagnostics_per_file = 12",
3847 "include_warnings = true",
3848 ] {
3849 assert!(body.contains(inert), "export lost {inert}: {body}");
3850 }
3851 assert!(find_rejected_entries(&exported).is_empty(), "{exported:?}");
3852
3853 let dir = tempfile::tempdir().expect("config dir");
3854 let path = dir.path().join("config.toml");
3855 std::fs::write(&path, "verbosity = \"concise\"\n").expect("seed config");
3856 let before = std::fs::read(&path).expect("config before imports");
3857 let mut store = ConfigStore::load(Some(path.clone())).expect("store loads");
3858 for (name, body) in [
3859 (
3860 "servers",
3861 r#"
3862 [global.lsp]
3863 enabled = true
3864
3865 [global.lsp.servers]
3866 rust = ["/synthetic/import-rust-analyzer", "--stdio"]
3867 "#,
3868 ),
3869 (
3870 "custom",
3871 r#"
3872 [global.lsp]
3873 include_warnings = true
3874
3875 [global.lsp.custom.foo]
3876 language_id = "foo-language"
3877 command = "/synthetic/import-foo-server"
3878 args = ["--stdio"]
3879 "#,
3880 ),
3881 ] {
3882 let text = format!("schema_version = 1\nkind = \"codewhale.portable-config\"\n{body}");
3883 let bundle = parse_bundle_str(&text, name).expect("LSP bundle parses");
3884 assert_eq!(find_rejected_entries(&bundle).len(), 1, "{name}");
3885 apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path())
3886 .expect_err("LSP executable authority import must fail");
3887 assert_eq!(std::fs::read(&path).expect("config after refusal"), before);
3888 assert_eq!(store.config.verbosity.as_deref(), Some("concise"));
3889 }
3890 }
3891
3892 #[test]
3893 fn trust_posture_and_local_endpoint_keys_are_rejected_on_import() {
3894 let bundle = parse_bundle_str(
3895 r#"
3896 schema_version = 1
3897 kind = "codewhale.portable-config"
3898
3899 [preferences]
3900 yolo = true
3901 allow_shell = true
3902 sandbox_mode = "danger-full-access"
3903 approval_policy = "never"
3904 sandboxNetworkAccess = true
3905 log_level = "debug"
3906
3907 [preferences.lifecycle_outbox]
3908 webhook_url = "https://collector.example/collect"
3909
3910 [preferences.extension_host]
3911 node = "/bin/echo"
3912
3913 [preferences.control_socket]
3914 enabled = true
3915
3916 [global]
3917 sandbox_read_denylist_exempt = ["/synthetic/exempt"]
3918 "#,
3919 "posture.toml",
3920 )
3921 .expect("posture bundle parses");
3922 let rejected = find_rejected_entries(&bundle);
3923 let keys: Vec<&str> = rejected.iter().map(|entry| entry.key.as_str()).collect();
3924 assert_eq!(rejected.len(), 9, "{rejected:?}");
3925 assert!(!keys.contains(&"preferences.log_level"), "{keys:?}");
3926 let rendered = format!("{rejected:?}");
3927 assert!(!rendered.contains("collector.example"), "{rendered}");
3928 assert!(!rendered.contains("/bin/echo"), "{rendered}");
3929
3930 let dir = tempfile::tempdir().expect("config dir");
3931 let path = dir.path().join("config.toml");
3932 std::fs::write(&path, "verbosity = \"quiet\"\n").expect("seed config");
3933 let before = std::fs::read(&path).expect("config before import");
3934 let mut store = ConfigStore::load(Some(path.clone())).expect("store loads");
3935 apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path())
3936 .expect_err("trust posture import must fail");
3937 assert_eq!(std::fs::read(&path).expect("config after refusal"), before);
3938 }
3939
3940 #[test]
3941 fn portable_export_drops_webhooks_local_executables_and_denylist_paths() {
3942 let config: ConfigToml = toml::from_str(
3943 r#"
3944 model = "safe-model"
3945 approval_policy = "never"
3946 sandbox_mode = "danger-full-access"
3947 yolo = true
3948 sandbox_denied_read_paths = ["/synthetic/private"]
3949 sandbox_read_denylist_exempt = ["/synthetic/exempt"]
3950
3951 [lifecycle_outbox]
3952 path = "/synthetic/outbox.jsonl"
3953 webhook_url = "https://hooks.slack.com/services/T0SYNTH/B0SYNTH/synthetichookvalue"
3954
3955 [extension_host]
3956 node = "/synthetic/bin/node"
3957
3958 [control_socket]
3959 enabled = true
3960 "#,
3961 )
3962 .expect("config parses");
3963 let exported = export_bundle(&config, BundleScope::Global, BundleMetadata::default())
3964 .expect("export scrubs posture");
3965 let body = serialize_bundle(&exported).expect("serialize");
3966 for forbidden in [
3967 "hooks.slack.com",
3968 "synthetichookvalue",
3969 "/synthetic/outbox.jsonl",
3970 "/synthetic/bin/node",
3971 "/synthetic/private",
3972 "/synthetic/exempt",
3973 "danger-full-access",
3974 "approval_policy",
3975 "yolo",
3976 "control_socket",
3977 ] {
3978 assert!(
3979 !body.contains(forbidden),
3980 "export retained {forbidden}: {body}"
3981 );
3982 }
3983 assert!(body.contains("model = \"safe-model\""), "{body}");
3984 }
3985
3986 #[test]
3987 fn urls_carrying_credentials_are_credential_shaped() {
3988 for url in [
3989 "https://user:pass@registry.example/index.json",
3990 "https://registry.example/index.json?token=synthetic",
3991 "https://registry.example/index.json?access_token=synthetic",
3992 "https://hooks.slack.com/services/T0SYNTH/B0SYNTH/synthetic",
3993 "https://discord.com/api/webhooks/1/synthetic",
3994 "https://tenant.webhook.office.com/webhookb2/synthetic",
3995 ] {
3996 assert!(string_secret_reason(url).is_some(), "must reject {url}");
3997 }
3998 for url in [
3999 "https://registry.example/skills.json",
4000 "https://registry.example/search?q=codewhale&page=2",
4001 "https://registry.example/search?q=a+b%20c",
4002 "https://hooks.slack.com/",
4003 ] {
4004 assert_eq!(string_secret_reason(url), None, "must preserve {url}");
4005 }
4006 }
4007
4008 #[test]
4009 fn machine_bound_authority_subtrees_are_rejected_and_never_exported() {
4010 let config: ConfigToml = toml::from_str(
4011 r#"
4012 managed_config_path = "/synthetic/managed-config.toml"
4013 requirements_path = "/synthetic/requirements.md"
4014
4015 [workspace]
4016 root = "/synthetic/workspace-root"
4017 trust = "trusted"
4018 allow_shell = true
4019
4020 [projects."/synthetic/project-root"]
4021 trust = "trusted"
4022 allow_shell = true
4023
4024 [hooks.session_start]
4025 command = "/synthetic/session-start"
4026
4027 [portable.workspace]
4028 label = "safe-nested-workspace-label"
4029
4030 [portable.projects]
4031 label = "safe-nested-projects-label"
4032
4033 [portable.hooks]
4034 label = "safe-nested-hooks-label"
4035 "#,
4036 )
4037 .expect("machine-bound authority config parses");
4038 let exported = export_bundle(&config, BundleScope::Global, BundleMetadata::default())
4039 .expect("machine-bound authority is scrubbed");
4040 assert!(!exported.global.entries.contains_key("workspace"));
4041 assert!(!exported.global.entries.contains_key("projects"));
4042 assert!(!exported.global.entries.contains_key("hooks"));
4043 assert!(!exported.global.entries.contains_key("managed_config_path"));
4044 assert!(!exported.global.entries.contains_key("requirements_path"));
4045 let body = serialize_bundle(&exported).expect("serialize machine-bound export");
4046 for path in [
4047 "/synthetic/workspace-root",
4048 "/synthetic/project-root",
4049 "/synthetic/session-start",
4050 "/synthetic/managed-config.toml",
4051 "/synthetic/requirements.md",
4052 ] {
4053 assert!(!body.contains(path), "export retained {path}: {body}");
4054 }
4055 assert!(body.contains("safe-nested-workspace-label"), "{body}");
4056 assert!(body.contains("safe-nested-projects-label"), "{body}");
4057 assert!(body.contains("safe-nested-hooks-label"), "{body}");
4058
4059 let bundle = parse_bundle_str(
4060 r#"
4061 schema_version = 1
4062 kind = "codewhale.portable-config"
4063
4064 [global]
4065 managed_config_path = "/synthetic/import-managed-config.toml"
4066 requirements_path = "/synthetic/import-requirements.md"
4067
4068 [global.workspace]
4069 root = "/synthetic/import-workspace"
4070 trust = "trusted"
4071 allow_shell = true
4072
4073 [global.projects."/synthetic/import-project"]
4074 trust = "trusted"
4075 allow_shell = true
4076
4077 [global.hooks.session_start]
4078 command = "/synthetic/import-session-start"
4079
4080 [global.portable.workspace]
4081 label = "safe-nested-workspace-label"
4082 "#,
4083 "machine-bound-authority.toml",
4084 )
4085 .expect("machine-bound authority bundle parses");
4086 let rejected = find_rejected_entries(&bundle);
4087 assert_eq!(rejected.len(), 5, "{rejected:?}");
4088 assert!(rejected.iter().any(|entry| entry.key == "global.workspace"));
4089 assert!(rejected.iter().any(|entry| entry.key == "global.projects"));
4090 assert!(rejected.iter().any(|entry| entry.key == "global.hooks"));
4091 assert!(
4092 rejected
4093 .iter()
4094 .any(|entry| entry.key == "global.managed_config_path")
4095 );
4096 assert!(
4097 rejected
4098 .iter()
4099 .any(|entry| entry.key == "global.requirements_path")
4100 );
4101
4102 let dir = tempfile::tempdir().expect("config dir");
4103 let path = dir.path().join("config.toml");
4104 std::fs::write(&path, "verbosity = \"concise\"\n").expect("seed config");
4105 let before = std::fs::read(&path).expect("config before import");
4106 let mut store = ConfigStore::load(Some(path.clone())).expect("store loads");
4107 apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path())
4108 .expect_err("machine-bound authority import must fail");
4109 assert_eq!(std::fs::read(path).expect("config after refusal"), before);
4110 assert_eq!(store.config.verbosity.as_deref(), Some("concise"));
4111 }
4112
4113 #[test]
4114 fn bundle_scope_must_match_the_target_for_import_and_export() {
4115 let dir = tempfile::tempdir().expect("config dir");
4116 let global_path = dir.path().join("config.toml");
4117 let error = validate_scope_target(BundleScope::Project, &global_path)
4118 .expect_err("global path cannot masquerade as project scope");
4119 assert!(error.to_string().contains("workspace config"), "{error:#}");
4120 validate_scope_target(BundleScope::Global, &global_path)
4121 .expect("global config accepts global scope");
4122
4123 let project_path = dir.path().join(".codewhale").join("config.toml");
4124 std::fs::create_dir(dir.path().join(".git")).expect("checkout marker");
4125 validate_scope_target(BundleScope::Project, &project_path)
4126 .expect("workspace config accepts project scope");
4127 let error = validate_scope_target(BundleScope::Global, &project_path)
4128 .expect_err("workspace path cannot masquerade as global scope");
4129 assert!(error.to_string().contains("--project"), "{error:#}");
4130
4131 let mut store = ConfigStore::load(Some(project_path)).expect("workspace store loads");
4132 let import = ImportArgs {
4133 source: dir
4134 .path()
4135 .join("must-not-be-read.toml")
4136 .display()
4137 .to_string(),
4138 dry_run: true,
4139 yes: true,
4140 project: false,
4141 };
4142 let error = run_import(&import, &mut store, dir.path())
4143 .expect_err("global import must refuse a workspace config before reading input");
4144 assert!(error.to_string().contains("--project"), "{error:#}");
4145
4146 let output = dir.path().join("must-not-be-written.toml");
4147 let export = ExportArgs {
4148 portable: true,
4149 project: false,
4150 out: Some(output.clone()),
4151 };
4152 let error = run_export(&export, &store)
4153 .expect_err("global export must refuse a workspace config before writing output");
4154 assert!(error.to_string().contains("--project"), "{error:#}");
4155 assert!(!output.exists(), "refused export must not create an output");
4156 }
4157
4158 #[test]
4159 fn exported_bundle_reimports_cleanly() {
4160 let mut store = isolated_store();
4161 store.config.set_value("verbosity", "concise").expect("set");
4162 store.save().expect("save");
4163
4164 let bundle = export_bundle(
4165 &store.config,
4166 BundleScope::Global,
4167 BundleMetadata::default(),
4168 )
4169 .expect("export");
4170 let rejected = find_rejected_entries(&bundle);
4171 assert!(
4172 rejected.is_empty(),
4173 "export must be secret-free: {rejected:?}"
4174 );
4175
4176 let plan = plan_import(&bundle, &store.config, BundleScope::Global);
4177 assert!(
4178 plan.rejected.is_empty() && plan.conflicting.is_empty(),
4179 "own export must not trip rejection: {plan:?}"
4180 );
4181 }
4182
4183 #[test]
4184 fn http_non_loopback_fetch_is_refused_without_network_access() {
4185 let err = fetch_bundle("http://example.com/bundle.toml")
4186 .expect_err("plain http to a public host must be refused");
4187 assert!(err.to_string().contains("loopback"), "{err:#}");
4188 assert!(!err.to_string().contains("example.com"), "{err:#}");
4189 }
4190
4191 #[test]
4192 fn redirect_to_non_loopback_http_is_refused_without_leaking_location() {
4193 let secret = "location-secret-must-not-leak";
4194 let location = format!("http://example.com/internal?token={secret}");
4195 let response = http_response("302 Found", &[("Location", location.as_str())], b"");
4196 let (url, server) = spawn_bundle_http_server(vec![response]);
4197
4198 let error = fetch_bundle(&url).expect_err("redirect target must be revalidated");
4199 let rendered = format!("{error:#}");
4200 assert!(rendered.contains("loopback"), "{rendered}");
4201 assert!(!rendered.contains("example.com"), "{rendered}");
4202 assert!(!rendered.contains(secret), "{rendered}");
4203 assert_eq!(server.join().expect("server joins"), 1);
4204 }
4205
4206 #[test]
4207 fn https_redirect_cannot_downgrade_to_loopback_http() {
4208 let secret = "downgrade-secret-must-not-leak";
4209 let target = reqwest::Url::parse(&format!("http://127.0.0.1/internal?token={secret}"))
4210 .expect("test target URL");
4211
4212 let error = validate_bundle_redirect("https", &target)
4213 .expect_err("HTTPS redirect must not downgrade to loopback HTTP");
4214 let rendered = format!("{error:#}");
4215 assert!(rendered.contains("scheme"), "{rendered}");
4216 assert!(!rendered.contains(secret), "{rendered}");
4217 assert!(!rendered.contains("127.0.0.1"), "{rendered}");
4218 }
4219
4220 #[test]
4221 fn relative_loopback_redirect_fetches_bundle() {
4222 let redirect = http_response("302 Found", &[("Location", "/bundle.toml")], b"");
4223 let body = VALID_TOML.as_bytes();
4224 let success = http_response("200 OK", &[("Content-Type", "text/plain")], body);
4225 let (url, server) = spawn_bundle_http_server(vec![redirect, success]);
4226
4227 let fetched = fetch_bundle(&url).expect("relative redirect remains allowed");
4228 assert_eq!(fetched, body);
4229 assert_eq!(server.join().expect("server joins"), 2);
4230 }
4231
4232 #[test]
4233 fn redirect_limit_is_enforced_before_a_sixth_hop() {
4234 let responses = (0..=MAX_REDIRECTS)
4235 .map(|hop| {
4236 let location = format!("/hop-{}", hop + 1);
4237 http_response("302 Found", &[("Location", location.as_str())], b"")
4238 })
4239 .collect();
4240 let (url, server) = spawn_bundle_http_server(responses);
4241
4242 let error = fetch_bundle(&url).expect_err("sixth redirect must be refused");
4243 assert!(error.to_string().contains("five-redirect"), "{error:#}");
4244 assert_eq!(server.join().expect("server joins"), MAX_REDIRECTS + 1);
4245 }
4246
4247 #[test]
4248 fn bundle_url_credentials_are_rejected_without_echoing_them() {
4249 let secret = "credential-secret-must-not-leak";
4250 let error = fetch_bundle(&format!("https://user:{secret}@example.com/bundle.toml"))
4251 .expect_err("URL userinfo must be refused");
4252 let rendered = format!("{error:#}");
4253 assert!(rendered.contains("credentials"), "{rendered}");
4254 assert!(!rendered.contains(secret), "{rendered}");
4255 assert!(!rendered.contains("example.com"), "{rendered}");
4256 }
4257
4258 #[test]
4259 fn unsupported_schemes_are_refused() {
4260 let err = fetch_bundle("file:///etc/passwd").expect_err("file scheme refused");
4261 assert!(err.to_string().contains("scheme"), "{err:#}");
4262 }
4263
4264 #[test]
4265 fn headless_import_requires_yes() {
4266 let plan = ImportPlan {
4267 added: vec!["preferences.x".to_string()],
4268 ..ImportPlan::default()
4269 };
4270 // The test harness runs headless (no tty), so consent without --yes
4271 // must refuse before any prompt.
4272 let err = require_import_consent(false, &plan).expect_err("headless needs --yes");
4273 assert!(err.to_string().contains("--yes"), "{err:#}");
4274 require_import_consent(true, &plan).expect("--yes short-circuits consent");
4275 }
4276
4277 #[test]
4278 fn bounded_paths_refuse_traversal_and_absolute_escapes() {
4279 let base = tempfile::tempdir().expect("base");
4280 let err =
4281 resolve_bounded_path(base.path(), "../escape.toml").expect_err("traversal refused");
4282 assert!(
4283 err.to_string().contains("escapes") || err.to_string().contains("absolute"),
4284 "{err:#}"
4285 );
4286 let absolute = base.path().join("absolute.toml");
4287 let err = resolve_bounded_path(base.path(), absolute.to_string_lossy().as_ref())
4288 .expect_err("absolute refused");
4289 assert!(err.to_string().contains("absolute"), "{err:#}");
4290 let ok = resolve_bounded_path(base.path(), "nested/thing.toml").expect("inside ok");
4291 assert!(ok.starts_with(base.path()));
4292 }
4293
4294 // -- helpers ------------------------------------------------------------
4295
4296 fn http_response(status: &str, headers: &[(&str, &str)], body: &[u8]) -> Vec<u8> {
4297 let mut response = format!(
4298 "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n",
4299 body.len()
4300 )
4301 .into_bytes();
4302 for (name, value) in headers {
4303 response.extend_from_slice(format!("{name}: {value}\r\n").as_bytes());
4304 }
4305 response.extend_from_slice(b"\r\n");
4306 response.extend_from_slice(body);
4307 response
4308 }
4309
4310 fn spawn_bundle_http_server(
4311 responses: Vec<Vec<u8>>,
4312 ) -> (String, std::thread::JoinHandle<usize>) {
4313 let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind HTTP fixture");
4314 let address = listener.local_addr().expect("fixture address");
4315 let handle = std::thread::spawn(move || {
4316 let mut served = 0usize;
4317 for response in responses {
4318 let (mut stream, _) = listener.accept().expect("accept fixture request");
4319 stream
4320 .set_read_timeout(Some(std::time::Duration::from_secs(5)))
4321 .expect("fixture read timeout");
4322 let mut request = Vec::new();
4323 let mut chunk = [0_u8; 1024];
4324 while !request.windows(4).any(|window| window == b"\r\n\r\n") {
4325 let read = stream.read(&mut chunk).expect("read fixture request");
4326 if read == 0 {
4327 break;
4328 }
4329 request.extend_from_slice(&chunk[..read]);
4330 }
4331 stream.write_all(&response).expect("write fixture response");
4332 served += 1;
4333 }
4334 served
4335 });
4336 (format!("http://{address}/start"), handle)
4337 }
4338
4339 /// A store over a config file that outlives the helper: the tempdir is
4340 /// leaked deliberately (tests are short-lived; explicit cleanup would need
4341 /// to thread the guard through every call site).
4342 fn isolated_store() -> ConfigStore {
4343 // Serialize with every other env-mutating test in this crate: a private
4344 // lock here would still race `ScopedEnvVar` users (observed as a flaky
4345 // credentials-dir failure in `api_key_config_failure_restores_*`).
4346 let _guard = crate::tests::env_lock();
4347
4348 let dir = {
4349 // TempDir::keep() is the non-deprecated ownership transfer.
4350 let temp = tempfile::TempDir::new().expect("tempdir");
4351 temp.keep()
4352 };
4353 let unique = dir.join("home").join(std::process::id().to_string());
4354 std::fs::create_dir_all(&unique).expect("unique home");
4355 // SAFETY: test-only env mutation, serialized by the lock above.
4356 unsafe { std::env::set_var("CODEWHALE_HOME", &unique) };
4357 let path = unique.join("config.toml");
4358 std::fs::write(&path, "# test config\n").expect("seed config file");
4359 ConfigStore::load(Some(path)).expect("store loads")
4360 }
4361 }
4362
4362 lines RUST