use std::collections::{BTreeMap, HashMap};
use serde_json::{Map, Value, json};
use super::policy::{self, strict_eq};
use crate::dlp::backup::retired_profile;
use crate::json::OValue;
use crate::json::{PathStep, at_path};
use crate::tenant::config::js_trim;
pub const MAX_INVENTORY: usize = 10_000;
pub const PAGE_LIMIT: u32 = 100;
const MAX_SAFE: f64 = 9_007_199_254_740_991.0;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Refusal {
Stated(String),
Withheld(String),
}
impl Refusal {
pub fn message(&self) -> &str {
match self {
Self::Stated(message) | Self::Withheld(message) => message,
}
}
pub fn stated(&self) -> Option<&str> {
match self {
Self::Stated(message) => Some(message),
Self::Withheld(_) => None,
}
}
}
impl From<Refusal> for String {
fn from(refusal: Refusal) -> Self {
match refusal {
Refusal::Stated(message) | Refusal::Withheld(message) => message,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Collection {
Profiles,
Topics,
}
impl Collection {
pub fn key(self) -> &'static str {
match self {
Self::Profiles => "ai_profiles",
Self::Topics => "custom_topics",
}
}
pub fn label(self) -> &'static str {
match self {
Self::Profiles => "security profiles",
Self::Topics => "custom topics",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Step {
Next(u64),
Done,
}
#[derive(Debug)]
pub struct OffsetWalk {
collection: Collection,
absent: String,
max_pages: u64,
pages: u64,
offset: u64,
items: Vec<OValue>,
}
pub const INCOMPLETE: &str =
"Inventory is incomplete: increase --max-pages before exporting or restoring";
impl OffsetWalk {
pub fn new(collection: Collection, max_pages: u64, absent: String) -> Self {
Self {
collection,
absent,
max_pages,
pages: 0,
offset: 0,
items: Vec::new(),
}
}
pub fn start(&self) -> u64 {
0
}
pub fn into_items(self) -> Vec<OValue> {
self.items
}
pub fn accept(&mut self, answer: &OValue) -> Result<Step, Refusal> {
let Some(OValue::Array(rows)) = answer.get(self.collection.key()) else {
return Err(Refusal::Stated(self.absent.clone()));
};
self.pages += 1;
self.items.extend(rows.iter().cloned());
if self.items.len() > MAX_INVENTORY {
return Err(Refusal::Stated(format!(
"Inventory exceeds the {MAX_INVENTORY}-record safety limit"
)));
}
let next = match answer.get("next_offset") {
None => return Ok(Step::Done),
Some(OValue::Number(number)) => number.as_f64(),
Some(_) => None,
};
if next == Some(0.0) {
return Ok(Step::Done);
}
let advance = match next {
Some(next) if next.fract() == 0.0 && (0.0..=MAX_SAFE).contains(&next) => {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let next = next as u64;
(!rows.is_empty() && next > self.offset).then_some(next)
}
_ => None,
};
let Some(next) = advance else {
return Err(Refusal::Stated(
"Inventory pagination did not advance".to_owned(),
));
};
if self.pages >= self.max_pages {
return Err(Refusal::Stated(INCOMPLETE.to_owned()));
}
self.offset = next;
Ok(Step::Next(next))
}
}
pub const NAME_RULE: &str =
"Profile/topic names must be nonempty and contain no control characters";
pub fn assert_name(name: &str) -> Result<(), String> {
if js_trim(name).is_empty() || name.chars().any(|c| (c as u32) < 32 || c as u32 == 127) {
Err(NAME_RULE.to_owned())
} else {
Ok(())
}
}
pub fn text<'a>(record: &'a Value, key: &str) -> Option<&'a str> {
record.get(key).and_then(Value::as_str)
}
fn name_of<'a>(record: &'a Value, key: &str) -> &'a str {
text(record, key).unwrap_or_default()
}
fn revision_or_zero(record: &Value) -> f64 {
record
.get("revision")
.and_then(Value::as_f64)
.unwrap_or(0.0)
}
fn quoted(text: &str) -> String {
serde_json::to_string(text).unwrap_or_default()
}
fn scalar(value: Option<&Value>) -> String {
match value {
None | Some(Value::Null) => "null".to_owned(),
Some(Value::Number(number)) => number
.as_f64()
.map_or_else(|| number.to_string(), crate::render::js_number),
Some(Value::String(text)) => quoted(text),
Some(other) => other.to_string(),
}
}
pub fn latest_profiles(profiles: &[Value]) -> Result<Vec<usize>, Refusal> {
let mut names: Vec<(String, usize)> = Vec::new();
let mut seen = std::collections::HashSet::new();
for (index, profile) in profiles.iter().enumerate() {
let name = name_of(profile, "profile_name");
assert_name(name).map_err(Refusal::Withheld)?;
let identity = format!(
"[{},{},{}]",
quoted(name),
scalar(profile.get("profile_id")),
scalar(profile.get("revision"))
);
if !seen.insert(identity) {
return Err(Refusal::Stated(
"Inventory contains repeated profile records".to_owned(),
));
}
match names.iter_mut().find(|(known, _)| known == name) {
Some((_, prior)) => {
let earlier = &profiles[*prior];
if !strict_eq(earlier.get("profile_id"), profile.get("profile_id")) {
return Err(Refusal::Withheld(format!("Ambiguous profile name: {name}")));
}
if revision_or_zero(profile) > revision_or_zero(earlier) {
*prior = index;
}
}
None => names.push((name.to_owned(), index)),
}
}
names.sort_by(|(a, _), (b, _)| crate::report::locale_order(a, b));
Ok(names.into_iter().map(|(_, index)| index).collect())
}
pub fn assert_tenant<'a>(
profiles: impl IntoIterator<Item = &'a Value>,
tsg: &str,
) -> Result<(), Refusal> {
let other = profiles
.into_iter()
.any(|profile| match profile.get("tsg_id") {
None | Some(Value::Null) => false,
Some(Value::String(id)) => id != tsg,
Some(other) => scalar(Some(other)) != tsg,
});
if other {
Err(Refusal::Stated(
"Destination/source profile tenant identity does not match the selected TSG".into(),
))
} else {
Ok(())
}
}
pub fn inventory_profiles(items: &[OValue]) -> Result<Vec<Value>, Refusal> {
let values: Vec<Value> = items.iter().map(crate::json::value_of).collect();
let kept = latest_profiles(&values)?;
Ok(kept
.into_iter()
.map(|index| values[index].clone())
.collect())
}
#[derive(Clone, Debug, PartialEq)]
pub struct TopicRef {
pub path: Vec<PathStep>,
pub topic_id: String,
pub topic_name: String,
pub revision: f64,
}
impl TopicRef {
pub fn key(&self) -> String {
topic_key(Some(&Value::String(self.topic_id.clone())), self.revision)
}
}
pub fn topic_key(id: Option<&Value>, revision: f64) -> String {
format!("[{},{}]", scalar(id), crate::render::js_number(revision))
}
pub fn record_key(topic: &Value) -> String {
topic_key(
topic.get("topic_id"),
topic
.get("revision")
.and_then(Value::as_f64)
.unwrap_or(f64::NAN),
)
}
fn is_object(value: Option<&Value>) -> bool {
matches!(value, Some(Value::Object(_)))
}
pub fn topic_references(policy: Option<&Value>) -> Result<Vec<TopicRef>, Refusal> {
fn visit(
value: &Value,
path: &mut Vec<PathStep>,
out: &mut Vec<TopicRef>,
) -> Result<(), Refusal> {
match value {
Value::Array(items) => {
for (index, item) in items.iter().enumerate() {
path.push(PathStep::Index(index));
visit(item, path, out)?;
path.pop();
}
}
Value::Object(map) => {
if map.contains_key("topic_id") {
let revision = map.get("revision").and_then(Value::as_f64);
match (
map.get("topic_id").and_then(Value::as_str),
map.get("topic_name").and_then(Value::as_str),
revision,
) {
(Some(id), Some(name), Some(revision))
if revision.fract() == 0.0 && (0.0..=MAX_SAFE).contains(&revision) =>
{
out.push(TopicRef {
path: path.clone(),
topic_id: id.to_owned(),
topic_name: name.to_owned(),
revision,
});
}
_ => {
return Err(Refusal::Withheld(
"Invalid topic identity in profile policy".to_owned(),
));
}
}
}
for (key, child) in map {
path.push(PathStep::Key(key.clone()));
visit(child, path, out)?;
path.pop();
}
}
_ => {}
}
Ok(())
}
let mut out = Vec::new();
if let Some(policy) = policy {
visit(policy, &mut Vec::new(), &mut out)?;
}
Ok(out)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DlpKind {
Embedded,
Member,
}
#[derive(Clone, Debug, PartialEq)]
pub struct DlpRef {
pub path: Vec<PathStep>,
pub name: String,
pub kind: DlpKind,
pub has_version: bool,
}
pub fn dlp_references(policy: Option<&Value>) -> Result<Vec<DlpRef>, Refusal> {
let Some(Value::Object(policy)) = policy else {
return Ok(Vec::new());
};
let mut refs: Vec<DlpRef> = Vec::new();
if let Some(Value::Array(entries)) = policy.get("dlp-data-profiles") {
for (index, entry) in entries.iter().enumerate() {
let Some(name) = entry
.as_object()
.and_then(|map| map.get("name"))
.and_then(Value::as_str)
else {
return Err(Refusal::Withheld(
"Invalid embedded DLP reference".to_owned(),
));
};
refs.push(DlpRef {
path: vec![
PathStep::Key("dlp-data-profiles".into()),
PathStep::Index(index),
],
name: name.to_owned(),
kind: DlpKind::Embedded,
has_version: entry.get("version").is_some(),
});
}
}
if let Some(Value::Array(entries)) = policy.get("ai-security-profiles") {
for (index, entry) in entries.iter().enumerate() {
let model = entry.get("model-configuration");
if !is_object(Some(entry)) || !is_object(model) {
continue;
}
let protection = model.and_then(|model| model.get("data-protection"));
let detection = protection.and_then(|protection| protection.get("data-leak-detection"));
if !is_object(protection) || !is_object(detection) {
continue;
}
let Some(Value::Array(members)) =
detection.and_then(|detection| detection.get("member"))
else {
continue;
};
for (at, member) in members.iter().enumerate() {
let Some(name) = member
.as_object()
.and_then(|map| map.get("text"))
.and_then(Value::as_str)
else {
return Err(Refusal::Withheld("Invalid DLP member".to_owned()));
};
let id = member.get("id");
let version = member.get("version");
let id_empty = match id {
None => true,
Some(Value::String(id)) => id.is_empty(),
Some(_) => false,
};
let builtin = name.to_lowercase() == "sensitive content"
&& id_empty
&& (version.is_none() || version.and_then(Value::as_str) == Some("2"))
&& !refs
.iter()
.any(|r| r.kind == DlpKind::Embedded && r.name == name);
if builtin {
continue;
}
refs.push(DlpRef {
path: vec![
PathStep::Key("ai-security-profiles".into()),
PathStep::Index(index),
PathStep::Key("model-configuration".into()),
PathStep::Key("data-protection".into()),
PathStep::Key("data-leak-detection".into()),
PathStep::Key("member".into()),
PathStep::Index(at),
],
name: name.to_owned(),
kind: DlpKind::Member,
has_version: version.is_some(),
});
}
}
}
Ok(refs)
}
pub fn dlp_names_of(policy: Option<&Value>) -> Result<Vec<String>, String> {
let mut names: Vec<String> = Vec::new();
for reference in dlp_references(policy)? {
if !names.contains(&reference.name) {
names.push(reference.name);
}
}
Ok(names)
}
fn truthy(value: Option<&Value>) -> bool {
match value {
None | Some(Value::Null) => false,
Some(Value::Bool(flag)) => *flag,
Some(Value::Number(number)) => number.as_f64().is_some_and(|n| n != 0.0 && !n.is_nan()),
Some(Value::String(text)) => !text.is_empty(),
Some(_) => true,
}
}
pub fn invalid_profile_body() -> String {
crate::writes::invalid_body("POST", "/v1/mgmt/profile").message()
}
pub fn basic_fallback(policy: Option<&Value>) -> Result<Value, String> {
let mut result = policy.cloned().unwrap_or(Value::Null);
let custom: Vec<String> = dlp_names_of(Some(&result))?;
let mut converted = false;
if let Some(Value::Array(entries)) = result.get_mut("ai-security-profiles") {
for entry in entries.iter_mut() {
let Some(detection) = entry
.get_mut("model-configuration")
.and_then(|model| model.get_mut("data-protection"))
.and_then(|protection| protection.get_mut("data-leak-detection"))
else {
continue;
};
let names_custom = detection
.get("member")
.and_then(Value::as_array)
.is_some_and(|members| {
members.iter().any(|member| {
member
.get("text")
.and_then(Value::as_str)
.is_some_and(|text| custom.iter().any(|name| name == text))
})
});
if !names_custom {
continue;
}
let action = detection.get("action").and_then(Value::as_str);
if !matches!(action, Some("block" | "allow" | "")) {
return Err("Cannot safely fall back to Basic: unsupported DLP action".into());
}
if truthy(detection.get("mask-data-inline")) && action != Some("block") {
return Err(
"Cannot safely fall back to Basic: inline masking requires a block action"
.into(),
);
}
detection["member"] = if action == Some("") {
Value::Null
} else {
json!([{"text": "sensitive content", "id": "", "version": "2"}])
};
converted = true;
}
}
if !converted {
return Err(
"Cannot safely fall back to Basic: no matching DLP detection configuration".into(),
);
}
if let Some(map) = result.as_object_mut()
&& map.contains_key("dlp-data-profiles")
{
map.insert("dlp-data-profiles".into(), Value::Array(Vec::new()));
}
scm::airs::management::models::Policy::parse(result.clone(), scm::validation::Mode::Request)
.map_err(|_| invalid_profile_body())?;
Ok(result)
}
pub fn id_like(key: &str) -> bool {
let lower = ["id", "uuid"].iter().any(|suffix| {
key == *suffix
|| key.ends_with(&format!("-{suffix}"))
|| key.ends_with(&format!("_{suffix}"))
});
lower
|| ["Id", "ID", "Uuid", "UUID"]
.iter()
.any(|suffix| key.ends_with(suffix))
}
pub fn assert_known_policy_references(policy: Option<&Value>) -> Result<(), String> {
let mut known: HashMap<Vec<PathStep>, &'static [&'static str]> = HashMap::new();
for reference in topic_references(policy)? {
known.insert(reference.path, &["topic_id"]);
}
for reference in dlp_references(policy)? {
let keys: &'static [&'static str] = match reference.kind {
DlpKind::Embedded => &["id", "uuid"],
DlpKind::Member => &["id"],
};
known.insert(reference.path, keys);
}
fn visit(
value: &Value,
text: &str,
path: &mut Vec<PathStep>,
known: &HashMap<Vec<PathStep>, &'static [&'static str]>,
) -> Result<(), String> {
match value {
Value::Array(items) => {
for (index, item) in items.iter().enumerate() {
path.push(PathStep::Index(index));
visit(item, &format!("{text}[{index}]"), path, known)?;
path.pop();
}
}
Value::Object(map) => {
for (key, child) in map {
let empty = matches!(child, Value::Null)
|| matches!(child, Value::String(text) if text.is_empty());
let allowed = known
.get(path.as_slice())
.is_some_and(|keys| keys.contains(&key.as_str()));
if !empty && id_like(key) && !allowed {
return Err(format!(
"Unsupported cross-tenant policy reference at {text}.{key}"
));
}
path.push(PathStep::Key(key.clone()));
visit(child, &format!("{text}.{key}"), path, known)?;
path.pop();
}
}
_ => {}
}
Ok(())
}
if let Some(policy) = policy {
visit(policy, "policy", &mut Vec::new(), &known)?;
}
Ok(())
}
fn examples(topic: &Value) -> Value {
match topic.get("examples") {
None => Value::Array(Vec::new()),
Some(value) => value.clone(),
}
}
fn active_or_true(record: &Value) -> Value {
match record.get("active") {
None | Some(Value::Null) => Value::Bool(true),
Some(value) => value.clone(),
}
}
pub fn same_topic(a: &Value, b: &Value) -> bool {
strict_eq(a.get("description"), b.get("description"))
&& js_equal(&examples(a), &examples(b))
&& strict_eq(Some(&active_or_true(a)), Some(&active_or_true(b)))
}
pub fn js_equal(a: &Value, b: &Value) -> bool {
match (a, b) {
(Value::Array(x), Value::Array(y)) => {
x.len() == y.len() && x.iter().zip(y).all(|(x, y)| js_equal(x, y))
}
(Value::Object(x), Value::Object(y)) => {
x.len() == y.len()
&& x.iter()
.all(|(key, value)| y.get(key).is_some_and(|other| js_equal(value, other)))
}
(x, y) => strict_eq(Some(x), Some(y)),
}
}
pub fn canonical_policy(policy: Option<&Value>) -> Option<Value> {
let mut value = policy.cloned()?;
if let Some(Value::Array(entries)) = value.get_mut("ai-security-profiles") {
for entry in entries {
if let Some(Value::Object(protection)) = entry
.get_mut("model-configuration")
.and_then(|model| model.get_mut("data-protection"))
&& !protection.contains_key("database-security")
{
protection.insert("database-security".into(), Value::Null);
}
}
}
Some(value)
}
pub fn unchanged(current: Option<&Value>, prior: &Value) -> bool {
let Some(current) = current else {
return false;
};
let policies = match (
canonical_policy(current.get("policy")),
canonical_policy(prior.get("policy")),
) {
(None, None) => true,
(Some(a), Some(b)) => js_equal(&a, &b),
_ => false,
};
strict_eq(current.get("profile_id"), prior.get("profile_id"))
&& strict_eq(current.get("revision"), prior.get("revision"))
&& strict_eq(current.get("active"), prior.get("active"))
&& policies
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Conflict {
Error,
Verify,
Skip,
Update,
}
impl Conflict {
pub fn parse(text: &str) -> Option<Self> {
Some(match text {
"error" => Self::Error,
"verify" => Self::Verify,
"skip" => Self::Skip,
"update" => Self::Update,
_ => return None,
})
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MissingDlp {
Error,
Basic,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Options {
pub prefix: String,
pub conflict: Conflict,
pub dlp_map: Vec<(String, String)>,
pub missing_dlp: MissingDlp,
}
impl Options {
fn mapped(&self, name: &str) -> Option<&str> {
self.dlp_map
.iter()
.find(|(source, _)| source == name)
.map(|(_, destination)| destination.as_str())
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Action {
Create,
Update,
Skip,
Verify,
}
impl Action {
pub fn word(self) -> &'static str {
match self {
Self::Create => "create",
Self::Update => "update",
Self::Skip => "skip",
Self::Verify => "verify",
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct ProfilePlan {
pub name: String,
pub source: Value,
pub existing: Option<Value>,
pub action: Action,
pub fallback: Option<Value>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct TopicPlan {
pub key: String,
pub name: String,
pub source: Value,
pub existing: Option<Value>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct DlpMapping {
pub source: String,
pub destination: String,
pub id: String,
pub version: Option<Value>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DlpFallback {
pub profile: String,
pub unresolved: Vec<String>,
pub replaced: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ServerDefaults {
pub profile: String,
pub fields: Vec<String>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Prepared {
pub selected: Vec<Value>,
pub cross_tenant: bool,
}
pub fn profile_body(name: &str, source: &Value, policy: &Value) -> Value {
let mut body = Map::new();
body.insert("profile_name".into(), Value::String(name.to_owned()));
if let Some(active) = source.get("active").filter(|value| value.is_boolean()) {
body.insert("active".into(), active.clone());
}
body.insert("policy".into(), policy.clone());
Value::Object(body)
}
pub fn topic_body(name: &str, source: &Value) -> Value {
let mut body = Map::new();
body.insert("topic_name".into(), Value::String(name.to_owned()));
for key in ["active", "description", "examples"] {
if let Some(value) = source.get(key).filter(|value| !value.is_null()) {
body.insert(key.into(), value.clone());
}
}
Value::Object(body)
}
pub fn valid_profile_body(body: &Value) -> bool {
scm::airs::management::models::CreateSecurityProfileRequest::parse(
body.clone(),
scm::validation::Mode::Request,
)
.is_ok()
}
pub fn prepare(
profiles: &[Value],
source_tsg: &str,
destination_tsg: &str,
options: &Options,
) -> Result<Prepared, String> {
let kept = latest_profiles(profiles)?;
let selected: Vec<Value> = kept.iter().map(|index| profiles[*index].clone()).collect();
assert_tenant(&selected, source_tsg)?;
if selected.len() != profiles.len() {
return Err("Backup contains duplicate profile names/revisions".to_owned());
}
let cross_tenant = source_tsg != destination_tsg;
for profile in &selected {
let Some(policy) = profile.get("policy").filter(|policy| !policy.is_null()) else {
return Err("Backup profile has no policy".to_owned());
};
let name = format!("{}{}", options.prefix, name_of(profile, "profile_name"));
if !valid_profile_body(&profile_body(&name, profile, policy)) {
return Err(invalid_profile_body());
}
if cross_tenant {
assert_known_policy_references(Some(policy))?;
}
}
Ok(Prepared {
selected,
cross_tenant,
})
}
pub fn plan_profiles(
prepared: &Prepared,
existing: &[Value],
options: &Options,
) -> Result<Vec<ProfilePlan>, String> {
let mut plans = Vec::with_capacity(prepared.selected.len());
for source in &prepared.selected {
let name = format!("{}{}", options.prefix, name_of(source, "profile_name"));
let found = existing
.iter()
.find(|profile| text(profile, "profile_name") == Some(name.as_str()));
if found.is_some() && options.conflict == Conflict::Error {
return Err(format!(
"Destination profile already exists: {name}; choose --on-conflict verify, skip or update"
));
}
if found.is_some_and(|profile| !truthy(profile.get("profile_id"))) {
return Err("Destination profile has no identity".to_owned());
}
let action = match (found, options.conflict) {
(None, _) => Action::Create,
(Some(_), Conflict::Skip) => Action::Skip,
(Some(_), Conflict::Verify) => Action::Verify,
(Some(_), _) => Action::Update,
};
plans.push(ProfilePlan {
name,
source: source.clone(),
existing: found.cloned(),
action,
fallback: None,
});
}
Ok(plans)
}
pub fn changing_refs(plans: &[ProfilePlan]) -> Result<Vec<TopicRef>, String> {
let mut refs = Vec::new();
for plan in plans.iter().filter(|plan| plan.action != Action::Skip) {
refs.extend(topic_references(plan.source.get("policy"))?);
}
Ok(refs)
}
pub fn plan_topics(
refs: &[TopicRef],
backup_topics: &[Value],
target_topics: &[Value],
prefix: &str,
) -> Result<Vec<TopicPlan>, String> {
let mut plans: Vec<TopicPlan> = Vec::new();
let mut names: BTreeMap<String, String> = BTreeMap::new();
for reference in refs {
let key = reference.key();
if plans.iter().any(|plan| plan.key == key) {
continue;
}
let definitions: Vec<&Value> = backup_topics
.iter()
.filter(|topic| {
record_key(topic) == key
&& text(topic, "topic_name") == Some(reference.topic_name.as_str())
})
.collect();
let [source] = definitions.as_slice() else {
return Err(format!(
"Backup lacks an unambiguous topic definition: {}",
reference.topic_name
));
};
let name = format!("{prefix}{}", name_of(source, "topic_name"));
assert_name(&name)?;
if names.get(&name).is_some_and(|known| *known != key) {
return Err(format!(
"Multiple source revisions use topic name {name}; split the backup by profile/revision before restoring"
));
}
names.insert(name.clone(), key.clone());
let mut matches: Vec<&Value> = target_topics
.iter()
.filter(|topic| text(topic, "topic_name") == Some(name.as_str()))
.collect();
matches.sort_by(|a, b| revision_or_nan(b).total_cmp(&revision_or_nan(a)));
let mut ids: Vec<String> = matches
.iter()
.map(|topic| scalar(topic.get("topic_id")))
.collect();
ids.sort();
ids.dedup();
if ids.len() > 1 {
return Err(format!("Ambiguous destination topic name: {name}"));
}
let found = matches.first().copied();
if let Some(found) = found
&& (!truthy(found.get("topic_id")) || !same_topic(source, found))
{
return Err(format!(
"Destination topic differs: {name}; use --name-prefix to avoid changing a shared topic"
));
}
plans.push(TopicPlan {
key,
name,
source: (*source).clone(),
existing: found.cloned(),
});
}
Ok(plans)
}
fn revision_or_nan(topic: &Value) -> f64 {
topic
.get("revision")
.and_then(Value::as_f64)
.unwrap_or(f64::NAN)
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct DlpNames {
pub refs: Vec<DlpRef>,
pub names: Vec<String>,
pub unresolved: Vec<String>,
}
impl DlpNames {
pub fn needs_catalog(&self, cross_tenant: bool, options: &Options) -> bool {
self.names
.iter()
.any(|name| target_for(name, cross_tenant, options).is_some())
}
}
fn target_for<'a>(name: &'a str, cross_tenant: bool, options: &'a Options) -> Option<&'a str> {
match options.mapped(name) {
Some(destination) => Some(destination),
None if cross_tenant => None,
None => Some(name),
}
}
pub fn dlp_names(
plans: &[ProfilePlan],
cross_tenant: bool,
options: &Options,
) -> Result<DlpNames, String> {
let mut out = DlpNames::default();
if !(cross_tenant || options.missing_dlp == MissingDlp::Basic) {
return Ok(out);
}
for plan in plans.iter().filter(|plan| plan.action != Action::Skip) {
out.refs.extend(dlp_references(plan.source.get("policy"))?);
}
for reference in &out.refs {
if !out.names.contains(&reference.name) {
out.names.push(reference.name.clone());
}
}
for name in &out.names {
if target_for(name, cross_tenant, options).is_none() {
if options.missing_dlp == MissingDlp::Error {
return Err(format!(
"Cross-tenant DLP reference requires explicit --dlp-map \"{name}=<destination-name>\"; DLP resources are not cloned. Inspect destination resources with airs-cli-rs runtime dlp profiles list --all --output json, or explicitly accept --on-missing-dlp basic."
));
}
out.unresolved.push(name.clone());
}
}
Ok(out)
}
pub fn catalog_matches<'a>(catalog: &'a [Value], target: &str) -> Vec<&'a Value> {
catalog
.iter()
.filter(|profile| text(profile, "name") == Some(target) && !retired_profile(profile))
.collect()
}
#[derive(Clone, Debug, PartialEq)]
pub struct RestorePlan {
pub source_tsg: String,
pub destination_tsg: String,
pub profiles: Vec<ProfilePlan>,
pub topics: Vec<TopicPlan>,
pub dlp_mappings: Vec<DlpMapping>,
pub dlp_fallbacks: Vec<DlpFallback>,
pub server_defaults: Vec<ServerDefaults>,
pub cross_tenant: bool,
}
impl RestorePlan {
pub fn changing(&self) -> usize {
self.profiles
.iter()
.filter(|plan| matches!(plan.action, Action::Create | Action::Update))
.count()
}
pub fn updates(&self) -> usize {
self.profiles
.iter()
.filter(|plan| plan.action == Action::Update)
.count()
}
}
pub struct Finish<'a> {
pub source_tsg: &'a str,
pub destination_tsg: &'a str,
pub cross_tenant: bool,
pub profiles: Vec<ProfilePlan>,
pub topics: Vec<TopicPlan>,
pub dlp: DlpNames,
pub catalog: &'a [Value],
}
pub fn finish(input: Finish<'_>, options: &Options) -> Result<RestorePlan, String> {
let Finish {
source_tsg,
destination_tsg,
cross_tenant,
mut profiles,
topics,
dlp,
catalog,
} = input;
let mut unresolved = dlp.unresolved.clone();
let mut resolved: Vec<DlpMapping> = Vec::new();
for name in &dlp.names {
let Some(target) = target_for(name, cross_tenant, options) else {
continue;
};
let matches = catalog_matches(catalog, target);
if matches.is_empty() && options.missing_dlp == MissingDlp::Basic {
unresolved.push(name.clone());
continue;
}
let id = match matches.as_slice() {
[only] if truthy(only.get("id")) => scalar_text(only.get("id")),
_ => {
return Err(format!(
"Missing or ambiguous destination DLP profile: {target}"
));
}
};
let version = matches[0]
.get("version")
.filter(|value| !value.is_null())
.cloned();
if dlp
.refs
.iter()
.any(|reference| reference.name == *name && reference.has_version)
&& version.is_none()
{
return Err(format!("Destination DLP version is unknown: {target}"));
}
resolved.push(DlpMapping {
source: name.clone(),
destination: target.to_owned(),
id,
version,
});
}
let mut fallbacks = Vec::new();
for plan in profiles
.iter_mut()
.filter(|plan| plan.action != Action::Skip)
{
let replaced = dlp_names_of(plan.source.get("policy"))?;
let missing: Vec<String> = replaced
.iter()
.filter(|name| unresolved.contains(name))
.cloned()
.collect();
if missing.is_empty() {
continue;
}
plan.fallback = Some(basic_fallback(plan.source.get("policy"))?);
fallbacks.push(DlpFallback {
profile: plan.name.clone(),
unresolved: missing,
replaced,
});
}
let mut remaining: Vec<String> = Vec::new();
for plan in profiles.iter().filter(|plan| plan.action != Action::Skip) {
remaining.extend(dlp_names_of(
plan.fallback.as_ref().or_else(|| plan.source.get("policy")),
)?);
}
let dlp_mappings: Vec<DlpMapping> = resolved
.into_iter()
.filter(|mapping| remaining.contains(&mapping.source))
.collect();
let mut plan = RestorePlan {
source_tsg: source_tsg.to_owned(),
destination_tsg: destination_tsg.to_owned(),
profiles,
topics,
dlp_mappings,
dlp_fallbacks: fallbacks,
server_defaults: Vec::new(),
cross_tenant,
};
let existing_topics: Vec<(String, Value)> = plan
.topics
.iter()
.filter_map(|topic| Some((topic.key.clone(), topic.existing.clone()?)))
.collect();
let mut defaults = Vec::new();
for item in plan
.profiles
.iter()
.filter(|plan| plan.action == Action::Verify)
{
let expected = mapped_policy(item, &plan.dlp_mappings, &existing_topics)?;
let existing = item.existing.as_ref();
let comparison = policy::compare(
Some(&expected),
existing.and_then(|record| record.get("policy")),
);
let active_differs = item.source.get("active").is_some()
&& !strict_eq(
existing.and_then(|record| record.get("active")),
item.source.get("active"),
);
if !comparison.matches || active_differs {
return Err(format!(
"Destination profile does not match restore plan: {}; verification made no changes",
item.name
));
}
if !comparison.server_defaults.is_empty() {
defaults.push(ServerDefaults {
profile: item.name.clone(),
fields: comparison.server_defaults,
});
}
}
plan.server_defaults = defaults;
Ok(plan)
}
fn scalar_text(value: Option<&Value>) -> String {
match value {
Some(Value::String(text)) => text.clone(),
Some(Value::Number(number)) => number
.as_f64()
.map_or_else(|| number.to_string(), crate::render::js_number),
Some(other) => other.to_string(),
None => String::new(),
}
}
pub fn mapped_policy(
item: &ProfilePlan,
mappings: &[DlpMapping],
topics: &[(String, Value)],
) -> Result<Value, Refusal> {
let mut policy = item
.fallback
.clone()
.or_else(|| item.source.get("policy").cloned())
.unwrap_or(Value::Null);
for reference in topic_references(Some(&policy))? {
let key = reference.key();
let topic = topics
.iter()
.find(|(known, _)| *known == key)
.map(|(_, topic)| topic)
.filter(|topic| truthy(topic.get("topic_id")));
let Some(topic) = topic else {
return Err(Refusal::Stated("Missing planned topic mapping; an existing profile cannot be verified without its destination dependencies".to_owned()));
};
if let Some(Value::Object(object)) = at_path(&mut policy, &reference.path) {
for field in ["topic_id", "topic_name", "revision"] {
match topic.get(field) {
Some(value) => {
object.insert(field.into(), value.clone());
}
None => {
object.remove(field);
}
}
}
}
}
for reference in dlp_references(Some(&policy))? {
let Some(mapping) = mappings
.iter()
.find(|mapping| mapping.source == reference.name)
else {
continue;
};
let Some(Value::Object(object)) = at_path(&mut policy, &reference.path) else {
continue;
};
match reference.kind {
DlpKind::Embedded => {
object.insert("name".into(), Value::String(mapping.destination.clone()));
object.insert("uuid".into(), Value::String(mapping.id.clone()));
}
DlpKind::Member => {
object.insert("text".into(), Value::String(mapping.destination.clone()));
}
}
if object.contains_key("id") {
object.insert("id".into(), Value::String(mapping.id.clone()));
}
if object.contains_key("version") {
let Some(version) = &mapping.version else {
return Err(Refusal::Stated(
"Destination DLP version is unknown".to_owned(),
));
};
object.insert("version".into(), Value::String(scalar_text(Some(version))));
}
}
Ok(policy)
}
#[cfg(test)]
#[path = "plan_tests.rs"]
mod tests;