Skip to main content

canic_host/
build_provenance.rs

1//! Stable source, Cargo, and artifact provenance for build outputs.
2
3use 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///
27/// BuildProvenanceV1
28///
29#[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///
43/// BuildProvenanceStatusV1
44///
45#[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///
54/// SourceProvenanceV1
55///
56#[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///
69/// SourceVcsV1
70///
71#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
72#[serde(rename_all = "snake_case")]
73pub enum SourceVcsV1 {
74    Git,
75    Unknown,
76}
77
78///
79/// SourceDirtyPolicyV1
80///
81#[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///
90/// CargoProvenanceV1
91///
92#[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///
113/// BuildScriptInputStateV1
114///
115#[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///
124/// ArtifactProvenanceV1
125///
126#[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///
140/// ArtifactProvenanceKindV1
141///
142#[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///
153/// BuildProvenanceRequest
154///
155#[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}