Skip to main content

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}