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