Interpolation foundation (InterpString v2) (#472)

# Interpolation foundation (InterpString v2)

First step of unifying config-string interpolation across Fabro. This PR
is the
**behavior-neutral foundation** only — it introduces the type machinery
and a
clippy gate, but changes no field's interpolation behavior. The actual
field
work follows as separate stacked PRs, sequenced **reduce-first**:
narrowing
changes (demote fields that shouldn't interpolate, de-template DOT
attrs) land
before capability additions (resolve env in MCP / prepare / hooks).

## Why

Config strings interpolate `{{ ... }}` inconsistently today — some
fields
resolve `{{ env.X }}`, others are typed as if they do but silently pass
the
literal template text downstream. We're converging on three field types
(`String`, `InterpString`, and later an importable template for
prompts/goals)
with four namespaces (`env`, `vars`, `secrets`, `inputs`). This PR lays
the
`InterpString` foundation; it does not migrate any field.

## What's in it

- Segments generalize to `Token { namespace, name }` with a `Namespace`
enum
(`env`/`vars`/`secrets`/`inputs`). `secrets`/`inputs` are **reserved** —
  parsed as tokens ahead of their resolvers.
- `ResolveCtx` with per-namespace lookups. `resolve_with()` fails loudly
  (`Unavailable`) for a token whose namespace isn't provided in context;
`substitute_with()` substitutes provided namespaces and preserves the
rest.
`resolve()` / `substitute_variables()` are thin wrappers over one core
path.
- `ResolveEnvError` → `ResolveError { namespace, name, kind: Missing |
Unavailable }`
(message text unchanged for env/vars; the kind no longer bakes the
namespace
  in, so it scales to four namespaces without an enum explosion).
- `Provenance` tracks secret-sourced names alongside env-sourced, for
uniform
  redaction later.
- **`as_source()` is clippy-gated** (`disallowed-methods`). It keeps its
name;
  every call site carries an `#[expect(..., reason)]` classifying it
(serialization, error display, known-leak-pending-fix, demotion-pending,
test). The lint turns the leak surface into a greppable, reasoned
work-list
  and the method stays for its permanent uses (serde round-trip of the
  unresolved template + diagnostics).
- fabro-server: five duplicate `process_env_var` facades and two
duplicate
  `resolve_interp` helpers consolidated into one `crate::interp` module.

## Behavior changes (honest list)

- **`{{ secrets.* }}` / `{{ inputs.* }}` are now reserved.** On main
they
  weren't recognized as tokens → silent literal passthrough. Now, at
`resolve()` consumers they **fail loud** (`Unavailable`) instead of
passing
the literal string through (nobody wants the literal characters as a
value —
  strictly better, but technically a change). At `as_source` sites they
  round-trip unchanged. Actual resolution lands in later enhancing PRs.
- Some fabro-server resolution errors gain a `"failed to resolve
<source>"`
  context line.

Otherwise behavior-neutral: every field resolves exactly as it did on
main.

## What's deferred to follow-up PRs (reduce-first order)

- **Reducing / cleanup (next):** demote leak fields to `String`
  (`run.model.*`, `cli.exec.model.*`, `run.git.author.*`,
  `run.scm.owner/repository`); de-template `condition`/`label`/`model`/
  `provider`/`speed` and `output_schema`.
- **Enhancing (after):** resolve `{{ env.* }}` in MCP transports,
prepare
  steps, and hooks; wire `secrets`/`inputs`.

## Verification

- `cargo build --workspace`
- `cargo nextest run --workspace` → 6449 passed, 181 skipped
- `cargo +nightly fmt --check --all`
- `cargo +nightly clippy --workspace --all-targets -- -D warnings` →
clean

## Reviewer notes

- The reserved-namespace `Unavailable` error for `secrets`/`inputs` is
  **intentional**, not a missing case — they're parsed ahead of their
  resolvers so misuse fails loud instead of leaking.
- `as_source` is clippy-gated but keeps its name deliberately — the gate
is
  the enforcement; renaming was avoided as unnecessary churn.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scott Werner 2026-06-10 12:51:08 -04:00 committed by GitHub
parent 3985eaf1d7
commit ce404cddef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 822 additions and 342 deletions

View file

@ -31,6 +31,7 @@ disallowed-methods = [
{ path = "reqwest::blocking::Client::new", reason = "Use fabro_http::blocking_http_client() or fabro_http::blocking_test_http_client()", allow-invalid = true }, { path = "reqwest::blocking::Client::new", reason = "Use fabro_http::blocking_http_client() or fabro_http::blocking_test_http_client()", allow-invalid = true },
{ path = "reqwest::blocking::Client::builder", reason = "Use fabro_http::BlockingHttpClientBuilder::new()", allow-invalid = true }, { path = "reqwest::blocking::Client::builder", reason = "Use fabro_http::BlockingHttpClientBuilder::new()", allow-invalid = true },
{ path = "reqwest::get", reason = "Build a fabro_http client and send the request explicitly", allow-invalid = true }, { path = "reqwest::get", reason = "Build a fabro_http client and send the request explicitly", allow-invalid = true },
{ path = "fabro_types::settings::interp::InterpString::as_source", reason = "Returns the unresolved template source, which leaks {{ ... }} tokens as literal text downstream. Resolve via resolve()/resolve_with() or substitute via substitute_with() instead; document intentional raw-source access (serialization, error messages, deliberate source preservation) with #[expect(clippy::disallowed_methods, reason = \"...\")]", allow-invalid = true },
] ]
disallowed-types = [ disallowed-types = [
{ path = "std::io::Read", reason = "Blocking trait; prefer tokio::io::AsyncReadExt on Tokio paths. Document intentional sync I/O with #[expect(clippy::disallowed_types, reason = \"...\")]" }, { path = "std::io::Read", reason = "Blocking trait; prefer tokio::io::AsyncReadExt on Tokio paths. Document intentional sync I/O with #[expect(clippy::disallowed_types, reason = \"...\")]" },

View file

@ -51,6 +51,11 @@ impl GitAuthor {
} }
} }
#[expect(
clippy::disallowed_methods,
reason = "raw source is today's behavior; run.git.author.* is slated for demotion to plain String in the \
interpolation unification (D2)"
)]
impl From<&GitAuthorLayer> for GitAuthor { impl From<&GitAuthorLayer> for GitAuthor {
fn from(value: &GitAuthorLayer) -> Self { fn from(value: &GitAuthorLayer) -> Self {
Self::from_options( Self::from_options(
@ -60,6 +65,11 @@ impl From<&GitAuthorLayer> for GitAuthor {
} }
} }
#[expect(
clippy::disallowed_methods,
reason = "raw source is today's behavior; run.git.author.* is slated for demotion to plain String in the \
interpolation unification (D2)"
)]
impl From<&GitAuthorSettings> for GitAuthor { impl From<&GitAuthorSettings> for GitAuthor {
fn from(value: &GitAuthorSettings) -> Self { fn from(value: &GitAuthorSettings) -> Self {
Self::from_options( Self::from_options(

View file

@ -276,6 +276,11 @@ impl ProviderAdapter for AuthenticatedFabroServerAdapter {
} }
} }
#[expect(
clippy::disallowed_methods,
reason = "raw source is today's behavior; cli.exec.model.* is slated for demotion to plain String in the \
interpolation unification (D2)"
)]
pub(crate) async fn execute(mut args: ExecArgs, ctx: &CommandContext) -> AnyResult<()> { pub(crate) async fn execute(mut args: ExecArgs, ctx: &CommandContext) -> AnyResult<()> {
use fabro_agent::cli::PermissionLevel as AgentPermissionLevel; use fabro_agent::cli::PermissionLevel as AgentPermissionLevel;
use fabro_types::settings::run::AgentPermissions; use fabro_types::settings::run::AgentPermissions;

View file

@ -114,6 +114,10 @@ pub(crate) fn preflight_args_overrides(args: &PreflightArgs) -> Result<ManifestS
} }
#[cfg(test)] #[cfg(test)]
#[expect(
clippy::disallowed_methods,
reason = "tests assert the raw template source"
)]
mod tests { mod tests {
use super::*; use super::*;

View file

@ -1108,6 +1108,11 @@ fn stamp_system_worker(mut event: RunEvent) -> RunEvent {
event event
} }
#[expect(
clippy::disallowed_methods,
reason = "known leak: GitHub App id/slug passes unresolved; strict resolution scheduled in the \
interpolation unification (Phase 2)"
)]
fn maybe_build_github_credentials( fn maybe_build_github_credentials(
settings: &WorkflowSettings, settings: &WorkflowSettings,
vault: Option<&fabro_vault::Vault>, vault: Option<&fabro_vault::Vault>,

View file

@ -86,6 +86,10 @@ fn process_env_var(name: &str) -> Option<String> {
std::env::var(name).ok() std::env::var(name).ok()
} }
#[expect(
clippy::disallowed_methods,
reason = "raw source shown in the error message when resolution fails"
)]
fn storage_dir_from_toml_with_lookup( fn storage_dir_from_toml_with_lookup(
source: &str, source: &str,
lookup: &dyn Fn(&str) -> Option<String>, lookup: &dyn Fn(&str) -> Option<String>,

View file

@ -222,6 +222,12 @@ fn value_at_path<'a>(document: &'a toml::Value, path: &[&str]) -> Option<&'a tom
/// Pull the resolved CLI target configuration out of `[cli.target]`. /// Pull the resolved CLI target configuration out of `[cli.target]`.
/// Returns either an http(s) URL or a unix socket path. /// Returns either an http(s) URL or a unix socket path.
#[expect(
clippy::disallowed_methods,
reason = "known leak: cli.target.* is a url/connection field that should resolve {{ env.* }} \
tokens but consumes them raw today; strict resolution scheduled in the \
interpolation unification (Phase 2 keep-rows)"
)]
fn cli_target_from_settings(settings: &CliNamespace) -> Option<String> { fn cli_target_from_settings(settings: &CliNamespace) -> Option<String> {
let target = settings.target.as_ref()?; let target = settings.target.as_ref()?;
match target { match target {

View file

@ -347,6 +347,10 @@ digraph FooWorkflow {
); );
} }
#[expect(
clippy::disallowed_methods,
reason = "test asserts the raw template source"
)]
#[test] #[test]
fn create_persists_requested_overrides_into_store() { fn create_persists_requested_overrides_into_store() {
let context = test_context!(); let context = test_context!();

View file

@ -417,6 +417,10 @@ provider = "daytona"
assert!(migrated.is_none()); assert!(migrated.is_none());
} }
#[expect(
clippy::disallowed_methods,
reason = "test asserts the raw template source"
)]
#[test] #[test]
fn daytona_snapshot_labels_lifecycle_and_volumes_migrate() { fn daytona_snapshot_labels_lifecycle_and_volumes_migrate() {
let migrated = migrate( let migrated = migrate(

View file

@ -693,6 +693,10 @@ command = ["demo-mcp"]
); );
} }
#[expect(
clippy::disallowed_methods,
reason = "test asserts the raw template source"
)]
#[test] #[test]
fn workflow_builder_preserves_run_overrides_when_cli_overrides_are_added() { fn workflow_builder_preserves_run_overrides_when_cli_overrides_are_added() {
let settings = WorkflowSettingsBuilder::new() let settings = WorkflowSettingsBuilder::new()

View file

@ -38,6 +38,10 @@ pub(crate) fn load_settings_path(path: &Path, source: SettingsSource) -> Result<
Ok(layer) Ok(layer)
} }
#[expect(
clippy::disallowed_methods,
reason = "guarded by is_literal() above; the raw source is the literal value"
)]
pub(crate) fn resolve_goal_file_paths(file: &mut SettingsLayer, base_dir: &Path) { pub(crate) fn resolve_goal_file_paths(file: &mut SettingsLayer, base_dir: &Path) {
let Some(run) = file.run.as_mut() else { let Some(run) = file.run.as_mut() else {
return; return;

View file

@ -144,6 +144,12 @@ fn sibling_workflow_toml_for(graph: &Path) -> Option<PathBuf> {
(toml_graph == graph).then_some(candidate) (toml_graph == graph).then_some(candidate)
} }
#[expect(
clippy::disallowed_methods,
reason = "known leak: run.working_dir is a path kept as InterpString (not demoted); it should \
resolve {{ env.* }}/{{ vars.* }} tokens but consumes them raw today; strict \
resolution scheduled in the interpolation unification (Phase 2 keep-rows)"
)]
pub fn resolve_working_directory_from_run(run: &RunNamespace, caller_cwd: &Path) -> PathBuf { pub fn resolve_working_directory_from_run(run: &RunNamespace, caller_cwd: &Path) -> PathBuf {
let Some(work_dir) = run.working_dir.as_ref().map(InterpString::as_source) else { let Some(work_dir) = run.working_dir.as_ref().map(InterpString::as_source) else {
return caller_cwd.to_path_buf(); return caller_cwd.to_path_buf();

View file

@ -29,6 +29,11 @@ pub(crate) fn require_interp(
}) })
} }
#[expect(
clippy::disallowed_methods,
reason = "parsed_value special case: the TCP listen address parses the literal source \
(templates intentionally unsupported)"
)]
pub(crate) fn parse_socket_addr( pub(crate) fn parse_socket_addr(
value: &InterpString, value: &InterpString,
path: &str, path: &str,

View file

@ -141,6 +141,11 @@ fn resolve_git(git: Option<&RunGitLayer>) -> RunGitSettings {
} }
} }
#[expect(
clippy::disallowed_methods,
reason = "known leak: prepare step templates collapse to raw source unresolved; strict \
resolution scheduled in the interpolation unification (Phase 2)"
)]
fn resolve_prepare( fn resolve_prepare(
prepare: Option<&RunPrepareLayer>, prepare: Option<&RunPrepareLayer>,
errors: &mut Vec<ResolveError>, errors: &mut Vec<ResolveError>,
@ -281,6 +286,11 @@ fn resolve_agent(agent: Option<&RunAgentLayer>) -> RunAgentSettings {
} }
} }
#[expect(
clippy::disallowed_methods,
reason = "known leak: MCP transport templates collapse to raw source unresolved; strict \
resolution scheduled in the interpolation unification (Phase 2, supersedes PR #370)"
)]
pub(crate) fn resolve_mcp_entry(name: &str, entry: &McpEntryLayer) -> McpServerSettings { pub(crate) fn resolve_mcp_entry(name: &str, entry: &McpEntryLayer) -> McpServerSettings {
let transport = match entry { let transport = match entry {
McpEntryLayer::Stdio { McpEntryLayer::Stdio {
@ -357,6 +367,11 @@ pub(crate) fn resolve_mcp_entry(name: &str, entry: &McpEntryLayer) -> McpServerS
} }
} }
#[expect(
clippy::disallowed_methods,
reason = "known leak: MCP transport templates collapse to raw source unresolved; strict \
resolution scheduled in the interpolation unification (Phase 2, supersedes PR #370)"
)]
fn resolve_mcp_command( fn resolve_mcp_command(
script: Option<&InterpString>, script: Option<&InterpString>,
command: Option<&Vec<InterpString>>, command: Option<&Vec<InterpString>>,
@ -369,6 +384,11 @@ fn resolve_mcp_command(
.unwrap_or_default() .unwrap_or_default()
} }
#[expect(
clippy::disallowed_methods,
reason = "intentional source preservation: the hook executor re-resolves {{ env.* }} \
tokens at hook fire time"
)]
fn resolve_hook(hook: &HookEntry, index: usize, errors: &mut Vec<ResolveError>) -> HookDefinition { fn resolve_hook(hook: &HookEntry, index: usize, errors: &mut Vec<ResolveError>) -> HookDefinition {
let variants = [ let variants = [
hook.script.is_some() || hook.command.is_some(), hook.script.is_some() || hook.command.is_some(),
@ -414,6 +434,11 @@ fn resolve_hook(hook: &HookEntry, index: usize, errors: &mut Vec<ResolveError>)
} }
} }
#[expect(
clippy::disallowed_methods,
reason = "intentional source preservation: the hook executor re-resolves {{ env.* }} \
tokens at hook fire time"
)]
fn resolve_hook_type(hook: &HookEntry) -> Option<HookType> { fn resolve_hook_type(hook: &HookEntry) -> Option<HookType> {
if hook.script.is_some() || hook.command.is_some() { if hook.script.is_some() || hook.command.is_some() {
return None; return None;

View file

@ -305,6 +305,11 @@ fn resolve_object_store(
} }
} }
#[expect(
clippy::disallowed_methods,
reason = "derives sibling default paths in source form; the result is re-parsed as an \
InterpString and resolves at consumption"
)]
fn object_store_default_root(storage_root: &InterpString, domain: &str) -> InterpString { fn object_store_default_root(storage_root: &InterpString, domain: &str) -> InterpString {
let root = storage_root.as_source(); let root = storage_root.as_source();
let root = root.trim_end_matches('/'); let root = root.trim_end_matches('/');

View file

@ -140,6 +140,11 @@ fn process_env_var(name: &str) -> Option<String> {
std::env::var(name).ok() std::env::var(name).ok()
} }
#[expect(
clippy::disallowed_methods,
reason = "goal text intentionally passes through in source form; goals become importable \
templates in the interpolation unification (Phase 3)"
)]
fn resolve_layer_goal( fn resolve_layer_goal(
goal: &RunGoalLayer, goal: &RunGoalLayer,
base_dir: &Path, base_dir: &Path,
@ -153,6 +158,11 @@ fn resolve_layer_goal(
} }
} }
#[expect(
clippy::disallowed_methods,
reason = "goal text intentionally passes through in source form; goals become importable \
templates in the interpolation unification (Phase 3)"
)]
fn resolve_goal( fn resolve_goal(
goal: &RunGoal, goal: &RunGoal,
base_dir: &Path, base_dir: &Path,
@ -167,6 +177,10 @@ fn resolve_goal(
} }
#[cfg(test)] #[cfg(test)]
#[expect(
clippy::disallowed_methods,
reason = "tests assert the raw template source"
)]
mod tests { mod tests {
use fabro_types::settings::run::RunGoal; use fabro_types::settings::run::RunGoal;

View file

@ -1,3 +1,8 @@
#![expect(
clippy::disallowed_methods,
reason = "tests assert the raw template source"
)]
use fabro_types::settings::InterpString; use fabro_types::settings::InterpString;
use fabro_types::settings::cli::{OutputFormat, OutputVerbosity}; use fabro_types::settings::cli::{OutputFormat, OutputVerbosity};
use fabro_types::settings::server::LogDestination; use fabro_types::settings::server::LogDestination;

View file

@ -80,6 +80,10 @@ fn user_settings_resolve_returns_defaults_when_default_settings_file_is_missing(
}); });
} }
#[expect(
clippy::disallowed_methods,
reason = "test asserts the raw template source"
)]
#[test] #[test]
fn resolves_cli_target_exec_and_output_settings() { fn resolves_cli_target_exec_and_output_settings() {
let cli = UserSettingsBuilder::from_toml( let cli = UserSettingsBuilder::from_toml(

View file

@ -80,6 +80,10 @@ provider = "not-a-provider"
assert!(rendered.contains("run.environment.provider")); assert!(rendered.contains("run.environment.provider"));
} }
#[expect(
clippy::disallowed_methods,
reason = "test asserts the raw template source"
)]
#[test] #[test]
fn namespace_resolvers_cover_root_level_settings_shape() { fn namespace_resolvers_cover_root_level_settings_shape() {
let source = r#" let source = r#"

View file

@ -94,6 +94,10 @@ fn resolves_run_defaults_from_empty_settings() {
assert!(settings.pull_request.is_none()); assert!(settings.pull_request.is_none());
} }
#[expect(
clippy::disallowed_methods,
reason = "test asserts the raw template source"
)]
#[test] #[test]
fn resolves_named_daytona_environment_from_injected_catalog() { fn resolves_named_daytona_environment_from_injected_catalog() {
let settings = workflow_settings_from_toml_with_catalog( let settings = workflow_settings_from_toml_with_catalog(
@ -748,6 +752,10 @@ issues = "read"
); );
} }
#[expect(
clippy::disallowed_methods,
reason = "test asserts the raw template source"
)]
#[test] #[test]
fn resolver_preserves_interp_string_in_permissions() { fn resolver_preserves_interp_string_in_permissions() {
let resolved = super::workflow_settings_from_toml( let resolved = super::workflow_settings_from_toml(

View file

@ -1,6 +1,6 @@
#![expect( #![expect(
clippy::disallowed_methods, clippy::disallowed_methods,
reason = "sync test fixture setup; not on a Tokio path" reason = "sync test fixture setup and raw template source assertions; not on a Tokio path"
)] )]
use fabro_types::settings::InterpString; use fabro_types::settings::InterpString;

View file

@ -310,6 +310,11 @@ fn append_string_map(root: &mut Table, name: &str, map: &StickyMap<String>) {
} }
} }
#[expect(
clippy::disallowed_methods,
reason = "serializing the InterpString map back to its TOML source form; tokens must \
round-trip unresolved (resolution happens at consumption, not serialization)"
)]
fn append_interp_map(root: &mut Table, name: &str, map: &StickyMap<InterpString>) { fn append_interp_map(root: &mut Table, name: &str, map: &StickyMap<InterpString>) {
if map.is_empty() { if map.is_empty() {
return; return;

View file

@ -769,6 +769,11 @@ fn build_git_context(
}) })
} }
#[expect(
clippy::disallowed_methods,
reason = "raw source is today's behavior; run.scm.owner/repository are slated for demotion to plain String in the \
interpolation unification (D2)"
)]
fn configured_repo_origin_url(settings: &WorkflowSettings) -> Option<String> { fn configured_repo_origin_url(settings: &WorkflowSettings) -> Option<String> {
let scm = &settings.run.scm; let scm = &settings.run.scm;
if !scm if !scm
@ -897,6 +902,10 @@ mod tests {
)])) )]))
} }
#[expect(
clippy::disallowed_methods,
reason = "test asserts the raw template source"
)]
#[test] #[test]
fn build_run_overrides_sets_common_cli_and_mcp_layers() { fn build_run_overrides_sets_common_cli_and_mcp_layers() {
let overrides = build_run_overrides(RunOverrideInput { let overrides = build_run_overrides(RunOverrideInput {

View file

@ -7,6 +7,10 @@ use fabro_types::settings::{ServerNamespace, validate_public_url};
use crate::server::EnvLookup; use crate::server::EnvLookup;
#[expect(
clippy::disallowed_methods,
reason = "raw source shown in the error message when resolution fails"
)]
pub(crate) fn resolve_canonical_origin( pub(crate) fn resolve_canonical_origin(
resolved: &ServerNamespace, resolved: &ServerNamespace,
env_lookup: &EnvLookup, env_lookup: &EnvLookup,

View file

@ -327,6 +327,11 @@ fn short_error_line(rendered: &str) -> String {
} }
} }
#[expect(
clippy::disallowed_methods,
reason = "known leak: GitHub App id/slug passes unresolved; strict resolution scheduled in the \
interpolation unification (Phase 2)"
)]
async fn check_github_app(state: &AppState) -> CheckResult { async fn check_github_app(state: &AppState) -> CheckResult {
let settings = state.server_settings(); let settings = state.server_settings();
if settings.server.integrations.github.strategy == GithubIntegrationStrategy::Token { if settings.server.integrations.github.strategy == GithubIntegrationStrategy::Token {

View file

@ -0,0 +1,51 @@
//! Shared process-env interpolation helpers for server-scope settings.
//!
//! Server-scope `InterpString` fields resolve `{{ env.* }}` tokens against
//! the server's own process environment. This module owns the single
//! process-env lookup facade and the canonical resolve helpers; do not add
//! per-module copies.
use std::path::PathBuf;
use anyhow::Context;
use fabro_types::settings::InterpString;
/// Resolve a server-scope `InterpString` with a caller-provided env lookup.
///
/// This is the single resolve core; [`resolve_interp`] (process env) and
/// `AppState::resolve_interp` (injectable `env_lookup` seam) both delegate
/// here.
pub(crate) fn resolve_interp_with(
value: &InterpString,
lookup: impl FnMut(&str) -> Option<String>,
) -> anyhow::Result<String> {
value
.resolve(lookup)
.map(|resolved| resolved.value)
.map_err(anyhow::Error::from)
}
/// Resolve a server-scope `InterpString` against the process environment.
#[expect(
clippy::disallowed_methods,
reason = "raw source shown in the error message when resolution fails"
)]
pub(crate) fn resolve_interp(value: &InterpString) -> anyhow::Result<String> {
resolve_interp_with(value, process_env_var)
.with_context(|| format!("failed to resolve {}", value.as_source()))
}
/// [`resolve_interp`], parsed into a filesystem path.
pub(crate) fn resolve_interp_path(value: &InterpString) -> anyhow::Result<PathBuf> {
Ok(PathBuf::from(resolve_interp(value)?))
}
/// The server-owned process-env lookup facade for `{{ env.* }}`
/// interpolation and server configuration/secret reads.
#[expect(
clippy::disallowed_methods,
reason = "server-scope interpolation and configuration own this process-env lookup facade"
)]
pub(crate) fn process_env_var(name: &str) -> Option<String> {
std::env::var(name).ok()
}

View file

@ -15,6 +15,7 @@ use tracing::info;
use crate::auth::REFRESH_TOKEN_PREFIX; use crate::auth::REFRESH_TOKEN_PREFIX;
use crate::auth::{self, AuthErrorCode, JwtError, JwtSigningKey, KeyDeriveError}; use crate::auth::{self, AuthErrorCode, JwtError, JwtSigningKey, KeyDeriveError};
use crate::error::ApiError; use crate::error::ApiError;
use crate::interp::process_env_var;
type HmacSha256 = Hmac<Sha256>; type HmacSha256 = Hmac<Sha256>;
const DEV_TOKEN_COMPARE_KEY: &[u8] = b"fabro-dev-token-compare-key"; const DEV_TOKEN_COMPARE_KEY: &[u8] = b"fabro-dev-token-compare-key";
@ -58,14 +59,6 @@ pub fn resolve_auth_mode(settings: &ServerNamespace) -> Result<AuthMode> {
resolve_auth_mode_with_lookup(settings, process_env_var) resolve_auth_mode_with_lookup(settings, process_env_var)
} }
#[expect(
clippy::disallowed_methods,
reason = "Server auth startup validation intentionally reads process env for server secrets."
)]
fn process_env_var(name: &str) -> Option<String> {
std::env::var(name).ok()
}
pub fn resolve_auth_mode_with_lookup<F>(settings: &ServerNamespace, lookup: F) -> Result<AuthMode> pub fn resolve_auth_mode_with_lookup<F>(settings: &ServerNamespace, lookup: F) -> Result<AuthMode>
where where
F: Fn(&str) -> Option<String>, F: Fn(&str) -> Option<String>,

View file

@ -27,6 +27,7 @@ pub mod diagnostics;
pub mod error; pub mod error;
pub mod github_webhooks; pub mod github_webhooks;
pub mod install; pub mod install;
mod interp;
pub mod jwt_auth; pub mod jwt_auth;
pub mod manifest_validation; pub mod manifest_validation;
mod migrations; mod migrations;

View file

@ -11,7 +11,7 @@ use fabro_config::parse::{self, SettingsSource};
use fabro_config::{ use fabro_config::{
CliLayer, CliOutputLayer, EnvironmentDockerfileLayer, EnvironmentImageLayer, EnvironmentLayer, CliLayer, CliOutputLayer, EnvironmentDockerfileLayer, EnvironmentImageLayer, EnvironmentLayer,
MergeMap, RunLayer, SettingsLayer, WorkflowSettingsBuilder, parse_input_overrides, MergeMap, RunLayer, SettingsLayer, WorkflowSettingsBuilder, parse_input_overrides,
parse_labels, parse_labels, project,
}; };
use fabro_graphviz::graph::{Graph, is_llm_handler_type}; use fabro_graphviz::graph::{Graph, is_llm_handler_type};
use fabro_graphviz::render::apply_direction; use fabro_graphviz::render::apply_direction;
@ -40,6 +40,7 @@ use tokio::process::Command;
use tokio::time; use tokio::time;
use tracing::warn; use tracing::warn;
use crate::interp::process_env_var;
use crate::server::AppState; use crate::server::AppState;
use crate::server_secrets::LlmClientResult; use crate::server_secrets::LlmClientResult;
@ -173,7 +174,7 @@ pub(crate) fn prepare_manifest_with_environment_defaults(
target_path, target_path,
workflow_bundle, workflow_bundle,
workflow_input, workflow_input,
source_directory: resolve_working_directory(&settings, &cwd), source_directory: project::resolve_working_directory_from_run(&settings.run, &cwd),
}) })
} }
@ -374,31 +375,6 @@ fn manifest_args_overrides(
}) })
} }
fn resolve_working_directory(settings: &WorkflowSettings, caller_cwd: &Path) -> PathBuf {
let Some(work_dir) = settings
.run
.working_dir
.as_ref()
.map(InterpString::as_source)
else {
return caller_cwd.to_path_buf();
};
let path = PathBuf::from(&work_dir);
if path.is_absolute() {
path
} else {
caller_cwd.join(path)
}
}
#[expect(
clippy::disallowed_methods,
reason = "Manifest preflight interpolation owns a process-env lookup facade for {{ env.* }} values."
)]
fn process_env_var(name: &str) -> Option<String> {
std::env::var(name).ok()
}
fn resolve_manifest_dockerfiles( fn resolve_manifest_dockerfiles(
layer: &mut SettingsLayer, layer: &mut SettingsLayer,
config_path: &ManifestPath, config_path: &ManifestPath,
@ -1166,6 +1142,11 @@ fn canonical_provider_id(catalog: &Catalog, provider_name: &str) -> ProviderId {
.map_or(provider_id, |provider| provider.id.clone()) .map_or(provider_id, |provider| provider.id.clone())
} }
#[expect(
clippy::disallowed_methods,
reason = "raw source is today's behavior; run.model.name/provider are slated for demotion to plain String in the \
interpolation unification (D2)"
)]
fn resolve_model_provider( fn resolve_model_provider(
settings: &RunNamespace, settings: &RunNamespace,
_graph: &Graph, _graph: &Graph,

View file

@ -131,6 +131,10 @@ mod tests {
assert_eq!(args.input, vec![r"count=3", r#"decision="approve""#]); assert_eq!(args.input, vec![r"count=3", r#"decision="approve""#]);
} }
#[expect(
clippy::disallowed_methods,
reason = "test asserts the raw template source"
)]
#[test] #[test]
fn run_overrides_preserve_goal_file_as_file_goal() { fn run_overrides_preserve_goal_file_as_file_goal() {
let spec = ValidatedCreateRunSpec::try_from(CreateRunSpec { let spec = ValidatedCreateRunSpec::try_from(CreateRunSpec {

View file

@ -16,8 +16,7 @@ use fabro_static::EnvVars;
use fabro_types::ServerSettings; use fabro_types::ServerSettings;
use fabro_types::settings::server::{GithubIntegrationStrategy, LogDestination, WebhookStrategy}; use fabro_types::settings::server::{GithubIntegrationStrategy, LogDestination, WebhookStrategy};
use fabro_types::settings::{ use fabro_types::settings::{
GithubIntegrationSettings, InterpString, ObjectStoreSettings, ServerListenSettings, GithubIntegrationSettings, ObjectStoreSettings, ServerListenSettings, ServerNamespace,
ServerNamespace,
}; };
use fabro_util::terminal::Styles; use fabro_util::terminal::Styles;
use object_store::aws::{AmazonS3Builder, AmazonS3ConfigKey}; use object_store::aws::{AmazonS3Builder, AmazonS3ConfigKey};
@ -33,6 +32,7 @@ use tracing::{error, info, warn};
use crate::canonical_origin::resolve_canonical_origin; use crate::canonical_origin::resolve_canonical_origin;
use crate::github_webhooks::{TailscaleFunnelManager, WEBHOOK_ROUTE, WEBHOOK_SECRET_ENV}; use crate::github_webhooks::{TailscaleFunnelManager, WEBHOOK_ROUTE, WEBHOOK_SECRET_ENV};
use crate::interp::{process_env_var, resolve_interp, resolve_interp_path};
use crate::server::{ use crate::server::{
AppState, AppStateConfig, ResolvedAppStateSettings, RouterOptions, build_app_state, AppState, AppStateConfig, ResolvedAppStateSettings, RouterOptions, build_app_state,
build_router_with_options, reconcile_incomplete_runs_on_startup, shutdown_active_workers, build_router_with_options, reconcile_incomplete_runs_on_startup, shutdown_active_workers,
@ -557,25 +557,6 @@ fn resolved_bind_request(
} }
} }
fn resolve_interp(value: &InterpString) -> anyhow::Result<String> {
value
.resolve(process_env_var)
.map(|resolved| resolved.value)
.with_context(|| format!("failed to resolve {}", value.as_source()))
}
#[expect(
clippy::disallowed_methods,
reason = "Server settings interpolation owns a process-env lookup facade for {{ env.* }} values."
)]
fn process_env_var(name: &str) -> Option<String> {
std::env::var(name).ok()
}
fn resolve_interp_path(value: &InterpString) -> anyhow::Result<PathBuf> {
Ok(PathBuf::from(resolve_interp(value)?))
}
fn absolute_path(path: PathBuf) -> anyhow::Result<PathBuf> { fn absolute_path(path: PathBuf) -> anyhow::Result<PathBuf> {
if path.is_absolute() { if path.is_absolute() {
Ok(path) Ok(path)
@ -1168,6 +1149,10 @@ fn server_bind_title(bind: &Bind) -> String {
reason = "tests reserve/probe ports via sync std::net::TcpListener; the async server under \ reason = "tests reserve/probe ports via sync std::net::TcpListener; the async server under \
test uses tokio::net::TcpListener separately" test uses tokio::net::TcpListener separately"
)] )]
#[expect(
clippy::disallowed_methods,
reason = "tests assert the raw template source"
)]
mod tests { mod tests {
use std::io; use std::io;
use std::path::PathBuf; use std::path::PathBuf;

View file

@ -146,6 +146,7 @@ use crate::error::ApiError;
use crate::github_webhooks::{ use crate::github_webhooks::{
WEBHOOK_ROUTE, WEBHOOK_SECRET_ENV, parse_event_metadata, verify_signature, WEBHOOK_ROUTE, WEBHOOK_SECRET_ENV, parse_event_metadata, verify_signature,
}; };
use crate::interp::{process_env_var, resolve_interp, resolve_interp_with};
use crate::jwt_auth::{self, AuthMode}; use crate::jwt_auth::{self, AuthMode};
use crate::principal_middleware::{ use crate::principal_middleware::{
AuthContextSlot, RequestAuth, RequestAuthContext, RequireRunBlob, RequireRunManagementTarget, AuthContextSlot, RequestAuth, RequestAuthContext, RequireRunBlob, RequireRunManagementTarget,
@ -1349,7 +1350,7 @@ impl AppState {
pub(crate) fn server_storage_dir(&self) -> PathBuf { pub(crate) fn server_storage_dir(&self) -> PathBuf {
PathBuf::from( PathBuf::from(
resolve_interp_string(&self.server_settings().server.storage.root) resolve_interp(&self.server_settings().server.storage.root)
.expect("server storage root should be resolved at startup"), .expect("server storage root should be resolved at startup"),
) )
} }
@ -1487,10 +1488,7 @@ impl AppState {
} }
pub(crate) fn resolve_interp(&self, value: &InterpString) -> anyhow::Result<String> { pub(crate) fn resolve_interp(&self, value: &InterpString) -> anyhow::Result<String> {
value resolve_interp_with(value, |name| (self.env_lookup)(name))
.resolve(|name| (self.env_lookup)(name))
.map(|resolved| resolved.value)
.map_err(anyhow::Error::from)
} }
pub(crate) fn canonical_origin(&self) -> Result<String, String> { pub(crate) fn canonical_origin(&self) -> Result<String, String> {
@ -1502,6 +1500,11 @@ impl AppState {
.and_then(|value| auth::derive_cookie_key(value.as_bytes()).ok()) .and_then(|value| auth::derive_cookie_key(value.as_bytes()).ok())
} }
#[expect(
clippy::disallowed_methods,
reason = "known leak: GitHub App id/slug passes unresolved; strict resolution scheduled in the \
interpolation unification (Phase 2)"
)]
pub(crate) fn github_credentials( pub(crate) fn github_credentials(
&self, &self,
settings: &GithubIntegrationSettings, settings: &GithubIntegrationSettings,
@ -1627,21 +1630,6 @@ fn decode_secret_pem(name: &str, raw: &str) -> Result<String, String> {
.map_err(|err| format!("{name} base64 decoded to invalid UTF-8: {err}")) .map_err(|err| format!("{name} base64 decoded to invalid UTF-8: {err}"))
} }
fn resolve_interp_string(value: &InterpString) -> anyhow::Result<String> {
value
.resolve(process_env_var)
.map(|resolved| resolved.value)
.map_err(anyhow::Error::from)
}
#[expect(
clippy::disallowed_methods,
reason = "Server state owns process-env lookup facades for interpolation and non-secret configuration."
)]
pub(crate) fn process_env_var(name: &str) -> Option<String> {
std::env::var(name).ok()
}
fn start_optional_slack_service(state: &Arc<AppState>) { fn start_optional_slack_service(state: &Arc<AppState>) {
let Some(service) = state.slack_service.clone() else { let Some(service) = state.slack_service.clone() else {
return; return;
@ -2399,7 +2387,7 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
let worker_tokens = worker_token_keys_from_server_secrets(&server_secrets)?; let worker_tokens = worker_token_keys_from_server_secrets(&server_secrets)?;
let github_api_base_url = github_api_base_url.unwrap_or_else(fabro_github::github_api_base_url); let github_api_base_url = github_api_base_url.unwrap_or_else(fabro_github::github_api_base_url);
let storage_root = PathBuf::from( let storage_root = PathBuf::from(
resolve_interp_string(&current_server_settings.server.storage.root) resolve_interp(&current_server_settings.server.storage.root)
.context("resolve server storage root")?, .context("resolve server storage root")?,
); );
let automation_repo_cache = Arc::new(GitRepoCache::new( let automation_repo_cache = Arc::new(GitRepoCache::new(

View file

@ -19,7 +19,7 @@ use fabro_api::types::{
use fabro_config::Storage; use fabro_config::Storage;
use fabro_interview::AnswerSubmission; use fabro_interview::AnswerSubmission;
use fabro_llm::client::Client as LlmClient; use fabro_llm::client::Client as LlmClient;
use fabro_types::settings::ResolveEnvError; use fabro_types::settings::ResolveError;
use fabro_types::{ use fabro_types::{
AutomationRef, Principal, RunClientProvenance, RunId, RunProvenance, RunServerProvenance, AutomationRef, Principal, RunClientProvenance, RunId, RunProvenance, RunServerProvenance,
StageContextWindow, StageContextWindowStaleness, StageContextWindowUnavailableReason, StageContextWindow, StageContextWindowStaleness, StageContextWindowUnavailableReason,
@ -37,10 +37,10 @@ use super::super::{
AppState, DeleteRunOutcome, ListResponse, PaginationParams, RunExecutionMode, AppState, DeleteRunOutcome, ListResponse, PaginationParams, RunExecutionMode,
answer_from_request, api_question_from_pending_interview, default_page_limit, answer_from_request, api_question_from_pending_interview, default_page_limit,
delete_run_internal, load_pending_interview, managed_run, paginate_items, parse_run_id_path, delete_run_internal, load_pending_interview, managed_run, paginate_items, parse_run_id_path,
parse_stage_id_path, reject_if_archived, resolve_interp_string, parse_stage_id_path, reject_if_archived, submit_pending_interview_answer, workflow_event,
submit_pending_interview_answer, workflow_event,
}; };
use crate::error::ApiError; use crate::error::ApiError;
use crate::interp::resolve_interp;
use crate::principal_middleware::{ use crate::principal_middleware::{
RequireCommandLog, RequireRunManagementTarget, RequireRunScoped, RequireRunStageScoped, RequireCommandLog, RequireRunManagementTarget, RequireRunScoped, RequireRunStageScoped,
RequiredRunManagementActor, RequiredUser, RequiredRunManagementActor, RequiredUser,
@ -697,7 +697,7 @@ pub(crate) async fn create_run_from_manifest(
create_input.submitted_manifest_bytes = Some(submitted_manifest_bytes); create_input.submitted_manifest_bytes = Some(submitted_manifest_bytes);
create_input.automation = automation; create_input.automation = automation;
let storage_root = match resolve_interp_string(&state.server_settings().server.storage.root) { let storage_root = match resolve_interp(&state.server_settings().server.storage.root) {
Ok(path) => PathBuf::from(path), Ok(path) => PathBuf::from(path),
Err(err) => { Err(err) => {
return ApiError::new( return ApiError::new(
@ -969,7 +969,7 @@ async fn validate_run_manifest(
async fn substitute_run_variables( async fn substitute_run_variables(
state: &AppState, state: &AppState,
settings: &mut WorkflowSettings, settings: &mut WorkflowSettings,
) -> Result<(), ResolveEnvError> { ) -> Result<(), ResolveError> {
let variables = state.variables.read().await; let variables = state.variables.read().await;
settings settings
.run .run

View file

@ -18,8 +18,9 @@ use super::super::{
SystemInfoResponse, SystemIntegrationStatus, SystemIntegrationsResponse, SystemRepairRunIssue, SystemInfoResponse, SystemIntegrationStatus, SystemIntegrationsResponse, SystemRepairRunIssue,
SystemRepairRunsResponse, SystemRunCounts, build_disk_usage_response, build_prune_plan, SystemRepairRunsResponse, SystemRunCounts, build_disk_usage_response, build_prune_plan,
counts_toward_scheduler_capacity, delete_run_internal, diagnostics, get, post, counts_toward_scheduler_capacity, delete_run_internal, diagnostics, get, post,
resolve_interp_string, resource_sampler, spawn_blocking, system_sandbox_provider, to_i64, resource_sampler, spawn_blocking, system_sandbox_provider, to_i64,
}; };
use crate::interp::resolve_interp;
pub(super) fn routes() -> Router<Arc<AppState>> { pub(super) fn routes() -> Router<Arc<AppState>> {
Router::new() Router::new()
@ -51,6 +52,11 @@ async fn get_server_settings(_auth: RequiredUser, State(state): State<Arc<AppSta
.into_response() .into_response()
} }
#[expect(
clippy::disallowed_methods,
reason = "known leak: server.web.url passes unresolved; strict resolution scheduled in the \
interpolation unification (Phase 2)"
)]
async fn get_system_info(_auth: RequiredUser, State(state): State<Arc<AppState>>) -> Response { async fn get_system_info(_auth: RequiredUser, State(state): State<Arc<AppState>>) -> Response {
let manifest_run_settings = state.manifest_run_settings(); let manifest_run_settings = state.manifest_run_settings();
let server_settings = state.server_settings(); let server_settings = state.server_settings();
@ -276,9 +282,7 @@ fn missing_vault_secret(state: &AppState, name: &str) -> bool {
} }
fn display_interp(state: &AppState, value: &InterpString) -> String { fn display_interp(state: &AppState, value: &InterpString) -> String {
state value.resolve_or_source(|name| (state.env_lookup)(name))
.resolve_interp(value)
.unwrap_or_else(|_| value.as_source())
} }
async fn get_system_resources(_auth: RequiredUser, State(state): State<Arc<AppState>>) -> Response { async fn get_system_resources(_auth: RequiredUser, State(state): State<Arc<AppState>>) -> Response {
@ -478,7 +482,7 @@ async fn get_github_repo(
) )
.into_response(); .into_response();
}; };
if let Err(err) = resolve_interp_string(app_id) { if let Err(err) = resolve_interp(app_id) {
return ApiError::new(StatusCode::SERVICE_UNAVAILABLE, err.to_string()) return ApiError::new(StatusCode::SERVICE_UNAVAILABLE, err.to_string())
.into_response(); .into_response();
} }
@ -506,7 +510,7 @@ async fn get_github_repo(
} }
}; };
let install_url = match github_settings.slug.as_ref() { let install_url = match github_settings.slug.as_ref() {
Some(slug) => match resolve_interp_string(slug) { Some(slug) => match resolve_interp(slug) {
Ok(slug) => format!("https://github.com/apps/{slug}/installations/new"), Ok(slug) => format!("https://github.com/apps/{slug}/installations/new"),
Err(err) => { Err(err) => {
return ApiError::new(StatusCode::SERVICE_UNAVAILABLE, err.to_string()) return ApiError::new(StatusCode::SERVICE_UNAVAILABLE, err.to_string())

View file

@ -1090,6 +1090,10 @@ url = "{{ env.FABRO_WEB_URL }}"
} }
} }
#[expect(
clippy::disallowed_methods,
reason = "test asserts the raw template source"
)]
#[test] #[test]
fn replace_settings_updates_layer_and_typed_server_settings() { fn replace_settings_updates_layer_and_typed_server_settings() {
let state = test_app_state_with_options( let state = test_app_state_with_options(
@ -13433,6 +13437,10 @@ async fn post_runs_returns_submitted_status() {
assert_eq!(run_json_status(&body)["kind"], "submitted"); assert_eq!(run_json_status(&body)["kind"], "submitted");
} }
#[expect(
clippy::disallowed_methods,
reason = "test asserts the raw template source"
)]
#[tokio::test] #[tokio::test]
async fn start_run_persists_full_settings_snapshot() { async fn start_run_persists_full_settings_snapshot() {
let source = r#" let source = r#"

View file

@ -30,12 +30,13 @@ use ulid::Ulid;
use crate::auth; use crate::auth;
use crate::automation_materializer::AutomationRunMaterializer; use crate::automation_materializer::AutomationRunMaterializer;
pub use crate::automation_materializer::TestAutomationRunMaterializer; pub use crate::automation_materializer::TestAutomationRunMaterializer;
use crate::interp::process_env_var;
use crate::jwt_auth::{AuthMode, ConfiguredAuth}; use crate::jwt_auth::{AuthMode, ConfiguredAuth};
#[cfg(test)] #[cfg(test)]
use crate::principal_middleware::{AuthContextSlot, RequestAuthContext}; use crate::principal_middleware::{AuthContextSlot, RequestAuthContext};
use crate::server::{ use crate::server::{
self, AppState, AppStateConfig, EnvLookup, RegistryFactoryOverride, ResolvedAppStateSettings, self, AppState, AppStateConfig, EnvLookup, RegistryFactoryOverride, ResolvedAppStateSettings,
RouterOptions, build_app_state, process_env_var, RouterOptions, build_app_state,
}; };
use crate::server_secrets::ServerSecrets; use crate::server_secrets::ServerSecrets;
#[cfg(test)] #[cfg(test)]

View file

@ -21,6 +21,7 @@ use tracing::{debug, error, info, warn};
use crate::auth::{GithubEndpoints, browser_shell}; use crate::auth::{GithubEndpoints, browser_shell};
use crate::error::ApiError; use crate::error::ApiError;
use crate::interp::process_env_var;
use crate::jwt_auth::{AuthMode, auth_method_name, dev_token_matches}; use crate::jwt_auth::{AuthMode, auth_method_name, dev_token_matches};
use crate::principal_middleware::{ use crate::principal_middleware::{
RequestAuth, RequestAuthContext, UserProfile, require_authenticated_user, RequestAuth, RequestAuthContext, UserProfile, require_authenticated_user,
@ -345,14 +346,6 @@ fn session_timestamp(timestamp: i64) -> Result<DateTime<Utc>, ApiError> {
}) })
} }
#[expect(
clippy::disallowed_methods,
reason = "Web auth resolves configured {{ env.* }} URLs through this process-env facade."
)]
fn process_env_var(name: &str) -> Option<String> {
std::env::var(name).ok()
}
async fn login_dev_token( async fn login_dev_token(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Extension(auth_mode): Extension<AuthMode>, Extension(auth_mode): Extension<AuthMode>,

View file

@ -114,6 +114,10 @@ async fn load_questions(app: &axum::Router, run_id: &str) -> serde_json::Value {
.await .await
} }
#[expect(
clippy::disallowed_methods,
reason = "test asserts the raw template source"
)]
#[tokio::test] #[tokio::test]
async fn get_system_info_returns_runtime_fields() { async fn get_system_info_returns_runtime_fields() {
let (_temp, settings, expected_storage_dir) = temp_storage_settings(); let (_temp, settings, expected_storage_dir) = temp_storage_settings();

View file

@ -1,10 +1,19 @@
//! Env var and run variable interpolation for config strings. //! Interpolation for config strings.
//! //!
//! Any string field may use `{{ env.NAME }}` tokens, either as a whole value or //! An [`InterpString`] field may contain narrow `{{ <namespace>.NAME }}`
//! as one or more substrings inside a larger string. Run-scoped settings may //! tokens — no template logic — drawn from the four [`Namespace`]s: `env`,
//! additionally use non-sensitive `{{ vars.NAME }}` tokens. Resolution happens //! `vars`, `secrets`, and `inputs`. Which namespaces actually resolve is
//! only when the field is consumed, and provenance tracking lets outward-facing //! scope-determined by the caller through [`ResolveCtx`]: server-scope
//! renderers redact env-sourced values uniformly. //! settings provide `env` (and eventually `secrets`), run-scope settings
//! additionally provide `vars` and `inputs`. A token whose namespace is not
//! available in the resolution context fails loudly instead of passing
//! through as literal text.
//!
//! Resolution timing is split: `vars`/`inputs` substitute early (server-side,
//! at run creation) via [`InterpString::substitute_with`], while
//! `env`/`secrets` resolve late, at consumption time in the process that owns
//! the value, via [`InterpString::resolve_with`]. Provenance tracking lets
//! outward-facing renderers redact env- and secret-sourced values uniformly.
use std::fmt; use std::fmt;
@ -13,8 +22,8 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::variable::is_env_style_name; use crate::variable::is_env_style_name;
/// A config string that may contain `{{ env.NAME }}` or `{{ vars.NAME }}` /// A config string that may contain `{{ env.NAME }}`, `{{ vars.NAME }}`,
/// tokens. /// `{{ secrets.NAME }}`, or `{{ inputs.NAME }}` tokens.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct InterpString { pub struct InterpString {
segments: Vec<Segment>, segments: Vec<Segment>,
@ -23,8 +32,130 @@ pub struct InterpString {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
enum Segment { enum Segment {
Literal(String), Literal(String),
EnvVar(String), Token {
Variable(String), namespace: Namespace,
name: String,
},
}
/// The interpolation namespaces recognized inside `{{ ... }}` tokens.
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Hash, strum::Display, strum::EnumString, strum::IntoStaticStr,
)]
#[strum(serialize_all = "lowercase")]
pub enum Namespace {
/// `{{ env.NAME }}` — process environment, resolved at consumption time.
Env,
/// `{{ vars.NAME }}` — non-sensitive run variables, substituted early.
Vars,
/// `{{ secrets.NAME }}` — vault secrets, resolved at consumption time.
Secrets,
/// `{{ inputs.NAME }}` — workflow run inputs, substituted early.
Inputs,
}
impl Namespace {
/// The noun used for this namespace in error messages.
fn noun(self) -> &'static str {
match self {
Self::Env => "environment variable",
Self::Vars => "variable",
Self::Secrets => "secret",
Self::Inputs => "input",
}
}
/// Parse a trimmed `{{ ... }}` token body into a namespace + name, or
/// `None` when the body is not a recognized token (it then stays literal).
fn parse_token(token: &str) -> Option<(Self, String)> {
let trimmed = token.trim();
let (prefix, name) = trimmed.split_once('.')?;
let namespace = prefix.parse::<Self>().ok()?;
namespace
.is_valid_name(name)
.then(|| (namespace, name.to_owned()))
}
fn is_valid_name(self, name: &str) -> bool {
match self {
// Preserves the original env token grammar: any non-empty run of
// ASCII alphanumerics/underscores (leading digits allowed).
Self::Env => {
!name.is_empty()
&& name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
}
Self::Vars | Self::Secrets => is_env_style_name(name),
// Input keys are TOML bare keys; additionally allow interior
// hyphens.
Self::Inputs => {
let mut chars = name.chars();
match chars.next() {
Some(first) if first.is_ascii_alphanumeric() || first == '_' => {}
_ => return false,
}
chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
}
}
}
}
/// The namespace lookups available when resolving or substituting an
/// [`InterpString`].
///
/// Which namespaces are populated is scope-determined by the caller: a token
/// in a namespace with no lookup is a [`ResolveErrorKind::Unavailable`] error
/// under [`InterpString::resolve_with`], and passes through unchanged under
/// [`InterpString::substitute_with`].
#[derive(Default)]
pub struct ResolveCtx<'a> {
env: Option<LookupFn<'a>>,
vars: Option<LookupFn<'a>>,
secrets: Option<LookupFn<'a>>,
inputs: Option<LookupFn<'a>>,
}
type LookupFn<'a> = Box<dyn FnMut(&str) -> Option<String> + 'a>;
impl<'a> ResolveCtx<'a> {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_env(mut self, lookup: impl FnMut(&str) -> Option<String> + 'a) -> Self {
self.env = Some(Box::new(lookup));
self
}
#[must_use]
pub fn with_vars(mut self, lookup: impl FnMut(&str) -> Option<String> + 'a) -> Self {
self.vars = Some(Box::new(lookup));
self
}
#[must_use]
pub fn with_secrets(mut self, lookup: impl FnMut(&str) -> Option<String> + 'a) -> Self {
self.secrets = Some(Box::new(lookup));
self
}
#[must_use]
pub fn with_inputs(mut self, lookup: impl FnMut(&str) -> Option<String> + 'a) -> Self {
self.inputs = Some(Box::new(lookup));
self
}
fn lookup_for(&mut self, namespace: Namespace) -> Option<&mut LookupFn<'a>> {
match namespace {
Namespace::Env => self.env.as_mut(),
Namespace::Vars => self.vars.as_mut(),
Namespace::Secrets => self.secrets.as_mut(),
Namespace::Inputs => self.inputs.as_mut(),
}
}
} }
impl InterpString { impl InterpString {
@ -35,41 +166,21 @@ impl InterpString {
match segments.last_mut() { match segments.last_mut() {
Some(Segment::Literal(existing)) => existing.push_str(text), Some(Segment::Literal(existing)) => existing.push_str(text),
Some(Segment::EnvVar(_) | Segment::Variable(_)) | None => { Some(Segment::Token { .. }) | None => {
segments.push(Segment::Literal(text.to_owned())); segments.push(Segment::Literal(text.to_owned()));
} }
} }
} }
fn parse_env_token(token: &str) -> Option<String> { /// Parse a raw string into its literal/token segments.
let trimmed = token.trim();
let name = trimmed.strip_prefix("env.")?;
if name.is_empty()
|| !name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
{
return None;
}
Some(name.to_owned())
}
fn parse_vars_token(token: &str) -> Option<String> {
let trimmed = token.trim();
let name = trimmed.strip_prefix("vars.")?;
if is_env_style_name(name) {
Some(name.to_owned())
} else {
None
}
}
/// Parse a raw string into its literal/env-var segments.
/// ///
/// The [`From<String>`] and [`From<&str>`] impls delegate here. /// The [`From<String>`] and [`From<&str>`] impls delegate here.
/// ///
/// Parsing is infallible: the token grammar is intentionally permissive so /// Parsing is infallible and intentionally permissive: only
/// that validation happens at consumption time along with env lookup. /// `{{ <known-namespace>.NAME }}` shaped tokens are claimed; any other
/// `{{ ... }}` text (jq programs, Go templates, unterminated braces)
/// stays literal. This is a documented known limitation — validation of
/// claimed tokens happens at substitution/resolution time.
#[must_use] #[must_use]
pub fn parse(input: &str) -> Self { pub fn parse(input: &str) -> Self {
let mut segments: Vec<Segment> = Vec::new(); let mut segments: Vec<Segment> = Vec::new();
@ -81,10 +192,8 @@ impl InterpString {
let after_open = &rest[start + 2..]; let after_open = &rest[start + 2..];
if let Some(close) = after_open.find("}}") { if let Some(close) = after_open.find("}}") {
let token = &after_open[..close]; let token = &after_open[..close];
if let Some(name) = Self::parse_env_token(token) { if let Some((namespace, name)) = Namespace::parse_token(token) {
segments.push(Segment::EnvVar(name)); segments.push(Segment::Token { namespace, name });
} else if let Some(name) = Self::parse_vars_token(token) {
segments.push(Segment::Variable(name));
} else { } else {
Self::push_literal(&mut segments, &rest[start..start + 2 + close + 2]); Self::push_literal(&mut segments, &rest[start..start + 2 + close + 2]);
} }
@ -116,60 +225,48 @@ impl InterpString {
.all(|seg| matches!(seg, Segment::Literal(_))) .all(|seg| matches!(seg, Segment::Literal(_)))
} }
/// True when this string contains at least one env var token. /// True when this string contains at least one token in `namespace`.
#[must_use] #[must_use]
pub fn references_env(&self) -> bool { pub fn references(&self, namespace: Namespace) -> bool {
self.segments self.segments
.iter() .iter()
.any(|seg| matches!(seg, Segment::EnvVar(_))) .any(|seg| matches!(seg, Segment::Token { namespace: ns, .. } if *ns == namespace))
} }
/// True when this string contains at least one run variable token. /// The names referenced in `namespace` by this string, in source order.
#[must_use] #[must_use]
pub fn references_vars(&self) -> bool { pub fn names(&self, namespace: Namespace) -> Vec<&str> {
self.segments
.iter()
.any(|seg| matches!(seg, Segment::Variable(_)))
}
/// The env var names referenced by this string, in source order.
#[must_use]
pub fn env_var_names(&self) -> Vec<&str> {
self.segments self.segments
.iter() .iter()
.filter_map(|seg| match seg { .filter_map(|seg| match seg {
Segment::EnvVar(name) => Some(name.as_str()), Segment::Token {
Segment::Literal(_) | Segment::Variable(_) => None, namespace: ns,
name,
} if *ns == namespace => Some(name.as_str()),
Segment::Literal(_) | Segment::Token { .. } => None,
}) })
.collect() .collect()
} }
/// The run variable names referenced by this string, in source order. /// The raw, unresolved template source.
#[must_use] ///
pub fn var_names(&self) -> Vec<&str> { /// This is a footgun for consumers: passing the raw source downstream
self.segments /// leaks `{{ ... }}` tokens as literal text. Resolve via
.iter() /// [`InterpString::resolve`] / [`InterpString::resolve_with`] (or
.filter_map(|seg| match seg { /// substitute via [`InterpString::substitute_with`]) instead. Intentional
Segment::Variable(name) => Some(name.as_str()), /// uses — serialization, error messages, deliberate source preservation —
Segment::Literal(_) | Segment::EnvVar(_) => None, /// must document themselves with
}) /// `#[expect(clippy::disallowed_methods, reason = "...")]`.
.collect()
}
/// The raw source string.
#[must_use] #[must_use]
pub fn as_source(&self) -> String { pub fn as_source(&self) -> String {
let mut out = String::new(); let mut out = String::new();
for seg in &self.segments { for seg in &self.segments {
match seg { match seg {
Segment::Literal(text) => out.push_str(text), Segment::Literal(text) => out.push_str(text),
Segment::EnvVar(name) => { Segment::Token { namespace, name } => {
out.push_str("{{ env."); out.push_str("{{ ");
out.push_str(name); out.push_str(namespace.into());
out.push_str(" }}"); out.push('.');
}
Segment::Variable(name) => {
out.push_str("{{ vars.");
out.push_str(name); out.push_str(name);
out.push_str(" }}"); out.push_str(" }}");
} }
@ -178,101 +275,65 @@ impl InterpString {
out out
} }
/// Resolve this string using `lookup`, which should return the current /// Fully resolve every token using the lookups in `ctx`.
/// value for a given env var name (or `None` if unset).
/// ///
/// On success the caller gets the final string plus provenance describing /// Tokens in a namespace `ctx` has no lookup for fail with
/// whether any env var contributed to the value. On failure the caller /// [`ResolveErrorKind::Unavailable`]: namespace availability is
/// learns which env var was unresolved. /// scope-determined, and a token outside its scope must fail loudly
pub fn resolve<F>(&self, mut lookup: F) -> Result<Resolved, ResolveEnvError> /// rather than pass through as literal text. A lookup miss fails with
where /// [`ResolveErrorKind::Missing`] — there is no fallback to the raw
F: FnMut(&str) -> Option<String>, /// source.
{ pub fn resolve_with(&self, ctx: &mut ResolveCtx<'_>) -> Result<Resolved, ResolveError> {
let mut value = String::new(); let mut value = String::new();
let mut used = Vec::new(); let mut env_names = Vec::new();
let mut secret_names = Vec::new();
for seg in &self.segments { for seg in &self.segments {
match seg { match seg {
Segment::Literal(text) => value.push_str(text), Segment::Literal(text) => value.push_str(text),
Segment::EnvVar(name) => { Segment::Token { namespace, name } => {
let Some(lookup) = ctx.lookup_for(*namespace) else {
return Err(ResolveError::unavailable(*namespace, name));
};
let Some(resolved) = lookup(name) else { let Some(resolved) = lookup(name) else {
return Err(ResolveEnvError::missing_env(name)); return Err(ResolveError::missing(*namespace, name));
}; };
value.push_str(&resolved); value.push_str(&resolved);
used.push(name.clone()); match namespace {
} Namespace::Env => env_names.push(name.clone()),
Segment::Variable(name) => { Namespace::Secrets => secret_names.push(name.clone()),
return Err(ResolveEnvError::unsupported_variable(name)); Namespace::Vars | Namespace::Inputs => {}
}
} }
} }
} }
let provenance = if used.is_empty() { Ok(Resolved {
Provenance::Literal value,
} else { provenance: Provenance::from_names(env_names, secret_names),
Provenance::EnvSourced { names: used } })
};
Ok(Resolved { value, provenance })
} }
/// Resolve env and run variable tokens with separate lookup functions. /// Substitute tokens for the namespaces `ctx` provides, preserving tokens
/// for the namespaces it does not — their resolution happens later,
/// possibly in a different process.
/// ///
/// Variables are non-sensitive, so variable-only interpolation does not /// This is the early, server-side pass (`vars`/`inputs`); late-bound
/// mark the value as env-sourced for redaction. /// namespaces (`env`/`secrets`) survive in token form for their
pub fn resolve_with_variables<F, G>( /// consumption-time [`InterpString::resolve_with`].
&self, pub fn substitute_with(&self, ctx: &mut ResolveCtx<'_>) -> Result<Self, ResolveError> {
mut env_lookup: F,
mut variable_lookup: G,
) -> Result<Resolved, ResolveEnvError>
where
F: FnMut(&str) -> Option<String>,
G: FnMut(&str) -> Option<String>,
{
let mut value = String::new();
let mut used_env = Vec::new();
for seg in &self.segments {
match seg {
Segment::Literal(text) => value.push_str(text),
Segment::EnvVar(name) => {
let Some(resolved) = env_lookup(name) else {
return Err(ResolveEnvError::missing_env(name));
};
value.push_str(&resolved);
used_env.push(name.clone());
}
Segment::Variable(name) => {
let Some(resolved) = variable_lookup(name) else {
return Err(ResolveEnvError::missing_variable(name));
};
value.push_str(&resolved);
}
}
}
let provenance = if used_env.is_empty() {
Provenance::Literal
} else {
Provenance::EnvSourced { names: used_env }
};
Ok(Resolved { value, provenance })
}
/// Substitute only `{{ vars.* }}` tokens while preserving `{{ env.* }}`
/// tokens for their existing consumption-time env lookup.
pub fn substitute_variables<F>(&self, mut lookup: F) -> Result<Self, ResolveEnvError>
where
F: FnMut(&str) -> Option<String>,
{
let mut segments = Vec::new(); let mut segments = Vec::new();
for seg in &self.segments { for seg in &self.segments {
match seg { match seg {
Segment::Literal(text) => Self::push_literal(&mut segments, text), Segment::Literal(text) => Self::push_literal(&mut segments, text),
Segment::EnvVar(name) => segments.push(Segment::EnvVar(name.clone())), Segment::Token { namespace, name } => match ctx.lookup_for(*namespace) {
Segment::Variable(name) => { Some(lookup) => {
let Some(resolved) = lookup(name) else { let Some(resolved) = lookup(name) else {
return Err(ResolveEnvError::missing_variable(name)); return Err(ResolveError::missing(*namespace, name));
}; };
Self::push_literal(&mut segments, &resolved); Self::push_literal(&mut segments, &resolved);
} }
None => segments.push(seg.clone()),
},
} }
} }
if segments.is_empty() { if segments.is_empty() {
@ -280,6 +341,67 @@ impl InterpString {
} }
Ok(Self { segments }) Ok(Self { segments })
} }
/// Resolve in an env-only context, e.g. server-scope settings.
///
/// `lookup` should return the current value for a given env var name (or
/// `None` if unset). Tokens in any other namespace fail with
/// [`ResolveErrorKind::Unavailable`].
pub fn resolve<F>(&self, lookup: F) -> Result<Resolved, ResolveError>
where
F: FnMut(&str) -> Option<String>,
{
self.resolve_with(&mut ResolveCtx::new().with_env(lookup))
}
/// Resolve in an env-only context, falling back to the raw template
/// source when resolution fails so a missing env var surfaces as a
/// recognizable diagnostic instead of a silently dropped value.
#[expect(
clippy::disallowed_methods,
reason = "intentional raw-source fallback so a missing env var surfaces as a \
recognizable diagnostic; slated for hard-error semantics in the \
interpolation unification (D3)"
)]
#[must_use]
pub fn resolve_or_source<F>(&self, lookup: F) -> String
where
F: FnMut(&str) -> Option<String>,
{
self.resolve(lookup)
.map_or_else(|_| self.as_source(), |resolved| resolved.value)
}
/// Substitute only `{{ vars.* }}` tokens while preserving all other
/// namespaces for their consumption-time resolution.
pub fn substitute_variables<F>(&self, lookup: F) -> Result<Self, ResolveError>
where
F: FnMut(&str) -> Option<String>,
{
self.substitute_with(&mut ResolveCtx::new().with_vars(lookup))
}
/// Substitute `{{ vars.* }}` tokens inside a plain string, returning the
/// result in source form with all other tokens preserved.
///
/// This is the string-typed counterpart of
/// [`InterpString::substitute_variables`] for settings fields stored as
/// `String`; it keeps the raw-source round-trip in one audited place.
pub fn substitute_variables_in_str<F>(value: &str, lookup: F) -> Result<String, ResolveError>
where
F: FnMut(&str) -> Option<String>,
{
let parsed = Self::parse(value);
if !parsed.references(Namespace::Vars) {
return Ok(value.to_owned());
}
#[expect(
clippy::disallowed_methods,
reason = "canonical raw-source round-trip for String-typed settings fields whose \
remaining tokens resolve downstream"
)]
Ok(parsed.substitute_variables(lookup)?.as_source())
}
} }
impl From<String> for InterpString { impl From<String> for InterpString {
@ -294,7 +416,7 @@ impl From<&str> for InterpString {
} }
} }
/// The outcome of a successful env interpolation resolution. /// The outcome of a successful interpolation resolution.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct Resolved { pub struct Resolved {
pub value: String, pub value: String,
@ -304,66 +426,78 @@ pub struct Resolved {
/// Provenance metadata for resolved config values. /// Provenance metadata for resolved config values.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum Provenance { pub enum Provenance {
/// No env var contributed to this value. /// No env var or secret contributed to this value.
Literal, Literal,
/// One or more env vars contributed to this value. Used by outward-facing /// One or more env vars and/or secrets contributed to this value. Used by
/// renderers to redact env-sourced values uniformly. /// outward-facing renderers to redact sensitive-sourced values uniformly.
EnvSourced { names: Vec<String> }, /// `vars`/`inputs` are non-sensitive and do not mark a value as sourced.
Sourced {
env_names: Vec<String>,
secret_names: Vec<String>,
},
} }
/// An error returned when an env var referenced in a config string is not set. impl Provenance {
fn from_names(env_names: Vec<String>, secret_names: Vec<String>) -> Self {
if env_names.is_empty() && secret_names.is_empty() {
Self::Literal
} else {
Self::Sourced {
env_names,
secret_names,
}
}
}
}
/// An error from resolving or substituting interpolation tokens.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolveEnvError { pub struct ResolveError {
pub name: String, pub namespace: Namespace,
pub kind: ResolveEnvErrorKind, pub name: String,
pub kind: ResolveErrorKind,
} }
impl ResolveEnvError { impl ResolveError {
fn missing_env(name: &str) -> Self { fn missing(namespace: Namespace, name: &str) -> Self {
Self { Self {
namespace,
name: name.to_string(), name: name.to_string(),
kind: ResolveEnvErrorKind::MissingEnv, kind: ResolveErrorKind::Missing,
} }
} }
fn missing_variable(name: &str) -> Self { fn unavailable(namespace: Namespace, name: &str) -> Self {
Self { Self {
namespace,
name: name.to_string(), name: name.to_string(),
kind: ResolveEnvErrorKind::MissingVariable, kind: ResolveErrorKind::Unavailable,
}
}
fn unsupported_variable(name: &str) -> Self {
Self {
name: name.to_string(),
kind: ResolveEnvErrorKind::UnsupportedVariable,
} }
} }
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResolveEnvErrorKind { pub enum ResolveErrorKind {
MissingEnv, /// The namespace is available in this context but has no value for the
MissingVariable, /// referenced name.
UnsupportedVariable, Missing,
/// The namespace is not available in this resolution context.
Unavailable,
} }
impl fmt::Display for ResolveEnvError { impl fmt::Display for ResolveError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let noun = self.namespace.noun();
let namespace = self.namespace;
match self.kind { match self.kind {
ResolveEnvErrorKind::MissingEnv => write!( ResolveErrorKind::Missing => write!(
f, f,
"environment variable {:?} referenced by {{{{ env.{} }}}} is not set", "{noun} {:?} referenced by {{{{ {namespace}.{} }}}} is not set",
self.name, self.name self.name, self.name
), ),
ResolveEnvErrorKind::MissingVariable => write!( ResolveErrorKind::Unavailable => write!(
f, f,
"variable {:?} referenced by {{{{ vars.{} }}}} is not set", "{noun} {:?} referenced by {{{{ {namespace}.{} }}}} is not supported in this \
self.name, self.name
),
ResolveEnvErrorKind::UnsupportedVariable => write!(
f,
"variable {:?} referenced by {{{{ vars.{} }}}} is not supported in this \
interpolation context", interpolation context",
self.name, self.name self.name, self.name
), ),
@ -371,10 +505,14 @@ impl fmt::Display for ResolveEnvError {
} }
} }
impl std::error::Error for ResolveEnvError {} impl std::error::Error for ResolveError {}
impl Serialize for InterpString { impl Serialize for InterpString {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
#[expect(
clippy::disallowed_methods,
reason = "serialization round-trips the unresolved template source by design"
)]
serializer.serialize_str(&self.as_source()) serializer.serialize_str(&self.as_source())
} }
} }
@ -387,7 +525,10 @@ impl<'de> Deserialize<'de> for InterpString {
type Value = InterpString; type Value = InterpString;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a string, optionally containing {{ env.NAME }} interpolation tokens") f.write_str(
"a string, optionally containing {{ env.NAME }}, {{ vars.NAME }}, \
{{ secrets.NAME }}, or {{ inputs.NAME }} interpolation tokens",
)
} }
fn visit_str<E: de::Error>(self, value: &str) -> Result<InterpString, E> { fn visit_str<E: de::Error>(self, value: &str) -> Result<InterpString, E> {
@ -404,6 +545,10 @@ impl<'de> Deserialize<'de> for InterpString {
} }
#[cfg(test)] #[cfg(test)]
#[expect(
clippy::disallowed_methods,
reason = "tests assert raw template source round-trips"
)]
mod tests { mod tests {
use std::collections::HashMap; use std::collections::HashMap;
@ -418,31 +563,31 @@ mod tests {
} }
#[test] #[test]
fn literal_string_has_no_env_refs() { fn literal_string_has_no_refs() {
let s = InterpString::parse("hello world"); let s = InterpString::parse("hello world");
assert!(s.is_literal()); assert!(s.is_literal());
assert!(!s.references_env()); assert!(!s.references(Namespace::Env));
assert_eq!(s.env_var_names(), Vec::<&str>::new()); assert_eq!(s.names(Namespace::Env), Vec::<&str>::new());
} }
#[test] #[test]
fn whole_value_env_reference() { fn whole_value_env_reference() {
let s = InterpString::parse("{{ env.API_KEY }}"); let s = InterpString::parse("{{ env.API_KEY }}");
assert!(!s.is_literal()); assert!(!s.is_literal());
assert_eq!(s.env_var_names(), vec!["API_KEY"]); assert_eq!(s.names(Namespace::Env), vec!["API_KEY"]);
assert_eq!(s.as_source(), "{{ env.API_KEY }}"); assert_eq!(s.as_source(), "{{ env.API_KEY }}");
} }
#[test] #[test]
fn substring_env_reference() { fn substring_env_reference() {
let s = InterpString::parse("Bearer {{ env.TOKEN }}"); let s = InterpString::parse("Bearer {{ env.TOKEN }}");
assert_eq!(s.env_var_names(), vec!["TOKEN"]); assert_eq!(s.names(Namespace::Env), vec!["TOKEN"]);
} }
#[test] #[test]
fn multi_token_env_reference() { fn multi_token_env_reference() {
let s = InterpString::parse("{{ env.USER }}@{{ env.HOST }}:{{env.PORT}}"); let s = InterpString::parse("{{ env.USER }}@{{ env.HOST }}:{{env.PORT}}");
assert_eq!(s.env_var_names(), vec!["USER", "HOST", "PORT"]); assert_eq!(s.names(Namespace::Env), vec!["USER", "HOST", "PORT"]);
} }
#[test] #[test]
@ -460,8 +605,9 @@ mod tests {
.resolve(lookup_from(&[("API_KEY", "secret-123")])) .resolve(lookup_from(&[("API_KEY", "secret-123")]))
.unwrap(); .unwrap();
assert_eq!(resolved.value, "secret-123"); assert_eq!(resolved.value, "secret-123");
assert_eq!(resolved.provenance, Provenance::EnvSourced { assert_eq!(resolved.provenance, Provenance::Sourced {
names: vec!["API_KEY".into()], env_names: vec!["API_KEY".into()],
secret_names: vec![],
}); });
} }
@ -479,8 +625,9 @@ mod tests {
.resolve(lookup_from(&[("USER", "root"), ("HOST", "example.com")])) .resolve(lookup_from(&[("USER", "root"), ("HOST", "example.com")]))
.unwrap(); .unwrap();
assert_eq!(resolved.value, "root@example.com"); assert_eq!(resolved.value, "root@example.com");
assert_eq!(resolved.provenance, Provenance::EnvSourced { assert_eq!(resolved.provenance, Provenance::Sourced {
names: vec!["USER".into(), "HOST".into()], env_names: vec!["USER".into(), "HOST".into()],
secret_names: vec![],
}); });
} }
@ -489,6 +636,12 @@ mod tests {
let s = InterpString::parse("{{ env.MISSING }}"); let s = InterpString::parse("{{ env.MISSING }}");
let err = s.resolve(lookup_from(&[])).unwrap_err(); let err = s.resolve(lookup_from(&[])).unwrap_err();
assert_eq!(err.name, "MISSING"); assert_eq!(err.name, "MISSING");
assert_eq!(err.namespace, Namespace::Env);
assert_eq!(err.kind, ResolveErrorKind::Missing);
assert_eq!(
err.to_string(),
"environment variable \"MISSING\" referenced by {{ env.MISSING }} is not set"
);
} }
#[test] #[test]
@ -499,6 +652,23 @@ mod tests {
assert_eq!(resolved.provenance, Provenance::Literal); assert_eq!(resolved.provenance, Provenance::Literal);
} }
#[test]
fn unknown_namespace_token_stays_literal() {
for raw in [
"{{ unknown.NAME }}",
"{{ .leading }}",
"{{ env. }}",
"{{ no_dot }}",
"{{ secrets.bad-name }}",
"{{ if .Values.foo }}",
] {
let s = InterpString::parse(raw);
assert!(s.is_literal(), "{raw} should stay literal");
let resolved = s.resolve(lookup_from(&[])).unwrap();
assert_eq!(resolved.value, raw);
}
}
#[test] #[test]
fn serde_round_trip_preserves_token_form() { fn serde_round_trip_preserves_token_form() {
#[derive(Debug, serde::Deserialize, serde::Serialize, PartialEq)] #[derive(Debug, serde::Deserialize, serde::Serialize, PartialEq)]
@ -513,41 +683,65 @@ mod tests {
assert_eq!(rendered, input); assert_eq!(rendered, input);
} }
#[test]
fn serde_round_trip_preserves_all_namespaces() {
#[derive(Debug, serde::Deserialize, serde::Serialize, PartialEq)]
struct Wrap {
s: InterpString,
}
let input = r#"{"s":"{{ env.A }}/{{ vars.B }}/{{ secrets.C }}/{{ inputs.d-key }}"}"#;
let parsed: Wrap = serde_json::from_str(input).unwrap();
let rendered = serde_json::to_string(&parsed).unwrap();
assert_eq!(rendered, input);
}
#[test] #[test]
fn vars_reference_round_trips_source() { fn vars_reference_round_trips_source() {
let s = InterpString::parse("{{ vars.RUNTIME_TOKEN }}"); let s = InterpString::parse("{{ vars.RUNTIME_TOKEN }}");
assert_eq!(s.var_names(), vec!["RUNTIME_TOKEN"]); assert_eq!(s.names(Namespace::Vars), vec!["RUNTIME_TOKEN"]);
assert_eq!(s.as_source(), "{{ vars.RUNTIME_TOKEN }}"); assert_eq!(s.as_source(), "{{ vars.RUNTIME_TOKEN }}");
} }
#[test] #[test]
fn resolve_with_variables_substitutes_env_and_var_tokens() { fn resolve_with_substitutes_env_and_var_tokens() {
let s = InterpString::parse("https://{{ env.REGION }}.{{ vars.DOMAIN }}"); let s = InterpString::parse("https://{{ env.REGION }}.{{ vars.DOMAIN }}");
let resolved = s let resolved = s
.resolve_with_variables( .resolve_with(
lookup_from(&[("REGION", "us-east-1")]), &mut ResolveCtx::new()
lookup_from(&[("DOMAIN", "example.com")]), .with_env(lookup_from(&[("REGION", "us-east-1")]))
.with_vars(lookup_from(&[("DOMAIN", "example.com")])),
) )
.unwrap(); .unwrap();
assert_eq!(resolved.value, "https://us-east-1.example.com"); assert_eq!(resolved.value, "https://us-east-1.example.com");
assert_eq!(resolved.provenance, Provenance::EnvSourced { assert_eq!(resolved.provenance, Provenance::Sourced {
names: vec!["REGION".into()], env_names: vec!["REGION".into()],
secret_names: vec![],
}); });
} }
#[test] #[test]
fn resolve_with_variables_reports_missing_variable() { fn resolve_with_reports_missing_variable() {
let s = InterpString::parse("{{ vars.MISSING }}"); let s = InterpString::parse("{{ vars.MISSING }}");
let err = s let err = s
.resolve_with_variables(lookup_from(&[]), lookup_from(&[])) .resolve_with(
&mut ResolveCtx::new()
.with_env(lookup_from(&[]))
.with_vars(lookup_from(&[])),
)
.unwrap_err(); .unwrap_err();
assert_eq!(err.name, "MISSING"); assert_eq!(err.name, "MISSING");
assert_eq!(err.kind, ResolveEnvErrorKind::MissingVariable); assert_eq!(err.namespace, Namespace::Vars);
assert_eq!(err.kind, ResolveErrorKind::Missing);
assert_eq!(
err.to_string(),
"variable \"MISSING\" referenced by {{ vars.MISSING }} is not set"
);
} }
#[test] #[test]
@ -557,6 +751,114 @@ mod tests {
let err = s.resolve(lookup_from(&[])).unwrap_err(); let err = s.resolve(lookup_from(&[])).unwrap_err();
assert_eq!(err.name, "RUNTIME_TOKEN"); assert_eq!(err.name, "RUNTIME_TOKEN");
assert_eq!(err.kind, ResolveEnvErrorKind::UnsupportedVariable); assert_eq!(err.namespace, Namespace::Vars);
assert_eq!(err.kind, ResolveErrorKind::Unavailable);
assert_eq!(
err.to_string(),
"variable \"RUNTIME_TOKEN\" referenced by {{ vars.RUNTIME_TOKEN }} is not supported \
in this interpolation context"
);
}
#[test]
fn env_only_resolution_rejects_secrets_reference() {
let s = InterpString::parse("{{ secrets.API_KEY }}");
let err = s.resolve(lookup_from(&[])).unwrap_err();
assert_eq!(err.namespace, Namespace::Secrets);
assert_eq!(err.kind, ResolveErrorKind::Unavailable);
assert_eq!(
err.to_string(),
"secret \"API_KEY\" referenced by {{ secrets.API_KEY }} is not supported in this \
interpolation context"
);
}
#[test]
fn resolve_with_secrets_tracks_provenance() {
let s = InterpString::parse("Bearer {{ secrets.API_KEY }} via {{ env.PROXY }}");
let resolved = s
.resolve_with(
&mut ResolveCtx::new()
.with_env(lookup_from(&[("PROXY", "proxy.internal")]))
.with_secrets(lookup_from(&[("API_KEY", "vault-value")])),
)
.unwrap();
assert_eq!(resolved.value, "Bearer vault-value via proxy.internal");
assert_eq!(resolved.provenance, Provenance::Sourced {
env_names: vec!["PROXY".into()],
secret_names: vec!["API_KEY".into()],
});
}
#[test]
fn resolve_with_inputs_substitutes_without_provenance() {
let s = InterpString::parse("run-{{ inputs.ticket-id }}");
let resolved = s
.resolve_with(&mut ResolveCtx::new().with_inputs(lookup_from(&[("ticket-id", "1234")])))
.unwrap();
assert_eq!(resolved.value, "run-1234");
assert_eq!(resolved.provenance, Provenance::Literal);
}
#[test]
fn substitute_variables_preserves_late_bound_tokens() {
let s =
InterpString::parse("{{ vars.NAME }}:{{ env.HOME }}:{{ secrets.KEY }}:{{ inputs.id }}");
let substituted = s
.substitute_variables(lookup_from(&[("NAME", "fabro")]))
.unwrap();
assert_eq!(
substituted.as_source(),
"fabro:{{ env.HOME }}:{{ secrets.KEY }}:{{ inputs.id }}"
);
}
#[test]
fn substitute_variables_reports_missing_variable() {
let s = InterpString::parse("{{ vars.MISSING }}");
let err = s.substitute_variables(lookup_from(&[])).unwrap_err();
assert_eq!(err.namespace, Namespace::Vars);
assert_eq!(err.kind, ResolveErrorKind::Missing);
}
#[test]
fn substitute_with_merges_adjacent_literals() {
let s = InterpString::parse("a{{ vars.B }}c");
let substituted = s
.substitute_with(&mut ResolveCtx::new().with_vars(lookup_from(&[("B", "b")])))
.unwrap();
assert!(substituted.is_literal());
assert_eq!(substituted.as_source(), "abc");
}
#[test]
fn substitute_variables_in_str_round_trips_source() {
let out = InterpString::substitute_variables_in_str(
"{{ vars.NAME }} at {{ env.HOME }}",
lookup_from(&[("NAME", "fabro")]),
)
.unwrap();
assert_eq!(out, "fabro at {{ env.HOME }}");
}
#[test]
fn namespace_displays_lowercase() {
assert_eq!(Namespace::Env.to_string(), "env");
assert_eq!(Namespace::Vars.to_string(), "vars");
assert_eq!(Namespace::Secrets.to_string(), "secrets");
assert_eq!(Namespace::Inputs.to_string(), "inputs");
} }
} }

View file

@ -25,7 +25,7 @@ pub use cli::{
CliLoggingSettings, CliNamespace, CliOutputSettings, CliTargetSettings, CliUpdatesSettings, CliLoggingSettings, CliNamespace, CliOutputSettings, CliTargetSettings, CliUpdatesSettings,
}; };
pub use duration::{Duration, ParseDurationError}; pub use duration::{Duration, ParseDurationError};
pub use interp::{InterpString, Provenance, ResolveEnvError, Resolved}; pub use interp::{InterpString, Provenance, ResolveCtx, ResolveError, ResolveErrorKind, Resolved};
pub use model_ref::{ pub use model_ref::{
AmbiguousModelRef, ModelRef, ModelRegistry, ParseModelRefError, ResolvedModelRef, AmbiguousModelRef, ModelRef, ModelRegistry, ParseModelRefError, ResolvedModelRef,
}; };

View file

@ -14,7 +14,7 @@ use serde::ser::SerializeStruct;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use super::duration::Duration; use super::duration::Duration;
use super::interp::{InterpString, ResolveEnvError}; use super::interp::{InterpString, Namespace, ResolveError};
use super::model_ref::ModelRef; use super::model_ref::ModelRef;
use super::size::Size; use super::size::Size;
@ -77,7 +77,7 @@ impl Default for RunNamespace {
} }
impl RunNamespace { impl RunNamespace {
pub fn substitute_variables<F>(&mut self, mut lookup: F) -> Result<(), ResolveEnvError> pub fn substitute_variables<F>(&mut self, mut lookup: F) -> Result<(), ResolveError>
where where
F: FnMut(&str) -> Option<String>, F: FnMut(&str) -> Option<String>,
{ {
@ -128,7 +128,7 @@ impl RunNamespace {
} }
} }
fn substitute_goal<F>(goal: &mut Option<RunGoal>, lookup: &mut F) -> Result<(), ResolveEnvError> fn substitute_goal<F>(goal: &mut Option<RunGoal>, lookup: &mut F) -> Result<(), ResolveError>
where where
F: FnMut(&str) -> Option<String>, F: FnMut(&str) -> Option<String>,
{ {
@ -141,7 +141,7 @@ where
fn substitute_option<F>( fn substitute_option<F>(
value: &mut Option<InterpString>, value: &mut Option<InterpString>,
lookup: &mut F, lookup: &mut F,
) -> Result<(), ResolveEnvError> ) -> Result<(), ResolveError>
where where
F: FnMut(&str) -> Option<String>, F: FnMut(&str) -> Option<String>,
{ {
@ -154,7 +154,7 @@ where
fn substitute_map<F>( fn substitute_map<F>(
values: &mut HashMap<String, InterpString>, values: &mut HashMap<String, InterpString>,
lookup: &mut F, lookup: &mut F,
) -> Result<(), ResolveEnvError> ) -> Result<(), ResolveError>
where where
F: FnMut(&str) -> Option<String>, F: FnMut(&str) -> Option<String>,
{ {
@ -164,27 +164,26 @@ where
Ok(()) Ok(())
} }
fn substitute<F>(value: &mut InterpString, lookup: &mut F) -> Result<(), ResolveEnvError> fn substitute<F>(value: &mut InterpString, lookup: &mut F) -> Result<(), ResolveError>
where where
F: FnMut(&str) -> Option<String>, F: FnMut(&str) -> Option<String>,
{ {
if !value.references_vars() { if !value.references(Namespace::Vars) {
return Ok(()); return Ok(());
} }
*value = value.substitute_variables(lookup)?; *value = value.substitute_variables(lookup)?;
Ok(()) Ok(())
} }
fn substitute_string<F>(value: &mut String, lookup: &mut F) -> Result<(), ResolveEnvError> fn substitute_string<F>(value: &mut String, lookup: &mut F) -> Result<(), ResolveError>
where where
F: FnMut(&str) -> Option<String>, F: FnMut(&str) -> Option<String>,
{ {
if !may_reference_variable(value) { if !may_reference_variable(value) {
return Ok(()); return Ok(());
} }
let parsed = InterpString::parse(value); if InterpString::parse(value).references(Namespace::Vars) {
if parsed.references_vars() { *value = InterpString::substitute_variables_in_str(value, lookup)?;
*value = parsed.substitute_variables(lookup)?.as_source();
} }
Ok(()) Ok(())
} }
@ -196,7 +195,7 @@ fn may_reference_variable(value: &str) -> bool {
fn substitute_option_string<F>( fn substitute_option_string<F>(
value: &mut Option<String>, value: &mut Option<String>,
lookup: &mut F, lookup: &mut F,
) -> Result<(), ResolveEnvError> ) -> Result<(), ResolveError>
where where
F: FnMut(&str) -> Option<String>, F: FnMut(&str) -> Option<String>,
{ {
@ -206,7 +205,7 @@ where
} }
} }
fn substitute_string_vec<F>(values: &mut [String], lookup: &mut F) -> Result<(), ResolveEnvError> fn substitute_string_vec<F>(values: &mut [String], lookup: &mut F) -> Result<(), ResolveError>
where where
F: FnMut(&str) -> Option<String>, F: FnMut(&str) -> Option<String>,
{ {
@ -219,7 +218,7 @@ where
fn substitute_string_map<F>( fn substitute_string_map<F>(
values: &mut HashMap<String, String>, values: &mut HashMap<String, String>,
lookup: &mut F, lookup: &mut F,
) -> Result<(), ResolveEnvError> ) -> Result<(), ResolveError>
where where
F: FnMut(&str) -> Option<String>, F: FnMut(&str) -> Option<String>,
{ {
@ -232,7 +231,7 @@ where
fn substitute_mcp_transport<F>( fn substitute_mcp_transport<F>(
transport: &mut McpTransport, transport: &mut McpTransport,
lookup: &mut F, lookup: &mut F,
) -> Result<(), ResolveEnvError> ) -> Result<(), ResolveError>
where where
F: FnMut(&str) -> Option<String>, F: FnMut(&str) -> Option<String>,
{ {
@ -251,7 +250,7 @@ where
fn substitute_environment<F>( fn substitute_environment<F>(
environment: &mut RunEnvironmentSettings, environment: &mut RunEnvironmentSettings,
lookup: &mut F, lookup: &mut F,
) -> Result<(), ResolveEnvError> ) -> Result<(), ResolveError>
where where
F: FnMut(&str) -> Option<String>, F: FnMut(&str) -> Option<String>,
{ {
@ -271,7 +270,7 @@ where
fn substitute_dockerfile_source<F>( fn substitute_dockerfile_source<F>(
source: &mut Option<DockerfileSource>, source: &mut Option<DockerfileSource>,
lookup: &mut F, lookup: &mut F,
) -> Result<(), ResolveEnvError> ) -> Result<(), ResolveError>
where where
F: FnMut(&str) -> Option<String>, F: FnMut(&str) -> Option<String>,
{ {
@ -283,7 +282,7 @@ where
} }
} }
fn substitute_hook_type<F>(hook_type: &mut HookType, lookup: &mut F) -> Result<(), ResolveEnvError> fn substitute_hook_type<F>(hook_type: &mut HookType, lookup: &mut F) -> Result<(), ResolveError>
where where
F: FnMut(&str) -> Option<String>, F: FnMut(&str) -> Option<String>,
{ {
@ -314,6 +313,10 @@ mod run_namespace_variable_substitution_tests {
RunEnvironmentSettings, RunGoal, RunNamespace, RunPrepareSettings, RunEnvironmentSettings, RunGoal, RunNamespace, RunPrepareSettings,
}; };
#[expect(
clippy::disallowed_methods,
reason = "test asserts the raw template source"
)]
#[test] #[test]
fn substitutes_variables_in_interp_and_late_bound_run_strings() { fn substitutes_variables_in_interp_and_late_bound_run_strings() {
let mut run = RunNamespace { let mut run = RunNamespace {
@ -500,9 +503,9 @@ impl RunIntegrationsGithubSettings {
} }
/// Resolve every `permissions` value's `{{ env.* }}` tokens via /// Resolve every `permissions` value's `{{ env.* }}` tokens via
/// `lookup`, falling back to `InterpString::as_source()` when /// `lookup`, falling back to the raw template source when resolution
/// resolution fails so callers see a recognizable diagnostic instead /// fails so callers see a recognizable diagnostic instead of a
/// of a silently dropped key. The `lookup` seam keeps tests free of /// silently dropped key. The `lookup` seam keeps tests free of
/// process-env coupling; production callers pass a thin wrapper over /// process-env coupling; production callers pass a thin wrapper over
/// `std::env::var`. /// `std::env::var`.
pub fn resolve_permissions<F>(&self, mut lookup: F) -> HashMap<String, String> pub fn resolve_permissions<F>(&self, mut lookup: F) -> HashMap<String, String>
@ -511,12 +514,7 @@ impl RunIntegrationsGithubSettings {
{ {
self.permissions self.permissions
.iter() .iter()
.map(|(name, value)| { .map(|(name, value)| (name.clone(), value.resolve_or_source(&mut lookup)))
let resolved = value
.resolve(&mut lookup)
.map_or_else(|_| value.as_source(), |resolved| resolved.value);
(name.clone(), resolved)
})
.collect() .collect()
} }
} }
@ -875,12 +873,7 @@ impl RunEnvironmentSettings {
{ {
self.env self.env
.iter() .iter()
.map(|(name, value)| { .map(|(name, value)| (name.clone(), value.resolve_or_source(&mut lookup)))
let resolved = value
.resolve(&mut lookup)
.map_or_else(|_| value.as_source(), |resolved| resolved.value);
(name.clone(), resolved)
})
.collect() .collect()
} }
} }

View file

@ -1133,6 +1133,10 @@ mod tests {
} }
} }
#[expect(
clippy::disallowed_methods,
reason = "test asserts the raw template source"
)]
#[tokio::test] #[tokio::test]
async fn create_persists_normalized_config_and_initial_state() { async fn create_persists_normalized_config_and_initial_state() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();

View file

@ -560,6 +560,11 @@ fn resolve_docker_config(settings: &ResolvedRunSettings) -> DockerSandboxOptions
docker_config_from_environment(&settings.environment, !settings.clone.enabled) docker_config_from_environment(&settings.environment, !settings.clone.enabled)
} }
#[expect(
clippy::disallowed_methods,
reason = "raw source is today's behavior; run.model.name/provider are slated for demotion to plain String in the \
interpolation unification (D2)"
)]
fn resolve_start_llm( fn resolve_start_llm(
catalog: &Catalog, catalog: &Catalog,
configured: &[ProviderId], configured: &[ProviderId],

View file

@ -4,6 +4,11 @@ use fabro_types::WorkflowSettings;
use fabro_types::settings::InterpString; use fabro_types::settings::InterpString;
use fabro_types::settings::run::RunGoal; use fabro_types::settings::run::RunGoal;
#[expect(
clippy::disallowed_methods,
reason = "raw source is today's behavior; run.model.name/provider are slated for demotion to plain String in the \
interpolation unification (D2)"
)]
pub fn materialize_run( pub fn materialize_run(
mut settings: WorkflowSettings, mut settings: WorkflowSettings,
graph: &Graph, graph: &Graph,

View file

@ -10,6 +10,10 @@ fn graph(source: &str) -> Graph {
parser::parse(source).expect("graph should parse") parser::parse(source).expect("graph should parse")
} }
#[expect(
clippy::disallowed_methods,
reason = "test asserts the raw template source"
)]
#[test] #[test]
fn materialize_run_applies_graph_and_catalog_defaults() { fn materialize_run_applies_graph_and_catalog_defaults() {
let source = r#"digraph Test { let source = r#"digraph Test {
@ -62,6 +66,10 @@ fn materialize_run_applies_graph_and_catalog_defaults() {
assert!(resolved.pull_request.is_none()); assert!(resolved.pull_request.is_none());
} }
#[expect(
clippy::disallowed_methods,
reason = "test asserts the raw template source"
)]
#[test] #[test]
fn materialize_run_uses_configured_provider_defaults() { fn materialize_run_uses_configured_provider_defaults() {
let source = r#"digraph Test { let source = r#"digraph Test {