Skip to main content

faucet_cli/serve/
load.rs

1//! Turn a submitted config body into expanded nodes, applying the workspace
2//! `--default-config` base. Mirrors `PipelineConfig::from_path_async` but merges
3//! a base `Value` and uses `from_value`. All `${env}`/`${file}`/`${secret}` and
4//! `${vault:…}`-style directives resolve against the *server's* environment and
5//! credentials (the documented privilege surface — spec §13).
6
7use crate::config::PipelineConfig;
8use crate::expand::{ExpandedNode, expand};
9use crate::serve::error::ServeError;
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13/// Wire format of a submitted config body.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
15#[serde(rename_all = "lowercase")]
16pub enum ConfigFormat {
17    #[default]
18    Yaml,
19    Json,
20}
21
22/// A loaded submission: the merged/resolved config and its expanded nodes.
23#[derive(Debug)]
24pub struct LoadedSubmission {
25    pub cfg: PipelineConfig,
26    pub nodes: Vec<ExpandedNode>,
27}
28
29/// Load + merge + expand a submitted config body.
30pub async fn load_submission(
31    body: &str,
32    format: ConfigFormat,
33    default_base: Option<&Value>,
34) -> Result<LoadedSubmission, ServeError> {
35    // 1. Parse to a Value per the declared format.
36    let mut submitted: Value = match format {
37        ConfigFormat::Yaml => serde_yaml::from_str(body)
38            .map_err(|e| ServeError::BadConfig(format!("invalid YAML: {e}")))?,
39        ConfigFormat::Json => serde_json::from_str(body)
40            .map_err(|e| ServeError::BadConfig(format!("invalid JSON: {e}")))?,
41    };
42
43    // 2. ${env}/${file}/${secret} interpolation against the server's env/fs,
44    // resolved INTO the parsed tree (post-parse) so a resolved value can never
45    // alter the submitted document's structure (F43).
46    crate::interpolate::interpolate_value(&mut submitted)
47        .map_err(|e| ServeError::BadConfig(e.to_string()))?;
48
49    // 3. Merge onto the workspace default (submitted wins; see merge.rs semantics).
50    let merged = match default_base {
51        Some(base) => {
52            let mut m = base.clone();
53            crate::merge::merge_value(&mut m, submitted);
54            m
55        }
56        None => submitted,
57    };
58
59    // 4. Version gate + structural-ref resolution.
60    let mut cfg = PipelineConfig::from_value(merged).map_err(|e| ServeError::Unprocessable {
61        message: e.to_string(),
62        details: None,
63    })?;
64
65    // serve runs once per submission; a schedule: block is a category error.
66    #[cfg(feature = "schedule")]
67    if cfg.schedule.is_some() {
68        return Err(ServeError::BadConfig(
69            "submitted config contains a `schedule:` block — serve runs once per \
70             submission; use `faucet schedule` for cron scheduling"
71                .into(),
72        ));
73    }
74
75    // 5. Secret-manager directives (${vault:…} etc.) with the server's creds.
76    crate::secrets::resolve_secrets(&mut cfg)
77        .await
78        .map_err(|e| ServeError::BadConfig(e.to_string()))?;
79
80    // 6. Expand the matrix.
81    let nodes = expand(&cfg).map_err(|e| ServeError::Unprocessable {
82        message: e.to_string(),
83        details: None,
84    })?;
85
86    Ok(LoadedSubmission { cfg, nodes })
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92    use serde_json::json;
93
94    fn base() -> Value {
95        json!({
96            "version": 1,
97            "pipeline": {
98                "source": { "type": "csv", "config": { "path": "DEFAULT.csv" } },
99                "sink": { "type": "jsonl", "config": { "path": "out.jsonl" } }
100            }
101        })
102    }
103
104    #[tokio::test]
105    async fn submitted_overrides_default() {
106        let body = r#"{ "pipeline": { "source": { "config": { "path": "OVERRIDE.csv" } } } }"#;
107        let loaded = load_submission(body, ConfigFormat::Json, Some(&base()))
108            .await
109            .unwrap();
110        // The override wins; the default sink survives the merge.
111        let node = &loaded.nodes[0];
112        assert_eq!(node.source.config["path"], "OVERRIDE.csv");
113        assert_eq!(node.sink.config["path"], "out.jsonl");
114    }
115
116    #[tokio::test]
117    async fn missing_version_without_base_is_unprocessable() {
118        // version defaults to 1 via serde, so this exercises the expand/validation
119        // failure path (pipeline with no source/sink). Accept either layer's error.
120        let body = r#"{ "pipeline": {} }"#;
121        let err = load_submission(body, ConfigFormat::Json, None)
122            .await
123            .unwrap_err();
124        assert!(matches!(
125            err,
126            ServeError::Unprocessable { .. } | ServeError::BadConfig(_)
127        ));
128    }
129
130    #[cfg(feature = "schedule")]
131    #[tokio::test]
132    async fn schedule_block_is_rejected() {
133        let body = r#"
134version: 1
135pipeline:
136  source: { type: csv, config: { path: x.csv } }
137  sink: { type: jsonl, config: { path: out.jsonl } }
138schedule:
139  cron: "0 * * * *"
140  timezone: UTC
141"#;
142        let err = load_submission(body, ConfigFormat::Yaml, None)
143            .await
144            .unwrap_err();
145        match err {
146            ServeError::BadConfig(m) => assert!(m.contains("schedule:")),
147            other => panic!("expected BadConfig, got {other:?}"),
148        }
149    }
150
151    #[tokio::test]
152    async fn invalid_yaml_is_bad_config() {
153        let err = load_submission("{[bad", ConfigFormat::Yaml, None)
154            .await
155            .unwrap_err();
156        assert!(matches!(err, ServeError::BadConfig(_)));
157    }
158
159    #[tokio::test]
160    async fn submitted_extends_is_rejected_with_composition_hint() {
161        // Composition must NOT run for HTTP-submitted bodies — otherwise a client
162        // could read arbitrary server files via `extends`. `deny_unknown_fields`
163        // rejects the key during `from_value` (no I/O), and `friendly_parse_error`
164        // attaches the composition hint.
165        let body = "version: 1\nextends: /etc/passwd\npipeline:\n  source: { type: csv, config: { path: x.csv } }\n  sink: { type: jsonl, config: { path: o.jsonl } }\n";
166        let err = load_submission(body, ConfigFormat::Yaml, None)
167            .await
168            .unwrap_err();
169        // ServeError doesn't implement Display; pull the inner message directly.
170        let msg = match &err {
171            ServeError::Unprocessable { message, .. } => message.clone(),
172            ServeError::BadConfig(m) => m.clone(),
173            other => format!("{other:?}"),
174        };
175        // Assert the hint fired (not merely that serde named the field) so a
176        // regression in `friendly_parse_error` is caught.
177        assert!(
178            msg.contains("composition"),
179            "submitted extends must be rejected with the composition hint, got: {msg}"
180        );
181    }
182}