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