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::manifest::Manifest;
22use super::node::{self, Node};
23use super::project_artifact::CanonicalDigest;
24use super::target::Target;
25use super::transport::winps::windows_component_problem;
26use super::tree::read_repo_file;
27use super::yamlio::{SchemaVersion, strict_parse};
28use crate::error::{CoreError, DeployFinding};
29
30/// Repo-relative path of the project file.
31pub const PROJECT_FILE_REL: &str = "deploy/project.yaml";
32
33/// Repo-relative path of the project descriptor every project repo holds at
34/// its root (D-09).
35pub const PROJECT_JSON_REL: &str = "project.json";
36
37/// The parsed `deploy/project.yaml` (D-08, V9).
38#[derive(Debug, Clone, PartialEq, Deserialize)]
39#[serde(deny_unknown_fields)]
40pub struct ProjectFile {
41    /// Always 1; any other value is refused by [`SchemaVersion`].
42    pub schema_version: SchemaVersion,
43    /// The project's name on the gateway. `project.json` holds only a display
44    /// `title`, so the name cannot come from there. It is a path segment on
45    /// the gateway host, so it must be one safe component on both systems.
46    pub project: String,
47    /// The environments (`targets/<ENV>.yaml`) this project deploys to.
48    pub environments: Vec<String>,
49    /// The tag providers this project owns (Phase 26, D-04): each one is read
50    /// from `tags/<P>/` in the repo and deployed with the project in one run.
51    /// Providers not listed are never touched. Absent means none. A build
52    /// older than Phase 26 refuses the key (`deny_unknown_fields`), which
53    /// fails closed.
54    #[serde(default)]
55    pub tag_providers: Vec<String>,
56}
57
58impl ProjectFile {
59    /// Parse and validate `bytes` (labelled `file` in errors). Every semantic
60    /// defect is collected into ONE `deploy_tree_invalid`.
61    pub fn parse(file: &str, bytes: &[u8]) -> Result<ProjectFile, CoreError> {
62        let parsed: ProjectFile = strict_parse(file, bytes)?;
63        parsed.validate(file)?;
64        Ok(parsed)
65    }
66
67    fn validate(&self, file: &str) -> Result<(), CoreError> {
68        let mut findings = Vec::new();
69        let mut push = |message: String| {
70            findings.push(DeployFinding {
71                file: file.to_string(),
72                line: None,
73                column: None,
74                message,
75            });
76        };
77
78        if let Some(why) = component_problem(&self.project) {
79            push(format!("project: the name {why}"));
80        } else if let Some(why) = windows_component_problem(&self.project) {
81            push(format!("project: {why}"));
82        }
83        if self.environments.is_empty() {
84            push("environments: must list at least one environment".to_string());
85        }
86        for (i, env) in self.environments.iter().enumerate() {
87            if let Some(why) = component_problem(env) {
88                push(format!("environments[{i}]: the name {why}"));
89            }
90            if self.environments[..i].contains(env) {
91                push(format!("environments[{i}]: duplicate environment"));
92            }
93        }
94        // A provider name becomes the directory `tags/<P>/` and a run-directory
95        // file name, so it must be one safe component on both systems. The
96        // gateway folds provider names, so two entries that differ only in
97        // ASCII case are one provider listed twice.
98        for (i, provider) in self.tag_providers.iter().enumerate() {
99            if let Some(why) = component_problem(provider) {
100                push(format!("tag_providers[{i}]: the name {why}"));
101            } else if let Some(why) = windows_component_problem(provider) {
102                push(format!("tag_providers[{i}]: {why}"));
103            }
104            if self.tag_providers[..i]
105                .iter()
106                .any(|seen| seen.eq_ignore_ascii_case(provider))
107            {
108                push(format!("tag_providers[{i}]: duplicate tag provider"));
109            }
110        }
111
112        if findings.is_empty() {
113            Ok(())
114        } else {
115            Err(CoreError::deploy_findings(findings))
116        }
117    }
118
119    /// Refuse an environment the file does not list (`deploy_tree_invalid`
120    /// naming [`PROJECT_FILE_REL`]).
121    pub fn require_env(&self, env: &str) -> Result<(), CoreError> {
122        if self.environments.iter().any(|e| e == env) {
123            Ok(())
124        } else {
125            Err(CoreError::deploy_tree_invalid(
126                PROJECT_FILE_REL,
127                None,
128                None,
129                format!("--env {env} is not listed under environments"),
130            ))
131        }
132    }
133}
134
135/// Read and parse [`PROJECT_FILE_REL`] below `repo`.
136pub fn load_project_file(repo: &Path) -> Result<ProjectFile, CoreError> {
137    let bytes = read_repo_file(
138        repo,
139        PROJECT_FILE_REL,
140        Some("a project repo names its gateway project and environments here"),
141    )?;
142    ProjectFile::parse(PROJECT_FILE_REL, &bytes)
143}
144
145/// Repo-relative directory of the owned providers' settings (Phase 26, D-09,
146/// D-12): `deploy/tag-providers/<P>.json`, beside [`PROJECT_FILE_REL`] and
147/// outside `tags/`, which the git module reads.
148pub const TAG_PROVIDERS_DIR_REL: &str = "deploy/tag-providers";
149
150/// One owned provider's settings, `deploy/tag-providers/<P>.json`. Strict:
151/// unknown keys are refused, `schema_version` is 1 and `config` is an object
152/// (the provider's `config` as the gateway's tag provider resource holds it).
153#[derive(Debug, Clone, PartialEq, Deserialize)]
154#[serde(deny_unknown_fields)]
155pub struct ProviderSettings {
156    /// Always 1; any other value is refused by [`SchemaVersion`].
157    pub schema_version: SchemaVersion,
158    /// Whether the provider is enabled.
159    pub enabled: bool,
160    /// The provider's description; absent means none.
161    #[serde(default)]
162    pub description: Option<String>,
163    /// The provider's configuration object.
164    pub config: serde_json::Value,
165}
166
167/// The repo-relative settings file of `provider`.
168fn provider_settings_rel(provider: &str) -> String {
169    format!("{TAG_PROVIDERS_DIR_REL}/{provider}.json")
170}
171
172/// Read and validate `deploy/tag-providers/<provider>.json` below `repo`.
173///
174/// A missing file, invalid JSON (a duplicate key included), an unknown key, a
175/// `schema_version` other than 1 or a `config` that is not an object is
176/// `deploy_tree_invalid` naming the file. ign would write these settings, so
177/// an Embedded secret anywhere in them is `embedded_secret` naming the file
178/// and the JSON path (D-14). No message carries a value.
179pub fn load_provider_settings(repo: &Path, provider: &str) -> Result<ProviderSettings, CoreError> {
180    let rel = provider_settings_rel(provider);
181    let bytes = read_repo_file(
182        repo,
183        &rel,
184        Some("each provider a project lists in tag_providers has its settings here"),
185    )?;
186    let invalid = |line: Option<usize>, column: Option<usize>, why: String| {
187        CoreError::deploy_tree_invalid(rel.as_str(), line, column, why)
188    };
189    let text = std::str::from_utf8(&bytes)
190        .map_err(|_| invalid(None, None, "is not valid UTF-8".to_string()))?;
191    let tree = node::parse(text)
192        .map_err(|why| invalid(None, None, format!("is not valid JSON ({why})")))?;
193    let settings: ProviderSettings = serde_json::from_str(text).map_err(|e| {
194        let mut why = e.to_string();
195        if let Some(at) = why.rfind(" at line ") {
196            why.truncate(at);
197        }
198        // serde_json quotes the offending scalar in a type error: say what is
199        // wrong without echoing it.
200        if why.starts_with("invalid type") || why.starts_with("invalid value") {
201            why = "a value has the wrong type".to_string();
202        }
203        invalid(Some(e.line()), Some(e.column()), why)
204    })?;
205    if !settings.config.is_object() {
206        return Err(invalid(None, None, "config: must be an object".to_string()));
207    }
208    let scan = super::lint::scan_embedded(&rel, &tree);
209    if !scan.refused.is_empty() {
210        return Err(CoreError::embedded_secret(scan.refused, Vec::new()));
211    }
212    Ok(settings)
213}
214
215/// The gateway-config manifest of `gateway`, relative to the targets root:
216/// `gateways/<gateway>/manifest.yaml` (D-05).
217pub fn gateway_manifest_rel(gateway: &str) -> String {
218    format!("gateways/{gateway}/manifest.yaml")
219}
220
221/// One list of providers no project may own, with the label a refusal names.
222#[derive(Debug, Clone, PartialEq, Eq)]
223pub struct ReadOnlySource {
224    /// Where the list comes from, as a refusal names it (`the built-in
225    /// default`, `read_only_tag_providers of targets/dev.yaml`, ...).
226    pub label: String,
227    /// The providers it holds, as written.
228    pub names: Vec<String>,
229}
230
231/// Every source of read-only tag providers (D-06), in the order a refusal
232/// looks them up: the built-in default (`System`), the target's
233/// `exclude_tag_providers` and `read_only_tag_providers`, then the
234/// manifest's.
235#[derive(Debug, Clone, PartialEq, Eq)]
236pub struct ReadOnlySources {
237    /// The target's gateway, for the warning that names its manifest path.
238    pub gateway: String,
239    /// The lists.
240    pub sources: Vec<ReadOnlySource>,
241}
242
243impl ReadOnlySources {
244    /// The sources of `target` (labelled `target_file`) and, when one exists,
245    /// the gateway-config `manifest` (labelled with its path).
246    pub fn new(target_file: &str, target: &Target, manifest: Option<(&str, &Manifest)>) -> Self {
247        let mut sources = vec![
248            ReadOnlySource {
249                label: "the built-in default".to_string(),
250                names: super::exclude::DEFAULT_EXCLUDED_TAG_PROVIDERS
251                    .iter()
252                    .map(|p| (*p).to_string())
253                    .collect(),
254            },
255            ReadOnlySource {
256                label: format!("exclude_tag_providers of {target_file}"),
257                names: target.exclude_tag_providers.clone(),
258            },
259            ReadOnlySource {
260                label: format!("read_only_tag_providers of {target_file}"),
261                names: target.read_only_tag_providers.clone(),
262            },
263        ];
264        if let Some((rel, manifest)) = manifest {
265            sources.push(ReadOnlySource {
266                label: format!("exclude_tag_providers of {rel}"),
267                names: manifest.exclude_tag_providers.clone(),
268            });
269            sources.push(ReadOnlySource {
270                label: format!("read_only_tag_providers of {rel}"),
271                names: manifest.read_only_tag_providers.clone(),
272            });
273        }
274        Self {
275            gateway: target.gateway.clone(),
276            sources,
277        }
278    }
279
280    /// The label of the first source holding `provider` under ASCII case
281    /// folding, or `None`.
282    fn holder(&self, provider: &str) -> Option<&str> {
283        self.sources
284            .iter()
285            .find(|s| s.names.iter().any(|n| n.eq_ignore_ascii_case(provider)))
286            .map(|s| s.label.as_str())
287    }
288}
289
290/// Whether `file` may own every provider it lists (D-04, D-05, D-06). Pure.
291///
292/// Each listed provider that a read-only source holds (`System`, either
293/// `exclude_tag_providers`, either `read_only_tag_providers`, under ASCII
294/// case folding) is a finding naming the source; all of them are one
295/// `deploy_tree_invalid` on [`PROJECT_FILE_REL`]. Then a listed provider the
296/// gateway-config manifest still lists in `tag_providers` (ASCII case folded)
297/// is `tag_provider_claimed` (D-05). Without a manifest the check passes and
298/// the returned warning names the manifest path it did not find. A project
299/// that owns no provider passes silently.
300pub fn check_tag_ownership(
301    file: &ProjectFile,
302    read_only: &ReadOnlySources,
303    manifest: Option<(&str, &Manifest)>,
304) -> Result<Vec<String>, CoreError> {
305    if file.tag_providers.is_empty() {
306        return Ok(Vec::new());
307    }
308    let findings: Vec<DeployFinding> = file
309        .tag_providers
310        .iter()
311        .enumerate()
312        .filter_map(|(i, provider)| {
313            read_only.holder(provider).map(|label| DeployFinding {
314                file: PROJECT_FILE_REL.to_string(),
315                line: None,
316                column: None,
317                message: format!(
318                    "tag_providers[{i}]: {provider} is read-only to ign ({label}) and cannot be owned by a project"
319                ),
320            })
321        })
322        .collect();
323    if !findings.is_empty() {
324        return Err(CoreError::deploy_findings(findings));
325    }
326    let Some((rel, manifest)) = manifest else {
327        return Ok(vec![format!(
328            "no gateway-config manifest at {} below the targets root: tag provider ownership was checked against the target file only",
329            gateway_manifest_rel(&read_only.gateway)
330        )]);
331    };
332    // D-05: the manifest is the other declared owner. The first listed
333    // provider it still deploys is refused.
334    if let Some(provider) = file.tag_providers.iter().find(|provider| {
335        manifest
336            .tag_providers
337            .iter()
338            .any(|p| p.eq_ignore_ascii_case(provider))
339    }) {
340        return Err(CoreError::TagProviderClaimed {
341            provider: provider.clone(),
342            claimed_by: format!("the gateway-config manifest {rel}"),
343        });
344    }
345    Ok(Vec::new())
346}
347
348/// What a repo's root `project.json` says about the deploy.
349#[derive(Debug, Clone, PartialEq, Eq)]
350pub struct ProjectMeta {
351    /// The parent project's name; `None` for a root project (absent, null or
352    /// empty).
353    pub parent: Option<String>,
354}
355
356/// Read the root `project.json` (D-09): it must exist as a regular file and
357/// parse as a JSON object.
358pub fn read_project_meta(repo: &Path) -> Result<ProjectMeta, CoreError> {
359    if let Err(e) = std::fs::symlink_metadata(repo.join(PROJECT_JSON_REL))
360        && e.kind() == std::io::ErrorKind::NotFound
361    {
362        return Err(CoreError::deploy_tree_invalid(
363            PROJECT_JSON_REL,
364            None,
365            None,
366            "does not exist (a project repo holds project.json at its root)",
367        ));
368    }
369    let bytes = read_repo_file(repo, PROJECT_JSON_REL, None)?;
370    let value: serde_json::Value = serde_json::from_slice(&bytes).map_err(|_| {
371        CoreError::deploy_tree_invalid(PROJECT_JSON_REL, None, None, "is not valid JSON")
372    })?;
373    let Some(object) = value.as_object() else {
374        return Err(CoreError::deploy_tree_invalid(
375            PROJECT_JSON_REL,
376            None,
377            None,
378            "must be a JSON object",
379        ));
380    };
381    // A present `parent` that is neither a string nor null is refused: reading
382    // it as "no parent" would skip the K6 parent checks while the artifact
383    // still ships the malformed descriptor verbatim.
384    let parent = match object.get("parent") {
385        None | Some(serde_json::Value::Null) => None,
386        Some(serde_json::Value::String(s)) if s.is_empty() => None,
387        Some(serde_json::Value::String(s)) => Some(s.clone()),
388        Some(_) => {
389            return Err(CoreError::deploy_tree_invalid(
390                PROJECT_JSON_REL,
391                None,
392                None,
393                "`parent` must be a string (or null)",
394            ));
395        }
396    };
397    Ok(ProjectMeta { parent })
398}
399
400/// What a deploy would do to the named project.
401#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
402#[serde(rename_all = "lowercase")]
403pub enum ProjectAction {
404    /// The project is absent from the gateway and would be created (D-13).
405    Create,
406    /// The live project equals the artifact; nothing would change.
407    Skip,
408    /// The live project differs from the artifact and would be overwritten.
409    Import,
410    /// The live project already equals the artifact, but the release record
411    /// is absent (the git module's handover, D-12) or names another release:
412    /// only the record is written, the project is not touched (K14).
413    Record,
414}
415
416impl ProjectAction {
417    /// Whether the action changes the gateway side: `create`, `import` and
418    /// `record` do (the last one writes the release record, K14).
419    pub fn would_change(self) -> bool {
420        !matches!(self, ProjectAction::Skip)
421    }
422}
423
424/// The longest release tag accepted, so a record or a flag cannot make a
425/// result line unbounded.
426const RELEASE_TAG_MAX: usize = 128;
427
428/// A release tag, parsed as semver after at most one leading `v` (D-02, V4).
429#[derive(Debug, Clone, PartialEq, Eq)]
430pub struct Release {
431    /// The tag as given, including its `v`.
432    pub tag: String,
433    /// The version the tag names.
434    pub version: semver::Version,
435}
436
437/// How one release ranks against another (D-03).
438#[derive(Debug, Clone, Copy, PartialEq, Eq)]
439pub enum ReleaseOrder {
440    /// Higher semver precedence.
441    Newer,
442    /// The same precedence (build metadata is ignored).
443    Equal,
444    /// Lower semver precedence.
445    Older,
446}
447
448impl Release {
449    /// Parse `tag`: one optional leading `v`, then a semver version. A
450    /// pre-release is older than its release (`v1.2.0-rc.1` < `v1.2.0`).
451    /// Refuses `invalid_input` without echoing the text.
452    pub fn parse(tag: &str) -> Result<Release, CoreError> {
453        Release::try_parse(tag).ok_or_else(|| CoreError::InvalidInput {
454            reason: "--release <tag>: not a semver tag (for example v1.4.0)".to_string(),
455        })
456    }
457
458    pub(crate) fn try_parse(tag: &str) -> Option<Release> {
459        if tag.is_empty() || tag.len() > RELEASE_TAG_MAX {
460            return None;
461        }
462        let bare = tag.strip_prefix('v').unwrap_or(tag);
463        let version = semver::Version::parse(bare).ok()?;
464        Some(Release {
465            tag: tag.to_string(),
466            version,
467        })
468    }
469
470    /// How `self` ranks against `other` by semver precedence; build metadata
471    /// is ignored, so `v1.2.0+a` and `v1.2.0` are [`ReleaseOrder::Equal`].
472    pub fn order_against(&self, other: &Release) -> ReleaseOrder {
473        match self.version.cmp_precedence(&other.version) {
474            std::cmp::Ordering::Greater => ReleaseOrder::Newer,
475            std::cmp::Ordering::Equal => ReleaseOrder::Equal,
476            std::cmp::Ordering::Less => ReleaseOrder::Older,
477        }
478    }
479}
480
481/// The only record schema this build reads and writes.
482const RECORD_SCHEMA_VERSION: u32 = 1;
483
484/// The record's keys, in write order.
485const RECORD_FIELDS: [&str; 7] = [
486    "schema_version",
487    "gateway",
488    "project",
489    "release",
490    "artifact_sha256",
491    "run_id",
492    "deployed_at",
493];
494
495/// What the last `ign` deploy of one project on one gateway left on the host
496/// (`ign-deploy/<gateway>/projects/<project>/release.json`, V5). Later runners
497/// read it for the downgrade gate (D-03), the first-deploy gate (D-12) and the
498/// live-drift warning (K13), so it is parsed as strictly as the ledger: a
499/// record that cannot be trusted refuses, it is never read as absent.
500#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
501#[serde(deny_unknown_fields)]
502pub struct ReleaseRecord {
503    /// Always 1.
504    pub schema_version: u32,
505    /// The gateway directory name the record belongs to (the target's
506    /// `gateway`).
507    pub gateway: String,
508    /// The gateway project name.
509    pub project: String,
510    /// The release tag that was deployed, as given.
511    pub release: String,
512    /// Canonical sha256 of the artifact that was deployed: 64 lowercase hex.
513    pub artifact_sha256: String,
514    /// The run that wrote the record: `[A-Za-z0-9._-]{1,128}`.
515    pub run_id: String,
516    /// When the record was written, UTC (informational; never compared).
517    pub deployed_at: String,
518}
519
520/// At most 64 printable characters of `text`, for a problem message.
521fn shown(text: &str) -> String {
522    text.chars().filter(|c| !c.is_control()).take(64).collect()
523}
524
525impl ReleaseRecord {
526    /// Parse and validate `bytes` read from `location`, for `gateway` and
527    /// `project`. Every refusal is [`CoreError::DeployRunInvalid`] naming
528    /// `location`; the problem text names fields and never echoes a value the
529    /// file holds.
530    pub fn parse(
531        bytes: &[u8],
532        location: &str,
533        gateway: &str,
534        project: &str,
535    ) -> Result<ReleaseRecord, CoreError> {
536        let bad = |problem: String| CoreError::DeployRunInvalid {
537            run_id: "unknown".to_string(),
538            problem: format!("{location}: {problem}"),
539        };
540        if bytes.is_empty() {
541            return Err(bad("the file is empty".to_string()));
542        }
543        if bytes.len() > RELEASE_RECORD_MAX_BYTES {
544            return Err(bad(format!(
545                "the file is {} bytes, over the {RELEASE_RECORD_MAX_BYTES} byte cap",
546                bytes.len()
547            )));
548        }
549        // Strict UTF-8, no BOM, no repeated key, bounded depth.
550        let (_, root) = node::classify(bytes).map_err(bad)?;
551        let Node::Obj(members) = &root else {
552            return Err(bad("the top level is not a JSON object".to_string()));
553        };
554        for (key, _) in members {
555            if !RECORD_FIELDS.contains(&key.as_str()) {
556                return Err(bad(format!("unknown field `{}`", shown(key))));
557            }
558        }
559        for field in RECORD_FIELDS {
560            if root.get(field).is_none() {
561                return Err(bad(format!("missing field `{field}`")));
562            }
563        }
564        let record: ReleaseRecord = serde_json::from_slice(bytes).map_err(|e| {
565            bad(format!(
566                "a field has the wrong type (line {} column {})",
567                e.line(),
568                e.column()
569            ))
570        })?;
571
572        if record.schema_version != RECORD_SCHEMA_VERSION {
573            return Err(bad(format!(
574                "schema_version is not {RECORD_SCHEMA_VERSION}, the only version this build reads"
575            )));
576        }
577        if record.gateway != gateway {
578            return Err(bad(format!(
579                "gateway names a different gateway than the target (`{}`)",
580                shown(gateway)
581            )));
582        }
583        if record.project != project {
584            return Err(bad(format!(
585                "project names a different project than this deploy (`{}`)",
586                shown(project)
587            )));
588        }
589        let hex = record.artifact_sha256.len() == 64
590            && record
591                .artifact_sha256
592                .bytes()
593                .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
594        if !hex {
595            return Err(bad(
596                "artifact_sha256 is not 64 lowercase hex digits".to_string()
597            ));
598        }
599        if Release::try_parse(&record.release).is_none() {
600            return Err(bad("release is not a semver tag".to_string()));
601        }
602        if RunId::parse(&record.run_id).is_err() {
603            return Err(bad(
604                "run_id is not 1 to 128 characters of [A-Za-z0-9._-]".to_string()
605            ));
606        }
607        if record.deployed_at.is_empty()
608            || record.deployed_at.len() > 64
609            || !record.deployed_at.bytes().all(|b| b.is_ascii_graphic())
610        {
611            return Err(bad(
612                "deployed_at is not 1 to 64 printable ASCII characters".to_string()
613            ));
614        }
615        Ok(record)
616    }
617
618    /// The canonical bytes: pretty JSON in field order plus one LF.
619    /// `parse(to_bytes(r)) == r`.
620    pub fn to_bytes(&self) -> Vec<u8> {
621        let mut bytes = serde_json::to_vec_pretty(self).unwrap_or_default();
622        bytes.push(b'\n');
623        bytes
624    }
625
626    /// The release this record names. Valid for any record `parse` accepted.
627    pub fn release(&self) -> Result<Release, CoreError> {
628        Release::parse(&self.release)
629    }
630}
631
632/// How the artifact's members differ from the live project's (K5). The
633/// gateway deletes every member an overwrite omits and never says so, so the
634/// counts and the removed paths are the only place the removals show.
635#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
636pub struct MemberDelta {
637    /// In the artifact only.
638    pub added: usize,
639    /// In both, canonical hashes differ.
640    pub changed: usize,
641    /// In the live project only (an overwrite would delete them).
642    pub removed: usize,
643    /// In both, canonical hashes equal.
644    pub same: usize,
645}
646
647/// Compare the artifact with the live project (`None` when the project is
648/// absent). Returns the counts and the removed member keys, byte-sorted.
649pub fn member_delta(
650    artifact: &CanonicalDigest,
651    live: Option<&CanonicalDigest>,
652) -> (MemberDelta, Vec<String>) {
653    let mut delta = MemberDelta::default();
654    let Some(live) = live else {
655        delta.added = artifact.members.len();
656        return (delta, Vec::new());
657    };
658    for (key, hash) in &artifact.members {
659        match live.members.get(key) {
660            None => delta.added += 1,
661            Some(live_hash) if live_hash == hash => delta.same += 1,
662            Some(_) => delta.changed += 1,
663        }
664    }
665    let removed: Vec<String> = live
666        .members
667        .keys()
668        .filter(|key| !artifact.members.contains_key(*key))
669        .cloned()
670        .collect();
671    delta.removed = removed.len();
672    (delta, removed)
673}
674
675/// Why a parent cannot be used (K6 a, D-05, DPRJ-02).
676#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
677#[serde(rename_all = "snake_case")]
678pub enum ParentReason {
679    /// The gateway has no such project (or an ancestor in its chain is
680    /// missing, which the gateway itself flags `invalidParent`).
681    Missing,
682    /// The project is its own ancestor, or the chain never ends.
683    Cycle,
684    /// The parent exists but `inheritable` is false.
685    NotInheritable,
686}
687
688impl ParentReason {
689    /// The word the error message, the hint and the JSON details use.
690    pub fn as_str(self) -> &'static str {
691        match self {
692            Self::Missing => "missing",
693            Self::Cycle => "cycle",
694            Self::NotInheritable => "not_inheritable",
695        }
696    }
697}
698
699/// One gateway project as the parent check needs it, read from
700/// `projects/list` (K6 a).
701#[derive(Debug, Clone, PartialEq, Eq)]
702pub struct ParentLink {
703    /// The project's name.
704    pub name: String,
705    /// Its parent, if any (an empty name counts as none).
706    pub parent: Option<String>,
707    /// Whether it may serve as a parent; `None` when the gateway did not say.
708    pub inheritable: Option<bool>,
709    /// The gateway's own verdict on this project's parent link
710    /// (`invalidParent`); `None` when the gateway did not say.
711    pub invalid_parent: Option<bool>,
712}
713
714/// Follow `parent` through `all` (every project the gateway lists) and return
715/// the chain as found, nearest first, plus whether the walk hit its bound.
716///
717/// The walk takes at most `all.len() + 1` steps: an acyclic chain cannot
718/// visit more projects than exist, so a longer walk is a cycle (T-25-31). It
719/// stops early at a name the gateway does not list.
720pub fn parent_chain(parent: &str, all: &[ParentLink]) -> (Vec<ParentLink>, bool) {
721    let bound = all.len() + 1;
722    let mut chain = Vec::new();
723    let mut next = Some(parent.to_string());
724    let mut steps = 0;
725    while let Some(name) = next.take() {
726        if steps >= bound {
727            return (chain, true);
728        }
729        steps += 1;
730        let Some(link) = all.iter().find(|l| l.name == name) else {
731            break;
732        };
733        next = link.parent.clone().filter(|p| !p.is_empty());
734        chain.push(link.clone());
735    }
736    (chain, false)
737}
738
739/// Decide whether `parent` may serve as `project`'s parent (K6 a). `chain` is
740/// [`parent_chain`]'s answer: `chain[0]` is the parent as found, and an empty
741/// chain means the gateway lists no such project. Pure: no request is made.
742pub fn check_parent(
743    project: &str,
744    parent: &str,
745    chain: &[ParentLink],
746    bound_hit: bool,
747) -> Result<(), ParentReason> {
748    if parent == project
749        || bound_hit
750        || chain
751            .iter()
752            .any(|link| link.name == project || link.parent.as_deref() == Some(project))
753    {
754        return Err(ParentReason::Cycle);
755    }
756    let Some(first) = chain.first() else {
757        return Err(ParentReason::Missing);
758    };
759    if first.inheritable == Some(false) {
760        return Err(ParentReason::NotInheritable);
761    }
762    // The walk ended at a link that names a parent the gateway does not list.
763    if chain
764        .last()
765        .is_some_and(|last| last.parent.as_deref().is_some_and(|p| !p.is_empty()))
766    {
767        return Err(ParentReason::Missing);
768    }
769    Ok(())
770}
771
772#[cfg(test)]
773mod tests {
774    use super::*;
775    use std::collections::BTreeMap;
776
777    fn digest(members: &[(&str, &str)]) -> CanonicalDigest {
778        CanonicalDigest {
779            sha256: String::new(),
780            members: members
781                .iter()
782                .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
783                .collect::<BTreeMap<_, _>>(),
784        }
785    }
786
787    const GOOD: &str = "schema_version: 1\nproject: CapApp\nenvironments: [dev, prod]\n";
788
789    #[test]
790    fn parses_a_valid_file_and_checks_the_environment() {
791        let file = ProjectFile::parse(PROJECT_FILE_REL, GOOD.as_bytes()).expect("valid");
792        assert_eq!(file.project, "CapApp");
793        file.require_env("dev").expect("listed");
794        let err = file.require_env("staging").expect_err("unlisted");
795        assert_eq!(err.code(), "deploy_tree_invalid");
796        assert!(err.to_string().contains(PROJECT_FILE_REL), "{err}");
797    }
798
799    #[test]
800    fn unknown_fields_and_bad_versions_are_refused() {
801        for bad in [
802            "schema_version: 1\nproject: A\nenvironments: [dev]\nextra: 1\n",
803            "schema_version: 2\nproject: A\nenvironments: [dev]\n",
804            "schema_version: 1\nenvironments: [dev]\n",
805        ] {
806            let err = ProjectFile::parse(PROJECT_FILE_REL, bad.as_bytes()).expect_err(bad);
807            assert_eq!(err.code(), "deploy_tree_invalid", "{bad}");
808        }
809    }
810
811    #[test]
812    fn semantic_defects_are_collected_into_one_error() {
813        let bytes = "schema_version: 1\nproject: ../x\nenvironments: [dev, dev, '']\n";
814        let err = ProjectFile::parse(PROJECT_FILE_REL, bytes.as_bytes()).expect_err("bad");
815        let text = err.to_string();
816        assert!(text.contains("project:"), "{text}");
817        assert!(text.contains("duplicate environment"), "{text}");
818        assert!(text.contains("environments[2]"), "{text}");
819        let empty = "schema_version: 1\nproject: A\nenvironments: []\n";
820        let err = ProjectFile::parse(PROJECT_FILE_REL, empty.as_bytes()).expect_err("empty");
821        assert!(err.to_string().contains("at least one"), "{err}");
822    }
823
824    #[test]
825    fn tag_providers_parse_and_default_to_none() {
826        let file = ProjectFile::parse(PROJECT_FILE_REL, GOOD.as_bytes()).expect("valid");
827        assert!(file.tag_providers.is_empty(), "absent means none");
828        let bytes =
829            "schema_version: 1\nproject: A\nenvironments: [dev]\ntag_providers: [PlantA, Site B]\n";
830        let file = ProjectFile::parse(PROJECT_FILE_REL, bytes.as_bytes()).expect("valid");
831        assert_eq!(file.tag_providers, ["PlantA", "Site B"]);
832    }
833
834    #[test]
835    fn tag_provider_defects_are_findings_of_one_error() {
836        let bytes = "schema_version: 1\nproject: A\nenvironments: [dev]\n\
837                     tag_providers: [PlantA, planta, '../x', 'CON', '']\n";
838        let err = ProjectFile::parse(PROJECT_FILE_REL, bytes.as_bytes()).expect_err("bad");
839        assert_eq!(err.code(), "deploy_tree_invalid");
840        let text = err.to_string();
841        for needle in [
842            "tag_providers[1]: duplicate tag provider",
843            "tag_providers[2]",
844            "tag_providers[3]",
845            "tag_providers[4]",
846        ] {
847            assert!(text.contains(needle), "{needle}: {text}");
848        }
849        assert!(!text.contains("tag_providers[0]"), "{text}");
850    }
851
852    #[test]
853    fn windows_unsafe_project_names_are_refused() {
854        for name in ["CON", "a:b", "trailing.", "a*b"] {
855            let bytes = format!("schema_version: 1\nproject: '{name}'\nenvironments: [dev]\n");
856            assert!(
857                ProjectFile::parse(PROJECT_FILE_REL, bytes.as_bytes()).is_err(),
858                "{name}"
859            );
860        }
861    }
862
863    #[test]
864    fn project_meta_reads_the_parent_and_refuses_a_missing_file() {
865        let dir = tempfile::tempdir().expect("tempdir");
866        let err = read_project_meta(dir.path()).expect_err("no project.json");
867        assert!(
868            err.to_string()
869                .contains("a project repo holds project.json at its root"),
870            "{err}"
871        );
872        std::fs::write(
873            dir.path().join("project.json"),
874            br#"{"title":"T","parent":"ParentProject"}"#,
875        )
876        .expect("write");
877        assert_eq!(
878            read_project_meta(dir.path()).expect("meta").parent,
879            Some("ParentProject".to_string())
880        );
881        std::fs::write(dir.path().join("project.json"), br#"{"parent":""}"#).expect("write");
882        assert_eq!(read_project_meta(dir.path()).expect("meta").parent, None);
883        std::fs::write(dir.path().join("project.json"), b"[]").expect("write");
884        assert!(read_project_meta(dir.path()).is_err());
885    }
886
887    #[test]
888    fn project_meta_refuses_a_parent_that_is_not_a_string() {
889        let dir = tempfile::tempdir().expect("tempdir");
890        for body in [
891            &br#"{"parent":5}"#[..],
892            br#"{"parent":[]}"#,
893            br#"{"parent":{}}"#,
894            br#"{"parent":true}"#,
895            br#"{"parent":["ParentProject"]}"#,
896        ] {
897            std::fs::write(dir.path().join("project.json"), body).expect("write");
898            let err = read_project_meta(dir.path()).expect_err("a non-string parent");
899            assert_eq!(err.code(), "deploy_tree_invalid", "{err}");
900            assert!(err.to_string().contains("project.json"), "{err}");
901            assert!(
902                err.to_string().contains("`parent` must be a string"),
903                "{err}"
904            );
905        }
906        // Absent and null stay "no parent".
907        for body in [&br#"{"title":"T"}"#[..], br#"{"parent":null}"#] {
908            std::fs::write(dir.path().join("project.json"), body).expect("write");
909            assert_eq!(read_project_meta(dir.path()).expect("meta").parent, None);
910        }
911    }
912
913    #[test]
914    fn member_delta_counts_and_sorts_removed() {
915        let artifact = digest(&[("a", "1"), ("b", "2"), ("c", "3")]);
916        let live = digest(&[("a", "1"), ("b", "X"), ("z", "9"), ("y", "8")]);
917        let (delta, removed) = member_delta(&artifact, Some(&live));
918        assert_eq!(
919            delta,
920            MemberDelta {
921                added: 1,
922                changed: 1,
923                removed: 2,
924                same: 1
925            }
926        );
927        assert_eq!(removed, ["y", "z"]);
928        let (created, none) = member_delta(&artifact, None);
929        assert_eq!(created.added, 3);
930        assert!(none.is_empty());
931    }
932
933    #[test]
934    fn only_skip_does_not_change_the_gateway() {
935        assert!(ProjectAction::Create.would_change());
936        assert!(ProjectAction::Import.would_change());
937        assert!(ProjectAction::Record.would_change());
938        assert!(!ProjectAction::Skip.would_change());
939    }
940
941    fn rel(tag: &str) -> Release {
942        Release::parse(tag).expect(tag)
943    }
944
945    #[test]
946    fn release_order() {
947        assert_eq!(
948            rel("v1.2.0").order_against(&rel("1.1.9")),
949            ReleaseOrder::Newer
950        );
951        assert_eq!(
952            rel("1.1.9").order_against(&rel("v1.2.0")),
953            ReleaseOrder::Older
954        );
955        assert_eq!(
956            rel("v1.2.0-rc.1").order_against(&rel("v1.2.0")),
957            ReleaseOrder::Older
958        );
959        assert_eq!(
960            rel("v1.2.0").order_against(&rel("v1.2.0-rc.1")),
961            ReleaseOrder::Newer
962        );
963        assert_eq!(
964            rel("v1.2.0-rc.2").order_against(&rel("v1.2.0-rc.10")),
965            ReleaseOrder::Older
966        );
967        // build metadata is not part of precedence
968        assert_eq!(
969            rel("v1.2.0+build").order_against(&rel("v1.2.0")),
970            ReleaseOrder::Equal
971        );
972        assert_eq!(
973            rel("v1.2.0+b").order_against(&rel("1.2.0+a")),
974            ReleaseOrder::Equal
975        );
976        // the leading v is optional and the tag is kept as given
977        assert_eq!(rel("v1.0.0").tag, "v1.0.0");
978        assert_eq!(rel("1.0.0").tag, "1.0.0");
979        for bad in [
980            "vv1.0.0", "", "1.0", "v", "banana", "v1.0.0 ", " v1.0.0", "V1.0.0",
981        ] {
982            let err = Release::parse(bad).expect_err(bad);
983            assert_eq!(err.code(), "invalid_input", "{bad:?}");
984            assert!(err.to_string().contains("--release"), "{err}");
985            // the refusal never echoes the text (too short to tell apart is skipped)
986            assert!(bad.len() < 6 || !err.to_string().contains(bad), "{err}");
987        }
988        let long = format!("v1.0.0-{}", "a".repeat(200));
989        assert!(Release::parse(&long).is_err(), "over the length cap");
990    }
991
992    const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
993
994    fn record() -> ReleaseRecord {
995        ReleaseRecord {
996            schema_version: 1,
997            gateway: "capgw".to_string(),
998            project: "CapApp".to_string(),
999            release: "v1.2.0".to_string(),
1000            artifact_sha256: HASH.to_string(),
1001            run_id: "20261020T101500Z-ab12".to_string(),
1002            deployed_at: "2026-10-20T10:15:00Z".to_string(),
1003        }
1004    }
1005
1006    const LOC: &str = "ign-deploy/capgw/projects/CapApp/release.json";
1007
1008    fn parse(bytes: &[u8]) -> Result<ReleaseRecord, CoreError> {
1009        ReleaseRecord::parse(bytes, LOC, "capgw", "CapApp")
1010    }
1011
1012    fn with(edit: impl FnOnce(&mut serde_json::Value)) -> Vec<u8> {
1013        let mut value = serde_json::to_value(record()).expect("value");
1014        edit(&mut value);
1015        serde_json::to_vec(&value).expect("bytes")
1016    }
1017
1018    #[test]
1019    fn release_record_parse() {
1020        let bytes = record().to_bytes();
1021        assert!(bytes.ends_with(b"}\n"));
1022        assert_eq!(parse(&bytes).expect("round trip"), record());
1023        assert_eq!(record().release().expect("tag").tag, "v1.2.0");
1024
1025        let refusals: Vec<(&str, Vec<u8>)> = vec![
1026            ("empty", Vec::new()),
1027            ("not json", b"nope".to_vec()),
1028            ("array", b"[]".to_vec()),
1029            ("bom", {
1030                let mut b = vec![0xEF, 0xBB, 0xBF];
1031                b.extend(record().to_bytes());
1032                b
1033            }),
1034            (
1035                "unknown field",
1036                with(|v| v["extra"] = serde_json::json!("SECRET-VALUE")),
1037            ),
1038            (
1039                "missing field",
1040                with(|v| {
1041                    v.as_object_mut().expect("object").remove("run_id");
1042                }),
1043            ),
1044            ("schema 2", with(|v| v["schema_version"] = 2.into())),
1045            (
1046                "wrong gateway",
1047                with(|v| v["gateway"] = "SECRET-GW".into()),
1048            ),
1049            (
1050                "wrong project",
1051                with(|v| v["project"] = "SECRET-PROJ".into()),
1052            ),
1053            ("uppercase hash", with(|v| v["artifact_sha256"] = HASH.to_uppercase().into())),
1054            ("short hash", with(|v| v["artifact_sha256"] = "abc".into())),
1055            ("bad release", with(|v| v["release"] = "SECRET-banana".into())),
1056            ("bad run id", with(|v| v["run_id"] = "../SECRET".into())),
1057            ("empty run id", with(|v| v["run_id"] = "".into())),
1058            ("bad stamp", with(|v| v["deployed_at"] = "".into())),
1059            ("wrong type", with(|v| v["schema_version"] = "1".into())),
1060            (
1061                "duplicate key",
1062                format!(
1063                    r#"{{"schema_version":1,"schema_version":1,"gateway":"capgw","project":"CapApp","release":"v1.0.0","artifact_sha256":"{HASH}","run_id":"r","deployed_at":"t"}}"#
1064                )
1065                .into_bytes(),
1066            ),
1067            ("over cap", vec![b' '; RELEASE_RECORD_MAX_BYTES + 1]),
1068        ];
1069        for (name, bytes) in refusals {
1070            let err = parse(&bytes).expect_err(name);
1071            assert_eq!(err.code(), "deploy_run_invalid", "{name}: {err}");
1072            let text = err.to_string();
1073            assert!(text.contains(LOC), "{name}: names the location: {text}");
1074            assert!(!text.contains("SECRET"), "{name}: echoes a value: {text}");
1075        }
1076    }
1077
1078    #[test]
1079    fn release_record_at_the_cap_is_read_not_refused_for_size() {
1080        // exactly at the cap is not a size refusal (it fails on content)
1081        let err = parse(&vec![b' '; RELEASE_RECORD_MAX_BYTES]).expect_err("blank");
1082        assert!(!err.to_string().contains("byte cap"), "{err}");
1083    }
1084
1085    fn link(name: &str, parent: Option<&str>, inheritable: Option<bool>) -> ParentLink {
1086        ParentLink {
1087            name: name.into(),
1088            parent: parent.map(str::to_string),
1089            inheritable,
1090            invalid_parent: None,
1091        }
1092    }
1093
1094    fn reason(project: &str, parent: &str, all: &[ParentLink]) -> Result<(), ParentReason> {
1095        let (chain, bound_hit) = parent_chain(parent, all);
1096        check_parent(project, parent, &chain, bound_hit)
1097    }
1098
1099    #[test]
1100    fn check_parent_reasons() {
1101        let all = [
1102            link("Root", None, Some(true)),
1103            link("Mid", Some("Root"), Some(true)),
1104            link("Closed", None, Some(false)),
1105            link("A", Some("B"), Some(true)),
1106            link("B", Some("A"), Some(true)),
1107            link("Orphan", Some("Gone"), Some(true)),
1108            link("CapApp", Some("Root"), Some(true)),
1109            link("Loop", Some("CapApp"), Some(true)),
1110        ];
1111        // No parent chain problem.
1112        assert_eq!(reason("CapApp", "Mid", &all), Ok(()));
1113        assert_eq!(reason("CapApp", "Root", &all), Ok(()));
1114        // Missing.
1115        assert_eq!(reason("CapApp", "Nope", &all), Err(ParentReason::Missing));
1116        // Self, whether or not the gateway lists the project.
1117        assert_eq!(reason("CapApp", "CapApp", &all), Err(ParentReason::Cycle));
1118        assert_eq!(reason("New", "New", &all), Err(ParentReason::Cycle));
1119        // The parent's chain reaches the project.
1120        assert_eq!(reason("CapApp", "Loop", &all), Err(ParentReason::Cycle));
1121        // A cycle among other projects never ends.
1122        assert_eq!(reason("CapApp", "A", &all), Err(ParentReason::Cycle));
1123        // Not inheritable.
1124        assert_eq!(
1125            reason("CapApp", "Closed", &all),
1126            Err(ParentReason::NotInheritable)
1127        );
1128        // An ancestor the gateway does not list is a missing parent chain.
1129        assert_eq!(reason("CapApp", "Orphan", &all), Err(ParentReason::Missing));
1130        // Unknown inheritable flag is not a refusal.
1131        let unknown = [link("P", None, None)];
1132        assert_eq!(reason("CapApp", "P", &unknown), Ok(()));
1133    }
1134
1135    #[test]
1136    fn parent_chain_is_bounded_by_the_listed_projects() {
1137        // Three projects that parent each other in a ring.
1138        let all = [
1139            link("X", Some("Y"), Some(true)),
1140            link("Y", Some("Z"), Some(true)),
1141            link("Z", Some("X"), Some(true)),
1142        ];
1143        let (chain, bound_hit) = parent_chain("X", &all);
1144        assert!(bound_hit);
1145        assert!(chain.len() <= all.len() + 1);
1146        // A legitimate chain as long as the list never trips the bound.
1147        let line = [
1148            link("P3", None, Some(true)),
1149            link("P2", Some("P3"), Some(true)),
1150            link("P1", Some("P2"), Some(true)),
1151        ];
1152        let (chain, bound_hit) = parent_chain("P1", &line);
1153        assert!(!bound_hit);
1154        assert_eq!(chain.len(), 3);
1155    }
1156
1157    #[test]
1158    fn parent_reason_words_are_stable() {
1159        assert_eq!(ParentReason::Missing.as_str(), "missing");
1160        assert_eq!(ParentReason::Cycle.as_str(), "cycle");
1161        assert_eq!(ParentReason::NotInheritable.as_str(), "not_inheritable");
1162        assert_eq!(
1163            serde_json::to_string(&ParentReason::NotInheritable).expect("json"),
1164            "\"not_inheritable\""
1165        );
1166    }
1167
1168    // -- 26-05: provider settings and tag ownership ------------------------
1169
1170    /// A repo holding `deploy/tag-providers/PlantA.json` = `json`.
1171    fn settings_repo(json: &str) -> tempfile::TempDir {
1172        let dir = tempfile::tempdir().expect("tempdir");
1173        let at = dir.path().join(TAG_PROVIDERS_DIR_REL);
1174        std::fs::create_dir_all(&at).expect("mkdir");
1175        std::fs::write(at.join("PlantA.json"), json).expect("write");
1176        dir
1177    }
1178
1179    const SETTINGS_REL: &str = "deploy/tag-providers/PlantA.json";
1180
1181    #[test]
1182    fn provider_settings_dir_is_beside_the_project_file() {
1183        assert_eq!(TAG_PROVIDERS_DIR_REL, "deploy/tag-providers");
1184        assert_eq!(
1185            gateway_manifest_rel("capgw"),
1186            "gateways/capgw/manifest.yaml"
1187        );
1188    }
1189
1190    #[test]
1191    fn provider_settings_load_strictly() {
1192        let repo = settings_repo(
1193            r#"{"schema_version": 1, "enabled": true, "description": "Plant A", "config": {"profile": {"type": "STANDARD"}}}"#,
1194        );
1195        let settings = load_provider_settings(repo.path(), "PlantA").expect("valid");
1196        assert!(settings.enabled);
1197        assert_eq!(settings.description.as_deref(), Some("Plant A"));
1198        assert_eq!(settings.config["profile"]["type"], "STANDARD");
1199
1200        let repo = settings_repo(r#"{"schema_version": 1, "enabled": false, "config": {}}"#);
1201        let settings = load_provider_settings(repo.path(), "PlantA").expect("no description");
1202        assert!(!settings.enabled);
1203        assert_eq!(settings.description, None);
1204
1205        for bad in [
1206            r#"{"schema_version": 1, "enabled": true, "config": {}, "extra": 1}"#,
1207            r#"{"schema_version": 2, "enabled": true, "config": {}}"#,
1208            r#"{"schema_version": 1, "enabled": true, "config": []}"#,
1209            r#"{"schema_version": 1, "enabled": true, "config": "x"}"#,
1210            r#"{"schema_version": 1, "enabled": true}"#,
1211            r#"{"schema_version": 1, "enabled": true, "config": {"a": 1, "a": 2}}"#,
1212            r#"{"schema_version": 1, "enabled": true, "config": {}"#,
1213            r#"[]"#,
1214        ] {
1215            let repo = settings_repo(bad);
1216            let err = load_provider_settings(repo.path(), "PlantA").expect_err(bad);
1217            assert_eq!(err.code(), "deploy_tree_invalid", "{bad}: {err}");
1218            assert!(err.to_string().contains(SETTINGS_REL), "{bad}: {err}");
1219        }
1220
1221        let repo = tempfile::tempdir().expect("tempdir");
1222        let err = load_provider_settings(repo.path(), "PlantA").expect_err("missing");
1223        assert_eq!(err.code(), "deploy_tree_invalid");
1224        let text = err.to_string();
1225        assert!(text.contains(SETTINGS_REL), "{text}");
1226        assert!(text.contains("does not exist"), "{text}");
1227    }
1228
1229    /// D-14: ign would write the settings, so an Embedded secret anywhere in
1230    /// them refuses `embedded_secret`, naming the file and the JSON path and
1231    /// never the value.
1232    #[test]
1233    fn provider_settings_embedded_secret_is_refused() {
1234        let repo = settings_repo(
1235            r#"{"schema_version": 1, "enabled": true, "config": {"auth": {"password": {"type": "Embedded", "data": {"ciphertext": "SENTINEL-CIPHER"}}}}}"#,
1236        );
1237        let err = load_provider_settings(repo.path(), "PlantA").expect_err("embedded");
1238        assert_eq!(err.code(), "embedded_secret");
1239        let text = err.to_string();
1240        assert!(text.contains(SETTINGS_REL), "{text}");
1241        assert!(text.contains("config.auth.password"), "{text}");
1242        assert!(!text.contains("SENTINEL-CIPHER"), "never a value: {text}");
1243    }
1244
1245    fn owning(providers: &str) -> ProjectFile {
1246        let yaml = format!(
1247            "schema_version: 1\nproject: CapApp\nenvironments: [dev]\ntag_providers: {providers}\n"
1248        );
1249        ProjectFile::parse(PROJECT_FILE_REL, yaml.as_bytes()).expect("project file")
1250    }
1251
1252    fn target_with(extra: &str) -> Target {
1253        let yaml = format!(
1254            "schema_version: 1\ngateway: capgw\nmode: dev\nnodes:\n  master:\n    profile: dev\n{extra}"
1255        );
1256        Target::parse("targets/dev.yaml", yaml.as_bytes()).expect("target")
1257    }
1258
1259    fn manifest_with(extra: &str) -> Manifest {
1260        let yaml = format!("schema_version: 1\ntypes: []\n{extra}");
1261        Manifest::parse("gateways/capgw/manifest.yaml", yaml.as_bytes()).expect("manifest")
1262    }
1263
1264    const MANIFEST_REL: &str = "gateways/capgw/manifest.yaml";
1265
1266    /// The one finding message of a `deploy_tree_invalid` on the project file.
1267    fn refusal(err: CoreError) -> Vec<String> {
1268        assert_eq!(err.code(), "deploy_tree_invalid", "{err}");
1269        let CoreError::DeployTreeInvalid(details) = err else {
1270            unreachable!()
1271        };
1272        details
1273            .findings
1274            .iter()
1275            .map(|f| {
1276                assert_eq!(f.file, PROJECT_FILE_REL);
1277                f.message.clone()
1278            })
1279            .collect()
1280    }
1281
1282    #[test]
1283    fn ownership_refuses_system_and_excluded_providers() {
1284        let target = target_with("exclude_tag_providers: [\"MQTT Engine\"]\n");
1285        let manifest = manifest_with("exclude_tag_providers: [Sparkplug]\n");
1286        let sources =
1287            ReadOnlySources::new("targets/dev.yaml", &target, Some((MANIFEST_REL, &manifest)));
1288        let err = check_tag_ownership(
1289            &owning("[system, PlantA, \"mqtt engine\", SPARKPLUG]"),
1290            &sources,
1291            Some((MANIFEST_REL, &manifest)),
1292        )
1293        .expect_err("three are read-only");
1294        let messages = refusal(err);
1295        assert_eq!(messages.len(), 3, "{messages:?}");
1296        assert!(
1297            messages[0].starts_with("tag_providers[0]") && messages[0].contains("built-in"),
1298            "{messages:?}"
1299        );
1300        assert!(
1301            messages[1].starts_with("tag_providers[2]")
1302                && messages[1].contains("exclude_tag_providers of targets/dev.yaml"),
1303            "{messages:?}"
1304        );
1305        assert!(
1306            messages[2].starts_with("tag_providers[3]")
1307                && messages[2].contains(&format!("exclude_tag_providers of {MANIFEST_REL}")),
1308            "{messages:?}"
1309        );
1310    }
1311
1312    /// D-06: a module-managed shape (`Broker Transmission`) and an
1313    /// edge-gateway-managed shape (`EdgeGateway01`) are refused from the
1314    /// target's and from the manifest's `read_only_tag_providers`, in any
1315    /// letter case.
1316    #[test]
1317    fn ownership_refuses_read_only_providers_from_target_and_manifest() {
1318        for name in ["Broker Transmission", "EdgeGateway01"] {
1319            for listed in [name.to_ascii_lowercase(), name.to_ascii_uppercase()] {
1320                let list = format!("read_only_tag_providers: [\"{name}\"]\n");
1321                let project = owning(&format!("[PlantA, \"{listed}\"]"));
1322
1323                let target = target_with(&list);
1324                let manifest = manifest_with("");
1325                let sources = ReadOnlySources::new(
1326                    "targets/dev.yaml",
1327                    &target,
1328                    Some((MANIFEST_REL, &manifest)),
1329                );
1330                let messages = refusal(
1331                    check_tag_ownership(&project, &sources, Some((MANIFEST_REL, &manifest)))
1332                        .expect_err("target read-only"),
1333                );
1334                assert_eq!(messages.len(), 1, "{messages:?}");
1335                assert!(
1336                    messages[0].starts_with("tag_providers[1]")
1337                        && messages[0].contains("read_only_tag_providers of targets/dev.yaml"),
1338                    "{messages:?}"
1339                );
1340
1341                let target = target_with("");
1342                let manifest = manifest_with(&list);
1343                let sources = ReadOnlySources::new(
1344                    "targets/dev.yaml",
1345                    &target,
1346                    Some((MANIFEST_REL, &manifest)),
1347                );
1348                let messages = refusal(
1349                    check_tag_ownership(&project, &sources, Some((MANIFEST_REL, &manifest)))
1350                        .expect_err("manifest read-only"),
1351                );
1352                assert_eq!(messages.len(), 1, "{messages:?}");
1353                assert!(
1354                    messages[0].contains(&format!("read_only_tag_providers of {MANIFEST_REL}")),
1355                    "{messages:?}"
1356                );
1357            }
1358        }
1359    }
1360
1361    /// D-05: a provider the gateway-config manifest still deploys has an
1362    /// owner already, in any letter case.
1363    #[test]
1364    fn ownership_refuses_a_provider_the_manifest_deploys() {
1365        let target = target_with("");
1366        let manifest = manifest_with("tag_providers: [default, planta]\n");
1367        let sources =
1368            ReadOnlySources::new("targets/dev.yaml", &target, Some((MANIFEST_REL, &manifest)));
1369        let err = check_tag_ownership(
1370            &owning("[Other, PlantA]"),
1371            &sources,
1372            Some((MANIFEST_REL, &manifest)),
1373        )
1374        .expect_err("claimed");
1375        assert_eq!(err.code(), "tag_provider_claimed");
1376        let CoreError::TagProviderClaimed {
1377            provider,
1378            claimed_by,
1379        } = err
1380        else {
1381            unreachable!()
1382        };
1383        assert_eq!(provider, "PlantA");
1384        assert!(claimed_by.contains(MANIFEST_REL), "{claimed_by}");
1385    }
1386
1387    #[test]
1388    fn ownership_without_a_manifest_passes_with_a_warning() {
1389        let target = target_with("");
1390        let sources = ReadOnlySources::new("targets/dev.yaml", &target, None);
1391        let warnings = check_tag_ownership(&owning("[PlantA]"), &sources, None).expect("passes");
1392        assert_eq!(warnings.len(), 1, "{warnings:?}");
1393        assert!(warnings[0].contains(MANIFEST_REL), "{warnings:?}");
1394
1395        let manifest = manifest_with("tag_providers: [Other]\n");
1396        let sources =
1397            ReadOnlySources::new("targets/dev.yaml", &target, Some((MANIFEST_REL, &manifest)));
1398        let warnings = check_tag_ownership(
1399            &owning("[PlantA]"),
1400            &sources,
1401            Some((MANIFEST_REL, &manifest)),
1402        )
1403        .expect("not claimed");
1404        assert!(warnings.is_empty(), "{warnings:?}");
1405
1406        let project = ProjectFile::parse(PROJECT_FILE_REL, GOOD.as_bytes()).expect("no owned");
1407        let sources = ReadOnlySources::new("targets/dev.yaml", &target, None);
1408        assert!(
1409            check_tag_ownership(&project, &sources, None)
1410                .expect("owns nothing")
1411                .is_empty()
1412        );
1413    }
1414}