Skip to main content

vault_core/
composite.rs

1//! Composite secrets (Phase 24.1) — one value with secrets inside it.
2//!
3//! The motivating case is a calendar-sharing URL,
4//! `https://outlook.office365.com/owa/calendar/{mailbox_id}@inf.elte.hu/{calendar_key}/calendar.ics`,
5//! where two path segments are credentials and the rest is structure. The
6//! root is the **template**; each placeholder is a **part**. Parts are
7//! ordinary `extra_vars` — see the design note in `src/ts/composite.rs`'s
8//! TypeScript twin (`src/ts/composite.ts`) for why a second array was
9//! rejected. This module renders the template against a set of parts and does
10//! nothing else: it does not know about `VaultEntry`, redaction, or `${…}`
11//! references.
12//!
13//! This is a twin pair with `src/ts/composite.ts`, pinned by
14//! `tests/fixtures/parity/composite.json` — the form needs a live preview, so
15//! rendering exists in both languages, and two implementations of one
16//! template language drift silently if nothing asserts they agree.
17//!
18//! ## Encoding — decided 2026-09-14
19//!
20//! A URL is several zones with different reserved characters, and a value
21//! inserted raw can change what the URL *means*
22//! (`postgres://app:p@ss@db/prod` parses with host `ss@db`). So: parts are
23//! stored **raw** (the vault holds the credential the issuer gave), and the
24//! renderer classifies each placeholder by the zone it sits in — from a parse
25//! of the **template text**, not of a URL built from real values — and
26//! percent-encodes accordingly. `custom` never encodes: the preview shows the
27//! encoded result, so what is copied is what was seen.
28//!
29//! The encoder implements exactly RFC 3986's unreserved set
30//! (`ALPHA / DIGIT / "-" / "." / "_" / "~"`) rather than delegating to a
31//! JS/Rust built-in — `encodeURIComponent` and this module must byte-for-byte
32//! agree, and the built-ins on the two sides do not use the same unreserved
33//! set (JS additionally leaves `! ~ * ' ( )` unescaped).
34
35use std::collections::BTreeMap;
36use std::fmt;
37
38/// What a composite template *is*, which decides its encoding and whether an
39/// Open action is offered. Open past the four presets, the same
40/// open-vocabulary reasoning as `primary_role`.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum Kind {
43    /// `http(s)://` only; Open behind a confirmation; zone-aware encoding.
44    Link,
45    /// A presigned/SAS URL — same zone encoding as `Link`. Whether editing
46    /// outside the signature part is refused is a form-level rule (C10), not
47    /// a rendering one, so it is not enforced here.
48    SignedLink,
49    /// A connection string (`postgres://user:pass@host/db`) — same zone
50    /// encoding; userinfo encoding is what stops a `@`/`:` in a password from
51    /// moving the host.
52    Connection,
53    /// Not a URL. No encoding, no zone classification.
54    Custom,
55    /// An unrecognised kind string. Treated as `Custom` — the safe default,
56    /// since assuming URL structure over an unknown shape as easily
57    /// *corrupts* a rendered value as it protects one.
58    Other,
59}
60
61impl Kind {
62    pub fn parse(s: &str) -> Self {
63        match s {
64            "link" => Kind::Link,
65            "signed_link" => Kind::SignedLink,
66            "connection" => Kind::Connection,
67            "custom" => Kind::Custom,
68            _ => Kind::Other,
69        }
70    }
71
72    fn is_url_shaped(self) -> bool {
73        matches!(self, Kind::Link | Kind::SignedLink | Kind::Connection)
74    }
75}
76
77/// Where in the template a placeholder sits, decided by a parse of the
78/// template text around it — never by parsing the rendered output, which
79/// would already contain the (possibly `/`- or `@`-bearing) part value.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81enum Zone {
82    /// Before `://`, or the whole string when there is no `://` at all
83    /// (a `custom`-shaped value, or a URL-kind template with no scheme).
84    Unstructured,
85    UserInfo,
86    Host,
87    Path,
88    QueryName,
89    QueryValue,
90    Fragment,
91}
92
93/// One named `{part}` and the raw value it holds.
94#[derive(Debug, Clone)]
95pub struct Part {
96    pub key: String,
97    pub value: String,
98}
99
100/// What went wrong rendering a template. `Display` gives the user-facing text.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub enum RenderError {
103    /// A `{name}` with no matching part. The name is the placeholder, exactly
104    /// as it appeared in the template.
105    UnfilledPlaceholder(String),
106    /// A `{` that starts neither `{{` nor a valid `{name}` — a template typo,
107    /// not a literal brace.
108    UnbalancedBrace(usize),
109    /// A part's raw value contains a newline or control character, and the
110    /// kind is URL-shaped (link/signed_link/connection) — C6.
111    ControlCharacterInPart(String),
112}
113
114impl fmt::Display for RenderError {
115    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116        match self {
117            RenderError::UnfilledPlaceholder(name) => {
118                write!(
119                    f,
120                    "no part named \"{name}\" — the template cannot render without it"
121                )
122            }
123            RenderError::UnbalancedBrace(at) => {
124                write!(
125                    f,
126                    "unbalanced '{{' at position {at} — use '{{{{' for a literal brace"
127                )
128            }
129            RenderError::ControlCharacterInPart(name) => {
130                write!(f, "part \"{name}\" contains a newline or control character, which a URL cannot carry")
131            }
132        }
133    }
134}
135
136/// The result of a successful render: the text, and which parts were used.
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct Rendered {
139    pub text: String,
140    /// Placeholder names actually found in the template, in first-use order.
141    pub used: Vec<String>,
142    /// Parts supplied but never referenced by any placeholder (C8's sibling —
143    /// not an error, a health-scan-shaped warning for the caller to raise).
144    pub unused: Vec<String>,
145}
146
147/// Exactly RFC 3986's unreserved set. Deliberately not `encodeURIComponent`
148/// (JS) or `percent-encoding`'s `NON_ALPHANUMERIC` (Rust) — both leave a
149/// different, larger set unescaped than this, and the two sides must agree on
150/// every byte, not merely on "encoded enough".
151fn percent_encode(s: &str) -> String {
152    let mut out = String::with_capacity(s.len());
153    for b in s.bytes() {
154        match b {
155            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
156                out.push(b as char);
157            }
158            _ => out.push_str(&format!("%{b:02X}")),
159        }
160    }
161    out
162}
163
164fn has_control_char(s: &str) -> bool {
165    s.chars().any(|c| c.is_control())
166}
167
168/// One token of a tokenised template: either literal text or a placeholder.
169enum Token {
170    Literal(String),
171    /// `(name, byte_offset_in_template)` — the offset is what `classify_zone`
172    /// needs, and it is the offset in the *template*, before substitution.
173    Placeholder(String, usize),
174}
175
176/// Splits a template into literal and placeholder tokens, validating brace
177/// balance and placeholder-name syntax as it goes.
178fn tokenize(template: &str) -> Result<Vec<Token>, RenderError> {
179    let bytes = template.as_bytes();
180    let mut tokens = Vec::new();
181    let mut literal = String::new();
182    let mut i = 0;
183    while i < bytes.len() {
184        match bytes[i] {
185            b'{' if bytes.get(i + 1) == Some(&b'{') => {
186                literal.push('{');
187                i += 2;
188            }
189            b'}' if bytes.get(i + 1) == Some(&b'}') => {
190                literal.push('}');
191                i += 2;
192            }
193            b'{' => {
194                let start = i;
195                let close = template[i..].find('}').map(|p| p + i);
196                let close = match close {
197                    Some(c) => c,
198                    None => return Err(RenderError::UnbalancedBrace(start)),
199                };
200                let name = &template[i + 1..close];
201                if name.is_empty() || !is_valid_name(name) {
202                    return Err(RenderError::UnbalancedBrace(start));
203                }
204                if !literal.is_empty() {
205                    tokens.push(Token::Literal(std::mem::take(&mut literal)));
206                }
207                tokens.push(Token::Placeholder(name.to_string(), start));
208                i = close + 1;
209            }
210            b'}' => return Err(RenderError::UnbalancedBrace(i)),
211            _ => {
212                // Push whole UTF-8 scalar values, not raw bytes, so a
213                // multi-byte character is never split across two pushes.
214                let ch = template[i..].chars().next().unwrap();
215                literal.push(ch);
216                i += ch.len_utf8();
217            }
218        }
219    }
220    if !literal.is_empty() {
221        tokens.push(Token::Literal(literal));
222    }
223    Ok(tokens)
224}
225
226fn is_valid_name(s: &str) -> bool {
227    let mut chars = s.chars();
228    match chars.next() {
229        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
230        _ => return false,
231    }
232    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
233}
234
235/// Classifies the zone a placeholder at `at` (a byte offset into `template`)
236/// sits in, by scanning the template's own structural characters —
237/// `://`, `@`, `/`, `?`, `#`, `&`, `=` — never the rendered output.
238fn classify_zone(template: &str, at: usize) -> Zone {
239    let scheme_end = match template.find("://") {
240        Some(p) => p + 3,
241        None => return Zone::Unstructured,
242    };
243    if at < scheme_end {
244        return Zone::Unstructured;
245    }
246    // The authority region runs from the scheme to the first '/', '?' or '#'.
247    let authority_end = template[scheme_end..]
248        .find(['/', '?', '#'])
249        .map(|p| p + scheme_end)
250        .unwrap_or(template.len());
251    if at < authority_end {
252        let authority = &template[scheme_end..authority_end];
253        if let Some(at_sign) = authority.find('@') {
254            let at_sign = at_sign + scheme_end;
255            return if at < at_sign {
256                Zone::UserInfo
257            } else {
258                Zone::Host
259            };
260        }
261        return Zone::Host;
262    }
263    let query_start = template[authority_end..]
264        .find('?')
265        .map(|p| p + authority_end);
266    let fragment_start = template[authority_end..]
267        .find('#')
268        .map(|p| p + authority_end);
269    if let Some(fs) = fragment_start {
270        if at >= fs {
271            return Zone::Fragment;
272        }
273    }
274    if let Some(qs) = query_start {
275        if at >= qs {
276            let query_end = fragment_start.unwrap_or(template.len());
277            let query = &template[qs..query_end];
278            // Which '&'-delimited segment contains `at`, and is `at` before or
279            // after that segment's '='?
280            let rel = at - qs;
281            let seg_start = query[..rel].rfind('&').map(|p| p + 1).unwrap_or(0);
282            let seg_end = query[rel..]
283                .find('&')
284                .map(|p| p + rel)
285                .unwrap_or(query.len());
286            let segment = &query[seg_start..seg_end];
287            return match segment.find('=') {
288                Some(eq) if rel - seg_start > eq => Zone::QueryValue,
289                Some(_) => Zone::QueryName,
290                None => Zone::QueryName,
291            };
292        }
293    }
294    Zone::Path
295}
296
297/// Renders `template` against `parts`, refusing on any unfilled placeholder,
298/// unbalanced brace, or (for a URL-shaped kind) a control character in a part.
299///
300/// `custom` and unrecognised kinds render every placeholder raw. Every other
301/// kind percent-encodes by zone, computed from the **template's** structure —
302/// see the module doc for why that has to be the template and not the output.
303pub fn render(template: &str, parts: &[Part], kind: Kind) -> Result<Rendered, RenderError> {
304    let by_name: BTreeMap<&str, &str> = parts
305        .iter()
306        .map(|p| (p.key.as_str(), p.value.as_str()))
307        .collect();
308    let tokens = tokenize(template)?;
309
310    let mut text = String::new();
311    let mut used = Vec::new();
312    let encode = kind.is_url_shaped();
313
314    for tok in &tokens {
315        match tok {
316            Token::Literal(s) => text.push_str(s),
317            Token::Placeholder(name, at) => {
318                let value = *by_name
319                    .get(name.as_str())
320                    .ok_or_else(|| RenderError::UnfilledPlaceholder(name.clone()))?;
321                if encode && has_control_char(value) {
322                    return Err(RenderError::ControlCharacterInPart(name.clone()));
323                }
324                if !used.contains(name) {
325                    used.push(name.clone());
326                }
327                if encode {
328                    let zone = classify_zone(template, *at);
329                    text.push_str(&match zone {
330                        Zone::Unstructured => value.to_string(),
331                        Zone::UserInfo
332                        | Zone::Host
333                        | Zone::Path
334                        | Zone::QueryName
335                        | Zone::QueryValue
336                        | Zone::Fragment => percent_encode(value),
337                    });
338                } else {
339                    text.push_str(value);
340                }
341            }
342        }
343    }
344
345    let unused: Vec<String> = parts
346        .iter()
347        .map(|p| p.key.clone())
348        .filter(|k| !used.contains(k))
349        .collect();
350
351    Ok(Rendered { text, used, unused })
352}
353
354/// The placeholder names a template references, without needing any parts —
355/// used by the form to build "Make part" suggestions and by the health scan
356/// to find orphaned parts, without rendering (and therefore without needing
357/// every part filled in first).
358pub fn placeholders(template: &str) -> Result<Vec<String>, RenderError> {
359    let mut names = Vec::new();
360    for tok in tokenize(template)? {
361        if let Token::Placeholder(name, _) = tok {
362            if !names.contains(&name) {
363                names.push(name);
364            }
365        }
366    }
367    Ok(names)
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    fn part(k: &str, v: &str) -> Part {
375        Part {
376            key: k.into(),
377            value: v.into(),
378        }
379    }
380
381    #[test]
382    fn renders_a_calendar_url_with_two_parts() {
383        let tpl = "https://outlook.office365.com/owa/calendar/{mailbox_id}@inf.elte.hu/{calendar_key}/calendar.ics";
384        let r = render(
385            tpl,
386            &[
387                part("mailbox_id", "js.doe"),
388                part("calendar_key", "abc-123"),
389            ],
390            Kind::Link,
391        )
392        .unwrap();
393        assert_eq!(
394            r.text,
395            "https://outlook.office365.com/owa/calendar/js.doe@inf.elte.hu/abc-123/calendar.ics"
396        );
397        assert_eq!(r.used, vec!["mailbox_id", "calendar_key"]);
398        assert!(r.unused.is_empty());
399    }
400
401    #[test]
402    fn refuses_an_unfilled_placeholder() {
403        let err = render("https://x/{a}", &[], Kind::Link).unwrap_err();
404        assert_eq!(err, RenderError::UnfilledPlaceholder("a".into()));
405    }
406
407    #[test]
408    fn double_braces_are_literal() {
409        let r = render("{{literal}}", &[], Kind::Custom).unwrap();
410        assert_eq!(r.text, "{literal}");
411    }
412
413    #[test]
414    fn an_unbalanced_brace_is_a_form_error_not_a_literal() {
415        assert!(matches!(
416            render("https://x/{oops", &[], Kind::Link),
417            Err(RenderError::UnbalancedBrace(_))
418        ));
419    }
420
421    #[test]
422    fn one_placeholder_used_twice_is_one_part_filled_twice() {
423        // C3
424        let r = render("{tok}/x/{tok}", &[part("tok", "abc")], Kind::Custom).unwrap();
425        assert_eq!(r.text, "abc/x/abc");
426        assert_eq!(r.used, vec!["tok"]);
427    }
428
429    #[test]
430    fn a_part_no_placeholder_uses_is_reported_not_refused() {
431        let r = render("no holes here", &[part("orphan", "v")], Kind::Custom).unwrap();
432        assert_eq!(r.unused, vec!["orphan"]);
433    }
434
435    #[test]
436    fn userinfo_encoding_stops_at_and_colon_from_moving_the_host() {
437        // The motivating bug: postgres://app:p@ss@db/prod parses with host ss@db.
438        let r = render(
439            "postgres://{user}:{pass}@db.example.com/prod",
440            &[part("user", "app"), part("pass", "p@ss:word")],
441            Kind::Connection,
442        )
443        .unwrap();
444        assert_eq!(r.text, "postgres://app:p%40ss%3Aword@db.example.com/prod");
445    }
446
447    #[test]
448    fn query_value_is_encoded_and_query_name_is_too() {
449        let r = render(
450            "https://x/?{name}={value}",
451            &[
452                part("name", "a b"),
453                part("value", "bot applications.commands"),
454            ],
455            Kind::Link,
456        )
457        .unwrap();
458        assert_eq!(r.text, "https://x/?a%20b=bot%20applications.commands");
459    }
460
461    #[test]
462    fn custom_never_encodes() {
463        let r = render("{a}", &[part("a", "sp ace/slash")], Kind::Custom).unwrap();
464        assert_eq!(r.text, "sp ace/slash");
465    }
466
467    #[test]
468    fn control_character_in_a_url_shaped_part_is_refused() {
469        let err = render("https://x/{a}", &[part("a", "line\nbreak")], Kind::Link).unwrap_err();
470        assert_eq!(err, RenderError::ControlCharacterInPart("a".into()));
471    }
472
473    #[test]
474    fn control_character_in_a_custom_part_is_allowed() {
475        let r = render("{a}", &[part("a", "line\nbreak")], Kind::Custom).unwrap();
476        assert_eq!(r.text, "line\nbreak");
477    }
478
479    #[test]
480    fn placeholders_lists_names_with_no_parts_needed() {
481        assert_eq!(
482            placeholders("https://x/{a}/{b}/{a}").unwrap(),
483            vec!["a".to_string(), "b".to_string()]
484        );
485    }
486
487    #[test]
488    fn an_unknown_kind_string_behaves_like_custom() {
489        // Assuming URL structure over an unrecognised shape corrupts a value
490        // as easily as it protects one.
491        assert_eq!(Kind::parse("nonsense"), Kind::Other);
492        let r = render("{a}", &[part("a", "raw value")], Kind::parse("nonsense")).unwrap();
493        assert_eq!(r.text, "raw value");
494    }
495}