use std::path::Path;
use agent_migrate_core::{FileKind, SecretPolicy, SecretRef, redact_json};
use toml_edit::{Array, ArrayOfTables, DocumentMut, InlineTable, Item, Table, Value, value};
use crate::error::AdapterError;
#[derive(Debug)]
pub(crate) struct SanitizedResource {
pub(crate) content: Vec<u8>,
pub(crate) mcp: Option<Vec<u8>>,
pub(crate) references: Vec<SecretRef>,
}
pub(crate) fn sanitize(
path: &Path,
kind: FileKind,
bytes: &[u8],
) -> Result<SanitizedResource, AdapterError> {
if !matches!(kind, FileKind::Settings | FileKind::Mcp | FileKind::Hook) {
return Ok(SanitizedResource {
content: bytes.to_vec(),
mcp: None,
references: Vec::new(),
});
}
let text = std::str::from_utf8(bytes).map_err(|error| AdapterError::Structured {
path: path.to_path_buf(),
message: error.to_string(),
})?;
if path.extension().and_then(|extension| extension.to_str()) == Some("toml") {
return sanitize_toml(path, text);
}
let mut value: serde_json::Value =
serde_json::from_str(text).map_err(|error| AdapterError::Structured {
path: path.to_path_buf(),
message: error.to_string(),
})?;
filter_dynamic_state(path, &mut value);
let mut redacted = redact_json(&value, &SecretPolicy::default())?;
disable_embedded_hooks(&mut redacted.value);
let mcp = if kind == FileKind::Settings {
extract_mcp(&mut redacted.value).map(|servers| serde_json::json!({"mcpServers": servers}))
} else {
None
};
Ok(SanitizedResource {
content: serialize(path, &disabled_hook(kind, redacted.value))?,
mcp: mcp.map(|value| serialize(path, &value)).transpose()?,
references: redacted.references,
})
}
fn sanitize_toml(path: &Path, text: &str) -> Result<SanitizedResource, AdapterError> {
let mut document = text
.parse::<DocumentMut>()
.map_err(|error| structured(path, error))?;
let mut redactor = TomlRedactor::new();
redactor.table(document.as_table_mut(), "$", path)?;
let value: serde_json::Value =
toml_edit::de::from_str(&document.to_string()).map_err(|error| structured(path, error))?;
let mut mcp_value = value;
let mcp = extract_mcp(&mut mcp_value)
.map(|servers| serialize(path, &serde_json::json!({"mcpServers": servers})));
let _removed = document.remove("mcp_servers");
Ok(SanitizedResource {
content: document.to_string().into_bytes(),
mcp: mcp.transpose()?,
references: redactor.references,
})
}
#[derive(Debug)]
struct TomlRedactor {
policy: SecretPolicy,
references: Vec<SecretRef>,
}
impl TomlRedactor {
fn new() -> Self {
Self {
policy: SecretPolicy::default(),
references: Vec::new(),
}
}
fn table(
&mut self,
table: &mut Table,
location: &str,
path: &Path,
) -> Result<(), AdapterError> {
for (key, item) in table.iter_mut() {
let key = key.get().to_owned();
let child = format!("{location}.{key}");
if self.policy.is_sensitive(&key) {
self.reference(&key, &child);
*item = value(format!("${{SECRET_REF:{}}}", reference_name(&key)));
} else {
self.item(item, &child, path)?;
}
}
Ok(())
}
fn item(&mut self, item: &mut Item, location: &str, path: &Path) -> Result<(), AdapterError> {
match item {
Item::Table(table) => self.table(table, location, path),
Item::ArrayOfTables(tables) => self.array_of_tables(tables, location, path),
Item::Value(value) => self.toml_value(value, location, path),
Item::None => Ok(()),
}
}
fn array_of_tables(
&mut self,
tables: &mut ArrayOfTables,
location: &str,
path: &Path,
) -> Result<(), AdapterError> {
for (index, table) in tables.iter_mut().enumerate() {
self.table(table, &format!("{location}[{index}]"), path)?;
}
Ok(())
}
fn toml_value(
&mut self,
value: &mut Value,
location: &str,
path: &Path,
) -> Result<(), AdapterError> {
match value {
Value::InlineTable(table) => self.inline_table(table, location, path),
Value::Array(array) => self.array(array, location, path),
Value::String(_)
| Value::Integer(_)
| Value::Float(_)
| Value::Boolean(_)
| Value::Datetime(_) => Ok(()),
}
}
fn inline_table(
&mut self,
table: &mut InlineTable,
location: &str,
path: &Path,
) -> Result<(), AdapterError> {
for (key, value) in table.iter_mut() {
let key = key.get().to_owned();
let child = format!("{location}.{key}");
if self.policy.is_sensitive(&key) {
self.reference(&key, &child);
*value = Value::from(format!("${{SECRET_REF:{}}}", reference_name(&key)));
} else {
self.toml_value(value, &child, path)?;
}
}
Ok(())
}
fn array(
&mut self,
array: &mut Array,
location: &str,
path: &Path,
) -> Result<(), AdapterError> {
for (index, value) in array.iter_mut().enumerate() {
self.toml_value(value, &format!("{location}[{index}]"), path)?;
}
Ok(())
}
fn reference(&mut self, key: &str, logical_location: &str) {
self.references.push(SecretRef {
name: reference_name(key),
logical_location: logical_location.to_owned(),
required: true,
});
}
}
fn filter_dynamic_state(path: &Path, value: &mut serde_json::Value) {
if path.file_name().and_then(std::ffi::OsStr::to_str) != Some(".claude.json") {
return;
}
let Some(object) = value.as_object_mut() else {
return;
};
for key in [
"projects",
"history",
"fileHistory",
"trust",
"oauthAccount",
"userID",
"telemetry",
"daemon",
"backups",
"cachedStatsigGates",
] {
let _removed = object.remove(key);
}
}
fn disable_embedded_hooks(value: &mut serde_json::Value) {
match value {
serde_json::Value::Object(object) => {
for (key, child) in object.iter_mut() {
if key.eq_ignore_ascii_case("hooks") {
let source = std::mem::take(child);
*child = serde_json::json!({"enabled": false, "source": source});
} else {
disable_embedded_hooks(child);
}
}
}
serde_json::Value::Array(values) => {
for value in values {
disable_embedded_hooks(value);
}
}
_ => {}
}
}
fn disabled_hook(kind: FileKind, value: serde_json::Value) -> serde_json::Value {
if kind == FileKind::Hook {
serde_json::json!({"enabled": false, "source": value})
} else {
value
}
}
fn reference_name(key: &str) -> String {
key.chars()
.map(|character| {
if character.is_ascii_alphanumeric() {
character.to_ascii_uppercase()
} else {
'_'
}
})
.collect()
}
fn structured(path: &Path, error: impl std::fmt::Display) -> AdapterError {
AdapterError::Structured {
path: path.to_path_buf(),
message: error.to_string(),
}
}
fn extract_mcp(value: &mut serde_json::Value) -> Option<serde_json::Value> {
let object = value.as_object_mut()?;
object
.remove("mcp_servers")
.or_else(|| object.remove("mcpServers"))
}
fn serialize(path: &Path, value: &serde_json::Value) -> Result<Vec<u8>, AdapterError> {
serde_json::to_vec_pretty(value).map_err(|error| AdapterError::Structured {
path: path.to_path_buf(),
message: error.to_string(),
})
}