use std::collections::BTreeMap;
use std::io::{Read, Write};
use serde::{Deserialize, Serialize};
use crate::error::CoreError;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ResourceEntry {
#[serde(default)]
pub path: Option<String>,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
pub(crate) fn member_path(user_path: &str) -> String {
match user_path.split_once('/') {
Some((collection, rest)) => format!("{collection}/resources/{rest}"),
None => format!("{user_path}/resources/{user_path}"),
}
}
fn user_path(member: &str) -> Option<String> {
let mut segments = member.split('/');
let collection = segments.next()?;
if collection.is_empty() || segments.next()? != "resources" {
return None;
}
let rest = segments.collect::<Vec<_>>().join("/");
if rest.is_empty() {
return None;
}
if rest == collection {
return Some(collection.to_string());
}
Some(format!("{collection}/{rest}"))
}
fn open_archive(zip_bytes: &[u8]) -> Result<zip::ZipArchive<std::io::Cursor<&[u8]>>, CoreError> {
zip::ZipArchive::new(std::io::Cursor::new(zip_bytes))
.map_err(|err| CoreError::Internal(format!("project export is not a readable zip: {err}")))
}
fn rewrite_options() -> zip::write::SimpleFileOptions {
zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated)
}
pub(crate) const FOLDER_DESCRIPTOR: &str = "resource.json";
fn parent_of(member: &str) -> Option<&str> {
member.rsplit_once('/').map(|(parent, _)| parent)
}
fn merge_descriptor_member(existing: &[u8], basename: &str) -> Result<Vec<u8>, CoreError> {
let mut value: serde_json::Value = serde_json::from_slice(existing).map_err(|err| {
CoreError::Internal(format!(
"parent resource descriptor is not valid JSON: {err}"
))
})?;
let object = value.as_object_mut().ok_or_else(|| {
CoreError::Internal("parent resource descriptor is not a JSON object".to_string())
})?;
let files = object
.entry("files")
.or_insert_with(|| serde_json::Value::Array(Vec::new()));
if !files.is_array() {
*files = serde_json::Value::Array(Vec::new());
}
let listed = files
.as_array()
.expect("just normalized to an array")
.iter()
.any(|name| name.as_str() == Some(basename));
if !listed {
files
.as_array_mut()
.expect("just normalized to an array")
.push(serde_json::Value::String(basename.to_string()));
}
serde_json::to_vec_pretty(&value).map_err(|err| {
CoreError::Internal(format!(
"cannot serialize merged resource descriptor: {err}"
))
})
}
fn synthesized_descriptor(basename: &str) -> Vec<u8> {
serde_json::to_vec_pretty(&serde_json::json!({
"scope": "G",
"version": 1,
"restricted": false,
"overridable": true,
"files": [basename],
"attributes": {},
}))
.expect("the descriptor shape always serializes")
}
pub fn resource_members(zip_bytes: &[u8]) -> Result<Vec<String>, CoreError> {
let mut archive = open_archive(zip_bytes)?;
let mut members = Vec::new();
for index in 0..archive.len() {
let file = archive
.by_index(index)
.map_err(|err| CoreError::Internal(format!("cannot walk project export zip: {err}")))?;
if file.is_dir() {
continue;
}
if let Some(user) = user_path(file.name()) {
members.push(user);
}
}
Ok(members)
}
pub fn read_member(zip_bytes: &[u8], member: &str) -> Result<Vec<u8>, CoreError> {
let target = member_path(member);
let mut archive = open_archive(zip_bytes)?;
let mut file = archive.by_name(&target).map_err(|err| match err {
zip::result::ZipError::FileNotFound => CoreError::NotFound { endpoint: None },
err => CoreError::Internal(format!("cannot read zip member {target:?}: {err}")),
})?;
let mut bytes = Vec::new();
file.read_to_end(&mut bytes).map_err(|err| {
CoreError::Internal(format!("cannot decompress zip member {target:?}: {err}"))
})?;
Ok(bytes)
}
enum Surgery<'a> {
Replace(&'a [u8]),
Remove,
}
enum DescriptorSurgery {
Merge(String),
Synthesize(String),
}
fn rewrite_zip(
zip_bytes: &[u8],
target: &str,
surgery: Surgery<'_>,
) -> Result<(Vec<u8>, bool), CoreError> {
let mut archive = open_archive(zip_bytes)?;
let names: Vec<String> = archive.file_names().map(str::to_string).collect();
let target_present = names.iter().any(|name| name == target);
let descriptor_surgery = match (&surgery, target_present) {
(Surgery::Replace(_), false)
if target
.rsplit('/')
.next()
.is_some_and(|base| base != FOLDER_DESCRIPTOR) =>
{
parent_of(target).map(|parent| {
let descriptor_path = format!("{parent}/{FOLDER_DESCRIPTOR}");
if names.iter().any(|name| name == &descriptor_path) {
DescriptorSurgery::Merge(descriptor_path)
} else {
DescriptorSurgery::Synthesize(descriptor_path)
}
})
}
_ => None,
};
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let options = rewrite_options();
let mut seen = false;
for index in 0..archive.len() {
let mut file = archive
.by_index(index)
.map_err(|err| CoreError::Internal(format!("cannot walk project export zip: {err}")))?;
let name = file.name().to_string();
let is_target = name == target;
seen |= is_target;
if is_target && matches!(surgery, Surgery::Remove) {
continue; }
if file.is_dir() {
writer
.add_directory(name, options)
.map_err(|err| CoreError::Internal(format!("cannot rewrite zip: {err}")))?;
continue;
}
let mut bytes = Vec::new();
file.read_to_end(&mut bytes).map_err(|err| {
CoreError::Internal(format!("cannot decompress zip member {name:?}: {err}"))
})?;
writer
.start_file(name.clone(), options)
.map_err(|err| CoreError::Internal(format!("cannot rewrite zip: {err}")))?;
let content = match (is_target, &surgery) {
(true, Surgery::Replace(content)) => *content,
(_, Surgery::Replace(_)) if matches!(&descriptor_surgery, Some(DescriptorSurgery::Merge(path)) if path == &name) =>
{
&merge_descriptor_member(
&bytes,
target.rsplit('/').next().expect("non-root — checked above"),
)?
}
_ => &bytes,
};
writer
.write_all(content)
.map_err(|err| CoreError::Internal(format!("cannot rewrite zip: {err}")))?;
}
if !seen && let Surgery::Replace(content) = surgery {
if let Some(DescriptorSurgery::Synthesize(descriptor_path)) = &descriptor_surgery {
let basename = target.rsplit('/').next().expect("non-root — checked above");
let descriptor = synthesized_descriptor(basename);
writer
.start_file(descriptor_path.clone(), options)
.map_err(|err| CoreError::Internal(format!("cannot rewrite zip: {err}")))?;
writer
.write_all(&descriptor)
.map_err(|err| CoreError::Internal(format!("cannot rewrite zip: {err}")))?;
}
writer
.start_file(target, options)
.map_err(|err| CoreError::Internal(format!("cannot rewrite zip: {err}")))?;
writer
.write_all(content)
.map_err(|err| CoreError::Internal(format!("cannot rewrite zip: {err}")))?;
}
let cursor = writer
.finish()
.map_err(|err| CoreError::Internal(format!("cannot finalize rewritten zip: {err}")))?;
Ok((cursor.into_inner(), seen))
}
pub fn replace_member(
zip_bytes: &[u8],
member: &str,
content: &[u8],
) -> Result<Vec<u8>, CoreError> {
let (zip, _) = rewrite_zip(zip_bytes, &member_path(member), Surgery::Replace(content))?;
Ok(zip)
}
pub fn remove_member(zip_bytes: &[u8], member: &str) -> Result<Vec<u8>, CoreError> {
let (zip, seen) = rewrite_zip(zip_bytes, &member_path(member), Surgery::Remove)?;
if !seen {
return Err(CoreError::NotFound { endpoint: None });
}
Ok(zip)
}
pub(crate) fn fnv1a(bytes: &[u8]) -> u64 {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for &byte in bytes {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
hash
}
fn canonicalize(value: serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::Object(map) => {
let mut entries: Vec<(String, serde_json::Value)> = map
.into_iter()
.map(|(key, value)| (key, canonicalize(value)))
.collect();
entries.sort_by(|a, b| a.0.cmp(&b.0));
serde_json::Value::Object(entries.into_iter().collect::<serde_json::Map<_, _>>())
}
serde_json::Value::Array(items) => {
serde_json::Value::Array(items.into_iter().map(canonicalize).collect())
}
other => other,
}
}
pub fn normalize_descriptor(json: &[u8]) -> Option<Vec<u8>> {
let mut value: serde_json::Value = serde_json::from_slice(json).ok()?;
if !value.is_object() {
return None; }
if let Some(attributes) = value
.as_object_mut()
.and_then(|object| object.get_mut("attributes"))
.and_then(|attributes| attributes.as_object_mut())
{
attributes.remove("lastModification");
attributes.remove("lastModificationSignature");
}
serde_json::to_vec(&canonicalize(value)).ok()
}
pub fn member_hashes(zip_bytes: &[u8]) -> Result<BTreeMap<String, u64>, CoreError> {
let mut archive = open_archive(zip_bytes)?;
let mut hashes = BTreeMap::new();
for index in 0..archive.len() {
let mut file = archive
.by_index(index)
.map_err(|err| CoreError::Internal(format!("cannot walk project export zip: {err}")))?;
if file.is_dir() {
continue;
}
let name = file.name().to_string();
let Some(user) = user_path(&name) else {
continue;
};
let mut bytes = Vec::new();
file.read_to_end(&mut bytes).map_err(|err| {
CoreError::Internal(format!("cannot decompress zip member {name:?}: {err}"))
})?;
let content = if name.rsplit('/').next() == Some(FOLDER_DESCRIPTOR) {
normalize_descriptor(&bytes).unwrap_or(bytes)
} else {
bytes
};
hashes.insert(user, fnv1a(&content));
}
Ok(hashes)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum MemberStatus {
Added,
Removed,
Changed,
Same,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct MemberDiffEntry {
pub path: String,
pub status: MemberStatus,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default)]
pub struct DiffSummary {
pub same: usize,
pub added: usize,
pub removed: usize,
pub changed: usize,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct MemberDiff {
pub summary: DiffSummary,
pub entries: Vec<MemberDiffEntry>,
}
pub fn diff_members(zip_a: &[u8], zip_b: &[u8]) -> Result<MemberDiff, CoreError> {
let hashes_a = member_hashes(zip_a)?;
let hashes_b = member_hashes(zip_b)?;
let paths: std::collections::BTreeSet<&String> =
hashes_a.keys().chain(hashes_b.keys()).collect();
let mut summary = DiffSummary::default();
let mut entries = Vec::with_capacity(paths.len());
for path in paths {
let status = match (hashes_a.get(path), hashes_b.get(path)) {
(Some(hash_a), Some(hash_b)) => {
if hash_a == hash_b {
MemberStatus::Same
} else {
MemberStatus::Changed
}
}
(None, Some(_)) => MemberStatus::Added,
(Some(_), None) => MemberStatus::Removed,
(None, None) => unreachable!("the union only carries present keys"),
};
match status {
MemberStatus::Same => summary.same += 1,
MemberStatus::Added => summary.added += 1,
MemberStatus::Removed => summary.removed += 1,
MemberStatus::Changed => summary.changed += 1,
}
entries.push(MemberDiffEntry {
path: (*path).clone(),
status,
});
}
Ok(MemberDiff { summary, entries })
}
fn root_project_json(zip_bytes: &[u8]) -> Result<Option<serde_json::Value>, CoreError> {
let mut archive = open_archive(zip_bytes)?;
let Ok(mut file) = archive.by_name("project.json") else {
return Ok(None);
};
let mut bytes = Vec::new();
file.read_to_end(&mut bytes).map_err(|err| {
CoreError::Internal(format!(
"cannot decompress zip member \"project.json\": {err}"
))
})?;
Ok(serde_json::from_slice(&bytes).ok())
}
fn value_text(value: &serde_json::Value) -> String {
match value {
serde_json::Value::Null => "null".to_string(),
serde_json::Value::String(text) => text.clone(),
other => other.to_string(),
}
}
pub fn project_meta_delta(
zip_a: &[u8],
zip_b: &[u8],
) -> Result<Vec<(String, String, String)>, CoreError> {
let a = root_project_json(zip_a)?.unwrap_or(serde_json::Value::Null);
let b = root_project_json(zip_b)?.unwrap_or(serde_json::Value::Null);
let mut deltas = Vec::new();
for field in ["title", "enabled", "parent"] {
let (value_a, value_b) = (&a[field], &b[field]);
if value_a != value_b {
deltas.push((field.to_string(), value_text(value_a), value_text(value_b)));
}
}
Ok(deltas)
}
#[cfg(test)]
mod tests {
use std::io::Write as _;
use super::{ResourceEntry, read_member, remove_member, replace_member, resource_members};
use crate::error::CoreError;
fn fixture_zip(members: &[(&str, &[u8])]) -> Vec<u8> {
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
writer
.start_file("project.json", options)
.expect("project.json starts");
writer
.write_all(br#"{"title":"T"}"#)
.expect("project.json writes");
for (name, bytes) in members {
writer.start_file(*name, options).expect("member starts");
writer.write_all(bytes).expect("member writes");
}
writer.finish().expect("zip finalizes").into_inner()
}
const SCRIPT_MEMBER: &str = "ignition/resources/script-python/e2e/scratch";
const VIEW_MEMBER: &str = "com.example/resources/views/Dashboard/view.json";
fn sample_zip() -> Vec<u8> {
fixture_zip(&[
(
SCRIPT_MEMBER,
br#"{"scope":"G","code":"print('old')"}"#.as_slice(),
),
(VIEW_MEMBER, br#"{"scope":"A"}"#.as_slice()),
])
}
#[test]
fn resource_members_maps_user_paths_and_skips_project_json() {
let zip = sample_zip();
assert_eq!(
resource_members(&zip).expect("list parses"),
vec![
"ignition/script-python/e2e/scratch".to_string(),
"com.example/views/Dashboard/view.json".to_string(),
],
"resources/ stripped, project.json skipped, order preserved"
);
assert!(
resource_members(&fixture_zip(&[]))
.expect("bare zip parses")
.is_empty()
);
}
#[test]
fn read_member_returns_verbatim_bytes() {
let zip = sample_zip();
assert_eq!(
read_member(&zip, "ignition/script-python/e2e/scratch").expect("member reads"),
br#"{"scope":"G","code":"print('old')"}"#.to_vec()
);
assert_eq!(
read_member(&zip, "com.example/views/Dashboard/view.json").expect("member reads"),
br#"{"scope":"A"}"#.to_vec()
);
}
#[test]
fn missing_member_and_garbage_zip_error_shapes() {
let zip = sample_zip();
let err =
read_member(&zip, "ignition/script-python/nope").expect_err("missing member must fail");
assert!(
matches!(err, CoreError::NotFound { endpoint: None }),
"wrong class: {err}"
);
assert_eq!(err.exit_code(), 6);
assert_eq!(err.code(), "not_found");
let root_level =
read_member(&zip, "ignition").expect_err("absent root-level member must fail");
assert!(
matches!(root_level, CoreError::NotFound { endpoint: None }),
"the root-level member form is absent until a put creates it: {root_level}"
);
let garbage = resource_members(b"not a zip at all").expect_err("garbage must fail");
assert!(
matches!(garbage, CoreError::Internal(_)),
"garbage: {garbage}"
);
assert_eq!(garbage.exit_code(), 1);
}
#[test]
fn replace_member_swaps_content_preserving_everything_else() {
let zip = sample_zip();
let new_body = br#"{"scope":"G","code":"print('new')"}"#.as_slice();
let out = replace_member(&zip, "ignition/script-python/e2e/scratch", new_body)
.expect("replace rewrites");
assert_eq!(
read_member(&out, "ignition/script-python/e2e/scratch").expect("re-read"),
new_body.to_vec(),
"the target carries the new content"
);
assert_eq!(
read_member(&out, "com.example/views/Dashboard/view.json").expect("re-read"),
br#"{"scope":"A"}"#.to_vec(),
"the neighbor is untouched"
);
assert_eq!(
resource_members(&out).expect("re-list"),
vec![
"ignition/script-python/e2e/scratch".to_string(),
"com.example/views/Dashboard/view.json".to_string(),
],
"order and membership preserved"
);
}
#[test]
fn replace_member_appends_when_absent() {
let zip = sample_zip();
let out = replace_member(&zip, "ignition/script-python/e2e/brand-new", b"print('x')")
.expect("append rewrites");
assert_eq!(
read_member(&out, "ignition/script-python/e2e/brand-new").expect("appended reads"),
b"print('x')".to_vec()
);
let descriptor =
read_member(&out, "ignition/script-python/e2e/resource.json").expect("descriptor");
let parsed: serde_json::Value = serde_json::from_slice(&descriptor).expect("json");
assert_eq!(parsed["files"], serde_json::json!(["brand-new"]));
assert_eq!(parsed["scope"], serde_json::json!("G"));
assert_eq!(
resource_members(&out).expect("re-list"),
vec![
"ignition/script-python/e2e/scratch".to_string(),
"com.example/views/Dashboard/view.json".to_string(),
"ignition/script-python/e2e/resource.json".to_string(),
"ignition/script-python/e2e/brand-new".to_string(),
],
"the descriptor rides LAST-but-one; the originals keep their order"
);
}
#[test]
fn replace_member_appends_merging_existing_descriptor() {
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
writer.start_file("project.json", options).expect("starts");
writer.write_all(br#"{"title":"T"}"#).expect("writes");
writer
.start_file(
"ignition/resources/script-python/uat/resource.json",
options,
)
.expect("starts");
writer
.write_all(br#"{"scope":"G","version":1,"restricted":false,"overridable":true,"files":["hello2.py"],"attributes":{"keep":"me"}}"#)
.expect("writes");
writer
.start_file("ignition/resources/script-python/uat/hello2.py", options)
.expect("starts");
writer.write_all(b"print('old')").expect("writes");
let zip = writer.finish().expect("finalize").into_inner();
let out = replace_member(
&zip,
"ignition/script-python/uat/hello3.py",
b"print('new')'",
)
.expect("append rewrites");
let descriptor: serde_json::Value = serde_json::from_slice(
&read_member(&out, "ignition/script-python/uat/resource.json").expect("descriptor"),
)
.expect("descriptor json");
assert_eq!(
descriptor["files"],
serde_json::json!(["hello2.py", "hello3.py"]),
"the new basename joins the files list"
);
assert_eq!(
descriptor["attributes"]["keep"],
serde_json::json!("me"),
"unknown descriptor keys ride verbatim"
);
assert_eq!(
read_member(&out, "ignition/script-python/uat/hello3.py").expect("appended reads"),
b"print('new')'".to_vec()
);
let again = replace_member(&out, "ignition/script-python/uat/hello4.py", b"x")
.expect("second append");
let descriptor: serde_json::Value = serde_json::from_slice(
&read_member(&again, "ignition/script-python/uat/resource.json").expect("d"),
)
.expect("json");
assert_eq!(
descriptor["files"],
serde_json::json!(["hello2.py", "hello3.py", "hello4.py"])
);
}
#[test]
fn replace_member_appending_a_descriptor_authors_it_explicitly() {
let zip = sample_zip();
let descriptor_body = br#"{"scope":"A","version":1,"files":["view.json"]}"#;
let out = replace_member(
&zip,
"com.example/views/Dashboard/resource.json",
descriptor_body,
)
.expect("append rewrites");
assert_eq!(
read_member(&out, "com.example/views/Dashboard/resource.json").expect("reads"),
descriptor_body.to_vec(),
"the authored descriptor rides verbatim"
);
assert!(
read_member(&out, "com.example/views/resource.json").is_err(),
"no second descriptor is synthesized for an authored descriptor member"
);
}
#[test]
fn replace_member_append_over_corrupt_descriptor_refuses() {
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default();
writer.start_file("project.json", options).expect("starts");
writer.write_all(br#"{"title":"T"}"#).expect("writes");
writer
.start_file(
"ignition/resources/script-python/uat/resource.json",
options,
)
.expect("starts");
writer.write_all(b"<<<not json>>>").expect("writes");
let zip = writer.finish().expect("finalize").into_inner();
let err = replace_member(&zip, "ignition/script-python/uat/new.py", b"x")
.expect_err("corrupt descriptor must refuse the append");
assert!(matches!(err, CoreError::Internal(_)), "{err}");
assert_eq!(err.exit_code(), 1);
}
#[test]
fn remove_member_leaves_descriptor_to_gateway_reconciliation() {
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default();
writer.start_file("project.json", options).expect("starts");
writer.write_all(br#"{"title":"T"}"#).expect("writes");
writer
.start_file(
"ignition/resources/script-python/uat/resource.json",
options,
)
.expect("starts");
writer
.write_all(br#"{"scope":"G","version":1,"files":["scratch.py"],"attributes":{}}"#)
.expect("writes");
writer
.start_file("ignition/resources/script-python/uat/scratch.py", options)
.expect("starts");
writer.write_all(b"print('x')").expect("writes");
let zip = writer.finish().expect("finalize").into_inner();
let out =
remove_member(&zip, "ignition/script-python/uat/scratch.py").expect("remove rewrites");
assert_eq!(
read_member(&out, "ignition/script-python/uat/resource.json").expect("descriptor"),
br#"{"scope":"G","version":1,"files":["scratch.py"],"attributes":{}}"#.to_vec(),
"the descriptor rides verbatim — the gateway prunes the stale entry"
);
}
#[test]
fn remove_member_drops_exactly_the_target() {
let zip = sample_zip();
let out =
remove_member(&zip, "com.example/views/Dashboard/view.json").expect("remove rewrites");
let err = read_member(&out, "com.example/views/Dashboard/view.json")
.expect_err("removed member must be gone");
assert!(matches!(err, CoreError::NotFound { .. }), "gone: {err}");
assert_eq!(
resource_members(&out).expect("re-list"),
vec!["ignition/script-python/e2e/scratch".to_string()]
);
let missing = remove_member(&zip, "com.example/views/Never").expect_err("must fail");
assert!(matches!(missing, CoreError::NotFound { .. }), "{missing}");
assert_eq!(missing.exit_code(), 6);
}
#[test]
fn rewrite_preserves_directory_entries() {
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default();
writer
.add_directory("ignition/resources/", options)
.expect("dir starts");
writer
.start_file("ignition/resources/a", options)
.expect("file starts");
writer.write_all(b"a").expect("file writes");
let zip = writer.finish().expect("finalize").into_inner();
let out = remove_member(&zip, "ignition/a").expect("remove");
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(&out)).expect("re-open");
assert_eq!(archive.len(), 1, "the directory entry survived the rewrite");
assert!(archive.by_index(0).expect("entry").is_dir());
assert!(resource_members(&out).expect("list").is_empty());
}
#[test]
fn resource_entry_shape_unchanged() {
let entry: ResourceEntry = serde_json::from_value(serde_json::json!({
"path": "ignition/script-python/e2e/scratch",
"scope": "G"
}))
.expect("plausible item must parse");
assert_eq!(
entry.path.as_deref(),
Some("ignition/script-python/e2e/scratch")
);
assert_eq!(entry.extra.get("scope"), Some(&serde_json::json!("G")));
let bare: ResourceEntry =
serde_json::from_value(serde_json::json!({"path": "x"})).expect("extras-free parses");
assert!(bare.extra.is_empty());
}
use super::{
DiffSummary, MemberDiff, MemberDiffEntry, MemberStatus, diff_members, member_hashes,
normalize_descriptor, project_meta_delta,
};
fn diff_zip(project_json: &[u8], members: &[(&str, &[u8])]) -> Vec<u8> {
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
writer
.start_file("project.json", options)
.expect("project.json starts");
writer.write_all(project_json).expect("project.json writes");
for (name, bytes) in members {
writer.start_file(*name, options).expect("member starts");
writer.write_all(bytes).expect("member writes");
}
writer.finish().expect("zip finalizes").into_inner()
}
fn descriptor(plain_body: &str, modified_a: &str) -> Vec<u8> {
format!(
r#"{{"scope":"G","version":1,"files":["script.py"],"attributes":{{"lastModification":{{"actor":"admin","timestamp":"{modified_a}","signature":"sig-{modified_a}"}},"lastModificationSignature":"sig-{modified_a}","notes":"{plain_body}"}}}}"#
)
.into_bytes()
}
const DESC_MEMBER: &str = "ignition/resources/script-python/uat/resource.json";
const SCRIPT_MEMBER_2: &str = "ignition/resources/script-python/uat/script.py";
const VIEW_MEMBER_2: &str = "com.example/resources/views/Dash/view.json";
#[test]
fn diff_same_content_differing_modification_attributes_is_same() {
let a = diff_zip(
br#"{"title":"T","enabled":true}"#,
&[(
DESC_MEMBER,
descriptor("kept", "2026-08-28T10:00:00Z").as_slice(),
)],
);
let b = diff_zip(
br#"{"title":"T","enabled":true}"#,
&[(
DESC_MEMBER,
descriptor("kept", "2026-08-28T11:30:00Z").as_slice(),
)],
);
let diff = diff_members(&a, &b).expect("diff parses");
assert_eq!(
diff,
MemberDiff {
summary: DiffSummary {
same: 1,
added: 0,
removed: 0,
changed: 0
},
entries: vec![MemberDiffEntry {
path: "ignition/script-python/uat/resource.json".to_string(),
status: MemberStatus::Same,
}],
}
);
}
#[test]
fn diff_direction_semantics_added_removed_changed() {
let a = diff_zip(
br#"{"title":"T"}"#,
&[
(SCRIPT_MEMBER_2, b"print('old')"),
(VIEW_MEMBER_2, br#"{"scope":"A"}"#.as_slice()),
],
);
let b = diff_zip(
br#"{"title":"T"}"#,
&[
(SCRIPT_MEMBER_2, b"print('new')"),
(
"com.example/resources/views/Fresh/view.json",
br#"{"scope":"G"}"#.as_slice(),
),
],
);
let diff = diff_members(&a, &b).expect("diff parses");
assert_eq!(
diff.entries,
vec![
MemberDiffEntry {
path: "com.example/views/Dash/view.json".to_string(),
status: MemberStatus::Removed,
},
MemberDiffEntry {
path: "com.example/views/Fresh/view.json".to_string(),
status: MemberStatus::Added,
},
MemberDiffEntry {
path: "ignition/script-python/uat/script.py".to_string(),
status: MemberStatus::Changed,
},
],
"B-relative-to-A, path-sorted"
);
assert_eq!(
diff.summary,
DiffSummary {
same: 0,
added: 1,
removed: 1,
changed: 1
}
);
}
#[test]
fn diff_empty_vs_populated_is_all_removed() {
let a = diff_zip(br#"{"title":"T"}"#, &[(SCRIPT_MEMBER_2, b"print('old')")]);
let b = diff_zip(br#"{"title":"T"}"#, &[]);
let diff = diff_members(&a, &b).expect("diff parses");
assert_eq!(diff.summary.removed, 1);
assert_eq!(
diff.summary.added + diff.summary.changed + diff.summary.same,
0
);
assert_eq!(diff.entries[0].status, MemberStatus::Removed);
let mirror = diff_members(&b, &a).expect("diff parses");
assert_eq!(mirror.summary.added, 1);
assert_eq!(mirror.entries[0].status, MemberStatus::Added);
}
#[test]
fn diff_excludes_root_project_json_and_surfaces_meta_delta() {
let a = diff_zip(
br#"{"title":"Old","enabled":true}"#,
&[(SCRIPT_MEMBER_2, b"x")],
);
let b = diff_zip(
br#"{"title":"New","enabled":true}"#,
&[(SCRIPT_MEMBER_2, b"x")],
);
let diff = diff_members(&a, &b).expect("diff parses");
assert_eq!(diff.summary.same, 1, "the only resource member is same");
assert!(
!diff
.entries
.iter()
.any(|entry| entry.path == "project.json"),
"the root project.json is never a resource entry"
);
assert_eq!(
project_meta_delta(&a, &b).expect("meta delta parses"),
vec![("title".to_string(), "Old".to_string(), "New".to_string())],
"the title difference rides the meta delta exactly"
);
let c = diff_zip(
br#"{"title":"New","enabled":true,"description":"differs"}"#,
&[(SCRIPT_MEMBER_2, b"x")],
);
assert!(
project_meta_delta(&b, &c)
.expect("meta delta parses")
.is_empty(),
"only title/enabled/parent compare"
);
}
#[test]
fn normalize_descriptor_is_key_order_independent() {
let order_a = br#"{"scope":"G","version":1,"files":["a.py","b.py"],"attributes":{"lastModification":{"actor":"x","timestamp":"t"},"keep":{"z":1,"a":2}}}"#;
let order_b = br#"{"attributes":{"keep":{"a":2,"z":1},"lastModification":{"timestamp":"t","actor":"x"}},"files":["a.py","b.py"],"version":1,"scope":"G"}"#;
let normalized_a = normalize_descriptor(order_a).expect("a normalizes");
let normalized_b = normalize_descriptor(order_b).expect("b normalizes");
assert_eq!(
normalized_a, normalized_b,
"canonical output depends only on content, never key order"
);
let zip_a = diff_zip(br#"{"title":"T"}"#, &[(DESC_MEMBER, &normalized_a)]);
let zip_b = diff_zip(br#"{"title":"T"}"#, &[(DESC_MEMBER, &normalized_b)]);
let diff = diff_members(&zip_a, &zip_b).expect("diff parses");
assert_eq!(diff.summary.same, 1);
assert_eq!(diff.summary.changed, 0);
let keep_a = br#"{"scope":"G","attributes":{"notes":"one"}}"#;
let keep_b = br#"{"scope":"G","attributes":{"notes":"two"}}"#;
assert_ne!(
normalize_descriptor(keep_a).expect("normalizes"),
normalize_descriptor(keep_b).expect("normalizes"),
"non-volatility attribute differences survive normalization"
);
}
#[test]
fn normalize_descriptor_refuses_non_json_and_non_object() {
assert!(normalize_descriptor(b"print('not json')").is_none());
assert!(normalize_descriptor(br#"[1,2,3]"#).is_none());
assert!(normalize_descriptor(b"").is_none());
assert!(normalize_descriptor(br#"{"scope":"G"}"#).is_some());
}
#[test]
fn member_hashes_key_set_matches_resource_members() {
let a = diff_zip(
br#"{"title":"T"}"#,
&[
(DESC_MEMBER, descriptor("x", "t1").as_slice()),
(SCRIPT_MEMBER_2, b"print('x')"),
(VIEW_MEMBER_2, br#"{"scope":"A"}"#.as_slice()),
],
);
let hashes = member_hashes(&a).expect("hashes parse");
assert_eq!(
hashes.keys().map(String::as_str).collect::<Vec<_>>(),
vec![
"com.example/views/Dash/view.json",
"ignition/script-python/uat/resource.json",
"ignition/script-python/uat/script.py",
],
"user paths, project.json skipped, path-sorted"
);
assert!(hashes.values().all(|hash| *hash != 0));
}
#[test]
fn diff_shapes_serialize_stably() {
let entry = MemberDiffEntry {
path: "x/y".to_string(),
status: MemberStatus::Added,
};
assert_eq!(
serde_json::to_value(&entry).expect("serializes"),
serde_json::json!({"path": "x/y", "status": "added"})
);
let summary = serde_json::to_value(DiffSummary {
same: 1,
added: 2,
removed: 3,
changed: 4,
})
.expect("serializes");
assert_eq!(
summary,
serde_json::json!({"same": 1, "added": 2, "removed": 3, "changed": 4})
);
for (status, word) in [
(MemberStatus::Added, "added"),
(MemberStatus::Removed, "removed"),
(MemberStatus::Changed, "changed"),
(MemberStatus::Same, "same"),
] {
assert_eq!(
serde_json::to_value(status).expect("serializes"),
serde_json::Value::String(word.to_string())
);
}
}
}