1pub mod doctor_secrets;
2pub mod export;
3pub mod lock;
4pub mod manifest;
5pub mod plan;
6pub mod signing;
7pub mod squashfs;
8pub mod warmup;
9
10use std::path::{Path, PathBuf};
11
12use anyhow::{Context, Result, bail};
13use greentic_bundle_reader::{
14 BundleLock as ReaderBundleLock, BundleManifest as ReaderBundleManifest, OpenedBundle,
15};
16use serde::Serialize;
17use tempfile::TempDir;
18
19pub const FUTURE_ARTIFACT_EXTENSION: &str = ".gtbundle";
20pub const BUILD_STATE_DIR: &str = "state/build";
21pub const BUILD_FORMAT_VERSION: &str = "gtbundle-v1";
22
23#[derive(Debug, Clone, Serialize)]
24pub struct BuildResult {
25 pub artifact_path: String,
26 pub build_dir: String,
27 pub manifest_path: String,
28 #[serde(skip_serializing_if = "Option::is_none")]
31 pub signature_path: Option<String>,
32 pub has_component_cache: bool,
37}
38
39#[derive(Debug, Clone, Serialize)]
40pub struct DoctorReport {
41 pub target: String,
42 pub ok: bool,
43 pub checks: Vec<DoctorCheck>,
44}
45
46#[derive(Debug, Clone, Serialize)]
47pub struct DoctorCheck {
48 pub name: String,
49 pub ok: bool,
50 #[serde(skip_serializing_if = "Option::is_none")]
51 pub details: Option<String>,
52}
53
54#[derive(Debug, Clone, Serialize)]
55pub struct InspectReport {
56 pub target: String,
57 pub kind: String,
58 pub manifest: ReaderBundleManifest,
59 pub lock: ReaderBundleLock,
60 pub runtime_surface: greentic_bundle_reader::BundleRuntimeSurface,
61 #[serde(skip_serializing_if = "Option::is_none")]
62 pub contents: Option<Vec<String>>,
63}
64
65#[derive(Debug, Clone, Serialize)]
66pub struct UnbundleResult {
67 pub artifact_path: String,
68 pub output_dir: String,
69}
70
71pub fn build_workspace(
72 root: &Path,
73 output: Option<&Path>,
74 dry_run: bool,
75 warmup: bool,
76 signing: Option<&signing::SigningConfig>,
77) -> Result<BuildResult> {
78 let state = plan::build_state(root)?;
79 ensure_app_packs_materialized(root)?;
80 let artifact = output
81 .map(|path| path.to_path_buf())
82 .unwrap_or_else(|| default_artifact_path(root, &state.manifest.bundle_id));
83 let export_plan = export::export_plan(&state, &artifact);
84 if dry_run {
85 return Ok(BuildResult {
86 artifact_path: export_plan.artifact_path,
87 build_dir: export_plan.build_dir,
88 manifest_path: export_plan.manifest_path,
89 signature_path: None,
90 has_component_cache: false,
91 });
92 }
93 export::write_build_outputs(&state, &artifact, warmup, signing)
94}
95
96fn ensure_app_packs_materialized(root: &Path) -> Result<()> {
110 let missing = crate::project::missing_app_pack_destinations(root)
111 .with_context(|| format!("check materialized app packs in {}", root.display()))?;
112 if missing.is_empty() {
113 return Ok(());
114 }
115 let detail = missing
116 .iter()
117 .map(|(reference, destination)| {
118 format!(" {reference} -> {} (not found)", destination.display())
119 })
120 .collect::<Vec<_>>()
121 .join("\n");
122 bail!(
123 "bundle declares {} app pack(s) that were not materialized:\n{detail}\n\
124 Each app pack reference must resolve to a pack that exists — check that \
125 it is published and reachable.",
126 missing.len()
127 )
128}
129
130pub fn export_build_dir(
131 build_dir: &Path,
132 output: &Path,
133 dry_run: bool,
134 warmup: bool,
135 signing: Option<&signing::SigningConfig>,
136) -> Result<BuildResult> {
137 let state = plan::load_build_state(build_dir)?;
138 let export_plan = export::export_plan(&state, output);
139 if dry_run {
140 return Ok(BuildResult {
141 artifact_path: export_plan.artifact_path,
142 build_dir: export_plan.build_dir,
143 manifest_path: export_plan.manifest_path,
144 signature_path: None,
145 has_component_cache: false,
146 });
147 }
148 export::write_build_outputs(&state, output, warmup, signing)
149}
150
151pub fn inspect_target(root: Option<&Path>, artifact: Option<&Path>) -> Result<InspectReport> {
152 match (root, artifact) {
153 (Some(root), None) => {
154 let opened = open_workspace_build_dir(root)?;
155 Ok(InspectReport {
156 target: root.display().to_string(),
157 kind: "workspace".to_string(),
158 manifest: opened.manifest.clone(),
159 lock: opened.lock.clone(),
160 runtime_surface: opened.runtime_surface(),
161 contents: None,
162 })
163 }
164 (None, Some(artifact)) => inspect_artifact(artifact),
165 _ => bail!("inspect requires exactly one of workspace root or artifact path"),
166 }
167}
168
169pub fn doctor_target(root: Option<&Path>, artifact: Option<&Path>) -> Result<DoctorReport> {
170 match (root, artifact) {
171 (Some(root), None) => doctor_workspace(root),
172 (None, Some(artifact)) => doctor_artifact(artifact),
173 _ => bail!("doctor requires exactly one of workspace root or artifact path"),
174 }
175}
176
177fn doctor_workspace(root: &Path) -> Result<DoctorReport> {
178 let state = plan::build_state(root)?;
179 let drift_ok = lock::lock_matches_manifest(&state.lock, &state.manifest);
180 let reader_validation = open_workspace_build_dir(root);
181 let reader_ok = reader_validation.is_ok();
182 let mut checks = vec![
183 DoctorCheck {
184 name: "bundle.yaml".to_string(),
185 ok: root.join(crate::project::WORKSPACE_ROOT_FILE).exists(),
186 details: None,
187 },
188 DoctorCheck {
189 name: "bundle.lock.json".to_string(),
190 ok: crate::project::resolve_lock_path(root).is_some(),
194 details: None,
195 },
196 DoctorCheck {
197 name: "lock drift".to_string(),
198 ok: drift_ok,
199 details: (!drift_ok).then_some(
200 "bundle.lock.json does not match current workspace manifest inputs".to_string(),
201 ),
202 },
203 DoctorCheck {
204 name: "reader validation".to_string(),
205 ok: reader_ok,
206 details: if reader_ok {
207 Some("workspace manifest/lock satisfy reader contract".to_string())
208 } else {
209 Some(
210 reader_validation
211 .err()
212 .map(|error| error.to_string())
213 .unwrap_or_else(|| {
214 "workspace manifest/lock do not satisfy reader contract".to_string()
215 }),
216 )
217 },
218 },
219 ];
220 let staging = temp_build_dir(&state)?;
221 let secrets = doctor_secrets::scan_build_dir(staging.path())?;
222 checks.extend(secret_scan_checks(secrets));
223 Ok(DoctorReport {
224 target: root.display().to_string(),
225 ok: checks.iter().all(|check| check.ok),
226 checks,
227 })
228}
229
230fn doctor_artifact(artifact: &Path) -> Result<DoctorReport> {
231 let opened = greentic_bundle_reader::open_artifact(artifact)
232 .with_context(|| format!("open artifact {}", artifact.display()))?;
233 let mut checks = vec![
234 DoctorCheck {
235 name: "artifact exists".to_string(),
236 ok: artifact.exists(),
237 details: None,
238 },
239 DoctorCheck {
240 name: "manifest embedded".to_string(),
241 ok: !opened.manifest.bundle_id.is_empty(),
242 details: None,
243 },
244 DoctorCheck {
245 name: "lock embedded".to_string(),
246 ok: !opened.lock.bundle_id.is_empty(),
247 details: None,
248 },
249 DoctorCheck {
250 name: "reader validation".to_string(),
251 ok: true,
252 details: Some(format!(
253 "{} opened by {} reader",
254 opened.format_version,
255 opened.source_kind.as_str()
256 )),
257 },
258 ];
259 let secrets = doctor_secrets::scan_artifact(artifact)?;
260 checks.extend(secret_scan_checks(secrets));
261 Ok(DoctorReport {
262 target: artifact.display().to_string(),
263 ok: checks.iter().all(|check| check.ok),
264 checks,
265 })
266}
267
268fn secret_scan_checks(report: doctor_secrets::SecretsReport) -> Vec<DoctorCheck> {
273 if report.findings.is_empty() {
274 return vec![DoctorCheck {
275 name: "secret-leak scan".to_string(),
276 ok: true,
277 details: None,
278 }];
279 }
280 report
281 .findings
282 .into_iter()
283 .map(|finding| {
284 let detail = match finding.path.as_deref() {
285 Some(path) => format!("{path}: {}", finding.message),
286 None => finding.message.clone(),
287 };
288 DoctorCheck {
289 name: format!("secret-leak: {}", finding_kind_label(finding.kind)),
290 ok: false,
291 details: Some(detail),
292 }
293 })
294 .collect()
295}
296
297fn finding_kind_label(kind: doctor_secrets::FindingKind) -> &'static str {
298 match kind {
299 doctor_secrets::FindingKind::DevStorePath => "dev-store path",
300 doctor_secrets::FindingKind::SecretValuesPopulated => "secret_values populated",
301 doctor_secrets::FindingKind::NormalizedAnswersLeak => "normalized_answers leak",
302 doctor_secrets::FindingKind::ArchiveBytesContainsDevPath => "archive-bytes dev path",
303 }
304}
305
306fn inspect_artifact(artifact: &Path) -> Result<InspectReport> {
307 let opened = greentic_bundle_reader::open_artifact(artifact)
308 .with_context(|| format!("open artifact {}", artifact.display()))?;
309 Ok(InspectReport {
310 target: artifact.display().to_string(),
311 kind: "artifact".to_string(),
312 manifest: opened.manifest.clone(),
313 lock: opened.lock.clone(),
314 runtime_surface: opened.runtime_surface(),
315 contents: Some(squashfs::list_artifact_contents(artifact)?),
316 })
317}
318
319pub fn unbundle_artifact(artifact: &Path, output_dir: &Path) -> Result<UnbundleResult> {
320 squashfs::unpack_artifact(artifact, output_dir)?;
321 Ok(UnbundleResult {
322 artifact_path: artifact.display().to_string(),
323 output_dir: output_dir.display().to_string(),
324 })
325}
326
327pub fn default_artifact_path(root: &Path, bundle_id: &str) -> PathBuf {
328 root.join("dist")
329 .join(format!("{bundle_id}{FUTURE_ARTIFACT_EXTENSION}"))
330}
331
332fn open_workspace_build_dir(root: &Path) -> Result<OpenedBundle> {
333 let state = plan::build_state(root)?;
334 let staging_dir = temp_build_dir(&state)?;
335 greentic_bundle_reader::open_build_dir_with_source(
336 staging_dir.path(),
337 root.display().to_string(),
338 )
339 .map_err(|error| anyhow::anyhow!(error.to_string()))
340}
341
342fn temp_build_dir(state: &plan::BuildState) -> Result<TempDir> {
343 let staging_dir = tempfile::tempdir().context("create temporary normalized build dir")?;
344 export::write_normalized_build_dir(state, staging_dir.path())?;
345 Ok(staging_dir)
346}