Skip to main content

greentic_bundle/build/
mod.rs

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    /// Path to the DSSE signature sidecar emitted next to `artifact_path`,
29    /// present iff the build ran with `--signing-key`.
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub signature_path: Option<String>,
32}
33
34#[derive(Debug, Clone, Serialize)]
35pub struct DoctorReport {
36    pub target: String,
37    pub ok: bool,
38    pub checks: Vec<DoctorCheck>,
39}
40
41#[derive(Debug, Clone, Serialize)]
42pub struct DoctorCheck {
43    pub name: String,
44    pub ok: bool,
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub details: Option<String>,
47}
48
49#[derive(Debug, Clone, Serialize)]
50pub struct InspectReport {
51    pub target: String,
52    pub kind: String,
53    pub manifest: ReaderBundleManifest,
54    pub lock: ReaderBundleLock,
55    pub runtime_surface: greentic_bundle_reader::BundleRuntimeSurface,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub contents: Option<Vec<String>>,
58}
59
60#[derive(Debug, Clone, Serialize)]
61pub struct UnbundleResult {
62    pub artifact_path: String,
63    pub output_dir: String,
64}
65
66pub fn build_workspace(
67    root: &Path,
68    output: Option<&Path>,
69    dry_run: bool,
70    warmup: bool,
71    signing: Option<&signing::SigningConfig>,
72) -> Result<BuildResult> {
73    let state = plan::build_state(root)?;
74    let artifact = output
75        .map(|path| path.to_path_buf())
76        .unwrap_or_else(|| default_artifact_path(root, &state.manifest.bundle_id));
77    let export_plan = export::export_plan(&state, &artifact);
78    if dry_run {
79        return Ok(BuildResult {
80            artifact_path: export_plan.artifact_path,
81            build_dir: export_plan.build_dir,
82            manifest_path: export_plan.manifest_path,
83            signature_path: None,
84        });
85    }
86    export::write_build_outputs(&state, &artifact, warmup, signing)
87}
88
89pub fn export_build_dir(
90    build_dir: &Path,
91    output: &Path,
92    dry_run: bool,
93    warmup: bool,
94    signing: Option<&signing::SigningConfig>,
95) -> Result<BuildResult> {
96    let state = plan::load_build_state(build_dir)?;
97    let export_plan = export::export_plan(&state, output);
98    if dry_run {
99        return Ok(BuildResult {
100            artifact_path: export_plan.artifact_path,
101            build_dir: export_plan.build_dir,
102            manifest_path: export_plan.manifest_path,
103            signature_path: None,
104        });
105    }
106    export::write_build_outputs(&state, output, warmup, signing)
107}
108
109pub fn inspect_target(root: Option<&Path>, artifact: Option<&Path>) -> Result<InspectReport> {
110    match (root, artifact) {
111        (Some(root), None) => {
112            let opened = open_workspace_build_dir(root)?;
113            Ok(InspectReport {
114                target: root.display().to_string(),
115                kind: "workspace".to_string(),
116                manifest: opened.manifest.clone(),
117                lock: opened.lock.clone(),
118                runtime_surface: opened.runtime_surface(),
119                contents: None,
120            })
121        }
122        (None, Some(artifact)) => inspect_artifact(artifact),
123        _ => bail!("inspect requires exactly one of workspace root or artifact path"),
124    }
125}
126
127pub fn doctor_target(root: Option<&Path>, artifact: Option<&Path>) -> Result<DoctorReport> {
128    match (root, artifact) {
129        (Some(root), None) => doctor_workspace(root),
130        (None, Some(artifact)) => doctor_artifact(artifact),
131        _ => bail!("doctor requires exactly one of workspace root or artifact path"),
132    }
133}
134
135fn doctor_workspace(root: &Path) -> Result<DoctorReport> {
136    let state = plan::build_state(root)?;
137    let drift_ok = lock::lock_matches_manifest(&state.lock, &state.manifest);
138    let reader_validation = open_workspace_build_dir(root);
139    let reader_ok = reader_validation.is_ok();
140    let mut checks = vec![
141        DoctorCheck {
142            name: "bundle.yaml".to_string(),
143            ok: root.join(crate::project::WORKSPACE_ROOT_FILE).exists(),
144            details: None,
145        },
146        DoctorCheck {
147            name: "bundle.lock.json".to_string(),
148            // Accept either layout, matching `read_bundle_lock`; otherwise doctor
149            // reports ok=false for an artifact-layout dir that `build_state`
150            // opened successfully.
151            ok: crate::project::resolve_lock_path(root).is_some(),
152            details: None,
153        },
154        DoctorCheck {
155            name: "lock drift".to_string(),
156            ok: drift_ok,
157            details: (!drift_ok).then_some(
158                "bundle.lock.json does not match current workspace manifest inputs".to_string(),
159            ),
160        },
161        DoctorCheck {
162            name: "reader validation".to_string(),
163            ok: reader_ok,
164            details: if reader_ok {
165                Some("workspace manifest/lock satisfy reader contract".to_string())
166            } else {
167                Some(
168                    reader_validation
169                        .err()
170                        .map(|error| error.to_string())
171                        .unwrap_or_else(|| {
172                            "workspace manifest/lock do not satisfy reader contract".to_string()
173                        }),
174                )
175            },
176        },
177    ];
178    let staging = temp_build_dir(&state)?;
179    let secrets = doctor_secrets::scan_build_dir(staging.path())?;
180    checks.extend(secret_scan_checks(secrets));
181    Ok(DoctorReport {
182        target: root.display().to_string(),
183        ok: checks.iter().all(|check| check.ok),
184        checks,
185    })
186}
187
188fn doctor_artifact(artifact: &Path) -> Result<DoctorReport> {
189    let opened = greentic_bundle_reader::open_artifact(artifact)
190        .with_context(|| format!("open artifact {}", artifact.display()))?;
191    let mut checks = vec![
192        DoctorCheck {
193            name: "artifact exists".to_string(),
194            ok: artifact.exists(),
195            details: None,
196        },
197        DoctorCheck {
198            name: "manifest embedded".to_string(),
199            ok: !opened.manifest.bundle_id.is_empty(),
200            details: None,
201        },
202        DoctorCheck {
203            name: "lock embedded".to_string(),
204            ok: !opened.lock.bundle_id.is_empty(),
205            details: None,
206        },
207        DoctorCheck {
208            name: "reader validation".to_string(),
209            ok: true,
210            details: Some(format!(
211                "{} opened by {} reader",
212                opened.format_version,
213                opened.source_kind.as_str()
214            )),
215        },
216    ];
217    let secrets = doctor_secrets::scan_artifact(artifact)?;
218    checks.extend(secret_scan_checks(secrets));
219    Ok(DoctorReport {
220        target: artifact.display().to_string(),
221        ok: checks.iter().all(|check| check.ok),
222        checks,
223    })
224}
225
226// Translates a SecretsReport into DoctorCheck entries so the existing JSON
227// schema stays stable for downstream consumers (CI, status pages). Clean
228// scans emit a single `secret-leak scan` pass; every finding becomes its own
229// `secret-leak: <kind>` fail-check with the finding message + path.
230fn secret_scan_checks(report: doctor_secrets::SecretsReport) -> Vec<DoctorCheck> {
231    if report.findings.is_empty() {
232        return vec![DoctorCheck {
233            name: "secret-leak scan".to_string(),
234            ok: true,
235            details: None,
236        }];
237    }
238    report
239        .findings
240        .into_iter()
241        .map(|finding| {
242            let detail = match finding.path.as_deref() {
243                Some(path) => format!("{path}: {}", finding.message),
244                None => finding.message.clone(),
245            };
246            DoctorCheck {
247                name: format!("secret-leak: {}", finding_kind_label(finding.kind)),
248                ok: false,
249                details: Some(detail),
250            }
251        })
252        .collect()
253}
254
255fn finding_kind_label(kind: doctor_secrets::FindingKind) -> &'static str {
256    match kind {
257        doctor_secrets::FindingKind::DevStorePath => "dev-store path",
258        doctor_secrets::FindingKind::SecretValuesPopulated => "secret_values populated",
259        doctor_secrets::FindingKind::NormalizedAnswersLeak => "normalized_answers leak",
260        doctor_secrets::FindingKind::ArchiveBytesContainsDevPath => "archive-bytes dev path",
261    }
262}
263
264fn inspect_artifact(artifact: &Path) -> Result<InspectReport> {
265    let opened = greentic_bundle_reader::open_artifact(artifact)
266        .with_context(|| format!("open artifact {}", artifact.display()))?;
267    Ok(InspectReport {
268        target: artifact.display().to_string(),
269        kind: "artifact".to_string(),
270        manifest: opened.manifest.clone(),
271        lock: opened.lock.clone(),
272        runtime_surface: opened.runtime_surface(),
273        contents: Some(squashfs::list_artifact_contents(artifact)?),
274    })
275}
276
277pub fn unbundle_artifact(artifact: &Path, output_dir: &Path) -> Result<UnbundleResult> {
278    squashfs::unpack_artifact(artifact, output_dir)?;
279    Ok(UnbundleResult {
280        artifact_path: artifact.display().to_string(),
281        output_dir: output_dir.display().to_string(),
282    })
283}
284
285pub fn default_artifact_path(root: &Path, bundle_id: &str) -> PathBuf {
286    root.join("dist")
287        .join(format!("{bundle_id}{FUTURE_ARTIFACT_EXTENSION}"))
288}
289
290fn open_workspace_build_dir(root: &Path) -> Result<OpenedBundle> {
291    let state = plan::build_state(root)?;
292    let staging_dir = temp_build_dir(&state)?;
293    greentic_bundle_reader::open_build_dir_with_source(
294        staging_dir.path(),
295        root.display().to_string(),
296    )
297    .map_err(|error| anyhow::anyhow!(error.to_string()))
298}
299
300fn temp_build_dir(state: &plan::BuildState) -> Result<TempDir> {
301    let staging_dir = tempfile::tempdir().context("create temporary normalized build dir")?;
302    export::write_normalized_build_dir(state, staging_dir.path())?;
303    Ok(staging_dir)
304}