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 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
127fn 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 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 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 #[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 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}