1use std::collections::BTreeMap;
34use std::io::{Read, Write};
35
36use serde::{Deserialize, Serialize};
37
38use crate::error::CoreError;
39
40#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
45pub struct ResourceEntry {
46 #[serde(default)]
48 pub path: Option<String>,
49 #[serde(flatten)]
52 pub extra: BTreeMap<String, serde_json::Value>,
53}
54
55pub(crate) fn member_path(user_path: &str) -> String {
74 match user_path.split_once('/') {
75 Some((collection, rest)) => format!("{collection}/resources/{rest}"),
76 None => format!("{user_path}/resources/{user_path}"),
77 }
78}
79
80fn user_path(member: &str) -> Option<String> {
90 let mut segments = member.split('/');
91 let collection = segments.next()?;
92 if collection.is_empty() || segments.next()? != "resources" {
93 return None;
94 }
95 let rest = segments.collect::<Vec<_>>().join("/");
96 if rest.is_empty() {
97 return None;
98 }
99 if rest == collection {
100 return Some(collection.to_string());
101 }
102 Some(format!("{collection}/{rest}"))
103}
104
105fn open_archive(zip_bytes: &[u8]) -> Result<zip::ZipArchive<std::io::Cursor<&[u8]>>, CoreError> {
108 zip::ZipArchive::new(std::io::Cursor::new(zip_bytes))
109 .map_err(|err| CoreError::Internal(format!("project export is not a readable zip: {err}")))
110}
111
112fn rewrite_options() -> zip::write::SimpleFileOptions {
117 zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated)
118}
119
120pub(crate) const FOLDER_DESCRIPTOR: &str = "resource.json";
135
136fn parent_of(member: &str) -> Option<&str> {
139 member.rsplit_once('/').map(|(parent, _)| parent)
140}
141
142fn merge_descriptor_member(existing: &[u8], basename: &str) -> Result<Vec<u8>, CoreError> {
148 let mut value: serde_json::Value = serde_json::from_slice(existing).map_err(|err| {
149 CoreError::Internal(format!(
150 "parent resource descriptor is not valid JSON: {err}"
151 ))
152 })?;
153 let object = value.as_object_mut().ok_or_else(|| {
154 CoreError::Internal("parent resource descriptor is not a JSON object".to_string())
155 })?;
156 let files = object
157 .entry("files")
158 .or_insert_with(|| serde_json::Value::Array(Vec::new()));
159 if !files.is_array() {
160 *files = serde_json::Value::Array(Vec::new());
161 }
162 let listed = files
163 .as_array()
164 .expect("just normalized to an array")
165 .iter()
166 .any(|name| name.as_str() == Some(basename));
167 if !listed {
168 files
169 .as_array_mut()
170 .expect("just normalized to an array")
171 .push(serde_json::Value::String(basename.to_string()));
172 }
173 serde_json::to_vec_pretty(&value).map_err(|err| {
174 CoreError::Internal(format!(
175 "cannot serialize merged resource descriptor: {err}"
176 ))
177 })
178}
179
180fn synthesized_descriptor(basename: &str) -> Vec<u8> {
185 serde_json::to_vec_pretty(&serde_json::json!({
186 "scope": "G",
187 "version": 1,
188 "restricted": false,
189 "overridable": true,
190 "files": [basename],
191 "attributes": {},
192 }))
193 .expect("the descriptor shape always serializes")
194}
195
196pub fn resource_members(zip_bytes: &[u8]) -> Result<Vec<String>, CoreError> {
200 let mut archive = open_archive(zip_bytes)?;
201 let mut members = Vec::new();
202 for index in 0..archive.len() {
203 let file = archive
204 .by_index(index)
205 .map_err(|err| CoreError::Internal(format!("cannot walk project export zip: {err}")))?;
206 if file.is_dir() {
207 continue;
208 }
209 if let Some(user) = user_path(file.name()) {
210 members.push(user);
211 }
212 }
213 Ok(members)
214}
215
216pub fn read_member(zip_bytes: &[u8], member: &str) -> Result<Vec<u8>, CoreError> {
220 let target = member_path(member);
221 let mut archive = open_archive(zip_bytes)?;
222 let mut file = archive.by_name(&target).map_err(|err| match err {
223 zip::result::ZipError::FileNotFound => CoreError::NotFound { endpoint: None },
224 err => CoreError::Internal(format!("cannot read zip member {target:?}: {err}")),
225 })?;
226 let mut bytes = Vec::new();
227 file.read_to_end(&mut bytes).map_err(|err| {
228 CoreError::Internal(format!("cannot decompress zip member {target:?}: {err}"))
229 })?;
230 Ok(bytes)
231}
232
233enum Surgery<'a> {
235 Replace(&'a [u8]),
238 Remove,
240}
241
242enum DescriptorSurgery {
246 Merge(String),
250 Synthesize(String),
254}
255
256fn rewrite_zip(
270 zip_bytes: &[u8],
271 target: &str,
272 surgery: Surgery<'_>,
273) -> Result<(Vec<u8>, bool), CoreError> {
274 let mut archive = open_archive(zip_bytes)?;
275 let names: Vec<String> = archive.file_names().map(str::to_string).collect();
276 let target_present = names.iter().any(|name| name == target);
277
278 let descriptor_surgery = match (&surgery, target_present) {
281 (Surgery::Replace(_), false)
282 if target
283 .rsplit('/')
284 .next()
285 .is_some_and(|base| base != FOLDER_DESCRIPTOR) =>
286 {
287 parent_of(target).map(|parent| {
288 let descriptor_path = format!("{parent}/{FOLDER_DESCRIPTOR}");
289 if names.iter().any(|name| name == &descriptor_path) {
290 DescriptorSurgery::Merge(descriptor_path)
291 } else {
292 DescriptorSurgery::Synthesize(descriptor_path)
293 }
294 })
295 }
296 _ => None,
297 };
298
299 let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
300 let options = rewrite_options();
301 let mut seen = false;
302
303 for index in 0..archive.len() {
304 let mut file = archive
305 .by_index(index)
306 .map_err(|err| CoreError::Internal(format!("cannot walk project export zip: {err}")))?;
307 let name = file.name().to_string();
308 let is_target = name == target;
309 seen |= is_target;
310 if is_target && matches!(surgery, Surgery::Remove) {
311 continue; }
313 if file.is_dir() {
314 writer
315 .add_directory(name, options)
316 .map_err(|err| CoreError::Internal(format!("cannot rewrite zip: {err}")))?;
317 continue;
318 }
319 let mut bytes = Vec::new();
320 file.read_to_end(&mut bytes).map_err(|err| {
321 CoreError::Internal(format!("cannot decompress zip member {name:?}: {err}"))
322 })?;
323 writer
324 .start_file(name.clone(), options)
325 .map_err(|err| CoreError::Internal(format!("cannot rewrite zip: {err}")))?;
326 let content = match (is_target, &surgery) {
327 (true, Surgery::Replace(content)) => *content,
328 (_, Surgery::Replace(_)) if matches!(&descriptor_surgery, Some(DescriptorSurgery::Merge(path)) if path == &name) =>
329 {
330 &merge_descriptor_member(
334 &bytes,
335 target.rsplit('/').next().expect("non-root — checked above"),
336 )?
337 }
338 _ => &bytes,
339 };
340 writer
341 .write_all(content)
342 .map_err(|err| CoreError::Internal(format!("cannot rewrite zip: {err}")))?;
343 }
344
345 if !seen && let Surgery::Replace(content) = surgery {
350 if let Some(DescriptorSurgery::Synthesize(descriptor_path)) = &descriptor_surgery {
351 let basename = target.rsplit('/').next().expect("non-root — checked above");
352 let descriptor = synthesized_descriptor(basename);
353 writer
354 .start_file(descriptor_path.clone(), options)
355 .map_err(|err| CoreError::Internal(format!("cannot rewrite zip: {err}")))?;
356 writer
357 .write_all(&descriptor)
358 .map_err(|err| CoreError::Internal(format!("cannot rewrite zip: {err}")))?;
359 }
360 writer
361 .start_file(target, options)
362 .map_err(|err| CoreError::Internal(format!("cannot rewrite zip: {err}")))?;
363 writer
364 .write_all(content)
365 .map_err(|err| CoreError::Internal(format!("cannot rewrite zip: {err}")))?;
366 }
367
368 let cursor = writer
369 .finish()
370 .map_err(|err| CoreError::Internal(format!("cannot finalize rewritten zip: {err}")))?;
371 Ok((cursor.into_inner(), seen))
372}
373
374pub fn replace_member(
378 zip_bytes: &[u8],
379 member: &str,
380 content: &[u8],
381) -> Result<Vec<u8>, CoreError> {
382 let (zip, _) = rewrite_zip(zip_bytes, &member_path(member), Surgery::Replace(content))?;
383 Ok(zip)
384}
385
386pub fn remove_member(zip_bytes: &[u8], member: &str) -> Result<Vec<u8>, CoreError> {
389 let (zip, seen) = rewrite_zip(zip_bytes, &member_path(member), Surgery::Remove)?;
390 if !seen {
391 return Err(CoreError::NotFound { endpoint: None });
392 }
393 Ok(zip)
394}
395
396pub(crate) fn fnv1a(bytes: &[u8]) -> u64 {
415 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
416 for &byte in bytes {
417 hash ^= u64::from(byte);
418 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
419 }
420 hash
421}
422
423fn canonicalize(value: serde_json::Value) -> serde_json::Value {
431 match value {
432 serde_json::Value::Object(map) => {
433 let mut entries: Vec<(String, serde_json::Value)> = map
434 .into_iter()
435 .map(|(key, value)| (key, canonicalize(value)))
436 .collect();
437 entries.sort_by(|a, b| a.0.cmp(&b.0));
438 serde_json::Value::Object(entries.into_iter().collect::<serde_json::Map<_, _>>())
439 }
440 serde_json::Value::Array(items) => {
441 serde_json::Value::Array(items.into_iter().map(canonicalize).collect())
442 }
443 other => other,
444 }
445}
446
447pub fn normalize_descriptor(json: &[u8]) -> Option<Vec<u8>> {
456 let mut value: serde_json::Value = serde_json::from_slice(json).ok()?;
457 if !value.is_object() {
458 return None; }
460 if let Some(attributes) = value
461 .as_object_mut()
462 .and_then(|object| object.get_mut("attributes"))
463 .and_then(|attributes| attributes.as_object_mut())
464 {
465 attributes.remove("lastModification");
466 attributes.remove("lastModificationSignature");
467 }
468 serde_json::to_vec(&canonicalize(value)).ok()
469}
470
471pub fn member_hashes(zip_bytes: &[u8]) -> Result<BTreeMap<String, u64>, CoreError> {
480 let mut archive = open_archive(zip_bytes)?;
481 let mut hashes = BTreeMap::new();
482 for index in 0..archive.len() {
483 let mut file = archive
484 .by_index(index)
485 .map_err(|err| CoreError::Internal(format!("cannot walk project export zip: {err}")))?;
486 if file.is_dir() {
487 continue;
488 }
489 let name = file.name().to_string();
490 let Some(user) = user_path(&name) else {
491 continue;
492 };
493 let mut bytes = Vec::new();
494 file.read_to_end(&mut bytes).map_err(|err| {
495 CoreError::Internal(format!("cannot decompress zip member {name:?}: {err}"))
496 })?;
497 let content = if name.rsplit('/').next() == Some(FOLDER_DESCRIPTOR) {
498 normalize_descriptor(&bytes).unwrap_or(bytes)
499 } else {
500 bytes
501 };
502 hashes.insert(user, fnv1a(&content));
503 }
504 Ok(hashes)
505}
506
507#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
510#[serde(rename_all = "lowercase")]
511pub enum MemberStatus {
512 Added,
514 Removed,
516 Changed,
518 Same,
520}
521
522#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
524pub struct MemberDiffEntry {
525 pub path: String,
527 pub status: MemberStatus,
529}
530
531#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default)]
533pub struct DiffSummary {
534 pub same: usize,
536 pub added: usize,
538 pub removed: usize,
540 pub changed: usize,
542}
543
544#[derive(Debug, Clone, PartialEq, Serialize)]
548pub struct MemberDiff {
549 pub summary: DiffSummary,
551 pub entries: Vec<MemberDiffEntry>,
553}
554
555pub fn diff_members(zip_a: &[u8], zip_b: &[u8]) -> Result<MemberDiff, CoreError> {
562 let hashes_a = member_hashes(zip_a)?;
563 let hashes_b = member_hashes(zip_b)?;
564 let paths: std::collections::BTreeSet<&String> =
565 hashes_a.keys().chain(hashes_b.keys()).collect();
566 let mut summary = DiffSummary::default();
567 let mut entries = Vec::with_capacity(paths.len());
568 for path in paths {
569 let status = match (hashes_a.get(path), hashes_b.get(path)) {
570 (Some(hash_a), Some(hash_b)) => {
571 if hash_a == hash_b {
572 MemberStatus::Same
573 } else {
574 MemberStatus::Changed
575 }
576 }
577 (None, Some(_)) => MemberStatus::Added,
578 (Some(_), None) => MemberStatus::Removed,
579 (None, None) => unreachable!("the union only carries present keys"),
580 };
581 match status {
582 MemberStatus::Same => summary.same += 1,
583 MemberStatus::Added => summary.added += 1,
584 MemberStatus::Removed => summary.removed += 1,
585 MemberStatus::Changed => summary.changed += 1,
586 }
587 entries.push(MemberDiffEntry {
588 path: (*path).clone(),
589 status,
590 });
591 }
592 Ok(MemberDiff { summary, entries })
593}
594
595fn root_project_json(zip_bytes: &[u8]) -> Result<Option<serde_json::Value>, CoreError> {
600 let mut archive = open_archive(zip_bytes)?;
601 let Ok(mut file) = archive.by_name("project.json") else {
602 return Ok(None);
603 };
604 let mut bytes = Vec::new();
605 file.read_to_end(&mut bytes).map_err(|err| {
606 CoreError::Internal(format!(
607 "cannot decompress zip member \"project.json\": {err}"
608 ))
609 })?;
610 Ok(serde_json::from_slice(&bytes).ok())
611}
612
613fn value_text(value: &serde_json::Value) -> String {
615 match value {
616 serde_json::Value::Null => "null".to_string(),
617 serde_json::Value::String(text) => text.clone(),
618 other => other.to_string(),
619 }
620}
621
622pub fn project_meta_delta(
628 zip_a: &[u8],
629 zip_b: &[u8],
630) -> Result<Vec<(String, String, String)>, CoreError> {
631 let a = root_project_json(zip_a)?.unwrap_or(serde_json::Value::Null);
632 let b = root_project_json(zip_b)?.unwrap_or(serde_json::Value::Null);
633 let mut deltas = Vec::new();
634 for field in ["title", "enabled", "parent"] {
635 let (value_a, value_b) = (&a[field], &b[field]);
636 if value_a != value_b {
637 deltas.push((field.to_string(), value_text(value_a), value_text(value_b)));
638 }
639 }
640 Ok(deltas)
641}
642
643#[cfg(test)]
644mod tests {
645 use std::io::Write as _;
646
647 use super::{ResourceEntry, read_member, remove_member, replace_member, resource_members};
648 use crate::error::CoreError;
649
650 fn fixture_zip(members: &[(&str, &[u8])]) -> Vec<u8> {
654 let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
655 let options = zip::write::SimpleFileOptions::default()
656 .compression_method(zip::CompressionMethod::Deflated);
657 writer
658 .start_file("project.json", options)
659 .expect("project.json starts");
660 writer
661 .write_all(br#"{"title":"T"}"#)
662 .expect("project.json writes");
663 for (name, bytes) in members {
664 writer.start_file(*name, options).expect("member starts");
665 writer.write_all(bytes).expect("member writes");
666 }
667 writer.finish().expect("zip finalizes").into_inner()
668 }
669
670 const SCRIPT_MEMBER: &str = "ignition/resources/script-python/e2e/scratch";
673 const VIEW_MEMBER: &str = "com.example/resources/views/Dashboard/view.json";
674
675 fn sample_zip() -> Vec<u8> {
676 fixture_zip(&[
677 (
678 SCRIPT_MEMBER,
679 br#"{"scope":"G","code":"print('old')"}"#.as_slice(),
680 ),
681 (VIEW_MEMBER, br#"{"scope":"A"}"#.as_slice()),
682 ])
683 }
684
685 #[test]
689 fn resource_members_maps_user_paths_and_skips_project_json() {
690 let zip = sample_zip();
691 assert_eq!(
692 resource_members(&zip).expect("list parses"),
693 vec![
694 "ignition/script-python/e2e/scratch".to_string(),
695 "com.example/views/Dashboard/view.json".to_string(),
696 ],
697 "resources/ stripped, project.json skipped, order preserved"
698 );
699
700 assert!(
702 resource_members(&fixture_zip(&[]))
703 .expect("bare zip parses")
704 .is_empty()
705 );
706 }
707
708 #[test]
710 fn read_member_returns_verbatim_bytes() {
711 let zip = sample_zip();
712 assert_eq!(
713 read_member(&zip, "ignition/script-python/e2e/scratch").expect("member reads"),
714 br#"{"scope":"G","code":"print('old')"}"#.to_vec()
715 );
716 assert_eq!(
717 read_member(&zip, "com.example/views/Dashboard/view.json").expect("member reads"),
718 br#"{"scope":"A"}"#.to_vec()
719 );
720 }
721
722 #[test]
727 fn missing_member_and_garbage_zip_error_shapes() {
728 let zip = sample_zip();
729 let err =
730 read_member(&zip, "ignition/script-python/nope").expect_err("missing member must fail");
731 assert!(
732 matches!(err, CoreError::NotFound { endpoint: None }),
733 "wrong class: {err}"
734 );
735 assert_eq!(err.exit_code(), 6);
736 assert_eq!(err.code(), "not_found");
737
738 let root_level =
739 read_member(&zip, "ignition").expect_err("absent root-level member must fail");
740 assert!(
741 matches!(root_level, CoreError::NotFound { endpoint: None }),
742 "the root-level member form is absent until a put creates it: {root_level}"
743 );
744
745 let garbage = resource_members(b"not a zip at all").expect_err("garbage must fail");
746 assert!(
747 matches!(garbage, CoreError::Internal(_)),
748 "garbage: {garbage}"
749 );
750 assert_eq!(garbage.exit_code(), 1);
751 }
752
753 #[test]
757 fn replace_member_swaps_content_preserving_everything_else() {
758 let zip = sample_zip();
759 let new_body = br#"{"scope":"G","code":"print('new')"}"#.as_slice();
760 let out = replace_member(&zip, "ignition/script-python/e2e/scratch", new_body)
761 .expect("replace rewrites");
762 assert_eq!(
763 read_member(&out, "ignition/script-python/e2e/scratch").expect("re-read"),
764 new_body.to_vec(),
765 "the target carries the new content"
766 );
767 assert_eq!(
768 read_member(&out, "com.example/views/Dashboard/view.json").expect("re-read"),
769 br#"{"scope":"A"}"#.to_vec(),
770 "the neighbor is untouched"
771 );
772 assert_eq!(
773 resource_members(&out).expect("re-list"),
774 vec![
775 "ignition/script-python/e2e/scratch".to_string(),
776 "com.example/views/Dashboard/view.json".to_string(),
777 ],
778 "order and membership preserved"
779 );
780 }
781
782 #[test]
788 fn replace_member_appends_when_absent() {
789 let zip = sample_zip();
790 let out = replace_member(&zip, "ignition/script-python/e2e/brand-new", b"print('x')")
791 .expect("append rewrites");
792 assert_eq!(
793 read_member(&out, "ignition/script-python/e2e/brand-new").expect("appended reads"),
794 b"print('x')".to_vec()
795 );
796 let descriptor =
799 read_member(&out, "ignition/script-python/e2e/resource.json").expect("descriptor");
800 let parsed: serde_json::Value = serde_json::from_slice(&descriptor).expect("json");
801 assert_eq!(parsed["files"], serde_json::json!(["brand-new"]));
802 assert_eq!(parsed["scope"], serde_json::json!("G"));
803 assert_eq!(
806 resource_members(&out).expect("re-list"),
807 vec![
808 "ignition/script-python/e2e/scratch".to_string(),
809 "com.example/views/Dashboard/view.json".to_string(),
810 "ignition/script-python/e2e/resource.json".to_string(),
811 "ignition/script-python/e2e/brand-new".to_string(),
812 ],
813 "the descriptor rides LAST-but-one; the originals keep their order"
814 );
815 }
816
817 #[test]
823 fn replace_member_appends_merging_existing_descriptor() {
824 let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
825 let options = zip::write::SimpleFileOptions::default()
826 .compression_method(zip::CompressionMethod::Deflated);
827 writer.start_file("project.json", options).expect("starts");
828 writer.write_all(br#"{"title":"T"}"#).expect("writes");
829 writer
830 .start_file(
831 "ignition/resources/script-python/uat/resource.json",
832 options,
833 )
834 .expect("starts");
835 writer
836 .write_all(br#"{"scope":"G","version":1,"restricted":false,"overridable":true,"files":["hello2.py"],"attributes":{"keep":"me"}}"#)
837 .expect("writes");
838 writer
839 .start_file("ignition/resources/script-python/uat/hello2.py", options)
840 .expect("starts");
841 writer.write_all(b"print('old')").expect("writes");
842 let zip = writer.finish().expect("finalize").into_inner();
843
844 let out = replace_member(
846 &zip,
847 "ignition/script-python/uat/hello3.py",
848 b"print('new')'",
849 )
850 .expect("append rewrites");
851 let descriptor: serde_json::Value = serde_json::from_slice(
852 &read_member(&out, "ignition/script-python/uat/resource.json").expect("descriptor"),
853 )
854 .expect("descriptor json");
855 assert_eq!(
856 descriptor["files"],
857 serde_json::json!(["hello2.py", "hello3.py"]),
858 "the new basename joins the files list"
859 );
860 assert_eq!(
861 descriptor["attributes"]["keep"],
862 serde_json::json!("me"),
863 "unknown descriptor keys ride verbatim"
864 );
865 assert_eq!(
866 read_member(&out, "ignition/script-python/uat/hello3.py").expect("appended reads"),
867 b"print('new')'".to_vec()
868 );
869
870 let again = replace_member(&out, "ignition/script-python/uat/hello4.py", b"x")
874 .expect("second append");
875 let descriptor: serde_json::Value = serde_json::from_slice(
876 &read_member(&again, "ignition/script-python/uat/resource.json").expect("d"),
877 )
878 .expect("json");
879 assert_eq!(
880 descriptor["files"],
881 serde_json::json!(["hello2.py", "hello3.py", "hello4.py"])
882 );
883 }
884
885 #[test]
889 fn replace_member_appending_a_descriptor_authors_it_explicitly() {
890 let zip = sample_zip();
891 let descriptor_body = br#"{"scope":"A","version":1,"files":["view.json"]}"#;
892 let out = replace_member(
893 &zip,
894 "com.example/views/Dashboard/resource.json",
895 descriptor_body,
896 )
897 .expect("append rewrites");
898 assert_eq!(
899 read_member(&out, "com.example/views/Dashboard/resource.json").expect("reads"),
900 descriptor_body.to_vec(),
901 "the authored descriptor rides verbatim"
902 );
903 assert!(
906 read_member(&out, "com.example/views/resource.json").is_err(),
907 "no second descriptor is synthesized for an authored descriptor member"
908 );
909 }
910
911 #[test]
915 fn replace_member_append_over_corrupt_descriptor_refuses() {
916 let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
917 let options = zip::write::SimpleFileOptions::default();
918 writer.start_file("project.json", options).expect("starts");
919 writer.write_all(br#"{"title":"T"}"#).expect("writes");
920 writer
921 .start_file(
922 "ignition/resources/script-python/uat/resource.json",
923 options,
924 )
925 .expect("starts");
926 writer.write_all(b"<<<not json>>>").expect("writes");
927 let zip = writer.finish().expect("finalize").into_inner();
928
929 let err = replace_member(&zip, "ignition/script-python/uat/new.py", b"x")
930 .expect_err("corrupt descriptor must refuse the append");
931 assert!(matches!(err, CoreError::Internal(_)), "{err}");
932 assert_eq!(err.exit_code(), 1);
933 }
934
935 #[test]
939 fn remove_member_leaves_descriptor_to_gateway_reconciliation() {
940 let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
941 let options = zip::write::SimpleFileOptions::default();
942 writer.start_file("project.json", options).expect("starts");
943 writer.write_all(br#"{"title":"T"}"#).expect("writes");
944 writer
945 .start_file(
946 "ignition/resources/script-python/uat/resource.json",
947 options,
948 )
949 .expect("starts");
950 writer
951 .write_all(br#"{"scope":"G","version":1,"files":["scratch.py"],"attributes":{}}"#)
952 .expect("writes");
953 writer
954 .start_file("ignition/resources/script-python/uat/scratch.py", options)
955 .expect("starts");
956 writer.write_all(b"print('x')").expect("writes");
957 let zip = writer.finish().expect("finalize").into_inner();
958
959 let out =
960 remove_member(&zip, "ignition/script-python/uat/scratch.py").expect("remove rewrites");
961 assert_eq!(
962 read_member(&out, "ignition/script-python/uat/resource.json").expect("descriptor"),
963 br#"{"scope":"G","version":1,"files":["scratch.py"],"attributes":{}}"#.to_vec(),
964 "the descriptor rides verbatim — the gateway prunes the stale entry"
965 );
966 }
967
968 #[test]
972 fn remove_member_drops_exactly_the_target() {
973 let zip = sample_zip();
974 let out =
975 remove_member(&zip, "com.example/views/Dashboard/view.json").expect("remove rewrites");
976 let err = read_member(&out, "com.example/views/Dashboard/view.json")
977 .expect_err("removed member must be gone");
978 assert!(matches!(err, CoreError::NotFound { .. }), "gone: {err}");
979 assert_eq!(
980 resource_members(&out).expect("re-list"),
981 vec!["ignition/script-python/e2e/scratch".to_string()]
982 );
983
984 let missing = remove_member(&zip, "com.example/views/Never").expect_err("must fail");
985 assert!(matches!(missing, CoreError::NotFound { .. }), "{missing}");
986 assert_eq!(missing.exit_code(), 6);
987 }
988
989 #[test]
994 fn rewrite_preserves_directory_entries() {
995 let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
996 let options = zip::write::SimpleFileOptions::default();
997 writer
998 .add_directory("ignition/resources/", options)
999 .expect("dir starts");
1000 writer
1001 .start_file("ignition/resources/a", options)
1002 .expect("file starts");
1003 writer.write_all(b"a").expect("file writes");
1004 let zip = writer.finish().expect("finalize").into_inner();
1005
1006 let out = remove_member(&zip, "ignition/a").expect("remove");
1007 let mut archive = zip::ZipArchive::new(std::io::Cursor::new(&out)).expect("re-open");
1010 assert_eq!(archive.len(), 1, "the directory entry survived the rewrite");
1011 assert!(archive.by_index(0).expect("entry").is_dir());
1012 assert!(resource_members(&out).expect("list").is_empty());
1013 }
1014
1015 #[test]
1019 fn resource_entry_shape_unchanged() {
1020 let entry: ResourceEntry = serde_json::from_value(serde_json::json!({
1021 "path": "ignition/script-python/e2e/scratch",
1022 "scope": "G"
1023 }))
1024 .expect("plausible item must parse");
1025 assert_eq!(
1026 entry.path.as_deref(),
1027 Some("ignition/script-python/e2e/scratch")
1028 );
1029 assert_eq!(entry.extra.get("scope"), Some(&serde_json::json!("G")));
1030
1031 let bare: ResourceEntry =
1032 serde_json::from_value(serde_json::json!({"path": "x"})).expect("extras-free parses");
1033 assert!(bare.extra.is_empty());
1034 }
1035
1036 use super::{
1039 DiffSummary, MemberDiff, MemberDiffEntry, MemberStatus, diff_members, member_hashes,
1040 normalize_descriptor, project_meta_delta,
1041 };
1042
1043 fn diff_zip(project_json: &[u8], members: &[(&str, &[u8])]) -> Vec<u8> {
1046 let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
1047 let options = zip::write::SimpleFileOptions::default()
1048 .compression_method(zip::CompressionMethod::Deflated);
1049 writer
1050 .start_file("project.json", options)
1051 .expect("project.json starts");
1052 writer.write_all(project_json).expect("project.json writes");
1053 for (name, bytes) in members {
1054 writer.start_file(*name, options).expect("member starts");
1055 writer.write_all(bytes).expect("member writes");
1056 }
1057 writer.finish().expect("zip finalizes").into_inner()
1058 }
1059
1060 fn descriptor(plain_body: &str, modified_a: &str) -> Vec<u8> {
1064 format!(
1065 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}"}}}}"#
1066 )
1067 .into_bytes()
1068 }
1069
1070 const DESC_MEMBER: &str = "ignition/resources/script-python/uat/resource.json";
1071 const SCRIPT_MEMBER_2: &str = "ignition/resources/script-python/uat/script.py";
1072 const VIEW_MEMBER_2: &str = "com.example/resources/views/Dash/view.json";
1073
1074 #[test]
1078 fn diff_same_content_differing_modification_attributes_is_same() {
1079 let a = diff_zip(
1080 br#"{"title":"T","enabled":true}"#,
1081 &[(
1082 DESC_MEMBER,
1083 descriptor("kept", "2026-08-28T10:00:00Z").as_slice(),
1084 )],
1085 );
1086 let b = diff_zip(
1087 br#"{"title":"T","enabled":true}"#,
1088 &[(
1089 DESC_MEMBER,
1090 descriptor("kept", "2026-08-28T11:30:00Z").as_slice(),
1091 )],
1092 );
1093 let diff = diff_members(&a, &b).expect("diff parses");
1094 assert_eq!(
1095 diff,
1096 MemberDiff {
1097 summary: DiffSummary {
1098 same: 1,
1099 added: 0,
1100 removed: 0,
1101 changed: 0
1102 },
1103 entries: vec![MemberDiffEntry {
1104 path: "ignition/script-python/uat/resource.json".to_string(),
1105 status: MemberStatus::Same,
1106 }],
1107 }
1108 );
1109 }
1110
1111 #[test]
1115 fn diff_direction_semantics_added_removed_changed() {
1116 let a = diff_zip(
1117 br#"{"title":"T"}"#,
1118 &[
1119 (SCRIPT_MEMBER_2, b"print('old')"),
1120 (VIEW_MEMBER_2, br#"{"scope":"A"}"#.as_slice()),
1121 ],
1122 );
1123 let b = diff_zip(
1124 br#"{"title":"T"}"#,
1125 &[
1126 (SCRIPT_MEMBER_2, b"print('new')"),
1127 (
1128 "com.example/resources/views/Fresh/view.json",
1129 br#"{"scope":"G"}"#.as_slice(),
1130 ),
1131 ],
1132 );
1133 let diff = diff_members(&a, &b).expect("diff parses");
1134 assert_eq!(
1135 diff.entries,
1136 vec![
1137 MemberDiffEntry {
1138 path: "com.example/views/Dash/view.json".to_string(),
1139 status: MemberStatus::Removed,
1140 },
1141 MemberDiffEntry {
1142 path: "com.example/views/Fresh/view.json".to_string(),
1143 status: MemberStatus::Added,
1144 },
1145 MemberDiffEntry {
1146 path: "ignition/script-python/uat/script.py".to_string(),
1147 status: MemberStatus::Changed,
1148 },
1149 ],
1150 "B-relative-to-A, path-sorted"
1151 );
1152 assert_eq!(
1153 diff.summary,
1154 DiffSummary {
1155 same: 0,
1156 added: 1,
1157 removed: 1,
1158 changed: 1
1159 }
1160 );
1161 }
1162
1163 #[test]
1166 fn diff_empty_vs_populated_is_all_removed() {
1167 let a = diff_zip(br#"{"title":"T"}"#, &[(SCRIPT_MEMBER_2, b"print('old')")]);
1168 let b = diff_zip(br#"{"title":"T"}"#, &[]);
1169 let diff = diff_members(&a, &b).expect("diff parses");
1170 assert_eq!(diff.summary.removed, 1);
1171 assert_eq!(
1172 diff.summary.added + diff.summary.changed + diff.summary.same,
1173 0
1174 );
1175 assert_eq!(diff.entries[0].status, MemberStatus::Removed);
1176
1177 let mirror = diff_members(&b, &a).expect("diff parses");
1179 assert_eq!(mirror.summary.added, 1);
1180 assert_eq!(mirror.entries[0].status, MemberStatus::Added);
1181 }
1182
1183 #[test]
1188 fn diff_excludes_root_project_json_and_surfaces_meta_delta() {
1189 let a = diff_zip(
1190 br#"{"title":"Old","enabled":true}"#,
1191 &[(SCRIPT_MEMBER_2, b"x")],
1192 );
1193 let b = diff_zip(
1194 br#"{"title":"New","enabled":true}"#,
1195 &[(SCRIPT_MEMBER_2, b"x")],
1196 );
1197 let diff = diff_members(&a, &b).expect("diff parses");
1198 assert_eq!(diff.summary.same, 1, "the only resource member is same");
1199 assert!(
1200 !diff
1201 .entries
1202 .iter()
1203 .any(|entry| entry.path == "project.json"),
1204 "the root project.json is never a resource entry"
1205 );
1206 assert_eq!(
1207 project_meta_delta(&a, &b).expect("meta delta parses"),
1208 vec![("title".to_string(), "Old".to_string(), "New".to_string())],
1209 "the title difference rides the meta delta exactly"
1210 );
1211
1212 let c = diff_zip(
1215 br#"{"title":"New","enabled":true,"description":"differs"}"#,
1216 &[(SCRIPT_MEMBER_2, b"x")],
1217 );
1218 assert!(
1219 project_meta_delta(&b, &c)
1220 .expect("meta delta parses")
1221 .is_empty(),
1222 "only title/enabled/parent compare"
1223 );
1224 }
1225
1226 #[test]
1234 fn normalize_descriptor_is_key_order_independent() {
1235 let order_a = br#"{"scope":"G","version":1,"files":["a.py","b.py"],"attributes":{"lastModification":{"actor":"x","timestamp":"t"},"keep":{"z":1,"a":2}}}"#;
1236 let order_b = br#"{"attributes":{"keep":{"a":2,"z":1},"lastModification":{"timestamp":"t","actor":"x"}},"files":["a.py","b.py"],"version":1,"scope":"G"}"#;
1237 let normalized_a = normalize_descriptor(order_a).expect("a normalizes");
1238 let normalized_b = normalize_descriptor(order_b).expect("b normalizes");
1239 assert_eq!(
1240 normalized_a, normalized_b,
1241 "canonical output depends only on content, never key order"
1242 );
1243 let zip_a = diff_zip(br#"{"title":"T"}"#, &[(DESC_MEMBER, &normalized_a)]);
1245 let zip_b = diff_zip(br#"{"title":"T"}"#, &[(DESC_MEMBER, &normalized_b)]);
1246 let diff = diff_members(&zip_a, &zip_b).expect("diff parses");
1247 assert_eq!(diff.summary.same, 1);
1248 assert_eq!(diff.summary.changed, 0);
1249
1250 let keep_a = br#"{"scope":"G","attributes":{"notes":"one"}}"#;
1253 let keep_b = br#"{"scope":"G","attributes":{"notes":"two"}}"#;
1254 assert_ne!(
1255 normalize_descriptor(keep_a).expect("normalizes"),
1256 normalize_descriptor(keep_b).expect("normalizes"),
1257 "non-volatility attribute differences survive normalization"
1258 );
1259 }
1260
1261 #[test]
1265 fn normalize_descriptor_refuses_non_json_and_non_object() {
1266 assert!(normalize_descriptor(b"print('not json')").is_none());
1267 assert!(normalize_descriptor(br#"[1,2,3]"#).is_none());
1268 assert!(normalize_descriptor(b"").is_none());
1269 assert!(normalize_descriptor(br#"{"scope":"G"}"#).is_some());
1272 }
1273
1274 #[test]
1278 fn member_hashes_key_set_matches_resource_members() {
1279 let a = diff_zip(
1280 br#"{"title":"T"}"#,
1281 &[
1282 (DESC_MEMBER, descriptor("x", "t1").as_slice()),
1283 (SCRIPT_MEMBER_2, b"print('x')"),
1284 (VIEW_MEMBER_2, br#"{"scope":"A"}"#.as_slice()),
1285 ],
1286 );
1287 let hashes = member_hashes(&a).expect("hashes parse");
1288 assert_eq!(
1289 hashes.keys().map(String::as_str).collect::<Vec<_>>(),
1290 vec![
1291 "com.example/views/Dash/view.json",
1292 "ignition/script-python/uat/resource.json",
1293 "ignition/script-python/uat/script.py",
1294 ],
1295 "user paths, project.json skipped, path-sorted"
1296 );
1297 assert!(hashes.values().all(|hash| *hash != 0));
1298 }
1299
1300 #[test]
1303 fn diff_shapes_serialize_stably() {
1304 let entry = MemberDiffEntry {
1305 path: "x/y".to_string(),
1306 status: MemberStatus::Added,
1307 };
1308 assert_eq!(
1309 serde_json::to_value(&entry).expect("serializes"),
1310 serde_json::json!({"path": "x/y", "status": "added"})
1311 );
1312 let summary = serde_json::to_value(DiffSummary {
1313 same: 1,
1314 added: 2,
1315 removed: 3,
1316 changed: 4,
1317 })
1318 .expect("serializes");
1319 assert_eq!(
1320 summary,
1321 serde_json::json!({"same": 1, "added": 2, "removed": 3, "changed": 4})
1322 );
1323 for (status, word) in [
1324 (MemberStatus::Added, "added"),
1325 (MemberStatus::Removed, "removed"),
1326 (MemberStatus::Changed, "changed"),
1327 (MemberStatus::Same, "same"),
1328 ] {
1329 assert_eq!(
1330 serde_json::to_value(status).expect("serializes"),
1331 serde_json::Value::String(word.to_string())
1332 );
1333 }
1334 }
1335}