use std::io::{IsTerminal, Read};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, anyhow, bail};
use serde::{Deserialize, Serialize};
use codewhale_config::{ConfigToml, is_sensitive_config_key};
pub const MAX_BUNDLE_BYTES: u64 = 5 * 1024 * 1024;
pub const BUNDLE_KIND: &str = "codewhale.portable-config";
pub const BUNDLE_SCHEMA_VERSION: u64 = 1;
const MAX_REDIRECTS: usize = 5;
const FETCH_TIMEOUT_SECS: u64 = 30;
const SECRET_VALUE_PREFIXES: [&str; 6] = ["sk-", "Bearer ", "ghp_", "xoxb-", "AKIA", "eyJ"];
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct PortableBundle {
pub schema_version: u64,
pub kind: String,
#[serde(default)]
pub metadata: BundleMetadata,
#[serde(default)]
pub preferences: BundleTable,
#[serde(default)]
pub profiles: BundleTable,
#[serde(default)]
pub plugins: BundleTable,
#[serde(default)]
pub project: BundleTable,
#[serde(default)]
pub global: BundleTable,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct BundleMetadata {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub created_at: Option<String>,
#[serde(default)]
pub generator: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct BundleTable {
#[serde(flatten)]
pub entries: std::collections::BTreeMap<String, toml::Value>,
}
pub fn parse_bundle_bytes(raw: &[u8], source: &str) -> Result<PortableBundle> {
if raw.len() as u64 > MAX_BUNDLE_BYTES {
bail!(
"bundle at {source} is {} bytes; the limit is {MAX_BUNDLE_BYTES} bytes",
raw.len()
);
}
let text = std::str::from_utf8(raw)
.with_context(|| format!("bundle at {source} is not valid UTF-8"))?;
parse_bundle_str(text, source)
}
pub fn parse_bundle_str(text: &str, source: &str) -> Result<PortableBundle> {
let trimmed = text.trim_start();
let bundle = if trimmed.starts_with('{') || source.ends_with(".json") {
reject_duplicate_json_keys(text)
.with_context(|| format!("bundle at {source} is not valid JSON"))?;
serde_json::from_str::<PortableBundle>(text)
.with_context(|| format!("bundle at {source} is not valid JSON"))?
} else {
toml::from_str::<PortableBundle>(text)
.with_context(|| format!("bundle at {source} is not valid TOML"))?
};
validate_bundle(&bundle, source)?;
Ok(bundle)
}
fn reject_duplicate_json_keys(text: &str) -> Result<()> {
use serde::de::{DeserializeSeed, Error as _, MapAccess, SeqAccess, Visitor};
use std::fmt;
struct NoDuplicates<'a> {
path: &'a mut Vec<String>,
}
impl<'de> DeserializeSeed<'de> for NoDuplicates<'_> {
type Value = ();
fn deserialize<D>(self, deserializer: D) -> Result<(), D::Error>
where
D: serde::Deserializer<'de>,
{
deserializer.deserialize_any(self)
}
}
impl<'de> Visitor<'de> for NoDuplicates<'_> {
type Value = ();
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a JSON value without duplicate object keys")
}
fn visit_bool<E: serde::de::Error>(self, _: bool) -> Result<(), E> {
Ok(())
}
fn visit_i64<E: serde::de::Error>(self, _: i64) -> Result<(), E> {
Ok(())
}
fn visit_u64<E: serde::de::Error>(self, _: u64) -> Result<(), E> {
Ok(())
}
fn visit_f64<E: serde::de::Error>(self, _: f64) -> Result<(), E> {
Ok(())
}
fn visit_str<E: serde::de::Error>(self, _: &str) -> Result<(), E> {
Ok(())
}
fn visit_unit<E: serde::de::Error>(self) -> Result<(), E> {
Ok(())
}
fn visit_none<E: serde::de::Error>(self) -> Result<(), E> {
Ok(())
}
fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<(), A::Error> {
let mut index = 0usize;
loop {
self.path.push(format!("[{index}]"));
let next = seq.next_element_seed(NoDuplicates { path: self.path });
self.path.pop();
if next?.is_none() {
return Ok(());
}
index += 1;
}
}
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<(), A::Error> {
let mut seen = std::collections::BTreeSet::new();
while let Some(key) = map.next_key::<String>()? {
if !seen.insert(key.clone()) {
let mut path = self.path.clone();
path.push(key);
return Err(A::Error::custom(format!(
"duplicate key {:?}",
path.join(".")
)));
}
self.path.push(key);
let nested = map.next_value_seed(NoDuplicates { path: self.path });
self.path.pop();
nested?;
}
Ok(())
}
}
let mut deserializer = serde_json::Deserializer::from_str(text);
let mut path = Vec::new();
NoDuplicates { path: &mut path }
.deserialize(&mut deserializer)
.map_err(|error| anyhow::anyhow!("{error}"))?;
deserializer
.end()
.map_err(|error| anyhow::anyhow!("{error}"))?;
Ok(())
}
fn validate_bundle(bundle: &PortableBundle, source: &str) -> Result<()> {
if bundle.kind != BUNDLE_KIND {
bail!(
"bundle at {source} has kind {:?}; expected {BUNDLE_KIND:?}",
bundle.kind
);
}
if bundle.schema_version != BUNDLE_SCHEMA_VERSION {
bail!(
"bundle at {source} has schema_version {}; this build understands {BUNDLE_SCHEMA_VERSION}",
bundle.schema_version
);
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RejectedEntry {
pub key: String,
pub reason: String,
}
pub fn find_rejected_entries(bundle: &PortableBundle) -> Vec<RejectedEntry> {
let mut rejected = Vec::new();
for (section, table) in [
("preferences", &bundle.preferences),
("profiles", &bundle.profiles),
("plugins", &bundle.plugins),
("project", &bundle.project),
("global", &bundle.global),
] {
for (key, value) in &table.entries {
let dotted = format!("{section}.{key}");
if let Some(reason) = nonportable_path_reason(key) {
rejected.push(RejectedEntry {
key: dotted,
reason: reason.to_string(),
});
continue;
}
if let Some(reason) = value_rejection_reason(key, value) {
rejected.push(RejectedEntry {
key: dotted,
reason,
});
}
}
}
rejected
}
fn value_rejection_reason(path: &str, value: &toml::Value) -> Option<String> {
if let Some(reason) = nonportable_value_reason(path, value) {
return Some(reason.to_string());
}
match value {
toml::Value::String(text) => string_secret_reason(text),
toml::Value::Array(items) => items
.iter()
.find_map(|value| value_rejection_reason(path, value))
.map(|reason| format!("array contains an entry where {reason}")),
toml::Value::Table(map) => {
for (key, nested_value) in map {
let child_path = if path.is_empty() {
key.clone()
} else {
format!("{path}.{key}")
};
if let Some(reason) = nonportable_path_reason(&child_path) {
return Some(format!("nested key {key:?} {reason}"));
}
if let Some(reason) = value_rejection_reason(&child_path, nested_value) {
return Some(format!("nested under {key:?}, {reason}"));
}
}
None
}
_ => None,
}
}
fn string_secret_reason(text: &str) -> Option<String> {
if let Some(prefix) = SECRET_VALUE_PREFIXES
.iter()
.find(|prefix| text.trim().starts_with(*prefix))
{
return Some(format!(
"value has the shape of a credential (prefix {prefix:?} redacted)"
));
}
if text.contains(codewhale_config::persistence::REDACTED) {
return Some("value contains a redaction placeholder".to_string());
}
if codewhale_config::persistence::redact_secrets(text) != text {
return Some("value contains credential-shaped text".to_string());
}
None
}
fn is_sensitive_bundle_key(key: &str) -> bool {
if is_sensitive_config_key(key) || is_credential_authority_key(key) {
return true;
}
let normalized = normalize_bundle_key(key);
matches!(
normalized.as_str(),
"access_key"
| "access_token"
| "api_key"
| "api_keys"
| "apikey"
| "authorization"
| "bearer"
| "client_secret"
| "cookie"
| "credential"
| "credentials"
| "id_token"
| "password"
| "passwords"
| "passwd"
| "private_key"
| "proxy_authorization"
| "refresh_token"
| "secret"
| "secrets"
| "set_cookie"
| "token"
| "tokens"
) || normalized.ends_with("_access_key")
|| normalized.ends_with("_api_key")
|| normalized.ends_with("_authorization")
|| normalized.ends_with("_cookie")
|| normalized.ends_with("_password")
|| normalized.ends_with("_private_key")
|| normalized.ends_with("_secret")
|| normalized.ends_with("_token")
}
fn normalize_bundle_key(key: &str) -> String {
let segment = key.trim().trim_matches('"');
let chars: Vec<char> = segment.chars().collect();
let mut normalized = String::with_capacity(segment.len());
for (index, character) in chars.iter().copied().enumerate() {
if !character.is_ascii_alphanumeric() {
if !normalized.ends_with('_') {
normalized.push('_');
}
continue;
}
if character.is_ascii_uppercase() {
let previous = index.checked_sub(1).and_then(|index| chars.get(index));
let next = chars.get(index + 1);
let starts_word = previous.is_some_and(|character| {
character.is_ascii_lowercase() || character.is_ascii_digit()
}) || (previous
.is_some_and(|character| character.is_ascii_uppercase())
&& next.is_some_and(|character| character.is_ascii_lowercase()));
if starts_word && !normalized.ends_with('_') {
normalized.push('_');
}
normalized.push(character.to_ascii_lowercase());
} else {
normalized.push(character.to_ascii_lowercase());
}
}
normalized.trim_matches('_').to_string()
}
fn is_credential_authority_key(key: &str) -> bool {
let normalized = normalize_bundle_key(key);
if normalized == "external_credentials"
|| normalized.ends_with("_external_credentials")
|| normalized == "oauth_credential_generation"
|| normalized.ends_with("_oauth_credential_generation")
{
return true;
}
key.split('.')
.map(normalize_bundle_key)
.any(|segment| segment == "auth")
}
fn is_machine_bound_top_level_key(key: &str) -> bool {
key.split('.')
.next()
.map(normalize_bundle_key)
.is_some_and(|root| {
matches!(
root.as_str(),
"auto_review"
| "hooks"
| "instructions"
| "managed_config_path"
| "project_instruction_imports"
| "projects"
| "requirements_path"
| "runtime_api"
| "workspace"
)
})
}
fn is_nonportable_lsp_authority_key(key: &str) -> bool {
let mut segments = key.split('.').map(normalize_bundle_key);
matches!(segments.next().as_deref(), Some("lsp"))
&& matches!(segments.next().as_deref(), Some("custom" | "servers"))
}
fn is_nonportable_nested_authority_key(key: &str) -> bool {
let segments = key.split('.').map(normalize_bundle_key).collect::<Vec<_>>();
match segments.as_slice() {
[root, field, ..]
if root == "tools" && matches!(field.as_str(), "overrides" | "plugin_dir") =>
{
true
}
[root, field, ..] if root == "update" && field == "update_uri" => true,
[root, field, ..] if root == "notifications" && field == "sound_file" => true,
[root, field, ..] if root == "speech" && field == "output_dir" => true,
[root, .., field] if root == "providers" && field == "api_key_env" => true,
_ => false,
}
}
fn is_machine_specific_config_path(path: &str) -> bool {
let path = normalize_bundle_key(path);
MACHINE_SPECIFIC_KEYS.iter().any(|key| {
let key = normalize_bundle_key(key);
path == key
|| path
.strip_suffix(&key)
.is_some_and(|prefix| prefix.ends_with('_'))
})
}
fn nonportable_path_reason(path: &str) -> Option<&'static str> {
if is_machine_bound_top_level_key(path) {
return Some("carries machine-bound execution or trust authority");
}
if is_nonportable_lsp_authority_key(path) {
return Some("carries executable LSP authority");
}
if is_nonportable_nested_authority_key(path) {
return Some("carries machine-local route or execution authority");
}
if is_machine_specific_config_path(path) {
return Some("carries machine-local route or filesystem authority");
}
if is_credential_authority_key(path) {
return Some("carries machine-local credential authority");
}
if is_sensitive_bundle_key(path) {
return Some("names credential material");
}
None
}
fn nonportable_value_reason(path: &str, value: &toml::Value) -> Option<&'static str> {
let top_level_telemetry = !path.contains('.') && normalize_bundle_key(path) == "telemetry";
(top_level_telemetry && matches!(value, toml::Value::Boolean(true)))
.then_some("would port telemetry opt-in consent between machines")
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ImportPlan {
pub added: Vec<String>,
pub changed: Vec<String>,
pub skipped: Vec<String>,
pub conflicting: Vec<String>,
pub rejected: Vec<RejectedEntry>,
}
impl ImportPlan {
#[must_use]
pub fn is_no_op(&self) -> bool {
self.added.is_empty() && self.changed.is_empty()
}
}
pub fn plan_import(bundle: &PortableBundle, config: &ConfigToml, scope: BundleScope) -> ImportPlan {
let mut plan = ImportPlan {
rejected: find_rejected_entries(bundle),
..ImportPlan::default()
};
let rejected_keys: std::collections::BTreeSet<&str> = plan
.rejected
.iter()
.map(|entry| entry.key.as_str())
.collect();
let mut applicable_key_counts = std::collections::BTreeMap::<&str, usize>::new();
for (section, table) in [
("preferences", &bundle.preferences),
("profiles", &bundle.profiles),
("plugins", &bundle.plugins),
("project", &bundle.project),
("global", &bundle.global),
] {
if section_applies(section, scope) {
for key in table.entries.keys() {
*applicable_key_counts.entry(key.as_str()).or_default() += 1;
}
}
}
let colliding_keys: std::collections::BTreeSet<&str> = applicable_key_counts
.into_iter()
.filter_map(|(key, count)| (count > 1).then_some(key))
.collect();
for (section, table) in [
("preferences", &bundle.preferences),
("profiles", &bundle.profiles),
("plugins", &bundle.plugins),
("project", &bundle.project),
("global", &bundle.global),
] {
let dotted = |key: &str| format!("{section}.{key}");
let applies = section_applies(section, scope);
for (key, value) in &table.entries {
let dotted = dotted(key);
if rejected_keys.contains(dotted.as_str())
|| (applies && colliding_keys.contains(key.as_str()))
{
plan.conflicting.push(dotted);
continue;
}
if !applies {
plan.skipped.push(dotted);
continue;
}
if config_value_matches(config, key, value) {
plan.skipped.push(dotted);
} else if config_has_value(config, key) {
plan.changed.push(dotted);
} else {
plan.added.push(dotted);
}
}
}
plan
}
fn config_value_matches(config: &ConfigToml, key: &str, value: &toml::Value) -> bool {
let semantically_equal = (|| {
let current = config_document(config).ok()?;
let mut candidate = config.clone();
apply_config_value(&mut candidate, key, value).ok()?;
Some(config_document(&candidate).ok()? == current)
})()
.unwrap_or(false);
semantically_equal
|| config.get_value(key).is_some_and(|current| {
render_toml_value(value).ok().as_deref() == Some(current.as_str())
})
}
fn config_has_value(config: &ConfigToml, key: &str) -> bool {
config_document(config)
.ok()
.is_some_and(|table| table.contains_key(key))
|| config.get_value(key).is_some()
}
fn section_applies(section: &str, scope: BundleScope) -> bool {
match section {
"project" => scope == BundleScope::Project,
"global" => scope == BundleScope::Global,
_ => true,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BundleScope {
Global,
Project,
}
impl BundleScope {
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::Global => "global",
Self::Project => "project",
}
}
}
fn validate_scope_target(scope: BundleScope, target: &Path) -> Result<()> {
let workspace_scoped = codewhale_config::config_path_is_workspace_scoped(target);
match (scope, workspace_scoped) {
(BundleScope::Project, false) => bail!(
"--project requires a workspace config ({} is the user-global document)",
target.display()
),
(BundleScope::Global, true) => bail!(
"global bundle operations cannot target workspace config {}; rerun with --project or select the user-global config",
target.display()
),
_ => {}
}
Ok(())
}
#[cfg_attr(
not(test),
expect(dead_code, reason = "path-carrying sections land with the next schema")
)]
pub fn resolve_bounded_path(base_dir: &Path, candidate: &str) -> Result<PathBuf> {
if candidate.contains('\0') {
bail!("bundle path contains a NUL byte; refused");
}
let candidate_path = Path::new(candidate);
if candidate_path.is_absolute() {
bail!(
"bundle path {candidate:?} is absolute; only paths inside the config directory are accepted"
);
}
let canonical_base = base_dir
.canonicalize()
.with_context(|| format!("config directory {} is unavailable", base_dir.display()))?;
let joined = base_dir.join(candidate_path);
let deepest_existing = joined
.ancestors()
.find(|ancestor| ancestor.symlink_metadata().is_ok())
.context("bundle path has no existing ancestor inside the config directory")?;
let resolved = deepest_existing.canonicalize().with_context(|| {
format!(
"could not resolve bundle path component {}",
deepest_existing.display()
)
})?;
if !resolved.starts_with(&canonical_base) {
bail!("bundle path {candidate:?} escapes the config directory via a symlink; refused");
}
Ok(joined)
}
pub fn fetch_bundle(url: &str) -> Result<Vec<u8>> {
let mut current_url = reqwest::Url::parse(url).map_err(|_| anyhow!("invalid bundle URL"))?;
validate_bundle_url(¤t_url)?;
let initial_scheme = current_url.scheme().to_string();
let client = codewhale_release::platform_blocking_http_client_builder()
.timeout(std::time::Duration::from_secs(FETCH_TIMEOUT_SECS))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|_| anyhow!("building bundle fetch client failed"))?;
let mut redirects = 0usize;
let response = loop {
let response = client
.get(current_url.clone())
.send()
.map_err(|_| anyhow!("bundle fetch request failed"))?;
if !response.status().is_redirection() {
break response;
}
if redirects >= MAX_REDIRECTS {
bail!("bundle fetch exceeded the five-redirect limit");
}
let location = response
.headers()
.get(reqwest::header::LOCATION)
.ok_or_else(|| anyhow!("bundle redirect is missing a valid Location header"))?
.to_str()
.map_err(|_| anyhow!("bundle redirect is missing a valid Location header"))?;
let next_url = current_url
.join(location)
.map_err(|_| anyhow!("bundle redirect Location is invalid"))?;
validate_bundle_redirect(&initial_scheme, &next_url)?;
current_url = next_url;
redirects += 1;
};
if !response.status().is_success() {
bail!(
"bundle fetch failed with HTTP status {}",
response.status().as_u16()
);
}
let mut buffer = Vec::new();
let body = response;
body.take(MAX_BUNDLE_BYTES + 1)
.read_to_end(&mut buffer)
.map_err(|_| anyhow!("reading remote bundle failed"))?;
if buffer.len() as u64 > MAX_BUNDLE_BYTES {
bail!("remote bundle exceeds the {MAX_BUNDLE_BYTES} byte limit; refused");
}
Ok(buffer)
}
fn validate_bundle_url(url: &reqwest::Url) -> Result<()> {
if !matches!(url.scheme(), "http" | "https") {
bail!("unsupported bundle URL scheme; use https");
}
if !url.username().is_empty() || url.password().is_some() {
bail!("bundle URLs may not include credentials");
}
let host = url.host_str().context("bundle URL must include a host")?;
match url.scheme() {
"https" => Ok(()),
"http" if is_loopback_bundle_host(host) => Ok(()),
"http" => bail!("plain http is only allowed for loopback hosts; use https"),
_ => unreachable!("scheme was validated above"),
}
}
fn validate_bundle_redirect(initial_scheme: &str, next_url: &reqwest::Url) -> Result<()> {
validate_bundle_url(next_url)?;
if next_url.scheme() != initial_scheme {
bail!("bundle redirects may not change URL scheme");
}
Ok(())
}
fn is_loopback_bundle_host(host: &str) -> bool {
let normalized = host
.strip_prefix('[')
.and_then(|value| value.strip_suffix(']'))
.unwrap_or(host);
normalized.eq_ignore_ascii_case("localhost")
|| normalized.to_ascii_lowercase().ends_with(".localhost")
|| normalized
.parse::<std::net::IpAddr>()
.is_ok_and(|address| address.is_loopback())
}
pub fn export_bundle(
config: &ConfigToml,
scope: BundleScope,
metadata: BundleMetadata,
) -> Result<PortableBundle> {
let mut preferences = BundleTable::default();
let mut profiles = BundleTable::default();
let mut global = BundleTable::default();
let mut project = BundleTable::default();
for (key, value) in config_document(config)? {
if let Some(value) = sanitize_export_value(&key, &value) {
match export_section_for(&key, scope) {
ExportSection::Preferences => {
preferences.entries.insert(key, value);
}
ExportSection::Profiles => {
profiles.entries.insert(key, value);
}
ExportSection::Global => {
global.entries.insert(key, value);
}
ExportSection::Project => {
project.entries.insert(key, value);
}
ExportSection::Drop => {}
}
}
}
let bundle = PortableBundle {
schema_version: BUNDLE_SCHEMA_VERSION,
kind: BUNDLE_KIND.to_string(),
metadata,
preferences,
profiles,
plugins: BundleTable::default(),
project,
global,
};
let rejected = find_rejected_entries(&bundle);
if !rejected.is_empty() {
bail!(
"portable export refused credential-bearing config paths: {}",
rejected
.iter()
.map(|entry| entry.key.as_str())
.collect::<Vec<_>>()
.join(", ")
);
}
Ok(bundle)
}
pub fn serialize_bundle(bundle: &PortableBundle) -> Result<String> {
toml::to_string_pretty(bundle).context("serializing portable bundle")
}
const MACHINE_SPECIFIC_KEYS: [&str; 14] = [
"base_url",
"bwrap_dev_roots",
"bwrap_ro_roots",
"hook_sinks.unix_socket_path",
"mcp_config_path",
"mcp_oauth_callback_port",
"mcp_oauth_callback_url",
"memory_path",
"network.proxy",
"notes_path",
"sandbox_backend",
"sandbox_url",
"skills_dir",
"telemetry_endpoint",
];
enum ExportSection {
Preferences,
Profiles,
Global,
Project,
Drop,
}
fn export_section_for(key: &str, scope: BundleScope) -> ExportSection {
if key.starts_with("harness") || key.contains("harness_profiles") {
return ExportSection::Profiles;
}
if key.starts_with("skills") || key.starts_with("tools") || key.starts_with("snapshots") {
return ExportSection::Preferences;
}
if key.starts_with("auth.") {
return ExportSection::Drop;
}
match scope {
BundleScope::Global => ExportSection::Global,
BundleScope::Project => ExportSection::Project,
}
}
fn config_document(config: &ConfigToml) -> Result<toml::map::Map<String, toml::Value>> {
let text = toml::to_string(config).context("serializing typed config for bundle")?;
let value: toml::Value =
toml::from_str(&text).map_err(|_| anyhow!("serialized typed config was not valid TOML"))?;
let toml::Value::Table(mut table) = value else {
bail!("typed config did not serialize to a TOML table");
};
table.insert(
"provider".to_string(),
toml::Value::String(config.provider_id().to_string()),
);
Ok(table)
}
fn sanitize_export_value(path: &str, value: &toml::Value) -> Option<toml::Value> {
if nonportable_path_reason(path).is_some() || nonportable_value_reason(path, value).is_some() {
return None;
}
match value {
toml::Value::String(text) if string_secret_reason(text).is_some() => None,
toml::Value::Array(values) => Some(toml::Value::Array(
values
.iter()
.filter_map(|value| sanitize_export_value(path, value))
.collect(),
)),
toml::Value::Table(table) => {
let mut scrubbed = toml::map::Map::new();
for (key, value) in table {
let child_path = format!("{path}.{key}");
if let Some(value) = sanitize_export_value(&child_path, value) {
scrubbed.insert(key.clone(), value);
}
}
Some(toml::Value::Table(scrubbed))
}
_ => Some(value.clone()),
}
}
#[derive(Debug)]
pub struct ImportReceipt {
pub plan: ImportPlan,
pub backup_path: Option<PathBuf>,
pub target: PathBuf,
}
pub fn apply_bundle(
bundle: &PortableBundle,
store: &mut codewhale_config::ConfigStore,
scope: BundleScope,
workspace: &Path,
) -> Result<ImportReceipt> {
apply_bundle_with(bundle, store, scope, workspace, apply_entries)
}
fn apply_bundle_with<F>(
bundle: &PortableBundle,
store: &mut codewhale_config::ConfigStore,
scope: BundleScope,
workspace: &Path,
apply: F,
) -> Result<ImportReceipt>
where
F: FnOnce(
&PortableBundle,
&mut codewhale_config::ConfigStore,
BundleScope,
&Path,
&mut bool,
) -> Result<()>,
{
match scope {
BundleScope::Global if !bundle.project.entries.is_empty() => {
bail!(
"bundle carries [project] entries; import it with --project from the workspace instead"
);
}
BundleScope::Project if !bundle.global.entries.is_empty() => {
bail!(
"bundle carries [global] entries; importing them into a project document would leak machine state"
);
}
_ => {}
}
validate_scope_target(scope, store.path())?;
let plan = plan_import(bundle, &store.config, scope);
if !plan.conflicting.is_empty() {
bail!(
"bundle contains conflicting or rejected entries: {}; remove duplicate keys or credential-shaped entries and re-export",
plan.conflicting.join(", ")
);
}
if plan.is_no_op() {
return Ok(ImportReceipt {
plan,
backup_path: None,
target: store.path().to_path_buf(),
});
}
let target = store.path().to_path_buf();
let original_config = store.config.clone();
let backup_path = if target
.try_exists()
.with_context(|| format!("checking config target {}", target.display()))?
{
Some(create_collision_safe_backup(&target)?)
} else {
None
};
let mut target_written = false;
let apply_result = apply(bundle, store, scope, workspace, &mut target_written);
if let Err(error) = apply_result {
store.config = original_config;
let rollback = rollback_import_target(&target, backup_path.as_deref(), target_written)
.and_then(|()| store.reload());
match rollback {
Ok(()) => bail!("{error:#}; rolled back to the pre-import document"),
Err(_) => bail!(
"{error:#}; ROLLBACK FAILED — the pre-import document is preserved at {}",
backup_path
.as_deref()
.map(Path::display)
.map(|path| path.to_string())
.unwrap_or_else(
|| "<no prior file; remove the new target manually>".to_string()
)
),
}
}
Ok(ImportReceipt {
plan,
backup_path,
target,
})
}
fn rollback_import_target(
target: &Path,
backup_path: Option<&Path>,
target_written: bool,
) -> Result<()> {
if !target_written {
return Ok(());
}
if let Some(backup_path) = backup_path {
let bytes = std::fs::read(backup_path)
.with_context(|| format!("reading pre-import backup {}", backup_path.display()))?;
std::fs::write(target, bytes)
.with_context(|| format!("restoring pre-import config {}", target.display()))?;
return Ok(());
}
match std::fs::remove_file(target) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error)
.with_context(|| format!("removing newly-created config {}", target.display())),
}
}
fn apply_entries(
bundle: &PortableBundle,
store: &mut codewhale_config::ConfigStore,
scope: BundleScope,
workspace: &Path,
target_written: &mut bool,
) -> Result<()> {
let mut candidate = store.config.clone();
for (section, table) in [
("preferences", &bundle.preferences),
("profiles", &bundle.profiles),
("plugins", &bundle.plugins),
("project", &bundle.project),
("global", &bundle.global),
] {
let applies = match section {
"project" => scope == BundleScope::Project,
"global" => scope == BundleScope::Global,
_ => true,
};
if !applies {
continue;
}
for (key, value) in &table.entries {
if key == "provider" {
continue;
}
let dotted = format!("{section}.{key}");
if nonportable_path_reason(key).is_some()
|| value_rejection_reason(key, value).is_some()
{
bail!("refusing to import non-portable config path {dotted}");
}
apply_config_value(&mut candidate, key, value)?;
}
}
for (section, table) in [
("preferences", &bundle.preferences),
("profiles", &bundle.profiles),
("plugins", &bundle.plugins),
("project", &bundle.project),
("global", &bundle.global),
] {
if !section_applies(section, scope) {
continue;
}
if let Some(value) = table.entries.get("provider") {
apply_config_value(&mut candidate, "provider", value)?;
}
}
store.config = candidate;
store.save().context("saving imported bundle")?;
*target_written = true;
let _ = workspace;
Ok(())
}
fn apply_config_value(config: &mut ConfigToml, key: &str, value: &toml::Value) -> Result<()> {
if key == "provider"
|| key == "auth.mode"
|| key == "hook_sinks.unix_socket_path"
|| key.starts_with("providers.")
{
return config.set_value(key, &render_toml_value(value)?);
}
let selected_provider_id = config.selected_provider_id.clone();
let mut document = config_document(config)?;
if let Some(current) = document.get_mut(key) {
deep_merge_toml_value(current, value);
} else {
document.insert(key.to_string(), value.clone());
}
let text = toml::to_string(&toml::Value::Table(document))
.with_context(|| format!("config entry {key:?} could not be serialized"))?;
let mut updated: ConfigToml = toml::from_str(&text)
.map_err(|_| anyhow!("config entry {key:?} has an invalid TOML type"))?;
updated.selected_provider_id = selected_provider_id;
*config = updated;
Ok(())
}
fn deep_merge_toml_value(target: &mut toml::Value, incoming: &toml::Value) {
match (target, incoming) {
(toml::Value::Table(target), toml::Value::Table(incoming)) => {
for (key, value) in incoming {
if let Some(current) = target.get_mut(key) {
deep_merge_toml_value(current, value);
} else {
target.insert(key.clone(), value.clone());
}
}
}
(target, incoming) => *target = incoming.clone(),
}
}
fn render_toml_value(value: &toml::Value) -> Result<String> {
Ok(match value {
toml::Value::String(text) => text.clone(),
toml::Value::Integer(number) => number.to_string(),
toml::Value::Float(number) => number.to_string(),
toml::Value::Boolean(flag) => flag.to_string(),
toml::Value::Datetime(text) => text.to_string(),
toml::Value::Array(_) | toml::Value::Table(_) => {
toml::to_string(value)?.trim_end().to_string()
}
})
}
fn create_collision_safe_backup(target: &Path) -> Result<PathBuf> {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|since| since.as_secs())
.unwrap_or_default();
let file_name = target
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| "config.toml".to_string());
let parent = target.parent().unwrap_or_else(|| Path::new("."));
let prefix = format!("{file_name}.bundle-backup-{timestamp}-");
let mut backup = tempfile::Builder::new()
.prefix(&prefix)
.tempfile_in(parent)
.with_context(|| {
format!(
"creating a collision-safe backup beside {}",
target.display()
)
})?;
let mut source = std::fs::File::open(target)
.with_context(|| format!("opening {} for bundle backup", target.display()))?;
std::io::copy(&mut source, backup.as_file_mut())
.with_context(|| format!("copying {} into its bundle backup", target.display()))?;
use std::io::Write as _;
backup
.as_file_mut()
.flush()
.context("flushing bundle backup")?;
let permissions = source
.metadata()
.with_context(|| format!("reading permissions for {}", target.display()))?
.permissions();
std::fs::set_permissions(backup.path(), permissions)
.context("preserving config permissions on bundle backup")?;
backup
.as_file()
.sync_all()
.context("syncing bundle backup")?;
let (_file, path) = backup
.keep()
.map_err(|error| error.error)
.context("persisting bundle backup")?;
Ok(path)
}
pub fn require_import_consent(yes: bool, plan: &ImportPlan) -> Result<()> {
if yes {
return Ok(());
}
if !std::io::stdin().is_terminal() {
bail!(
"import refused: non-interactive use requires explicit --yes after reviewing the plan"
);
}
print!(
"Apply this bundle ({} added, {} changed)? Type 'yes': ",
plan.added.len(),
plan.changed.len()
);
use std::io::Write;
std::io::stdout().flush()?;
let mut answer = String::new();
std::io::stdin()
.read_line(&mut answer)
.context("reading import consent")?;
if answer.trim() != "yes" {
bail!("import cancelled; no configuration was changed");
}
Ok(())
}
#[derive(Debug, clap::Args)]
pub struct ImportArgs {
pub source: String,
#[arg(long, default_value_t = false)]
dry_run: bool,
#[arg(long, default_value_t = false)]
yes: bool,
#[arg(long, default_value_t = false)]
project: bool,
}
#[derive(Debug, clap::Args)]
pub struct ExportArgs {
#[arg(long, default_value_t = false)]
portable: bool,
#[arg(long, default_value_t = false)]
project: bool,
#[arg(long, value_name = "FILE")]
out: Option<PathBuf>,
}
pub fn run_import(
args: &ImportArgs,
store: &mut codewhale_config::ConfigStore,
workspace: &Path,
) -> Result<()> {
let scope = if args.project {
BundleScope::Project
} else {
BundleScope::Global
};
validate_scope_target(scope, store.path())?;
let remote_source = args.source.starts_with("https://") || args.source.starts_with("http://");
let source_label = if args.source == "-" {
"stdin"
} else if remote_source {
"remote bundle"
} else {
args.source.as_str()
};
let raw = if args.source == "-" {
let mut buffer = Vec::new();
std::io::stdin()
.lock()
.take(MAX_BUNDLE_BYTES + 1)
.read_to_end(&mut buffer)
.context("reading bundle from stdin")?;
if buffer.len() as u64 > MAX_BUNDLE_BYTES {
bail!("stdin bundle exceeds the {MAX_BUNDLE_BYTES} byte limit; refused");
}
buffer
} else if remote_source {
fetch_bundle(&args.source)?
} else {
let path = PathBuf::from(&args.source);
let metadata = std::fs::metadata(&path)
.with_context(|| format!("reading bundle at {}", path.display()))?;
if metadata.len() > MAX_BUNDLE_BYTES {
bail!(
"bundle at {} is {} bytes; the limit is {MAX_BUNDLE_BYTES} bytes",
path.display(),
metadata.len()
);
}
std::fs::read(&path).with_context(|| format!("reading bundle at {}", path.display()))?
};
let bundle = parse_bundle_bytes(&raw, source_label)?;
let plan = plan_import(&bundle, &store.config, scope);
println!("import plan ({} scope, {source_label}):", scope.label());
println!(" added: {}", plan.added.len());
println!(" changed: {}", plan.changed.len());
println!(" skipped: {}", plan.skipped.len());
println!(" conflicting: {}", plan.conflicting.len());
println!(" rejected: {}", plan.rejected.len());
for entry in &plan.added {
println!(" + {entry}");
}
for entry in &plan.changed {
println!(" ~ {entry}");
}
for entry in &plan.rejected {
println!(" ! {} — {}", entry.key, entry.reason);
}
for entry in &plan.conflicting {
println!(" x {entry}");
}
if args.dry_run {
println!("dry run: nothing was written");
return Ok(());
}
require_import_consent(args.yes, &plan)?;
let receipt = apply_bundle(&bundle, store, scope, workspace)?;
if receipt.plan.is_no_op() {
println!("nothing to apply; config already matches the bundle (idempotent re-import)");
return Ok(());
}
println!(
"imported: {} added, {} changed into {}",
receipt.plan.added.len(),
receipt.plan.changed.len(),
receipt.target.display()
);
if let Some(backup) = &receipt.backup_path {
println!("pre-import backup: {}", backup.display());
}
Ok(())
}
pub fn run_export(args: &ExportArgs, store: &codewhale_config::ConfigStore) -> Result<()> {
if !args.portable {
bail!("config export requires --portable; plain export is not defined yet");
}
let scope = if args.project {
BundleScope::Project
} else {
BundleScope::Global
};
validate_scope_target(scope, store.path())?;
let metadata = BundleMetadata {
name: None,
created_at: Some(chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)),
generator: Some(format!("codewhale {}", env!("CARGO_PKG_VERSION"))),
};
let bundle = export_bundle(&store.config, scope, metadata)?;
let body = serialize_bundle(&bundle)?;
match &args.out {
Some(path) => {
codewhale_config::persistence::atomic_write(path, body.as_bytes())
.with_context(|| format!("writing bundle to {}", path.display()))?;
println!("wrote portable bundle to {}", path.display());
}
None => {
use std::io::Write;
std::io::stdout().write_all(body.as_bytes())?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use codewhale_config::ConfigStore;
use std::io::Write;
use std::net::{Ipv4Addr, TcpListener};
const VALID_TOML: &str = r#"
schema_version = 1
kind = "codewhale.portable-config"
[metadata]
name = "team-baseline"
[preferences]
verbosity = "quiet"
telemetry = false
[global]
output_mode = "plain"
"#;
fn sample_bundle() -> PortableBundle {
parse_bundle_str(VALID_TOML, "test.toml").expect("valid bundle")
}
#[test]
fn valid_bundle_parses_and_validates() {
let bundle = sample_bundle();
assert_eq!(bundle.schema_version, 1);
assert_eq!(bundle.kind, "codewhale.portable-config");
assert_eq!(bundle.metadata.name.as_deref(), Some("team-baseline"));
assert_eq!(bundle.preferences.entries.len(), 2);
}
#[test]
fn unknown_envelope_fields_fail_the_parse() {
let text = r#"
schema_version = 1
kind = "codewhale.portable-config"
sneaky_extra = true
"#;
let err = parse_bundle_str(text, "test.toml").expect_err("unknown field must fail");
let rendered = format!("{err:#}");
assert!(rendered.contains("unknown field"), "{rendered}");
}
#[test]
fn wrong_kind_or_schema_version_is_refused() {
let bad_kind = "schema_version = 1
kind = \"something-else\"\n";
let err = parse_bundle_str(bad_kind, "t.toml").expect_err("kind must match");
assert!(err.to_string().contains("kind"), "{err:#}");
let bad_version = "schema_version = 99\nkind = \"codewhale.portable-config\"\n";
let err = parse_bundle_str(bad_version, "t.toml").expect_err("schema version must match");
assert!(err.to_string().contains("schema_version"), "{err:#}");
}
#[test]
fn json_bundles_parse_when_the_document_is_json() {
let json = r#"{"schema_version": 1, "kind": "codewhale.portable-config",
"preferences": {"verbosity": "quiet"}}"#;
let bundle = parse_bundle_str(json, "bundle.json").expect("json bundle");
assert_eq!(bundle.preferences.entries.len(), 1);
}
#[test]
fn oversize_input_is_refused_before_parse() {
let big = vec![b'#'; (MAX_BUNDLE_BYTES + 1) as usize];
let err = parse_bundle_bytes(&big, "big.toml").expect_err("oversize must fail");
assert!(err.to_string().contains("limit"), "{err:#}");
}
#[test]
fn credential_keys_are_rejected_by_name() {
let text = r#"
schema_version = 1
kind = "codewhale.portable-config"
[global]
api_key = "value-is-never-echoed"
[preferences]
openai_api_key = "also-secret"
"#;
let bundle = parse_bundle_str(text, "t.toml").expect("parses");
let rejected = find_rejected_entries(&bundle);
assert_eq!(rejected.len(), 2, "{rejected:?}");
assert!(rejected.iter().any(|r| r.key == "global.api_key"));
assert!(
rejected
.iter()
.all(|r| !r.reason.contains("value-is-never-echoed"))
);
}
#[test]
fn credential_shaped_values_are_rejected_under_benign_names() {
let text = r#"
schema_version = 1
kind = "codewhale.portable-config"
[preferences]
note = "sk-abcdefghij0123456789"
"#;
let bundle = parse_bundle_str(text, "t.toml").expect("parses");
let rejected = find_rejected_entries(&bundle);
assert_eq!(rejected.len(), 1, "{rejected:?}");
assert!(!rejected[0].reason.contains("sk-abcdefghij"));
}
#[test]
fn nested_secret_keys_and_values_are_rejected_without_echoing_values() {
let shaped_value = ["Bear", "er nested-token-must-not-leak"].concat();
let text = format!(
r#"
schema_version = 1
kind = "codewhale.portable-config"
[preferences.with_key.nested]
password = "nested-password-must-not-leak"
[preferences.with_value.nested]
note = "{shaped_value}"
"#
);
let bundle = parse_bundle_str(&text, "nested.toml").expect("bundle parses");
let rejected = find_rejected_entries(&bundle);
assert_eq!(rejected.len(), 2, "{rejected:?}");
assert!(
rejected
.iter()
.any(|entry| entry.key == "preferences.with_key")
);
assert!(
rejected
.iter()
.any(|entry| entry.key == "preferences.with_value")
);
let rendered = format!("{rejected:?}");
assert!(!rendered.contains("nested-password-must-not-leak"));
assert!(!rendered.contains("nested-token-must-not-leak"));
}
#[test]
fn json_bundles_with_duplicate_keys_fail_before_parse() {
let duplicate = r#"{"schema_version":1,"kind":"codewhale.portable-config","preferences":{"verbosity":"quiet","verbosity":"loud"}}"#;
let error = parse_bundle_str(duplicate, "dup.json").expect_err("duplicate key must fail");
let rendered = format!("{error:#}");
assert!(rendered.contains("duplicate key"), "{rendered}");
assert!(rendered.contains("preferences.verbosity"), "{rendered}");
assert!(!rendered.contains("loud"), "{rendered}");
let nested_array = r#"{"schema_version":1,"kind":"codewhale.portable-config","preferences":{"list":[{"a":1,"a":2}]}}"#;
let error = parse_bundle_str(nested_array, "dup-array.json")
.expect_err("nested duplicate must fail");
assert!(
format!("{error:#}").contains("preferences.list.[0].a"),
"{error:#}"
);
let clean = r#"{"schema_version":1,"kind":"codewhale.portable-config","preferences":{"verbosity":"quiet","profiles":{"verbosity":"loud"}}}"#;
parse_bundle_str(clean, "clean.json").expect("same key under different parents is fine");
}
#[test]
fn network_proxy_routes_are_rejected_on_import_and_scrubbed_on_export() {
let proxy_url = ["http://proxy-user:proxy-", "pass@proxy.internal:3128"].concat();
let text = format!(
r#"
schema_version = 1
kind = "codewhale.portable-config"
[global.network]
default = "prompt"
allow = ["registry.example"]
proxy = ["{proxy_url}"]
"#
);
let bundle = parse_bundle_str(&text, "network-proxy.toml").expect("bundle parses");
let rejected = find_rejected_entries(&bundle);
assert_eq!(rejected.len(), 1, "{rejected:?}");
assert_eq!(rejected[0].key, "global.network");
let rendered = format!("{rejected:?}");
assert!(!rendered.contains("proxy-pass"), "{rendered}");
assert!(!rendered.contains("proxy.internal"), "{rendered}");
let config: ConfigToml = toml::from_str(&format!(
r#"
[network]
default = "prompt"
allow = ["registry.example"]
proxy = ["{proxy_url}"]
"#
))
.expect("network config parses");
let exported = export_bundle(&config, BundleScope::Global, BundleMetadata::default())
.expect("network proxy is scrubbed");
let body = serialize_bundle(&exported).expect("serialize network export");
assert!(!body.contains("proxy-user"), "{body}");
assert!(!body.contains("proxy.internal"), "{body}");
let reparsed: toml::Value = toml::from_str(&body).expect("export reparses");
let network = reparsed
.get("global")
.and_then(|global| global.get("network"))
.expect("portable network policy is kept");
assert!(network.get("proxy").is_none(), "{body}");
assert_eq!(
network.get("default").and_then(toml::Value::as_str),
Some("prompt")
);
assert!(body.contains("registry.example"), "{body}");
}
#[test]
fn redaction_placeholders_are_rejected_on_import_and_export() {
let placeholder = codewhale_config::persistence::REDACTED;
let text = format!(
r#"
schema_version = 1
kind = "codewhale.portable-config"
[preferences]
verbosity = "quiet"
note = "prefix {placeholder} suffix"
"#
);
let bundle = parse_bundle_str(&text, "placeholder.toml").expect("bundle parses");
let rejected = find_rejected_entries(&bundle);
assert_eq!(rejected.len(), 1, "{rejected:?}");
assert_eq!(rejected[0].key, "preferences.note");
assert!(
rejected[0].reason.contains("redaction placeholder"),
"{rejected:?}"
);
let config: ConfigToml = toml::from_str(&format!(
"verbosity = \"quiet\"\nnote = \"prefix {placeholder} suffix\"\n"
))
.expect("placeholder config parses");
let exported = export_bundle(&config, BundleScope::Global, BundleMetadata::default())
.expect("placeholder is scrubbed");
let body = serialize_bundle(&exported).expect("serialize placeholder export");
assert!(!body.contains(placeholder), "{body}");
assert!(body.contains("quiet"), "{body}");
}
#[test]
fn camel_case_and_dotted_secret_keys_avoid_token_count_false_positives() {
for key in [
"accessToken",
"refreshToken",
"clientSecret",
"apiKey",
"api.key",
"private.key",
"accessKey",
"aws_access_key",
"awsSecretAccessKey",
"Cookie",
"Set-Cookie",
"providers.xai.auth.command",
"providers.xai.external_credentials",
"providers.xai.oauth_credential_generation",
"nested.service.accessToken",
"nested.service.refreshToken",
] {
assert!(is_sensitive_bundle_key(key), "must reject {key}");
}
for key in [
"auth_mode",
"maxTokens",
"tokenizer",
"tokenBudget",
"max_tokens",
] {
assert!(!is_sensitive_bundle_key(key), "must preserve {key}");
}
}
#[test]
fn compound_and_access_keys_are_rejected_before_import_without_mutation() {
let api_dot = ["api", ".key"].concat();
let private_dot = ["private", ".key"].concat();
let access_camel = ["access", "Key"].concat();
let aws_snake = ["aws", "_access_key"].concat();
let aws_camel = ["aws", "SecretAccessKey"].concat();
let cookie = ["Coo", "kie"].concat();
let set_cookie = ["Set-", "Cookie"].concat();
let text = format!(
r#"
schema_version = 1
kind = "codewhale.portable-config"
[preferences]
"{api_dot}" = "opaque-api-value"
"{private_dot}" = "opaque-private-value"
{access_camel} = "opaque-access-value"
{aws_snake} = "opaque-aws-access-value"
{aws_camel} = "opaque-aws-secret-access-value"
maxTokens = 8192
tokenizer = "bpe"
[preferences.http_headers]
{cookie} = "opaque-cookie-import-value"
{set_cookie} = "opaque-set-cookie-import-value"
"#
);
let bundle =
parse_bundle_str(&text, "compound-secrets.toml").expect("compound-key bundle parses");
let rejected = find_rejected_entries(&bundle);
assert_eq!(rejected.len(), 6, "{rejected:?}");
assert!(
rejected
.iter()
.any(|entry| entry.key == "preferences.http_headers"),
"{rejected:?}"
);
assert!(
rejected
.iter()
.all(|entry| !entry.key.contains("maxTokens") && !entry.key.contains("tokenizer")),
"{rejected:?}"
);
let dir = tempfile::tempdir().expect("config dir");
let path = dir.path().join("config.toml");
std::fs::write(&path, "verbosity = \"quiet\"\n").expect("seed config");
let before = std::fs::read(&path).expect("config before import");
let mut store = ConfigStore::load(Some(path.clone())).expect("store loads");
let error = apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path())
.expect_err("credential keys must refuse the entire import");
let rendered = format!("{error:#}");
assert!(rendered.contains("conflicting or rejected"), "{rendered}");
for secret in [
"opaque-api-value",
"opaque-private-value",
"opaque-access-value",
"opaque-aws-access-value",
"opaque-aws-secret-access-value",
"opaque-cookie-import-value",
"opaque-set-cookie-import-value",
] {
assert!(
!rendered.contains(secret),
"error leaked {secret}: {rendered}"
);
}
assert_eq!(std::fs::read(path).expect("config after refusal"), before);
assert_eq!(store.config.verbosity.as_deref(), Some("quiet"));
}
#[test]
fn plan_reports_added_changed_skipped_deterministically() {
let store = isolated_store();
let bundle_text = r#"
schema_version = 1
kind = "codewhale.portable-config"
[preferences]
verbosity = "quiet"
log_level = "debug"
[global]
output_mode = "plain"
"#;
let bundle = parse_bundle_str(bundle_text, "t.toml").expect("bundle");
let plan_global = plan_import(&bundle, &store.config, BundleScope::Global);
assert!(
plan_global
.added
.contains(&"preferences.log_level".to_string())
);
let all: std::collections::BTreeSet<&String> = plan_global
.added
.iter()
.chain(plan_global.changed.iter())
.chain(plan_global.skipped.iter())
.collect();
assert_eq!(all.len(), 3, "{plan_global:?}");
let plan_project = plan_import(&bundle, &store.config, BundleScope::Project);
assert!(
plan_project
.skipped
.contains(&"global.output_mode".to_string())
);
}
#[test]
fn rejected_entries_show_up_as_conflicting_in_the_plan() {
let store = isolated_store();
let text = r#"
schema_version = 1
kind = "codewhale.portable-config"
[global]
api_key = "never-echoed"
"#;
let bundle = parse_bundle_str(text, "t.toml").expect("bundle");
let plan = plan_import(&bundle, &store.config, BundleScope::Global);
assert!(plan.conflicting.contains(&"global.api_key".to_string()));
assert!(plan.added.is_empty());
}
#[test]
fn duplicate_flat_keys_across_applicable_sections_fail_before_apply() {
let mut store = isolated_store();
let before = std::fs::read(store.path()).expect("config before import");
let text = r#"
schema_version = 1
kind = "codewhale.portable-config"
[preferences]
verbosity = "quiet"
[global]
verbosity = "verbose"
"#;
let bundle = parse_bundle_str(text, "collision.toml").expect("bundle parses");
let plan = plan_import(&bundle, &store.config, BundleScope::Global);
assert_eq!(
plan.conflicting,
["preferences.verbosity", "global.verbosity"]
);
assert!(plan.added.is_empty(), "{plan:?}");
assert!(plan.changed.is_empty(), "{plan:?}");
assert!(plan.skipped.is_empty(), "{plan:?}");
let workspace = tempfile::tempdir().expect("workspace");
let error = apply_bundle(&bundle, &mut store, BundleScope::Global, workspace.path())
.expect_err("ambiguous flat key must fail closed");
let rendered = error.to_string();
assert!(rendered.contains("conflicting"), "{error:#}");
assert!(!rendered.contains("quiet"), "{error:#}");
assert!(!rendered.contains("verbose"), "{error:#}");
assert_eq!(
std::fs::read(store.path()).expect("config after refused import"),
before,
"collision must be refused before any write"
);
}
#[test]
fn dry_run_semantics_plan_never_mutates() {
let store = isolated_store();
let before = std::fs::read_to_string(store.path()).expect("read config");
let bundle = sample_bundle();
let _plan = plan_import(&bundle, &store.config, BundleScope::Global);
let after = std::fs::read_to_string(store.path()).expect("read config");
assert_eq!(before, after, "planning must not write");
}
#[test]
fn apply_is_idempotent_on_reimport() {
let mut store = isolated_store();
let workspace = tempfile::tempdir().expect("workspace");
let bundle = sample_bundle();
let first = apply_bundle(&bundle, &mut store, BundleScope::Global, workspace.path())
.expect("first import");
assert!(first.plan.added.len() + first.plan.changed.len() > 0);
let second = apply_bundle(&bundle, &mut store, BundleScope::Global, workspace.path())
.expect("second import");
assert!(
second.plan.is_no_op(),
"re-import must be a no-op: {:?}",
second.plan
);
assert!(second.backup_path.is_none());
}
#[test]
fn immediate_mutating_imports_create_distinct_no_clobber_backups() {
let dir = tempfile::tempdir().expect("config dir");
let path = dir.path().join("config.toml");
let original = b"verbosity = \"quiet\"\n";
std::fs::write(&path, original).expect("seed config");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
.expect("restrict target permissions");
}
let mut store = ConfigStore::load(Some(path.clone())).expect("store loads");
let first_bundle = parse_bundle_str(
r#"
schema_version = 1
kind = "codewhale.portable-config"
[global]
verbosity = "verbose"
"#,
"first.toml",
)
.expect("first bundle parses");
let first = apply_bundle(&first_bundle, &mut store, BundleScope::Global, dir.path())
.expect("first import");
let first_backup = first.backup_path.expect("first backup receipt");
assert_eq!(
std::fs::read(&first_backup).expect("first backup"),
original
);
let after_first = std::fs::read(&path).expect("target after first import");
let second_bundle = parse_bundle_str(
r#"
schema_version = 1
kind = "codewhale.portable-config"
[global]
output_mode = "plain"
"#,
"second.toml",
)
.expect("second bundle parses");
let second = apply_bundle(&second_bundle, &mut store, BundleScope::Global, dir.path())
.expect("second import");
let second_backup = second.backup_path.expect("second backup receipt");
assert_ne!(first_backup, second_backup, "backups must never collide");
assert_eq!(
std::fs::read(&second_backup).expect("second backup"),
after_first,
"second receipt must preserve its exact pre-import document"
);
assert_eq!(
std::fs::read(&first_backup).expect("first backup remains"),
original,
"second import must not overwrite the first receipt"
);
for backup in [&first_backup, &second_backup] {
assert!(
backup
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.contains(".bundle-backup-")),
"unexpected backup name: {}",
backup.display()
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
assert_eq!(
std::fs::metadata(backup)
.expect("backup metadata")
.permissions()
.mode()
& 0o777,
0o600,
"backup must preserve restrictive target permissions"
);
}
}
}
#[test]
fn non_no_op_import_creates_a_missing_config_without_a_backup() {
let dir = tempfile::tempdir().expect("config dir");
let path = dir.path().join("config.toml");
let mut store = ConfigStore::load(Some(path.clone())).expect("missing config loads");
assert!(!path.exists(), "load must not create the config");
let receipt = apply_bundle(
&sample_bundle(),
&mut store,
BundleScope::Global,
dir.path(),
)
.expect("import creates config");
assert!(path.is_file(), "non-no-op import must create the config");
assert!(
receipt.backup_path.is_none(),
"no prior file means no backup"
);
let reloaded = ConfigStore::load(Some(path)).expect("created config reloads");
assert_eq!(reloaded.config.verbosity.as_deref(), Some("quiet"));
assert_eq!(reloaded.config.output_mode.as_deref(), Some("plain"));
}
#[test]
fn failed_import_removes_a_config_created_during_the_transaction() {
let dir = tempfile::tempdir().expect("config dir");
let path = dir.path().join("config.toml");
let mut store = ConfigStore::load(Some(path.clone())).expect("missing config loads");
let error = apply_bundle_with(
&sample_bundle(),
&mut store,
BundleScope::Global,
dir.path(),
|bundle, store, scope, workspace, target_written| {
apply_entries(bundle, store, scope, workspace, target_written)?;
bail!("forced failure after the new document was saved")
},
)
.expect_err("forced post-save failure must roll back");
assert!(error.to_string().contains("rolled back"), "{error:#}");
assert!(
!path.exists(),
"rollback must remove the newly-created file"
);
assert_eq!(
store.config.verbosity, None,
"in-memory state also rolls back"
);
}
#[test]
fn project_scope_never_touches_the_global_document() {
let mut store = isolated_store();
let global_before = std::fs::read_to_string(store.path()).expect("global doc");
let text = r#"
schema_version = 1
kind = "codewhale.portable-config"
[project]
approval_policy = "unless-allowed"
"#;
let bundle = parse_bundle_str(text, "t.toml").expect("bundle");
let ws = tempfile::tempdir().expect("ws");
apply_bundle(&bundle, &mut store, BundleScope::Project, ws.path())
.expect_err("project entries cannot land in a global-scoped store");
let global_after = std::fs::read_to_string(store.path()).expect("global doc");
assert_eq!(global_before, global_after);
}
#[cfg(unix)]
#[test]
fn failed_apply_rolls_back_to_the_prior_document() {
let mut store = isolated_store();
let original = std::fs::read_to_string(store.path()).expect("config");
let text = r#"
schema_version = 1
kind = "codewhale.portable-config"
[preferences]
log_level = "debug"
[global]
providers_deepseek_wire = "not-a-real-key-so-this-errors"
"#;
let _ = text;
let text_ok = r#"
schema_version = 1
kind = "codewhale.portable-config"
[preferences]
log_level = "debug"
"#;
let bundle = parse_bundle_str(text_ok, "t.toml").expect("bundle");
let path = store.path().to_path_buf();
use std::os::unix::fs::PermissionsExt;
let dir = path.parent().expect("config dir").to_path_buf();
let mut perms = std::fs::metadata(&dir).expect("dir meta").permissions();
perms.set_mode(0o555);
std::fs::set_permissions(&dir, perms).expect("chmod dir");
let result = apply_bundle(&bundle, &mut store, BundleScope::Global, Path::new("."));
let mut perms = std::fs::metadata(&dir).expect("dir meta").permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&dir, perms).expect("chmod restore");
assert!(result.is_err(), "apply must fail on a read-only document");
let restored = std::fs::read_to_string(&path).expect("config after rollback");
assert_eq!(restored, original, "rollback must preserve the prior bytes");
}
#[test]
fn export_is_deterministic_and_secret_free() {
let mut store = isolated_store();
store
.config
.set_value("verbosity", "quiet")
.expect("set verbosity");
store
.config
.set_value("default_text_model", "deepseek-v4-pro")
.expect("set model");
store.save().expect("save");
let metadata = BundleMetadata::default();
let one = export_bundle(&store.config, BundleScope::Global, metadata.clone())
.and_then(|b| serialize_bundle(&b))
.expect("export one");
let two = export_bundle(&store.config, BundleScope::Global, metadata)
.and_then(|b| serialize_bundle(&b))
.expect("export two");
assert_eq!(one, two, "export must be deterministic");
assert!(!one.contains("/Users/"), "{one}");
assert!(!one.contains("/home/"), "{one}");
}
#[test]
fn export_preserves_typed_structured_config_and_toml_value_kinds() {
let config: ConfigToml = toml::from_str(
r#"
provider = "deepseek"
telemetry = false
retry_count = 3
ratio = 1.25
started_at = 1979-05-27T07:32:00Z
labels = ["alpha", "beta"]
[skills]
registry_url = "https://registry.example/skills.json"
max_install_size_bytes = 12345
[snapshots]
enabled = false
max_age_days = 11
[portable_table]
enabled = true
count = 4
[[harness_profiles]]
provider_route = "deepseek"
model_pattern = "deepseek-v4-*"
[harness_profiles.posture]
kind = "custom"
max_subagents = 3
prefer_codebase_search = true
compaction_strategy = "prefix-cache"
tool_surface = "read-only"
safety_posture = "strict"
"#,
)
.expect("typed config parses");
let bundle = export_bundle(&config, BundleScope::Global, BundleMetadata::default())
.expect("typed export");
assert!(matches!(
bundle.profiles.entries.get("harness_profiles"),
Some(toml::Value::Array(_))
));
assert!(matches!(
bundle.preferences.entries.get("skills"),
Some(toml::Value::Table(_))
));
assert!(matches!(
bundle.preferences.entries.get("snapshots"),
Some(toml::Value::Table(_))
));
assert!(matches!(
bundle.global.entries.get("telemetry"),
Some(toml::Value::Boolean(false))
));
assert!(matches!(
bundle.global.entries.get("retry_count"),
Some(toml::Value::Integer(3))
));
assert!(matches!(
bundle.global.entries.get("ratio"),
Some(toml::Value::Float(value)) if *value == 1.25
));
assert!(
matches!(
bundle.global.entries.get("started_at"),
Some(toml::Value::Datetime(_))
),
"{bundle:#?}"
);
let dir = tempfile::tempdir().expect("round-trip dir");
let path = dir.path().join("config.toml");
let mut store = ConfigStore::load(Some(path.clone())).expect("fresh store");
apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path())
.expect("typed bundle imports");
let reloaded = ConfigStore::load(Some(path)).expect("typed config reloads");
let reexported = export_bundle(
&reloaded.config,
BundleScope::Global,
BundleMetadata::default(),
)
.expect("round-trip export");
assert_eq!(
serialize_bundle(&reexported).expect("serialize round trip"),
serialize_bundle(&bundle).expect("serialize original"),
"typed portable config must round-trip without stringification or loss"
);
let plan = plan_import(&bundle, &reloaded.config, BundleScope::Global);
assert!(
plan.is_no_op(),
"typed re-import must be idempotent: {plan:?}"
);
}
#[test]
fn typed_reimport_normalizes_omitted_serde_defaults_before_comparison() {
let bundle = parse_bundle_str(
r#"
schema_version = 1
kind = "codewhale.portable-config"
[preferences.snapshots]
enabled = false
"#,
"defaults.toml",
)
.expect("bundle with omitted typed default");
let dir = tempfile::tempdir().expect("config dir");
let path = dir.path().join("config.toml");
let mut store = ConfigStore::load(Some(path)).expect("fresh store");
apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path())
.expect("first typed import");
assert_eq!(
store
.config
.snapshots
.as_ref()
.expect("snapshots configured")
.max_age_days,
7,
"serde default must be materialized"
);
let plan = plan_import(&bundle, &store.config, BundleScope::Global);
assert!(
plan.is_no_op(),
"normalized re-import must be a no-op: {plan:?}"
);
}
#[test]
fn telemetry_opt_out_round_trips_but_opt_in_consent_never_does() {
let opted_in = ConfigToml {
telemetry: Some(true),
..ConfigToml::default()
};
let exported = export_bundle(&opted_in, BundleScope::Global, BundleMetadata::default())
.expect("opt-in export is safely omitted");
assert!(
!exported.global.entries.contains_key("telemetry"),
"opt-in consent must not be portable: {exported:?}"
);
let opt_in_bundle = parse_bundle_str(
r#"
schema_version = 1
kind = "codewhale.portable-config"
[global]
telemetry = true
"#,
"telemetry-opt-in.toml",
)
.expect("opt-in bundle parses before policy validation");
let rejected = find_rejected_entries(&opt_in_bundle);
assert_eq!(rejected.len(), 1, "{rejected:?}");
assert!(
rejected[0].reason.contains("opt-in consent"),
"{rejected:?}"
);
let dir = tempfile::tempdir().expect("config dir");
let path = dir.path().join("config.toml");
std::fs::write(&path, "verbosity = \"quiet\"\n").expect("seed config");
let before = std::fs::read(&path).expect("config before refusal");
let mut store = ConfigStore::load(Some(path.clone())).expect("store loads");
apply_bundle(&opt_in_bundle, &mut store, BundleScope::Global, dir.path())
.expect_err("portable opt-in consent must be refused");
assert_eq!(std::fs::read(&path).expect("config after refusal"), before);
assert_eq!(store.config.telemetry, None);
let opt_out_bundle = parse_bundle_str(
r#"
schema_version = 1
kind = "codewhale.portable-config"
[global]
telemetry = false
"#,
"telemetry-opt-out.toml",
)
.expect("opt-out bundle parses");
assert!(find_rejected_entries(&opt_out_bundle).is_empty());
apply_bundle(&opt_out_bundle, &mut store, BundleScope::Global, dir.path())
.expect("portable opt-out applies");
assert_eq!(store.config.telemetry, Some(false));
let reloaded = ConfigStore::load(Some(path)).expect("opt-out config reloads");
let plan = plan_import(&opt_out_bundle, &reloaded.config, BundleScope::Global);
assert!(
plan.is_no_op(),
"opt-out re-import must be idempotent: {plan:?}"
);
let reexported = export_bundle(
&reloaded.config,
BundleScope::Global,
BundleMetadata::default(),
)
.expect("opt-out re-exports");
assert_eq!(
reexported.global.entries.get("telemetry"),
Some(&toml::Value::Boolean(false))
);
}
#[test]
fn structured_import_deep_merges_without_erasing_local_authority() {
let dir = tempfile::tempdir().expect("config dir");
let path = dir.path().join("config.toml");
let api_key_name = ["api", "_key"].concat();
let api_key_env_name = ["api", "_key_env"].concat();
let target = format!(
r#"
provider = "acme_gateway"
[providers.acme_gateway]
kind = "openai-compatible"
base_url = "https://local-only.invalid/v1"
model = "old-model"
{api_key_name} = "opaque-local-api-value"
{api_key_env_name} = "LOCAL_ACME_GATEWAY_KEY"
[providers.acme_gateway.auth]
source = "command"
command = ["/synthetic/local-credential-helper"]
[lsp]
enabled = true
include_warnings = false
[lsp.servers]
rust = ["/synthetic/local-rust-analyzer", "--stdio"]
[lsp.custom.foo]
language_id = "foo-language"
command = "/synthetic/local-foo-lsp"
args = ["--stdio"]
[hook_sinks]
unix_socket_path = "/synthetic/local-codewhale.sock"
"#
);
std::fs::write(&path, target).expect("seed local-authority config");
let mut store = ConfigStore::load(Some(path.clone())).expect("target config loads");
let bundle = parse_bundle_str(
r#"
schema_version = 1
kind = "codewhale.portable-config"
[global]
provider = "acme_gateway"
[global.providers.acme_gateway]
kind = "openai-compatible"
model = "new-portable-model"
[global.lsp]
enabled = false
include_warnings = true
"#,
"deep-merge.toml",
)
.expect("portable update parses");
assert!(find_rejected_entries(&bundle).is_empty(), "{bundle:?}");
let receipt = apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path())
.expect("portable values merge into target");
assert_eq!(
receipt.plan.changed,
["global.lsp", "global.providers"],
"{:?}",
receipt.plan
);
assert_eq!(
receipt.plan.skipped,
["global.provider"],
"{:?}",
receipt.plan
);
assert_eq!(store.config.provider_id(), "acme_gateway");
let document = config_document(&store.config).expect("merged typed document");
let acme = document
.get("providers")
.and_then(toml::Value::as_table)
.and_then(|providers| providers.get("acme_gateway"))
.and_then(toml::Value::as_table)
.expect("custom provider survives");
assert_eq!(
acme.get("model").and_then(toml::Value::as_str),
Some("new-portable-model")
);
assert_eq!(
acme.get("base_url").and_then(toml::Value::as_str),
Some("https://local-only.invalid/v1")
);
assert_eq!(
acme.get("api_key").and_then(toml::Value::as_str),
Some("opaque-local-api-value")
);
assert_eq!(
acme.get("api_key_env").and_then(toml::Value::as_str),
Some("LOCAL_ACME_GATEWAY_KEY")
);
assert_eq!(
acme.get("auth")
.and_then(toml::Value::as_table)
.and_then(|auth| auth.get("command"))
.and_then(toml::Value::as_array)
.and_then(|command| command.first())
.and_then(toml::Value::as_str),
Some("/synthetic/local-credential-helper")
);
let lsp = store.config.lsp.as_ref().expect("LSP config survives");
assert_eq!(lsp.enabled, Some(false));
assert_eq!(lsp.include_warnings, Some(true));
assert!(lsp.servers.as_ref().is_some_and(|servers| {
servers
.get("rust")
.is_some_and(|command| command.first().is_some_and(|part| part.contains("rust")))
}));
assert!(
lsp.custom
.as_ref()
.is_some_and(|custom| custom.contains_key("foo"))
);
assert_eq!(
store
.config
.hook_sinks
.as_ref()
.and_then(|sinks| sinks.unix_socket_path.as_deref()),
Some(Path::new("/synthetic/local-codewhale.sock"))
);
let reloaded = ConfigStore::load(Some(path)).expect("merged config reloads");
assert_eq!(reloaded.config.provider_id(), "acme_gateway");
let plan = plan_import(&bundle, &reloaded.config, BundleScope::Global);
assert!(
plan.is_no_op(),
"deep-merged re-import must be idempotent: {plan:?}"
);
}
#[test]
fn export_recursively_drops_nested_secrets_but_keeps_safe_typed_siblings() {
let provider_prefix = ["s", "k-"].concat();
let bearer_prefix = ["Bear", "er "].concat();
let access_key = ["access", "Token"].concat();
let dotted_refresh_key = ["service.refresh", "Token"].concat();
let refresh_key = ["refresh", "Token"].concat();
let fixture = format!(
r#"
[tools]
always_load = ["read_file", "{provider_prefix}nested-tool-value-must-not-leak", "write_file"]
[portable]
safe_count = 7
note = "{bearer_prefix}nested-export-value-must-not-leak"
values = ["plain", "{provider_prefix}nested-array-value-must-not-leak"]
[portable.nested]
{access_key} = "nested-export-key-must-not-leak"
"{dotted_refresh_key}" = "nested-dotted-value-must-not-leak"
label = "keep-me"
[[portable.records]]
{refresh_key} = "nested-record-value-must-not-leak"
count = 2
[[portable.records]]
label = "safe-record"
"#
);
let config: ConfigToml = toml::from_str(&fixture).expect("secret-bearing config parses");
let bundle = export_bundle(&config, BundleScope::Global, BundleMetadata::default())
.expect("safe export");
let body = serialize_bundle(&bundle).expect("serialize export");
for secret in [
"nested-export-value-must-not-leak",
"nested-array-value-must-not-leak",
"nested-tool-value-must-not-leak",
"nested-export-key-must-not-leak",
"nested-dotted-value-must-not-leak",
"nested-record-value-must-not-leak",
] {
assert!(!body.contains(secret), "export leaked {secret}: {body}");
}
assert!(body.contains("safe_count = 7"), "{body}");
assert!(body.contains("label = \"keep-me\""), "{body}");
assert!(body.contains("label = \"safe-record\""), "{body}");
assert!(body.contains("values = [\"plain\"]"), "{body}");
assert!(find_rejected_entries(&bundle).is_empty(), "{bundle:?}");
let dir = tempfile::tempdir().expect("sanitized import dir");
let path = dir.path().join("config.toml");
let mut store = ConfigStore::load(Some(path)).expect("fresh store");
apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path())
.expect("sanitized typed arrays re-import");
assert_eq!(
store
.config
.tools
.as_ref()
.expect("tools preserved")
.always_load
.as_slice(),
["read_file", "write_file"],
"dropping a secret array element must preserve a valid typed array"
);
let plan = plan_import(&bundle, &store.config, BundleScope::Global);
assert!(
plan.is_no_op(),
"sanitized re-import must be idempotent: {plan:?}"
);
}
#[test]
fn compound_access_and_cookie_fields_are_scrubbed_on_export() {
let cookie = ["Coo", "kie"].concat();
let set_cookie = ["Set-", "Cookie"].concat();
let api_dot = ["api", ".key"].concat();
let private_dot = ["private", ".key"].concat();
let access_camel = ["access", "Key"].concat();
let aws_snake = ["aws", "_access_key"].concat();
let aws_camel = ["aws", "SecretAccessKey"].concat();
let fixture = format!(
r#"
[http_headers]
{cookie} = "opaque-cookie-value"
{set_cookie} = "opaque-set-cookie-value"
X-Safe = "portable-header"
[portable]
"{api_dot}" = "opaque-api-value"
"{private_dot}" = "opaque-private-value"
{access_camel} = "opaque-access-value"
{aws_snake} = "opaque-aws-access-value"
{aws_camel} = "opaque-aws-secret-access-value"
maxTokens = 8192
tokenizer = "bpe"
"#
);
let config: ConfigToml = toml::from_str(&fixture).expect("credential-key config parses");
let bundle = export_bundle(&config, BundleScope::Global, BundleMetadata::default())
.expect("credential fields are scrubbed");
let body = serialize_bundle(&bundle).expect("serialize scrubbed export");
for forbidden in [
"opaque-cookie-value",
"opaque-set-cookie-value",
"opaque-api-value",
"opaque-private-value",
"opaque-access-value",
"opaque-aws-access-value",
"opaque-aws-secret-access-value",
"api.key",
"private.key",
"accessKey",
"aws_access_key",
"awsSecretAccessKey",
"Cookie",
"Set-Cookie",
] {
assert!(
!body.contains(forbidden),
"export retained {forbidden}: {body}"
);
}
assert!(body.contains("X-Safe = \"portable-header\""), "{body}");
assert!(body.contains("maxTokens = 8192"), "{body}");
assert!(body.contains("tokenizer = \"bpe\""), "{body}");
assert!(find_rejected_entries(&bundle).is_empty(), "{bundle:?}");
}
#[test]
fn provider_credential_authority_is_rejected_on_import_and_scrubbed_on_export() {
let dir = tempfile::tempdir().expect("config dir");
let path = dir.path().join("config.toml");
std::fs::write(&path, "verbosity = \"quiet\"\n").expect("seed config");
let before = std::fs::read(&path).expect("config before imports");
let mut store = ConfigStore::load(Some(path.clone())).expect("store loads");
let api_key_env_name = ["api", "_key_env"].concat();
for (name, body) in [
(
"auth",
r#"
[global.providers.xai.auth]
source = "command"
command = ["synthetic-credential-helper"]
"#
.to_string(),
),
(
"external",
r#"
[global.providers.xai.external_credentials]
access = "read_only"
provider = "xai"
source = "grok_cli"
path = "/synthetic/external/auth.json"
consent_version = 1
"#
.to_string(),
),
(
"oauth-generation",
r#"
[global.providers.xai]
oauth_credential_generation = "synthetic-owned-generation.toml"
"#
.to_string(),
),
(
"api-key-env",
format!(
r#"
[global.providers.xai]
{api_key_env_name} = "SYNTHETIC_RANDOM_PROVIDER_KEY"
"#
),
),
] {
let text = format!("schema_version = 1\nkind = \"codewhale.portable-config\"\n{body}");
let bundle = parse_bundle_str(&text, name).expect("authority bundle parses");
assert_eq!(find_rejected_entries(&bundle).len(), 1, "{name}");
let error = apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path())
.expect_err("authority-bearing import must fail");
assert!(
error.to_string().contains("conflicting or rejected"),
"{name}: {error:#}"
);
assert_eq!(std::fs::read(&path).expect("config after refusal"), before);
assert_eq!(store.config.verbosity.as_deref(), Some("quiet"));
}
let fixture = format!(
r#"
provider = "xai"
[providers.xai]
model = "grok-safe-model"
oauth_credential_generation = "synthetic-owned-generation.toml"
{api_key_env_name} = "SYNTHETIC_RANDOM_PROVIDER_KEY"
[providers.xai.auth]
source = "command"
command = ["synthetic-credential-helper"]
[providers.xai.external_credentials]
access = "read_only"
provider = "xai"
source = "grok_cli"
path = "/synthetic/external/auth.json"
consent_version = 1
"#
);
let config: ConfigToml =
toml::from_str(&fixture).expect("provider authority config parses");
let exported = export_bundle(&config, BundleScope::Global, BundleMetadata::default())
.expect("provider authority is scrubbed");
let body = serialize_bundle(&exported).expect("serialize provider export");
for forbidden in [
"external_credentials",
"oauth_credential_generation",
"synthetic-credential-helper",
"synthetic-owned-generation.toml",
"SYNTHETIC_RANDOM_PROVIDER_KEY",
api_key_env_name.as_str(),
"/synthetic/external/auth.json",
] {
assert!(
!body.contains(forbidden),
"export retained {forbidden}: {body}"
);
}
assert!(body.contains("model = \"grok-safe-model\""), "{body}");
assert!(find_rejected_entries(&exported).is_empty(), "{exported:?}");
}
#[test]
fn machine_local_route_and_path_fields_are_rejected_and_scrubbed_symmetrically() {
let bundle = parse_bundle_str(
r#"
schema_version = 1
kind = "codewhale.portable-config"
[global]
telemetry_endpoint = "https://synthetic.invalid/telemetry"
mcpConfigPath = "/synthetic/import-mcp.json"
[global.providers.deepseek]
baseUrl = "https://synthetic.invalid/provider/v1"
model = "safe-model"
[global.hook_sinks]
unix_socket_path = "/synthetic/import-codewhale.sock"
"#,
"machine-local-paths.toml",
)
.expect("machine-local bundle parses");
let rejected = find_rejected_entries(&bundle);
assert_eq!(rejected.len(), 4, "{rejected:?}");
for key in [
"global.telemetry_endpoint",
"global.mcpConfigPath",
"global.providers",
"global.hook_sinks",
] {
assert!(
rejected.iter().any(|entry| entry.key == key),
"{rejected:?}"
);
}
let dir = tempfile::tempdir().expect("config dir");
let path = dir.path().join("config.toml");
std::fs::write(&path, "verbosity = \"quiet\"\n").expect("seed config");
let before = std::fs::read(&path).expect("config before import");
let mut store = ConfigStore::load(Some(path.clone())).expect("store loads");
let error = apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path())
.expect_err("machine-local paths must refuse the entire import");
assert!(
error.to_string().contains("conflicting or rejected"),
"{error:#}"
);
assert_eq!(std::fs::read(&path).expect("config after refusal"), before);
assert_eq!(store.config.verbosity.as_deref(), Some("quiet"));
let config: ConfigToml = toml::from_str(
r#"
telemetry_endpoint = "https://synthetic.invalid/telemetry"
mcp_config_path = "/synthetic/export-mcp.json"
[providers.deepseek]
base_url = "https://synthetic.invalid/provider/v1"
model = "safe-model"
[hook_sinks]
unix_socket_path = "/synthetic/export-codewhale.sock"
"#,
)
.expect("machine-local config parses");
let exported = export_bundle(&config, BundleScope::Global, BundleMetadata::default())
.expect("machine-local paths are scrubbed");
let body = serialize_bundle(&exported).expect("serialize machine-local export");
for forbidden in [
"synthetic.invalid",
"/synthetic/export-mcp.json",
"/synthetic/export-codewhale.sock",
"telemetry_endpoint",
"mcp_config_path",
"unix_socket_path",
"base_url",
] {
assert!(
!body.contains(forbidden),
"export retained {forbidden}: {body}"
);
}
assert!(body.contains("model = \"safe-model\""), "{body}");
assert!(find_rejected_entries(&exported).is_empty(), "{exported:?}");
}
#[test]
fn remaining_local_authority_is_rejected_while_safe_policy_stays_portable() {
for safe in [
"databaseUrl",
"baseUrlTemplate",
"memoryPathology",
"sandboxUrlTemplate",
"skills.registry_url",
"network.allow",
"workflow.automatic",
"fleet.exec.allowed_tools",
] {
assert_eq!(nonportable_path_reason(safe), None, "must preserve {safe}");
}
let bundle = parse_bundle_str(
r#"
schema_version = 1
kind = "codewhale.portable-config"
[global]
instructions = "/synthetic/import-instructions.md"
project_instruction_imports = "all"
projectInstructionImports = "all"
sandbox_backend = "synthetic-local-backend"
sandboxUrl = "http://127.0.0.1:47891"
bwrapRoRoots = ["/synthetic/import-ro"]
bwrap_dev_roots = ["/synthetic/import-dev"]
skills_dir = "/synthetic/import-skills"
memoryPath = "/synthetic/import-memory.md"
mcpOauthCallbackUrl = "http://127.0.0.1:47892/callback"
mcp_oauth_callback_port = 47892
notes_path = "/synthetic/import-notes.md"
[global.runtime_api]
bind = "127.0.0.1:47893"
[global.auto_review]
allow = ["synthetic-shell-action"]
[global.tools]
always_load = ["read_file"]
plugin_dir = "/synthetic/import-plugins"
[global.tools.overrides.shell]
command = "/synthetic/import-tool-override"
[global.update]
channel = "stable"
update_uri = "file:///synthetic/import-update"
[global.notifications]
enabled = true
sound_file = "/synthetic/import-sound.wav"
[global.speech]
enabled = true
output_dir = "/synthetic/import-speech"
[global.skills]
registry_url = "https://registry.example/skills.json"
[global.network]
default = "prompt"
allow = ["registry.example"]
[global.workflow]
automatic = false
[global.fleet.exec]
allowed_tools = ["read_file"]
"#,
"remaining-local-authority.toml",
)
.expect("remaining authority bundle parses");
let rejected = find_rejected_entries(&bundle);
assert_eq!(rejected.len(), 18, "{rejected:?}");
for safe in [
"global.skills",
"global.network",
"global.workflow",
"global.fleet",
] {
assert!(
rejected.iter().all(|entry| entry.key != safe),
"safe entry {safe} was rejected: {rejected:?}"
);
}
let dir = tempfile::tempdir().expect("config dir");
let path = dir.path().join("config.toml");
std::fs::write(&path, "verbosity = \"quiet\"\n").expect("seed config");
let before = std::fs::read(&path).expect("config before import");
let mut store = ConfigStore::load(Some(path.clone())).expect("store loads");
apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path())
.expect_err("remaining local authority import must fail");
assert_eq!(std::fs::read(&path).expect("config after refusal"), before);
assert_eq!(store.config.verbosity.as_deref(), Some("quiet"));
let config: ConfigToml = toml::from_str(
r#"
instructions = "/synthetic/export-instructions.md"
project_instruction_imports = "all"
projectInstructionImports = "all"
sandbox_backend = "synthetic-local-backend"
sandboxUrl = "http://127.0.0.1:47894"
bwrapRoRoots = ["/synthetic/export-ro"]
bwrap_dev_roots = ["/synthetic/export-dev"]
skills_dir = "/synthetic/export-skills"
memoryPath = "/synthetic/export-memory.md"
mcpOauthCallbackUrl = "http://127.0.0.1:47895/callback"
mcp_oauth_callback_port = 47895
notes_path = "/synthetic/export-notes.md"
[runtime_api]
bind = "127.0.0.1:47896"
[auto_review]
allow = ["synthetic-shell-action"]
[tools]
always_load = ["read_file"]
plugin_dir = "/synthetic/export-plugins"
[tools.overrides.shell]
command = "/synthetic/export-tool-override"
[update]
channel = "stable"
update_uri = "file:///synthetic/export-update"
[notifications]
enabled = true
sound_file = "/synthetic/export-sound.wav"
[speech]
enabled = true
output_dir = "/synthetic/export-speech"
[skills]
registry_url = "https://registry.example/skills.json"
max_install_size_bytes = 12345
[network]
default = "prompt"
allow = ["registry.example"]
[workflow]
automatic = false
[fleet.exec]
allowed_tools = ["read_file"]
"#,
)
.expect("remaining authority config parses");
let exported = export_bundle(&config, BundleScope::Global, BundleMetadata::default())
.expect("remaining authority is scrubbed");
for key in [
"instructions",
"project_instruction_imports",
"projectInstructionImports",
"sandbox_backend",
"sandboxUrl",
"bwrapRoRoots",
"bwrap_dev_roots",
"skills_dir",
"memoryPath",
"mcpOauthCallbackUrl",
"mcp_oauth_callback_port",
"notes_path",
"runtime_api",
"auto_review",
] {
assert!(!exported.global.entries.contains_key(key), "retained {key}");
}
let body = serialize_bundle(&exported).expect("serialize safe policy export");
for forbidden in [
"/synthetic/export-instructions.md",
"/synthetic/export-ro",
"/synthetic/export-dev",
"/synthetic/export-skills",
"/synthetic/export-memory.md",
"/synthetic/export-notes.md",
"/synthetic/export-sound.wav",
"/synthetic/export-speech",
"file:///synthetic/export-update",
"127.0.0.1:47896",
] {
assert!(
!body.contains(forbidden),
"export retained {forbidden}: {body}"
);
}
for safe in [
"https://registry.example/skills.json",
"registry.example",
"automatic = false",
"allowed_tools = [\"read_file\"]",
"channel = \"stable\"",
"enabled = true",
] {
assert!(body.contains(safe), "export lost {safe}: {body}");
}
let raw: toml::Value = toml::from_str(
r#"
[tools]
always_load = ["read_file"]
plugin_dir = "/synthetic/direct-plugin-dir"
[tools.overrides.shell]
command = "/synthetic/direct-tool-override"
"#,
)
.expect("raw tools table parses");
let scrubbed =
sanitize_export_value("tools", raw.get("tools").expect("raw tools table exists"))
.expect("safe tools sibling remains");
let scrubbed = scrubbed.to_string();
assert!(scrubbed.contains("read_file"), "{scrubbed}");
assert!(!scrubbed.contains("plugin_dir"), "{scrubbed}");
assert!(!scrubbed.contains("overrides"), "{scrubbed}");
assert!(find_rejected_entries(&exported).is_empty(), "{exported:?}");
}
#[test]
fn lsp_executable_authority_is_rejected_while_inert_settings_remain_portable() {
let config: ConfigToml = toml::from_str(
r#"
[lsp]
enabled = true
poll_after_edit_ms = 250
max_diagnostics_per_file = 12
include_warnings = true
[lsp.servers]
rust = ["/synthetic/rust-analyzer", "--stdio"]
[lsp.custom.foo]
language_id = "foo-language"
command = "/synthetic/foo-language-server"
args = ["--stdio", "--synthetic"]
"#,
)
.expect("LSP config parses");
let exported = export_bundle(&config, BundleScope::Global, BundleMetadata::default())
.expect("LSP executable authority is scrubbed");
let body = serialize_bundle(&exported).expect("serialize LSP export");
for forbidden in [
"/synthetic/rust-analyzer",
"/synthetic/foo-language-server",
"foo-language",
"--stdio",
"--synthetic",
] {
assert!(
!body.contains(forbidden),
"export retained {forbidden}: {body}"
);
}
for inert in [
"enabled = true",
"poll_after_edit_ms = 250",
"max_diagnostics_per_file = 12",
"include_warnings = true",
] {
assert!(body.contains(inert), "export lost {inert}: {body}");
}
assert!(find_rejected_entries(&exported).is_empty(), "{exported:?}");
let dir = tempfile::tempdir().expect("config dir");
let path = dir.path().join("config.toml");
std::fs::write(&path, "verbosity = \"quiet\"\n").expect("seed config");
let before = std::fs::read(&path).expect("config before imports");
let mut store = ConfigStore::load(Some(path.clone())).expect("store loads");
for (name, body) in [
(
"servers",
r#"
[global.lsp]
enabled = true
[global.lsp.servers]
rust = ["/synthetic/import-rust-analyzer", "--stdio"]
"#,
),
(
"custom",
r#"
[global.lsp]
include_warnings = true
[global.lsp.custom.foo]
language_id = "foo-language"
command = "/synthetic/import-foo-server"
args = ["--stdio"]
"#,
),
] {
let text = format!("schema_version = 1\nkind = \"codewhale.portable-config\"\n{body}");
let bundle = parse_bundle_str(&text, name).expect("LSP bundle parses");
assert_eq!(find_rejected_entries(&bundle).len(), 1, "{name}");
apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path())
.expect_err("LSP executable authority import must fail");
assert_eq!(std::fs::read(&path).expect("config after refusal"), before);
assert_eq!(store.config.verbosity.as_deref(), Some("quiet"));
}
}
#[test]
fn machine_bound_authority_subtrees_are_rejected_and_never_exported() {
let config: ConfigToml = toml::from_str(
r#"
managed_config_path = "/synthetic/managed-config.toml"
requirements_path = "/synthetic/requirements.md"
[workspace]
root = "/synthetic/workspace-root"
trust = "trusted"
allow_shell = true
[projects."/synthetic/project-root"]
trust = "trusted"
allow_shell = true
[hooks.session_start]
command = "/synthetic/session-start"
[portable.workspace]
label = "safe-nested-workspace-label"
[portable.projects]
label = "safe-nested-projects-label"
[portable.hooks]
label = "safe-nested-hooks-label"
"#,
)
.expect("machine-bound authority config parses");
let exported = export_bundle(&config, BundleScope::Global, BundleMetadata::default())
.expect("machine-bound authority is scrubbed");
assert!(!exported.global.entries.contains_key("workspace"));
assert!(!exported.global.entries.contains_key("projects"));
assert!(!exported.global.entries.contains_key("hooks"));
assert!(!exported.global.entries.contains_key("managed_config_path"));
assert!(!exported.global.entries.contains_key("requirements_path"));
let body = serialize_bundle(&exported).expect("serialize machine-bound export");
for path in [
"/synthetic/workspace-root",
"/synthetic/project-root",
"/synthetic/session-start",
"/synthetic/managed-config.toml",
"/synthetic/requirements.md",
] {
assert!(!body.contains(path), "export retained {path}: {body}");
}
assert!(body.contains("safe-nested-workspace-label"), "{body}");
assert!(body.contains("safe-nested-projects-label"), "{body}");
assert!(body.contains("safe-nested-hooks-label"), "{body}");
let bundle = parse_bundle_str(
r#"
schema_version = 1
kind = "codewhale.portable-config"
[global]
managed_config_path = "/synthetic/import-managed-config.toml"
requirements_path = "/synthetic/import-requirements.md"
[global.workspace]
root = "/synthetic/import-workspace"
trust = "trusted"
allow_shell = true
[global.projects."/synthetic/import-project"]
trust = "trusted"
allow_shell = true
[global.hooks.session_start]
command = "/synthetic/import-session-start"
[global.portable.workspace]
label = "safe-nested-workspace-label"
"#,
"machine-bound-authority.toml",
)
.expect("machine-bound authority bundle parses");
let rejected = find_rejected_entries(&bundle);
assert_eq!(rejected.len(), 5, "{rejected:?}");
assert!(rejected.iter().any(|entry| entry.key == "global.workspace"));
assert!(rejected.iter().any(|entry| entry.key == "global.projects"));
assert!(rejected.iter().any(|entry| entry.key == "global.hooks"));
assert!(
rejected
.iter()
.any(|entry| entry.key == "global.managed_config_path")
);
assert!(
rejected
.iter()
.any(|entry| entry.key == "global.requirements_path")
);
let dir = tempfile::tempdir().expect("config dir");
let path = dir.path().join("config.toml");
std::fs::write(&path, "verbosity = \"quiet\"\n").expect("seed config");
let before = std::fs::read(&path).expect("config before import");
let mut store = ConfigStore::load(Some(path.clone())).expect("store loads");
apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path())
.expect_err("machine-bound authority import must fail");
assert_eq!(std::fs::read(path).expect("config after refusal"), before);
assert_eq!(store.config.verbosity.as_deref(), Some("quiet"));
}
#[test]
fn bundle_scope_must_match_the_target_for_import_and_export() {
let dir = tempfile::tempdir().expect("config dir");
let global_path = dir.path().join("config.toml");
let error = validate_scope_target(BundleScope::Project, &global_path)
.expect_err("global path cannot masquerade as project scope");
assert!(error.to_string().contains("workspace config"), "{error:#}");
validate_scope_target(BundleScope::Global, &global_path)
.expect("global config accepts global scope");
let project_path = dir.path().join(".codewhale").join("config.toml");
std::fs::create_dir(dir.path().join(".git")).expect("checkout marker");
validate_scope_target(BundleScope::Project, &project_path)
.expect("workspace config accepts project scope");
let error = validate_scope_target(BundleScope::Global, &project_path)
.expect_err("workspace path cannot masquerade as global scope");
assert!(error.to_string().contains("--project"), "{error:#}");
let mut store = ConfigStore::load(Some(project_path)).expect("workspace store loads");
let import = ImportArgs {
source: dir
.path()
.join("must-not-be-read.toml")
.display()
.to_string(),
dry_run: true,
yes: true,
project: false,
};
let error = run_import(&import, &mut store, dir.path())
.expect_err("global import must refuse a workspace config before reading input");
assert!(error.to_string().contains("--project"), "{error:#}");
let output = dir.path().join("must-not-be-written.toml");
let export = ExportArgs {
portable: true,
project: false,
out: Some(output.clone()),
};
let error = run_export(&export, &store)
.expect_err("global export must refuse a workspace config before writing output");
assert!(error.to_string().contains("--project"), "{error:#}");
assert!(!output.exists(), "refused export must not create an output");
}
#[test]
fn exported_bundle_reimports_cleanly() {
let mut store = isolated_store();
store.config.set_value("verbosity", "quiet").expect("set");
store.save().expect("save");
let bundle = export_bundle(
&store.config,
BundleScope::Global,
BundleMetadata::default(),
)
.expect("export");
let rejected = find_rejected_entries(&bundle);
assert!(
rejected.is_empty(),
"export must be secret-free: {rejected:?}"
);
let plan = plan_import(&bundle, &store.config, BundleScope::Global);
assert!(
plan.rejected.is_empty() && plan.conflicting.is_empty(),
"own export must not trip rejection: {plan:?}"
);
}
#[test]
fn http_non_loopback_fetch_is_refused_without_network_access() {
let err = fetch_bundle("http://example.com/bundle.toml")
.expect_err("plain http to a public host must be refused");
assert!(err.to_string().contains("loopback"), "{err:#}");
assert!(!err.to_string().contains("example.com"), "{err:#}");
}
#[test]
fn redirect_to_non_loopback_http_is_refused_without_leaking_location() {
let secret = "location-secret-must-not-leak";
let location = format!("http://example.com/internal?token={secret}");
let response = http_response("302 Found", &[("Location", location.as_str())], b"");
let (url, server) = spawn_bundle_http_server(vec![response]);
let error = fetch_bundle(&url).expect_err("redirect target must be revalidated");
let rendered = format!("{error:#}");
assert!(rendered.contains("loopback"), "{rendered}");
assert!(!rendered.contains("example.com"), "{rendered}");
assert!(!rendered.contains(secret), "{rendered}");
assert_eq!(server.join().expect("server joins"), 1);
}
#[test]
fn https_redirect_cannot_downgrade_to_loopback_http() {
let secret = "downgrade-secret-must-not-leak";
let target = reqwest::Url::parse(&format!("http://127.0.0.1/internal?token={secret}"))
.expect("test target URL");
let error = validate_bundle_redirect("https", &target)
.expect_err("HTTPS redirect must not downgrade to loopback HTTP");
let rendered = format!("{error:#}");
assert!(rendered.contains("scheme"), "{rendered}");
assert!(!rendered.contains(secret), "{rendered}");
assert!(!rendered.contains("127.0.0.1"), "{rendered}");
}
#[test]
fn relative_loopback_redirect_fetches_bundle() {
let redirect = http_response("302 Found", &[("Location", "/bundle.toml")], b"");
let body = VALID_TOML.as_bytes();
let success = http_response("200 OK", &[("Content-Type", "text/plain")], body);
let (url, server) = spawn_bundle_http_server(vec![redirect, success]);
let fetched = fetch_bundle(&url).expect("relative redirect remains allowed");
assert_eq!(fetched, body);
assert_eq!(server.join().expect("server joins"), 2);
}
#[test]
fn redirect_limit_is_enforced_before_a_sixth_hop() {
let responses = (0..=MAX_REDIRECTS)
.map(|hop| {
let location = format!("/hop-{}", hop + 1);
http_response("302 Found", &[("Location", location.as_str())], b"")
})
.collect();
let (url, server) = spawn_bundle_http_server(responses);
let error = fetch_bundle(&url).expect_err("sixth redirect must be refused");
assert!(error.to_string().contains("five-redirect"), "{error:#}");
assert_eq!(server.join().expect("server joins"), MAX_REDIRECTS + 1);
}
#[test]
fn bundle_url_credentials_are_rejected_without_echoing_them() {
let secret = "credential-secret-must-not-leak";
let error = fetch_bundle(&format!("https://user:{secret}@example.com/bundle.toml"))
.expect_err("URL userinfo must be refused");
let rendered = format!("{error:#}");
assert!(rendered.contains("credentials"), "{rendered}");
assert!(!rendered.contains(secret), "{rendered}");
assert!(!rendered.contains("example.com"), "{rendered}");
}
#[test]
fn unsupported_schemes_are_refused() {
let err = fetch_bundle("file:///etc/passwd").expect_err("file scheme refused");
assert!(err.to_string().contains("scheme"), "{err:#}");
}
#[test]
fn headless_import_requires_yes() {
let plan = ImportPlan {
added: vec!["preferences.x".to_string()],
..ImportPlan::default()
};
let err = require_import_consent(false, &plan).expect_err("headless needs --yes");
assert!(err.to_string().contains("--yes"), "{err:#}");
require_import_consent(true, &plan).expect("--yes short-circuits consent");
}
#[test]
fn bounded_paths_refuse_traversal_and_absolute_escapes() {
let base = tempfile::tempdir().expect("base");
let err =
resolve_bounded_path(base.path(), "../escape.toml").expect_err("traversal refused");
assert!(
err.to_string().contains("escapes") || err.to_string().contains("absolute"),
"{err:#}"
);
let absolute = base.path().join("absolute.toml");
let err = resolve_bounded_path(base.path(), absolute.to_string_lossy().as_ref())
.expect_err("absolute refused");
assert!(err.to_string().contains("absolute"), "{err:#}");
let ok = resolve_bounded_path(base.path(), "nested/thing.toml").expect("inside ok");
assert!(ok.starts_with(base.path()));
}
fn http_response(status: &str, headers: &[(&str, &str)], body: &[u8]) -> Vec<u8> {
let mut response = format!(
"HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n",
body.len()
)
.into_bytes();
for (name, value) in headers {
response.extend_from_slice(format!("{name}: {value}\r\n").as_bytes());
}
response.extend_from_slice(b"\r\n");
response.extend_from_slice(body);
response
}
fn spawn_bundle_http_server(
responses: Vec<Vec<u8>>,
) -> (String, std::thread::JoinHandle<usize>) {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind HTTP fixture");
let address = listener.local_addr().expect("fixture address");
let handle = std::thread::spawn(move || {
let mut served = 0usize;
for response in responses {
let (mut stream, _) = listener.accept().expect("accept fixture request");
stream
.set_read_timeout(Some(std::time::Duration::from_secs(5)))
.expect("fixture read timeout");
let mut request = Vec::new();
let mut chunk = [0_u8; 1024];
while !request.windows(4).any(|window| window == b"\r\n\r\n") {
let read = stream.read(&mut chunk).expect("read fixture request");
if read == 0 {
break;
}
request.extend_from_slice(&chunk[..read]);
}
stream.write_all(&response).expect("write fixture response");
served += 1;
}
served
});
(format!("http://{address}/start"), handle)
}
fn isolated_store() -> ConfigStore {
let _guard = crate::tests::env_lock();
let dir = {
let temp = tempfile::TempDir::new().expect("tempdir");
temp.keep()
};
let unique = dir.join("home").join(std::process::id().to_string());
std::fs::create_dir_all(&unique).expect("unique home");
unsafe { std::env::set_var("CODEWHALE_HOME", &unique) };
let path = unique.join("config.toml");
std::fs::write(&path, "# test config\n").expect("seed config file");
ConfigStore::load(Some(path)).expect("store loads")
}
}