use super::*;
use anodizer_core::config::{Config, CrateConfig, WorkspaceConfig};
use std::fs;
use tempfile::tempdir;
fn make_crate(name: &str, tag_template: &str, depends_on: Option<Vec<&str>>) -> CrateConfig {
CrateConfig {
name: name.to_string(),
path: ".".to_string(),
tag_template: Some(tag_template.to_string()),
depends_on: depends_on.map(|d| d.iter().map(|s| s.to_string()).collect()),
..Default::default()
}
}
fn make_config(crates: Vec<CrateConfig>) -> Config {
Config {
project_name: "test".to_string(),
crates,
..Default::default()
}
}
fn test_logger() -> StageLogger {
StageLogger::new("check", Verbosity::Quiet)
}
const NO_WORKSPACE_BASE: &str = "/nonexistent/anodizer-check-config-test-base";
fn write_disk_workspace(root: &std::path::Path, members: &[(&str, &str, &[&str])]) {
fs::create_dir_all(root).unwrap();
let member_list = members
.iter()
.map(|(path, _, _)| format!("\"{path}\""))
.collect::<Vec<_>>()
.join(", ");
fs::write(
root.join("Cargo.toml"),
format!("[workspace]\nmembers = [{member_list}]\n"),
)
.unwrap();
for (path, name, deps) in members {
let dir = root.join(path);
fs::create_dir_all(&dir).unwrap();
let mut body = format!("[package]\nname = \"{name}\"\n");
if !deps.is_empty() {
body.push_str("[dependencies]\n");
for dep in *deps {
body.push_str(&format!("{dep}.workspace = true\n"));
}
}
fs::write(dir.join("Cargo.toml"), body).unwrap();
}
}
fn p(root: &std::path::Path, rel: &str) -> String {
root.join(rel).to_string_lossy().to_string()
}
fn with_active_cargo_publisher(mut c: CrateConfig) -> CrateConfig {
c.publish = Some(anodizer_core::config::PublishConfig {
cargo: Some(anodizer_core::config::CargoPublishConfig::default()),
..Default::default()
});
c
}
#[test]
fn check_workspace_membership_direct_missing_dep_names_both_crates() {
let tmp = tempdir().unwrap();
let root = tmp.path();
write_disk_workspace(
root,
&[
("crates/main", "main", &["helper"]),
("crates/helper", "helper", &[]),
],
);
let config = make_config(vec![with_active_cargo_publisher(CrateConfig {
name: "main".to_string(),
path: p(root, "crates/main"),
tag_template: Some("v{{ .Version }}".to_string()),
..Default::default()
})]);
let all_names = flatten_crate_names(&config);
let mut errors = vec![];
check_workspace_membership(&config, root, &all_names, &mut errors);
assert_eq!(
errors.len(),
1,
"expected exactly one missing-membership error: {errors:?}"
);
assert!(
errors[0].contains("helper"),
"error should name the missing crate: {}",
errors[0]
);
assert!(
errors[0].contains("main"),
"error should name the dependent crate: {}",
errors[0]
);
}
#[test]
fn check_workspace_membership_single_crate_mode_missing_dep_fails() {
let tmp = tempdir().unwrap();
let root = tmp.path();
write_disk_workspace(
root,
&[
("crates/main", "main", &["helper"]),
("crates/helper", "helper", &[]),
],
);
let config = make_config(vec![with_active_cargo_publisher(CrateConfig {
name: "main".to_string(),
path: p(root, "crates/main"),
tag_template: Some("v{{ .Version }}".to_string()),
..Default::default()
})]);
let result = run_checks(&config, false, &test_logger(), root);
assert!(
result.is_err(),
"single-crate config missing an on-disk workspace dep should fail"
);
}
#[test]
fn check_workspace_membership_single_crate_mode_complete_passes() {
let tmp = tempdir().unwrap();
let root = tmp.path();
write_disk_workspace(
root,
&[
("crates/main", "main", &["helper"]),
("crates/helper", "helper", &[]),
],
);
let config = make_config(vec![
with_active_cargo_publisher(CrateConfig {
name: "main".to_string(),
path: p(root, "crates/main"),
tag_template: Some("v{{ .Version }}".to_string()),
depends_on: Some(vec!["helper".to_string()]),
..Default::default()
}),
with_active_cargo_publisher(CrateConfig {
name: "helper".to_string(),
path: p(root, "crates/helper"),
tag_template: Some("helper-v{{ .Version }}".to_string()),
..Default::default()
}),
]);
let result = run_checks(&config, false, &test_logger(), root);
assert!(
result.is_ok(),
"complete single-crate-mode membership should pass: {:?}",
result.err()
);
}
#[test]
fn check_workspace_membership_lockstep_multi_crate_missing_dep_fails() {
let tmp = tempdir().unwrap();
let root = tmp.path();
write_disk_workspace(
root,
&[
("crates/api", "api", &["shared"]),
("crates/cli", "cli", &["shared"]),
("crates/shared", "shared", &[]),
],
);
let config = make_config(vec![
with_active_cargo_publisher(CrateConfig {
name: "api".to_string(),
path: p(root, "crates/api"),
tag_template: Some("v{{ .Version }}".to_string()),
..Default::default()
}),
with_active_cargo_publisher(CrateConfig {
name: "cli".to_string(),
path: p(root, "crates/cli"),
tag_template: Some("v{{ .Version }}".to_string()),
..Default::default()
}),
]);
let result = run_checks(&config, false, &test_logger(), root);
assert!(
result.is_err(),
"lockstep config missing an on-disk workspace dep should fail"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("2 error(s)"),
"expected one error per dependent crate referencing 'shared': {}",
msg
);
}
#[test]
fn check_workspace_membership_lockstep_multi_crate_complete_passes() {
let tmp = tempdir().unwrap();
let root = tmp.path();
write_disk_workspace(
root,
&[
("crates/api", "api", &["shared"]),
("crates/cli", "cli", &["shared"]),
("crates/shared", "shared", &[]),
],
);
let config = make_config(vec![
with_active_cargo_publisher(CrateConfig {
name: "api".to_string(),
path: p(root, "crates/api"),
tag_template: Some("v{{ .Version }}".to_string()),
depends_on: Some(vec!["shared".to_string()]),
..Default::default()
}),
with_active_cargo_publisher(CrateConfig {
name: "cli".to_string(),
path: p(root, "crates/cli"),
tag_template: Some("v{{ .Version }}".to_string()),
depends_on: Some(vec!["shared".to_string()]),
..Default::default()
}),
with_active_cargo_publisher(CrateConfig {
name: "shared".to_string(),
path: p(root, "crates/shared"),
tag_template: Some("shared-v{{ .Version }}".to_string()),
..Default::default()
}),
]);
let result = run_checks(&config, false, &test_logger(), root);
assert!(
result.is_ok(),
"complete lockstep membership should pass: {:?}",
result.err()
);
}
#[test]
fn check_workspace_membership_per_crate_workspace_missing_dep_fails() {
let tmp = tempdir().unwrap();
let root = tmp.path();
write_disk_workspace(
root,
&[
("crates/frontend", "frontend", &["util"]),
("crates/util", "util", &[]),
],
);
let mut config = make_config(vec![]);
config.workspaces = Some(vec![WorkspaceConfig {
name: "web".to_string(),
crates: vec![with_active_cargo_publisher(CrateConfig {
name: "frontend".to_string(),
path: p(root, "crates/frontend"),
tag_template: Some("frontend-v{{ .Version }}".to_string()),
..Default::default()
})],
..Default::default()
}]);
let result = run_checks(&config, false, &test_logger(), root);
assert!(
result.is_err(),
"per-crate workspace config missing an on-disk dep should fail"
);
}
#[test]
fn check_workspace_membership_per_crate_workspace_complete_passes() {
let tmp = tempdir().unwrap();
let root = tmp.path();
write_disk_workspace(
root,
&[
("crates/frontend", "frontend", &["util"]),
("crates/util", "util", &[]),
],
);
let mut config = make_config(vec![]);
config.workspaces = Some(vec![WorkspaceConfig {
name: "web".to_string(),
crates: vec![
with_active_cargo_publisher(CrateConfig {
name: "frontend".to_string(),
path: p(root, "crates/frontend"),
tag_template: Some("frontend-v{{ .Version }}".to_string()),
depends_on: Some(vec!["util".to_string()]),
..Default::default()
}),
with_active_cargo_publisher(CrateConfig {
name: "util".to_string(),
path: p(root, "crates/util"),
tag_template: Some("util-v{{ .Version }}".to_string()),
..Default::default()
}),
],
..Default::default()
}]);
let result = run_checks(&config, false, &test_logger(), root);
assert!(
result.is_ok(),
"complete per-crate workspace membership should pass: {:?}",
result.err()
);
}
#[test]
fn check_workspace_membership_no_active_publisher_skips_check() {
let tmp = tempdir().unwrap();
let root = tmp.path();
write_disk_workspace(
root,
&[
("crates/main", "main", &["helper"]),
("crates/helper", "helper", &[]),
],
);
let config = make_config(vec![CrateConfig {
name: "main".to_string(),
path: p(root, "crates/main"),
tag_template: Some("v{{ .Version }}".to_string()),
..Default::default()
}]);
let all_names = flatten_crate_names(&config);
let mut errors = vec![];
check_workspace_membership(&config, root, &all_names, &mut errors);
assert!(
errors.is_empty(),
"crate with no active cargo publisher must not be checked for workspace membership: {errors:?}"
);
}
#[test]
fn check_workspace_membership_dep_with_cargo_skip_still_errors() {
let tmp = tempdir().unwrap();
let root = tmp.path();
write_disk_workspace(
root,
&[
("crates/main", "main", &["helper"]),
("crates/helper", "helper", &[]),
],
);
let mut helper = CrateConfig {
name: "helper".to_string(),
path: p(root, "crates/helper"),
tag_template: Some("helper-v{{ .Version }}".to_string()),
..Default::default()
};
helper.publish = Some(anodizer_core::config::PublishConfig {
cargo: Some(anodizer_core::config::CargoPublishConfig {
skip: Some(anodizer_core::config::StringOrBool::Bool(true)),
..Default::default()
}),
..Default::default()
});
let config = make_config(vec![
with_active_cargo_publisher(CrateConfig {
name: "main".to_string(),
path: p(root, "crates/main"),
tag_template: Some("v{{ .Version }}".to_string()),
depends_on: Some(vec!["helper".to_string()]),
..Default::default()
}),
helper,
]);
let all_names = flatten_crate_names(&config);
let mut errors = vec![];
check_workspace_membership(&config, root, &all_names, &mut errors);
assert_eq!(
errors.len(),
1,
"dependency with publish.cargo.skip=true should still fail the membership check: {errors:?}"
);
assert!(
errors[0].contains("no active cargo publisher"),
"error should explain the skipped publisher, got: {}",
errors[0]
);
}
#[test]
fn check_workspace_membership_discriminates_distinct_cargo_workspace_roots() {
let tmp = tempdir().unwrap();
let root = tmp.path();
write_disk_workspace(
&root.join("ws-a"),
&[
("crates/frontend", "frontend", &["util"]),
("crates/util", "util", &[]),
],
);
write_disk_workspace(
&root.join("ws-b"),
&[
("crates/backend", "backend", &["dbutil"]),
("crates/dbutil", "dbutil", &[]),
],
);
let config = make_config(vec![
with_active_cargo_publisher(CrateConfig {
name: "frontend".to_string(),
path: p(root, "ws-a/crates/frontend"),
tag_template: Some("frontend-v{{ .Version }}".to_string()),
..Default::default()
}),
with_active_cargo_publisher(CrateConfig {
name: "backend".to_string(),
path: p(root, "ws-b/crates/backend"),
tag_template: Some("backend-v{{ .Version }}".to_string()),
depends_on: Some(vec!["dbutil".to_string()]),
..Default::default()
}),
with_active_cargo_publisher(CrateConfig {
name: "dbutil".to_string(),
path: p(root, "ws-b/crates/dbutil"),
tag_template: Some("dbutil-v{{ .Version }}".to_string()),
..Default::default()
}),
]);
let all_names = flatten_crate_names(&config);
let mut errors = vec![];
check_workspace_membership(&config, root, &all_names, &mut errors);
assert_eq!(
errors.len(),
1,
"only ws-a's frontend/util gap should error; ws-b's backend/dbutil is complete: {errors:?}"
);
assert!(
errors[0].contains("util") && errors[0].contains("frontend"),
"error should name ws-a's missing dep, got: {}",
errors[0]
);
}
#[test]
fn workspace_scoped_checks_ignore_sibling_errors() {
let config = Config {
project_name: "test".to_string(),
workspaces: Some(vec![
WorkspaceConfig {
name: "ws-a".to_string(),
crates: vec![make_crate("a-one", "a-one-v{{ .Version }}", None)],
..Default::default()
},
WorkspaceConfig {
name: "ws-b".to_string(),
crates: vec![
make_crate("b-one", "b-one-v{{ .Version }}", Some(vec!["b-two"])),
make_crate("b-two", "b-two-v{{ .Version }}", Some(vec!["b-one"])),
],
..Default::default()
},
]),
..Default::default()
};
assert!(
run_checks(&config, false, &test_logger(), Path::new(NO_WORKSPACE_BASE)).is_err(),
"raw config must fail on the sibling's cycle"
);
let ws = config.workspaces.as_ref().unwrap()[0].clone();
let mut resolved = config.clone();
helpers::apply_workspace_overlay(&mut resolved, &ws);
run_checks(
&resolved,
false,
&test_logger(),
Path::new(NO_WORKSPACE_BASE),
)
.expect("workspace-scoped validation must ignore sibling workspace errors");
}
#[test]
fn command_workspace_scoped_run_ignores_sibling_errors() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let config_path = root.join(".anodizer.yaml");
std::fs::write(
&config_path,
r#"project_name: fixture
workspaces:
- name: ws-a
crates:
- name: a-one
path: .
tag_template: "a-one-v{{ .Version }}"
- name: ws-b
crates:
- name: b-one
path: .
tag_template: "b-one-v{{ .Version }}"
depends_on: [b-two]
- name: b-two
path: .
tag_template: "b-two-v{{ .Version }}"
depends_on: [b-one]
"#,
)
.unwrap();
run(Some(&config_path), Some("ws-a"), &[], false, false, true)
.expect("scoped run must not fail on the sibling workspace's cycle");
let err = run(Some(&config_path), None, &[], false, false, true)
.expect_err("the no-flag run still validates the whole file");
assert!(err.to_string().contains("validation failed"), "got: {err}");
}
#[test]
fn test_no_cycle_linear() {
let crates = vec![
make_crate("a", "a-v{{ .Version }}", None),
make_crate("b", "b-v{{ .Version }}", Some(vec!["a"])),
make_crate("c", "c-v{{ .Version }}", Some(vec!["b"])),
];
assert!(find_cycle(&crates).is_none());
}
#[test]
fn test_cycle_two_nodes() {
let crates = vec![
make_crate("a", "a-v{{ .Version }}", Some(vec!["b"])),
make_crate("b", "b-v{{ .Version }}", Some(vec!["a"])),
];
let cycle = find_cycle(&crates);
assert!(cycle.is_some(), "expected a cycle to be detected");
}
#[test]
fn test_cycle_three_nodes() {
let crates = vec![
make_crate("a", "a-v{{ .Version }}", Some(vec!["c"])),
make_crate("b", "b-v{{ .Version }}", Some(vec!["a"])),
make_crate("c", "c-v{{ .Version }}", Some(vec!["b"])),
];
let cycle = find_cycle(&crates);
assert!(cycle.is_some(), "expected a cycle to be detected");
}
#[test]
fn test_no_cycle_diamond() {
let crates = vec![
make_crate("base", "base-v{{ .Version }}", None),
make_crate("left", "left-v{{ .Version }}", Some(vec!["base"])),
make_crate("right", "right-v{{ .Version }}", Some(vec!["base"])),
make_crate("top", "top-v{{ .Version }}", Some(vec!["left", "right"])),
];
assert!(find_cycle(&crates).is_none());
}
#[test]
fn test_tag_template_valid() {
let config = make_config(vec![make_crate("a", "a-v{{ .Version }}", None)]);
assert!(run_checks(&config, false, &test_logger(), Path::new(NO_WORKSPACE_BASE)).is_ok());
}
#[test]
fn test_tag_template_missing_version() {
let config = make_config(vec![make_crate("a", "release-tag", None)]);
let result = run_checks(&config, false, &test_logger(), Path::new(NO_WORKSPACE_BASE));
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("validation failed"), "got: {}", msg);
}
#[test]
fn test_tag_template_empty_skipped() {
let config = make_config(vec![make_crate("a", "", None)]);
assert!(run_checks(&config, false, &test_logger(), Path::new(NO_WORKSPACE_BASE)).is_ok());
}
#[test]
fn test_depends_on_missing_crate() {
let config = make_config(vec![make_crate(
"a",
"a-v{{ .Version }}",
Some(vec!["nonexistent"]),
)]);
let result = run_checks(&config, false, &test_logger(), Path::new(NO_WORKSPACE_BASE));
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("validation failed"), "got: {}", msg);
}
#[test]
fn test_depends_on_cycle_fails() {
let crates = vec![
make_crate("a", "a-v{{ .Version }}", Some(vec!["b"])),
make_crate("b", "b-v{{ .Version }}", Some(vec!["a"])),
];
let config = make_config(crates);
let result = run_checks(&config, false, &test_logger(), Path::new(NO_WORKSPACE_BASE));
assert!(result.is_err());
}
#[test]
fn test_copy_from_valid() {
use anodizer_core::config::BuildConfig;
let mut c = make_crate("a", "a-v{{ .Version }}", None);
c.builds = Some(vec![
BuildConfig {
binary: Some("a".to_string()),
..Default::default()
},
BuildConfig {
binary: Some("b".to_string()),
copy_from: Some("a".to_string()),
..Default::default()
},
]);
let config = make_config(vec![c]);
assert!(run_checks(&config, false, &test_logger(), Path::new(NO_WORKSPACE_BASE)).is_ok());
}
#[test]
fn test_copy_from_invalid() {
use anodizer_core::config::BuildConfig;
let mut c = make_crate("a", "a-v{{ .Version }}", None);
c.builds = Some(vec![BuildConfig {
binary: Some("b".to_string()),
copy_from: Some("nonexistent".to_string()),
..Default::default()
}]);
let config = make_config(vec![c]);
let result = run_checks(&config, false, &test_logger(), Path::new(NO_WORKSPACE_BASE));
assert!(result.is_err());
}
#[test]
fn test_check_changelog_disabled_with_other_fields_passes() {
use anodizer_core::config::{ChangelogConfig, ChangelogGroup};
let mut config = make_config(vec![make_crate("a", "a-v{{ .Version }}", None)]);
config.changelog = Some(ChangelogConfig {
skip: Some(anodizer_core::config::StringOrBool::Bool(true)),
sort: Some("desc".to_string()),
header: Some(anodizer_core::config::ContentSource::Inline(
"header".to_string(),
)),
groups: Some(vec![ChangelogGroup {
title: "Features".to_string(),
regexp: Some("^feat".to_string()),
order: Some(0),
groups: None,
}]),
..Default::default()
});
assert!(run_checks(&config, false, &test_logger(), Path::new(NO_WORKSPACE_BASE)).is_ok());
}
#[test]
fn test_check_checksum_disabled_with_other_fields_passes() {
use anodizer_core::config::{ChecksumConfig, Defaults, StringOrBool};
let mut config = make_config(vec![make_crate("a", "a-v{{ .Version }}", None)]);
config.defaults = Some(Defaults {
checksum: Some(ChecksumConfig {
skip: Some(StringOrBool::Bool(true)),
algorithm: Some("sha512".to_string()),
..Default::default()
}),
..Default::default()
});
assert!(run_checks(&config, false, &test_logger(), Path::new(NO_WORKSPACE_BASE)).is_ok());
}
#[test]
fn test_empty_crate_name_fails() {
let config = make_config(vec![make_crate("", "v{{ .Version }}", None)]);
let result = run_checks(&config, false, &test_logger(), Path::new(NO_WORKSPACE_BASE));
assert!(result.is_err(), "empty crate name should fail validation");
let msg = result.unwrap_err().to_string();
assert!(msg.contains("validation failed"), "got: {}", msg);
}
#[test]
fn test_whitespace_only_crate_name_fails() {
let config = make_config(vec![make_crate(" ", "v{{ .Version }}", None)]);
let result = run_checks(&config, false, &test_logger(), Path::new(NO_WORKSPACE_BASE));
assert!(
result.is_err(),
"whitespace-only crate name should fail validation"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("1 error(s)"),
"error should report 1 validation error, got: {msg}"
);
}
#[test]
fn test_tag_template_compact_version_accepted() {
let config = make_config(vec![make_crate("a", "v{{.Version}}", None)]);
assert!(run_checks(&config, false, &test_logger(), Path::new(NO_WORKSPACE_BASE)).is_ok());
}
#[test]
fn test_tag_template_tera_native_version_accepted() {
let config = make_config(vec![make_crate("a", "v{{ Version }}", None)]);
assert!(run_checks(&config, false, &test_logger(), Path::new(NO_WORKSPACE_BASE)).is_ok());
}
#[test]
fn test_tag_template_tera_native_compact_version_accepted() {
let config = make_config(vec![make_crate("a", "v{{Version}}", None)]);
assert!(run_checks(&config, false, &test_logger(), Path::new(NO_WORKSPACE_BASE)).is_ok());
}
#[test]
fn test_tag_template_missing_version_with_other_placeholder() {
let config = make_config(vec![make_crate("a", "{{ .Tag }}-release", None)]);
let result = run_checks(&config, false, &test_logger(), Path::new(NO_WORKSPACE_BASE));
assert!(
result.is_err(),
"tag_template without Version placeholder should fail"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("1 error(s)"),
"error should report 1 validation error, got: {msg}"
);
}
#[test]
fn test_multiple_validation_errors_reported() {
let crates = vec![
make_crate("", "v{{ .Version }}", None), make_crate("b", "bad-tag", Some(vec!["nonexistent"])), ];
let config = make_config(crates);
let result = run_checks(&config, false, &test_logger(), Path::new(NO_WORKSPACE_BASE));
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("3 error(s)"),
"should report 3 error(s), got: {}",
msg
);
}
#[test]
fn test_check_per_crate_checksum_disabled_with_other_fields_passes() {
use anodizer_core::config::{ChecksumConfig, StringOrBool};
let mut c = make_crate("a", "a-v{{ .Version }}", None);
c.checksum = Some(ChecksumConfig {
skip: Some(StringOrBool::Bool(true)),
algorithm: Some("sha512".to_string()),
name_template: Some("checksums.txt".to_string()),
..Default::default()
});
let config = make_config(vec![c]);
assert!(run_checks(&config, false, &test_logger(), Path::new(NO_WORKSPACE_BASE)).is_ok());
}
#[test]
fn workspace_only_crates_flag_tool_needs() {
use anodizer_core::config::{DockerV2Config, NfpmConfig, ReleaseConfig};
let mut member = make_crate("svc", "svc-v{{ .Version }}", None);
member.dockers_v2 = Some(vec![DockerV2Config::default()]);
member.release = Some(ReleaseConfig::default());
member.nfpms = Some(vec![NfpmConfig::default()]);
let mut config = make_config(vec![]);
config.workspaces = Some(vec![WorkspaceConfig {
name: "grp".to_string(),
crates: vec![member],
..Default::default()
}]);
assert!(config_needs_docker(&config));
assert!(config_needs_release(&config));
assert!(config_needs_nfpm(&config));
}
#[test]
fn test_workspace_names_unique_passes() {
let mut config = make_config(vec![make_crate("a", "a-v{{ .Version }}", None)]);
config.workspaces = Some(vec![
WorkspaceConfig {
name: "frontend".to_string(),
crates: vec![make_crate("fe", "fe-v{{ .Version }}", None)],
..Default::default()
},
WorkspaceConfig {
name: "backend".to_string(),
crates: vec![make_crate("be", "be-v{{ .Version }}", None)],
..Default::default()
},
]);
assert!(run_checks(&config, false, &test_logger(), Path::new(NO_WORKSPACE_BASE)).is_ok());
}
#[test]
fn test_workspace_duplicate_name_fails() {
let mut config = make_config(vec![make_crate("a", "a-v{{ .Version }}", None)]);
config.workspaces = Some(vec![
WorkspaceConfig {
name: "dup".to_string(),
crates: vec![make_crate("x", "x-v{{ .Version }}", None)],
..Default::default()
},
WorkspaceConfig {
name: "dup".to_string(),
crates: vec![make_crate("y", "y-v{{ .Version }}", None)],
..Default::default()
},
]);
let result = run_checks(&config, false, &test_logger(), Path::new(NO_WORKSPACE_BASE));
assert!(result.is_err(), "duplicate workspace names should fail");
}
#[test]
fn test_workspace_empty_name_fails() {
let mut config = make_config(vec![make_crate("a", "a-v{{ .Version }}", None)]);
config.workspaces = Some(vec![WorkspaceConfig {
name: "".to_string(),
crates: vec![make_crate("x", "x-v{{ .Version }}", None)],
..Default::default()
}]);
let result = run_checks(&config, false, &test_logger(), Path::new(NO_WORKSPACE_BASE));
assert!(result.is_err(), "empty workspace name should fail");
}
#[test]
fn test_workspace_crate_empty_name_fails() {
let mut config = make_config(vec![make_crate("a", "a-v{{ .Version }}", None)]);
config.workspaces = Some(vec![WorkspaceConfig {
name: "ws1".to_string(),
crates: vec![make_crate("", "v{{ .Version }}", None)],
..Default::default()
}]);
let result = run_checks(&config, false, &test_logger(), Path::new(NO_WORKSPACE_BASE));
assert!(result.is_err(), "empty crate name in workspace should fail");
}
#[test]
fn test_workspace_crate_bad_tag_template_fails() {
let mut config = make_config(vec![make_crate("a", "a-v{{ .Version }}", None)]);
config.workspaces = Some(vec![WorkspaceConfig {
name: "ws1".to_string(),
crates: vec![make_crate("x", "no-version-here", None)],
..Default::default()
}]);
let result = run_checks(&config, false, &test_logger(), Path::new(NO_WORKSPACE_BASE));
assert!(
result.is_err(),
"bad tag_template in workspace crate should fail"
);
}
#[test]
fn test_no_workspaces_passes() {
let config = make_config(vec![make_crate("a", "a-v{{ .Version }}", None)]);
assert!(run_checks(&config, false, &test_logger(), Path::new(NO_WORKSPACE_BASE)).is_ok());
}
#[test]
fn test_workspace_duplicate_crate_name_fails() {
let mut config = make_config(vec![make_crate("a", "a-v{{ .Version }}", None)]);
config.workspaces = Some(vec![WorkspaceConfig {
name: "ws1".to_string(),
crates: vec![
make_crate("dup", "dup-v{{ .Version }}", None),
make_crate("dup", "dup-v{{ .Version }}", None),
],
..Default::default()
}]);
let result = run_checks(&config, false, &test_logger(), Path::new(NO_WORKSPACE_BASE));
assert!(
result.is_err(),
"duplicate crate names within a workspace should fail"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("1 error(s)"),
"should report 1 validation error for duplicate crate name: {}",
msg
);
}
#[test]
fn test_workspace_depends_on_missing_fails() {
let mut config = make_config(vec![make_crate("a", "a-v{{ .Version }}", None)]);
config.workspaces = Some(vec![WorkspaceConfig {
name: "ws1".to_string(),
crates: vec![make_crate(
"x",
"x-v{{ .Version }}",
Some(vec!["nonexistent"]),
)],
..Default::default()
}]);
let result = run_checks(&config, false, &test_logger(), Path::new(NO_WORKSPACE_BASE));
assert!(
result.is_err(),
"workspace crate with missing depends_on should fail"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("1 error(s)"),
"should report 1 validation error for missing depends_on: {}",
msg
);
}
#[test]
fn test_workspace_depends_on_cross_workspace_passes() {
let config = Config {
project_name: "test".to_string(),
workspaces: Some(vec![
WorkspaceConfig {
name: "core-ws".to_string(),
crates: vec![make_crate("core", "core-v{{ .Version }}", None)],
..Default::default()
},
WorkspaceConfig {
name: "app-ws".to_string(),
crates: vec![make_crate("app", "app-v{{ .Version }}", Some(vec!["core"]))],
..Default::default()
},
]),
..Default::default()
};
assert!(
run_checks(&config, false, &test_logger(), Path::new(NO_WORKSPACE_BASE)).is_ok(),
"cross-workspace depends_on should be accepted"
);
}
#[test]
fn test_workspace_depends_on_valid_passes() {
let mut config = make_config(vec![make_crate("a", "a-v{{ .Version }}", None)]);
config.workspaces = Some(vec![WorkspaceConfig {
name: "ws1".to_string(),
crates: vec![
make_crate("lib", "lib-v{{ .Version }}", None),
make_crate("app", "app-v{{ .Version }}", Some(vec!["lib"])),
],
..Default::default()
}]);
assert!(
run_checks(&config, false, &test_logger(), Path::new(NO_WORKSPACE_BASE)).is_ok(),
"valid depends_on within workspace should pass"
);
}
#[test]
fn test_invalid_source_format_fails() {
use anodizer_core::config::SourceConfig;
let mut config = make_config(vec![make_crate("a", "a-v{{ .Version }}", None)]);
config.source = Some(SourceConfig {
enabled: Some(true),
format: Some("tar.bz2".to_string()),
name_template: None,
prefix_template: None,
files: vec![],
});
let result = run_checks(&config, false, &test_logger(), Path::new(NO_WORKSPACE_BASE));
assert!(result.is_err(), "invalid source format should fail");
let msg = result.unwrap_err().to_string();
assert!(msg.contains("validation failed"), "got: {}", msg);
}
#[test]
fn test_valid_source_formats_pass() {
use anodizer_core::config::SourceConfig;
for fmt in &["tar.gz", "tgz", "tar", "zip"] {
let mut config = make_config(vec![make_crate("a", "a-v{{ .Version }}", None)]);
config.source = Some(SourceConfig {
enabled: Some(true),
format: Some(fmt.to_string()),
name_template: None,
prefix_template: None,
files: vec![],
});
assert!(
run_checks(&config, false, &test_logger(), Path::new(NO_WORKSPACE_BASE)).is_ok(),
"source format '{}' should pass",
fmt
);
}
}
#[test]
fn test_invalid_sbom_artifacts_fails() {
use anodizer_core::config::SbomConfig;
let mut config = make_config(vec![make_crate("a", "a-v{{ .Version }}", None)]);
config.sboms = vec![SbomConfig {
artifacts: Some("invalid".to_string()),
..Default::default()
}];
let result = run_checks(&config, false, &test_logger(), Path::new(NO_WORKSPACE_BASE));
assert!(result.is_err(), "invalid sbom artifacts should fail");
let msg = result.unwrap_err().to_string();
assert!(msg.contains("validation failed"), "got: {}", msg);
}
#[test]
fn test_valid_sbom_artifacts_pass() {
use anodizer_core::config::SbomConfig;
for art in &[
"source",
"archive",
"binary",
"package",
"diskimage",
"installer",
"any",
] {
let mut config = make_config(vec![make_crate("a", "a-v{{ .Version }}", None)]);
config.sboms = vec![SbomConfig {
artifacts: Some(art.to_string()),
..Default::default()
}];
assert!(
run_checks(&config, false, &test_logger(), Path::new(NO_WORKSPACE_BASE)).is_ok(),
"sbom artifacts '{}' should pass",
art
);
}
}
#[test]
fn test_blob_config_valid_provider() {
use anodizer_core::config::BlobConfig;
for provider in &["s3", "gcs", "gs", "azblob", "azure"] {
let mut config = make_config(vec![make_crate("a", "v{{ .Version }}", None)]);
config.crates[0].blobs = Some(vec![BlobConfig {
provider: provider.to_string(),
bucket: "my-bucket".to_string(),
..Default::default()
}]);
assert!(
run_checks(&config, false, &test_logger(), Path::new(NO_WORKSPACE_BASE)).is_ok(),
"blob provider '{}' should pass",
provider
);
}
}
#[test]
fn test_blob_config_invalid_provider() {
use anodizer_core::config::BlobConfig;
let mut config = make_config(vec![make_crate("a", "v{{ .Version }}", None)]);
config.crates[0].blobs = Some(vec![BlobConfig {
provider: "dropbox".to_string(),
bucket: "b".to_string(),
..Default::default()
}]);
let result = run_checks(&config, false, &test_logger(), Path::new(NO_WORKSPACE_BASE));
assert!(result.is_err(), "invalid blob provider should fail");
let msg = result.unwrap_err().to_string();
assert!(msg.contains("validation failed"), "got: {}", msg);
}
#[test]
fn test_blob_config_empty_provider() {
use anodizer_core::config::BlobConfig;
let mut config = make_config(vec![make_crate("a", "v{{ .Version }}", None)]);
config.crates[0].blobs = Some(vec![BlobConfig {
provider: String::new(),
bucket: "b".to_string(),
..Default::default()
}]);
let result = run_checks(&config, false, &test_logger(), Path::new(NO_WORKSPACE_BASE));
assert!(result.is_err(), "empty blob provider should fail");
let msg = result.unwrap_err().to_string();
assert!(msg.contains("validation failed"), "got: {}", msg);
}
#[test]
fn test_blob_config_empty_bucket() {
use anodizer_core::config::BlobConfig;
let mut config = make_config(vec![make_crate("a", "v{{ .Version }}", None)]);
config.crates[0].blobs = Some(vec![BlobConfig {
provider: "s3".to_string(),
bucket: String::new(),
..Default::default()
}]);
let result = run_checks(&config, false, &test_logger(), Path::new(NO_WORKSPACE_BASE));
assert!(result.is_err(), "empty blob bucket should fail");
let msg = result.unwrap_err().to_string();
assert!(msg.contains("validation failed"), "got: {}", msg);
}
#[test]
fn test_blob_config_id_in_error_label() {
use anodizer_core::config::BlobConfig;
let mut config = make_config(vec![make_crate("a", "v{{ .Version }}", None)]);
config.crates[0].blobs = Some(vec![BlobConfig {
id: Some("my-upload".to_string()),
provider: "invalid".to_string(),
bucket: "b".to_string(),
..Default::default()
}]);
let result = run_checks(&config, false, &test_logger(), Path::new(NO_WORKSPACE_BASE));
assert!(result.is_err(), "invalid provider with id should fail");
let msg = result.unwrap_err().to_string();
assert!(msg.contains("validation failed"), "got: {}", msg);
}
use anodizer_core::config::{
AnnounceConfig, BlueskyAnnounce, DiscourseAnnounce, EmailAnnounce, SlackAnnounce,
SlackAttachment, SlackBlock, SlackTextObject, TwitterAnnounce,
};
fn collect_announce_warnings(announce: AnnounceConfig) -> Vec<String> {
let mut config = make_config(vec![make_crate("a", "a-v{{ .Version }}", None)]);
config.announce = Some(announce);
let mut warnings = Vec::new();
check_announce_secret_exposure(&config, &mut warnings);
warnings
}
#[test]
fn test_announce_secret_warns_on_token_in_message() {
let warnings = collect_announce_warnings(AnnounceConfig {
twitter: Some(TwitterAnnounce {
message_template: Some("deploy {{ Env.GITHUB_TOKEN }}".to_string()),
..Default::default()
}),
..Default::default()
});
assert_eq!(
warnings.len(),
1,
"expected one warning, got: {:?}",
warnings
);
assert!(warnings[0].contains("announce.twitter.message_template"));
assert!(warnings[0].contains("Env.GITHUB_TOKEN"));
assert!(
warnings[0].contains("$GITHUB_TOKEN"),
"warning should state the masked form: {}",
warnings[0]
);
}
#[test]
fn test_announce_secret_warns_on_title_and_email_subject() {
let title_warnings = collect_announce_warnings(AnnounceConfig {
discourse: Some(DiscourseAnnounce {
title_template: Some("release {{ Env.SIGNING_KEY }}".to_string()),
..Default::default()
}),
..Default::default()
});
assert_eq!(title_warnings.len(), 1, "got: {:?}", title_warnings);
assert!(title_warnings[0].contains("announce.discourse.title_template"));
assert!(title_warnings[0].contains("Env.SIGNING_KEY"));
let email_warnings = collect_announce_warnings(AnnounceConfig {
email: Some(EmailAnnounce {
subject_template: Some("v{{ Env.NPM_PASSWORD }}".to_string()),
..Default::default()
}),
..Default::default()
});
assert_eq!(email_warnings.len(), 1, "got: {:?}", email_warnings);
assert!(email_warnings[0].contains("announce.email.subject_template"));
assert!(email_warnings[0].contains("Env.NPM_PASSWORD"));
}
#[test]
fn test_announce_secret_warns_on_go_style_dotted_env() {
let warnings = collect_announce_warnings(AnnounceConfig {
twitter: Some(TwitterAnnounce {
message_template: Some("{{ .Env.CARGO_REGISTRY_TOKEN }}".to_string()),
..Default::default()
}),
..Default::default()
});
assert_eq!(warnings.len(), 1, "got: {:?}", warnings);
assert!(warnings[0].contains("Env.CARGO_REGISTRY_TOKEN"));
}
#[test]
fn test_announce_secret_warns_in_slack_blocks_and_attachments() {
let warnings = collect_announce_warnings(AnnounceConfig {
slack: Some(SlackAnnounce {
blocks: Some(vec![SlackBlock {
block_type: "section".to_string(),
text: Some(SlackTextObject {
text_type: "mrkdwn".to_string(),
text: "see {{ Env.SLACK_API_TOKEN }}".to_string(),
..Default::default()
}),
..Default::default()
}]),
attachments: Some(vec![SlackAttachment {
footer: Some("built by {{ Env.BUILDER_SECRET }}".to_string()),
..Default::default()
}]),
..Default::default()
}),
..Default::default()
});
assert_eq!(warnings.len(), 2, "got: {:?}", warnings);
assert!(
warnings
.iter()
.any(|w| w.contains("announce.slack.blocks[0].text")
&& w.contains("Env.SLACK_API_TOKEN")),
"block-nested secret not warned: {:?}",
warnings
);
assert!(
warnings
.iter()
.any(|w| w.contains("announce.slack.attachments[0].footer")
&& w.contains("Env.BUILDER_SECRET")),
"attachment-nested secret not warned: {:?}",
warnings
);
}
#[test]
fn test_announce_secret_silent_on_non_secret_refs() {
let warnings = collect_announce_warnings(AnnounceConfig {
bluesky: Some(BlueskyAnnounce {
message_template: Some("{{ ProjectName }} {{ Tag }} home={{ Env.HOME }}".to_string()),
..Default::default()
}),
twitter: Some(TwitterAnnounce {
message_template: None,
..Default::default()
}),
..Default::default()
});
assert!(
warnings.is_empty(),
"non-secret refs should not warn: {:?}",
warnings
);
let mut config = make_config(vec![make_crate("a", "a-v{{ .Version }}", None)]);
config.announce = None;
let mut no_announce = Vec::new();
check_announce_secret_exposure(&config, &mut no_announce);
assert!(
no_announce.is_empty(),
"absent announce block should not warn: {:?}",
no_announce
);
}
#[test]
fn test_announce_secret_silent_on_bare_prose_no_braces() {
let warnings = collect_announce_warnings(AnnounceConfig {
twitter: Some(TwitterAnnounce {
message_template: Some("contact Env.GITHUB_TOKEN admin".to_string()),
..Default::default()
}),
..Default::default()
});
assert!(
warnings.is_empty(),
"bare-prose Env ref outside a render block should not warn: {:?}",
warnings
);
}
#[test]
fn test_announce_secret_warns_on_both_refs_in_one_block() {
let warnings = collect_announce_warnings(AnnounceConfig {
twitter: Some(TwitterAnnounce {
message_template: Some("{{ Env.A_TOKEN | default(Env.B_TOKEN) }}".to_string()),
..Default::default()
}),
..Default::default()
});
assert_eq!(
warnings.len(),
2,
"both secret refs should warn: {:?}",
warnings
);
assert!(
warnings.iter().any(|w| w.contains("Env.A_TOKEN")),
"first ref missed: {:?}",
warnings
);
assert!(
warnings.iter().any(|w| w.contains("Env.B_TOKEN")),
"second ref in same block missed: {:?}",
warnings
);
}
fn config_with_sign_artifacts(filter: &str) -> Config {
Config {
project_name: "test".to_string(),
signs: vec![anodizer_core::config::SignConfig {
artifacts: Some(filter.to_string()),
..Default::default()
}],
..Default::default()
}
}
#[test]
fn sign_filter_accepts_runtime_recognized_values_without_warning() {
for filter in anodizer_stage_sign::VALID_SIGN_ARTIFACT_FILTERS {
let config = config_with_sign_artifacts(filter);
let mut warnings: Vec<String> = vec![];
check_sign_artifact_filters(&config, &mut warnings);
assert!(
warnings.is_empty(),
"filter '{filter}' must NOT warn (it is runtime-valid), got: {warnings:?}"
);
}
}
#[test]
fn sign_filter_warns_on_unrecognized_value() {
let config = config_with_sign_artifacts("bogus");
let mut warnings: Vec<String> = vec![];
check_sign_artifact_filters(&config, &mut warnings);
assert_eq!(warnings.len(), 1, "an unknown filter must still warn");
assert!(
warnings[0].contains("bogus"),
"warning should name the offending filter: {:?}",
warnings
);
}
#[test]
fn sign_authenticode_filter_warns_on_unrecognized_value() {
let config = Config {
project_name: "test".to_string(),
signs: vec![anodizer_core::config::SignConfig {
authenticode: Some(anodizer_core::config::AuthenticodeConfig {
artifacts: Some("nonsense".to_string()),
..Default::default()
}),
..Default::default()
}],
..Default::default()
};
let mut warnings: Vec<String> = vec![];
check_sign_artifact_filters(&config, &mut warnings);
assert_eq!(warnings.len(), 1, "unknown authenticode filter must warn");
assert!(
warnings[0].contains("authenticode") && warnings[0].contains("nonsense"),
"authenticode warning should name the block and value: {:?}",
warnings
);
}
#[test]
fn asset_name_template_on_signs_warns_that_it_is_ignored() {
let sign_with_template = || anodizer_core::config::SignConfig {
asset_name_template: Some("{{ Binary }}-{{ Version }}".to_string()),
..Default::default()
};
let config = Config {
project_name: "test".to_string(),
signs: vec![sign_with_template()],
binary_signs: vec![sign_with_template()],
workspaces: Some(vec![anodizer_core::config::WorkspaceConfig {
name: "ws".to_string(),
signs: vec![sign_with_template()],
..Default::default()
}]),
..Default::default()
};
let mut warnings: Vec<String> = vec![];
check_sign_asset_name_templates(&config, &mut warnings);
assert_eq!(
warnings,
vec![
"signs[0].asset_name_template is set but only binary_signs honors it (it will be ignored)".to_string(),
"workspaces.ws.signs[0].asset_name_template is set but only binary_signs honors it (it will be ignored)".to_string(),
],
"binary_signs honors the field and must not warn"
);
}
#[test]
fn asset_name_template_under_defaults_sign_names_the_defaults_block() {
let mut config = Config {
project_name: "test".to_string(),
defaults: Some(anodizer_core::config::Defaults {
sign: Some(anodizer_core::config::SignConfig {
asset_name_template: Some("{{ Binary }}-{{ Version }}".to_string()),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
anodizer_core::defaults_merge::apply_defaults(&mut config);
let mut warnings: Vec<String> = vec![];
check_sign_asset_name_templates(&config, &mut warnings);
assert_eq!(
warnings,
vec![
"defaults.sign.asset_name_template is set but only binary_signs honors it (it will be ignored)".to_string(),
]
);
}
#[test]
fn an_entry_repeating_the_defaults_value_is_still_named_as_the_entry() {
let mut config = Config {
project_name: "test".to_string(),
defaults: Some(anodizer_core::config::Defaults {
sign: Some(anodizer_core::config::SignConfig {
asset_name_template: Some("{{ Binary }}-{{ Version }}".to_string()),
..Default::default()
}),
..Default::default()
}),
signs: vec![anodizer_core::config::SignConfig {
asset_name_template: Some("{{ Binary }}-{{ Version }}".to_string()),
cmd: Some("cosign".to_string()),
..Default::default()
}],
..Default::default()
};
anodizer_core::defaults_merge::apply_defaults(&mut config);
let mut warnings: Vec<String> = vec![];
check_sign_asset_name_templates(&config, &mut warnings);
assert_eq!(
warnings,
vec![
"signs[0].asset_name_template is set but only binary_signs honors it (it will be ignored)".to_string(),
]
);
}
#[test]
fn a_workspace_signs_entry_is_named_as_the_entry_after_the_overlay() {
let workspace = WorkspaceConfig {
name: "tools".to_string(),
signs: vec![anodizer_core::config::SignConfig {
cmd: Some("cosign".to_string()),
asset_name_template: Some("{{ Binary }}-{{ Version }}".to_string()),
..Default::default()
}],
..Default::default()
};
let mut config = Config {
project_name: "test".to_string(),
defaults: Some(anodizer_core::config::Defaults {
sign: Some(anodizer_core::config::SignConfig {
cmd: Some("cosign".to_string()),
..Default::default()
}),
..Default::default()
}),
workspaces: Some(vec![workspace.clone()]),
..Default::default()
};
anodizer_core::defaults_merge::apply_defaults(&mut config);
assert!(
config.filled_from_defaults.contains("signs"),
"the fold must have filled the top-level slice for this to be a test"
);
let mut resolved = config.clone();
helpers::apply_workspace_overlay(&mut resolved, &workspace);
let mut warnings: Vec<String> = vec![];
check_sign_asset_name_templates(&resolved, &mut warnings);
assert_eq!(
warnings,
vec![
"signs[0].asset_name_template is set but only binary_signs honors it (it will be ignored)".to_string(),
]
);
}
#[test]
fn no_asset_name_template_warns_nothing() {
let config = Config {
project_name: "test".to_string(),
signs: vec![anodizer_core::config::SignConfig::default()],
..Default::default()
};
let mut warnings: Vec<String> = vec![];
check_sign_asset_name_templates(&config, &mut warnings);
assert!(warnings.is_empty(), "{warnings:?}");
}
fn one_file_warning(first: &str, second: &str, field: &str) -> String {
format!(
"{first} and {second} resolve one {field} file for the artifacts both \
select — the second {field} overwrites the first, so one file ships \
where two were configured"
)
}
#[test]
fn two_binary_signs_entries_over_one_file_warn() {
use anodizer_core::config::SignConfig;
let config = Config {
binary_signs: vec![
SignConfig {
cmd: Some("cosign".to_string()),
..Default::default()
},
SignConfig {
cmd: Some("gpg".to_string()),
..Default::default()
},
],
..Default::default()
};
let mut warnings = Vec::new();
check_sign_duplicate_outputs(&config, &mut warnings);
assert_eq!(
warnings,
vec![one_file_warning(
"binary_signs[0]",
"binary_signs[1]",
"signature"
)]
);
}
#[test]
fn two_workspace_binary_signs_entries_over_one_file_warn() {
use anodizer_core::config::SignConfig;
let entry = |cmd: &str| SignConfig {
cmd: Some(cmd.to_string()),
..Default::default()
};
let config = Config {
workspaces: Some(vec![anodizer_core::config::WorkspaceConfig {
name: "ws".to_string(),
binary_signs: vec![entry("cosign"), entry("gpg")],
..Default::default()
}]),
..Default::default()
};
let mut warnings = Vec::new();
check_sign_duplicate_outputs(&config, &mut warnings);
assert_eq!(
warnings,
vec![one_file_warning(
"workspaces.ws.binary_signs[0]",
"workspaces.ws.binary_signs[1]",
"signature"
)]
);
}
#[test]
fn an_unset_signature_and_its_default_spelling_are_one_file() {
use anodizer_core::config::SignConfig;
let config = Config {
binary_signs: vec![
SignConfig {
cmd: Some("cosign".to_string()),
..Default::default()
},
SignConfig {
cmd: Some("gpg".to_string()),
signature: Some(SignConfig::DEFAULT_BINARY_SIGNATURE_TEMPLATE.to_string()),
..Default::default()
},
],
..Default::default()
};
let mut warnings = Vec::new();
check_sign_duplicate_outputs(&config, &mut warnings);
assert_eq!(
warnings,
vec![one_file_warning(
"binary_signs[0]",
"binary_signs[1]",
"signature"
)]
);
}
#[test]
fn two_binary_signs_entries_over_one_certificate_warn() {
use anodizer_core::config::SignConfig;
let entry = |cmd: &str| SignConfig {
cmd: Some(cmd.to_string()),
signature: Some(format!("{{{{ .Artifact }}}}.{cmd}.sig")),
certificate: Some("{{ .Artifact }}.pem".to_string()),
..Default::default()
};
let config = Config {
binary_signs: vec![entry("cosign"), entry("gpg")],
..Default::default()
};
let mut warnings = Vec::new();
check_sign_duplicate_outputs(&config, &mut warnings);
assert_eq!(
warnings,
vec![one_file_warning(
"binary_signs[0]",
"binary_signs[1]",
"certificate"
)]
);
}
#[test]
fn entries_that_write_no_detached_output_warn_nothing() {
use anodizer_core::config::SignConfig;
let cosign = || SignConfig {
cmd: Some("cosign".to_string()),
..Default::default()
};
for quiet in [
SignConfig {
authenticode: Some(anodizer_core::config::AuthenticodeConfig::default()),
..Default::default()
},
SignConfig {
artifacts: Some("none".to_string()),
..Default::default()
},
] {
let config = Config {
binary_signs: vec![cosign(), quiet],
..Default::default()
};
let mut warnings = Vec::new();
check_sign_duplicate_outputs(&config, &mut warnings);
assert!(warnings.is_empty(), "{warnings:?}");
}
}
#[test]
fn a_filtered_entry_does_not_renumber_the_labels_after_it() {
use anodizer_core::config::SignConfig;
let cosign = |cmd: &str| SignConfig {
cmd: Some(cmd.to_string()),
..Default::default()
};
let config = Config {
binary_signs: vec![
SignConfig {
artifacts: Some("none".to_string()),
..Default::default()
},
cosign("cosign"),
cosign("gpg"),
],
..Default::default()
};
let mut warnings = Vec::new();
check_sign_duplicate_outputs(&config, &mut warnings);
assert_eq!(
warnings,
vec![one_file_warning(
"binary_signs[1]",
"binary_signs[2]",
"signature"
)]
);
}
#[test]
fn entries_under_different_gates_warn_nothing() {
use anodizer_core::config::SignConfig;
let entry = |gate: &str| SignConfig {
cmd: Some("cosign".to_string()),
if_condition: Some(gate.to_string()),
..Default::default()
};
let ungated = SignConfig {
cmd: Some("cosign".to_string()),
..Default::default()
};
let warnings_for = |pair: Vec<SignConfig>| {
let config = Config {
binary_signs: pair,
..Default::default()
};
let mut warnings = Vec::new();
check_sign_duplicate_outputs(&config, &mut warnings);
warnings
};
assert!(
warnings_for(vec![
entry("{{ IsSnapshot }}"),
entry("{{ not IsSnapshot }}")
])
.is_empty()
);
assert_eq!(
warnings_for(vec![entry("{{ IsSnapshot }}"), entry("{{ IsSnapshot }}")]).len(),
1
);
assert_eq!(
warnings_for(vec![ungated.clone(), entry("{{ IsSnapshot }}")]).len(),
1
);
assert_eq!(
warnings_for(vec![entry("{{ IsSnapshot }}"), ungated]).len(),
1
);
assert_eq!(
warnings_for(vec![entry(""), entry("{{ IsSnapshot }}")]).len(),
1
);
assert_eq!(
warnings_for(vec![entry("{{ IsSnapshot }}"), entry("")]).len(),
1
);
}
#[test]
fn two_spellings_of_one_signature_path_warn() {
use anodizer_core::config::SignConfig;
let entry = |signature: &str| SignConfig {
cmd: Some("cosign".to_string()),
signature: Some(signature.to_string()),
..Default::default()
};
let config = Config {
binary_signs: vec![entry("dist/sigs/app.sig"), entry("./dist/sigs/app.sig")],
..Default::default()
};
let mut warnings = Vec::new();
check_sign_duplicate_outputs(&config, &mut warnings);
assert_eq!(
warnings,
vec![one_file_warning(
"binary_signs[0]",
"binary_signs[1]",
"signature"
)]
);
let templated = Config {
binary_signs: vec![
entry("{{ .Artifact }}.sig"),
entry("dist/{{ .Artifact }}.sig"),
],
..Default::default()
};
let mut none = Vec::new();
check_sign_duplicate_outputs(&templated, &mut none);
assert!(none.is_empty(), "{none:?}");
}
#[test]
fn a_signature_outside_dist_names_the_same_file_as_its_dist_spelling() {
use anodizer_core::config::SignConfig;
let entry = |signature: &str| SignConfig {
cmd: Some("cosign".to_string()),
signature: Some(signature.to_string()),
..Default::default()
};
let config = Config {
binary_signs: vec![entry("app.sig"), entry("dist/app.sig")],
..Default::default()
};
let mut warnings = Vec::new();
check_sign_duplicate_outputs(&config, &mut warnings);
assert_eq!(
warnings,
vec![one_file_warning(
"binary_signs[0]",
"binary_signs[1]",
"signature"
)]
);
let moved = Config {
dist: std::path::PathBuf::from("out"),
binary_signs: vec![entry("app.sig"), entry("dist/app.sig")],
..Default::default()
};
let mut none = Vec::new();
check_sign_duplicate_outputs(&moved, &mut none);
assert!(none.is_empty(), "{none:?}");
}
#[test]
fn two_spellings_of_one_templated_signature_path_warn() {
use anodizer_core::config::SignConfig;
let entry = |signature: &str| SignConfig {
cmd: Some("cosign".to_string()),
signature: Some(signature.to_string()),
..Default::default()
};
let config = Config {
binary_signs: vec![
entry("dist/sigs/{{ .Artifact }}.sig"),
entry("dist/./sigs/../sigs/{{ .Artifact }}.sig"),
],
..Default::default()
};
let mut warnings = Vec::new();
check_sign_duplicate_outputs(&config, &mut warnings);
assert_eq!(
warnings,
vec![one_file_warning(
"binary_signs[0]",
"binary_signs[1]",
"signature"
)]
);
let distinct = Config {
binary_signs: vec![
entry("dist/{{ .Artifact }}.sig"),
entry("./dist/{{ .Binary }}.sig"),
],
..Default::default()
};
let mut none = Vec::new();
check_sign_duplicate_outputs(&distinct, &mut none);
assert!(none.is_empty(), "{none:?}");
}
#[test]
fn a_templated_signature_outside_dist_names_its_dist_spelling() {
use anodizer_core::config::SignConfig;
let entry = |signature: &str| SignConfig {
cmd: Some("cosign".to_string()),
signature: Some(signature.to_string()),
..Default::default()
};
let config = Config {
binary_signs: vec![
entry("{{ ProjectName }}-{{ Version }}.sig"),
entry("dist/{{ ProjectName }}-{{ Version }}.sig"),
],
..Default::default()
};
let mut warnings = Vec::new();
check_sign_duplicate_outputs(&config, &mut warnings);
assert_eq!(
warnings,
vec![one_file_warning(
"binary_signs[0]",
"binary_signs[1]",
"signature"
)]
);
let distinct = Config {
binary_signs: vec![
entry("{{ ProjectName }}.sig"),
entry("dist/{{ Binary }}.sig"),
],
..Default::default()
};
let mut none = Vec::new();
check_sign_duplicate_outputs(&distinct, &mut none);
assert!(none.is_empty(), "{none:?}");
}
#[test]
fn placeholder_spacing_does_not_split_one_template_in_two() {
use anodizer_core::config::SignConfig;
let entry = |signature: &str| SignConfig {
cmd: Some("cosign".to_string()),
signature: Some(signature.to_string()),
..Default::default()
};
for pair in [
["dist/{{ Target }}.sig", "./dist/{{Target}}.sig"],
["{{ Version }}.sig", "dist/{{Version}}.sig"],
] {
let config = Config {
binary_signs: vec![entry(pair[0]), entry(pair[1])],
..Default::default()
};
let mut warnings = Vec::new();
check_sign_duplicate_outputs(&config, &mut warnings);
assert_eq!(
warnings,
vec![one_file_warning(
"binary_signs[0]",
"binary_signs[1]",
"signature"
)],
"{pair:?}"
);
}
}
#[test]
fn an_unterminated_placeholder_is_opaque_to_the_end() {
use anodizer_core::config::SignConfig;
let entry = |signature: &str| SignConfig {
cmd: Some("cosign".to_string()),
signature: Some(signature.to_string()),
..Default::default()
};
let config = Config {
binary_signs: vec![entry("dist/{{ Version.sig"), entry("./dist/{{ Version.sig")],
..Default::default()
};
let mut warnings = Vec::new();
check_sign_duplicate_outputs(&config, &mut warnings);
assert_eq!(
warnings,
vec![one_file_warning(
"binary_signs[0]",
"binary_signs[1]",
"signature"
)]
);
let climbing = Config {
binary_signs: vec![entry("dist/{{ Version/../app.sig"), entry("dist/app.sig")],
..Default::default()
};
let mut none = Vec::new();
check_sign_duplicate_outputs(&climbing, &mut none);
assert!(none.is_empty(), "{none:?}");
}
#[test]
fn a_spelling_that_renders_a_path_is_compared_without_the_dist_join() {
use anodizer_core::config::SignConfig;
let entry = |signature: &str| SignConfig {
cmd: Some("cosign".to_string()),
signature: Some(signature.to_string()),
..Default::default()
};
for pair in [
["${artifact}.sig", "dist/${artifact}.sig"],
["$artifact.sig", "dist/$artifact.sig"],
["${signature}.asc", "dist/${signature}.asc"],
["$signature.asc", "dist/$signature.asc"],
["${certificate}.pem", "dist/${certificate}.pem"],
["$certificate.pem", "dist/$certificate.pem"],
[
"{{ .Env.SIG_DIR }}/app.sig",
"dist/{{ .Env.SIG_DIR }}/app.sig",
],
[
"{{ Var.sig_dir }}/app.sig",
"dist/{{ Var.sig_dir }}/app.sig",
],
[
"{{ .Artifact }}-{{ Version }}.sig",
"dist/{{ .Artifact }}-{{ Version }}.sig",
],
] {
let config = Config {
binary_signs: vec![entry(pair[0]), entry(pair[1])],
..Default::default()
};
let mut warnings = Vec::new();
check_sign_duplicate_outputs(&config, &mut warnings);
assert!(warnings.is_empty(), "{pair:?}: {warnings:?}");
}
for pair in [
["$artifactName.sig", "dist/$artifactName.sig"],
["$artifactID.sig", "dist/$artifactID.sig"],
] {
let config = Config {
binary_signs: vec![entry(pair[0]), entry(pair[1])],
..Default::default()
};
let mut warnings = Vec::new();
check_sign_duplicate_outputs(&config, &mut warnings);
assert_eq!(
warnings,
vec![one_file_warning(
"binary_signs[0]",
"binary_signs[1]",
"signature"
)],
"{pair:?}"
);
}
let bounded = Config {
binary_signs: vec![
entry("{{ ProjectName }}-{{ Version }}.sig"),
entry("dist/{{ ProjectName }}-{{ Version }}.sig"),
],
..Default::default()
};
let mut warnings = Vec::new();
check_sign_duplicate_outputs(&bounded, &mut warnings);
assert_eq!(
warnings,
vec![one_file_warning(
"binary_signs[0]",
"binary_signs[1]",
"signature"
)]
);
}
#[test]
fn a_placeholder_closed_by_a_quoted_brace_leaves_its_tail_unmasked() {
use anodizer_core::config::SignConfig;
let entry = |signature: &str| SignConfig {
cmd: Some("cosign".to_string()),
signature: Some(signature.to_string()),
..Default::default()
};
let config = Config {
binary_signs: vec![
entry(r#"dist/{{ printf "}}" }}/sigs/app.sig"#),
entry(r#"./dist/{{ printf "}}" }}/sigs/../sigs/app.sig"#),
],
..Default::default()
};
let mut warnings = Vec::new();
check_sign_duplicate_outputs(&config, &mut warnings);
assert_eq!(
warnings,
vec![one_file_warning(
"binary_signs[0]",
"binary_signs[1]",
"signature"
)]
);
}
#[test]
fn two_signs_entries_resolving_one_file_warn() {
use anodizer_core::config::SignConfig;
let entry = |signature: &str| SignConfig {
cmd: Some("cosign".to_string()),
artifacts: Some("all".to_string()),
signature: Some(signature.to_string()),
..Default::default()
};
let config = Config {
signs: vec![entry("app.sig"), entry("dist/app.sig")],
..Default::default()
};
let mut warnings = Vec::new();
check_sign_duplicate_outputs(&config, &mut warnings);
assert_eq!(
warnings,
vec![one_file_warning("signs[0]", "signs[1]", "signature")]
);
}
#[test]
fn sign_entries_selecting_disjoint_artifact_kinds_warn_nothing() {
use anodizer_core::config::SignConfig;
let entry = |artifacts: Option<&str>, cmd: &str| SignConfig {
artifacts: artifacts.map(str::to_string),
cmd: Some(cmd.to_string()),
args: Some(vec!["${signature}".to_string(), "${artifact}".to_string()]),
..Default::default()
};
for pair in [
vec![
entry(Some("archive"), "gpg"),
entry(Some("checksum"), "cosign"),
],
vec![entry(None, "gpg"), entry(None, "cosign")],
] {
let config = Config {
signs: pair,
..Default::default()
};
let mut warnings = Vec::new();
check_sign_duplicate_outputs(&config, &mut warnings);
assert!(warnings.is_empty(), "{warnings:?}");
}
let both = Config {
signs: vec![entry(Some("all"), "gpg"), entry(Some("all"), "cosign")],
..Default::default()
};
let mut warnings = Vec::new();
check_sign_duplicate_outputs(&both, &mut warnings);
assert_eq!(
warnings,
vec![one_file_warning("signs[0]", "signs[1]", "signature")]
);
}
#[test]
fn the_artifacts_term_reads_each_slices_own_default() {
use anodizer_core::config::SignConfig;
let entry = |cmd: &str| SignConfig {
cmd: Some(cmd.to_string()),
..Default::default()
};
let config = Config {
binary_signs: vec![entry("cosign"), entry("gpg")],
..Default::default()
};
let mut warnings = Vec::new();
check_sign_duplicate_outputs(&config, &mut warnings);
assert_eq!(
warnings,
vec![one_file_warning(
"binary_signs[0]",
"binary_signs[1]",
"signature"
)]
);
let windows = Config {
signs: vec![
SignConfig {
artifacts: Some("windows".to_string()),
..entry("osslsigncode")
},
SignConfig {
artifacts: Some("binary".to_string()),
..entry("gpg")
},
],
..Default::default()
};
let mut warnings = Vec::new();
check_sign_duplicate_outputs(&windows, &mut warnings);
assert_eq!(
warnings,
vec![one_file_warning("signs[0]", "signs[1]", "signature")]
);
}
#[test]
fn a_mis_padded_literal_placeholder_warns() {
use anodizer_core::config::SignConfig;
for spelling in [
"{{.Artifact}}",
"{{Artifact}}",
"{{ .Artifact}}",
"{{.Artifact }}",
"{{ .Artifact }}",
"{{ Artifact}}",
] {
let config = Config {
binary_signs: vec![SignConfig {
signature: Some(format!("{spelling}.sig")),
..Default::default()
}],
..Default::default()
};
let mut warnings = Vec::new();
check_unpadded_sign_placeholders(&config, &mut warnings);
assert_eq!(
warnings,
vec![format!(
"binary_signs[0].signature names `{spelling}`, which anodizer \
substitutes only as the literal `{{{{ .Artifact }}}}` or \
`{{{{ Artifact }}}}` — every other spelling reaches the \
template engine as an undefined variable and fails the sign \
stage"
)]
);
}
for spelling in ["{{ .Artifact }}", "{{ Artifact }}"] {
let config = Config {
binary_signs: vec![SignConfig {
signature: Some(format!("{spelling}.sig")),
..Default::default()
}],
..Default::default()
};
let mut warnings = Vec::new();
check_unpadded_sign_placeholders(&config, &mut warnings);
assert!(warnings.is_empty(), "{spelling}: {warnings:?}");
}
}
#[test]
fn a_placeholder_the_field_never_substitutes_warns_in_every_padding() {
use anodizer_core::config::SignConfig;
let derived = |field: &str, shell: &str| {
format!(
"; the {field} path is what this template renders, so \
`${{{shell}}}` has no value here either — remove the reference"
)
};
let sibling = |shell: &str| {
format!("; write `${{{shell}}}`, which the sign stage expands after the render")
};
for (field, spelling, remedy) in [
(
"signature",
"{{ .Signature }}",
derived("signature", "signature"),
),
(
"signature",
"{{.Signature}}",
derived("signature", "signature"),
),
(
"certificate",
"{{ Certificate }}",
derived("certificate", "certificate"),
),
("signature", "{{ Certificate }}", sibling("certificate")),
("certificate", "{{ .Signature }}", sibling("signature")),
] {
let mut cfg = SignConfig::default();
match field {
"signature" => cfg.signature = Some(format!("{spelling}.x")),
_ => cfg.certificate = Some(format!("{spelling}.x")),
}
let config = Config {
binary_signs: vec![cfg],
..Default::default()
};
let mut warnings = Vec::new();
check_unpadded_sign_placeholders(&config, &mut warnings);
assert_eq!(
warnings,
vec![format!(
"binary_signs[0].{field} names `{spelling}`, which anodizer \
does not substitute in {field}: — it reaches the template \
engine as an undefined variable and fails the sign \
stage{remedy}"
)],
"{spelling} in {field}"
);
}
}
#[test]
fn a_placeholder_inside_an_expression_warns() {
use anodizer_core::config::SignConfig;
for spelling in [
"{{ Artifact | upper }}",
"{{ Artifact.path }}",
"{{ .Artifact | default(value=\"x\") }}",
] {
let config = Config {
binary_signs: vec![SignConfig {
signature: Some(format!("{spelling}.sig")),
..Default::default()
}],
..Default::default()
};
let mut warnings = Vec::new();
check_unpadded_sign_placeholders(&config, &mut warnings);
assert_eq!(
warnings,
vec![format!(
"binary_signs[0].signature names `{spelling}`, which anodizer \
substitutes only as the literal `{{{{ .Artifact }}}}` or \
`{{{{ Artifact }}}}` — every other spelling reaches the \
template engine as an undefined variable and fails the sign \
stage"
)]
);
}
for spelling in [
"{{ ArtifactName }}",
"{{ my_Artifact }}",
"{{ my_artifact }}",
"{{ \"Artifact\" }}",
"{{ Version | replace(from=\"Artifact\", to=\"x\") }}",
"{{ ['Artifact'] | join(sep=\"-\") }}",
] {
let config = Config {
binary_signs: vec![SignConfig {
signature: Some(format!("{spelling}.sig")),
..Default::default()
}],
..Default::default()
};
let mut warnings = Vec::new();
check_unpadded_sign_placeholders(&config, &mut warnings);
assert!(warnings.is_empty(), "{spelling}: {warnings:?}");
}
}
#[test]
fn a_placeholder_inside_a_statement_block_warns() {
use anodizer_core::config::SignConfig;
let spelling = "{% set x = Artifact %}";
let config = Config {
binary_signs: vec![SignConfig {
signature: Some(format!("{spelling}out.sig")),
..Default::default()
}],
..Default::default()
};
let mut warnings = Vec::new();
check_unpadded_sign_placeholders(&config, &mut warnings);
assert_eq!(
warnings,
vec![format!(
"binary_signs[0].signature names `{spelling}`, which anodizer \
substitutes only as the literal `{{{{ .Artifact }}}}` or \
`{{{{ Artifact }}}}` — every other spelling reaches the template \
engine as an undefined variable and fails the sign stage"
)]
);
}
#[test]
fn an_unclosed_run_does_not_hide_the_runs_after_it() {
use anodizer_core::config::SignConfig;
let warnings_for = |template: &str| {
let config = Config {
binary_signs: vec![SignConfig {
signature: Some(template.to_string()),
..Default::default()
}],
..Default::default()
};
let mut warnings = Vec::new();
check_unpadded_sign_placeholders(&config, &mut warnings);
warnings
};
assert!(warnings_for("{{ Artifact | upper").is_empty());
assert!(warnings_for("out.sig {{ .Artifact").is_empty());
assert_eq!(warnings_for("{{ oops {{ Artifact }}.sig").len(), 1);
assert!(warnings_for("{{ .Artifact }}{{ Artifact }}.sig").is_empty());
assert_eq!(
warnings_for("{{ oops {% set x = Artifact %}"),
vec![
"binary_signs[0].signature names `{% set x = Artifact %}`, which \
anodizer substitutes only as the literal `{{ .Artifact }}` or \
`{{ Artifact }}` — every other spelling reaches the template \
engine as an undefined variable and fails the sign stage"
.to_string()
]
);
assert_eq!(
warnings_for("{% oops {{ Artifact | upper }}"),
vec![
"binary_signs[0].signature names `{{ Artifact | upper }}`, which \
anodizer substitutes only as the literal `{{ .Artifact }}` or \
`{{ Artifact }}` — every other spelling reaches the template \
engine as an undefined variable and fails the sign stage"
.to_string()
]
);
assert_eq!(warnings_for("{# oops {{ Artifact | upper }}").len(), 1);
}
#[test]
fn a_name_inside_any_tera_string_literal_warns_nothing() {
use anodizer_core::config::SignConfig;
for spelling in [
"{{ `Artifact` }}",
"{{ \"he said \\\"Artifact\\\"\" }}",
"{{ Version | replace(from=`Artifact`, to=\"x\") }}",
] {
let config = Config {
binary_signs: vec![SignConfig {
signature: Some(format!("{spelling}.sig")),
..Default::default()
}],
..Default::default()
};
let mut warnings = Vec::new();
check_unpadded_sign_placeholders(&config, &mut warnings);
assert!(warnings.is_empty(), "{spelling}: {warnings:?}");
}
let config = Config {
binary_signs: vec![SignConfig {
signature: Some("{{ `x` }}{{ Artifact | upper }}.sig".to_string()),
..Default::default()
}],
..Default::default()
};
let mut warnings = Vec::new();
check_unpadded_sign_placeholders(&config, &mut warnings);
assert_eq!(warnings.len(), 1, "{warnings:?}");
}
#[test]
fn a_placeholder_inside_a_tera_comment_warns_nothing() {
use anodizer_core::config::SignConfig;
let config = Config {
binary_signs: vec![SignConfig {
signature: Some("{# {{.Artifact}} #}out.sig".to_string()),
certificate: Some("{# note #}{{.Artifact}}.pem".to_string()),
..Default::default()
}],
..Default::default()
};
let mut warnings = Vec::new();
check_unpadded_sign_placeholders(&config, &mut warnings);
assert_eq!(
warnings,
vec![
"binary_signs[0].certificate names `{{.Artifact}}`, which anodizer \
substitutes only as the literal `{{ .Artifact }}` or \
`{{ Artifact }}` — every other spelling reaches the template \
engine as an undefined variable and fails the sign stage"
.to_string(),
]
);
}
#[test]
fn an_args_template_substitutes_every_placeholder_name() {
use anodizer_core::config::SignConfig;
let config = Config {
binary_signs: vec![SignConfig {
args: Some(vec![
"{{ .Signature }}".to_string(),
"{{ Certificate }}".to_string(),
"{{.Signature}}".to_string(),
]),
..Default::default()
}],
..Default::default()
};
let mut warnings = Vec::new();
check_unpadded_sign_placeholders(&config, &mut warnings);
assert_eq!(
warnings,
vec![
"binary_signs[0].args names `{{.Signature}}`, which anodizer \
substitutes only as the literal `{{ .Signature }}` or \
`{{ Signature }}` — every other spelling reaches the template \
engine as an undefined variable and fails the sign stage"
.to_string(),
]
);
}
#[test]
fn a_stdin_template_substitutes_no_placeholder() {
use anodizer_core::config::{DockerSignConfig, SignConfig};
let config = Config {
binary_signs: vec![SignConfig {
stdin: Some("{{ .Artifact }}".to_string()),
..Default::default()
}],
docker_signs: Some(vec![DockerSignConfig {
stdin: Some("{{ Signature }}".to_string()),
..Default::default()
}]),
..Default::default()
};
let mut warnings = Vec::new();
check_unpadded_sign_placeholders(&config, &mut warnings);
assert_eq!(
warnings,
vec![
"binary_signs[0].stdin names `{{ .Artifact }}`, which anodizer does \
not substitute in stdin: — it reaches the template engine as an \
undefined variable and fails the sign stage; write `${artifact}`, \
which the sign stage expands after the render"
.to_string(),
"docker_signs[0].stdin names `{{ Signature }}`, which anodizer does \
not substitute in stdin: — it reaches the template engine as an \
undefined variable and fails the sign stage"
.to_string(),
]
);
}
#[test]
fn a_docker_args_placeholder_warns_on_its_padding() {
use anodizer_core::config::DockerSignConfig;
let config = Config {
docker_signs: Some(vec![DockerSignConfig {
args: Some(vec!["{{.Certificate}}".to_string()]),
..Default::default()
}]),
..Default::default()
};
let mut warnings = Vec::new();
check_unpadded_sign_placeholders(&config, &mut warnings);
assert_eq!(
warnings,
vec![
"docker_signs[0].args names `{{.Certificate}}`, which anodizer \
substitutes only as the literal `{{ .Certificate }}` or \
`{{ Certificate }}` — every other spelling reaches the template \
engine as an undefined variable and fails the sign stage"
.to_string(),
]
);
}
#[test]
fn a_defaults_filled_sign_slice_is_named_as_the_defaults_block() {
use anodizer_core::config::{Defaults, DockerSignConfig, SignConfig};
let mut config = Config {
project_name: "test".to_string(),
defaults: Some(Defaults {
sign: Some(SignConfig {
args: Some(vec!["{{.Artifact}}".to_string()]),
artifacts: Some("bogus".to_string()),
..Default::default()
}),
binary_signs: Some(SignConfig {
stdin: Some("{{ Signature }}".to_string()),
..Default::default()
}),
docker_signs: Some(DockerSignConfig {
args: Some(vec!["{{Artifact}}".to_string()]),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
anodizer_core::defaults_merge::apply_defaults(&mut config);
let mut warnings = Vec::new();
check_unpadded_sign_placeholders(&config, &mut warnings);
assert_eq!(
warnings,
vec![
"defaults.sign.args names `{{.Artifact}}`, which anodizer \
substitutes only as the literal `{{ .Artifact }}` or \
`{{ Artifact }}` — every other spelling reaches the template \
engine as an undefined variable and fails the sign stage"
.to_string(),
"defaults.binary_signs.stdin names `{{ Signature }}`, which \
anodizer does not substitute in stdin: — it reaches the template \
engine as an undefined variable and fails the sign stage; write \
`${signature}`, which the sign stage expands after the render"
.to_string(),
"defaults.docker_signs.args names `{{Artifact}}`, which anodizer \
substitutes only as the literal `{{ .Artifact }}` or \
`{{ Artifact }}` — every other spelling reaches the template \
engine as an undefined variable and fails the sign stage"
.to_string(),
]
);
let mut warnings = Vec::new();
check_sign_artifact_filters(&config, &mut warnings);
assert_eq!(
warnings,
vec![format!(
"unrecognized defaults.sign artifacts filter 'bogus' (valid: {})",
anodizer_stage_sign::VALID_SIGN_ARTIFACT_FILTERS.join(", ")
)]
);
}
fn message_literals(src: &str) -> Vec<String> {
let chars: Vec<char> = src.chars().collect();
let mut out = Vec::new();
let mut i = 0;
while i < chars.len() {
if chars[i] == '/' && chars.get(i + 1) == Some(&'/') {
while i < chars.len() && chars[i] != '\n' {
i += 1;
}
continue;
}
if chars[i] == 'r' {
let mut j = i + 1;
while chars.get(j) == Some(&'#') {
j += 1;
}
if chars.get(j) == Some(&'"') {
let hashes = j - i - 1;
let mut k = j + 1;
loop {
match chars.get(k) {
None => break,
Some('"') if chars[k + 1..k + 1 + hashes].iter().all(|c| *c == '#') => {
break;
}
Some(_) => k += 1,
}
}
out.push(chars[j + 1..k.min(chars.len())].iter().collect());
i = (k + 1 + hashes).min(chars.len());
continue;
}
}
if chars[i] != '"' {
i += 1;
continue;
}
i += 1;
let mut literal = String::new();
while i < chars.len() && chars[i] != '"' {
if chars[i] == '\\' {
i += 1;
match chars.get(i) {
Some('\n') => {
i += 1;
while matches!(chars.get(i), Some(' ') | Some('\t')) {
i += 1;
}
}
Some(_) => i += 1,
None => break,
}
continue;
}
literal.push(chars[i]);
i += 1;
}
i += 1;
out.push(literal);
}
out
}
const MESSAGE_BUILDING_BODIES: usize = 31;
#[test]
fn no_check_config_message_carries_a_run_of_spaces() {
use anodizer_core::test_helpers::test_sources::{
function_bodies, production_half, rust_sources,
};
let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/src/commands/check/config");
let sources = rust_sources(std::path::Path::new(dir));
assert_eq!(
sources.len(),
5,
"the production sources under {dir} the walk found: {sources:?}"
);
let building: Vec<String> = sources
.iter()
.flat_map(|path| {
let src =
std::fs::read_to_string(path).unwrap_or_else(|e| panic!("read {path:?}: {e}"));
function_bodies(production_half(&src))
})
.filter(|body| body.contains("format!("))
.collect();
assert_eq!(
building.len(),
MESSAGE_BUILDING_BODIES,
"the message-building bodies the walk found under {dir}"
);
let offenders: Vec<String> = building
.iter()
.flat_map(|body| message_literals(body))
.filter(|literal| literal.contains(" "))
.collect();
assert!(
offenders.is_empty(),
"a message literal carries a run of three or more spaces, so its line \
continuation is missing and the warning prints this file's own \
indentation: {offenders:?}"
);
}
#[test]
fn an_unrecognized_filter_warns_on_every_sign_slice() {
let yaml = r#"
project_name: test
signs:
- artifacts: bogus
workspaces:
- name: ws
crates:
- name: app
signs:
- artifacts: bogus
"#;
let mut config: Config = serde_yaml_ng::from_str(yaml).expect("the loader accepts it");
anodizer_core::defaults_merge::apply_defaults(&mut config);
let mut warnings = Vec::new();
check_sign_artifact_filters(&config, &mut warnings);
let valid = anodizer_stage_sign::VALID_SIGN_ARTIFACT_FILTERS.join(", ");
assert_eq!(
warnings,
vec![
format!("unrecognized signs[0] artifacts filter 'bogus' (valid: {valid})"),
format!(
"unrecognized workspaces.ws.signs[0] artifacts filter 'bogus' (valid: {valid})"
),
]
);
}
#[test]
fn a_wide_filter_under_defaults_binary_signs_warns() {
let yaml = r#"
project_name: test
defaults:
binary_signs:
artifacts: archive
cmd: cosign
"#;
let mut config: Config = serde_yaml_ng::from_str(yaml).expect("the loader accepts it");
anodizer_core::defaults_merge::apply_defaults(&mut config);
let mut warnings = Vec::new();
check_sign_artifact_filters(&config, &mut warnings);
assert_eq!(
warnings,
vec![
"defaults.binary_signs artifacts filter 'archive' is not allowed \
on binary_signs (valid: binary, none) — the sign stage signs \
binaries whatever it says"
.to_string(),
]
);
for filter in ["binary", "none"] {
let mut config: Config = serde_yaml_ng::from_str(&format!(
"project_name: test\ndefaults:\n binary_signs:\n artifacts: {filter}\n"
))
.expect("the loader accepts it");
anodizer_core::defaults_merge::apply_defaults(&mut config);
let mut warnings = Vec::new();
check_sign_artifact_filters(&config, &mut warnings);
assert!(warnings.is_empty(), "{filter}: {warnings:?}");
}
}
#[test]
fn a_docker_shell_variable_warns_that_it_is_never_expanded() {
use anodizer_core::config::DockerSignConfig;
let config = Config {
docker_signs: Some(vec![DockerSignConfig {
args: Some(vec![
"sign".to_string(),
"${artifact}".to_string(),
"--certificate=$certificate".to_string(),
]),
stdin: Some("${signature}".to_string()),
..Default::default()
}]),
..Default::default()
};
let mut warnings = Vec::new();
check_unpadded_sign_placeholders(&config, &mut warnings);
assert_eq!(
warnings,
vec![
"docker_signs[0].args names `${artifact}`, which the docker sign \
path never expands — it reaches the signing command as that \
literal text; write `{{ .Artifact }}`, which anodizer substitutes \
before the render"
.to_string(),
"docker_signs[0].args names `${certificate}`, which the docker \
sign path never expands — it reaches the signing command as that \
literal text; a docker certificate path is read nowhere, so \
remove the reference"
.to_string(),
"docker_signs[0].stdin names `${signature}`, which the docker sign \
path never expands — it reaches the signing command as that \
literal text"
.to_string(),
]
);
let config = Config {
docker_signs: Some(vec![DockerSignConfig {
args: Some(vec![
"{{ .Artifact }}@{{ .Digest }}".to_string(),
"{{ Signature }}".to_string(),
]),
..Default::default()
}]),
..Default::default()
};
let mut warnings = Vec::new();
check_unpadded_sign_placeholders(&config, &mut warnings);
assert!(warnings.is_empty(), "{warnings:?}");
}
#[test]
fn a_docker_digest_shell_variable_warns_with_the_template_spelling() {
use anodizer_core::config::DockerSignConfig;
let config = Config {
docker_signs: Some(vec![DockerSignConfig {
args: Some(vec![
"sign".to_string(),
"${artifact}@${digest}".to_string(),
"--annotation=id=$artifactID".to_string(),
]),
stdin: Some("${digest}".to_string()),
..Default::default()
}]),
..Default::default()
};
let mut warnings = Vec::new();
check_unpadded_sign_placeholders(&config, &mut warnings);
assert_eq!(
warnings,
vec![
"docker_signs[0].args names `${artifact}`, which the docker sign \
path never expands — it reaches the signing command as that \
literal text; write `{{ .Artifact }}`, which anodizer substitutes \
before the render"
.to_string(),
"docker_signs[0].args names `${digest}`, which the docker sign \
path never expands — it reaches the signing command as that \
literal text; write `{{ .Digest }}`, which the docker sign path \
renders from the image"
.to_string(),
"docker_signs[0].args names `${artifactID}`, which the docker sign \
path never expands — it reaches the signing command as that \
literal text; write `{{ .ArtifactID }}`, which the docker sign \
path renders from the image"
.to_string(),
"docker_signs[0].stdin names `${digest}`, which the docker sign \
path never expands — it reaches the signing command as that \
literal text; write `{{ .Digest }}`, which the docker sign path \
renders from the image"
.to_string(),
]
);
let config = Config {
docker_signs: Some(vec![DockerSignConfig {
args: Some(vec!["{{ .Artifact }}@{{ .Digest }}".to_string()]),
stdin: Some("{{ ArtifactID }}".to_string()),
..Default::default()
}]),
..Default::default()
};
let mut warnings = Vec::new();
check_unpadded_sign_placeholders(&config, &mut warnings);
assert!(warnings.is_empty(), "{warnings:?}");
}
#[test]
fn a_docker_certificate_placeholder_warns_that_it_renders_empty() {
use anodizer_core::config::DockerSignConfig;
let config = Config {
docker_signs: Some(vec![DockerSignConfig {
certificate: Some("cert.pem".to_string()),
args: Some(vec!["--certificate={{ .Certificate }}".to_string()]),
..Default::default()
}]),
..Default::default()
};
let mut warnings = Vec::new();
check_unpadded_sign_placeholders(&config, &mut warnings);
assert_eq!(
warnings,
vec![
"docker_signs[0].args names `{{ .Certificate }}`, which anodizer \
substitutes with the empty string on the docker path — a docker \
certificate path is read nowhere, so the argument reaches the \
signing command with no value"
.to_string(),
]
);
}
#[test]
fn a_docker_sign_signature_template_warns_that_it_is_ignored() {
use anodizer_core::config::DockerSignConfig;
let config = Config {
docker_signs: Some(vec![
DockerSignConfig {
signature: Some("{{ .Artifact }}.sig".to_string()),
..Default::default()
},
DockerSignConfig::default(),
]),
..Default::default()
};
let mut warnings = Vec::new();
check_docker_sign_signature_templates(&config, &mut warnings);
assert_eq!(
warnings,
vec![
"docker_signs[0].signature is set but a docker signature is stored \
in the registry rather than written to a file (it will be ignored)"
.to_string(),
]
);
}
#[test]
fn binary_signs_entries_that_write_two_files_warn_nothing() {
use anodizer_core::config::SignConfig;
let entry = |ids: Option<Vec<String>>, signature: &str| SignConfig {
cmd: Some("cosign".to_string()),
ids,
signature: Some(signature.to_string()),
..Default::default()
};
for pair in [
vec![
entry(None, "{{ .Artifact }}.sig"),
entry(None, "{{ .Artifact }}.bundle.sig"),
],
vec![
entry(Some(vec!["app".to_string()]), "{{ .Artifact }}.sig"),
entry(Some(vec!["helper".to_string()]), "{{ .Artifact }}.sig"),
],
] {
let config = Config {
binary_signs: pair,
..Default::default()
};
let mut warnings = Vec::new();
check_sign_duplicate_outputs(&config, &mut warnings);
assert!(warnings.is_empty(), "{warnings:?}");
}
}
#[test]
fn target_triple_warns_on_unrecognized_in_defaults() {
use anodizer_core::config::Defaults;
let mut config = make_config(vec![make_crate("a", "a-v{{ .Version }}", None)]);
config.defaults = Some(Defaults {
targets: Some(vec![
"x86_64-unknown-linux-gnu".to_string(), "sparc-sun-solaris".to_string(), ]),
..Default::default()
});
let mut warnings: Vec<String> = vec![];
check_target_triples(&config, &mut warnings);
assert_eq!(warnings.len(), 1, "only the bad triple warns: {warnings:?}");
assert!(
warnings[0].contains("sparc-sun-solaris") && warnings[0].contains("defaults.targets"),
"warning should name the triple and its context: {:?}",
warnings
);
}
#[test]
fn target_triple_warns_on_unrecognized_in_crate_build() {
use anodizer_core::config::BuildConfig;
let mut crate_cfg = make_crate("mycrate", "v{{ .Version }}", None);
crate_cfg.builds = Some(vec![BuildConfig {
binary: Some("mybin".to_string()),
targets: Some(vec!["not-a-real-triple".to_string()]),
..Default::default()
}]);
let config = make_config(vec![crate_cfg]);
let mut warnings: Vec<String> = vec![];
check_target_triples(&config, &mut warnings);
assert_eq!(warnings.len(), 1, "got: {warnings:?}");
assert!(
warnings[0].contains("not-a-real-triple")
&& warnings[0].contains("crate 'mycrate'")
&& warnings[0].contains("build 'mybin'"),
"warning should name the triple, crate, and build binary: {:?}",
warnings
);
}
#[test]
fn target_triple_silent_on_known_triples() {
use anodizer_core::config::Defaults;
let mut config = make_config(vec![make_crate("a", "a-v{{ .Version }}", None)]);
config.defaults = Some(Defaults {
targets: Some(vec![
"aarch64-apple-darwin".to_string(),
"x86_64-pc-windows-msvc".to_string(),
]),
..Default::default()
});
let mut warnings: Vec<String> = vec![];
check_target_triples(&config, &mut warnings);
assert!(
warnings.is_empty(),
"known triples must not warn: {warnings:?}"
);
}
#[test]
fn changelog_use_warns_on_unrecognized_value() {
use anodizer_core::config::ChangelogConfig;
let mut config = make_config(vec![make_crate("a", "a-v{{ .Version }}", None)]);
config.changelog = Some(ChangelogConfig {
use_source: Some("mercurial".to_string()),
..Default::default()
});
let mut warnings: Vec<String> = vec![];
check_changelog(&config, &mut warnings);
assert_eq!(warnings.len(), 1, "got: {warnings:?}");
assert!(
warnings[0].contains("mercurial") && warnings[0].contains("git, github-native"),
"warning should name the bad value and valid set: {:?}",
warnings
);
}
#[test]
fn changelog_use_silent_on_github_native() {
use anodizer_core::config::ChangelogConfig;
let mut config = make_config(vec![make_crate("a", "a-v{{ .Version }}", None)]);
config.changelog = Some(ChangelogConfig {
use_source: Some("github-native".to_string()),
..Default::default()
});
let mut warnings: Vec<String> = vec![];
check_changelog(&config, &mut warnings);
assert!(warnings.is_empty(), "github-native is valid: {warnings:?}");
}
#[test]
fn checksum_algorithm_warns_on_unrecognized_in_defaults() {
use anodizer_core::config::{ChecksumConfig, Defaults};
let mut config = make_config(vec![make_crate("a", "a-v{{ .Version }}", None)]);
config.defaults = Some(Defaults {
checksum: Some(ChecksumConfig {
algorithm: Some("crc32".to_string()),
..Default::default()
}),
..Default::default()
});
let mut warnings: Vec<String> = vec![];
check_checksum_algorithms(&config, &mut warnings);
assert_eq!(warnings.len(), 1, "got: {warnings:?}");
assert!(
warnings[0].contains("crc32") && warnings[0].contains("defaults.checksum"),
"warning should name the algorithm and context: {:?}",
warnings
);
}
#[test]
fn checksum_algorithm_warns_on_unrecognized_per_crate() {
use anodizer_core::config::ChecksumConfig;
let mut crate_cfg = make_crate("mycrate", "v{{ .Version }}", None);
crate_cfg.checksum = Some(ChecksumConfig {
algorithm: Some("md5".to_string()),
..Default::default()
});
let config = make_config(vec![crate_cfg]);
let mut warnings: Vec<String> = vec![];
check_checksum_algorithms(&config, &mut warnings);
assert_eq!(warnings.len(), 1, "got: {warnings:?}");
assert!(
warnings[0].contains("md5") && warnings[0].contains("mycrate"),
"warning should name the algorithm and crate: {:?}",
warnings
);
}
#[test]
fn checksum_algorithm_silent_on_known_algorithm() {
use anodizer_core::config::{ChecksumConfig, Defaults};
let mut config = make_config(vec![make_crate("a", "a-v{{ .Version }}", None)]);
config.defaults = Some(Defaults {
checksum: Some(ChecksumConfig {
algorithm: Some("blake2b".to_string()),
..Default::default()
}),
..Default::default()
});
let mut warnings: Vec<String> = vec![];
check_checksum_algorithms(&config, &mut warnings);
assert!(warnings.is_empty(), "blake2b is valid: {warnings:?}");
}
#[test]
fn sbom_artifacts_errors_on_unrecognized_value() {
use anodizer_core::config::SbomConfig;
let mut config = make_config(vec![make_crate("a", "a-v{{ .Version }}", None)]);
config.sboms = vec![SbomConfig {
id: Some("main".to_string()),
artifacts: Some("everything".to_string()),
..Default::default()
}];
let mut errors: Vec<String> = vec![];
check_sbom_configs(&config, &mut errors);
assert_eq!(errors.len(), 1, "got: {errors:?}");
assert!(
errors[0].contains("everything") && errors[0].contains("main"),
"error should name the value and the sbom label: {:?}",
errors
);
}
#[test]
fn sbom_artifacts_silent_on_known_value() {
use anodizer_core::config::SbomConfig;
let mut config = make_config(vec![make_crate("a", "a-v{{ .Version }}", None)]);
config.sboms = vec![SbomConfig {
artifacts: Some("binary".to_string()),
..Default::default()
}];
let mut errors: Vec<String> = vec![];
check_sbom_configs(&config, &mut errors);
assert!(
errors.is_empty(),
"'binary' is a valid artifacts type: {errors:?}"
);
}
#[test]
fn announce_secret_warns_across_all_remaining_channels() {
use anodizer_core::config::{
DiscordAnnounce, LinkedInAnnounce, MastodonAnnounce, MattermostAnnounce,
OpenCollectiveAnnounce, RedditAnnounce, TeamsAnnounce, TelegramAnnounce, WebhookConfig,
};
let warnings = collect_announce_warnings(AnnounceConfig {
linkedin: Some(LinkedInAnnounce {
message_template: Some("{{ Env.LINKEDIN_TOKEN }}".to_string()),
..Default::default()
}),
opencollective: Some(OpenCollectiveAnnounce {
title_template: Some("{{ Env.OC_API_KEY }}".to_string()),
message_template: Some("{{ Env.OC_SECRET }}".to_string()),
..Default::default()
}),
mastodon: Some(MastodonAnnounce {
message_template: Some("{{ Env.MASTODON_TOKEN }}".to_string()),
..Default::default()
}),
discord: Some(DiscordAnnounce {
message_template: Some("{{ Env.DISCORD_TOKEN }}".to_string()),
author: Some("{{ Env.DISCORD_SECRET }}".to_string()),
..Default::default()
}),
webhook: Some(WebhookConfig {
message_template: Some("{{ Env.WEBHOOK_TOKEN }}".to_string()),
..Default::default()
}),
telegram: Some(TelegramAnnounce {
message_template: Some("{{ Env.TELEGRAM_TOKEN }}".to_string()),
..Default::default()
}),
teams: Some(TeamsAnnounce {
message_template: Some("{{ Env.TEAMS_TOKEN }}".to_string()),
title_template: Some("{{ Env.TEAMS_SECRET }}".to_string()),
..Default::default()
}),
mattermost: Some(MattermostAnnounce {
message_template: Some("{{ Env.MM_TOKEN }}".to_string()),
title_template: Some("{{ Env.MM_SECRET }}".to_string()),
..Default::default()
}),
reddit: Some(RedditAnnounce {
title_template: Some("{{ Env.REDDIT_TOKEN }}".to_string()),
url_template: Some("https://x/{{ Env.REDDIT_SECRET }}".to_string()),
..Default::default()
}),
..Default::default()
});
assert_eq!(
warnings.len(),
14,
"one warning per secret-named field: {warnings:?}"
);
for needle in [
"announce.linkedin.message_template",
"announce.opencollective.title_template",
"announce.opencollective.message_template",
"announce.mastodon.message_template",
"announce.discord.message_template",
"announce.discord.author",
"announce.webhook.message_template",
"announce.telegram.message_template",
"announce.teams.message_template",
"announce.teams.title_template",
"announce.mattermost.message_template",
"announce.mattermost.title_template",
"announce.reddit.title_template",
"announce.reddit.url_template",
] {
assert!(
warnings.iter().any(|w| w.contains(needle)),
"missing warning for {needle}: {warnings:?}"
);
}
}
#[test]
fn announce_secret_warns_in_slack_attachment_text_fields() {
use anodizer_core::config::{SlackAnnounce, SlackAttachment};
let warnings = collect_announce_warnings(AnnounceConfig {
slack: Some(SlackAnnounce {
attachments: Some(vec![SlackAttachment {
text: Some("{{ Env.SLACK_A_TOKEN }}".to_string()),
title: Some("{{ Env.SLACK_B_TOKEN }}".to_string()),
fallback: Some("{{ Env.SLACK_C_TOKEN }}".to_string()),
pretext: Some("{{ Env.SLACK_D_TOKEN }}".to_string()),
..Default::default()
}]),
..Default::default()
}),
..Default::default()
});
assert_eq!(
warnings.len(),
4,
"one per attachment content field: {warnings:?}"
);
for suffix in [".text", ".title", ".fallback", ".pretext"] {
assert!(
warnings
.iter()
.any(|w| w.contains(&format!("announce.slack.attachments[0]{suffix}"))),
"missing attachment{suffix} warning: {warnings:?}"
);
}
}
#[test]
fn signing_tools_warns_on_missing_sign_cmd() {
let config = Config {
project_name: "test".to_string(),
signs: vec![anodizer_core::config::SignConfig {
cmd: Some("anodizer-nonexistent-signer".to_string()),
..Default::default()
}],
..Default::default()
};
let mut warnings: Vec<String> = vec![];
check_signing_tools(&config, &mut warnings);
assert_eq!(warnings.len(), 1, "got: {warnings:?}");
assert!(
warnings[0].contains("anodizer-nonexistent-signer")
&& warnings[0].contains("signs section"),
"warning should name the missing tool and section: {:?}",
warnings
);
}
#[test]
fn signing_tools_warns_on_missing_docker_sign_cmd() {
let config = Config {
project_name: "test".to_string(),
docker_signs: Some(vec![anodizer_core::config::DockerSignConfig {
cmd: Some("anodizer-nonexistent-cosign".to_string()),
..Default::default()
}]),
..Default::default()
};
let mut warnings: Vec<String> = vec![];
check_signing_tools(&config, &mut warnings);
assert_eq!(warnings.len(), 1, "got: {warnings:?}");
assert!(
warnings[0].contains("anodizer-nonexistent-cosign")
&& warnings[0].contains("docker_signs section"),
"warning should name the missing tool and section: {:?}",
warnings
);
}
#[test]
fn signing_tools_silent_when_no_signing_configured() {
let config = Config {
project_name: "test".to_string(),
..Default::default()
};
let mut warnings: Vec<String> = vec![];
check_signing_tools(&config, &mut warnings);
assert!(
warnings.is_empty(),
"no signing config → no warnings: {warnings:?}"
);
}
#[test]
fn every_warning_quoted_in_the_sign_docs_is_a_message_the_checks_produce() {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../docs/site/content/docs/sign/binaries-archives.md"
);
let page = std::fs::read_to_string(path).unwrap_or_else(|e| panic!("read {path}: {e}"));
let blocks = fenced_blocks(&page);
let mut fixtures = 0usize;
let mut refused = 0usize;
for (at, (language, body)) in blocks.iter().enumerate() {
if *language != "yaml" {
continue;
}
let Ok(mut config) = serde_yaml_ng::from_str::<Config>(body) else {
refused += 1;
continue;
};
anodizer_core::defaults_merge::apply_defaults(&mut config);
fixtures += 1;
let mut produced: Vec<String> = vec![];
check_sign_artifact_filters(&config, &mut produced);
check_sign_asset_name_templates(&config, &mut produced);
check_sign_duplicate_outputs(&config, &mut produced);
check_unpadded_sign_placeholders(&config, &mut produced);
check_docker_sign_signature_templates(&config, &mut produced);
let quoted: Vec<String> = blocks[at + 1..]
.iter()
.take_while(|(language, _)| *language != "yaml")
.flat_map(|(_, body)| body.lines())
.filter_map(|line| line.trim_start().strip_prefix("Warning "))
.map(str::to_string)
.collect();
assert_eq!(
produced, quoted,
"the config in block {at} and the output quoted under it disagree"
);
}
assert_eq!(fixtures, 12, "the page's parseable config blocks");
assert_eq!(refused, 0, "every config block on the page has to load");
assert_eq!(
quoted_warning_lines("sign/binaries-archives.md").len(),
9,
"the warnings the page quotes"
);
}
fn fenced_blocks(page: &str) -> Vec<(&str, &str)> {
let mut blocks = Vec::new();
let mut open: Option<(&str, usize)> = None;
for line in page.lines() {
let at = line.as_ptr() as usize - page.as_ptr() as usize;
match (line.strip_prefix("```"), open) {
(Some(_), Some((language, from))) => {
blocks.push((language, &page[from..at]));
open = None;
}
(Some(language), None) => open = Some((language.trim(), at + line.len() + 1)),
(None, _) => {}
}
}
blocks
}
#[test]
fn the_warning_quoted_in_the_resilience_docs_is_a_message_the_check_produces() {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../docs/site/content/docs/advanced/release-resilience.md"
);
let page = std::fs::read_to_string(path).unwrap_or_else(|e| panic!("read {path}: {e}"));
let section = page
.split_once("### Static lint — `anodizer check config`")
.expect("the lint section is on the page")
.1;
let blocks = fenced_blocks(section);
let yaml = blocks
.iter()
.find(|(language, _)| *language == "yaml")
.expect("the section shows a config");
let quoted: Vec<String> = blocks
.iter()
.find(|(language, _)| *language == "text")
.expect("the section shows the output")
.1
.lines()
.filter_map(|line| line.trim_start().strip_prefix("Warning "))
.map(str::to_string)
.collect();
let mut config: Config = serde_yaml_ng::from_str(yaml.1).expect("the loader accepts it");
anodizer_core::defaults_merge::apply_defaults(&mut config);
let mut produced: Vec<String> = vec![];
check_announce_secret_exposure(&config, &mut produced);
assert_eq!(produced, quoted);
assert_eq!(
quoted_warning_lines("advanced/release-resilience.md").len(),
1,
"the warnings the page quotes"
);
}
fn quoted_label_lines(relative: &str, label: &str) -> Vec<String> {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../docs/site/content/docs")
.join(relative);
let page =
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
page.lines()
.filter_map(|line| line.trim_start().strip_prefix(label))
.filter(|line| !line.contains('\u{2026}'))
.map(str::to_string)
.collect()
}
fn quoted_error_lines(relative: &str) -> Vec<String> {
quoted_label_lines(relative, "Error ")
}
fn quoted_warning_lines(relative: &str) -> Vec<String> {
quoted_label_lines(relative, "Warning ")
}
#[test]
fn the_membership_errors_quoted_in_the_docs_are_what_the_guard_produces() {
let absent = tempdir().unwrap();
write_disk_workspace(
absent.path(),
&[
("crates/cli", "anodizer", &["anodizer-stage-install-script"]),
(
"crates/stage-install-script",
"anodizer-stage-install-script",
&[],
),
],
);
let config = make_config(vec![with_active_cargo_publisher(CrateConfig {
name: "anodizer".to_string(),
path: p(absent.path(), "crates/cli"),
tag_template: Some("v{{ .Version }}".to_string()),
..Default::default()
})]);
let mut produced = vec![];
check_workspace_membership(
&config,
absent.path(),
&flatten_crate_names(&config),
&mut produced,
);
let unpublished = tempdir().unwrap();
write_disk_workspace(
unpublished.path(),
&[
("crates/cli", "anodizer", &["anodizer-core"]),
("crates/core", "anodizer-core", &[]),
],
);
let config = make_config(vec![
with_active_cargo_publisher(CrateConfig {
name: "anodizer".to_string(),
path: p(unpublished.path(), "crates/cli"),
tag_template: Some("v{{ .Version }}".to_string()),
..Default::default()
}),
CrateConfig {
name: "anodizer-core".to_string(),
path: p(unpublished.path(), "crates/core"),
tag_template: Some("v{{ .Version }}".to_string()),
..Default::default()
},
]);
check_workspace_membership(
&config,
unpublished.path(),
&flatten_crate_names(&config),
&mut produced,
);
let monorepo = quoted_error_lines("advanced/monorepo.md");
assert_eq!(monorepo, produced[..1], "the monorepo page's Error lines");
assert_eq!(monorepo.len(), 1, "the errors the monorepo page quotes");
let resilience = quoted_error_lines("advanced/release-resilience.md");
assert_eq!(
resilience[3..],
produced[..],
"the release-resilience page's last two Error lines"
);
assert_eq!(
resilience.len(),
5,
"the errors the release-resilience page quotes"
);
}