Skip to main content

ignition_core/deploy/
project_run.rs

1//! The host copy of a tag-owning `deploy projects` run (26-12, TAGDEP-10).
2//!
3//! Beside the release record of a project
4//! (`ign-deploy/<gateway>/projects/<project>/`, the deploy account's home on
5//! an `ssh` node, the data directory on a `local` one) a tag-owning run keeps:
6//!
7//! ```text
8//! journal.json                   the run in flight (ProjectJournal)
9//! pre-image/run.json             what the set holds (PreImageRun), written last
10//! pre-image/project.zip          the project export, when the run imports it
11//! pre-image/release-record.json  the project's release record, when it had one
12//! pre-image/tags-<P>.json        each existing owned provider's export
13//! pre-image/provider-<P>.json    each existing owned provider's resource record
14//! pre-image/record-<P>.json      each owned provider's previous tag record
15//! ```
16//!
17//! Only the latest run's pre-image is kept: the next run writes over it.
18//! `run.json` is written after every other file of the set and names each
19//! file with its sha256, so a set without a `run.json` naming the asked run,
20//! or a file that does not hash as `run.json` says, is incomplete. Both files
21//! here are parsed as strictly as the release and tag records: an unknown or
22//! missing field, another gateway or project, another schema, an unsafe name
23//! or path, an unsorted list or a repeated key is `deploy_run_invalid` naming
24//! the location. Problem texts never echo a value the file holds.
25
26use 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
38/// File name of the host journal of a project run.
39pub const PROJECT_JOURNAL_FILE: &str = "journal.json";
40
41/// The largest project journal accepted (64 KiB), checked before any parse.
42pub const PROJECT_JOURNAL_MAX_BYTES: usize = 65_536;
43
44/// The directory beside the release record that holds the latest run's
45/// pre-image.
46pub const PRE_IMAGE_DIR: &str = "pre-image";
47
48/// The largest pre-image file accepted (512 MiB): the project export ceiling
49/// (`actions::projects::IMPORT_MAX_BYTES`), which is also the largest file in
50/// a set.
51pub const PRE_IMAGE_MAX_BYTES: usize = 512 * 1024 * 1024;
52
53/// The share of `pre-image/run.json` one owned provider may take (40 MiB,
54/// five tag record caps; ultrareview B4): a node of the provider is listed
55/// at most three times (planned, sent, collided; or removed and deleted),
56/// each line a little longer than in the tag record.
57pub const PRE_IMAGE_RUN_PROVIDER_BYTES: usize = 5 * crate::deploy::tag_record::TAG_RECORD_MAX_BYTES;
58
59/// The largest `pre-image/run.json` accepted for a set listing `providers`
60/// owned providers: [`PRE_IMAGE_RUN_PROVIDER_BYTES`] per provider (at least
61/// one) plus 1 MiB for the rest of the file. A file over
62/// [`PRE_IMAGE_MAX_BYTES`] is refused before it is parsed.
63pub 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
69/// The only schema version this build reads and writes.
70const SCHEMA_VERSION: u32 = 1;
71
72/// One file of the pre-image set. The names are fixed; a provider name inside
73/// one must also pass the host's tag-record name check.
74#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
75pub enum PreImageFile {
76    /// `run.json`, the completeness marker.
77    Run,
78    /// `project.zip`, the project export.
79    Project,
80    /// `release-record.json`, the project's release record before the run
81    /// (ultrareview U1): `--rollback-run` puts it back. Absent when the
82    /// project had none.
83    Release,
84    /// `tags-<P>.json`, a provider's native export.
85    Tags(String),
86    /// `provider-<P>.json`, a provider's resource record.
87    Provider(String),
88    /// `record-<P>.json`, a provider's tag record before the run.
89    Record(String),
90    /// `sent-<P>.json`, the nodes the run's plan adds to an existing
91    /// provider as the run sends them (26-REVIEW CR-01): a JSON object of
92    /// provider-relative path to flattened node.
93    Sent(String),
94}
95
96impl PreImageFile {
97    /// The file name.
98    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    /// The file `name` names, or `None` for a name outside the fixed set. A
111    /// provider part must be one safe path component without a control
112    /// character.
113    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    /// The provider a per-provider file names.
143    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/// Where a run in flight stands.
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
156#[serde(rename_all = "snake_case")]
157pub enum JournalState {
158    /// The run is writing to the gateway.
159    Applying,
160    /// The run's rollback, automatic or `--rollback-run`, is writing.
161    RollingBack,
162}
163
164impl JournalState {
165    /// The word the journal and `data.tags.journal` carry.
166    pub fn as_str(self) -> &'static str {
167        match self {
168            JournalState::Applying => "applying",
169            JournalState::RollingBack => "rolling_back",
170        }
171    }
172}
173
174/// The host journal of a tag-owning run (26-12): written after the complete
175/// pre-image and before the first gateway write, cleared after a green run or
176/// a verified rollback, left after `rollback_failed`.
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178#[serde(deny_unknown_fields)]
179pub struct ProjectJournal {
180    /// Always 1.
181    pub schema_version: u32,
182    /// The target's gateway.
183    pub gateway: String,
184    /// The gateway project.
185    pub project: String,
186    /// The run the journal belongs to.
187    pub run_id: String,
188    /// Where the run stands.
189    pub state: JournalState,
190    /// When the run (or the rollback) started, UTC.
191    pub started_at: String,
192    /// The owned providers, byte-sorted and unique.
193    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/// What a provider the run created is proven by (26-11): the create's answer,
207/// what was sent, and the signature read right after the run's own create.
208#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
209#[serde(deny_unknown_fields)]
210pub struct CreatedProof {
211    /// The create answered success; `false` when its answer was lost.
212    pub confirmed: bool,
213    /// The signature of the record read right after the create; `None`
214    /// when nothing proves the provider is the run's.
215    pub signature: Option<String>,
216    /// What the run sent: `enabled`.
217    pub enabled: bool,
218    /// What the run sent: `description`.
219    pub description: Option<String>,
220    /// What the run sent: `config`.
221    pub config: Value,
222}
223
224/// One owned provider as the run planned it.
225#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
226#[serde(deny_unknown_fields)]
227pub struct PreImageProvider {
228    /// The provider.
229    pub name: String,
230    /// The provider-settings action: `same`, `modify` or `create`.
231    pub settings: String,
232    /// The nodes the run's plan adds, provider-relative, byte-sorted.
233    pub added: Vec<String>,
234    /// The nodes the run's plan changes, provider-relative, byte-sorted.
235    pub changed: Vec<String>,
236    /// Set once the run created the provider (26-11 proof).
237    pub created: Option<CreatedProof>,
238    /// The nodes the run's plan removes (26-20, D-02), provider-relative,
239    /// byte-sorted. Absent in a set an earlier release wrote: none.
240    #[serde(default)]
241    pub removed: Vec<String>,
242    /// Every path a delete call of the run named, written before the call
243    /// was sent (26-20), provider-relative, byte-sorted, each one of
244    /// `removed`: what `--rollback-run` re-creates from the pre-image.
245    #[serde(default)]
246    pub deleted: Vec<String>,
247    /// The nodes of every import batch the run sent, written before the
248    /// batch was sent (26-REVIEW CR-01), provider-relative, byte-sorted,
249    /// each one of `added` or `changed`: what `--rollback-run` counts as
250    /// possibly applied. Absent in a set an earlier release wrote: every
251    /// planned node counts, and no created node can be shown to be the
252    /// run's.
253    #[serde(default, skip_serializing_if = "Option::is_none")]
254    pub sent: Option<Vec<String>>,
255    /// The added paths whose import answered that the node already existed
256    /// (26-REVIEW CR-01), byte-sorted, each one of `sent`: never deleted.
257    #[serde(default)]
258    pub collided: Vec<String>,
259}
260
261/// `pre-image/run.json`: the complete set of one run's pre-image (26-12).
262#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
263#[serde(deny_unknown_fields)]
264pub struct PreImageRun {
265    /// Always 1.
266    pub schema_version: u32,
267    /// The target's gateway.
268    pub gateway: String,
269    /// The gateway project.
270    pub project: String,
271    /// The run the set belongs to.
272    pub run_id: String,
273    /// The release the run deployed.
274    pub release: String,
275    /// When the set was written, UTC.
276    pub written_at: String,
277    /// The project action of the run: `create`, `import`, `record` or
278    /// `skip`.
279    pub project_action: String,
280    /// Whether the run planned a tag-group commit.
281    pub groups: bool,
282    /// The owned providers, byte-sorted by name and unique.
283    pub providers: Vec<PreImageProvider>,
284    /// Every other file of the set, by name, with its sha256.
285    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
301/// At most 64 printable characters of `text`, for a problem message.
302fn 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
317/// The strict front half both parses share: size, UTF-8, no repeated key, a
318/// top-level object holding exactly `fields`.
319fn 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    // The checks of `node::classify` (strict UTF-8, no BOM, bounded depth,
335    // no repeated key) without its 16 MiB cap: `cap` bounds the file.
336    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
362/// The checks of the names both files carry.
363fn 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    /// Parse and validate `bytes` read from `location` for `gateway` and
424    /// `project`. Every refusal is [`CoreError::DeployRunInvalid`].
425    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    /// Pretty JSON in field order plus one LF.
457    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    /// Parse and validate `bytes` read from `location` for `gateway` and
466    /// `project`. Every refusal is [`CoreError::DeployRunInvalid`].
467    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    /// Pretty JSON in field order plus one LF.
614    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    /// The provider entry named `name`.
621    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    /// Ultrareview B4: `run.json` lists each provider's planned paths and
744    /// the sent ones again, so a run whose plan is near one tag record's
745    /// size is well over 8 MiB; the cap is sized for the aggregate.
746    #[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    /// 26-REVIEW CR-01: `sent` names planned nodes only, `collided` added
790    /// nodes it sent only, and a set an earlier release wrote without them
791    /// parses with `sent` absent; `sent-<P>.json` is a file of the set.
792    #[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    /// 26-20: `removed` and `deleted` are node paths in byte order, every
830    /// deleted path is one of the removals, and a set an earlier release
831    /// wrote without them parses with both empty.
832    #[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}