Skip to main content

deps_core/
config_trust.rs

1//! Config-value trust tiers and `${VAR}`/`%VAR%` env-var interpolation (issue #1434).
2//!
3//! [`ConfigTier`](crate::config_trust::ConfigTier) records a config value's provenance. This
4//! module gates **env-var interpolation only**:
5//! [`Project`](crate::config_trust::ConfigTier::Project) values never have placeholders
6//! expanded. It is NOT a secret-binding gate — a
7//! [`User`](crate::config_trust::ConfigTier::User) value is not by itself authorization to
8//! attach a credential; callers keep their own credential-tier enforcement (e.g. deps-nuget's
9//! C2 binding).
10//!
11//! `Windows` + `Project` has no production caller today (deps-nuget reaches this only for
12//! `User`-tier credentials, and rejects `Project`-tier credentials before expansion ever runs);
13//! covered by unit tests only.
14//!
15//! `deps-cargo`'s `Provenance`/`IndexTrust` are intentionally separate from this module (env
16//! overrides, not interpolation) — see that crate's own docs.
17
18// TODO(#1459): centralize env-override-by-name trust (cargo CARGO_REGISTRIES_*, future
19// COMPOSER_AUTH/PIP_INDEX_URL) once a second ecosystem needs it
20
21use zeroize::Zeroizing;
22
23/// Which tier a config value was read from — records provenance for [`expand_env_vars`]'s
24/// interpolation gate. Not a secret-binding gate on its own; see this module's doc.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
26pub enum ConfigTier {
27    /// A committed or ancestor-walked config file — attacker-controlled the instant a hostile
28    /// repository is cloned. Never interpolated.
29    Project,
30    /// A user-home config file (e.g. `~/.npmrc`, a user-profile `NuGet.Config`).
31    ///
32    /// Not attacker-controlled the way a cloned repository's own files are. Interpolation is
33    /// permitted.
34    User,
35}
36
37/// Which placeholder grammar a config value uses.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
39pub enum EnvVarSyntax {
40    /// `${NAME}` (npm's `.npmrc`). The scanner performs no name validation; an unterminated
41    /// `${` is kept as literal text, and `${}` looks up the empty name.
42    Shell,
43    /// `%NAME%` (`NuGet.Config`).
44    ///
45    /// A well-formed reference requires `NAME` to be non-empty and `[A-Za-z0-9_]+`; anything
46    /// else (including a bare `%`, which is URL percent-encoding) is kept as literal text and
47    /// scanning resumes just past it.
48    Windows,
49}
50
51/// Why [`expand_env_vars`] failed.
52#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
53pub enum EnvExpansionError {
54    /// A referenced environment variable is not set. The whole expansion fails — never a
55    /// partial substitution.
56    #[error("environment variable {name:?} referenced in config value is not set")]
57    UndefinedVar {
58        /// The undefined variable's name.
59        name: String,
60    },
61    /// `raw` was [`ConfigTier::Project`] and contained an env-var placeholder.
62    ///
63    /// Interpolation reads the server's own process environment, so a project-tier value
64    /// (attacker-controlled the instant a hostile repository is cloned) could otherwise
65    /// exfiltrate an arbitrary environment variable to a URL the value is later used to build.
66    #[error("env-var expansion is not permitted in a project-tier config value")]
67    NotAllowedInProjectTier,
68}
69
70/// The `${` token opening a [`EnvVarSyntax::Shell`] placeholder — the single source of truth
71/// for both the scanner and the project-tier gate, so a future placeholder syntax addition
72/// cannot silently update one without the other.
73const SHELL_PLACEHOLDER_START: &str = "${";
74
75/// One resolved segment of a scan — literal text (borrowed from the input) or a looked-up
76/// value.
77///
78/// Building the output as a list of segments, then allocating the result buffer at its exact
79/// final capacity in one pass, avoids a `String`/`Zeroizing<String>` reallocation ever leaving
80/// a stale, un-zeroized copy of secret material on the heap.
81enum Segment<'a> {
82    Literal(&'a str),
83    Value(Zeroizing<String>),
84}
85
86fn assemble(segments: &[Segment<'_>]) -> Zeroizing<String> {
87    let total_len: usize = segments
88        .iter()
89        .map(|segment| match segment {
90            Segment::Literal(s) => s.len(),
91            Segment::Value(v) => v.len(),
92        })
93        .sum();
94    let mut out = Zeroizing::new(String::with_capacity(total_len));
95    for segment in segments {
96        match segment {
97            Segment::Literal(s) => out.push_str(s),
98            Segment::Value(v) => out.push_str(v.as_str()),
99        }
100    }
101    out
102}
103
104// All indices come from `find(SHELL_PLACEHOLDER_START)`/`find('}')`, both ASCII tokens, so
105// every slice bound is always a char boundary.
106#[allow(clippy::string_slice)]
107fn scan_shell(
108    raw: &str,
109    lookup: &impl Fn(&str) -> Option<Zeroizing<String>>,
110) -> Result<Zeroizing<String>, EnvExpansionError> {
111    let mut segments = Vec::new();
112    let mut rest = raw;
113    while let Some(start) = rest.find(SHELL_PLACEHOLDER_START) {
114        if start > 0 {
115            segments.push(Segment::Literal(&rest[..start]));
116        }
117        let after = &rest[start + SHELL_PLACEHOLDER_START.len()..];
118        let Some(end) = after.find('}') else {
119            // No closing brace: keep the rest of the string literal.
120            segments.push(Segment::Literal(&rest[start..]));
121            rest = "";
122            break;
123        };
124        let var_name = &after[..end];
125        let value = lookup(var_name).ok_or_else(|| EnvExpansionError::UndefinedVar {
126            name: var_name.to_string(),
127        })?;
128        segments.push(Segment::Value(value));
129        rest = &after[end + 1..];
130    }
131    if !rest.is_empty() {
132        segments.push(Segment::Literal(rest));
133    }
134    Ok(assemble(&segments))
135}
136
137// All indices come from `find('%')`, an ASCII byte, so every slice bound is always a char
138// boundary.
139#[allow(clippy::string_slice)]
140fn scan_windows(
141    raw: &str,
142    lookup: &impl Fn(&str) -> Option<Zeroizing<String>>,
143) -> Result<Zeroizing<String>, EnvExpansionError> {
144    let mut segments = Vec::new();
145    let mut rest = raw;
146    while let Some(pct) = rest.find('%') {
147        let literal = &rest[..pct];
148        let after = &rest[pct + 1..];
149        if let Some(end) = after.find('%') {
150            let name = &after[..end];
151            if !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
152                if !literal.is_empty() {
153                    segments.push(Segment::Literal(literal));
154                }
155                let value = lookup(name).ok_or_else(|| EnvExpansionError::UndefinedVar {
156                    name: name.to_string(),
157                })?;
158                segments.push(Segment::Value(value));
159                rest = &after[end + 1..];
160                continue;
161            }
162        }
163        // Not a well-formed `%NAME%` reference: keep everything up to and including this `%`
164        // as literal text, then keep scanning from just past it.
165        segments.push(Segment::Literal(&rest[..=pct]));
166        rest = &rest[pct + 1..];
167    }
168    if !rest.is_empty() {
169        segments.push(Segment::Literal(rest));
170    }
171    Ok(assemble(&segments))
172}
173
174/// Whether `raw` contains a well-formed [`EnvVarSyntax::Windows`] placeholder.
175///
176/// Runs [`scan_windows`] with a detecting lookup rather than a second, independently-maintained
177/// predicate, so the project-tier gate always agrees with the scanner about what counts as a
178/// reference (e.g. `%x!%A%` is detected via the well-formed `%A%` found once scanning resumes
179/// past the malformed `%x!%`).
180fn windows_has_placeholder(raw: &str) -> bool {
181    let detected = std::cell::Cell::new(false);
182    let _: Result<_, _> = scan_windows(raw, &|_name| {
183        detected.set(true);
184        None
185    });
186    detected.get()
187}
188
189/// Expands every env-var placeholder in `raw` (grammar selected by `syntax`) against `lookup`.
190///
191/// [`ConfigTier::Project`] values are never interpolated: if `raw` contains a placeholder (even
192/// a malformed/unterminated one, for [`EnvVarSyntax::Shell`]), this returns
193/// [`EnvExpansionError::NotAllowedInProjectTier`] before `lookup` is ever called.
194/// [`ConfigTier::Project`] with no placeholder returns the value unchanged (`Ok`) — this is a
195/// passthrough, not a secret filter (see this module's doc).
196///
197/// For [`ConfigTier::User`], every referenced variable is looked up left to right; the first
198/// unset variable fails the whole expansion closed via
199/// [`EnvExpansionError::UndefinedVar`] — never a partial substitution, and `lookup` is never
200/// called for a placeholder after that point. A looked-up value is never itself re-scanned for
201/// placeholders (no recursive expansion).
202///
203/// # Errors
204///
205/// Returns [`EnvExpansionError::NotAllowedInProjectTier`] for a project-tier value containing a
206/// placeholder, or [`EnvExpansionError::UndefinedVar`] naming the first placeholder whose
207/// variable `lookup` does not resolve.
208///
209/// # Examples
210///
211/// ```
212/// use deps_core::config_trust::{ConfigTier, EnvVarSyntax, expand_env_vars};
213/// use zeroize::Zeroizing;
214///
215/// let value = expand_env_vars("${HOME}/repo", EnvVarSyntax::Shell, ConfigTier::User, |name| {
216///     (name == "HOME").then(|| Zeroizing::new("/home/user".to_string()))
217/// })
218/// .unwrap();
219/// assert_eq!(value.as_str(), "/home/user/repo");
220///
221/// // A project-tier value containing a placeholder is rejected before `lookup` ever runs.
222/// let err = expand_env_vars("${HOME}/repo", EnvVarSyntax::Shell, ConfigTier::Project, |_| {
223///     panic!("lookup must not be called for a project-tier value")
224/// })
225/// .unwrap_err();
226/// assert!(matches!(
227///     err,
228///     deps_core::config_trust::EnvExpansionError::NotAllowedInProjectTier
229/// ));
230/// ```
231pub fn expand_env_vars(
232    raw: &str,
233    syntax: EnvVarSyntax,
234    tier: ConfigTier,
235    lookup: impl Fn(&str) -> Option<Zeroizing<String>>,
236) -> Result<Zeroizing<String>, EnvExpansionError> {
237    if tier == ConfigTier::Project {
238        let has_placeholder = match syntax {
239            EnvVarSyntax::Shell => raw.contains(SHELL_PLACEHOLDER_START),
240            EnvVarSyntax::Windows => windows_has_placeholder(raw),
241        };
242        if has_placeholder {
243            return Err(EnvExpansionError::NotAllowedInProjectTier);
244        }
245        return Ok(Zeroizing::new(raw.to_owned()));
246    }
247
248    match syntax {
249        EnvVarSyntax::Shell => scan_shell(raw, &lookup),
250        EnvVarSyntax::Windows => scan_windows(raw, &lookup),
251    }
252}
253
254/// Looks up `name` in the current process environment.
255///
256/// The production `lookup` for [`expand_env_vars`] in both `deps-npm` and `deps-nuget`. A
257/// non-UTF-8 value is treated as unset, same as [`std::env::var`].
258///
259/// # Examples
260///
261/// ```
262/// use deps_core::config_trust::process_env;
263///
264/// assert!(process_env("DEPS_LSP_CONFIG_TRUST_DOCTEST_UNSET_VAR_XYZ").is_none());
265/// ```
266#[must_use]
267pub fn process_env(name: &str) -> Option<Zeroizing<String>> {
268    std::env::var(name).ok().map(Zeroizing::new)
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    fn z(s: &str) -> Zeroizing<String> {
276        Zeroizing::new(s.to_string())
277    }
278
279    // --- Shell syntax ---
280
281    #[test]
282    fn shell_no_placeholder_passthrough() {
283        assert_eq!(
284            expand_env_vars(
285                "https://npm.example/",
286                EnvVarSyntax::Shell,
287                ConfigTier::User,
288                |_| None
289            )
290            .unwrap()
291            .as_str(),
292            "https://npm.example/"
293        );
294    }
295
296    #[test]
297    fn shell_defined_var_expands() {
298        let result = expand_env_vars(
299            "${NPM_REGISTRY}/",
300            EnvVarSyntax::Shell,
301            ConfigTier::User,
302            |name| (name == "NPM_REGISTRY").then(|| z("https://npm.mycorp.example")),
303        );
304        assert_eq!(result.unwrap().as_str(), "https://npm.mycorp.example/");
305    }
306
307    #[test]
308    fn shell_undefined_var_errors() {
309        let result = expand_env_vars(
310            "${UNDEFINED_VAR}",
311            EnvVarSyntax::Shell,
312            ConfigTier::User,
313            |_| None,
314        );
315        assert_eq!(
316            result,
317            Err(EnvExpansionError::UndefinedVar {
318                name: "UNDEFINED_VAR".to_string()
319            })
320        );
321    }
322
323    #[test]
324    fn shell_unterminated_placeholder_is_literal() {
325        assert_eq!(
326            expand_env_vars("a${B", EnvVarSyntax::Shell, ConfigTier::User, |_| {
327                panic!("lookup must not be called")
328            })
329            .unwrap()
330            .as_str(),
331            "a${B"
332        );
333    }
334
335    #[test]
336    fn shell_empty_name_is_undefined() {
337        let result = expand_env_vars("${}", EnvVarSyntax::Shell, ConfigTier::User, |name| {
338            assert_eq!(name, "");
339            None
340        });
341        assert_eq!(
342            result,
343            Err(EnvExpansionError::UndefinedVar {
344                name: String::new()
345            })
346        );
347    }
348
349    #[test]
350    fn shell_adjacent_placeholders() {
351        let lookup = |name: &str| match name {
352            "A" => Some(z("1")),
353            "B" => Some(z("2")),
354            _ => None,
355        };
356        assert_eq!(
357            expand_env_vars("${A}${B}", EnvVarSyntax::Shell, ConfigTier::User, lookup)
358                .unwrap()
359                .as_str(),
360            "12"
361        );
362    }
363
364    #[test]
365    fn shell_dollar_before_placeholder() {
366        let lookup = |name: &str| (name == "A").then(|| z("1"));
367        assert_eq!(
368            expand_env_vars("$${A}", EnvVarSyntax::Shell, ConfigTier::User, lookup)
369                .unwrap()
370                .as_str(),
371            "$1"
372        );
373    }
374
375    #[test]
376    fn shell_trailing_brace_is_literal() {
377        let lookup = |name: &str| (name == "A").then(|| z("1"));
378        assert_eq!(
379            expand_env_vars("${A}}", EnvVarSyntax::Shell, ConfigTier::User, lookup)
380                .unwrap()
381                .as_str(),
382            "1}"
383        );
384    }
385
386    #[test]
387    fn shell_surrounding_literal_text() {
388        let lookup = |name: &str| (name == "A").then(|| z("1"));
389        assert_eq!(
390            expand_env_vars("pre${A}post", EnvVarSyntax::Shell, ConfigTier::User, lookup)
391                .unwrap()
392                .as_str(),
393            "pre1post"
394        );
395    }
396
397    #[test]
398    fn shell_looked_up_value_is_not_recursively_expanded() {
399        let lookup = |name: &str| (name == "A").then(|| z("${B}"));
400        assert_eq!(
401            expand_env_vars("${A}", EnvVarSyntax::Shell, ConfigTier::User, lookup)
402                .unwrap()
403                .as_str(),
404            "${B}"
405        );
406    }
407
408    #[test]
409    fn shell_first_unset_var_short_circuits() {
410        let calls = std::cell::RefCell::new(Vec::new());
411        let lookup = |name: &str| {
412            calls.borrow_mut().push(name.to_string());
413            None
414        };
415        let result = expand_env_vars("${A}${B}", EnvVarSyntax::Shell, ConfigTier::User, lookup);
416        assert_eq!(
417            result,
418            Err(EnvExpansionError::UndefinedVar {
419                name: "A".to_string()
420            })
421        );
422        assert_eq!(*calls.borrow(), vec!["A".to_string()]);
423    }
424
425    // --- Windows syntax ---
426
427    #[test]
428    fn windows_set_and_unset() {
429        let set = expand_env_vars(
430            "%CORP_FEED_PAT%",
431            EnvVarSyntax::Windows,
432            ConfigTier::User,
433            |name| (name == "CORP_FEED_PAT").then(|| z("secret-pat")),
434        );
435        assert_eq!(set.unwrap().as_str(), "secret-pat");
436
437        let unset = expand_env_vars(
438            "%CORP_FEED_PAT%",
439            EnvVarSyntax::Windows,
440            ConfigTier::User,
441            |_| None,
442        );
443        assert_eq!(
444            unset,
445            Err(EnvExpansionError::UndefinedVar {
446                name: "CORP_FEED_PAT".to_string()
447            })
448        );
449    }
450
451    #[test]
452    fn windows_edge_cases() {
453        let lookup = |name: &str| match name {
454            "A" => Some(z("1")),
455            "B" => Some(z("2")),
456            _ => None,
457        };
458        assert_eq!(
459            expand_env_vars(
460                "pre-%A%-post",
461                EnvVarSyntax::Windows,
462                ConfigTier::User,
463                lookup
464            )
465            .unwrap()
466            .as_str(),
467            "pre-1-post"
468        );
469        assert_eq!(
470            expand_env_vars("%A%%B%", EnvVarSyntax::Windows, ConfigTier::User, lookup)
471                .unwrap()
472                .as_str(),
473            "12"
474        );
475        assert_eq!(
476            expand_env_vars(
477                "abc%1bad!name%def",
478                EnvVarSyntax::Windows,
479                ConfigTier::User,
480                lookup
481            )
482            .unwrap()
483            .as_str(),
484            "abc%1bad!name%def"
485        );
486        assert_eq!(
487            expand_env_vars("abc%A", EnvVarSyntax::Windows, ConfigTier::User, lookup)
488                .unwrap()
489                .as_str(),
490            "abc%A"
491        );
492        assert_eq!(
493            expand_env_vars("%%", EnvVarSyntax::Windows, ConfigTier::User, lookup)
494                .unwrap()
495                .as_str(),
496            "%%"
497        );
498        assert_eq!(
499            expand_env_vars(
500                "pre-%UNSET%-post",
501                EnvVarSyntax::Windows,
502                ConfigTier::User,
503                lookup
504            ),
505            Err(EnvExpansionError::UndefinedVar {
506                name: "UNSET".to_string()
507            })
508        );
509    }
510
511    #[test]
512    fn windows_malformed_then_well_formed() {
513        let lookup = |name: &str| (name == "A").then(|| z("1"));
514        assert_eq!(
515            expand_env_vars("%x!%A%", EnvVarSyntax::Windows, ConfigTier::User, lookup)
516                .unwrap()
517                .as_str(),
518            "%x!1"
519        );
520    }
521
522    #[test]
523    fn windows_looked_up_value_is_not_recursively_expanded() {
524        let lookup = |name: &str| (name == "A").then(|| z("%B%"));
525        assert_eq!(
526            expand_env_vars("%A%", EnvVarSyntax::Windows, ConfigTier::User, lookup)
527                .unwrap()
528                .as_str(),
529            "%B%"
530        );
531    }
532
533    #[test]
534    fn windows_first_unset_var_short_circuits() {
535        let calls = std::cell::RefCell::new(Vec::new());
536        let lookup = |name: &str| {
537            calls.borrow_mut().push(name.to_string());
538            None
539        };
540        let result = expand_env_vars("%A%%B%", EnvVarSyntax::Windows, ConfigTier::User, lookup);
541        assert_eq!(
542            result,
543            Err(EnvExpansionError::UndefinedVar {
544                name: "A".to_string()
545            })
546        );
547        assert_eq!(*calls.borrow(), vec!["A".to_string()]);
548    }
549
550    // --- project-tier gate ---
551
552    #[test]
553    fn shell_project_tier_rejects_unterminated_placeholder() {
554        let result = expand_env_vars("a${B", EnvVarSyntax::Shell, ConfigTier::Project, |_| {
555            panic!("lookup must not be called for a project-tier value")
556        });
557        assert_eq!(result, Err(EnvExpansionError::NotAllowedInProjectTier));
558    }
559
560    #[test]
561    fn shell_project_tier_rejects_defined_var() {
562        let result = expand_env_vars("${PATH}", EnvVarSyntax::Shell, ConfigTier::Project, |_| {
563            panic!("lookup must not be called for a project-tier value")
564        });
565        assert_eq!(result, Err(EnvExpansionError::NotAllowedInProjectTier));
566    }
567
568    #[test]
569    fn shell_project_tier_no_placeholder_is_passthrough() {
570        let result = expand_env_vars(
571            "https://npm.example/",
572            EnvVarSyntax::Shell,
573            ConfigTier::Project,
574            |_| panic!("lookup must not be called"),
575        );
576        assert_eq!(result.unwrap().as_str(), "https://npm.example/");
577    }
578
579    #[test]
580    fn windows_project_tier_rejects_well_formed_placeholder() {
581        let result = expand_env_vars("%A%", EnvVarSyntax::Windows, ConfigTier::Project, |_| {
582            panic!("lookup must not be called for a project-tier value")
583        });
584        assert_eq!(result, Err(EnvExpansionError::NotAllowedInProjectTier));
585    }
586
587    #[test]
588    fn windows_project_tier_rejects_malformed_then_well_formed() {
589        let result = expand_env_vars("%x!%A%", EnvVarSyntax::Windows, ConfigTier::Project, |_| {
590            panic!("lookup must not be called for a project-tier value")
591        });
592        assert_eq!(result, Err(EnvExpansionError::NotAllowedInProjectTier));
593    }
594
595    #[test]
596    fn windows_project_tier_percent_encoding_not_rejected() {
597        let result = expand_env_vars("%20", EnvVarSyntax::Windows, ConfigTier::Project, |_| {
598            panic!("lookup must not be called: %20 is not a well-formed placeholder")
599        });
600        assert_eq!(result.unwrap().as_str(), "%20");
601    }
602
603    // --- process_env ---
604
605    #[test]
606    fn process_env_unset_var_is_none() {
607        assert!(process_env("DEPS_LSP_CONFIG_TRUST_TEST_UNSET_VAR_XYZ").is_none());
608    }
609}