use crate::json::OValue;
pub const FORMAT: &str = "airs-cli-rs.redteam-targets";
pub const VERSION: u64 = 1;
pub const CREATE_KEYS: [&str; 18] = [
"name",
"description",
"target_type",
"connection_type",
"api_endpoint_type",
"response_mode",
"connection_params",
"auth_config",
"auth_type",
"canonical_id",
"session_supported",
"target_metadata",
"target_background",
"additional_context",
"extra_info",
"network_broker_channel_uuid",
"adapter_uuid",
"adapter_variable_overrides",
];
pub fn projection(raw: &OValue) -> OValue {
let mut entries = Vec::new();
for key in CREATE_KEYS {
let Some(value) = raw.get(key) else {
continue;
};
if matches!(value, OValue::Null) {
continue;
}
let value = match key {
"target_metadata" => metadata(value),
"adapter_variable_overrides" => overrides(value),
_ => value.clone(),
};
entries.push((key.to_owned(), value));
}
OValue::Object(entries)
}
fn metadata(value: &OValue) -> OValue {
match value {
OValue::Object(entries) if value.get("multi_turn") == Some(&OValue::Bool(false)) => {
OValue::Object(
entries
.iter()
.filter(|(key, _)| key != "multi_turn_error_message")
.cloned()
.collect(),
)
}
other => other.clone(),
}
}
fn overrides(value: &OValue) -> OValue {
match value {
OValue::Array(items) => OValue::Array(
items
.iter()
.map(|item| match item {
OValue::Object(_) => OValue::Object(
["key", "value", "type"]
.iter()
.filter_map(|key| {
item.get(key)
.map(|value| ((*key).to_owned(), value.clone()))
})
.collect(),
),
other => other.clone(),
})
.collect(),
),
other => other.clone(),
}
}
pub const WITHHELD_REASON: &str = "the service withheld its credentials when it was backed up, so it cannot be restored; recreate the target or set its credentials by hand";
#[derive(Clone, Debug, PartialEq)]
pub struct Stored {
pub uuid: String,
pub name: String,
pub credentials_withheld: bool,
pub target: OValue,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Failed {
pub uuid: String,
pub name: String,
pub error: String,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Backup {
pub exported_at: String,
pub tenant: String,
pub tsg_id: String,
pub targets: Vec<Stored>,
pub failed: Vec<Failed>,
}
fn text(value: &str) -> OValue {
OValue::String(value.to_owned())
}
impl Backup {
pub fn document(&self) -> OValue {
OValue::Object(vec![
("format".into(), text(FORMAT)),
("version".into(), OValue::Number(VERSION.into())),
("exportedAt".into(), text(&self.exported_at)),
("tenant".into(), text(&self.tenant)),
("tsgId".into(), text(&self.tsg_id)),
("complete".into(), OValue::Bool(self.failed.is_empty())),
(
"targets".into(),
OValue::Array(
self.targets
.iter()
.map(|stored| {
OValue::Object(vec![
("uuid".into(), text(&stored.uuid)),
("name".into(), text(&stored.name)),
(
"credentialsWithheld".into(),
OValue::Bool(stored.credentials_withheld),
),
("target".into(), stored.target.clone()),
])
})
.collect(),
),
),
(
"failed".into(),
OValue::Array(
self.failed
.iter()
.map(|failed| {
OValue::Object(vec![
("uuid".into(), text(&failed.uuid)),
("name".into(), text(&failed.name)),
("error".into(), text(&failed.error)),
])
})
.collect(),
),
),
])
}
pub fn to_text(&self) -> String {
let mut text = crate::json::to_pretty(&self.document()).unwrap_or_default();
text.push('\n');
text
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ReadFailure {
Malformed,
TypeScript,
Newer,
Invalid(String),
}
const ENVELOPE: [&str; 8] = [
"format",
"version",
"exportedAt",
"tenant",
"tsgId",
"complete",
"targets",
"failed",
];
fn typescript_yaml(text: &str) -> bool {
text.lines().any(|line| {
let line = line.trim_end();
line.starts_with("resourceType:") || line == "version: '1'" || line == "version: \"1\""
})
}
pub fn parse(text: &str, valid_target: impl Fn(&OValue) -> bool) -> Result<Backup, ReadFailure> {
let document = crate::json::parse(text).map_err(|_| {
if typescript_yaml(text) {
ReadFailure::TypeScript
} else {
ReadFailure::Malformed
}
})?;
let invalid = |reason: &str| ReadFailure::Invalid(reason.to_owned());
let OValue::Object(entries) = &document else {
return Err(invalid("the file does not hold a JSON object"));
};
if document.get("resourceType").is_some()
|| matches!(document.get("version"), Some(OValue::String(_)))
{
return Err(ReadFailure::TypeScript);
}
if document.get("format").and_then(OValue::as_str) != Some(FORMAT) {
return Err(invalid("it is not an airs-cli-rs red team target backup"));
}
let version = match document.get("version") {
Some(OValue::Number(number)) => number.as_u64(),
_ => None,
};
match version {
Some(VERSION) => {}
Some(version) if version > VERSION => return Err(ReadFailure::Newer),
_ => return Err(invalid("version must be 1")),
}
if let Some((key, _)) = entries
.iter()
.find(|(key, _)| !ENVELOPE.contains(&key.as_str()))
{
let _ = key;
return Err(invalid("the envelope has an unknown key"));
}
let string = |key: &str| -> Result<String, ReadFailure> {
document
.get(key)
.and_then(OValue::as_str)
.map(str::to_owned)
.ok_or_else(|| ReadFailure::Invalid(format!("{key} must be a string")))
};
let exported_at = string("exportedAt")?;
let tenant = string("tenant")?;
let tsg_id = string("tsgId")?;
let Some(OValue::Bool(complete)) = document.get("complete") else {
return Err(invalid("complete must be true or false"));
};
let Some(OValue::Array(targets)) = document.get("targets") else {
return Err(invalid("targets must be an array"));
};
let Some(OValue::Array(failed)) = document.get("failed") else {
return Err(invalid("failed must be an array"));
};
let entry = |value: &OValue, keys: &[&str], index: usize, list: &str| {
let OValue::Object(fields) = value else {
return Err(ReadFailure::Invalid(format!(
"{list}[{index}] must be an object"
)));
};
if fields.len() != keys.len()
|| keys.iter().any(|key| value.get(key).is_none())
|| fields.iter().any(|(key, _)| !keys.contains(&key.as_str()))
{
return Err(ReadFailure::Invalid(format!(
"{list}[{index}] must have exactly the keys {}",
keys.join(", ")
)));
}
Ok(())
};
let mut stored = Vec::with_capacity(targets.len());
for (index, value) in targets.iter().enumerate() {
entry(
value,
&["uuid", "name", "credentialsWithheld", "target"],
index,
"targets",
)?;
let (Some(uuid), Some(name)) = (
value.get("uuid").and_then(OValue::as_str),
value.get("name").and_then(OValue::as_str),
) else {
return Err(ReadFailure::Invalid(format!(
"targets[{index}] uuid and name must be strings"
)));
};
let target = value.get("target").cloned().unwrap_or(OValue::Null);
if name.is_empty() || target.get("name").and_then(OValue::as_str) != Some(name) {
return Err(ReadFailure::Invalid(format!(
"targets[{index}] name must be the target's name"
)));
}
let Some(OValue::Bool(credentials_withheld)) = value.get("credentialsWithheld") else {
return Err(ReadFailure::Invalid(format!(
"targets[{index}] credentialsWithheld must be true or false"
)));
};
if !valid_target(&target) {
return Err(ReadFailure::Invalid(format!(
"targets[{index}] does not match the target create schema"
)));
}
stored.push(Stored {
uuid: uuid.to_owned(),
name: name.to_owned(),
credentials_withheld: *credentials_withheld,
target,
});
}
let mut failures = Vec::with_capacity(failed.len());
for (index, value) in failed.iter().enumerate() {
entry(value, &["uuid", "name", "error"], index, "failed")?;
let field = |key: &str| value.get(key).and_then(OValue::as_str).map(str::to_owned);
let (Some(uuid), Some(name), Some(error)) = (field("uuid"), field("name"), field("error"))
else {
return Err(ReadFailure::Invalid(format!(
"failed[{index}] uuid, name and error must be strings"
)));
};
failures.push(Failed { uuid, name, error });
}
if *complete != failures.is_empty() {
return Err(invalid("complete does not match the failed list"));
}
Ok(Backup {
exported_at,
tenant,
tsg_id,
targets: stored,
failed: failures,
})
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Planned {
Failed(String),
Skip(String),
Update(String),
Create,
}
pub fn plan(
backup: &Backup,
tenant_targets: &[(String, String)],
overwrite: bool,
withheld: impl Fn(&OValue) -> bool,
) -> Vec<Planned> {
backup
.targets
.iter()
.map(|stored| {
let name = &stored.name;
let shown = crate::output::terminal_safe(name);
if backup
.targets
.iter()
.filter(|other| other.name == *name)
.count()
> 1
{
return Planned::Failed(format!(
"the backup holds more than one target named {shown}"
));
}
if stored.credentials_withheld || withheld(&stored.target) {
return Planned::Failed(WITHHELD_REASON.to_owned());
}
let existing: Vec<&String> = tenant_targets
.iter()
.filter(|(other, _)| other == name)
.map(|(_, uuid)| uuid)
.collect();
match existing.as_slice() {
[] => Planned::Create,
[uuid] if overwrite => Planned::Update((*uuid).clone()),
[uuid] => Planned::Skip((*uuid).clone()),
_ => Planned::Failed(format!("more than one target named {shown} exists")),
}
})
.collect()
}
pub fn with_routing(stored: &OValue, current: Option<&OValue>) -> OValue {
let mut entries = stored.as_object().unwrap_or_default().to_vec();
let fields: &[(&str, Option<&str>)] = &[
("target_type", None),
("connection_type", Some("CUSTOM")),
("api_endpoint_type", Some("PUBLIC")),
("response_mode", Some("REST")),
];
for (key, default) in fields {
let missing = !entries.iter().any(|(name, value)| {
name == key && matches!(value, OValue::String(text) if !text.is_empty())
});
if !missing {
continue;
}
let value = current
.and_then(|current| current.get(key))
.filter(|value| matches!(value, OValue::String(text) if !text.is_empty()))
.cloned()
.or_else(|| default.map(text));
if let Some(value) = value {
match entries.iter_mut().find(|(name, _)| name == key) {
Some((_, slot)) => *slot = value,
None => entries.push(((*key).to_owned(), value)),
}
}
}
OValue::Object(entries)
}
pub fn stamp(iso: &str) -> String {
let mut out: String = iso
.chars()
.take(19)
.filter(|c| *c != '-' && *c != ':')
.collect();
out.push('Z');
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::json::parse as json;
fn backup(targets: &[(&str, &str)]) -> Backup {
Backup {
exported_at: "2026-10-17T12:00:00.000Z".into(),
tenant: "prod".into(),
tsg_id: "100".into(),
targets: targets
.iter()
.map(|(uuid, name)| Stored {
uuid: (*uuid).into(),
name: (*name).into(),
credentials_withheld: false,
target: json(&format!(
r#"{{"name": "{name}", "target_type": "APPLICATION"}}"#
))
.unwrap(),
})
.collect(),
failed: Vec::new(),
}
}
#[test]
fn the_projection_keeps_the_create_keys_in_schema_order() {
let raw = json(
r#"{"uuid": "u", "status": "ACTIVE", "auth_type": "HEADERS", "name": "t",
"target_metadata": {"multi_turn": false, "multi_turn_error_message": "noise", "rate_limit": 5},
"adapter_variable_overrides": [{"type": "SECRET", "value": "s", "key": "k", "is_redacted": false}],
"description": null, "canonical_id": "c", "created_at": "x"}"#,
)
.unwrap();
assert_eq!(
crate::json::to_compact(&projection(&raw)).unwrap(),
r#"{"name":"t","auth_type":"HEADERS","canonical_id":"c","target_metadata":{"multi_turn":false,"rate_limit":5},"adapter_variable_overrides":[{"key":"k","value":"s","type":"SECRET"}]}"#
);
}
#[test]
fn a_written_backup_reads_back() {
let mut written = backup(&[("u1", "one"), ("u2", "two")]);
written.targets[1].credentials_withheld = true;
written.failed.push(Failed {
uuid: "u3".into(),
name: "three".into(),
error: "AISEC_SDK_ERROR:x".into(),
});
let text = written.to_text();
assert!(
text.starts_with(
"{\n \"format\": \"airs-cli-rs.redteam-targets\",\n \"version\": 1,\n"
)
);
assert!(text.contains("\"complete\": false"));
assert!(text.contains(
"\"name\": \"two\",\n \"credentialsWithheld\": true,\n \"target\": {"
));
assert_eq!(parse(&text, |_| true).unwrap(), written);
}
#[test]
fn foreign_and_broken_files_are_refused_with_fixed_reasons() {
let refused = |text: &str| parse(text, |_| true).unwrap_err();
assert_eq!(refused("{"), ReadFailure::Malformed);
assert_eq!(refused(r#"{"a": "\ud800"}"#), ReadFailure::Malformed);
assert_eq!(
refused(r#"{"version": "1", "resourceType": "redteam-target", "data": {}}"#),
ReadFailure::TypeScript
);
assert_eq!(refused(r#"{"version": "1"}"#), ReadFailure::TypeScript);
assert_eq!(
refused(
"version: '1'\nresourceType: redteam-target\nexportedAt: '2026-10-17T12:00:00.000Z'\ndata:\n name: t\n"
),
ReadFailure::TypeScript
);
assert_eq!(
refused("version: \"1\"\nresourceType: redteam-target\n"),
ReadFailure::TypeScript
);
assert_eq!(
refused("name: t\n resourceType: x\n"),
ReadFailure::Malformed
);
let newer = backup(&[])
.to_text()
.replace("\"version\": 1", "\"version\": 2");
assert_eq!(refused(&newer), ReadFailure::Newer);
let extra = backup(&[]).to_text().replacen('{', "{\"extra\": 1,", 1);
assert!(matches!(refused(&extra), ReadFailure::Invalid(_)));
let lying = backup(&[])
.to_text()
.replace("\"complete\": true", "\"complete\": false");
assert!(matches!(refused(&lying), ReadFailure::Invalid(_)));
let text = backup(&[("u1", "one")]).to_text();
assert_eq!(
parse(&text, |_| false).unwrap_err(),
ReadFailure::Invalid("targets[0] does not match the target create schema".into())
);
let renamed = text.replace(
"\"name\": \"one\",\n \"credentialsWithheld\"",
"\"name\": \"other\",\n \"credentialsWithheld\"",
);
assert!(matches!(
parse(&renamed, |_| true),
Err(ReadFailure::Invalid(_))
));
assert!(matches!(refused("[]"), ReadFailure::Invalid(_)));
let unmarked = text.replace(" \"credentialsWithheld\": false,\n", "");
assert_eq!(
parse(&unmarked, |_| true).unwrap_err(),
ReadFailure::Invalid(
"targets[0] must have exactly the keys uuid, name, credentialsWithheld, target"
.into()
)
);
let textual = text.replace(
"\"credentialsWithheld\": false",
"\"credentialsWithheld\": \"no\"",
);
assert_eq!(
parse(&textual, |_| true).unwrap_err(),
ReadFailure::Invalid("targets[0] credentialsWithheld must be true or false".into())
);
}
#[test]
fn the_plan_applies_the_first_rule_that_matches() {
let mut file = backup(&[
("u1", "dup"),
("u2", "dup"),
("u3", "secret"),
("u4", "two"),
("u5", "one"),
("u6", "new"),
("u7", "marked"),
]);
file.targets[6].credentials_withheld = true;
let tenant = [
("two".to_owned(), "t1".to_owned()),
("two".to_owned(), "t2".to_owned()),
("one".to_owned(), "t3".to_owned()),
];
let withheld =
|target: &OValue| target.get("name").and_then(OValue::as_str) == Some("secret");
let rows = plan(&file, &tenant, false, withheld);
assert_eq!(
rows,
[
Planned::Failed("the backup holds more than one target named dup".into()),
Planned::Failed("the backup holds more than one target named dup".into()),
Planned::Failed(WITHHELD_REASON.into()),
Planned::Failed("more than one target named two exists".into()),
Planned::Skip("t3".into()),
Planned::Create,
Planned::Failed(WITHHELD_REASON.into()),
]
);
assert_eq!(
plan(&file, &tenant, true, withheld)[4],
Planned::Update("t3".into())
);
}
#[test]
fn routing_defaults_fill_only_what_is_missing() {
let stored =
json(r#"{"name": "t", "target_type": "MODEL", "response_mode": "STREAMING"}"#).unwrap();
assert_eq!(
crate::json::to_compact(&with_routing(&stored, None)).unwrap(),
r#"{"name":"t","target_type":"MODEL","response_mode":"STREAMING","connection_type":"CUSTOM","api_endpoint_type":"PUBLIC"}"#
);
let bare = json(r#"{"name": "t"}"#).unwrap();
let current = json(r#"{"target_type": "AGENT", "connection_type": "OPENAI"}"#).unwrap();
assert_eq!(
crate::json::to_compact(&with_routing(&bare, Some(¤t))).unwrap(),
r#"{"name":"t","target_type":"AGENT","connection_type":"OPENAI","api_endpoint_type":"PUBLIC","response_mode":"REST"}"#
);
assert_eq!(stamp("2026-10-17T12:03:04.567Z"), "20261017T120304Z");
}
}