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::node::{self, Node};
22use super::project_artifact::CanonicalDigest;
23use super::transport::winps::windows_component_problem;
24use super::tree::read_repo_file;
25use super::yamlio::{SchemaVersion, strict_parse};
26use crate::error::{CoreError, DeployFinding};
27
28pub const PROJECT_FILE_REL: &str = "deploy/project.yaml";
30
31pub const PROJECT_JSON_REL: &str = "project.json";
34
35#[derive(Debug, Clone, PartialEq, Deserialize)]
37#[serde(deny_unknown_fields)]
38pub struct ProjectFile {
39 pub schema_version: SchemaVersion,
41 pub project: String,
45 pub environments: Vec<String>,
47}
48
49impl ProjectFile {
50 pub fn parse(file: &str, bytes: &[u8]) -> Result<ProjectFile, CoreError> {
53 let parsed: ProjectFile = strict_parse(file, bytes)?;
54 parsed.validate(file)?;
55 Ok(parsed)
56 }
57
58 fn validate(&self, file: &str) -> Result<(), CoreError> {
59 let mut findings = Vec::new();
60 let mut push = |message: String| {
61 findings.push(DeployFinding {
62 file: file.to_string(),
63 line: None,
64 column: None,
65 message,
66 });
67 };
68
69 if let Some(why) = component_problem(&self.project) {
70 push(format!("project: the name {why}"));
71 } else if let Some(why) = windows_component_problem(&self.project) {
72 push(format!("project: {why}"));
73 }
74 if self.environments.is_empty() {
75 push("environments: must list at least one environment".to_string());
76 }
77 for (i, env) in self.environments.iter().enumerate() {
78 if let Some(why) = component_problem(env) {
79 push(format!("environments[{i}]: the name {why}"));
80 }
81 if self.environments[..i].contains(env) {
82 push(format!("environments[{i}]: duplicate environment"));
83 }
84 }
85
86 if findings.is_empty() {
87 Ok(())
88 } else {
89 Err(CoreError::deploy_findings(findings))
90 }
91 }
92
93 pub fn require_env(&self, env: &str) -> Result<(), CoreError> {
96 if self.environments.iter().any(|e| e == env) {
97 Ok(())
98 } else {
99 Err(CoreError::deploy_tree_invalid(
100 PROJECT_FILE_REL,
101 None,
102 None,
103 format!("--env {env} is not listed under environments"),
104 ))
105 }
106 }
107}
108
109pub fn load_project_file(repo: &Path) -> Result<ProjectFile, CoreError> {
111 let bytes = read_repo_file(
112 repo,
113 PROJECT_FILE_REL,
114 Some("a project repo names its gateway project and environments here"),
115 )?;
116 ProjectFile::parse(PROJECT_FILE_REL, &bytes)
117}
118
119#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct ProjectMeta {
122 pub parent: Option<String>,
125}
126
127pub fn read_project_meta(repo: &Path) -> Result<ProjectMeta, CoreError> {
130 if let Err(e) = std::fs::symlink_metadata(repo.join(PROJECT_JSON_REL))
131 && e.kind() == std::io::ErrorKind::NotFound
132 {
133 return Err(CoreError::deploy_tree_invalid(
134 PROJECT_JSON_REL,
135 None,
136 None,
137 "does not exist (a project repo holds project.json at its root)",
138 ));
139 }
140 let bytes = read_repo_file(repo, PROJECT_JSON_REL, None)?;
141 let value: serde_json::Value = serde_json::from_slice(&bytes).map_err(|_| {
142 CoreError::deploy_tree_invalid(PROJECT_JSON_REL, None, None, "is not valid JSON")
143 })?;
144 let Some(object) = value.as_object() else {
145 return Err(CoreError::deploy_tree_invalid(
146 PROJECT_JSON_REL,
147 None,
148 None,
149 "must be a JSON object",
150 ));
151 };
152 let parent = match object.get("parent") {
156 None | Some(serde_json::Value::Null) => None,
157 Some(serde_json::Value::String(s)) if s.is_empty() => None,
158 Some(serde_json::Value::String(s)) => Some(s.clone()),
159 Some(_) => {
160 return Err(CoreError::deploy_tree_invalid(
161 PROJECT_JSON_REL,
162 None,
163 None,
164 "`parent` must be a string (or null)",
165 ));
166 }
167 };
168 Ok(ProjectMeta { parent })
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
173#[serde(rename_all = "lowercase")]
174pub enum ProjectAction {
175 Create,
177 Skip,
179 Import,
181 Record,
185}
186
187impl ProjectAction {
188 pub fn would_change(self) -> bool {
191 !matches!(self, ProjectAction::Skip)
192 }
193}
194
195const RELEASE_TAG_MAX: usize = 128;
198
199#[derive(Debug, Clone, PartialEq, Eq)]
201pub struct Release {
202 pub tag: String,
204 pub version: semver::Version,
206}
207
208#[derive(Debug, Clone, Copy, PartialEq, Eq)]
210pub enum ReleaseOrder {
211 Newer,
213 Equal,
215 Older,
217}
218
219impl Release {
220 pub fn parse(tag: &str) -> Result<Release, CoreError> {
224 Release::try_parse(tag).ok_or_else(|| CoreError::InvalidInput {
225 reason: "--release <tag>: not a semver tag (for example v1.4.0)".to_string(),
226 })
227 }
228
229 fn try_parse(tag: &str) -> Option<Release> {
230 if tag.is_empty() || tag.len() > RELEASE_TAG_MAX {
231 return None;
232 }
233 let bare = tag.strip_prefix('v').unwrap_or(tag);
234 let version = semver::Version::parse(bare).ok()?;
235 Some(Release {
236 tag: tag.to_string(),
237 version,
238 })
239 }
240
241 pub fn order_against(&self, other: &Release) -> ReleaseOrder {
244 match self.version.cmp_precedence(&other.version) {
245 std::cmp::Ordering::Greater => ReleaseOrder::Newer,
246 std::cmp::Ordering::Equal => ReleaseOrder::Equal,
247 std::cmp::Ordering::Less => ReleaseOrder::Older,
248 }
249 }
250}
251
252const RECORD_SCHEMA_VERSION: u32 = 1;
254
255const RECORD_FIELDS: [&str; 7] = [
257 "schema_version",
258 "gateway",
259 "project",
260 "release",
261 "artifact_sha256",
262 "run_id",
263 "deployed_at",
264];
265
266#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
272#[serde(deny_unknown_fields)]
273pub struct ReleaseRecord {
274 pub schema_version: u32,
276 pub gateway: String,
279 pub project: String,
281 pub release: String,
283 pub artifact_sha256: String,
285 pub run_id: String,
287 pub deployed_at: String,
289}
290
291fn shown(text: &str) -> String {
293 text.chars().filter(|c| !c.is_control()).take(64).collect()
294}
295
296impl ReleaseRecord {
297 pub fn parse(
302 bytes: &[u8],
303 location: &str,
304 gateway: &str,
305 project: &str,
306 ) -> Result<ReleaseRecord, CoreError> {
307 let bad = |problem: String| CoreError::DeployRunInvalid {
308 run_id: "unknown".to_string(),
309 problem: format!("{location}: {problem}"),
310 };
311 if bytes.is_empty() {
312 return Err(bad("the file is empty".to_string()));
313 }
314 if bytes.len() > RELEASE_RECORD_MAX_BYTES {
315 return Err(bad(format!(
316 "the file is {} bytes, over the {RELEASE_RECORD_MAX_BYTES} byte cap",
317 bytes.len()
318 )));
319 }
320 let (_, root) = node::classify(bytes).map_err(bad)?;
322 let Node::Obj(members) = &root else {
323 return Err(bad("the top level is not a JSON object".to_string()));
324 };
325 for (key, _) in members {
326 if !RECORD_FIELDS.contains(&key.as_str()) {
327 return Err(bad(format!("unknown field `{}`", shown(key))));
328 }
329 }
330 for field in RECORD_FIELDS {
331 if root.get(field).is_none() {
332 return Err(bad(format!("missing field `{field}`")));
333 }
334 }
335 let record: ReleaseRecord = serde_json::from_slice(bytes).map_err(|e| {
336 bad(format!(
337 "a field has the wrong type (line {} column {})",
338 e.line(),
339 e.column()
340 ))
341 })?;
342
343 if record.schema_version != RECORD_SCHEMA_VERSION {
344 return Err(bad(format!(
345 "schema_version is not {RECORD_SCHEMA_VERSION}, the only version this build reads"
346 )));
347 }
348 if record.gateway != gateway {
349 return Err(bad(format!(
350 "gateway names a different gateway than the target (`{}`)",
351 shown(gateway)
352 )));
353 }
354 if record.project != project {
355 return Err(bad(format!(
356 "project names a different project than this deploy (`{}`)",
357 shown(project)
358 )));
359 }
360 let hex = record.artifact_sha256.len() == 64
361 && record
362 .artifact_sha256
363 .bytes()
364 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
365 if !hex {
366 return Err(bad(
367 "artifact_sha256 is not 64 lowercase hex digits".to_string()
368 ));
369 }
370 if Release::try_parse(&record.release).is_none() {
371 return Err(bad("release is not a semver tag".to_string()));
372 }
373 if RunId::parse(&record.run_id).is_err() {
374 return Err(bad(
375 "run_id is not 1 to 128 characters of [A-Za-z0-9._-]".to_string()
376 ));
377 }
378 if record.deployed_at.is_empty()
379 || record.deployed_at.len() > 64
380 || !record.deployed_at.bytes().all(|b| b.is_ascii_graphic())
381 {
382 return Err(bad(
383 "deployed_at is not 1 to 64 printable ASCII characters".to_string()
384 ));
385 }
386 Ok(record)
387 }
388
389 pub fn to_bytes(&self) -> Vec<u8> {
392 let mut bytes = serde_json::to_vec_pretty(self).unwrap_or_default();
393 bytes.push(b'\n');
394 bytes
395 }
396
397 pub fn release(&self) -> Result<Release, CoreError> {
399 Release::parse(&self.release)
400 }
401}
402
403#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
407pub struct MemberDelta {
408 pub added: usize,
410 pub changed: usize,
412 pub removed: usize,
414 pub same: usize,
416}
417
418pub fn member_delta(
421 artifact: &CanonicalDigest,
422 live: Option<&CanonicalDigest>,
423) -> (MemberDelta, Vec<String>) {
424 let mut delta = MemberDelta::default();
425 let Some(live) = live else {
426 delta.added = artifact.members.len();
427 return (delta, Vec::new());
428 };
429 for (key, hash) in &artifact.members {
430 match live.members.get(key) {
431 None => delta.added += 1,
432 Some(live_hash) if live_hash == hash => delta.same += 1,
433 Some(_) => delta.changed += 1,
434 }
435 }
436 let removed: Vec<String> = live
437 .members
438 .keys()
439 .filter(|key| !artifact.members.contains_key(*key))
440 .cloned()
441 .collect();
442 delta.removed = removed.len();
443 (delta, removed)
444}
445
446#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
448#[serde(rename_all = "snake_case")]
449pub enum ParentReason {
450 Missing,
453 Cycle,
455 NotInheritable,
457}
458
459impl ParentReason {
460 pub fn as_str(self) -> &'static str {
462 match self {
463 Self::Missing => "missing",
464 Self::Cycle => "cycle",
465 Self::NotInheritable => "not_inheritable",
466 }
467 }
468}
469
470#[derive(Debug, Clone, PartialEq, Eq)]
473pub struct ParentLink {
474 pub name: String,
476 pub parent: Option<String>,
478 pub inheritable: Option<bool>,
480 pub invalid_parent: Option<bool>,
483}
484
485pub fn parent_chain(parent: &str, all: &[ParentLink]) -> (Vec<ParentLink>, bool) {
492 let bound = all.len() + 1;
493 let mut chain = Vec::new();
494 let mut next = Some(parent.to_string());
495 let mut steps = 0;
496 while let Some(name) = next.take() {
497 if steps >= bound {
498 return (chain, true);
499 }
500 steps += 1;
501 let Some(link) = all.iter().find(|l| l.name == name) else {
502 break;
503 };
504 next = link.parent.clone().filter(|p| !p.is_empty());
505 chain.push(link.clone());
506 }
507 (chain, false)
508}
509
510pub fn check_parent(
514 project: &str,
515 parent: &str,
516 chain: &[ParentLink],
517 bound_hit: bool,
518) -> Result<(), ParentReason> {
519 if parent == project
520 || bound_hit
521 || chain
522 .iter()
523 .any(|link| link.name == project || link.parent.as_deref() == Some(project))
524 {
525 return Err(ParentReason::Cycle);
526 }
527 let Some(first) = chain.first() else {
528 return Err(ParentReason::Missing);
529 };
530 if first.inheritable == Some(false) {
531 return Err(ParentReason::NotInheritable);
532 }
533 if chain
535 .last()
536 .is_some_and(|last| last.parent.as_deref().is_some_and(|p| !p.is_empty()))
537 {
538 return Err(ParentReason::Missing);
539 }
540 Ok(())
541}
542
543#[cfg(test)]
544mod tests {
545 use super::*;
546 use std::collections::BTreeMap;
547
548 fn digest(members: &[(&str, &str)]) -> CanonicalDigest {
549 CanonicalDigest {
550 sha256: String::new(),
551 members: members
552 .iter()
553 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
554 .collect::<BTreeMap<_, _>>(),
555 }
556 }
557
558 const GOOD: &str = "schema_version: 1\nproject: CapApp\nenvironments: [dev, prod]\n";
559
560 #[test]
561 fn parses_a_valid_file_and_checks_the_environment() {
562 let file = ProjectFile::parse(PROJECT_FILE_REL, GOOD.as_bytes()).expect("valid");
563 assert_eq!(file.project, "CapApp");
564 file.require_env("dev").expect("listed");
565 let err = file.require_env("staging").expect_err("unlisted");
566 assert_eq!(err.code(), "deploy_tree_invalid");
567 assert!(err.to_string().contains(PROJECT_FILE_REL), "{err}");
568 }
569
570 #[test]
571 fn unknown_fields_and_bad_versions_are_refused() {
572 for bad in [
573 "schema_version: 1\nproject: A\nenvironments: [dev]\nextra: 1\n",
574 "schema_version: 2\nproject: A\nenvironments: [dev]\n",
575 "schema_version: 1\nenvironments: [dev]\n",
576 ] {
577 let err = ProjectFile::parse(PROJECT_FILE_REL, bad.as_bytes()).expect_err(bad);
578 assert_eq!(err.code(), "deploy_tree_invalid", "{bad}");
579 }
580 }
581
582 #[test]
583 fn semantic_defects_are_collected_into_one_error() {
584 let bytes = "schema_version: 1\nproject: ../x\nenvironments: [dev, dev, '']\n";
585 let err = ProjectFile::parse(PROJECT_FILE_REL, bytes.as_bytes()).expect_err("bad");
586 let text = err.to_string();
587 assert!(text.contains("project:"), "{text}");
588 assert!(text.contains("duplicate environment"), "{text}");
589 assert!(text.contains("environments[2]"), "{text}");
590 let empty = "schema_version: 1\nproject: A\nenvironments: []\n";
591 let err = ProjectFile::parse(PROJECT_FILE_REL, empty.as_bytes()).expect_err("empty");
592 assert!(err.to_string().contains("at least one"), "{err}");
593 }
594
595 #[test]
596 fn windows_unsafe_project_names_are_refused() {
597 for name in ["CON", "a:b", "trailing.", "a*b"] {
598 let bytes = format!("schema_version: 1\nproject: '{name}'\nenvironments: [dev]\n");
599 assert!(
600 ProjectFile::parse(PROJECT_FILE_REL, bytes.as_bytes()).is_err(),
601 "{name}"
602 );
603 }
604 }
605
606 #[test]
607 fn project_meta_reads_the_parent_and_refuses_a_missing_file() {
608 let dir = tempfile::tempdir().expect("tempdir");
609 let err = read_project_meta(dir.path()).expect_err("no project.json");
610 assert!(
611 err.to_string()
612 .contains("a project repo holds project.json at its root"),
613 "{err}"
614 );
615 std::fs::write(
616 dir.path().join("project.json"),
617 br#"{"title":"T","parent":"WHK-Global"}"#,
618 )
619 .expect("write");
620 assert_eq!(
621 read_project_meta(dir.path()).expect("meta").parent,
622 Some("WHK-Global".to_string())
623 );
624 std::fs::write(dir.path().join("project.json"), br#"{"parent":""}"#).expect("write");
625 assert_eq!(read_project_meta(dir.path()).expect("meta").parent, None);
626 std::fs::write(dir.path().join("project.json"), b"[]").expect("write");
627 assert!(read_project_meta(dir.path()).is_err());
628 }
629
630 #[test]
631 fn project_meta_refuses_a_parent_that_is_not_a_string() {
632 let dir = tempfile::tempdir().expect("tempdir");
633 for body in [
634 &br#"{"parent":5}"#[..],
635 br#"{"parent":[]}"#,
636 br#"{"parent":{}}"#,
637 br#"{"parent":true}"#,
638 br#"{"parent":["WHK-Global"]}"#,
639 ] {
640 std::fs::write(dir.path().join("project.json"), body).expect("write");
641 let err = read_project_meta(dir.path()).expect_err("a non-string parent");
642 assert_eq!(err.code(), "deploy_tree_invalid", "{err}");
643 assert!(err.to_string().contains("project.json"), "{err}");
644 assert!(
645 err.to_string().contains("`parent` must be a string"),
646 "{err}"
647 );
648 }
649 for body in [&br#"{"title":"T"}"#[..], br#"{"parent":null}"#] {
651 std::fs::write(dir.path().join("project.json"), body).expect("write");
652 assert_eq!(read_project_meta(dir.path()).expect("meta").parent, None);
653 }
654 }
655
656 #[test]
657 fn member_delta_counts_and_sorts_removed() {
658 let artifact = digest(&[("a", "1"), ("b", "2"), ("c", "3")]);
659 let live = digest(&[("a", "1"), ("b", "X"), ("z", "9"), ("y", "8")]);
660 let (delta, removed) = member_delta(&artifact, Some(&live));
661 assert_eq!(
662 delta,
663 MemberDelta {
664 added: 1,
665 changed: 1,
666 removed: 2,
667 same: 1
668 }
669 );
670 assert_eq!(removed, ["y", "z"]);
671 let (created, none) = member_delta(&artifact, None);
672 assert_eq!(created.added, 3);
673 assert!(none.is_empty());
674 }
675
676 #[test]
677 fn only_skip_does_not_change_the_gateway() {
678 assert!(ProjectAction::Create.would_change());
679 assert!(ProjectAction::Import.would_change());
680 assert!(ProjectAction::Record.would_change());
681 assert!(!ProjectAction::Skip.would_change());
682 }
683
684 fn rel(tag: &str) -> Release {
685 Release::parse(tag).expect(tag)
686 }
687
688 #[test]
689 fn release_order() {
690 assert_eq!(
691 rel("v1.2.0").order_against(&rel("1.1.9")),
692 ReleaseOrder::Newer
693 );
694 assert_eq!(
695 rel("1.1.9").order_against(&rel("v1.2.0")),
696 ReleaseOrder::Older
697 );
698 assert_eq!(
699 rel("v1.2.0-rc.1").order_against(&rel("v1.2.0")),
700 ReleaseOrder::Older
701 );
702 assert_eq!(
703 rel("v1.2.0").order_against(&rel("v1.2.0-rc.1")),
704 ReleaseOrder::Newer
705 );
706 assert_eq!(
707 rel("v1.2.0-rc.2").order_against(&rel("v1.2.0-rc.10")),
708 ReleaseOrder::Older
709 );
710 assert_eq!(
712 rel("v1.2.0+build").order_against(&rel("v1.2.0")),
713 ReleaseOrder::Equal
714 );
715 assert_eq!(
716 rel("v1.2.0+b").order_against(&rel("1.2.0+a")),
717 ReleaseOrder::Equal
718 );
719 assert_eq!(rel("v1.0.0").tag, "v1.0.0");
721 assert_eq!(rel("1.0.0").tag, "1.0.0");
722 for bad in [
723 "vv1.0.0", "", "1.0", "v", "banana", "v1.0.0 ", " v1.0.0", "V1.0.0",
724 ] {
725 let err = Release::parse(bad).expect_err(bad);
726 assert_eq!(err.code(), "invalid_input", "{bad:?}");
727 assert!(err.to_string().contains("--release"), "{err}");
728 assert!(bad.len() < 6 || !err.to_string().contains(bad), "{err}");
730 }
731 let long = format!("v1.0.0-{}", "a".repeat(200));
732 assert!(Release::parse(&long).is_err(), "over the length cap");
733 }
734
735 const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
736
737 fn record() -> ReleaseRecord {
738 ReleaseRecord {
739 schema_version: 1,
740 gateway: "capgw".to_string(),
741 project: "CapApp".to_string(),
742 release: "v1.2.0".to_string(),
743 artifact_sha256: HASH.to_string(),
744 run_id: "20261020T101500Z-ab12".to_string(),
745 deployed_at: "2026-10-20T10:15:00Z".to_string(),
746 }
747 }
748
749 const LOC: &str = "ign-deploy/capgw/projects/CapApp/release.json";
750
751 fn parse(bytes: &[u8]) -> Result<ReleaseRecord, CoreError> {
752 ReleaseRecord::parse(bytes, LOC, "capgw", "CapApp")
753 }
754
755 fn with(edit: impl FnOnce(&mut serde_json::Value)) -> Vec<u8> {
756 let mut value = serde_json::to_value(record()).expect("value");
757 edit(&mut value);
758 serde_json::to_vec(&value).expect("bytes")
759 }
760
761 #[test]
762 fn release_record_parse() {
763 let bytes = record().to_bytes();
764 assert!(bytes.ends_with(b"}\n"));
765 assert_eq!(parse(&bytes).expect("round trip"), record());
766 assert_eq!(record().release().expect("tag").tag, "v1.2.0");
767
768 let refusals: Vec<(&str, Vec<u8>)> = vec![
769 ("empty", Vec::new()),
770 ("not json", b"nope".to_vec()),
771 ("array", b"[]".to_vec()),
772 ("bom", {
773 let mut b = vec![0xEF, 0xBB, 0xBF];
774 b.extend(record().to_bytes());
775 b
776 }),
777 (
778 "unknown field",
779 with(|v| v["extra"] = serde_json::json!("SECRET-VALUE")),
780 ),
781 (
782 "missing field",
783 with(|v| {
784 v.as_object_mut().expect("object").remove("run_id");
785 }),
786 ),
787 ("schema 2", with(|v| v["schema_version"] = 2.into())),
788 (
789 "wrong gateway",
790 with(|v| v["gateway"] = "SECRET-GW".into()),
791 ),
792 (
793 "wrong project",
794 with(|v| v["project"] = "SECRET-PROJ".into()),
795 ),
796 ("uppercase hash", with(|v| v["artifact_sha256"] = HASH.to_uppercase().into())),
797 ("short hash", with(|v| v["artifact_sha256"] = "abc".into())),
798 ("bad release", with(|v| v["release"] = "SECRET-banana".into())),
799 ("bad run id", with(|v| v["run_id"] = "../SECRET".into())),
800 ("empty run id", with(|v| v["run_id"] = "".into())),
801 ("bad stamp", with(|v| v["deployed_at"] = "".into())),
802 ("wrong type", with(|v| v["schema_version"] = "1".into())),
803 (
804 "duplicate key",
805 format!(
806 r#"{{"schema_version":1,"schema_version":1,"gateway":"capgw","project":"CapApp","release":"v1.0.0","artifact_sha256":"{HASH}","run_id":"r","deployed_at":"t"}}"#
807 )
808 .into_bytes(),
809 ),
810 ("over cap", vec![b' '; RELEASE_RECORD_MAX_BYTES + 1]),
811 ];
812 for (name, bytes) in refusals {
813 let err = parse(&bytes).expect_err(name);
814 assert_eq!(err.code(), "deploy_run_invalid", "{name}: {err}");
815 let text = err.to_string();
816 assert!(text.contains(LOC), "{name}: names the location: {text}");
817 assert!(!text.contains("SECRET"), "{name}: echoes a value: {text}");
818 }
819 }
820
821 #[test]
822 fn release_record_at_the_cap_is_read_not_refused_for_size() {
823 let err = parse(&vec![b' '; RELEASE_RECORD_MAX_BYTES]).expect_err("blank");
825 assert!(!err.to_string().contains("byte cap"), "{err}");
826 }
827
828 fn link(name: &str, parent: Option<&str>, inheritable: Option<bool>) -> ParentLink {
829 ParentLink {
830 name: name.into(),
831 parent: parent.map(str::to_string),
832 inheritable,
833 invalid_parent: None,
834 }
835 }
836
837 fn reason(project: &str, parent: &str, all: &[ParentLink]) -> Result<(), ParentReason> {
838 let (chain, bound_hit) = parent_chain(parent, all);
839 check_parent(project, parent, &chain, bound_hit)
840 }
841
842 #[test]
843 fn check_parent_reasons() {
844 let all = [
845 link("Root", None, Some(true)),
846 link("Mid", Some("Root"), Some(true)),
847 link("Closed", None, Some(false)),
848 link("A", Some("B"), Some(true)),
849 link("B", Some("A"), Some(true)),
850 link("Orphan", Some("Gone"), Some(true)),
851 link("CapApp", Some("Root"), Some(true)),
852 link("Loop", Some("CapApp"), Some(true)),
853 ];
854 assert_eq!(reason("CapApp", "Mid", &all), Ok(()));
856 assert_eq!(reason("CapApp", "Root", &all), Ok(()));
857 assert_eq!(reason("CapApp", "Nope", &all), Err(ParentReason::Missing));
859 assert_eq!(reason("CapApp", "CapApp", &all), Err(ParentReason::Cycle));
861 assert_eq!(reason("New", "New", &all), Err(ParentReason::Cycle));
862 assert_eq!(reason("CapApp", "Loop", &all), Err(ParentReason::Cycle));
864 assert_eq!(reason("CapApp", "A", &all), Err(ParentReason::Cycle));
866 assert_eq!(
868 reason("CapApp", "Closed", &all),
869 Err(ParentReason::NotInheritable)
870 );
871 assert_eq!(reason("CapApp", "Orphan", &all), Err(ParentReason::Missing));
873 let unknown = [link("P", None, None)];
875 assert_eq!(reason("CapApp", "P", &unknown), Ok(()));
876 }
877
878 #[test]
879 fn parent_chain_is_bounded_by_the_listed_projects() {
880 let all = [
882 link("X", Some("Y"), Some(true)),
883 link("Y", Some("Z"), Some(true)),
884 link("Z", Some("X"), Some(true)),
885 ];
886 let (chain, bound_hit) = parent_chain("X", &all);
887 assert!(bound_hit);
888 assert!(chain.len() <= all.len() + 1);
889 let line = [
891 link("P3", None, Some(true)),
892 link("P2", Some("P3"), Some(true)),
893 link("P1", Some("P2"), Some(true)),
894 ];
895 let (chain, bound_hit) = parent_chain("P1", &line);
896 assert!(!bound_hit);
897 assert_eq!(chain.len(), 3);
898 }
899
900 #[test]
901 fn parent_reason_words_are_stable() {
902 assert_eq!(ParentReason::Missing.as_str(), "missing");
903 assert_eq!(ParentReason::Cycle.as_str(), "cycle");
904 assert_eq!(ParentReason::NotInheritable.as_str(), "not_inheritable");
905 assert_eq!(
906 serde_json::to_string(&ParentReason::NotInheritable).expect("json"),
907 "\"not_inheritable\""
908 );
909 }
910}