1use crate::{
4 canister_build::{CanisterArtifactBuildOutput, CanisterBuildProfile},
5 cargo_command,
6 evidence_envelope::{
7 CommandProvenanceV1, EvidenceEnvelopeV1, EvidenceMessageSeverityV1, EvidenceMessageV1,
8 EvidenceSummaryV1, EvidenceTargetKindV1, EvidenceTargetV1, ExitClassV1, InputFingerprintV1,
9 InputPathDisplayV1, PayloadSchemaRefV1, evidence_envelope_schema, file_input_fingerprint,
10 json_payload_sha256, sha256_hex,
11 },
12 release_set::canister_manifest_path,
13};
14use serde::{Deserialize, Serialize};
15use std::{
16 env, fs,
17 path::{Path, PathBuf},
18 process::Command,
19};
20use toml::Value as TomlValue;
21
22pub const BUILD_PROVENANCE_SCHEMA_ID: &str = "canic.build_provenance.v1";
23const WASM_TARGET: &str = "wasm32-unknown-unknown";
24const DIRTY_SUMMARY_ALGORITHM: &str = "git-status-porcelain-v1-z-sha256";
25
26#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
30pub struct BuildProvenanceV1 {
31 pub schema_version: u8,
32 pub generated_at: String,
33 pub canic_version: String,
34 pub command: CommandProvenanceV1,
35 pub build_status: BuildProvenanceStatusV1,
36 pub source: SourceProvenanceV1,
37 pub cargo: CargoProvenanceV1,
38 pub artifacts: Vec<ArtifactProvenanceV1>,
39 pub warnings: Vec<EvidenceMessageV1>,
40}
41
42#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
46#[serde(rename_all = "snake_case")]
47pub enum BuildProvenanceStatusV1 {
48 Success,
49 Failed,
50 NotRecorded,
51}
52
53#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
57pub struct SourceProvenanceV1 {
58 pub schema_version: u8,
59 pub vcs: SourceVcsV1,
60 pub revision: Option<String>,
61 pub branch: Option<String>,
62 pub dirty: Option<bool>,
63 pub dirty_policy: SourceDirtyPolicyV1,
64 pub dirty_summary_digest: Option<String>,
65 pub dirty_summary_algorithm: Option<String>,
66}
67
68#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
72#[serde(rename_all = "snake_case")]
73pub enum SourceVcsV1 {
74 Git,
75 Unknown,
76}
77
78#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
82#[serde(rename_all = "snake_case")]
83pub enum SourceDirtyPolicyV1 {
84 Clean,
85 DirtyRecorded,
86 Unknown,
87}
88
89#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
93pub struct CargoProvenanceV1 {
94 pub cargo_lock_sha256: Option<String>,
95 pub package_manifest_sha256: Option<String>,
96 pub package_name: String,
97 pub package_manifest: String,
98 pub package_metadata_fleet: String,
99 pub package_metadata_role: String,
100 pub rustc_version: Option<String>,
101 pub cargo_version: Option<String>,
102 pub target: Option<String>,
103 pub profile: String,
104 pub features: Vec<String>,
105 pub default_features: Option<bool>,
106 pub rustflags_digest: Option<String>,
107 pub rustflags_digest_algorithm: Option<String>,
108 pub cargo_config_fingerprints: Vec<InputFingerprintV1>,
109 pub build_script_inputs: BuildScriptInputStateV1,
110}
111
112#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
116#[serde(rename_all = "snake_case")]
117pub enum BuildScriptInputStateV1 {
118 NotRecorded,
119 Recorded,
120 Unknown,
121}
122
123#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
127pub struct ArtifactProvenanceV1 {
128 pub role: String,
129 pub fleet: String,
130 pub artifact_kind: ArtifactProvenanceKindV1,
131 pub path: Option<String>,
132 pub path_display: InputPathDisplayV1,
133 pub hash_algorithm: String,
134 pub sha256: String,
135 pub size_bytes: u64,
136 pub produced_by: String,
137}
138
139#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
143#[serde(rename_all = "snake_case")]
144pub enum ArtifactProvenanceKindV1 {
145 Wasm,
146 WasmGzip,
147 Candid,
148 Metadata,
149 Other,
150}
151
152#[derive(Clone, Debug)]
156pub struct BuildProvenanceRequest {
157 pub fleet: String,
158 pub role: String,
159 pub network: String,
160 pub profile: CanisterBuildProfile,
161 pub workspace_root: PathBuf,
162 pub config_path: PathBuf,
163 pub output: CanisterArtifactBuildOutput,
164 pub command: CommandProvenanceV1,
165 pub generated_at: String,
166 pub canic_version: String,
167}
168
169#[must_use]
170pub fn build_provenance_schema() -> PayloadSchemaRefV1 {
171 PayloadSchemaRefV1::stable(BUILD_PROVENANCE_SCHEMA_ID, "1")
172}
173
174pub fn build_provenance_envelope(
175 request: &BuildProvenanceRequest,
176) -> Result<EvidenceEnvelopeV1, Box<dyn std::error::Error>> {
177 let payload = build_provenance_payload(request)?;
178 let payload_sha256 = Some(json_payload_sha256(&payload)?);
179 let payload_value = serde_json::to_value(&payload)?;
180 let summary = EvidenceSummaryV1 {
181 warnings: payload.warnings.clone(),
182 blocked_actions: Vec::new(),
183 missing_or_stale_evidence: Vec::new(),
184 evidence_conflicts: Vec::new(),
185 };
186 let generated_at = payload.generated_at;
187 let exit_class = if summary.warnings.is_empty() {
188 ExitClassV1::Success
189 } else {
190 ExitClassV1::SuccessWithWarnings
191 };
192
193 Ok(EvidenceEnvelopeV1 {
194 envelope_schema: evidence_envelope_schema(),
195 canic_version: request.canic_version.clone(),
196 command: request.command.clone(),
197 target: EvidenceTargetV1 {
198 kind: EvidenceTargetKindV1::Artifact,
199 deployment: None,
200 fleet: Some(request.fleet.clone()),
201 role: Some(request.role.clone()),
202 profile: Some(request.profile.target_dir_name().to_string()),
203 network: Some(request.network.clone()),
204 },
205 generated_at,
206 source_config: Some(file_input_fingerprint(
207 "canic_config",
208 &request.config_path,
209 &request.workspace_root,
210 Some(PayloadSchemaRefV1::internal("canic.config.toml", "1")),
211 None,
212 )?),
213 inputs: build_input_fingerprints(request)?,
214 payload_schema: build_provenance_schema(),
215 payload_sha256,
216 payload: payload_value,
217 summary,
218 exit_class,
219 })
220}
221
222pub fn build_provenance_payload(
223 request: &BuildProvenanceRequest,
224) -> Result<BuildProvenanceV1, Box<dyn std::error::Error>> {
225 let mut warnings = Vec::new();
226 let source = source_provenance(&request.workspace_root);
227 if source.dirty == Some(true) {
228 warnings.push(EvidenceMessageV1::new(
229 "build_provenance.source_dirty",
230 "build used uncommitted local source state",
231 EvidenceMessageSeverityV1::Warning,
232 ));
233 }
234 if source.vcs == SourceVcsV1::Unknown {
235 warnings.push(EvidenceMessageV1::new(
236 "build_provenance.source_unknown",
237 "source revision could not be read from git",
238 EvidenceMessageSeverityV1::Warning,
239 ));
240 }
241
242 Ok(BuildProvenanceV1 {
243 schema_version: 1,
244 generated_at: request.generated_at.clone(),
245 canic_version: request.canic_version.clone(),
246 command: request.command.clone(),
247 build_status: BuildProvenanceStatusV1::Success,
248 source,
249 cargo: cargo_provenance(request)?,
250 artifacts: artifact_provenance(request)?,
251 warnings,
252 })
253}
254
255fn source_provenance(workspace_root: &Path) -> SourceProvenanceV1 {
256 if !is_git_worktree_root(workspace_root) {
257 return unknown_source_provenance();
258 }
259
260 let Some(revision) = git_output_text(workspace_root, ["rev-parse", "HEAD"]) else {
261 return unknown_source_provenance();
262 };
263 let branch = git_output_text(workspace_root, ["rev-parse", "--abbrev-ref", "HEAD"]);
264 let Some(status) = git_output_bytes(workspace_root, ["status", "--porcelain=v1", "-z"]) else {
265 return SourceProvenanceV1 {
266 schema_version: 1,
267 vcs: SourceVcsV1::Git,
268 revision: Some(revision),
269 branch,
270 dirty: None,
271 dirty_policy: SourceDirtyPolicyV1::Unknown,
272 dirty_summary_digest: None,
273 dirty_summary_algorithm: None,
274 };
275 };
276
277 let dirty = !status.is_empty();
278 SourceProvenanceV1 {
279 schema_version: 1,
280 vcs: SourceVcsV1::Git,
281 revision: Some(revision),
282 branch,
283 dirty: Some(dirty),
284 dirty_policy: if dirty {
285 SourceDirtyPolicyV1::DirtyRecorded
286 } else {
287 SourceDirtyPolicyV1::Clean
288 },
289 dirty_summary_digest: dirty.then(|| sha256_hex(&status)),
290 dirty_summary_algorithm: dirty.then(|| DIRTY_SUMMARY_ALGORITHM.to_string()),
291 }
292}
293
294fn is_git_worktree_root(workspace_root: &Path) -> bool {
295 let Some(top_level) = git_output_text(workspace_root, ["rev-parse", "--show-toplevel"]) else {
296 return false;
297 };
298 let Ok(top_level) = PathBuf::from(top_level).canonicalize() else {
299 return false;
300 };
301 let Ok(workspace_root) = workspace_root.canonicalize() else {
302 return false;
303 };
304
305 top_level == workspace_root
306}
307
308const fn unknown_source_provenance() -> SourceProvenanceV1 {
309 SourceProvenanceV1 {
310 schema_version: 1,
311 vcs: SourceVcsV1::Unknown,
312 revision: None,
313 branch: None,
314 dirty: None,
315 dirty_policy: SourceDirtyPolicyV1::Unknown,
316 dirty_summary_digest: None,
317 dirty_summary_algorithm: None,
318 }
319}
320
321fn cargo_provenance(
322 request: &BuildProvenanceRequest,
323) -> Result<CargoProvenanceV1, Box<dyn std::error::Error>> {
324 let package_manifest = canister_manifest_path(&request.workspace_root, &request.role)?;
325 let manifest_source = fs::read_to_string(&package_manifest)?;
326 let manifest = toml::from_str::<TomlValue>(&manifest_source)?;
327 let cargo_lock_path = request.workspace_root.join("Cargo.lock");
328 let package_metadata_fleet = required_manifest_str(
329 &manifest,
330 &["package", "metadata", "canic", "fleet"],
331 &package_manifest,
332 )?;
333 let package_metadata_role = required_manifest_str(
334 &manifest,
335 &["package", "metadata", "canic", "role"],
336 &package_manifest,
337 )?;
338 if package_metadata_fleet != request.fleet || package_metadata_role != request.role {
339 return Err(format!(
340 "{} declares [package.metadata.canic] fleet={:?} role={:?}, not {}.{}",
341 package_manifest.display(),
342 package_metadata_fleet,
343 package_metadata_role,
344 request.fleet,
345 request.role
346 )
347 .into());
348 }
349
350 Ok(CargoProvenanceV1 {
351 cargo_lock_sha256: optional_file_sha256(&cargo_lock_path)?,
352 package_manifest_sha256: Some(sha256_hex(manifest_source.as_bytes())),
353 package_name: required_manifest_str(&manifest, &["package", "name"], &package_manifest)?,
354 package_manifest: display_path(&package_manifest, &request.workspace_root),
355 package_metadata_fleet,
356 package_metadata_role,
357 rustc_version: command_version("rustc", ["--version"]),
358 cargo_version: cargo_version(),
359 target: Some(WASM_TARGET.to_string()),
360 profile: request.profile.target_dir_name().to_string(),
361 features: Vec::new(),
362 default_features: None,
363 rustflags_digest: env::var("RUSTFLAGS")
364 .ok()
365 .map(|value| sha256_hex(value.as_bytes())),
366 rustflags_digest_algorithm: env::var_os("RUSTFLAGS")
367 .is_some()
368 .then(|| "sha256".to_string()),
369 cargo_config_fingerprints: cargo_config_fingerprints(&request.workspace_root)?,
370 build_script_inputs: BuildScriptInputStateV1::NotRecorded,
371 })
372}
373
374fn artifact_provenance(
375 request: &BuildProvenanceRequest,
376) -> Result<Vec<ArtifactProvenanceV1>, Box<dyn std::error::Error>> {
377 let mut artifacts = Vec::new();
378 push_artifact(
379 &mut artifacts,
380 request,
381 ArtifactProvenanceKindV1::Wasm,
382 &request.output.wasm_path,
383 )?;
384 push_artifact(
385 &mut artifacts,
386 request,
387 ArtifactProvenanceKindV1::WasmGzip,
388 &request.output.wasm_gz_path,
389 )?;
390 push_existing_artifact(
391 &mut artifacts,
392 request,
393 ArtifactProvenanceKindV1::Candid,
394 &request.output.did_path,
395 )?;
396 if let Some(path) = &request.output.manifest_path {
397 push_existing_artifact(
398 &mut artifacts,
399 request,
400 ArtifactProvenanceKindV1::Metadata,
401 path,
402 )?;
403 }
404
405 Ok(artifacts)
406}
407
408fn push_existing_artifact(
409 artifacts: &mut Vec<ArtifactProvenanceV1>,
410 request: &BuildProvenanceRequest,
411 kind: ArtifactProvenanceKindV1,
412 path: &Path,
413) -> Result<(), Box<dyn std::error::Error>> {
414 if path.is_file() {
415 push_artifact(artifacts, request, kind, path)?;
416 }
417 Ok(())
418}
419
420fn push_artifact(
421 artifacts: &mut Vec<ArtifactProvenanceV1>,
422 request: &BuildProvenanceRequest,
423 kind: ArtifactProvenanceKindV1,
424 path: &Path,
425) -> Result<(), Box<dyn std::error::Error>> {
426 let fingerprint =
427 file_input_fingerprint("build_artifact", path, &request.workspace_root, None, None)?;
428 artifacts.push(ArtifactProvenanceV1 {
429 role: request.role.clone(),
430 fleet: request.fleet.clone(),
431 artifact_kind: kind,
432 path: fingerprint.path,
433 path_display: fingerprint.path_display,
434 hash_algorithm: "sha256".to_string(),
435 sha256: fingerprint
436 .sha256
437 .ok_or_else(|| format!("missing sha256 for {}", path.display()))?,
438 size_bytes: fingerprint
439 .size_bytes
440 .ok_or_else(|| format!("missing size for {}", path.display()))?,
441 produced_by: "canic build".to_string(),
442 });
443 Ok(())
444}
445
446fn build_input_fingerprints(
447 request: &BuildProvenanceRequest,
448) -> Result<Vec<InputFingerprintV1>, Box<dyn std::error::Error>> {
449 let package_manifest = canister_manifest_path(&request.workspace_root, &request.role)?;
450 let mut inputs = vec![file_input_fingerprint(
451 "cargo_package_manifest",
452 &package_manifest,
453 &request.workspace_root,
454 Some(PayloadSchemaRefV1::internal(
455 "cargo.package_manifest.toml",
456 "1",
457 )),
458 None,
459 )?];
460 let cargo_lock_path = request.workspace_root.join("Cargo.lock");
461 if cargo_lock_path.is_file() {
462 inputs.push(file_input_fingerprint(
463 "cargo_lock",
464 &cargo_lock_path,
465 &request.workspace_root,
466 Some(PayloadSchemaRefV1::internal("cargo.lock", "1")),
467 None,
468 )?);
469 }
470 inputs.extend(cargo_config_fingerprints(&request.workspace_root)?);
471 Ok(inputs)
472}
473
474fn cargo_config_fingerprints(
475 workspace_root: &Path,
476) -> Result<Vec<InputFingerprintV1>, Box<dyn std::error::Error>> {
477 [".cargo/config.toml", ".cargo/config"]
478 .into_iter()
479 .map(|relative| workspace_root.join(relative))
480 .filter(|path| path.is_file())
481 .map(|path| {
482 Ok(file_input_fingerprint(
483 "cargo_config",
484 &path,
485 workspace_root,
486 Some(PayloadSchemaRefV1::internal("cargo.config.toml", "1")),
487 None,
488 )?)
489 })
490 .collect()
491}
492
493fn optional_file_sha256(path: &Path) -> Result<Option<String>, Box<dyn std::error::Error>> {
494 match fs::read(path) {
495 Ok(bytes) => Ok(Some(sha256_hex(&bytes))),
496 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
497 Err(err) => Err(err.into()),
498 }
499}
500
501fn required_manifest_str(
502 manifest: &TomlValue,
503 path: &[&str],
504 manifest_path: &Path,
505) -> Result<String, Box<dyn std::error::Error>> {
506 let mut value = manifest;
507 for segment in path {
508 value = value
509 .get(*segment)
510 .ok_or_else(|| format!("missing {} in {}", path.join("."), manifest_path.display()))?;
511 }
512
513 value.as_str().map(ToString::to_string).ok_or_else(|| {
514 format!(
515 "{} must be a string in {}",
516 path.join("."),
517 manifest_path.display()
518 )
519 .into()
520 })
521}
522
523fn display_path(path: &Path, root: &Path) -> String {
524 file_input_fingerprint("path", path, root, None, None)
525 .ok()
526 .and_then(|fingerprint| fingerprint.path)
527 .unwrap_or_else(|| "<redacted:absolute-outside-root>".to_string())
528}
529
530fn git_output_text<const N: usize>(workspace_root: &Path, args: [&str; N]) -> Option<String> {
531 String::from_utf8(git_output_bytes(workspace_root, args)?)
532 .ok()
533 .map(|value| value.trim().to_string())
534 .filter(|value| !value.is_empty())
535}
536
537fn git_output_bytes<const N: usize>(workspace_root: &Path, args: [&str; N]) -> Option<Vec<u8>> {
538 let mut command = Command::new("git");
539 command.current_dir(workspace_root);
540 clear_git_environment(&mut command);
541
542 let output = command.args(args).output().ok()?;
543 output.status.success().then_some(output.stdout)
544}
545
546fn clear_git_environment(command: &mut Command) {
547 for key in [
548 "GIT_ALTERNATE_OBJECT_DIRECTORIES",
549 "GIT_CEILING_DIRECTORIES",
550 "GIT_COMMON_DIR",
551 "GIT_DIR",
552 "GIT_DISCOVERY_ACROSS_FILESYSTEM",
553 "GIT_INDEX_FILE",
554 "GIT_NAMESPACE",
555 "GIT_OBJECT_DIRECTORY",
556 "GIT_PREFIX",
557 "GIT_WORK_TREE",
558 ] {
559 command.env_remove(key);
560 }
561}
562
563fn command_version<const N: usize>(command: &str, args: [&str; N]) -> Option<String> {
564 let mut command = Command::new(command);
565 if let Some(toolchain) = env::var_os("RUSTUP_TOOLCHAIN") {
566 command.env("RUSTUP_TOOLCHAIN", toolchain);
567 }
568 let output = command.args(args).output().ok()?;
569 output
570 .status
571 .success()
572 .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
573 .filter(|value| !value.is_empty())
574}
575
576fn cargo_version() -> Option<String> {
577 let output = cargo_command().arg("--version").output().ok()?;
578 output
579 .status
580 .success()
581 .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
582 .filter(|value| !value.is_empty())
583}
584
585#[cfg(test)]
586mod tests {
587 use super::*;
588 use crate::test_support::temp_dir;
589
590 #[test]
591 fn build_provenance_schema_is_stable() {
592 assert_eq!(
593 build_provenance_schema(),
594 PayloadSchemaRefV1::stable("canic.build_provenance.v1", "1")
595 );
596 }
597
598 #[test]
599 fn unknown_source_provenance_is_explicit() {
600 let root = temp_dir("canic-build-provenance-no-git");
601 fs::create_dir_all(&root).expect("create root");
602
603 let provenance = source_provenance(&root);
604
605 fs::remove_dir_all(&root).expect("remove root");
606 assert_eq!(provenance.vcs, SourceVcsV1::Unknown);
607 assert_eq!(provenance.dirty_policy, SourceDirtyPolicyV1::Unknown);
608 }
609
610 #[test]
611 fn source_provenance_requires_selected_git_worktree_root() {
612 let temp = temp_dir("canic-build-provenance-parent-git");
613 let root = canic_repo_root()
614 .join("target")
615 .join(temp.file_name().expect("temp path has file name"));
616 fs::create_dir_all(&root).expect("create root");
617
618 let provenance = source_provenance(&root);
619
620 fs::remove_dir_all(&root).expect("remove root");
621 assert_eq!(provenance.vcs, SourceVcsV1::Unknown);
622 assert_eq!(provenance.dirty_policy, SourceDirtyPolicyV1::Unknown);
623 }
624
625 #[test]
626 fn artifact_provenance_records_wasm_and_gzip_separately() {
627 let root = temp_dir("canic-build-provenance-artifacts");
628 let artifact_root = root.join(".icp/local/canisters/app");
629 fs::create_dir_all(&artifact_root).expect("create artifacts");
630 let wasm_path = artifact_root.join("app.wasm");
631 let wasm_gz_path = artifact_root.join("app.wasm.gz");
632 let did_path = artifact_root.join("app.did");
633 fs::write(&wasm_path, b"wasm").expect("write wasm");
634 fs::write(&wasm_gz_path, b"gzip").expect("write gzip");
635
636 let request = sample_request(
637 &root,
638 CanisterArtifactBuildOutput {
639 artifact_root,
640 wasm_path,
641 wasm_gz_path,
642 did_path,
643 manifest_path: None,
644 },
645 );
646 let artifacts = artifact_provenance(&request).expect("artifact provenance");
647
648 fs::remove_dir_all(&root).expect("remove root");
649 assert_eq!(artifacts.len(), 2);
650 assert_eq!(artifacts[0].artifact_kind, ArtifactProvenanceKindV1::Wasm);
651 assert_eq!(
652 artifacts[1].artifact_kind,
653 ArtifactProvenanceKindV1::WasmGzip
654 );
655 assert_ne!(artifacts[0].sha256, artifacts[1].sha256);
656 }
657
658 #[test]
659 fn build_provenance_envelope_wraps_stable_payload() {
660 let root = temp_dir("canic-build-provenance-envelope");
661 write_sample_workspace(&root, "demo", "app");
662 let output = write_sample_artifacts(&root, "app");
663 let request = BuildProvenanceRequest {
664 fleet: "demo".to_string(),
665 role: "app".to_string(),
666 network: "local".to_string(),
667 profile: CanisterBuildProfile::Fast,
668 workspace_root: root.clone(),
669 config_path: root.join("fleets/demo/canic.toml"),
670 output,
671 command: sample_command(),
672 generated_at: "unix:1".to_string(),
673 canic_version: "0.0.0-test".to_string(),
674 };
675
676 let envelope = build_provenance_envelope(&request).expect("build envelope");
677 let payload = serde_json::from_value::<BuildProvenanceV1>(envelope.payload.clone())
678 .expect("decode payload");
679
680 fs::remove_dir_all(&root).expect("remove root");
681 assert_eq!(envelope.target.kind, EvidenceTargetKindV1::Artifact);
682 assert_eq!(envelope.target.fleet.as_deref(), Some("demo"));
683 assert_eq!(envelope.target.role.as_deref(), Some("app"));
684 assert_eq!(envelope.payload_schema, build_provenance_schema());
685 assert_eq!(payload.cargo.package_metadata_fleet, "demo");
686 assert_eq!(payload.cargo.package_metadata_role, "app");
687 assert!(payload.cargo.cargo_lock_sha256.is_some());
688 assert_eq!(payload.artifacts.len(), 2);
689 }
690
691 fn sample_request(root: &Path, output: CanisterArtifactBuildOutput) -> BuildProvenanceRequest {
692 BuildProvenanceRequest {
693 fleet: "demo".to_string(),
694 role: "app".to_string(),
695 network: "local".to_string(),
696 profile: CanisterBuildProfile::Fast,
697 workspace_root: root.to_path_buf(),
698 config_path: root.join("fleets/demo/canic.toml"),
699 output,
700 command: sample_command(),
701 generated_at: "unix:1".to_string(),
702 canic_version: "0.0.0-test".to_string(),
703 }
704 }
705
706 fn sample_command() -> CommandProvenanceV1 {
707 CommandProvenanceV1 {
708 name: "canic build".to_string(),
709 argv_normalized: vec!["canic".to_string(), "build".to_string()],
710 argv_redactions: Vec::new(),
711 format: "provenance".to_string(),
712 }
713 }
714
715 fn write_sample_workspace(root: &Path, fleet: &str, role: &str) {
716 let package_dir = root.join("fleets").join(fleet).join(role);
717 fs::create_dir_all(package_dir.join("src")).expect("create package");
718 fs::write(
719 root.join("Cargo.toml"),
720 format!(
721 r#"[workspace]
722members = ["fleets/{fleet}/{role}"]
723resolver = "3"
724"#
725 ),
726 )
727 .expect("write workspace manifest");
728 fs::write(root.join("Cargo.lock"), "# lock\n").expect("write lock");
729 fs::write(
730 root.join("fleets").join(fleet).join("canic.toml"),
731 format!(
732 r#"[fleet]
733name = "{fleet}"
734
735[roles.{role}]
736kind = "canister"
737package = "{role}"
738
739[subnets.prime.canisters.{role}]
740kind = "singleton"
741"#
742 ),
743 )
744 .expect("write canic config");
745 fs::write(
746 package_dir.join("Cargo.toml"),
747 format!(
748 r#"[package]
749name = "canister_{fleet}_{role}"
750version = "0.0.0"
751edition = "2024"
752
753[package.metadata.canic]
754fleet = "{fleet}"
755role = "{role}"
756"#
757 ),
758 )
759 .expect("write package manifest");
760 fs::write(package_dir.join("src/lib.rs"), "").expect("write lib");
761 }
762
763 fn write_sample_artifacts(root: &Path, role: &str) -> CanisterArtifactBuildOutput {
764 let artifact_root = root.join(".icp/local/canisters").join(role);
765 fs::create_dir_all(&artifact_root).expect("create artifacts");
766 let wasm_path = artifact_root.join(format!("{role}.wasm"));
767 let wasm_gz_path = artifact_root.join(format!("{role}.wasm.gz"));
768 let did_path = artifact_root.join(format!("{role}.did"));
769 fs::write(&wasm_path, b"wasm").expect("write wasm");
770 fs::write(&wasm_gz_path, b"gzip").expect("write gzip");
771
772 CanisterArtifactBuildOutput {
773 artifact_root,
774 wasm_path,
775 wasm_gz_path,
776 did_path,
777 manifest_path: None,
778 }
779 }
780
781 fn canic_repo_root() -> PathBuf {
782 Path::new(env!("CARGO_MANIFEST_DIR"))
783 .ancestors()
784 .find(|path| path.join(".git").exists())
785 .expect("Canic repository root has .git")
786 .to_path_buf()
787 }
788}