pmcp_server_toolkit/env_ref.rs
1//! The ONE place the `${VAR}` / `env:VAR` reference grammar is defined for the
2//! whole toolkit.
3//!
4//! Every path that reads an operator-supplied value which MAY be an environment
5//! reference routes through [`parse_env_ref`]:
6//!
7//! - outgoing credentials (`[backend.auth]` api_key / bearer token / basic
8//! password / oauth2 client_secret) — `crate::http::auth`;
9//! - `[code_mode] token_secret` — `crate::code_mode`;
10//! - `[backend] base_url` — [`crate::config::BackendSection::resolved_base_url`].
11//!
12//! # Why this module is NOT feature-gated
13//!
14//! The function previously lived as a private helper inside `crate::http::auth`,
15//! and the whole `http` module is `#[cfg(feature = "http")]`. It compiled
16//! (`BackendSection` is itself http-gated), but the accompanying claim — "the
17//! single chokepoint every env-reference path shares" — was reachable only in
18//! `http` builds, so it was architecturally false. Packaging tooling is about to
19//! build a cross-crate grammar-parity claim on top of that statement, so the
20//! statement has to be structurally true rather than aspirational: this module
21//! carries NO `#[cfg(feature = ...)]` gate and compiles in every feature
22//! configuration.
23//!
24//! # Grammar
25//!
26//! | Input | Result | Meaning |
27//! |---|---|---|
28//! | `env:VAR` | `Some("VAR")` | reference |
29//! | `${VAR}` | `Some("VAR")` | reference (`VAR` must match `[A-Za-z0-9_]+`) |
30//! | `${}` | `Some("")` | MALFORMED reference — a reference to an empty name |
31//! | `${A}://${B}` | `Some("")` | MALFORMED reference — a multi-placeholder composition |
32//! | `${VAR` | `None` | unterminated → a plain literal |
33//! | `plain` | `None` | plain literal |
34//!
35//! The malformed-means-empty rule is deliberate: the empty name is the signal
36//! that a value is reference-SHAPED but names nothing, so no caller ships the
37//! literal `${...}` text to the wire. A `${...}` value is a reference to exactly
38//! ONE variable — the grammar does not interpolate inside a larger string, so a
39//! composition like `${SCHEME}://${HOST}` names nothing any environment could
40//! set; compose the full value in one variable instead.
41//!
42//! # Callers diverge on UNSET, agree on MALFORMED, never diverge on PARSING
43//!
44//! Two different questions, two different answers:
45//!
46//! - **Unset variable** (`Some("VAR")`, `VAR` not exported) — caller policy, and
47//! it legitimately differs. A credential resolves it to the empty string so an
48//! OPTIONAL credential is omitted; an endpoint errors, because an empty
49//! endpoint is not a degraded request but a broken one.
50//! - **Malformed reference** (`Some("")`) — NOT caller policy. Every caller
51//! refuses it, because no environment could ever satisfy it, so "omit" would
52//! mean silently proceeding without a value the operator believed they had
53//! supplied. The credential path once applied the unset rule here, which made
54//! `token = "${GITHUB-PAT}"` send every backend request unauthenticated with
55//! no error and no log line; it is now refused at load time
56//! ([`crate::error::ConfigValidationError::MalformedBackendAuthRef`]) and at
57//! provider-build time, matching `base_url`'s
58//! [`crate::error::ConfigValidationError::MalformedBackendBaseUrlRef`].
59//!
60//! What must NOT differ is the PARSE: a second `${}` parser with slightly
61//! different edge cases is a latent security bug (one parser treating `${VAR` as
62//! a reference and another as a literal is exactly how a placeholder reaches the
63//! wire).
64//!
65//! Note that `pmcp-package` deliberately DUPLICATES this grammar rather than
66//! depending on the toolkit — that crate is the workspace-excluded leaf and a
67//! dependency in either direction inverts the layering. The duplication is kept
68//! honest by a package-side grammar-parity table, not by a shared type.
69
70/// The single brace/env-ref parse core shared by EVERY credential-resolution
71/// path (api_key, bearer token, basic password, oauth2 client_secret).
72///
73/// Returns `Some(var_name)` when `raw` is a secret REFERENCE — either the
74/// `"env:VAR"` or the `"${VAR}"` form — and `None` for a plain literal (which the
75/// caller uses verbatim). A malformed brace reference (e.g. `"${}"`) is treated
76/// as a reference to an empty name, i.e. `Some("")`, so the caller resolves it to
77/// the empty string (omission) rather than shipping the literal `${}`.
78///
79/// This consolidates the two brace parsers that previously existed (the inline
80/// `${`-strip in the old api_key resolver in `crate::http::auth` and
81/// `expand_braced_var` in `crate::code_mode`): all env-reference resolution now
82/// flows through this one chokepoint so the discipline cannot drift per-variant.
83///
84/// # Why this is `pub`
85///
86/// The grammar is duplicated by necessity in `pmcp-package`'s
87/// `is_env_reference` — that crate is the workspace-excluded leaf and neither
88/// crate may depend on the other. The two implementations are held to a shared
89/// accept/reject table asserted from an INTEGRATION test in each crate
90/// (`tests/env_ref_grammar_parity.rs` here). An integration test is an external
91/// consumer, so the reference implementation has to be reachable from outside
92/// the crate for that parity claim to be checkable at all.
93///
94/// (It was previously `pub(crate)` with an `#[allow(dead_code)]`, because in a
95/// `--no-default-features` build neither `http` nor `code-mode` is compiled and
96/// no in-crate caller exists. Being `pub` removes that need: the module is
97/// still deliberately UNGATED, so the grammar is defined in one place that
98/// compiles in every feature configuration.)
99pub fn parse_env_ref(raw: &str) -> Option<&str> {
100 if let Some(v) = raw.strip_prefix("env:") {
101 Some(v)
102 } else {
103 // `${...}` → the inner name when it is a valid variable name. Any
104 // other interior — the empty `${}` form, or a multi-placeholder
105 // composition like `${A}://${B}` (whose interior would be the
106 // unsettable `A}://${B`) — is MALFORMED: a reference to the empty
107 // name. Callers resolve that to omission (credentials) or an error
108 // (endpoints), so a placeholder never reaches the wire as a literal.
109 raw.strip_prefix("${")
110 .and_then(|s| s.strip_suffix('}'))
111 .map(|name| {
112 if is_valid_env_var_name(name) {
113 name
114 } else {
115 ""
116 }
117 })
118 }
119}
120
121/// Whether `name` is a variable name a target environment can actually be
122/// told to set: non-empty, ASCII alphanumerics and `_` only — the portable
123/// intersection of POSIX shells, env files, and container manifests.
124///
125/// The `${NAME}` form requires this (it is the form config authors compose
126/// by accident — `${SCHEME}://${HOST}` must not silently become one garbage
127/// reference). The explicit `env:NAME` form deliberately does NOT: its prefix
128/// is unambiguous, so it stays the runtime escape hatch for exotic names.
129pub fn is_valid_env_var_name(name: &str) -> bool {
130 !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
131}
132
133#[cfg(test)]
134mod tests {
135 use super::parse_env_ref;
136 use proptest::prelude::*;
137
138 #[test]
139 fn test_parse_env_ref_distinguishes_literal_from_reference() {
140 assert_eq!(parse_env_ref("env:FOO"), Some("FOO"));
141 assert_eq!(parse_env_ref("${FOO}"), Some("FOO"));
142 assert_eq!(parse_env_ref("${}"), Some("")); // malformed-but-a-reference
143 assert_eq!(parse_env_ref("plain"), None);
144 assert_eq!(parse_env_ref("${FOO"), None); // unterminated → literal
145 }
146
147 #[test]
148 fn test_multi_placeholder_compositions_are_malformed_references() {
149 // A `${...}` value references exactly ONE variable; a composition is
150 // reference-SHAPED (so it must never ship as a literal) but names
151 // nothing settable → the empty name, same as `${}`.
152 assert_eq!(parse_env_ref("${TFL_SCHEME}://${TFL_HOST}"), Some(""));
153 assert_eq!(parse_env_ref("${A}-${B}"), Some(""));
154 // A dash is not portably settable; `env:` remains the escape hatch.
155 assert_eq!(parse_env_ref("${TFL-HOST}"), Some(""));
156 assert_eq!(parse_env_ref("env:TFL-HOST"), Some("TFL-HOST"));
157 }
158
159 proptest! {
160 /// PROPERTY/FUZZ (CLAUDE.md ALWAYS): the grammar chokepoint is total —
161 /// every input yields `Some`/`None`, never a panic. This is the toolkit
162 /// half of the guarantee `pmcp-package` pins for its duplicate
163 /// (`config_validation.rs`'s never-panic properties); a parser that can
164 /// unwind on adversarial config text would take config loading down
165 /// with it.
166 #[test]
167 fn parse_env_ref_never_panics_on_arbitrary_text(raw in "\\PC{0,256}") {
168 let _ = parse_env_ref(&raw);
169 }
170
171 /// PROPERTY: both reference forms round-trip any brace-free,
172 /// colon-agnostic name — `${NAME}` and `env:NAME` must parse back to
173 /// exactly `NAME`, so the two forms can never diverge on which
174 /// variable they address.
175 #[test]
176 fn both_reference_forms_recover_the_exact_variable_name(
177 name in "[A-Za-z0-9_]{1,64}"
178 ) {
179 let braced = format!("${{{name}}}");
180 prop_assert_eq!(parse_env_ref(&braced), Some(name.as_str()));
181 let prefixed = format!("env:{name}");
182 prop_assert_eq!(parse_env_ref(&prefixed), Some(name.as_str()));
183 }
184
185 /// PROPERTY: a value that starts with neither `env:` nor `${` is
186 /// ALWAYS a plain literal (`None`) — the rule that keeps an Athena
187 /// `output_location` containing `${` mid-string, or any URL, from
188 /// being misread as a reference.
189 #[test]
190 fn values_without_a_reference_prefix_are_always_literals(
191 raw in "\\PC{0,256}"
192 ) {
193 prop_assume!(!raw.starts_with("env:") && !raw.starts_with("${"));
194 prop_assert_eq!(parse_env_ref(&raw), None);
195 }
196
197 /// PROPERTY: a braced form NEVER yields an unsettable name — whatever
198 /// the interior, the parse is either a valid variable name or the
199 /// empty (malformed) name. This is the invariant that keeps a
200 /// multi-placeholder composition from being looked up in the
201 /// environment as one garbage variable.
202 #[test]
203 fn brace_forms_never_yield_an_unsettable_name(
204 interior in "\\PC{0,64}"
205 ) {
206 let raw = format!("${{{interior}}}");
207 match parse_env_ref(&raw) {
208 Some(name) => prop_assert!(
209 name.is_empty() || super::is_valid_env_var_name(name)
210 ),
211 None => prop_assert!(false, "a `${{...}}` wrap must parse as a reference"),
212 }
213 }
214 }
215}