anodizer_core/template/guard.rs
1//! Residual-template-delimiter guard for generated manifests.
2//!
3//! After a publisher renders a manifest to its final text, that text MUST NOT
4//! still contain unrendered Go/Tera `{{ … }}` template delimiters. A residual
5//! delimiter means a user-supplied config string field was emitted verbatim
6//! instead of being run through the template engine — the bug class this guard
7//! exists to make unrepresentable (cfgd v0.4.0 shipped a Chocolatey `docs_url`
8//! containing URL-encoded `{{ .Tag }}`).
9//!
10//! Only the `{{` … `}}` delimiter pair is scanned. The manifest formats this
11//! guard protects (nuspec XML, scoop/winget/krew JSON+YAML, homebrew Ruby, nix
12//! derivations, AUR PKGBUILD/.SRCINFO, snapcraft YAML) do not legitimately
13//! contain that pair: Ruby string interpolation is `#{}`, nix is `${}`,
14//! shell/PowerShell is `$`. So a `{{ … }}` in final text is always a leak.
15
16use anyhow::{Result, bail};
17
18/// A residual `{{` … `}}` delimiter pair found in finished manifest text.
19///
20/// Returned by [`find_unrendered`]; the `snippet` is already secret-redacted
21/// (see [`assert_no_unrendered`]) so it is safe to surface in logs or errors.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct Residual {
24 /// The offending `{{ … }}` substring (redacted), bounded so a runaway
25 /// open-delimiter cannot dump the whole manifest into a log line.
26 pub snippet: String,
27}
28
29/// Maximum bytes of context captured for a residual snippet, so an unbalanced
30/// `{{` with no closing `}}` (or a very long templated value) cannot spill an
31/// unbounded amount of manifest text into a warning or error message.
32const MAX_SNIPPET: usize = 120;
33
34/// Find the first residual `{{` … `}}` delimiter pair in `text`, if any.
35///
36/// Returns `None` for clean manifests. The returned [`Residual::snippet`] is
37/// the raw (un-redacted) matched substring — callers that surface it MUST
38/// redact first; prefer [`assert_no_unrendered`], which redacts for you.
39fn find_unrendered_raw(text: &str) -> Option<String> {
40 let open = text.find("{{")?;
41 // Bound the captured region: prefer the matching `}}`, but never exceed
42 // MAX_SNIPPET so a missing close delimiter can't dump the whole manifest.
43 let rest = &text[open..];
44 let mut end = match rest.find("}}") {
45 Some(close) => (close + 2).min(MAX_SNIPPET),
46 None => rest.len().min(MAX_SNIPPET),
47 };
48 // `end` is a raw byte index that the MAX_SNIPPET clamp can land mid-codepoint
49 // when an unbalanced `{{` is followed by multibyte content (emoji/CJK/accent)
50 // — slicing there would panic. Walk back to the nearest char boundary first.
51 while end > 0 && !rest.is_char_boundary(end) {
52 end -= 1;
53 }
54 Some(rest[..end].to_string())
55}
56
57/// Assert that `text` (a publisher's finished manifest) contains no residual
58/// `{{ … }}` template delimiters.
59///
60/// `label` names the publisher + manifest (e.g. `"chocolatey nuspec"`) for the
61/// diagnostic. `redact` is applied to the offending snippet before it is
62/// surfaced, so a secret-flagged config value cannot leak into a log line or
63/// error message — callers pass [`crate::redact::redact_process_env`] (or an
64/// equivalent that also masks config-declared secrets).
65///
66/// - **strict** (`is_strict == true`): returns `Err` naming the redacted
67/// snippet and `label`, so a leaking manifest fails the publish BEFORE any
68/// irreversible publisher fires.
69/// - **non-strict**: returns `Ok(Some(Residual))` with the redacted snippet so
70/// the caller can `log.warn(...)` it; returns `Ok(None)` when clean.
71///
72/// A clean manifest always returns `Ok(None)` regardless of `is_strict`.
73pub fn assert_no_unrendered(
74 text: &str,
75 label: &str,
76 is_strict: bool,
77 redact: impl Fn(&str) -> String,
78) -> Result<Option<Residual>> {
79 match find_unrendered_raw(text) {
80 None => Ok(None),
81 Some(raw) => {
82 let snippet = redact(&raw);
83 if is_strict {
84 bail!("{}", unrendered_message(label, &snippet));
85 }
86 Ok(Some(Residual { snippet }))
87 }
88 }
89}
90
91/// [`assert_no_unrendered`] plus the lenient-mode warn emission, so the
92/// warn-message wording lives in exactly one place across every caller.
93///
94/// Strict mode bails (via [`assert_no_unrendered`]). Lenient mode invokes
95/// `warn` with the fully-formatted line — same text as the strict error body —
96/// describing the redacted residual snippet. The `warn` closure lets callers
97/// route through their own `StageLogger` (which `anodizer-core` cannot depend
98/// on at this layer without a cycle) while keeping the message canonical.
99///
100/// `label` and `redact` carry the same meaning as in [`assert_no_unrendered`].
101pub fn assert_no_unrendered_logged(
102 text: &str,
103 label: &str,
104 is_strict: bool,
105 redact: impl Fn(&str) -> String,
106 warn: impl FnOnce(&str),
107) -> Result<()> {
108 if let Some(residual) = assert_no_unrendered(text, label, is_strict, redact)? {
109 warn(&unrendered_message(label, &residual.snippet));
110 }
111 Ok(())
112}
113
114/// The single canonical phrasing for a residual-delimiter diagnostic, shared by
115/// the strict-bail path and the lenient-warn path so the two never drift.
116fn unrendered_message(label: &str, snippet: &str) -> String {
117 format!(
118 "{label}: unrendered template delimiter in generated manifest: {snippet:?} \
119 (a user-supplied config field was emitted without template rendering)"
120 )
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126
127 fn identity(s: &str) -> String {
128 s.to_string()
129 }
130
131 #[test]
132 fn clean_manifest_passes_strict_and_lenient() {
133 let clean = "url = \"https://example.com/v1.2.3/tool.tar.gz\"\nsha256 = \"abc\"";
134 assert_eq!(
135 assert_no_unrendered(clean, "test", true, identity).unwrap(),
136 None
137 );
138 assert_eq!(
139 assert_no_unrendered(clean, "test", false, identity).unwrap(),
140 None
141 );
142 }
143
144 #[test]
145 fn ruby_and_nix_interpolation_do_not_false_positive() {
146 // Ruby `#{}` and nix `${}` are legitimate manifest syntax.
147 let ruby = "depends_on macos: :catalina\ncaveats { \"installed #{version}\" }";
148 let nix = "src = fetchurl { url = \"${baseUrl}/tool\"; };";
149 assert_eq!(
150 assert_no_unrendered(ruby, "homebrew", true, identity).unwrap(),
151 None
152 );
153 assert_eq!(
154 assert_no_unrendered(nix, "nix", true, identity).unwrap(),
155 None
156 );
157 }
158
159 #[test]
160 fn leaked_tag_fails_in_strict_mode() {
161 let leaked = "docs_url = \"https://x/y/blob/{{ .Tag }}/docs.md\"";
162 let err = assert_no_unrendered(leaked, "chocolatey nuspec", true, identity).unwrap_err();
163 let msg = err.to_string();
164 assert!(msg.contains("chocolatey nuspec"), "label missing: {msg}");
165 assert!(msg.contains("{{ .Tag }}"), "snippet missing: {msg}");
166 }
167
168 #[test]
169 fn leaked_tag_warns_in_lenient_mode() {
170 let leaked = "url=\"https://x/{{ .Version }}/t\"";
171 let residual = assert_no_unrendered(leaked, "aur PKGBUILD", false, identity)
172 .unwrap()
173 .expect("expected a residual");
174 assert!(residual.snippet.contains("{{ .Version }}"));
175 }
176
177 #[test]
178 fn snippet_is_redacted_before_surfacing() {
179 let leaked = "token = {{ ghp_supersecrettoken123 }}";
180 let redactor = |s: &str| s.replace("ghp_supersecrettoken123", "$REDACTED");
181 // Strict: secret must not appear in the error.
182 let err = assert_no_unrendered(leaked, "x", true, redactor).unwrap_err();
183 assert!(!err.to_string().contains("ghp_supersecrettoken123"));
184 assert!(err.to_string().contains("$REDACTED"));
185 // Lenient: secret must not appear in the residual snippet.
186 let residual = assert_no_unrendered(leaked, "x", false, redactor)
187 .unwrap()
188 .unwrap();
189 assert!(!residual.snippet.contains("ghp_supersecrettoken123"));
190 assert!(residual.snippet.contains("$REDACTED"));
191 }
192
193 #[test]
194 fn unbalanced_open_delimiter_is_bounded() {
195 let runaway = format!("{{{{ {}", "A".repeat(500));
196 let residual = assert_no_unrendered(&runaway, "x", false, identity)
197 .unwrap()
198 .unwrap();
199 assert!(residual.snippet.len() <= MAX_SNIPPET, "snippet not bounded");
200 }
201
202 #[test]
203 fn unbalanced_open_delimiter_with_multibyte_does_not_panic() {
204 // An unbalanced `{{` followed by multibyte content straddling the
205 // MAX_SNIPPET byte clamp must not panic on a non-char-boundary slice.
206 // Each "日本語" is 9 bytes; ~30 copies overruns the 120-byte clamp so the
207 // cut lands mid-codepoint unless the boundary walk-back applies.
208 let runaway = format!("{{{{ {}", "日本語".repeat(40));
209 let residual = assert_no_unrendered(&runaway, "x", false, identity)
210 .unwrap()
211 .unwrap();
212 assert!(residual.snippet.len() <= MAX_SNIPPET, "snippet not bounded");
213 // Round-trips as valid UTF-8 (it is already a String, but assert the
214 // boundary walk-back left no partial codepoint by re-validating bytes).
215 assert!(std::str::from_utf8(residual.snippet.as_bytes()).is_ok());
216
217 // Also exercise the no-close-delimiter emoji case at the boundary.
218 let emoji = format!("{{{{ {}", "🦀".repeat(40));
219 let r2 = assert_no_unrendered(&emoji, "x", false, identity)
220 .unwrap()
221 .unwrap();
222 assert!(r2.snippet.len() <= MAX_SNIPPET);
223 assert!(std::str::from_utf8(r2.snippet.as_bytes()).is_ok());
224 }
225
226 #[test]
227 fn logged_helper_warns_once_in_lenient_and_bails_in_strict() {
228 let leaked = "url=\"https://x/{{ .Version }}/t\"";
229 // Lenient: warn fires exactly once with the canonical message.
230 let mut warns: Vec<String> = Vec::new();
231 assert_no_unrendered_logged(leaked, "scoop manifest", false, identity, |m| {
232 warns.push(m.to_string())
233 })
234 .unwrap();
235 assert_eq!(warns.len(), 1, "warn must fire exactly once");
236 assert!(warns[0].contains("scoop manifest"));
237 assert!(warns[0].contains("{{ .Version }}"));
238 assert!(warns[0].contains("unrendered template delimiter"));
239
240 // Strict: bails, warn never fires.
241 let mut strict_warns = 0usize;
242 let err = assert_no_unrendered_logged(leaked, "scoop manifest", true, identity, |_| {
243 strict_warns += 1
244 })
245 .unwrap_err();
246 assert_eq!(strict_warns, 0, "strict path must not warn");
247 // Strict error body and lenient warn body share the same wording.
248 assert!(err.to_string().contains("unrendered template delimiter"));
249 assert!(err.to_string().contains("scoop manifest"));
250
251 // Clean text: no warn, Ok.
252 let mut clean_warns = 0usize;
253 assert_no_unrendered_logged("clean", "x", false, identity, |_| clean_warns += 1).unwrap();
254 assert_eq!(clean_warns, 0);
255 }
256}