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}
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 ok: root.join(crate::project::LOCK_FILE).exists(),
149 details: None,
150 },
151 DoctorCheck {
152 name: "lock drift".to_string(),
153 ok: drift_ok,
154 details: (!drift_ok).then_some(
155 "bundle.lock.json does not match current workspace manifest inputs".to_string(),
156 ),
157 },
158 DoctorCheck {
159 name: "reader validation".to_string(),
160 ok: reader_ok,
161 details: if reader_ok {
162 Some("workspace manifest/lock satisfy reader contract".to_string())
163 } else {
164 Some(
165 reader_validation
166 .err()
167 .map(|error| error.to_string())
168 .unwrap_or_else(|| {
169 "workspace manifest/lock do not satisfy reader contract".to_string()
170 }),
171 )
172 },
173 },
174 ];
175 let staging = temp_build_dir(&state)?;
176 let secrets = doctor_secrets::scan_build_dir(staging.path())?;
177 checks.extend(secret_scan_checks(secrets));
178 Ok(DoctorReport {
179 target: root.display().to_string(),
180 ok: checks.iter().all(|check| check.ok),
181 checks,
182 })
183}
184
185fn doctor_artifact(artifact: &Path) -> Result<DoctorReport> {
186 let opened = greentic_bundle_reader::open_artifact(artifact)
187 .with_context(|| format!("open artifact {}", artifact.display()))?;
188 let mut checks = vec![
189 DoctorCheck {
190 name: "artifact exists".to_string(),
191 ok: artifact.exists(),
192 details: None,
193 },
194 DoctorCheck {
195 name: "manifest embedded".to_string(),
196 ok: !opened.manifest.bundle_id.is_empty(),
197 details: None,
198 },
199 DoctorCheck {
200 name: "lock embedded".to_string(),
201 ok: !opened.lock.bundle_id.is_empty(),
202 details: None,
203 },
204 DoctorCheck {
205 name: "reader validation".to_string(),
206 ok: true,
207 details: Some(format!(
208 "{} opened by {} reader",
209 opened.format_version,
210 opened.source_kind.as_str()
211 )),
212 },
213 ];
214 let secrets = doctor_secrets::scan_artifact(artifact)?;
215 checks.extend(secret_scan_checks(secrets));
216 Ok(DoctorReport {
217 target: artifact.display().to_string(),
218 ok: checks.iter().all(|check| check.ok),
219 checks,
220 })
221}
222
223fn secret_scan_checks(report: doctor_secrets::SecretsReport) -> Vec<DoctorCheck> {
228 if report.findings.is_empty() {
229 return vec![DoctorCheck {
230 name: "secret-leak scan".to_string(),
231 ok: true,
232 details: None,
233 }];
234 }
235 report
236 .findings
237 .into_iter()
238 .map(|finding| {
239 let detail = match finding.path.as_deref() {
240 Some(path) => format!("{path}: {}", finding.message),
241 None => finding.message.clone(),
242 };
243 DoctorCheck {
244 name: format!("secret-leak: {}", finding_kind_label(finding.kind)),
245 ok: false,
246 details: Some(detail),
247 }
248 })
249 .collect()
250}
251
252fn finding_kind_label(kind: doctor_secrets::FindingKind) -> &'static str {
253 match kind {
254 doctor_secrets::FindingKind::DevStorePath => "dev-store path",
255 doctor_secrets::FindingKind::SecretValuesPopulated => "secret_values populated",
256 doctor_secrets::FindingKind::NormalizedAnswersLeak => "normalized_answers leak",
257 doctor_secrets::FindingKind::ArchiveBytesContainsDevPath => "archive-bytes dev path",
258 }
259}
260
261fn inspect_artifact(artifact: &Path) -> Result<InspectReport> {
262 let opened = greentic_bundle_reader::open_artifact(artifact)
263 .with_context(|| format!("open artifact {}", artifact.display()))?;
264 Ok(InspectReport {
265 target: artifact.display().to_string(),
266 kind: "artifact".to_string(),
267 manifest: opened.manifest.clone(),
268 lock: opened.lock.clone(),
269 runtime_surface: opened.runtime_surface(),
270 contents: Some(squashfs::list_artifact_contents(artifact)?),
271 })
272}
273
274pub fn unbundle_artifact(artifact: &Path, output_dir: &Path) -> Result<UnbundleResult> {
275 squashfs::unpack_artifact(artifact, output_dir)?;
276 Ok(UnbundleResult {
277 artifact_path: artifact.display().to_string(),
278 output_dir: output_dir.display().to_string(),
279 })
280}
281
282pub fn default_artifact_path(root: &Path, bundle_id: &str) -> PathBuf {
283 root.join("dist")
284 .join(format!("{bundle_id}{FUTURE_ARTIFACT_EXTENSION}"))
285}
286
287fn open_workspace_build_dir(root: &Path) -> Result<OpenedBundle> {
288 let state = plan::build_state(root)?;
289 let staging_dir = temp_build_dir(&state)?;
290 greentic_bundle_reader::open_build_dir_with_source(
291 staging_dir.path(),
292 root.display().to_string(),
293 )
294 .map_err(|error| anyhow::anyhow!(error.to_string()))
295}
296
297fn temp_build_dir(state: &plan::BuildState) -> Result<TempDir> {
298 let staging_dir = tempfile::tempdir().context("create temporary normalized build dir")?;
299 export::write_normalized_build_dir(state, staging_dir.path())?;
300 Ok(staging_dir)
301}