Skip to main content

ignition_core/deploy/
projects.rs

1//! `deploy/project.yaml` and the pure model of a project deploy (Phase 25).
2//!
3//! One Ignition project is deployed from its own repo (CONTEXT D-08). The
4//! repo names the gateway project and the environments it deploys to in
5//! `deploy/project.yaml`, a strict file like `manifest.yaml`: unknown fields
6//! are refused and `schema_version` is 1. The parent project is read from the
7//! root `project.json` (D-05). Everything here is pure: bytes in, models out,
8//! no network, no clock.
9//!
10//! The comparison model lives here too: [`ProjectAction`] is what a deploy
11//! would do and [`member_delta`] counts how the artifact's members differ
12//! from the live project's (K5).
13
14use std::path::Path;
15
16use serde::{Deserialize, Serialize};
17
18use super::apply::run_store::RunId;
19use super::component_problem;
20use super::ledger::RELEASE_RECORD_MAX_BYTES;
21use super::node::{self, Node};
22use super::project_artifact::CanonicalDigest;
23use super::transport::winps::windows_component_problem;
24use super::tree::read_repo_file;
25use super::yamlio::{SchemaVersion, strict_parse};
26use crate::error::{CoreError, DeployFinding};
27
28/// Repo-relative path of the project file.
29pub const PROJECT_FILE_REL: &str = "deploy/project.yaml";
30
31/// Repo-relative path of the project descriptor every project repo holds at
32/// its root (D-09).
33pub const PROJECT_JSON_REL: &str = "project.json";
34
35/// The parsed `deploy/project.yaml` (D-08, V9).
36#[derive(Debug, Clone, PartialEq, Deserialize)]
37#[serde(deny_unknown_fields)]
38pub struct ProjectFile {
39    /// Always 1; any other value is refused by [`SchemaVersion`].
40    pub schema_version: SchemaVersion,
41    /// The project's name on the gateway. `project.json` holds only a display
42    /// `title`, so the name cannot come from there. It is a path segment on
43    /// the gateway host, so it must be one safe component on both systems.
44    pub project: String,
45    /// The environments (`targets/<ENV>.yaml`) this project deploys to.
46    pub environments: Vec<String>,
47}
48
49impl ProjectFile {
50    /// Parse and validate `bytes` (labelled `file` in errors). Every semantic
51    /// defect is collected into ONE `deploy_tree_invalid`.
52    pub fn parse(file: &str, bytes: &[u8]) -> Result<ProjectFile, CoreError> {
53        let parsed: ProjectFile = strict_parse(file, bytes)?;
54        parsed.validate(file)?;
55        Ok(parsed)
56    }
57
58    fn validate(&self, file: &str) -> Result<(), CoreError> {
59        let mut findings = Vec::new();
60        let mut push = |message: String| {
61            findings.push(DeployFinding {
62                file: file.to_string(),
63                line: None,
64                column: None,
65                message,
66            });
67        };
68
69        if let Some(why) = component_problem(&self.project) {
70            push(format!("project: the name {why}"));
71        } else if let Some(why) = windows_component_problem(&self.project) {
72            push(format!("project: {why}"));
73        }
74        if self.environments.is_empty() {
75            push("environments: must list at least one environment".to_string());
76        }
77        for (i, env) in self.environments.iter().enumerate() {
78            if let Some(why) = component_problem(env) {
79                push(format!("environments[{i}]: the name {why}"));
80            }
81            if self.environments[..i].contains(env) {
82                push(format!("environments[{i}]: duplicate environment"));
83            }
84        }
85
86        if findings.is_empty() {
87            Ok(())
88        } else {
89            Err(CoreError::deploy_findings(findings))
90        }
91    }
92
93    /// Refuse an environment the file does not list (`deploy_tree_invalid`
94    /// naming [`PROJECT_FILE_REL`]).
95    pub fn require_env(&self, env: &str) -> Result<(), CoreError> {
96        if self.environments.iter().any(|e| e == env) {
97            Ok(())
98        } else {
99            Err(CoreError::deploy_tree_invalid(
100                PROJECT_FILE_REL,
101                None,
102                None,
103                format!("--env {env} is not listed under environments"),
104            ))
105        }
106    }
107}
108
109/// Read and parse [`PROJECT_FILE_REL`] below `repo`.
110pub fn load_project_file(repo: &Path) -> Result<ProjectFile, CoreError> {
111    let bytes = read_repo_file(
112        repo,
113        PROJECT_FILE_REL,
114        Some("a project repo names its gateway project and environments here"),
115    )?;
116    ProjectFile::parse(PROJECT_FILE_REL, &bytes)
117}
118
119/// What a repo's root `project.json` says about the deploy.
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct ProjectMeta {
122    /// The parent project's name; `None` for a root project (absent, null or
123    /// empty).
124    pub parent: Option<String>,
125}
126
127/// Read the root `project.json` (D-09): it must exist as a regular file and
128/// parse as a JSON object.
129pub fn read_project_meta(repo: &Path) -> Result<ProjectMeta, CoreError> {
130    if let Err(e) = std::fs::symlink_metadata(repo.join(PROJECT_JSON_REL))
131        && e.kind() == std::io::ErrorKind::NotFound
132    {
133        return Err(CoreError::deploy_tree_invalid(
134            PROJECT_JSON_REL,
135            None,
136            None,
137            "does not exist (a project repo holds project.json at its root)",
138        ));
139    }
140    let bytes = read_repo_file(repo, PROJECT_JSON_REL, None)?;
141    let value: serde_json::Value = serde_json::from_slice(&bytes).map_err(|_| {
142        CoreError::deploy_tree_invalid(PROJECT_JSON_REL, None, None, "is not valid JSON")
143    })?;
144    let Some(object) = value.as_object() else {
145        return Err(CoreError::deploy_tree_invalid(
146            PROJECT_JSON_REL,
147            None,
148            None,
149            "must be a JSON object",
150        ));
151    };
152    // A present `parent` that is neither a string nor null is refused: reading
153    // it as "no parent" would skip the K6 parent checks while the artifact
154    // still ships the malformed descriptor verbatim.
155    let parent = match object.get("parent") {
156        None | Some(serde_json::Value::Null) => None,
157        Some(serde_json::Value::String(s)) if s.is_empty() => None,
158        Some(serde_json::Value::String(s)) => Some(s.clone()),
159        Some(_) => {
160            return Err(CoreError::deploy_tree_invalid(
161                PROJECT_JSON_REL,
162                None,
163                None,
164                "`parent` must be a string (or null)",
165            ));
166        }
167    };
168    Ok(ProjectMeta { parent })
169}
170
171/// What a deploy would do to the named project.
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
173#[serde(rename_all = "lowercase")]
174pub enum ProjectAction {
175    /// The project is absent from the gateway and would be created (D-13).
176    Create,
177    /// The live project equals the artifact; nothing would change.
178    Skip,
179    /// The live project differs from the artifact and would be overwritten.
180    Import,
181    /// The live project already equals the artifact, but the release record
182    /// is absent (the git module's handover, D-12) or names another release:
183    /// only the record is written, the project is not touched (K14).
184    Record,
185}
186
187impl ProjectAction {
188    /// Whether the action changes the gateway side: `create`, `import` and
189    /// `record` do (the last one writes the release record, K14).
190    pub fn would_change(self) -> bool {
191        !matches!(self, ProjectAction::Skip)
192    }
193}
194
195/// The longest release tag accepted, so a record or a flag cannot make a
196/// result line unbounded.
197const RELEASE_TAG_MAX: usize = 128;
198
199/// A release tag, parsed as semver after at most one leading `v` (D-02, V4).
200#[derive(Debug, Clone, PartialEq, Eq)]
201pub struct Release {
202    /// The tag as given, including its `v`.
203    pub tag: String,
204    /// The version the tag names.
205    pub version: semver::Version,
206}
207
208/// How one release ranks against another (D-03).
209#[derive(Debug, Clone, Copy, PartialEq, Eq)]
210pub enum ReleaseOrder {
211    /// Higher semver precedence.
212    Newer,
213    /// The same precedence (build metadata is ignored).
214    Equal,
215    /// Lower semver precedence.
216    Older,
217}
218
219impl Release {
220    /// Parse `tag`: one optional leading `v`, then a semver version. A
221    /// pre-release is older than its release (`v1.2.0-rc.1` < `v1.2.0`).
222    /// Refuses `invalid_input` without echoing the text.
223    pub fn parse(tag: &str) -> Result<Release, CoreError> {
224        Release::try_parse(tag).ok_or_else(|| CoreError::InvalidInput {
225            reason: "--release <tag>: not a semver tag (for example v1.4.0)".to_string(),
226        })
227    }
228
229    fn try_parse(tag: &str) -> Option<Release> {
230        if tag.is_empty() || tag.len() > RELEASE_TAG_MAX {
231            return None;
232        }
233        let bare = tag.strip_prefix('v').unwrap_or(tag);
234        let version = semver::Version::parse(bare).ok()?;
235        Some(Release {
236            tag: tag.to_string(),
237            version,
238        })
239    }
240
241    /// How `self` ranks against `other` by semver precedence; build metadata
242    /// is ignored, so `v1.2.0+a` and `v1.2.0` are [`ReleaseOrder::Equal`].
243    pub fn order_against(&self, other: &Release) -> ReleaseOrder {
244        match self.version.cmp_precedence(&other.version) {
245            std::cmp::Ordering::Greater => ReleaseOrder::Newer,
246            std::cmp::Ordering::Equal => ReleaseOrder::Equal,
247            std::cmp::Ordering::Less => ReleaseOrder::Older,
248        }
249    }
250}
251
252/// The only record schema this build reads and writes.
253const RECORD_SCHEMA_VERSION: u32 = 1;
254
255/// The record's keys, in write order.
256const RECORD_FIELDS: [&str; 7] = [
257    "schema_version",
258    "gateway",
259    "project",
260    "release",
261    "artifact_sha256",
262    "run_id",
263    "deployed_at",
264];
265
266/// What the last `ign` deploy of one project on one gateway left on the host
267/// (`ign-deploy/<gateway>/projects/<project>/release.json`, V5). Later runners
268/// read it for the downgrade gate (D-03), the first-deploy gate (D-12) and the
269/// live-drift warning (K13), so it is parsed as strictly as the ledger: a
270/// record that cannot be trusted refuses, it is never read as absent.
271#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
272#[serde(deny_unknown_fields)]
273pub struct ReleaseRecord {
274    /// Always 1.
275    pub schema_version: u32,
276    /// The gateway directory name the record belongs to (the target's
277    /// `gateway`).
278    pub gateway: String,
279    /// The gateway project name.
280    pub project: String,
281    /// The release tag that was deployed, as given.
282    pub release: String,
283    /// Canonical sha256 of the artifact that was deployed: 64 lowercase hex.
284    pub artifact_sha256: String,
285    /// The run that wrote the record: `[A-Za-z0-9._-]{1,128}`.
286    pub run_id: String,
287    /// When the record was written, UTC (informational; never compared).
288    pub deployed_at: String,
289}
290
291/// At most 64 printable characters of `text`, for a problem message.
292fn shown(text: &str) -> String {
293    text.chars().filter(|c| !c.is_control()).take(64).collect()
294}
295
296impl ReleaseRecord {
297    /// Parse and validate `bytes` read from `location`, for `gateway` and
298    /// `project`. Every refusal is [`CoreError::DeployRunInvalid`] naming
299    /// `location`; the problem text names fields and never echoes a value the
300    /// file holds.
301    pub fn parse(
302        bytes: &[u8],
303        location: &str,
304        gateway: &str,
305        project: &str,
306    ) -> Result<ReleaseRecord, CoreError> {
307        let bad = |problem: String| CoreError::DeployRunInvalid {
308            run_id: "unknown".to_string(),
309            problem: format!("{location}: {problem}"),
310        };
311        if bytes.is_empty() {
312            return Err(bad("the file is empty".to_string()));
313        }
314        if bytes.len() > RELEASE_RECORD_MAX_BYTES {
315            return Err(bad(format!(
316                "the file is {} bytes, over the {RELEASE_RECORD_MAX_BYTES} byte cap",
317                bytes.len()
318            )));
319        }
320        // Strict UTF-8, no BOM, no repeated key, bounded depth.
321        let (_, root) = node::classify(bytes).map_err(bad)?;
322        let Node::Obj(members) = &root else {
323            return Err(bad("the top level is not a JSON object".to_string()));
324        };
325        for (key, _) in members {
326            if !RECORD_FIELDS.contains(&key.as_str()) {
327                return Err(bad(format!("unknown field `{}`", shown(key))));
328            }
329        }
330        for field in RECORD_FIELDS {
331            if root.get(field).is_none() {
332                return Err(bad(format!("missing field `{field}`")));
333            }
334        }
335        let record: ReleaseRecord = serde_json::from_slice(bytes).map_err(|e| {
336            bad(format!(
337                "a field has the wrong type (line {} column {})",
338                e.line(),
339                e.column()
340            ))
341        })?;
342
343        if record.schema_version != RECORD_SCHEMA_VERSION {
344            return Err(bad(format!(
345                "schema_version is not {RECORD_SCHEMA_VERSION}, the only version this build reads"
346            )));
347        }
348        if record.gateway != gateway {
349            return Err(bad(format!(
350                "gateway names a different gateway than the target (`{}`)",
351                shown(gateway)
352            )));
353        }
354        if record.project != project {
355            return Err(bad(format!(
356                "project names a different project than this deploy (`{}`)",
357                shown(project)
358            )));
359        }
360        let hex = record.artifact_sha256.len() == 64
361            && record
362                .artifact_sha256
363                .bytes()
364                .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
365        if !hex {
366            return Err(bad(
367                "artifact_sha256 is not 64 lowercase hex digits".to_string()
368            ));
369        }
370        if Release::try_parse(&record.release).is_none() {
371            return Err(bad("release is not a semver tag".to_string()));
372        }
373        if RunId::parse(&record.run_id).is_err() {
374            return Err(bad(
375                "run_id is not 1 to 128 characters of [A-Za-z0-9._-]".to_string()
376            ));
377        }
378        if record.deployed_at.is_empty()
379            || record.deployed_at.len() > 64
380            || !record.deployed_at.bytes().all(|b| b.is_ascii_graphic())
381        {
382            return Err(bad(
383                "deployed_at is not 1 to 64 printable ASCII characters".to_string()
384            ));
385        }
386        Ok(record)
387    }
388
389    /// The canonical bytes: pretty JSON in field order plus one LF.
390    /// `parse(to_bytes(r)) == r`.
391    pub fn to_bytes(&self) -> Vec<u8> {
392        let mut bytes = serde_json::to_vec_pretty(self).unwrap_or_default();
393        bytes.push(b'\n');
394        bytes
395    }
396
397    /// The release this record names. Valid for any record `parse` accepted.
398    pub fn release(&self) -> Result<Release, CoreError> {
399        Release::parse(&self.release)
400    }
401}
402
403/// How the artifact's members differ from the live project's (K5). The
404/// gateway deletes every member an overwrite omits and never says so, so the
405/// counts and the removed paths are the only place the removals show.
406#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
407pub struct MemberDelta {
408    /// In the artifact only.
409    pub added: usize,
410    /// In both, canonical hashes differ.
411    pub changed: usize,
412    /// In the live project only (an overwrite would delete them).
413    pub removed: usize,
414    /// In both, canonical hashes equal.
415    pub same: usize,
416}
417
418/// Compare the artifact with the live project (`None` when the project is
419/// absent). Returns the counts and the removed member keys, byte-sorted.
420pub fn member_delta(
421    artifact: &CanonicalDigest,
422    live: Option<&CanonicalDigest>,
423) -> (MemberDelta, Vec<String>) {
424    let mut delta = MemberDelta::default();
425    let Some(live) = live else {
426        delta.added = artifact.members.len();
427        return (delta, Vec::new());
428    };
429    for (key, hash) in &artifact.members {
430        match live.members.get(key) {
431            None => delta.added += 1,
432            Some(live_hash) if live_hash == hash => delta.same += 1,
433            Some(_) => delta.changed += 1,
434        }
435    }
436    let removed: Vec<String> = live
437        .members
438        .keys()
439        .filter(|key| !artifact.members.contains_key(*key))
440        .cloned()
441        .collect();
442    delta.removed = removed.len();
443    (delta, removed)
444}
445
446/// Why a parent cannot be used (K6 a, D-05, DPRJ-02).
447#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
448#[serde(rename_all = "snake_case")]
449pub enum ParentReason {
450    /// The gateway has no such project (or an ancestor in its chain is
451    /// missing, which the gateway itself flags `invalidParent`).
452    Missing,
453    /// The project is its own ancestor, or the chain never ends.
454    Cycle,
455    /// The parent exists but `inheritable` is false.
456    NotInheritable,
457}
458
459impl ParentReason {
460    /// The word the error message, the hint and the JSON details use.
461    pub fn as_str(self) -> &'static str {
462        match self {
463            Self::Missing => "missing",
464            Self::Cycle => "cycle",
465            Self::NotInheritable => "not_inheritable",
466        }
467    }
468}
469
470/// One gateway project as the parent check needs it, read from
471/// `projects/list` (K6 a).
472#[derive(Debug, Clone, PartialEq, Eq)]
473pub struct ParentLink {
474    /// The project's name.
475    pub name: String,
476    /// Its parent, if any (an empty name counts as none).
477    pub parent: Option<String>,
478    /// Whether it may serve as a parent; `None` when the gateway did not say.
479    pub inheritable: Option<bool>,
480    /// The gateway's own verdict on this project's parent link
481    /// (`invalidParent`); `None` when the gateway did not say.
482    pub invalid_parent: Option<bool>,
483}
484
485/// Follow `parent` through `all` (every project the gateway lists) and return
486/// the chain as found, nearest first, plus whether the walk hit its bound.
487///
488/// The walk takes at most `all.len() + 1` steps: an acyclic chain cannot
489/// visit more projects than exist, so a longer walk is a cycle (T-25-31). It
490/// stops early at a name the gateway does not list.
491pub fn parent_chain(parent: &str, all: &[ParentLink]) -> (Vec<ParentLink>, bool) {
492    let bound = all.len() + 1;
493    let mut chain = Vec::new();
494    let mut next = Some(parent.to_string());
495    let mut steps = 0;
496    while let Some(name) = next.take() {
497        if steps >= bound {
498            return (chain, true);
499        }
500        steps += 1;
501        let Some(link) = all.iter().find(|l| l.name == name) else {
502            break;
503        };
504        next = link.parent.clone().filter(|p| !p.is_empty());
505        chain.push(link.clone());
506    }
507    (chain, false)
508}
509
510/// Decide whether `parent` may serve as `project`'s parent (K6 a). `chain` is
511/// [`parent_chain`]'s answer: `chain[0]` is the parent as found, and an empty
512/// chain means the gateway lists no such project. Pure: no request is made.
513pub fn check_parent(
514    project: &str,
515    parent: &str,
516    chain: &[ParentLink],
517    bound_hit: bool,
518) -> Result<(), ParentReason> {
519    if parent == project
520        || bound_hit
521        || chain
522            .iter()
523            .any(|link| link.name == project || link.parent.as_deref() == Some(project))
524    {
525        return Err(ParentReason::Cycle);
526    }
527    let Some(first) = chain.first() else {
528        return Err(ParentReason::Missing);
529    };
530    if first.inheritable == Some(false) {
531        return Err(ParentReason::NotInheritable);
532    }
533    // The walk ended at a link that names a parent the gateway does not list.
534    if chain
535        .last()
536        .is_some_and(|last| last.parent.as_deref().is_some_and(|p| !p.is_empty()))
537    {
538        return Err(ParentReason::Missing);
539    }
540    Ok(())
541}
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546    use std::collections::BTreeMap;
547
548    fn digest(members: &[(&str, &str)]) -> CanonicalDigest {
549        CanonicalDigest {
550            sha256: String::new(),
551            members: members
552                .iter()
553                .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
554                .collect::<BTreeMap<_, _>>(),
555        }
556    }
557
558    const GOOD: &str = "schema_version: 1\nproject: CapApp\nenvironments: [dev, prod]\n";
559
560    #[test]
561    fn parses_a_valid_file_and_checks_the_environment() {
562        let file = ProjectFile::parse(PROJECT_FILE_REL, GOOD.as_bytes()).expect("valid");
563        assert_eq!(file.project, "CapApp");
564        file.require_env("dev").expect("listed");
565        let err = file.require_env("staging").expect_err("unlisted");
566        assert_eq!(err.code(), "deploy_tree_invalid");
567        assert!(err.to_string().contains(PROJECT_FILE_REL), "{err}");
568    }
569
570    #[test]
571    fn unknown_fields_and_bad_versions_are_refused() {
572        for bad in [
573            "schema_version: 1\nproject: A\nenvironments: [dev]\nextra: 1\n",
574            "schema_version: 2\nproject: A\nenvironments: [dev]\n",
575            "schema_version: 1\nenvironments: [dev]\n",
576        ] {
577            let err = ProjectFile::parse(PROJECT_FILE_REL, bad.as_bytes()).expect_err(bad);
578            assert_eq!(err.code(), "deploy_tree_invalid", "{bad}");
579        }
580    }
581
582    #[test]
583    fn semantic_defects_are_collected_into_one_error() {
584        let bytes = "schema_version: 1\nproject: ../x\nenvironments: [dev, dev, '']\n";
585        let err = ProjectFile::parse(PROJECT_FILE_REL, bytes.as_bytes()).expect_err("bad");
586        let text = err.to_string();
587        assert!(text.contains("project:"), "{text}");
588        assert!(text.contains("duplicate environment"), "{text}");
589        assert!(text.contains("environments[2]"), "{text}");
590        let empty = "schema_version: 1\nproject: A\nenvironments: []\n";
591        let err = ProjectFile::parse(PROJECT_FILE_REL, empty.as_bytes()).expect_err("empty");
592        assert!(err.to_string().contains("at least one"), "{err}");
593    }
594
595    #[test]
596    fn windows_unsafe_project_names_are_refused() {
597        for name in ["CON", "a:b", "trailing.", "a*b"] {
598            let bytes = format!("schema_version: 1\nproject: '{name}'\nenvironments: [dev]\n");
599            assert!(
600                ProjectFile::parse(PROJECT_FILE_REL, bytes.as_bytes()).is_err(),
601                "{name}"
602            );
603        }
604    }
605
606    #[test]
607    fn project_meta_reads_the_parent_and_refuses_a_missing_file() {
608        let dir = tempfile::tempdir().expect("tempdir");
609        let err = read_project_meta(dir.path()).expect_err("no project.json");
610        assert!(
611            err.to_string()
612                .contains("a project repo holds project.json at its root"),
613            "{err}"
614        );
615        std::fs::write(
616            dir.path().join("project.json"),
617            br#"{"title":"T","parent":"WHK-Global"}"#,
618        )
619        .expect("write");
620        assert_eq!(
621            read_project_meta(dir.path()).expect("meta").parent,
622            Some("WHK-Global".to_string())
623        );
624        std::fs::write(dir.path().join("project.json"), br#"{"parent":""}"#).expect("write");
625        assert_eq!(read_project_meta(dir.path()).expect("meta").parent, None);
626        std::fs::write(dir.path().join("project.json"), b"[]").expect("write");
627        assert!(read_project_meta(dir.path()).is_err());
628    }
629
630    #[test]
631    fn project_meta_refuses_a_parent_that_is_not_a_string() {
632        let dir = tempfile::tempdir().expect("tempdir");
633        for body in [
634            &br#"{"parent":5}"#[..],
635            br#"{"parent":[]}"#,
636            br#"{"parent":{}}"#,
637            br#"{"parent":true}"#,
638            br#"{"parent":["WHK-Global"]}"#,
639        ] {
640            std::fs::write(dir.path().join("project.json"), body).expect("write");
641            let err = read_project_meta(dir.path()).expect_err("a non-string parent");
642            assert_eq!(err.code(), "deploy_tree_invalid", "{err}");
643            assert!(err.to_string().contains("project.json"), "{err}");
644            assert!(
645                err.to_string().contains("`parent` must be a string"),
646                "{err}"
647            );
648        }
649        // Absent and null stay "no parent".
650        for body in [&br#"{"title":"T"}"#[..], br#"{"parent":null}"#] {
651            std::fs::write(dir.path().join("project.json"), body).expect("write");
652            assert_eq!(read_project_meta(dir.path()).expect("meta").parent, None);
653        }
654    }
655
656    #[test]
657    fn member_delta_counts_and_sorts_removed() {
658        let artifact = digest(&[("a", "1"), ("b", "2"), ("c", "3")]);
659        let live = digest(&[("a", "1"), ("b", "X"), ("z", "9"), ("y", "8")]);
660        let (delta, removed) = member_delta(&artifact, Some(&live));
661        assert_eq!(
662            delta,
663            MemberDelta {
664                added: 1,
665                changed: 1,
666                removed: 2,
667                same: 1
668            }
669        );
670        assert_eq!(removed, ["y", "z"]);
671        let (created, none) = member_delta(&artifact, None);
672        assert_eq!(created.added, 3);
673        assert!(none.is_empty());
674    }
675
676    #[test]
677    fn only_skip_does_not_change_the_gateway() {
678        assert!(ProjectAction::Create.would_change());
679        assert!(ProjectAction::Import.would_change());
680        assert!(ProjectAction::Record.would_change());
681        assert!(!ProjectAction::Skip.would_change());
682    }
683
684    fn rel(tag: &str) -> Release {
685        Release::parse(tag).expect(tag)
686    }
687
688    #[test]
689    fn release_order() {
690        assert_eq!(
691            rel("v1.2.0").order_against(&rel("1.1.9")),
692            ReleaseOrder::Newer
693        );
694        assert_eq!(
695            rel("1.1.9").order_against(&rel("v1.2.0")),
696            ReleaseOrder::Older
697        );
698        assert_eq!(
699            rel("v1.2.0-rc.1").order_against(&rel("v1.2.0")),
700            ReleaseOrder::Older
701        );
702        assert_eq!(
703            rel("v1.2.0").order_against(&rel("v1.2.0-rc.1")),
704            ReleaseOrder::Newer
705        );
706        assert_eq!(
707            rel("v1.2.0-rc.2").order_against(&rel("v1.2.0-rc.10")),
708            ReleaseOrder::Older
709        );
710        // build metadata is not part of precedence
711        assert_eq!(
712            rel("v1.2.0+build").order_against(&rel("v1.2.0")),
713            ReleaseOrder::Equal
714        );
715        assert_eq!(
716            rel("v1.2.0+b").order_against(&rel("1.2.0+a")),
717            ReleaseOrder::Equal
718        );
719        // the leading v is optional and the tag is kept as given
720        assert_eq!(rel("v1.0.0").tag, "v1.0.0");
721        assert_eq!(rel("1.0.0").tag, "1.0.0");
722        for bad in [
723            "vv1.0.0", "", "1.0", "v", "banana", "v1.0.0 ", " v1.0.0", "V1.0.0",
724        ] {
725            let err = Release::parse(bad).expect_err(bad);
726            assert_eq!(err.code(), "invalid_input", "{bad:?}");
727            assert!(err.to_string().contains("--release"), "{err}");
728            // the refusal never echoes the text (too short to tell apart is skipped)
729            assert!(bad.len() < 6 || !err.to_string().contains(bad), "{err}");
730        }
731        let long = format!("v1.0.0-{}", "a".repeat(200));
732        assert!(Release::parse(&long).is_err(), "over the length cap");
733    }
734
735    const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
736
737    fn record() -> ReleaseRecord {
738        ReleaseRecord {
739            schema_version: 1,
740            gateway: "capgw".to_string(),
741            project: "CapApp".to_string(),
742            release: "v1.2.0".to_string(),
743            artifact_sha256: HASH.to_string(),
744            run_id: "20261020T101500Z-ab12".to_string(),
745            deployed_at: "2026-10-20T10:15:00Z".to_string(),
746        }
747    }
748
749    const LOC: &str = "ign-deploy/capgw/projects/CapApp/release.json";
750
751    fn parse(bytes: &[u8]) -> Result<ReleaseRecord, CoreError> {
752        ReleaseRecord::parse(bytes, LOC, "capgw", "CapApp")
753    }
754
755    fn with(edit: impl FnOnce(&mut serde_json::Value)) -> Vec<u8> {
756        let mut value = serde_json::to_value(record()).expect("value");
757        edit(&mut value);
758        serde_json::to_vec(&value).expect("bytes")
759    }
760
761    #[test]
762    fn release_record_parse() {
763        let bytes = record().to_bytes();
764        assert!(bytes.ends_with(b"}\n"));
765        assert_eq!(parse(&bytes).expect("round trip"), record());
766        assert_eq!(record().release().expect("tag").tag, "v1.2.0");
767
768        let refusals: Vec<(&str, Vec<u8>)> = vec![
769            ("empty", Vec::new()),
770            ("not json", b"nope".to_vec()),
771            ("array", b"[]".to_vec()),
772            ("bom", {
773                let mut b = vec![0xEF, 0xBB, 0xBF];
774                b.extend(record().to_bytes());
775                b
776            }),
777            (
778                "unknown field",
779                with(|v| v["extra"] = serde_json::json!("SECRET-VALUE")),
780            ),
781            (
782                "missing field",
783                with(|v| {
784                    v.as_object_mut().expect("object").remove("run_id");
785                }),
786            ),
787            ("schema 2", with(|v| v["schema_version"] = 2.into())),
788            (
789                "wrong gateway",
790                with(|v| v["gateway"] = "SECRET-GW".into()),
791            ),
792            (
793                "wrong project",
794                with(|v| v["project"] = "SECRET-PROJ".into()),
795            ),
796            ("uppercase hash", with(|v| v["artifact_sha256"] = HASH.to_uppercase().into())),
797            ("short hash", with(|v| v["artifact_sha256"] = "abc".into())),
798            ("bad release", with(|v| v["release"] = "SECRET-banana".into())),
799            ("bad run id", with(|v| v["run_id"] = "../SECRET".into())),
800            ("empty run id", with(|v| v["run_id"] = "".into())),
801            ("bad stamp", with(|v| v["deployed_at"] = "".into())),
802            ("wrong type", with(|v| v["schema_version"] = "1".into())),
803            (
804                "duplicate key",
805                format!(
806                    r#"{{"schema_version":1,"schema_version":1,"gateway":"capgw","project":"CapApp","release":"v1.0.0","artifact_sha256":"{HASH}","run_id":"r","deployed_at":"t"}}"#
807                )
808                .into_bytes(),
809            ),
810            ("over cap", vec![b' '; RELEASE_RECORD_MAX_BYTES + 1]),
811        ];
812        for (name, bytes) in refusals {
813            let err = parse(&bytes).expect_err(name);
814            assert_eq!(err.code(), "deploy_run_invalid", "{name}: {err}");
815            let text = err.to_string();
816            assert!(text.contains(LOC), "{name}: names the location: {text}");
817            assert!(!text.contains("SECRET"), "{name}: echoes a value: {text}");
818        }
819    }
820
821    #[test]
822    fn release_record_at_the_cap_is_read_not_refused_for_size() {
823        // exactly at the cap is not a size refusal (it fails on content)
824        let err = parse(&vec![b' '; RELEASE_RECORD_MAX_BYTES]).expect_err("blank");
825        assert!(!err.to_string().contains("byte cap"), "{err}");
826    }
827
828    fn link(name: &str, parent: Option<&str>, inheritable: Option<bool>) -> ParentLink {
829        ParentLink {
830            name: name.into(),
831            parent: parent.map(str::to_string),
832            inheritable,
833            invalid_parent: None,
834        }
835    }
836
837    fn reason(project: &str, parent: &str, all: &[ParentLink]) -> Result<(), ParentReason> {
838        let (chain, bound_hit) = parent_chain(parent, all);
839        check_parent(project, parent, &chain, bound_hit)
840    }
841
842    #[test]
843    fn check_parent_reasons() {
844        let all = [
845            link("Root", None, Some(true)),
846            link("Mid", Some("Root"), Some(true)),
847            link("Closed", None, Some(false)),
848            link("A", Some("B"), Some(true)),
849            link("B", Some("A"), Some(true)),
850            link("Orphan", Some("Gone"), Some(true)),
851            link("CapApp", Some("Root"), Some(true)),
852            link("Loop", Some("CapApp"), Some(true)),
853        ];
854        // No parent chain problem.
855        assert_eq!(reason("CapApp", "Mid", &all), Ok(()));
856        assert_eq!(reason("CapApp", "Root", &all), Ok(()));
857        // Missing.
858        assert_eq!(reason("CapApp", "Nope", &all), Err(ParentReason::Missing));
859        // Self, whether or not the gateway lists the project.
860        assert_eq!(reason("CapApp", "CapApp", &all), Err(ParentReason::Cycle));
861        assert_eq!(reason("New", "New", &all), Err(ParentReason::Cycle));
862        // The parent's chain reaches the project.
863        assert_eq!(reason("CapApp", "Loop", &all), Err(ParentReason::Cycle));
864        // A cycle among other projects never ends.
865        assert_eq!(reason("CapApp", "A", &all), Err(ParentReason::Cycle));
866        // Not inheritable.
867        assert_eq!(
868            reason("CapApp", "Closed", &all),
869            Err(ParentReason::NotInheritable)
870        );
871        // An ancestor the gateway does not list is a missing parent chain.
872        assert_eq!(reason("CapApp", "Orphan", &all), Err(ParentReason::Missing));
873        // Unknown inheritable flag is not a refusal.
874        let unknown = [link("P", None, None)];
875        assert_eq!(reason("CapApp", "P", &unknown), Ok(()));
876    }
877
878    #[test]
879    fn parent_chain_is_bounded_by_the_listed_projects() {
880        // Three projects that parent each other in a ring.
881        let all = [
882            link("X", Some("Y"), Some(true)),
883            link("Y", Some("Z"), Some(true)),
884            link("Z", Some("X"), Some(true)),
885        ];
886        let (chain, bound_hit) = parent_chain("X", &all);
887        assert!(bound_hit);
888        assert!(chain.len() <= all.len() + 1);
889        // A legitimate chain as long as the list never trips the bound.
890        let line = [
891            link("P3", None, Some(true)),
892            link("P2", Some("P3"), Some(true)),
893            link("P1", Some("P2"), Some(true)),
894        ];
895        let (chain, bound_hit) = parent_chain("P1", &line);
896        assert!(!bound_hit);
897        assert_eq!(chain.len(), 3);
898    }
899
900    #[test]
901    fn parent_reason_words_are_stable() {
902        assert_eq!(ParentReason::Missing.as_str(), "missing");
903        assert_eq!(ParentReason::Cycle.as_str(), "cycle");
904        assert_eq!(ParentReason::NotInheritable.as_str(), "not_inheritable");
905        assert_eq!(
906            serde_json::to_string(&ParentReason::NotInheritable).expect("json"),
907            "\"not_inheritable\""
908        );
909    }
910}