use std::path::Path;
use serde::{Deserialize, Serialize};
use super::apply::run_store::RunId;
use super::component_problem;
use super::ledger::RELEASE_RECORD_MAX_BYTES;
use super::manifest::Manifest;
use super::node::{self, Node};
use super::project_artifact::CanonicalDigest;
use super::target::Target;
use super::transport::winps::windows_component_problem;
use super::tree::read_repo_file;
use super::yamlio::{SchemaVersion, strict_parse};
use crate::error::{CoreError, DeployFinding};
pub const PROJECT_FILE_REL: &str = "deploy/project.yaml";
pub const PROJECT_JSON_REL: &str = "project.json";
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProjectFile {
pub schema_version: SchemaVersion,
pub project: String,
pub environments: Vec<String>,
#[serde(default)]
pub tag_providers: Vec<String>,
}
impl ProjectFile {
pub fn parse(file: &str, bytes: &[u8]) -> Result<ProjectFile, CoreError> {
let parsed: ProjectFile = strict_parse(file, bytes)?;
parsed.validate(file)?;
Ok(parsed)
}
fn validate(&self, file: &str) -> Result<(), CoreError> {
let mut findings = Vec::new();
let mut push = |message: String| {
findings.push(DeployFinding {
file: file.to_string(),
line: None,
column: None,
message,
});
};
if let Some(why) = component_problem(&self.project) {
push(format!("project: the name {why}"));
} else if let Some(why) = windows_component_problem(&self.project) {
push(format!("project: {why}"));
}
if self.environments.is_empty() {
push("environments: must list at least one environment".to_string());
}
for (i, env) in self.environments.iter().enumerate() {
if let Some(why) = component_problem(env) {
push(format!("environments[{i}]: the name {why}"));
}
if self.environments[..i].contains(env) {
push(format!("environments[{i}]: duplicate environment"));
}
}
for (i, provider) in self.tag_providers.iter().enumerate() {
if let Some(why) = component_problem(provider) {
push(format!("tag_providers[{i}]: the name {why}"));
} else if let Some(why) = windows_component_problem(provider) {
push(format!("tag_providers[{i}]: {why}"));
}
if self.tag_providers[..i]
.iter()
.any(|seen| seen.eq_ignore_ascii_case(provider))
{
push(format!("tag_providers[{i}]: duplicate tag provider"));
}
}
if findings.is_empty() {
Ok(())
} else {
Err(CoreError::deploy_findings(findings))
}
}
pub fn require_env(&self, env: &str) -> Result<(), CoreError> {
if self.environments.iter().any(|e| e == env) {
Ok(())
} else {
Err(CoreError::deploy_tree_invalid(
PROJECT_FILE_REL,
None,
None,
format!("--env {env} is not listed under environments"),
))
}
}
}
pub fn load_project_file(repo: &Path) -> Result<ProjectFile, CoreError> {
let bytes = read_repo_file(
repo,
PROJECT_FILE_REL,
Some("a project repo names its gateway project and environments here"),
)?;
ProjectFile::parse(PROJECT_FILE_REL, &bytes)
}
pub const TAG_PROVIDERS_DIR_REL: &str = "deploy/tag-providers";
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProviderSettings {
pub schema_version: SchemaVersion,
pub enabled: bool,
#[serde(default)]
pub description: Option<String>,
pub config: serde_json::Value,
}
fn provider_settings_rel(provider: &str) -> String {
format!("{TAG_PROVIDERS_DIR_REL}/{provider}.json")
}
pub fn load_provider_settings(repo: &Path, provider: &str) -> Result<ProviderSettings, CoreError> {
let rel = provider_settings_rel(provider);
let bytes = read_repo_file(
repo,
&rel,
Some("each provider a project lists in tag_providers has its settings here"),
)?;
let invalid = |line: Option<usize>, column: Option<usize>, why: String| {
CoreError::deploy_tree_invalid(rel.as_str(), line, column, why)
};
let text = std::str::from_utf8(&bytes)
.map_err(|_| invalid(None, None, "is not valid UTF-8".to_string()))?;
let tree = node::parse(text)
.map_err(|why| invalid(None, None, format!("is not valid JSON ({why})")))?;
let settings: ProviderSettings = serde_json::from_str(text).map_err(|e| {
let mut why = e.to_string();
if let Some(at) = why.rfind(" at line ") {
why.truncate(at);
}
if why.starts_with("invalid type") || why.starts_with("invalid value") {
why = "a value has the wrong type".to_string();
}
invalid(Some(e.line()), Some(e.column()), why)
})?;
if !settings.config.is_object() {
return Err(invalid(None, None, "config: must be an object".to_string()));
}
let scan = super::lint::scan_embedded(&rel, &tree);
if !scan.refused.is_empty() {
return Err(CoreError::embedded_secret(scan.refused, Vec::new()));
}
Ok(settings)
}
pub fn gateway_manifest_rel(gateway: &str) -> String {
format!("gateways/{gateway}/manifest.yaml")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReadOnlySource {
pub label: String,
pub names: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReadOnlySources {
pub gateway: String,
pub sources: Vec<ReadOnlySource>,
}
impl ReadOnlySources {
pub fn new(target_file: &str, target: &Target, manifest: Option<(&str, &Manifest)>) -> Self {
let mut sources = vec![
ReadOnlySource {
label: "the built-in default".to_string(),
names: super::exclude::DEFAULT_EXCLUDED_TAG_PROVIDERS
.iter()
.map(|p| (*p).to_string())
.collect(),
},
ReadOnlySource {
label: format!("exclude_tag_providers of {target_file}"),
names: target.exclude_tag_providers.clone(),
},
ReadOnlySource {
label: format!("read_only_tag_providers of {target_file}"),
names: target.read_only_tag_providers.clone(),
},
];
if let Some((rel, manifest)) = manifest {
sources.push(ReadOnlySource {
label: format!("exclude_tag_providers of {rel}"),
names: manifest.exclude_tag_providers.clone(),
});
sources.push(ReadOnlySource {
label: format!("read_only_tag_providers of {rel}"),
names: manifest.read_only_tag_providers.clone(),
});
}
Self {
gateway: target.gateway.clone(),
sources,
}
}
fn holder(&self, provider: &str) -> Option<&str> {
self.sources
.iter()
.find(|s| s.names.iter().any(|n| n.eq_ignore_ascii_case(provider)))
.map(|s| s.label.as_str())
}
}
pub fn check_tag_ownership(
file: &ProjectFile,
read_only: &ReadOnlySources,
manifest: Option<(&str, &Manifest)>,
) -> Result<Vec<String>, CoreError> {
if file.tag_providers.is_empty() {
return Ok(Vec::new());
}
let findings: Vec<DeployFinding> = file
.tag_providers
.iter()
.enumerate()
.filter_map(|(i, provider)| {
read_only.holder(provider).map(|label| DeployFinding {
file: PROJECT_FILE_REL.to_string(),
line: None,
column: None,
message: format!(
"tag_providers[{i}]: {provider} is read-only to ign ({label}) and cannot be owned by a project"
),
})
})
.collect();
if !findings.is_empty() {
return Err(CoreError::deploy_findings(findings));
}
let Some((rel, manifest)) = manifest else {
return Ok(vec![format!(
"no gateway-config manifest at {} below the targets root: tag provider ownership was checked against the target file only",
gateway_manifest_rel(&read_only.gateway)
)]);
};
if let Some(provider) = file.tag_providers.iter().find(|provider| {
manifest
.tag_providers
.iter()
.any(|p| p.eq_ignore_ascii_case(provider))
}) {
return Err(CoreError::TagProviderClaimed {
provider: provider.clone(),
claimed_by: format!("the gateway-config manifest {rel}"),
});
}
Ok(Vec::new())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectMeta {
pub parent: Option<String>,
}
pub fn read_project_meta(repo: &Path) -> Result<ProjectMeta, CoreError> {
if let Err(e) = std::fs::symlink_metadata(repo.join(PROJECT_JSON_REL))
&& e.kind() == std::io::ErrorKind::NotFound
{
return Err(CoreError::deploy_tree_invalid(
PROJECT_JSON_REL,
None,
None,
"does not exist (a project repo holds project.json at its root)",
));
}
let bytes = read_repo_file(repo, PROJECT_JSON_REL, None)?;
let value: serde_json::Value = serde_json::from_slice(&bytes).map_err(|_| {
CoreError::deploy_tree_invalid(PROJECT_JSON_REL, None, None, "is not valid JSON")
})?;
let Some(object) = value.as_object() else {
return Err(CoreError::deploy_tree_invalid(
PROJECT_JSON_REL,
None,
None,
"must be a JSON object",
));
};
let parent = match object.get("parent") {
None | Some(serde_json::Value::Null) => None,
Some(serde_json::Value::String(s)) if s.is_empty() => None,
Some(serde_json::Value::String(s)) => Some(s.clone()),
Some(_) => {
return Err(CoreError::deploy_tree_invalid(
PROJECT_JSON_REL,
None,
None,
"`parent` must be a string (or null)",
));
}
};
Ok(ProjectMeta { parent })
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ProjectAction {
Create,
Skip,
Import,
Record,
}
impl ProjectAction {
pub fn would_change(self) -> bool {
!matches!(self, ProjectAction::Skip)
}
}
const RELEASE_TAG_MAX: usize = 128;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Release {
pub tag: String,
pub version: semver::Version,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReleaseOrder {
Newer,
Equal,
Older,
}
impl Release {
pub fn parse(tag: &str) -> Result<Release, CoreError> {
Release::try_parse(tag).ok_or_else(|| CoreError::InvalidInput {
reason: "--release <tag>: not a semver tag (for example v1.4.0)".to_string(),
})
}
pub(crate) fn try_parse(tag: &str) -> Option<Release> {
if tag.is_empty() || tag.len() > RELEASE_TAG_MAX {
return None;
}
let bare = tag.strip_prefix('v').unwrap_or(tag);
let version = semver::Version::parse(bare).ok()?;
Some(Release {
tag: tag.to_string(),
version,
})
}
pub fn order_against(&self, other: &Release) -> ReleaseOrder {
match self.version.cmp_precedence(&other.version) {
std::cmp::Ordering::Greater => ReleaseOrder::Newer,
std::cmp::Ordering::Equal => ReleaseOrder::Equal,
std::cmp::Ordering::Less => ReleaseOrder::Older,
}
}
}
const RECORD_SCHEMA_VERSION: u32 = 1;
const RECORD_FIELDS: [&str; 7] = [
"schema_version",
"gateway",
"project",
"release",
"artifact_sha256",
"run_id",
"deployed_at",
];
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ReleaseRecord {
pub schema_version: u32,
pub gateway: String,
pub project: String,
pub release: String,
pub artifact_sha256: String,
pub run_id: String,
pub deployed_at: String,
}
fn shown(text: &str) -> String {
text.chars().filter(|c| !c.is_control()).take(64).collect()
}
impl ReleaseRecord {
pub fn parse(
bytes: &[u8],
location: &str,
gateway: &str,
project: &str,
) -> Result<ReleaseRecord, CoreError> {
let bad = |problem: String| CoreError::DeployRunInvalid {
run_id: "unknown".to_string(),
problem: format!("{location}: {problem}"),
};
if bytes.is_empty() {
return Err(bad("the file is empty".to_string()));
}
if bytes.len() > RELEASE_RECORD_MAX_BYTES {
return Err(bad(format!(
"the file is {} bytes, over the {RELEASE_RECORD_MAX_BYTES} byte cap",
bytes.len()
)));
}
let (_, root) = node::classify(bytes).map_err(bad)?;
let Node::Obj(members) = &root else {
return Err(bad("the top level is not a JSON object".to_string()));
};
for (key, _) in members {
if !RECORD_FIELDS.contains(&key.as_str()) {
return Err(bad(format!("unknown field `{}`", shown(key))));
}
}
for field in RECORD_FIELDS {
if root.get(field).is_none() {
return Err(bad(format!("missing field `{field}`")));
}
}
let record: ReleaseRecord = serde_json::from_slice(bytes).map_err(|e| {
bad(format!(
"a field has the wrong type (line {} column {})",
e.line(),
e.column()
))
})?;
if record.schema_version != RECORD_SCHEMA_VERSION {
return Err(bad(format!(
"schema_version is not {RECORD_SCHEMA_VERSION}, the only version this build reads"
)));
}
if record.gateway != gateway {
return Err(bad(format!(
"gateway names a different gateway than the target (`{}`)",
shown(gateway)
)));
}
if record.project != project {
return Err(bad(format!(
"project names a different project than this deploy (`{}`)",
shown(project)
)));
}
let hex = record.artifact_sha256.len() == 64
&& record
.artifact_sha256
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
if !hex {
return Err(bad(
"artifact_sha256 is not 64 lowercase hex digits".to_string()
));
}
if Release::try_parse(&record.release).is_none() {
return Err(bad("release is not a semver tag".to_string()));
}
if RunId::parse(&record.run_id).is_err() {
return Err(bad(
"run_id is not 1 to 128 characters of [A-Za-z0-9._-]".to_string()
));
}
if record.deployed_at.is_empty()
|| record.deployed_at.len() > 64
|| !record.deployed_at.bytes().all(|b| b.is_ascii_graphic())
{
return Err(bad(
"deployed_at is not 1 to 64 printable ASCII characters".to_string()
));
}
Ok(record)
}
pub fn to_bytes(&self) -> Vec<u8> {
let mut bytes = serde_json::to_vec_pretty(self).unwrap_or_default();
bytes.push(b'\n');
bytes
}
pub fn release(&self) -> Result<Release, CoreError> {
Release::parse(&self.release)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
pub struct MemberDelta {
pub added: usize,
pub changed: usize,
pub removed: usize,
pub same: usize,
}
pub fn member_delta(
artifact: &CanonicalDigest,
live: Option<&CanonicalDigest>,
) -> (MemberDelta, Vec<String>) {
let mut delta = MemberDelta::default();
let Some(live) = live else {
delta.added = artifact.members.len();
return (delta, Vec::new());
};
for (key, hash) in &artifact.members {
match live.members.get(key) {
None => delta.added += 1,
Some(live_hash) if live_hash == hash => delta.same += 1,
Some(_) => delta.changed += 1,
}
}
let removed: Vec<String> = live
.members
.keys()
.filter(|key| !artifact.members.contains_key(*key))
.cloned()
.collect();
delta.removed = removed.len();
(delta, removed)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ParentReason {
Missing,
Cycle,
NotInheritable,
}
impl ParentReason {
pub fn as_str(self) -> &'static str {
match self {
Self::Missing => "missing",
Self::Cycle => "cycle",
Self::NotInheritable => "not_inheritable",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParentLink {
pub name: String,
pub parent: Option<String>,
pub inheritable: Option<bool>,
pub invalid_parent: Option<bool>,
}
pub fn parent_chain(parent: &str, all: &[ParentLink]) -> (Vec<ParentLink>, bool) {
let bound = all.len() + 1;
let mut chain = Vec::new();
let mut next = Some(parent.to_string());
let mut steps = 0;
while let Some(name) = next.take() {
if steps >= bound {
return (chain, true);
}
steps += 1;
let Some(link) = all.iter().find(|l| l.name == name) else {
break;
};
next = link.parent.clone().filter(|p| !p.is_empty());
chain.push(link.clone());
}
(chain, false)
}
pub fn check_parent(
project: &str,
parent: &str,
chain: &[ParentLink],
bound_hit: bool,
) -> Result<(), ParentReason> {
if parent == project
|| bound_hit
|| chain
.iter()
.any(|link| link.name == project || link.parent.as_deref() == Some(project))
{
return Err(ParentReason::Cycle);
}
let Some(first) = chain.first() else {
return Err(ParentReason::Missing);
};
if first.inheritable == Some(false) {
return Err(ParentReason::NotInheritable);
}
if chain
.last()
.is_some_and(|last| last.parent.as_deref().is_some_and(|p| !p.is_empty()))
{
return Err(ParentReason::Missing);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
fn digest(members: &[(&str, &str)]) -> CanonicalDigest {
CanonicalDigest {
sha256: String::new(),
members: members
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect::<BTreeMap<_, _>>(),
}
}
const GOOD: &str = "schema_version: 1\nproject: CapApp\nenvironments: [dev, prod]\n";
#[test]
fn parses_a_valid_file_and_checks_the_environment() {
let file = ProjectFile::parse(PROJECT_FILE_REL, GOOD.as_bytes()).expect("valid");
assert_eq!(file.project, "CapApp");
file.require_env("dev").expect("listed");
let err = file.require_env("staging").expect_err("unlisted");
assert_eq!(err.code(), "deploy_tree_invalid");
assert!(err.to_string().contains(PROJECT_FILE_REL), "{err}");
}
#[test]
fn unknown_fields_and_bad_versions_are_refused() {
for bad in [
"schema_version: 1\nproject: A\nenvironments: [dev]\nextra: 1\n",
"schema_version: 2\nproject: A\nenvironments: [dev]\n",
"schema_version: 1\nenvironments: [dev]\n",
] {
let err = ProjectFile::parse(PROJECT_FILE_REL, bad.as_bytes()).expect_err(bad);
assert_eq!(err.code(), "deploy_tree_invalid", "{bad}");
}
}
#[test]
fn semantic_defects_are_collected_into_one_error() {
let bytes = "schema_version: 1\nproject: ../x\nenvironments: [dev, dev, '']\n";
let err = ProjectFile::parse(PROJECT_FILE_REL, bytes.as_bytes()).expect_err("bad");
let text = err.to_string();
assert!(text.contains("project:"), "{text}");
assert!(text.contains("duplicate environment"), "{text}");
assert!(text.contains("environments[2]"), "{text}");
let empty = "schema_version: 1\nproject: A\nenvironments: []\n";
let err = ProjectFile::parse(PROJECT_FILE_REL, empty.as_bytes()).expect_err("empty");
assert!(err.to_string().contains("at least one"), "{err}");
}
#[test]
fn tag_providers_parse_and_default_to_none() {
let file = ProjectFile::parse(PROJECT_FILE_REL, GOOD.as_bytes()).expect("valid");
assert!(file.tag_providers.is_empty(), "absent means none");
let bytes =
"schema_version: 1\nproject: A\nenvironments: [dev]\ntag_providers: [PlantA, Site B]\n";
let file = ProjectFile::parse(PROJECT_FILE_REL, bytes.as_bytes()).expect("valid");
assert_eq!(file.tag_providers, ["PlantA", "Site B"]);
}
#[test]
fn tag_provider_defects_are_findings_of_one_error() {
let bytes = "schema_version: 1\nproject: A\nenvironments: [dev]\n\
tag_providers: [PlantA, planta, '../x', 'CON', '']\n";
let err = ProjectFile::parse(PROJECT_FILE_REL, bytes.as_bytes()).expect_err("bad");
assert_eq!(err.code(), "deploy_tree_invalid");
let text = err.to_string();
for needle in [
"tag_providers[1]: duplicate tag provider",
"tag_providers[2]",
"tag_providers[3]",
"tag_providers[4]",
] {
assert!(text.contains(needle), "{needle}: {text}");
}
assert!(!text.contains("tag_providers[0]"), "{text}");
}
#[test]
fn windows_unsafe_project_names_are_refused() {
for name in ["CON", "a:b", "trailing.", "a*b"] {
let bytes = format!("schema_version: 1\nproject: '{name}'\nenvironments: [dev]\n");
assert!(
ProjectFile::parse(PROJECT_FILE_REL, bytes.as_bytes()).is_err(),
"{name}"
);
}
}
#[test]
fn project_meta_reads_the_parent_and_refuses_a_missing_file() {
let dir = tempfile::tempdir().expect("tempdir");
let err = read_project_meta(dir.path()).expect_err("no project.json");
assert!(
err.to_string()
.contains("a project repo holds project.json at its root"),
"{err}"
);
std::fs::write(
dir.path().join("project.json"),
br#"{"title":"T","parent":"ParentProject"}"#,
)
.expect("write");
assert_eq!(
read_project_meta(dir.path()).expect("meta").parent,
Some("ParentProject".to_string())
);
std::fs::write(dir.path().join("project.json"), br#"{"parent":""}"#).expect("write");
assert_eq!(read_project_meta(dir.path()).expect("meta").parent, None);
std::fs::write(dir.path().join("project.json"), b"[]").expect("write");
assert!(read_project_meta(dir.path()).is_err());
}
#[test]
fn project_meta_refuses_a_parent_that_is_not_a_string() {
let dir = tempfile::tempdir().expect("tempdir");
for body in [
&br#"{"parent":5}"#[..],
br#"{"parent":[]}"#,
br#"{"parent":{}}"#,
br#"{"parent":true}"#,
br#"{"parent":["ParentProject"]}"#,
] {
std::fs::write(dir.path().join("project.json"), body).expect("write");
let err = read_project_meta(dir.path()).expect_err("a non-string parent");
assert_eq!(err.code(), "deploy_tree_invalid", "{err}");
assert!(err.to_string().contains("project.json"), "{err}");
assert!(
err.to_string().contains("`parent` must be a string"),
"{err}"
);
}
for body in [&br#"{"title":"T"}"#[..], br#"{"parent":null}"#] {
std::fs::write(dir.path().join("project.json"), body).expect("write");
assert_eq!(read_project_meta(dir.path()).expect("meta").parent, None);
}
}
#[test]
fn member_delta_counts_and_sorts_removed() {
let artifact = digest(&[("a", "1"), ("b", "2"), ("c", "3")]);
let live = digest(&[("a", "1"), ("b", "X"), ("z", "9"), ("y", "8")]);
let (delta, removed) = member_delta(&artifact, Some(&live));
assert_eq!(
delta,
MemberDelta {
added: 1,
changed: 1,
removed: 2,
same: 1
}
);
assert_eq!(removed, ["y", "z"]);
let (created, none) = member_delta(&artifact, None);
assert_eq!(created.added, 3);
assert!(none.is_empty());
}
#[test]
fn only_skip_does_not_change_the_gateway() {
assert!(ProjectAction::Create.would_change());
assert!(ProjectAction::Import.would_change());
assert!(ProjectAction::Record.would_change());
assert!(!ProjectAction::Skip.would_change());
}
fn rel(tag: &str) -> Release {
Release::parse(tag).expect(tag)
}
#[test]
fn release_order() {
assert_eq!(
rel("v1.2.0").order_against(&rel("1.1.9")),
ReleaseOrder::Newer
);
assert_eq!(
rel("1.1.9").order_against(&rel("v1.2.0")),
ReleaseOrder::Older
);
assert_eq!(
rel("v1.2.0-rc.1").order_against(&rel("v1.2.0")),
ReleaseOrder::Older
);
assert_eq!(
rel("v1.2.0").order_against(&rel("v1.2.0-rc.1")),
ReleaseOrder::Newer
);
assert_eq!(
rel("v1.2.0-rc.2").order_against(&rel("v1.2.0-rc.10")),
ReleaseOrder::Older
);
assert_eq!(
rel("v1.2.0+build").order_against(&rel("v1.2.0")),
ReleaseOrder::Equal
);
assert_eq!(
rel("v1.2.0+b").order_against(&rel("1.2.0+a")),
ReleaseOrder::Equal
);
assert_eq!(rel("v1.0.0").tag, "v1.0.0");
assert_eq!(rel("1.0.0").tag, "1.0.0");
for bad in [
"vv1.0.0", "", "1.0", "v", "banana", "v1.0.0 ", " v1.0.0", "V1.0.0",
] {
let err = Release::parse(bad).expect_err(bad);
assert_eq!(err.code(), "invalid_input", "{bad:?}");
assert!(err.to_string().contains("--release"), "{err}");
assert!(bad.len() < 6 || !err.to_string().contains(bad), "{err}");
}
let long = format!("v1.0.0-{}", "a".repeat(200));
assert!(Release::parse(&long).is_err(), "over the length cap");
}
const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
fn record() -> ReleaseRecord {
ReleaseRecord {
schema_version: 1,
gateway: "capgw".to_string(),
project: "CapApp".to_string(),
release: "v1.2.0".to_string(),
artifact_sha256: HASH.to_string(),
run_id: "20261020T101500Z-ab12".to_string(),
deployed_at: "2026-10-20T10:15:00Z".to_string(),
}
}
const LOC: &str = "ign-deploy/capgw/projects/CapApp/release.json";
fn parse(bytes: &[u8]) -> Result<ReleaseRecord, CoreError> {
ReleaseRecord::parse(bytes, LOC, "capgw", "CapApp")
}
fn with(edit: impl FnOnce(&mut serde_json::Value)) -> Vec<u8> {
let mut value = serde_json::to_value(record()).expect("value");
edit(&mut value);
serde_json::to_vec(&value).expect("bytes")
}
#[test]
fn release_record_parse() {
let bytes = record().to_bytes();
assert!(bytes.ends_with(b"}\n"));
assert_eq!(parse(&bytes).expect("round trip"), record());
assert_eq!(record().release().expect("tag").tag, "v1.2.0");
let refusals: Vec<(&str, Vec<u8>)> = vec![
("empty", Vec::new()),
("not json", b"nope".to_vec()),
("array", b"[]".to_vec()),
("bom", {
let mut b = vec![0xEF, 0xBB, 0xBF];
b.extend(record().to_bytes());
b
}),
(
"unknown field",
with(|v| v["extra"] = serde_json::json!("SECRET-VALUE")),
),
(
"missing field",
with(|v| {
v.as_object_mut().expect("object").remove("run_id");
}),
),
("schema 2", with(|v| v["schema_version"] = 2.into())),
(
"wrong gateway",
with(|v| v["gateway"] = "SECRET-GW".into()),
),
(
"wrong project",
with(|v| v["project"] = "SECRET-PROJ".into()),
),
("uppercase hash", with(|v| v["artifact_sha256"] = HASH.to_uppercase().into())),
("short hash", with(|v| v["artifact_sha256"] = "abc".into())),
("bad release", with(|v| v["release"] = "SECRET-banana".into())),
("bad run id", with(|v| v["run_id"] = "../SECRET".into())),
("empty run id", with(|v| v["run_id"] = "".into())),
("bad stamp", with(|v| v["deployed_at"] = "".into())),
("wrong type", with(|v| v["schema_version"] = "1".into())),
(
"duplicate key",
format!(
r#"{{"schema_version":1,"schema_version":1,"gateway":"capgw","project":"CapApp","release":"v1.0.0","artifact_sha256":"{HASH}","run_id":"r","deployed_at":"t"}}"#
)
.into_bytes(),
),
("over cap", vec![b' '; RELEASE_RECORD_MAX_BYTES + 1]),
];
for (name, bytes) in refusals {
let err = parse(&bytes).expect_err(name);
assert_eq!(err.code(), "deploy_run_invalid", "{name}: {err}");
let text = err.to_string();
assert!(text.contains(LOC), "{name}: names the location: {text}");
assert!(!text.contains("SECRET"), "{name}: echoes a value: {text}");
}
}
#[test]
fn release_record_at_the_cap_is_read_not_refused_for_size() {
let err = parse(&vec![b' '; RELEASE_RECORD_MAX_BYTES]).expect_err("blank");
assert!(!err.to_string().contains("byte cap"), "{err}");
}
fn link(name: &str, parent: Option<&str>, inheritable: Option<bool>) -> ParentLink {
ParentLink {
name: name.into(),
parent: parent.map(str::to_string),
inheritable,
invalid_parent: None,
}
}
fn reason(project: &str, parent: &str, all: &[ParentLink]) -> Result<(), ParentReason> {
let (chain, bound_hit) = parent_chain(parent, all);
check_parent(project, parent, &chain, bound_hit)
}
#[test]
fn check_parent_reasons() {
let all = [
link("Root", None, Some(true)),
link("Mid", Some("Root"), Some(true)),
link("Closed", None, Some(false)),
link("A", Some("B"), Some(true)),
link("B", Some("A"), Some(true)),
link("Orphan", Some("Gone"), Some(true)),
link("CapApp", Some("Root"), Some(true)),
link("Loop", Some("CapApp"), Some(true)),
];
assert_eq!(reason("CapApp", "Mid", &all), Ok(()));
assert_eq!(reason("CapApp", "Root", &all), Ok(()));
assert_eq!(reason("CapApp", "Nope", &all), Err(ParentReason::Missing));
assert_eq!(reason("CapApp", "CapApp", &all), Err(ParentReason::Cycle));
assert_eq!(reason("New", "New", &all), Err(ParentReason::Cycle));
assert_eq!(reason("CapApp", "Loop", &all), Err(ParentReason::Cycle));
assert_eq!(reason("CapApp", "A", &all), Err(ParentReason::Cycle));
assert_eq!(
reason("CapApp", "Closed", &all),
Err(ParentReason::NotInheritable)
);
assert_eq!(reason("CapApp", "Orphan", &all), Err(ParentReason::Missing));
let unknown = [link("P", None, None)];
assert_eq!(reason("CapApp", "P", &unknown), Ok(()));
}
#[test]
fn parent_chain_is_bounded_by_the_listed_projects() {
let all = [
link("X", Some("Y"), Some(true)),
link("Y", Some("Z"), Some(true)),
link("Z", Some("X"), Some(true)),
];
let (chain, bound_hit) = parent_chain("X", &all);
assert!(bound_hit);
assert!(chain.len() <= all.len() + 1);
let line = [
link("P3", None, Some(true)),
link("P2", Some("P3"), Some(true)),
link("P1", Some("P2"), Some(true)),
];
let (chain, bound_hit) = parent_chain("P1", &line);
assert!(!bound_hit);
assert_eq!(chain.len(), 3);
}
#[test]
fn parent_reason_words_are_stable() {
assert_eq!(ParentReason::Missing.as_str(), "missing");
assert_eq!(ParentReason::Cycle.as_str(), "cycle");
assert_eq!(ParentReason::NotInheritable.as_str(), "not_inheritable");
assert_eq!(
serde_json::to_string(&ParentReason::NotInheritable).expect("json"),
"\"not_inheritable\""
);
}
fn settings_repo(json: &str) -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("tempdir");
let at = dir.path().join(TAG_PROVIDERS_DIR_REL);
std::fs::create_dir_all(&at).expect("mkdir");
std::fs::write(at.join("PlantA.json"), json).expect("write");
dir
}
const SETTINGS_REL: &str = "deploy/tag-providers/PlantA.json";
#[test]
fn provider_settings_dir_is_beside_the_project_file() {
assert_eq!(TAG_PROVIDERS_DIR_REL, "deploy/tag-providers");
assert_eq!(
gateway_manifest_rel("capgw"),
"gateways/capgw/manifest.yaml"
);
}
#[test]
fn provider_settings_load_strictly() {
let repo = settings_repo(
r#"{"schema_version": 1, "enabled": true, "description": "Plant A", "config": {"profile": {"type": "STANDARD"}}}"#,
);
let settings = load_provider_settings(repo.path(), "PlantA").expect("valid");
assert!(settings.enabled);
assert_eq!(settings.description.as_deref(), Some("Plant A"));
assert_eq!(settings.config["profile"]["type"], "STANDARD");
let repo = settings_repo(r#"{"schema_version": 1, "enabled": false, "config": {}}"#);
let settings = load_provider_settings(repo.path(), "PlantA").expect("no description");
assert!(!settings.enabled);
assert_eq!(settings.description, None);
for bad in [
r#"{"schema_version": 1, "enabled": true, "config": {}, "extra": 1}"#,
r#"{"schema_version": 2, "enabled": true, "config": {}}"#,
r#"{"schema_version": 1, "enabled": true, "config": []}"#,
r#"{"schema_version": 1, "enabled": true, "config": "x"}"#,
r#"{"schema_version": 1, "enabled": true}"#,
r#"{"schema_version": 1, "enabled": true, "config": {"a": 1, "a": 2}}"#,
r#"{"schema_version": 1, "enabled": true, "config": {}"#,
r#"[]"#,
] {
let repo = settings_repo(bad);
let err = load_provider_settings(repo.path(), "PlantA").expect_err(bad);
assert_eq!(err.code(), "deploy_tree_invalid", "{bad}: {err}");
assert!(err.to_string().contains(SETTINGS_REL), "{bad}: {err}");
}
let repo = tempfile::tempdir().expect("tempdir");
let err = load_provider_settings(repo.path(), "PlantA").expect_err("missing");
assert_eq!(err.code(), "deploy_tree_invalid");
let text = err.to_string();
assert!(text.contains(SETTINGS_REL), "{text}");
assert!(text.contains("does not exist"), "{text}");
}
#[test]
fn provider_settings_embedded_secret_is_refused() {
let repo = settings_repo(
r#"{"schema_version": 1, "enabled": true, "config": {"auth": {"password": {"type": "Embedded", "data": {"ciphertext": "SENTINEL-CIPHER"}}}}}"#,
);
let err = load_provider_settings(repo.path(), "PlantA").expect_err("embedded");
assert_eq!(err.code(), "embedded_secret");
let text = err.to_string();
assert!(text.contains(SETTINGS_REL), "{text}");
assert!(text.contains("config.auth.password"), "{text}");
assert!(!text.contains("SENTINEL-CIPHER"), "never a value: {text}");
}
fn owning(providers: &str) -> ProjectFile {
let yaml = format!(
"schema_version: 1\nproject: CapApp\nenvironments: [dev]\ntag_providers: {providers}\n"
);
ProjectFile::parse(PROJECT_FILE_REL, yaml.as_bytes()).expect("project file")
}
fn target_with(extra: &str) -> Target {
let yaml = format!(
"schema_version: 1\ngateway: capgw\nmode: dev\nnodes:\n master:\n profile: dev\n{extra}"
);
Target::parse("targets/dev.yaml", yaml.as_bytes()).expect("target")
}
fn manifest_with(extra: &str) -> Manifest {
let yaml = format!("schema_version: 1\ntypes: []\n{extra}");
Manifest::parse("gateways/capgw/manifest.yaml", yaml.as_bytes()).expect("manifest")
}
const MANIFEST_REL: &str = "gateways/capgw/manifest.yaml";
fn refusal(err: CoreError) -> Vec<String> {
assert_eq!(err.code(), "deploy_tree_invalid", "{err}");
let CoreError::DeployTreeInvalid(details) = err else {
unreachable!()
};
details
.findings
.iter()
.map(|f| {
assert_eq!(f.file, PROJECT_FILE_REL);
f.message.clone()
})
.collect()
}
#[test]
fn ownership_refuses_system_and_excluded_providers() {
let target = target_with("exclude_tag_providers: [\"MQTT Engine\"]\n");
let manifest = manifest_with("exclude_tag_providers: [Sparkplug]\n");
let sources =
ReadOnlySources::new("targets/dev.yaml", &target, Some((MANIFEST_REL, &manifest)));
let err = check_tag_ownership(
&owning("[system, PlantA, \"mqtt engine\", SPARKPLUG]"),
&sources,
Some((MANIFEST_REL, &manifest)),
)
.expect_err("three are read-only");
let messages = refusal(err);
assert_eq!(messages.len(), 3, "{messages:?}");
assert!(
messages[0].starts_with("tag_providers[0]") && messages[0].contains("built-in"),
"{messages:?}"
);
assert!(
messages[1].starts_with("tag_providers[2]")
&& messages[1].contains("exclude_tag_providers of targets/dev.yaml"),
"{messages:?}"
);
assert!(
messages[2].starts_with("tag_providers[3]")
&& messages[2].contains(&format!("exclude_tag_providers of {MANIFEST_REL}")),
"{messages:?}"
);
}
#[test]
fn ownership_refuses_read_only_providers_from_target_and_manifest() {
for name in ["Broker Transmission", "EdgeGateway01"] {
for listed in [name.to_ascii_lowercase(), name.to_ascii_uppercase()] {
let list = format!("read_only_tag_providers: [\"{name}\"]\n");
let project = owning(&format!("[PlantA, \"{listed}\"]"));
let target = target_with(&list);
let manifest = manifest_with("");
let sources = ReadOnlySources::new(
"targets/dev.yaml",
&target,
Some((MANIFEST_REL, &manifest)),
);
let messages = refusal(
check_tag_ownership(&project, &sources, Some((MANIFEST_REL, &manifest)))
.expect_err("target read-only"),
);
assert_eq!(messages.len(), 1, "{messages:?}");
assert!(
messages[0].starts_with("tag_providers[1]")
&& messages[0].contains("read_only_tag_providers of targets/dev.yaml"),
"{messages:?}"
);
let target = target_with("");
let manifest = manifest_with(&list);
let sources = ReadOnlySources::new(
"targets/dev.yaml",
&target,
Some((MANIFEST_REL, &manifest)),
);
let messages = refusal(
check_tag_ownership(&project, &sources, Some((MANIFEST_REL, &manifest)))
.expect_err("manifest read-only"),
);
assert_eq!(messages.len(), 1, "{messages:?}");
assert!(
messages[0].contains(&format!("read_only_tag_providers of {MANIFEST_REL}")),
"{messages:?}"
);
}
}
}
#[test]
fn ownership_refuses_a_provider_the_manifest_deploys() {
let target = target_with("");
let manifest = manifest_with("tag_providers: [default, planta]\n");
let sources =
ReadOnlySources::new("targets/dev.yaml", &target, Some((MANIFEST_REL, &manifest)));
let err = check_tag_ownership(
&owning("[Other, PlantA]"),
&sources,
Some((MANIFEST_REL, &manifest)),
)
.expect_err("claimed");
assert_eq!(err.code(), "tag_provider_claimed");
let CoreError::TagProviderClaimed {
provider,
claimed_by,
} = err
else {
unreachable!()
};
assert_eq!(provider, "PlantA");
assert!(claimed_by.contains(MANIFEST_REL), "{claimed_by}");
}
#[test]
fn ownership_without_a_manifest_passes_with_a_warning() {
let target = target_with("");
let sources = ReadOnlySources::new("targets/dev.yaml", &target, None);
let warnings = check_tag_ownership(&owning("[PlantA]"), &sources, None).expect("passes");
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(warnings[0].contains(MANIFEST_REL), "{warnings:?}");
let manifest = manifest_with("tag_providers: [Other]\n");
let sources =
ReadOnlySources::new("targets/dev.yaml", &target, Some((MANIFEST_REL, &manifest)));
let warnings = check_tag_ownership(
&owning("[PlantA]"),
&sources,
Some((MANIFEST_REL, &manifest)),
)
.expect("not claimed");
assert!(warnings.is_empty(), "{warnings:?}");
let project = ProjectFile::parse(PROJECT_FILE_REL, GOOD.as_bytes()).expect("no owned");
let sources = ReadOnlySources::new("targets/dev.yaml", &target, None);
assert!(
check_tag_ownership(&project, &sources, None)
.expect("owns nothing")
.is_empty()
);
}
}