1use 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
30pub const PROJECT_FILE_REL: &str = "deploy/project.yaml";
32
33pub const PROJECT_JSON_REL: &str = "project.json";
36
37#[derive(Debug, Clone, PartialEq, Deserialize)]
39#[serde(deny_unknown_fields)]
40pub struct ProjectFile {
41 pub schema_version: SchemaVersion,
43 pub project: String,
47 pub environments: Vec<String>,
49 #[serde(default)]
55 pub tag_providers: Vec<String>,
56}
57
58impl ProjectFile {
59 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 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 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
135pub 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
145pub const TAG_PROVIDERS_DIR_REL: &str = "deploy/tag-providers";
149
150#[derive(Debug, Clone, PartialEq, Deserialize)]
154#[serde(deny_unknown_fields)]
155pub struct ProviderSettings {
156 pub schema_version: SchemaVersion,
158 pub enabled: bool,
160 #[serde(default)]
162 pub description: Option<String>,
163 pub config: serde_json::Value,
165}
166
167fn provider_settings_rel(provider: &str) -> String {
169 format!("{TAG_PROVIDERS_DIR_REL}/{provider}.json")
170}
171
172pub 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 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
215pub fn gateway_manifest_rel(gateway: &str) -> String {
218 format!("gateways/{gateway}/manifest.yaml")
219}
220
221#[derive(Debug, Clone, PartialEq, Eq)]
223pub struct ReadOnlySource {
224 pub label: String,
227 pub names: Vec<String>,
229}
230
231#[derive(Debug, Clone, PartialEq, Eq)]
236pub struct ReadOnlySources {
237 pub gateway: String,
239 pub sources: Vec<ReadOnlySource>,
241}
242
243impl ReadOnlySources {
244 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 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
290pub 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 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#[derive(Debug, Clone, PartialEq, Eq)]
350pub struct ProjectMeta {
351 pub parent: Option<String>,
354}
355
356pub 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
402#[serde(rename_all = "lowercase")]
403pub enum ProjectAction {
404 Create,
406 Skip,
408 Import,
410 Record,
414}
415
416impl ProjectAction {
417 pub fn would_change(self) -> bool {
420 !matches!(self, ProjectAction::Skip)
421 }
422}
423
424const RELEASE_TAG_MAX: usize = 128;
427
428#[derive(Debug, Clone, PartialEq, Eq)]
430pub struct Release {
431 pub tag: String,
433 pub version: semver::Version,
435}
436
437#[derive(Debug, Clone, Copy, PartialEq, Eq)]
439pub enum ReleaseOrder {
440 Newer,
442 Equal,
444 Older,
446}
447
448impl Release {
449 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 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
481const RECORD_SCHEMA_VERSION: u32 = 1;
483
484const 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
501#[serde(deny_unknown_fields)]
502pub struct ReleaseRecord {
503 pub schema_version: u32,
505 pub gateway: String,
508 pub project: String,
510 pub release: String,
512 pub artifact_sha256: String,
514 pub run_id: String,
516 pub deployed_at: String,
518}
519
520fn shown(text: &str) -> String {
522 text.chars().filter(|c| !c.is_control()).take(64).collect()
523}
524
525impl ReleaseRecord {
526 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 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 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 pub fn release(&self) -> Result<Release, CoreError> {
628 Release::parse(&self.release)
629 }
630}
631
632#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
636pub struct MemberDelta {
637 pub added: usize,
639 pub changed: usize,
641 pub removed: usize,
643 pub same: usize,
645}
646
647pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
677#[serde(rename_all = "snake_case")]
678pub enum ParentReason {
679 Missing,
682 Cycle,
684 NotInheritable,
686}
687
688impl ParentReason {
689 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#[derive(Debug, Clone, PartialEq, Eq)]
702pub struct ParentLink {
703 pub name: String,
705 pub parent: Option<String>,
707 pub inheritable: Option<bool>,
709 pub invalid_parent: Option<bool>,
712}
713
714pub 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
739pub 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 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 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 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 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 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 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 assert_eq!(reason("CapApp", "Mid", &all), Ok(()));
1113 assert_eq!(reason("CapApp", "Root", &all), Ok(()));
1114 assert_eq!(reason("CapApp", "Nope", &all), Err(ParentReason::Missing));
1116 assert_eq!(reason("CapApp", "CapApp", &all), Err(ParentReason::Cycle));
1118 assert_eq!(reason("New", "New", &all), Err(ParentReason::Cycle));
1119 assert_eq!(reason("CapApp", "Loop", &all), Err(ParentReason::Cycle));
1121 assert_eq!(reason("CapApp", "A", &all), Err(ParentReason::Cycle));
1123 assert_eq!(
1125 reason("CapApp", "Closed", &all),
1126 Err(ParentReason::NotInheritable)
1127 );
1128 assert_eq!(reason("CapApp", "Orphan", &all), Err(ParentReason::Missing));
1130 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 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 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 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 #[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 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 #[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 #[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}