Skip to main content

greentic_bundle/build/
export.rs

1use std::borrow::Cow;
2use std::fs;
3use std::path::Path;
4
5use anyhow::{Context, Result};
6use serde_json::Value;
7
8const SETUP_STATE_PREFIX: &str = "state/setup/";
9const SETUP_STATE_SUFFIX: &str = ".json";
10const SECRET_VALUES_KEY: &str = "secret_values";
11const NORMALIZED_ANSWERS_KEY: &str = "normalized_answers";
12const FORM_KEY: &str = "form";
13const QUESTIONS_KEY: &str = "questions";
14const QUESTION_ID_KEY: &str = "id";
15const QUESTION_SECRET_KEY: &str = "secret";
16
17#[derive(Debug, Clone)]
18pub struct ExportPlan {
19    pub artifact_path: String,
20    pub build_dir: String,
21    pub manifest_path: String,
22}
23
24pub fn export_plan(state: &crate::build::plan::BuildState, artifact: &Path) -> ExportPlan {
25    ExportPlan {
26        artifact_path: artifact.display().to_string(),
27        build_dir: state.build_dir.display().to_string(),
28        manifest_path: state
29            .build_dir
30            .join("bundle-manifest.json")
31            .display()
32            .to_string(),
33    }
34}
35
36pub fn write_build_outputs(
37    state: &crate::build::plan::BuildState,
38    artifact: &Path,
39    warmup: bool,
40    signing: Option<&crate::build::signing::SigningConfig>,
41) -> Result<crate::build::BuildResult> {
42    // Validate the signing config BEFORE any artifact lands on disk. A bad
43    // key, mismatched .pub sibling, or signature_output==artifact must abort
44    // before write_bundle, never after — closes Codex finding #3.
45    let signer = match signing {
46        Some(cfg) => Some(crate::build::signing::PreparedSigner::prepare(
47            artifact, cfg,
48        )?),
49        None => None,
50    };
51
52    write_normalized_build_dir(state, &state.build_dir)?;
53    if warmup {
54        crate::build::warmup::warmup_build_dir(&state.build_dir)?;
55    }
56    let has_component_cache = state.build_dir.join(".cache").is_dir();
57
58    let signature_path = match signer {
59        Some(s) => {
60            let build_dir = state.build_dir.clone();
61            let sig_path = crate::build::signing::stage_sign_and_publish(artifact, &s, |staged| {
62                crate::bundle_fs::write_bundle(&build_dir, staged)
63            })?;
64            Some(sig_path.display().to_string())
65        }
66        None => {
67            crate::bundle_fs::write_bundle(&state.build_dir, artifact)?;
68            None
69        }
70    };
71
72    Ok(crate::build::BuildResult {
73        artifact_path: artifact.display().to_string(),
74        build_dir: state.build_dir.display().to_string(),
75        manifest_path: state
76            .build_dir
77            .join("bundle-manifest.json")
78            .display()
79            .to_string(),
80        signature_path,
81        has_component_cache,
82    })
83}
84
85pub fn write_normalized_build_dir(
86    state: &crate::build::plan::BuildState,
87    build_dir: &Path,
88) -> Result<()> {
89    if build_dir.exists() {
90        fs::remove_dir_all(build_dir)?;
91    }
92    fs::create_dir_all(build_dir)?;
93    fs::write(
94        build_dir.join("bundle-manifest.json"),
95        format!("{}\n", serde_json::to_string_pretty(&state.manifest)?),
96    )?;
97    fs::write(
98        build_dir.join("bundle-lock.json"),
99        format!("{}\n", serde_json::to_string_pretty(&state.lock)?),
100    )?;
101    fs::write(build_dir.join("bundle.yaml"), &state.bundle_yaml)?;
102    for (name, contents) in &state.resolved_files {
103        let path = build_dir.join(name);
104        if let Some(parent) = path.parent() {
105            fs::create_dir_all(parent)?;
106        }
107        fs::write(path, contents)?;
108    }
109    for (name, contents) in &state.setup_files {
110        let path = build_dir.join(name);
111        if let Some(parent) = path.parent() {
112            fs::create_dir_all(parent)?;
113        }
114        let redacted = redact_secret_values(name, contents)?;
115        fs::write(path, redacted.as_bytes())?;
116    }
117    for (name, contents) in &state.asset_files {
118        let path = build_dir.join(name);
119        if let Some(parent) = path.parent() {
120            fs::create_dir_all(parent)?;
121        }
122        fs::write(path, contents)?;
123    }
124    Ok(())
125}
126
127// Phase 0 secret-leak hotfix: setup-state JSON files carry plaintext secrets
128// in TWO places — `secret_values` (the split-out map) and `normalized_answers`
129// (the full pre-split map, which retains every answer including secret ones —
130// see greentic-bundle/src/setup/persist.rs:84-105). The runtime still reads
131// plaintext from the on-disk source-of-truth, but the archived copy that ships
132// in the .gtbundle must never carry plaintext.
133//
134// Strategy: parse with serde_json::Value (tolerant to schema drift), discover
135// the secret question IDs from the embedded `form.questions[*].secret` flag,
136// then drop those IDs from `normalized_answers` AND clear `secret_values`.
137// See plans/next-gen-deployment.md P0.1.
138fn redact_secret_values<'a>(name: &str, contents: &'a str) -> Result<Cow<'a, str>> {
139    if !is_setup_state_file(name) {
140        return Ok(Cow::Borrowed(contents));
141    }
142    let mut value: Value = serde_json::from_str(contents)
143        .with_context(|| format!("parse setup-state JSON for secret_values redaction: {name}"))?;
144    let Some(map) = value.as_object_mut() else {
145        return Ok(Cow::Borrowed(contents));
146    };
147    let secret_ids = collect_secret_question_ids(map);
148    let mut changed = false;
149    // Drop the legacy `secret_values` key entirely (B12 producers no longer
150    // emit it; leaving a stale `{}` would round-trip through a B12-aware
151    // deserializer as an unknown field and mask a real missing `secret_refs`).
152    if map.remove(SECRET_VALUES_KEY).is_some() {
153        changed = true;
154    }
155    if !secret_ids.is_empty()
156        && let Some(answers) = map
157            .get_mut(NORMALIZED_ANSWERS_KEY)
158            .and_then(Value::as_object_mut)
159    {
160        for id in &secret_ids {
161            if answers.remove(id).is_some() {
162                changed = true;
163            }
164        }
165    }
166    if !changed {
167        return Ok(Cow::Borrowed(contents));
168    }
169    let redacted = serde_json::to_string_pretty(&value)
170        .with_context(|| format!("re-serialize redacted setup-state JSON: {name}"))?;
171    Ok(Cow::Owned(format!("{redacted}\n")))
172}
173
174fn collect_secret_question_ids(map: &serde_json::Map<String, Value>) -> Vec<String> {
175    let Some(questions) = map
176        .get(FORM_KEY)
177        .and_then(|form| form.get(QUESTIONS_KEY))
178        .and_then(Value::as_array)
179    else {
180        return Vec::new();
181    };
182    questions
183        .iter()
184        .filter(|q| {
185            q.get(QUESTION_SECRET_KEY)
186                .and_then(Value::as_bool)
187                .unwrap_or(false)
188        })
189        .filter_map(|q| {
190            q.get(QUESTION_ID_KEY)
191                .and_then(Value::as_str)
192                .map(str::to_string)
193        })
194        .collect()
195}
196
197fn is_setup_state_file(name: &str) -> bool {
198    name.starts_with(SETUP_STATE_PREFIX) && name.ends_with(SETUP_STATE_SUFFIX)
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use serde_json::json;
205
206    #[test]
207    fn redacts_plaintext_secret_values_in_setup_state() {
208        let input = r#"{"schema_version":1,"provider_id":"p","source_kind":"legacy","form":{"id":"f","title":"t","version":"1","questions":[]},"normalized_answers":{},"non_secret_config":{},"secret_values":{"api_token":"sk-PLAINTEXT-LEAK"}}"#;
209        let out = redact_secret_values("state/setup/p.json", input).expect("redact");
210        let parsed: Value = serde_json::from_str(out.as_ref()).expect("parse output");
211        // B12: the legacy `secret_values` key is removed entirely (not cleared)
212        // so the redacted file deserializes cleanly into the new schema.
213        assert!(parsed.get("secret_values").is_none());
214        assert!(!out.contains("sk-PLAINTEXT-LEAK"));
215        assert_eq!(parsed["non_secret_config"], json!({}));
216    }
217
218    // Codex adversarial review caught this: persist.rs:84-105 writes the full
219    // pre-split map to `normalized_answers`, then copies secret-marked values
220    // into `secret_values`. Both fields ship in the archive. Redacting only
221    // `secret_values` leaves the plaintext alive in `normalized_answers`.
222    #[test]
223    fn redacts_plaintext_secrets_from_normalized_answers_via_form_metadata() {
224        let input = r#"{
225            "schema_version":1,
226            "provider_id":"telegram",
227            "source_kind":"legacy",
228            "form":{
229                "id":"telegram","title":"Telegram","version":"1",
230                "questions":[
231                    {"id":"api_token","kind":"string","title":"Token","required":true,"secret":true},
232                    {"id":"name","kind":"string","title":"Name","required":true,"secret":false}
233                ]
234            },
235            "normalized_answers":{"api_token":"sk-PLAINTEXT-LEAK","name":"my-bot"},
236            "non_secret_config":{"name":"my-bot"},
237            "secret_values":{"api_token":"sk-PLAINTEXT-LEAK"}
238        }"#;
239        let out = redact_secret_values("state/setup/telegram.json", input).expect("redact");
240        assert!(
241            !out.contains("sk-PLAINTEXT-LEAK"),
242            "redacted JSON must not contain the secret token, got:\n{out}"
243        );
244        let parsed: Value = serde_json::from_str(out.as_ref()).expect("parse output");
245        assert!(parsed.get("secret_values").is_none());
246        assert_eq!(parsed["normalized_answers"], json!({"name": "my-bot"}));
247        assert_eq!(parsed["non_secret_config"], json!({"name": "my-bot"}));
248    }
249
250    #[test]
251    fn collects_secret_ids_from_embedded_form_metadata() {
252        let map: serde_json::Map<String, Value> = serde_json::from_str(
253            r#"{
254                "form": {
255                    "questions": [
256                        {"id":"k1","secret":true},
257                        {"id":"k2","secret":false},
258                        {"id":"k3","secret":true}
259                    ]
260                }
261            }"#,
262        )
263        .unwrap();
264        let mut ids = collect_secret_question_ids(&map);
265        ids.sort();
266        assert_eq!(ids, vec!["k1".to_string(), "k3".to_string()]);
267    }
268
269    #[test]
270    fn collects_no_secret_ids_when_form_missing() {
271        let map: serde_json::Map<String, Value> =
272            serde_json::from_str(r#"{"normalized_answers":{}}"#).unwrap();
273        assert!(collect_secret_question_ids(&map).is_empty());
274    }
275
276    #[test]
277    fn removes_empty_secret_values_field() {
278        let input = r#"{"secret_values":{}}"#;
279        let out = redact_secret_values("state/setup/p.json", input).expect("redact");
280        let parsed: Value = serde_json::from_str(out.as_ref()).expect("parse output");
281        // B12: a stray `secret_values` key (even empty) is dropped so it can't
282        // mask a missing `secret_refs` for a B12-aware reader.
283        assert!(parsed.get("secret_values").is_none());
284    }
285
286    #[test]
287    fn passes_through_non_setup_state_files() {
288        let input = r#"{"secret_values":{"leaked":"value"}}"#;
289        let out = redact_secret_values("resolved/default.yaml", input).expect("redact");
290        assert!(matches!(out, Cow::Borrowed(_)));
291        assert!(out.contains("leaked"));
292    }
293
294    #[test]
295    fn passes_through_setup_state_without_secret_values_field() {
296        let input = r#"{"schema_version":1}"#;
297        let out = redact_secret_values("state/setup/p.json", input).expect("redact");
298        assert!(matches!(out, Cow::Borrowed(_)));
299    }
300
301    #[test]
302    fn bails_on_invalid_setup_state_json() {
303        let input = "not-json-at-all";
304        let err = redact_secret_values("state/setup/p.json", input).expect_err("must fail");
305        let msg = format!("{err:#}");
306        assert!(msg.contains("state/setup/p.json"));
307    }
308
309    #[test]
310    fn rejects_setup_state_files_outside_setup_dir() {
311        assert!(!is_setup_state_file("resolved/foo.json"));
312        assert!(!is_setup_state_file("state/setup/foo.txt"));
313        assert!(is_setup_state_file("state/setup/foo.json"));
314        assert!(is_setup_state_file("state/setup/nested/foo.json"));
315    }
316}