1use std::collections::BTreeMap;
27
28use serde::{Deserialize, Serialize};
29use serde_json::Value;
30
31use super::apply::run_store::RunId;
32use super::component_problem;
33use super::node::{self, Node};
34use super::projects::Release;
35use super::tag_record::{node_path_problem, order_problem};
36use crate::error::CoreError;
37
38pub const PROJECT_JOURNAL_FILE: &str = "journal.json";
40
41pub const PROJECT_JOURNAL_MAX_BYTES: usize = 65_536;
43
44pub const PRE_IMAGE_DIR: &str = "pre-image";
47
48pub const PRE_IMAGE_MAX_BYTES: usize = 512 * 1024 * 1024;
52
53pub const PRE_IMAGE_RUN_PROVIDER_BYTES: usize = 5 * crate::deploy::tag_record::TAG_RECORD_MAX_BYTES;
58
59pub fn pre_image_run_max_bytes(providers: usize) -> usize {
64 PRE_IMAGE_RUN_PROVIDER_BYTES
65 .saturating_mul(providers.max(1))
66 .saturating_add(1024 * 1024)
67}
68
69const SCHEMA_VERSION: u32 = 1;
71
72#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
75pub enum PreImageFile {
76 Run,
78 Project,
80 Release,
84 Tags(String),
86 Provider(String),
88 Record(String),
90 Sent(String),
94}
95
96impl PreImageFile {
97 pub fn file_name(&self) -> String {
99 match self {
100 PreImageFile::Run => "run.json".to_string(),
101 PreImageFile::Project => "project.zip".to_string(),
102 PreImageFile::Release => "release-record.json".to_string(),
103 PreImageFile::Tags(p) => format!("tags-{p}.json"),
104 PreImageFile::Provider(p) => format!("provider-{p}.json"),
105 PreImageFile::Record(p) => format!("record-{p}.json"),
106 PreImageFile::Sent(p) => format!("sent-{p}.json"),
107 }
108 }
109
110 pub fn parse(name: &str) -> Option<PreImageFile> {
114 match name {
115 "run.json" => return Some(PreImageFile::Run),
116 "project.zip" => return Some(PreImageFile::Project),
117 "release-record.json" => return Some(PreImageFile::Release),
118 _ => {}
119 }
120 let stem = name.strip_suffix(".json")?;
121 let (kind, provider) = if let Some(p) = stem.strip_prefix("tags-") {
122 (0, p)
123 } else if let Some(p) = stem.strip_prefix("provider-") {
124 (1, p)
125 } else if let Some(p) = stem.strip_prefix("record-") {
126 (2, p)
127 } else {
128 (3, stem.strip_prefix("sent-")?)
129 };
130 if component_problem(provider).is_some() || provider.chars().any(char::is_control) {
131 return None;
132 }
133 let provider = provider.to_string();
134 Some(match kind {
135 0 => PreImageFile::Tags(provider),
136 1 => PreImageFile::Provider(provider),
137 2 => PreImageFile::Record(provider),
138 _ => PreImageFile::Sent(provider),
139 })
140 }
141
142 pub fn provider(&self) -> Option<&str> {
144 match self {
145 PreImageFile::Tags(p)
146 | PreImageFile::Provider(p)
147 | PreImageFile::Record(p)
148 | PreImageFile::Sent(p) => Some(p),
149 PreImageFile::Run | PreImageFile::Project | PreImageFile::Release => None,
150 }
151 }
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
156#[serde(rename_all = "snake_case")]
157pub enum JournalState {
158 Applying,
160 RollingBack,
162}
163
164impl JournalState {
165 pub fn as_str(self) -> &'static str {
167 match self {
168 JournalState::Applying => "applying",
169 JournalState::RollingBack => "rolling_back",
170 }
171 }
172}
173
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178#[serde(deny_unknown_fields)]
179pub struct ProjectJournal {
180 pub schema_version: u32,
182 pub gateway: String,
184 pub project: String,
186 pub run_id: String,
188 pub state: JournalState,
190 pub started_at: String,
192 pub providers: Vec<String>,
194}
195
196const JOURNAL_FIELDS: [&str; 7] = [
197 "schema_version",
198 "gateway",
199 "project",
200 "run_id",
201 "state",
202 "started_at",
203 "providers",
204];
205
206#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
209#[serde(deny_unknown_fields)]
210pub struct CreatedProof {
211 pub confirmed: bool,
213 pub signature: Option<String>,
216 pub enabled: bool,
218 pub description: Option<String>,
220 pub config: Value,
222}
223
224#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
226#[serde(deny_unknown_fields)]
227pub struct PreImageProvider {
228 pub name: String,
230 pub settings: String,
232 pub added: Vec<String>,
234 pub changed: Vec<String>,
236 pub created: Option<CreatedProof>,
238 #[serde(default)]
241 pub removed: Vec<String>,
242 #[serde(default)]
246 pub deleted: Vec<String>,
247 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub sent: Option<Vec<String>>,
255 #[serde(default)]
258 pub collided: Vec<String>,
259}
260
261#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
263#[serde(deny_unknown_fields)]
264pub struct PreImageRun {
265 pub schema_version: u32,
267 pub gateway: String,
269 pub project: String,
271 pub run_id: String,
273 pub release: String,
275 pub written_at: String,
277 pub project_action: String,
280 pub groups: bool,
282 pub providers: Vec<PreImageProvider>,
284 pub files: BTreeMap<String, String>,
286}
287
288const RUN_FIELDS: [&str; 10] = [
289 "schema_version",
290 "gateway",
291 "project",
292 "run_id",
293 "release",
294 "written_at",
295 "project_action",
296 "groups",
297 "providers",
298 "files",
299];
300
301fn shown(text: &str) -> String {
303 text.chars().filter(|c| !c.is_control()).take(64).collect()
304}
305
306fn lower_hex64(text: &str) -> bool {
307 text.len() == 64
308 && text
309 .bytes()
310 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
311}
312
313fn stamp_ok(text: &str) -> bool {
314 !text.is_empty() && text.len() <= 64 && text.bytes().all(|b| b.is_ascii_graphic())
315}
316
317fn strict_object(
320 bytes: &[u8],
321 cap: usize,
322 fields: &[&str],
323 bad: &dyn Fn(String) -> CoreError,
324) -> Result<(), CoreError> {
325 if bytes.is_empty() {
326 return Err(bad("the file is empty".to_string()));
327 }
328 if bytes.len() > cap {
329 return Err(bad(format!(
330 "the file is {} bytes, over the {cap} byte cap",
331 bytes.len()
332 )));
333 }
334 let text = std::str::from_utf8(bytes).map_err(|e| {
337 bad(format!(
338 "not valid UTF-8 (valid up to byte {})",
339 e.valid_up_to()
340 ))
341 })?;
342 if text.starts_with('\u{feff}') {
343 return Err(bad("starts with a byte-order mark".to_string()));
344 }
345 let root = node::parse(text).map_err(bad)?;
346 let Node::Obj(members) = &root else {
347 return Err(bad("the top level is not a JSON object".to_string()));
348 };
349 for (key, _) in members {
350 if !fields.contains(&key.as_str()) {
351 return Err(bad(format!("unknown field `{}`", shown(key))));
352 }
353 }
354 for field in fields {
355 if root.get(field).is_none() {
356 return Err(bad(format!("missing field `{field}`")));
357 }
358 }
359 Ok(())
360}
361
362fn common_checks(
364 schema_version: u32,
365 (gateway, project, run_id): (&str, &str, &str),
366 (want_gateway, want_project): (&str, &str),
367 bad: &dyn Fn(String) -> CoreError,
368) -> Result<(), CoreError> {
369 if schema_version != SCHEMA_VERSION {
370 return Err(bad(format!(
371 "schema_version is not {SCHEMA_VERSION}, the only version this build reads"
372 )));
373 }
374 if gateway != want_gateway {
375 return Err(bad(format!(
376 "gateway names a different gateway than the target (`{}`)",
377 shown(want_gateway)
378 )));
379 }
380 if project != want_project {
381 return Err(bad(format!(
382 "project names a different project than this path (`{}`)",
383 shown(want_project)
384 )));
385 }
386 if RunId::parse(run_id).is_err() {
387 return Err(bad(
388 "run_id is not 1 to 128 characters of [A-Za-z0-9._-]".to_string()
389 ));
390 }
391 Ok(())
392}
393
394fn provider_names_ok(
395 names: &[&str],
396 what: &str,
397 bad: &dyn Fn(String) -> CoreError,
398) -> Result<(), CoreError> {
399 for (i, name) in names.iter().enumerate() {
400 if component_problem(name).is_some() || name.chars().any(char::is_control) {
401 return Err(bad(format!("{what}[{i}] is not one safe provider name")));
402 }
403 }
404 if names
405 .windows(2)
406 .any(|pair| pair[0].as_bytes() >= pair[1].as_bytes())
407 {
408 return Err(bad(format!(
409 "{what} is out of byte order or repeats an entry"
410 )));
411 }
412 Ok(())
413}
414
415fn location_error(location: &str) -> impl Fn(String) -> CoreError + '_ {
416 move |problem| CoreError::DeployRunInvalid {
417 run_id: "unknown".to_string(),
418 problem: format!("{location}: {problem}"),
419 }
420}
421
422impl ProjectJournal {
423 pub fn parse(
426 bytes: &[u8],
427 location: &str,
428 gateway: &str,
429 project: &str,
430 ) -> Result<ProjectJournal, CoreError> {
431 let bad = location_error(location);
432 strict_object(bytes, PROJECT_JOURNAL_MAX_BYTES, &JOURNAL_FIELDS, &bad)?;
433 let journal: ProjectJournal = serde_json::from_slice(bytes).map_err(|e| {
434 bad(format!(
435 "a field has the wrong type (line {} column {})",
436 e.line(),
437 e.column()
438 ))
439 })?;
440 common_checks(
441 journal.schema_version,
442 (&journal.gateway, &journal.project, &journal.run_id),
443 (gateway, project),
444 &bad,
445 )?;
446 if !stamp_ok(&journal.started_at) {
447 return Err(bad(
448 "started_at is not 1 to 64 printable ASCII characters".to_string()
449 ));
450 }
451 let names: Vec<&str> = journal.providers.iter().map(String::as_str).collect();
452 provider_names_ok(&names, "providers", &bad)?;
453 Ok(journal)
454 }
455
456 pub fn to_bytes(&self) -> Vec<u8> {
458 let mut bytes = serde_json::to_vec_pretty(self).unwrap_or_default();
459 bytes.push(b'\n');
460 bytes
461 }
462}
463
464impl PreImageRun {
465 pub fn parse(
468 bytes: &[u8],
469 location: &str,
470 gateway: &str,
471 project: &str,
472 ) -> Result<PreImageRun, CoreError> {
473 let bad = location_error(location);
474 strict_object(bytes, PRE_IMAGE_MAX_BYTES, &RUN_FIELDS, &bad)?;
475 let run: PreImageRun = serde_json::from_slice(bytes).map_err(|e| {
476 bad(format!(
477 "a field has the wrong type (line {} column {})",
478 e.line(),
479 e.column()
480 ))
481 })?;
482 let cap = pre_image_run_max_bytes(run.providers.len());
483 if bytes.len() > cap {
484 return Err(bad(format!(
485 "the file is {} bytes, over the {cap} byte cap for {} provider(s)",
486 bytes.len(),
487 run.providers.len()
488 )));
489 }
490 common_checks(
491 run.schema_version,
492 (&run.gateway, &run.project, &run.run_id),
493 (gateway, project),
494 &bad,
495 )?;
496 if Release::try_parse(&run.release).is_none() {
497 return Err(bad("release is not a semver tag".to_string()));
498 }
499 if !stamp_ok(&run.written_at) {
500 return Err(bad(
501 "written_at is not 1 to 64 printable ASCII characters".to_string()
502 ));
503 }
504 if !matches!(
505 run.project_action.as_str(),
506 "create" | "import" | "record" | "skip"
507 ) {
508 return Err(bad(
509 "project_action is not create, import, record or skip".to_string()
510 ));
511 }
512 let names: Vec<&str> = run.providers.iter().map(|p| p.name.as_str()).collect();
513 provider_names_ok(&names, "providers", &bad)?;
514 for (i, provider) in run.providers.iter().enumerate() {
515 if !matches!(provider.settings.as_str(), "same" | "modify" | "create") {
516 return Err(bad(format!(
517 "providers[{i}].settings is not same, modify or create"
518 )));
519 }
520 let no_sent = Vec::new();
521 let sent = provider.sent.as_ref().unwrap_or(&no_sent);
522 for (list, what) in [
523 (&provider.added, "added"),
524 (&provider.changed, "changed"),
525 (&provider.removed, "removed"),
526 (&provider.deleted, "deleted"),
527 (sent, "sent"),
528 (&provider.collided, "collided"),
529 ] {
530 for (j, path) in list.iter().enumerate() {
531 if let Some(why) = node_path_problem(path) {
532 return Err(bad(format!(
533 "providers[{i}].{what}[{j}] is not a provider-relative node path: it {why}"
534 )));
535 }
536 }
537 if let Some(j) = order_problem(list) {
538 return Err(bad(format!(
539 "providers[{i}].{what}[{j}] is out of byte order or repeats an entry"
540 )));
541 }
542 }
543 if let Some((j, path)) = provider
544 .deleted
545 .iter()
546 .enumerate()
547 .find(|(_, path)| provider.removed.binary_search(path).is_err())
548 {
549 return Err(bad(format!(
550 "providers[{i}].deleted[{j}] `{}` is not one of the run's removals: the set \
551 is stale or partial",
552 shown(path)
553 )));
554 }
555 if let Some((j, path)) = sent.iter().enumerate().find(|(_, path)| {
556 provider.added.binary_search(path).is_err()
557 && provider.changed.binary_search(path).is_err()
558 }) {
559 return Err(bad(format!(
560 "providers[{i}].sent[{j}] `{}` is not one of the run's planned nodes: the set \
561 is stale or partial",
562 shown(path)
563 )));
564 }
565 if let Some((j, path)) = provider.collided.iter().enumerate().find(|(_, path)| {
566 sent.binary_search(path).is_err() || provider.added.binary_search(path).is_err()
567 }) {
568 return Err(bad(format!(
569 "providers[{i}].collided[{j}] `{}` is not an added node the run sent: the set \
570 is stale or partial",
571 shown(path)
572 )));
573 }
574 if let Some(created) = &provider.created
575 && created
576 .signature
577 .as_deref()
578 .is_some_and(|s| s.is_empty() || s.chars().any(char::is_control))
579 {
580 return Err(bad(format!(
581 "providers[{i}].created.signature is empty or holds a control character"
582 )));
583 }
584 }
585 for (name, sha) in &run.files {
586 let Some(file) = PreImageFile::parse(name) else {
587 return Err(bad(format!(
588 "files names `{}`, which is not a pre-image file name",
589 shown(name)
590 )));
591 };
592 if file == PreImageFile::Run {
593 return Err(bad("files names run.json itself".to_string()));
594 }
595 if let Some(provider) = file.provider()
596 && !names.contains(&provider)
597 {
598 return Err(bad(format!(
599 "files names `{}` for a provider the set does not list",
600 shown(name)
601 )));
602 }
603 if !lower_hex64(sha) {
604 return Err(bad(format!(
605 "files.`{}` is not 64 lowercase hex digits",
606 shown(name)
607 )));
608 }
609 }
610 Ok(run)
611 }
612
613 pub fn to_bytes(&self) -> Vec<u8> {
615 let mut bytes = serde_json::to_vec_pretty(self).unwrap_or_default();
616 bytes.push(b'\n');
617 bytes
618 }
619
620 pub fn provider(&self, name: &str) -> Option<&PreImageProvider> {
622 self.providers.iter().find(|p| p.name == name)
623 }
624}
625
626#[cfg(test)]
627mod tests {
628 use super::*;
629
630 const LOC: &str = "ign-deploy/gw/projects/CapApp/journal.json";
631
632 fn journal() -> ProjectJournal {
633 ProjectJournal {
634 schema_version: 1,
635 gateway: "gw".to_string(),
636 project: "CapApp".to_string(),
637 run_id: "20261109T101500Z-ab12".to_string(),
638 state: JournalState::Applying,
639 started_at: "2026-11-09T10:15:00Z".to_string(),
640 providers: vec!["PlantA".to_string()],
641 }
642 }
643
644 fn run() -> PreImageRun {
645 PreImageRun {
646 schema_version: 1,
647 gateway: "gw".to_string(),
648 project: "CapApp".to_string(),
649 run_id: "20261109T101500Z-ab12".to_string(),
650 release: "v1.0.0".to_string(),
651 written_at: "2026-11-09T10:15:00Z".to_string(),
652 project_action: "import".to_string(),
653 groups: false,
654 providers: vec![PreImageProvider {
655 name: "PlantA".to_string(),
656 settings: "same".to_string(),
657 added: vec!["Line1/Speed".to_string()],
658 changed: Vec::new(),
659 created: None,
660 removed: Vec::new(),
661 deleted: Vec::new(),
662 sent: None,
663 collided: Vec::new(),
664 }],
665 files: BTreeMap::from([
666 ("project.zip".to_string(), "a".repeat(64)),
667 ("tags-PlantA.json".to_string(), "b".repeat(64)),
668 ]),
669 }
670 }
671
672 #[test]
673 fn pre_image_names_are_the_fixed_set() {
674 for (name, file) in [
675 ("run.json", PreImageFile::Run),
676 ("project.zip", PreImageFile::Project),
677 ("release-record.json", PreImageFile::Release),
678 ("tags-Plant A.json", PreImageFile::Tags("Plant A".into())),
679 ("provider-P.json", PreImageFile::Provider("P".into())),
680 ("record-P.json", PreImageFile::Record("P".into())),
681 ] {
682 assert_eq!(PreImageFile::parse(name), Some(file.clone()), "{name}");
683 assert_eq!(file.file_name(), name);
684 }
685 for bad in [
686 "journal.json",
687 "release.json",
688 "tags-.json",
689 "tags-...json",
690 "tags-a/b.json",
691 "other-P.json",
692 "tags-P.zip",
693 "run.json.tmp",
694 ] {
695 assert_eq!(PreImageFile::parse(bad), None, "{bad}");
696 }
697 assert_eq!(
698 PRE_IMAGE_MAX_BYTES,
699 crate::actions::projects::IMPORT_MAX_BYTES
700 );
701 }
702
703 #[test]
704 fn journal_round_trips_and_is_strict() {
705 let j = journal();
706 assert_eq!(
707 ProjectJournal::parse(&j.to_bytes(), LOC, "gw", "CapApp").expect("parses"),
708 j
709 );
710 let mut rolling = j.clone();
711 rolling.state = JournalState::RollingBack;
712 assert!(
713 String::from_utf8(rolling.to_bytes())
714 .unwrap()
715 .contains("rolling_back")
716 );
717 for (bytes, gw, project) in [
718 (j.to_bytes(), "other", "CapApp"),
719 (j.to_bytes(), "gw", "Other"),
720 (b"{}".to_vec(), "gw", "CapApp"),
721 (b"not json".to_vec(), "gw", "CapApp"),
722 (vec![b' '; PROJECT_JOURNAL_MAX_BYTES + 1], "gw", "CapApp"),
723 (
724 br#"{"schema_version":1,"schema_version":1}"#.to_vec(),
725 "gw",
726 "CapApp",
727 ),
728 ] {
729 let err = ProjectJournal::parse(&bytes, LOC, gw, project).expect_err("refused");
730 assert_eq!(err.code(), "deploy_run_invalid", "{err}");
731 assert!(err.to_string().contains(LOC), "{err}");
732 }
733 let mut extra: Value = serde_json::from_slice(&j.to_bytes()).unwrap();
734 extra["secret"] = Value::from("x");
735 let err = ProjectJournal::parse(&serde_json::to_vec(&extra).unwrap(), LOC, "gw", "CapApp")
736 .expect_err("unknown field");
737 assert!(!err.to_string().contains("\"x\""), "{err}");
738 let mut bad_run = j.clone();
739 bad_run.run_id = "../x".to_string();
740 assert!(ProjectJournal::parse(&bad_run.to_bytes(), LOC, "gw", "CapApp").is_err());
741 }
742
743 #[test]
747 fn a_run_json_over_one_record_cap_still_parses() {
748 let mut r = run();
749 let paths: Vec<String> = (0..300_000).map(|i| format!("Line1/Tag{i:07}")).collect();
750 r.providers[0].added = paths.clone();
751 r.providers[0].sent = Some(paths);
752 let bytes = r.to_bytes();
753 assert!(
754 bytes.len() > crate::deploy::tag_record::TAG_RECORD_MAX_BYTES,
755 "{}",
756 bytes.len()
757 );
758 PreImageRun::parse(&bytes, LOC, "gw", "CapApp").expect("parses");
759 assert_eq!(
760 pre_image_run_max_bytes(3) - pre_image_run_max_bytes(2),
761 PRE_IMAGE_RUN_PROVIDER_BYTES
762 );
763 assert_eq!(pre_image_run_max_bytes(0), pre_image_run_max_bytes(1));
764 }
765
766 #[test]
767 fn run_round_trips_and_is_strict() {
768 let r = run();
769 assert_eq!(
770 PreImageRun::parse(&r.to_bytes(), LOC, "gw", "CapApp").expect("parses"),
771 r
772 );
773 let mut stray = r.clone();
774 stray
775 .files
776 .insert("tags-Other.json".to_string(), "c".repeat(64));
777 assert!(PreImageRun::parse(&stray.to_bytes(), LOC, "gw", "CapApp").is_err());
778 let mut odd = r.clone();
779 odd.files.insert("journal.json".to_string(), "c".repeat(64));
780 assert!(PreImageRun::parse(&odd.to_bytes(), LOC, "gw", "CapApp").is_err());
781 let mut unsorted = r.clone();
782 unsorted.providers[0].added = vec!["b".into(), "a".into()];
783 assert!(PreImageRun::parse(&unsorted.to_bytes(), LOC, "gw", "CapApp").is_err());
784 let mut action = r;
785 action.project_action = "delete".to_string();
786 assert!(PreImageRun::parse(&action.to_bytes(), LOC, "gw", "CapApp").is_err());
787 }
788
789 #[test]
793 fn pre_image_run_sent_paths_are_planned() {
794 let mut r = run();
795 r.providers[0].sent = Some(vec!["Line1/Speed".into()]);
796 r.providers[0].collided = vec!["Line1/Speed".into()];
797 r.files
798 .insert("sent-PlantA.json".to_string(), "c".repeat(64));
799 let parsed = PreImageRun::parse(&r.to_bytes(), LOC, "gw", "CapApp").expect("valid");
800 assert_eq!(parsed, r);
801 let mut stray = r.clone();
802 stray.providers[0].sent = Some(vec!["Line1/Other".into()]);
803 stray.providers[0].collided = Vec::new();
804 let err = PreImageRun::parse(&stray.to_bytes(), LOC, "gw", "CapApp").expect_err("stray");
805 assert!(err.to_string().contains("sent[0]"), "{err}");
806 let mut unsent = r.clone();
807 unsent.providers[0].sent = Some(Vec::new());
808 let err = PreImageRun::parse(&unsent.to_bytes(), LOC, "gw", "CapApp").expect_err("unsent");
809 assert!(err.to_string().contains("collided[0]"), "{err}");
810 let mut old: Value = serde_json::from_slice(&run().to_bytes()).unwrap();
811 old["providers"][0]
812 .as_object_mut()
813 .unwrap()
814 .remove("collided");
815 let parsed = PreImageRun::parse(&serde_json::to_vec(&old).unwrap(), LOC, "gw", "CapApp")
816 .expect("an earlier set");
817 assert_eq!(parsed.providers[0].sent, None);
818 assert!(parsed.providers[0].collided.is_empty());
819 assert_eq!(
820 PreImageFile::parse("sent-PlantA.json"),
821 Some(PreImageFile::Sent("PlantA".to_string()))
822 );
823 assert_eq!(
824 PreImageFile::Sent("P".to_string()).file_name(),
825 "sent-P.json"
826 );
827 }
828
829 #[test]
833 fn pre_image_run_deleted_paths_are_removals() {
834 let mut r = run();
835 r.providers[0].removed = vec!["Line1/A".into(), "Line1/B".into()];
836 r.providers[0].deleted = vec!["Line1/A".into()];
837 let parsed = PreImageRun::parse(&r.to_bytes(), LOC, "gw", "CapApp").expect("valid");
838 assert_eq!(parsed.providers[0].deleted, ["Line1/A"]);
839 let mut stray = r.clone();
840 stray.providers[0].deleted = vec!["Line1/C".into()];
841 let err = PreImageRun::parse(&stray.to_bytes(), LOC, "gw", "CapApp").expect_err("stray");
842 assert_eq!(err.code(), "deploy_run_invalid");
843 assert!(err.to_string().contains("Line1/C"), "{err}");
844 let mut unsorted = r.clone();
845 unsorted.providers[0].removed = vec!["Line1/B".into(), "Line1/A".into()];
846 assert!(PreImageRun::parse(&unsorted.to_bytes(), LOC, "gw", "CapApp").is_err());
847 let mut old: serde_json::Value = serde_json::from_slice(&run().to_bytes()).expect("json");
848 for provider in old["providers"].as_array_mut().expect("providers") {
849 let object = provider.as_object_mut().expect("object");
850 object.remove("removed");
851 object.remove("deleted");
852 }
853 let parsed = PreImageRun::parse(
854 &serde_json::to_vec(&old).expect("json"),
855 LOC,
856 "gw",
857 "CapApp",
858 )
859 .expect("an earlier set");
860 assert!(parsed.providers[0].removed.is_empty() && parsed.providers[0].deleted.is_empty());
861 }
862}