use super::*;
use anodizer_core::config::DockerSignConfig;
const BINARY_SIGN_ARTIFACT_FILTERS: &[&str] = &["binary", "none"];
pub(super) fn check_sign_artifact_filters(config: &Config, warnings: &mut Vec<String>) {
let valid_artifact_filters = anodizer_stage_sign::VALID_SIGN_ARTIFACT_FILTERS;
let unrecognized = |filter: &Option<String>| -> Option<String> {
let filter = filter.as_deref()?;
(!valid_artifact_filters.contains(&filter)).then(|| filter.to_string())
};
for slice in sign_slices(config) {
for (idx, sign_cfg) in slice.configs.iter().enumerate() {
let block = slice.block(idx);
if slice.is_binary_signs()
&& let Some(filter) = sign_cfg.artifacts.as_deref()
&& !BINARY_SIGN_ARTIFACT_FILTERS.contains(&filter)
{
warnings.push(format!(
"{block} artifacts filter '{filter}' is not allowed on \
binary_signs (valid: {}) — the sign stage signs binaries \
whatever it says",
BINARY_SIGN_ARTIFACT_FILTERS.join(", ")
));
} else if let Some(filter) = unrecognized(&sign_cfg.artifacts) {
warnings.push(format!(
"unrecognized {block} artifacts filter '{filter}' (valid: {})",
valid_artifact_filters.join(", ")
));
}
if let Some(ref auth) = sign_cfg.authenticode
&& let Some(filter) = unrecognized(&auth.artifacts)
{
warnings.push(format!(
"unrecognized {block} authenticode artifacts filter \
'{filter}' (valid: {})",
valid_artifact_filters.join(", ")
));
}
}
}
}
pub(super) fn check_sign_asset_name_templates(config: &Config, warnings: &mut Vec<String>) {
for slice in sign_slices(config) {
if slice.is_binary_signs() {
continue;
}
for (idx, cfg) in slice.configs.iter().enumerate() {
if cfg.asset_name_template.is_none() {
continue;
}
let block = slice.block(idx);
warnings.push(format!(
"{block}.asset_name_template is set but only binary_signs \
honors it (it will be ignored)"
));
}
}
}
fn sign_selections_overlap(
a: &anodizer_core::config::SignConfig,
b: &anodizer_core::config::SignConfig,
artifacts_fallback: &str,
) -> bool {
use anodizer_core::config::active_if_gate;
if !anodizer_stage_sign::sign_filters_can_overlap(
a.resolved_artifacts(artifacts_fallback),
b.resolved_artifacts(artifacts_fallback),
) {
return false;
}
if let (Some(left), Some(right)) = (
active_if_gate(a.if_condition.as_deref()),
active_if_gate(b.if_condition.as_deref()),
) && left != right
{
return false;
}
match (&a.ids, &b.ids) {
(Some(left), Some(right)) => left.iter().any(|id| right.contains(id)),
_ => true,
}
}
fn writes_detached_outputs(cfg: &anodizer_core::config::SignConfig) -> bool {
cfg.authenticode.is_none() && cfg.artifacts.as_deref() != Some("none")
}
fn mask_placeholder_separators(template: &str) -> String {
const OPAQUE: char = '\u{1}';
let hide = |run: &str| -> String {
run.chars()
.map(|c| match c {
'/' | '\\' => OPAQUE,
other => other,
})
.collect()
};
let mut masked = String::with_capacity(template.len());
let mut rest = template;
while let Some(open) = rest.find("{{") {
masked.push_str(&rest[..open]);
let tail = &rest[open..];
masked.push_str("{{");
match tail.find("}}") {
Some(close) => {
masked.push_str(&hide(tail[2..close].trim()));
masked.push_str("}}");
rest = &tail[close + 2..];
}
None => {
masked.push_str(&hide(tail[2..].trim()));
rest = "";
}
}
}
masked.push_str(rest);
masked
}
const PATH_CARRYING_SPELLINGS: &[&str] = &["{{.Artifact}}", "{{Artifact}}"];
const PATH_CARRYING_SHELL_VARS: &[&str] = &["artifact", "signature", "certificate"];
const DOCKER_LITERAL_SHELL_VARS: &[&str] = &[
"artifact",
"signature",
"certificate",
"digest",
"artifactID",
];
fn continues_a_name(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_'
}
fn names_whole_word(text: &str, name: &str) -> bool {
text.match_indices(name).any(|(at, _)| {
!text[..at].chars().next_back().is_some_and(continues_a_name)
&& !text[at + name.len()..]
.chars()
.next()
.is_some_and(continues_a_name)
})
}
fn names_shell_var(text: &str, name: &str) -> bool {
if text.contains(&format!("${{{name}}}")) {
return true;
}
let bare = format!("${name}");
text.match_indices(&bare).any(|(at, _)| {
!text[at + bare.len()..]
.chars()
.next()
.is_some_and(continues_a_name)
})
}
const BOUNDED_VARIABLES: &[&str] = &[
"ProjectName",
"Version",
"Binary",
"Target",
"Os",
"Arch",
"Arm",
"Amd64",
"Mips",
"Tag",
];
fn renders_an_unbounded_path(masked: &str) -> bool {
if PATH_CARRYING_SPELLINGS.iter().any(|s| masked.contains(s))
|| PATH_CARRYING_SHELL_VARS
.iter()
.any(|name| names_shell_var(masked, name))
{
return true;
}
let mut rest = masked;
while let Some(open) = rest.find("{{") {
let tail = &rest[open + 2..];
let (inner, next) = match tail.find("}}") {
Some(close) => (&tail[..close], &tail[close + 2..]),
None => (tail, ""),
};
if !BOUNDED_VARIABLES.contains(&inner.trim_start_matches('.')) {
return true;
}
rest = next;
}
false
}
fn same_output_file(dist: &std::path::Path, left: &str, right: &str) -> bool {
if left == right {
return true;
}
let (left, right) = (
mask_placeholder_separators(left),
mask_placeholder_separators(right),
);
if renders_an_unbounded_path(&left) || renders_an_unbounded_path(&right) {
let fold = |t: &str| anodizer_core::util::fold_dot_components(std::path::Path::new(t));
return fold(&left) == fold(&right);
}
anodizer_stage_sign::sign_outputs_are_one_file(dist, &left, &right)
}
struct SignSlice<'a> {
label: String,
defaults_block: Option<&'static str>,
configs: &'a Vec<anodizer_core::config::SignConfig>,
}
impl SignSlice<'_> {
fn block(&self, idx: usize) -> String {
match self.defaults_block {
Some(block) => block.to_string(),
None => format!("{}[{idx}]", self.label),
}
}
fn is_binary_signs(&self) -> bool {
self.label.ends_with("binary_signs")
}
fn signature_default(&self) -> &'static str {
if self.is_binary_signs() {
anodizer_core::config::SignConfig::DEFAULT_BINARY_SIGNATURE_TEMPLATE
} else {
anodizer_core::config::SignConfig::DEFAULT_SIGNATURE_TEMPLATE
}
}
fn artifacts_default(&self) -> &'static str {
if self.is_binary_signs() {
anodizer_core::config::SignConfig::DEFAULT_ARTIFACTS_BINARY
} else {
anodizer_core::config::SignConfig::DEFAULT_ARTIFACTS
}
}
}
fn sign_slices(config: &Config) -> Vec<SignSlice<'_>> {
let filled = |key: &'static str, block: &'static str| {
config.filled_from_defaults.contains(key).then_some(block)
};
let mut slices = vec![
SignSlice {
label: "signs".to_string(),
defaults_block: filled("signs", "defaults.sign"),
configs: &config.signs,
},
SignSlice {
label: "binary_signs".to_string(),
defaults_block: filled("binary_signs", "defaults.binary_signs"),
configs: &config.binary_signs,
},
];
for ws in config.workspaces.iter().flatten() {
slices.push(SignSlice {
label: format!("workspaces.{}.signs", ws.name),
defaults_block: None,
configs: &ws.signs,
});
slices.push(SignSlice {
label: format!("workspaces.{}.binary_signs", ws.name),
defaults_block: None,
configs: &ws.binary_signs,
});
}
slices
}
pub(super) fn check_sign_duplicate_outputs(config: &Config, warnings: &mut Vec<String>) {
for slice in sign_slices(config) {
let default = slice.signature_default();
let writing: Vec<(usize, &anodizer_core::config::SignConfig)> = slice
.configs
.iter()
.enumerate()
.filter(|(_, cfg)| writes_detached_outputs(cfg))
.collect();
for (pos, (first, a)) in writing.iter().enumerate() {
for (second, b) in writing.iter().skip(pos + 1) {
if !sign_selections_overlap(a, b, slice.artifacts_default()) {
continue;
}
for (field, same) in [
(
"signature",
same_output_file(
&config.dist,
a.resolved_signature_template(default),
b.resolved_signature_template(default),
),
),
(
"certificate",
match (a.certificate.as_deref(), b.certificate.as_deref()) {
(Some(left), Some(right)) => {
same_output_file(&config.dist, left, right)
}
_ => false,
},
),
] {
if !same {
continue;
}
let (first_block, second_block) = (slice.block(*first), slice.block(*second));
warnings.push(format!(
"{first_block} and {second_block} resolve one {field} \
file for the artifacts both select — the second \
{field} overwrites the first, so one file ships \
where two were configured"
));
}
}
}
}
}
const LITERAL_SIGN_PLACEHOLDERS: &[&str] = &["Artifact", "Signature", "Certificate"];
const ARTIFACT_PLACEHOLDER_ONLY: &[&str] = &["Artifact"];
const NO_SIGN_PLACEHOLDERS: &[&str] = &[];
fn mask_string_literals(core: &str) -> String {
let mut masked = String::with_capacity(core.len());
let mut quote: Option<char> = None;
let mut escaped = false;
for c in core.chars() {
match quote {
Some(_) if escaped => {
escaped = false;
masked.push(' ');
}
Some(_) if c == '\\' => {
escaped = true;
masked.push(' ');
}
Some(open) if c == open => {
quote = None;
masked.push(c);
}
Some(_) => masked.push(' '),
None if c == '"' || c == '\'' || c == '`' => {
quote = Some(c);
masked.push(c);
}
None => masked.push(c),
}
}
masked
}
fn placeholder_spellings<'a>(template: &'a str, name: &str) -> Vec<&'a str> {
let mut found = Vec::new();
let mut at = 0usize;
while at < template.len() {
let rest = &template[at..];
let run = rest.find("{{").map(|open| (open, "}}"));
let statement = rest.find("{%").map(|open| (open, "%}"));
let comment = rest.find("{#");
let block = match (run, statement) {
(Some(run), Some(statement)) => Some(std::cmp::min_by_key(run, statement, |b| b.0)),
(run, statement) => run.or(statement),
};
match (block, comment) {
(Some((open, closer)), c) if c.is_none_or(|c| open < c) => {
let after = &rest[open + 2..];
let Some(close) = after.find(closer) else {
at += open + 2;
continue;
};
if names_whole_word(mask_string_literals(after[..close].trim()).trim(), name) {
found.push(&rest[open..open + close + 4]);
}
at += open + close + 4;
}
(_, Some(open)) => {
let Some(end) = rest[open + 2..].find("#}") else {
at += open + 2;
continue;
};
at += open + 2 + end + 2;
}
_ => break,
}
}
found
}
pub(super) fn check_unpadded_sign_placeholders(config: &Config, warnings: &mut Vec<String>) {
let warn = |block: &str,
field: &str,
substituted: &[&str],
shell_expanded: bool,
template: &str,
warnings: &mut Vec<String>| {
for name in LITERAL_SIGN_PLACEHOLDERS {
for spelling in placeholder_spellings(template, name) {
if !substituted.contains(name) {
let shell = name.to_ascii_lowercase();
let remedy = if shell == field {
format!(
"; the {field} path is what this template \
renders, so `${{{shell}}}` has no value here \
either — remove the reference"
)
} else if shell_expanded {
format!(
"; write `${{{shell}}}`, which the sign stage \
expands after the render"
)
} else {
String::new()
};
warnings.push(format!(
"{block}.{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}"
));
} else if spelling != format!("{{{{ .{name} }}}}")
&& spelling != format!("{{{{ {name} }}}}")
{
warnings.push(format!(
"{block}.{field} names `{spelling}`, which anodizer \
substitutes only as the literal `{{{{ .{name} }}}}` \
or `{{{{ {name} }}}}` — every other spelling \
reaches the template engine as an undefined \
variable and fails the sign stage"
));
}
}
}
};
for slice in sign_slices(config) {
for (idx, cfg) in slice.configs.iter().enumerate() {
let block = slice.block(idx);
for (field, substituted, template) in [
(
"signature",
ARTIFACT_PLACEHOLDER_ONLY,
cfg.signature.as_deref(),
),
(
"certificate",
ARTIFACT_PLACEHOLDER_ONLY,
cfg.certificate.as_deref(),
),
("stdin", NO_SIGN_PLACEHOLDERS, cfg.stdin.as_deref()),
] {
if let Some(template) = template {
warn(&block, field, substituted, true, template, warnings);
}
}
for arg in cfg.args.iter().flatten() {
warn(
&block,
"args",
LITERAL_SIGN_PLACEHOLDERS,
true,
arg,
warnings,
);
}
}
}
for (idx, cfg) in config.docker_signs.iter().flatten().enumerate() {
let block = docker_sign_block(config, idx);
for arg in cfg.args.iter().flatten() {
warn(
&block,
"args",
LITERAL_SIGN_PLACEHOLDERS,
false,
arg,
warnings,
);
}
if let Some(stdin) = cfg.stdin.as_deref() {
warn(
&block,
"stdin",
NO_SIGN_PLACEHOLDERS,
false,
stdin,
warnings,
);
}
for (field, template) in docker_sign_templates(cfg) {
check_docker_sign_literal_text(&block, field, template, warnings);
}
}
}
fn docker_sign_templates(cfg: &DockerSignConfig) -> Vec<(&'static str, &str)> {
cfg.args
.iter()
.flatten()
.map(|arg| ("args", arg.as_str()))
.chain(cfg.stdin.as_deref().map(|stdin| ("stdin", stdin)))
.collect()
}
fn check_docker_sign_literal_text(
block: &str,
field: &str,
template: &str,
warnings: &mut Vec<String>,
) {
for name in DOCKER_LITERAL_SHELL_VARS {
if !names_shell_var(template, name) {
continue;
}
let title = format!("{}{}", name[..1].to_uppercase(), &name[1..]);
let remedy = match (*name, field) {
("certificate", _) => {
"; a docker certificate path is read nowhere, so remove the reference".to_string()
}
("digest" | "artifactID", _) => format!(
"; write `{{{{ .{title} }}}}`, which the docker sign path \
renders from the image"
),
(_, "stdin") => String::new(),
_ => {
format!(
"; write `{{{{ .{title} }}}}`, which anodizer substitutes before the render"
)
}
};
warnings.push(format!(
"{block}.{field} names `${{{name}}}`, which the docker sign path \
never expands — it reaches the signing command as that literal \
text{remedy}"
));
}
if field == "args" {
for spelling in placeholder_spellings(template, "Certificate")
.into_iter()
.filter(|spelling| {
*spelling == "{{ .Certificate }}" || *spelling == "{{ Certificate }}"
})
{
warnings.push(format!(
"{block}.args names `{spelling}`, 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"
));
}
}
}
fn docker_sign_block(config: &Config, idx: usize) -> String {
match config.filled_from_defaults.contains("docker_signs") {
true => "defaults.docker_signs".to_string(),
false => format!("docker_signs[{idx}]"),
}
}
pub(super) fn check_docker_sign_signature_templates(config: &Config, warnings: &mut Vec<String>) {
for (idx, cfg) in config.docker_signs.iter().flatten().enumerate() {
if cfg.signature.is_some() {
warnings.push(format!(
"{}.signature is set but a docker signature is stored in the \
registry rather than written to a file (it will be ignored)",
docker_sign_block(config, idx)
));
}
}
}