use serde_json::Value;
use crate::{Audience, Disclosure};
use super::embedded::EMBEDDED;
const AUDIENCES: [Audience; 3] = [
Audience::Public,
Audience::LegitimateInterest,
Audience::Authority,
];
fn class(token: &str) -> Disclosure {
match token {
"public" => Disclosure::Public,
"restricted" => Disclosure::Restricted,
"conformity" => Disclosure::Conformity,
"individual" => Disclosure::Individual,
other => panic!("unknown x-disclosure token {other:?}"),
}
}
fn nests_within(member: Disclosure, parent: Disclosure) -> bool {
AUDIENCES
.iter()
.all(|a| !a.may_see(member) || a.may_see(parent))
}
fn walk(
node: &Value,
path: &str,
enclosing: Option<(Disclosure, &str)>,
root: &Value,
active_refs: &mut Vec<String>,
out: &mut Vec<String>,
) {
if let Some(pointer) = node.get("$ref").and_then(Value::as_str) {
if active_refs.iter().any(|r| r == pointer) {
return;
}
let target = pointer
.strip_prefix('#')
.and_then(|p| root.pointer(p))
.unwrap_or_else(|| panic!("{path}: cannot resolve $ref {pointer:?}"));
active_refs.push(pointer.to_owned());
walk(target, path, enclosing, root, active_refs, out);
active_refs.pop();
}
let own = node.get("x-disclosure").and_then(Value::as_str).map(class);
if let (Some(member), Some((parent, parent_path))) = (own, enclosing)
&& !nests_within(member, parent)
{
out.push(format!(
"{path} is {} inside {parent_path}, which is {}",
member.token(),
parent.token()
));
}
let here = own.map(|c| (c, path)).or(enclosing);
if let Some(properties) = node.get("properties").and_then(Value::as_object) {
for (key, member) in properties {
let child = if path.is_empty() {
key.clone()
} else {
format!("{path}.{key}")
};
walk(member, &child, here, root, active_refs, out);
}
}
if let Some(items) = node.get("items") {
walk(items, &format!("{path}[]"), here, root, active_refs, out);
}
for combinator in ["allOf", "anyOf", "oneOf"] {
if let Some(branches) = node.get(combinator).and_then(Value::as_array) {
for branch in branches {
walk(branch, path, here, root, active_refs, out);
}
}
}
}
fn faults(json: &str) -> Vec<String> {
let root: Value = serde_json::from_str(json).expect("an embedded schema parses");
let mut out = Vec::new();
walk(&root, "", None, &root, &mut Vec::new(), &mut out);
out
}
const KNOWN_FAULTS: &[(&str, &str, &str, &str)] = &[
(
"battery",
"2.8.0",
"anodeMaterial[].name",
"shared-definition floor",
),
(
"battery",
"2.8.0",
"anodeMaterial[].casNumber",
"shared-definition floor",
),
(
"battery",
"2.8.0",
"cathodeMaterial[].name",
"shared-definition floor",
),
(
"battery",
"2.8.0",
"cathodeMaterial[].casNumber",
"shared-definition floor",
),
(
"battery",
"2.8.0",
"electrolyteMaterial[].name",
"shared-definition floor",
),
(
"battery",
"2.8.0",
"electrolyteMaterial[].casNumber",
"shared-definition floor",
),
(
"electronics",
"1.4.0",
"criticalRawMaterials[].name",
"the array is restricted and these members are public. Regulation (EU) \
2024/1252 does not decide it: its Art. 28 and 29 reach permanent \
magnets in a closed list of products, where an electric motor is one \
rated at 0,12 kW or more (Art. 2(47)), and a smartphone or tablet is \
neither on the list nor such a motor. The tier is this schema's own \
to set. Relabelling needs a new \
electronics schema version, and the members come from a definition \
shared with battery, where they are public (the shared-definition \
floor above)",
),
(
"electronics",
"1.4.0",
"criticalRawMaterials[].countryOfOrigin",
"as above",
),
];
#[test]
fn every_member_of_a_current_schema_nests_within_its_enclosing_class() {
let catalog = crate::catalog::ProductGroupCatalog::new();
let mut found: Vec<(String, String, String)> = Vec::new();
for schema in EMBEDDED {
let current = catalog.resolve_schema_version(schema.product_group, None);
if current.as_deref() != Some(schema.version) {
continue;
}
for fault in faults(schema.json) {
found.push((
schema.product_group.to_owned(),
schema.version.to_owned(),
fault,
));
}
}
let is_known = |group: &str, version: &str, fault: &str| {
KNOWN_FAULTS
.iter()
.any(|(g, v, p, _)| *g == group && *v == version && fault.starts_with(&format!("{p} ")))
};
let unexcused: Vec<String> = found
.iter()
.filter(|(g, v, f)| !is_known(g, v, f))
.map(|(g, v, f)| format!(" {g} v{v}: {f}"))
.collect();
assert!(
unexcused.is_empty(),
"members visible to an audience their enclosing object is hidden from:\n{}",
unexcused.join("\n")
);
let stale: Vec<String> = KNOWN_FAULTS
.iter()
.filter(|(g, v, p, _)| {
!found
.iter()
.any(|(fg, fv, f)| fg == g && fv == v && f.starts_with(&format!("{p} ")))
})
.map(|(g, v, p, _)| format!(" {g} v{v}: {p}"))
.collect();
assert!(
stale.is_empty(),
"KNOWN_FAULTS excuses what is no longer a fault, or a version that is no \
longer current; remove these:\n{}",
stale.join("\n")
);
}
#[test]
fn the_nesting_check_catches_what_it_is_for() {
let schema = |parent: &str, member: &str| {
serde_json::json!({
"properties": {
"block": {
"x-disclosure": parent,
"properties": { "value": { "x-disclosure": member } }
}
}
})
.to_string()
};
for (parent, member) in [
("individual", "public"),
("restricted", "public"),
("individual", "restricted"),
("conformity", "individual"),
] {
assert_eq!(
faults(&schema(parent, member)).len(),
1,
"{member} inside {parent} must be a fault"
);
}
for (parent, member) in [
("individual", "individual"),
("restricted", "conformity"),
("restricted", "individual"),
("public", "restricted"),
] {
assert!(
faults(&schema(parent, member)).is_empty(),
"{member} inside {parent} is not a fault"
);
}
let through_ref = serde_json::json!({
"properties": {
"list": {
"x-disclosure": "restricted",
"items": { "$ref": "#/definitions/entry" }
}
},
"definitions": {
"entry": { "properties": { "name": { "x-disclosure": "public" } } }
}
})
.to_string();
assert_eq!(
faults(&through_ref),
["list[].name is public inside list, which is restricted"]
);
}