Skip to main content

nex_pkg/
artifact.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7use crate::machine_profile::{MachineProfileDocument, MACHINE_PROFILE_SCHEMA_V1};
8use crate::materialization::MaterializationPayload;
9
10pub const MATERIALIZATION_PAYLOAD_SCHEMA_V1: &str = "io.styrene.nex.materialization-payload.v1";
11pub const MACHINE_PROFILE_ARTIFACT_TYPE_V1: &str =
12    "application/vnd.styrene.nex.machine-profile.v1+tar";
13pub const MATERIALIZATION_PAYLOAD_ARTIFACT_TYPE_V1: &str =
14    "application/vnd.styrene.nex.materialization-payload.v1+tar";
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
17#[serde(rename_all = "kebab-case")]
18pub enum ArtifactKind {
19    MachineProfile,
20    MaterializationPayload,
21}
22
23impl ArtifactKind {
24    pub fn as_str(self) -> &'static str {
25        match self {
26            Self::MachineProfile => "machine-profile",
27            Self::MaterializationPayload => "materialization-payload",
28        }
29    }
30
31    pub fn entrypoint(self) -> &'static str {
32        match self {
33            Self::MachineProfile => "machine-profile.pkl",
34            Self::MaterializationPayload => "payload.pkl",
35        }
36    }
37
38    pub fn schema(self) -> &'static str {
39        match self {
40            Self::MachineProfile => MACHINE_PROFILE_SCHEMA_V1,
41            Self::MaterializationPayload => MATERIALIZATION_PAYLOAD_SCHEMA_V1,
42        }
43    }
44
45    pub fn artifact_type(self) -> &'static str {
46        match self {
47            Self::MachineProfile => MACHINE_PROFILE_ARTIFACT_TYPE_V1,
48            Self::MaterializationPayload => MATERIALIZATION_PAYLOAD_ARTIFACT_TYPE_V1,
49        }
50    }
51}
52
53#[derive(Debug, Clone, Serialize)]
54pub struct ArtifactCheckReport {
55    pub ok: bool,
56    pub path: String,
57    pub artifact_kind: Option<ArtifactKind>,
58    pub id: Option<String>,
59    pub schema: Option<String>,
60    pub version: Option<String>,
61    pub entrypoint: Option<String>,
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub evidence: Option<EvidenceRecord>,
64    pub diagnostics: Vec<ArtifactDiagnostic>,
65}
66
67#[derive(Debug, Clone, Serialize)]
68pub struct EvidenceRecord {
69    pub tier: String,
70    pub result: String,
71    pub validated_with: String,
72}
73
74#[derive(Debug, Clone, Serialize)]
75pub struct ArtifactRelationshipReport {
76    pub ok: bool,
77    pub profile: ArtifactRelationshipSide,
78    pub payload: ArtifactRelationshipSide,
79    pub compatibility: ArtifactCompatibility,
80    pub diagnostics: Vec<ArtifactDiagnostic>,
81}
82
83#[derive(Debug, Clone, Serialize)]
84pub struct ArtifactRelationshipSide {
85    pub id: Option<String>,
86    pub schema: Option<String>,
87    pub artifact_kind: Option<ArtifactKind>,
88    pub ok: bool,
89}
90
91#[derive(Debug, Clone, Serialize)]
92pub struct ArtifactCompatibility {
93    pub systems: Vec<String>,
94    pub targets: Vec<String>,
95    pub build_targets: Vec<String>,
96}
97
98#[derive(Debug, Clone, Serialize)]
99pub struct ArtifactDiagnostic {
100    pub severity: DiagnosticSeverity,
101    pub code: String,
102    pub message: String,
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub path: Option<String>,
105}
106
107#[derive(Debug, Clone, Serialize)]
108#[serde(rename_all = "kebab-case")]
109pub enum DiagnosticSeverity {
110    Error,
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub enum EvidenceTier {
115    Evaluates,
116    Materializes,
117    BuildsImage,
118    BootsEmulated,
119    BootsHardware,
120    Operational,
121}
122
123impl EvidenceTier {
124    pub fn parse(value: &str) -> std::result::Result<Self, ArtifactDiagnostic> {
125        match value {
126            "evaluates" => Ok(Self::Evaluates),
127            "materializes" => Ok(Self::Materializes),
128            "builds-image" => Ok(Self::BuildsImage),
129            "boots-emulated" => Ok(Self::BootsEmulated),
130            "boots-hardware" => Ok(Self::BootsHardware),
131            "operational" => Ok(Self::Operational),
132            other => Err(ArtifactDiagnostic::error(
133                "unknown-evidence-tier",
134                format!("unknown evidence tier '{other}'"),
135                Some("evidence".to_string()),
136            )),
137        }
138    }
139
140    pub fn as_str(self) -> &'static str {
141        match self {
142            Self::Evaluates => "evaluates",
143            Self::Materializes => "materializes",
144            Self::BuildsImage => "builds-image",
145            Self::BootsEmulated => "boots-emulated",
146            Self::BootsHardware => "boots-hardware",
147            Self::Operational => "operational",
148        }
149    }
150}
151
152impl ArtifactDiagnostic {
153    fn error(code: impl Into<String>, message: impl Into<String>, path: Option<String>) -> Self {
154        Self {
155            severity: DiagnosticSeverity::Error,
156            code: code.into(),
157            message: message.into(),
158            path,
159        }
160    }
161}
162
163#[derive(Debug)]
164struct DetectedArtifact {
165    kind: ArtifactKind,
166    entrypoint: PathBuf,
167}
168
169pub fn check_artifact_dir(path: &Path) -> ArtifactCheckReport {
170    check_artifact_dir_with_evidence(path, "evaluates")
171}
172
173pub fn check_artifact_relationship(profile: &Path, payload: &Path) -> ArtifactRelationshipReport {
174    let profile_report = check_artifact_dir(profile);
175    let payload_report = check_artifact_dir(payload);
176    let mut diagnostics = Vec::new();
177
178    if profile_report.artifact_kind != Some(ArtifactKind::MachineProfile) {
179        diagnostics.push(ArtifactDiagnostic::error(
180            "relationship-profile-kind-invalid",
181            "relationship profile path must be a machine-profile artifact",
182            Some("profile".to_string()),
183        ));
184    }
185    if payload_report.artifact_kind != Some(ArtifactKind::MaterializationPayload) {
186        diagnostics.push(ArtifactDiagnostic::error(
187            "relationship-payload-kind-invalid",
188            "relationship payload path must be a materialization-payload artifact",
189            Some("payload".to_string()),
190        ));
191    }
192    if !profile_report.ok {
193        diagnostics.push(ArtifactDiagnostic::error(
194            "relationship-profile-invalid",
195            "profile artifact failed standalone validation",
196            Some("profile".to_string()),
197        ));
198    }
199    if !payload_report.ok {
200        diagnostics.push(ArtifactDiagnostic::error(
201            "relationship-payload-invalid",
202            "payload artifact failed standalone validation",
203            Some("payload".to_string()),
204        ));
205    }
206
207    ArtifactRelationshipReport {
208        ok: profile_report.ok && payload_report.ok && diagnostics.is_empty(),
209        profile: relationship_side(&profile_report),
210        payload: relationship_side(&payload_report),
211        compatibility: compatibility_summary(&profile_report, &payload_report),
212        diagnostics,
213    }
214}
215
216pub fn check_artifact_dir_with_evidence(path: &Path, evidence: &str) -> ArtifactCheckReport {
217    let mut diagnostics = Vec::new();
218    let tier = match EvidenceTier::parse(evidence) {
219        Ok(tier) => tier,
220        Err(diagnostic) => {
221            diagnostics.push(diagnostic);
222            return ArtifactCheckReport {
223                ok: false,
224                path: path.display().to_string(),
225                artifact_kind: None,
226                id: None,
227                schema: None,
228                version: None,
229                entrypoint: None,
230                evidence: Some(evidence_record(evidence, "failed")),
231                diagnostics,
232            };
233        }
234    };
235    let detected = match detect_artifact(path) {
236        Ok(detected) => detected,
237        Err(diagnostic) => {
238            diagnostics.push(diagnostic);
239            return ArtifactCheckReport {
240                ok: false,
241                path: path.display().to_string(),
242                artifact_kind: None,
243                id: None,
244                schema: None,
245                version: None,
246                entrypoint: None,
247                evidence: Some(evidence_record(tier.as_str(), "failed")),
248                diagnostics,
249            };
250        }
251    };
252
253    let json = match crate::pkl::evaluate_json(&detected.entrypoint) {
254        Ok(json) => json,
255        Err(error) => {
256            diagnostics.push(ArtifactDiagnostic::error(
257                "pkl-evaluation-failed",
258                format!(
259                    "failed to evaluate {}: {error:#}",
260                    detected.entrypoint.display()
261                ),
262                None,
263            ));
264            return report(path, &detected, tier, diagnostics);
265        }
266    };
267
268    diagnostics.extend(validate_boundary_fields(detected.kind, &json));
269    if diagnostics.is_empty() {
270        diagnostics.extend(validate_typed_semantics(detected.kind, json));
271    }
272    diagnostics.extend(validate_armory_metadata(path, detected.kind));
273    diagnostics.extend(validate_evidence_tier(tier));
274
275    report(path, &detected, tier, diagnostics)
276}
277
278fn report(
279    path: &Path,
280    detected: &DetectedArtifact,
281    tier: EvidenceTier,
282    diagnostics: Vec<ArtifactDiagnostic>,
283) -> ArtifactCheckReport {
284    ArtifactCheckReport {
285        ok: diagnostics.is_empty(),
286        path: path.display().to_string(),
287        artifact_kind: Some(detected.kind),
288        id: artifact_id(detected.kind, path),
289        schema: Some(detected.kind.schema().to_string()),
290        version: None,
291        entrypoint: Some(detected.kind.entrypoint().to_string()),
292        evidence: Some(evidence_record(
293            tier.as_str(),
294            if diagnostics.is_empty() {
295                "passed"
296            } else {
297                "failed"
298            },
299        )),
300        diagnostics,
301    }
302}
303
304fn relationship_side(report: &ArtifactCheckReport) -> ArtifactRelationshipSide {
305    ArtifactRelationshipSide {
306        id: report.id.clone(),
307        schema: report.schema.clone(),
308        artifact_kind: report.artifact_kind,
309        ok: report.ok,
310    }
311}
312
313fn compatibility_summary(
314    _profile: &ArtifactCheckReport,
315    _payload: &ArtifactCheckReport,
316) -> ArtifactCompatibility {
317    ArtifactCompatibility {
318        systems: Vec::new(),
319        targets: Vec::new(),
320        build_targets: Vec::new(),
321    }
322}
323
324fn artifact_id(kind: ArtifactKind, path: &Path) -> Option<String> {
325    match kind {
326        ArtifactKind::MachineProfile | ArtifactKind::MaterializationPayload => path
327            .file_name()
328            .and_then(|name| name.to_str())
329            .map(ToString::to_string),
330    }
331}
332
333fn evidence_record(tier: &str, result: &str) -> EvidenceRecord {
334    EvidenceRecord {
335        tier: tier.to_string(),
336        result: result.to_string(),
337        validated_with: format!("nex {}", env!("CARGO_PKG_VERSION")),
338    }
339}
340
341fn validate_evidence_tier(tier: EvidenceTier) -> Vec<ArtifactDiagnostic> {
342    if tier == EvidenceTier::Evaluates {
343        return Vec::new();
344    }
345    vec![ArtifactDiagnostic::error(
346        "unsupported-evidence-tier",
347        format!(
348            "evidence tier '{}' is recognized but does not have a validator yet",
349            tier.as_str()
350        ),
351        Some("evidence".to_string()),
352    )]
353}
354
355fn detect_artifact(path: &Path) -> std::result::Result<DetectedArtifact, ArtifactDiagnostic> {
356    if !path.is_dir() {
357        return Err(ArtifactDiagnostic::error(
358            "artifact-path-not-directory",
359            format!("artifact path must be a directory: {}", path.display()),
360            None,
361        ));
362    }
363
364    let machine_profile = path.join(ArtifactKind::MachineProfile.entrypoint());
365    let materialization_payload = path.join(ArtifactKind::MaterializationPayload.entrypoint());
366    match (machine_profile.is_file(), materialization_payload.is_file()) {
367        (true, false) => Ok(DetectedArtifact {
368            kind: ArtifactKind::MachineProfile,
369            entrypoint: machine_profile,
370        }),
371        (false, true) => Ok(DetectedArtifact {
372            kind: ArtifactKind::MaterializationPayload,
373            entrypoint: materialization_payload,
374        }),
375        (true, true) => Err(ArtifactDiagnostic::error(
376            "ambiguous-artifact-kind",
377            "artifact directory contains both machine-profile.pkl and payload.pkl",
378            None,
379        )),
380        (false, false) => Err(ArtifactDiagnostic::error(
381            "missing-artifact-entrypoint",
382            "artifact directory must contain machine-profile.pkl or payload.pkl",
383            None,
384        )),
385    }
386}
387
388fn validate_boundary_fields(kind: ArtifactKind, json: &Value) -> Vec<ArtifactDiagnostic> {
389    let Some(object) = json.as_object() else {
390        return vec![ArtifactDiagnostic::error(
391            "artifact-root-not-object",
392            "evaluated artifact document must be an object",
393            None,
394        )];
395    };
396
397    let forbidden: &[(&str, &str)] = match kind {
398        ArtifactKind::MachineProfile => &[
399            (
400                "flake_inputs",
401                "machine-profile artifacts must not declare materialization flake inputs",
402            ),
403            (
404                "nixos_module",
405                "machine-profile artifacts must not declare NixOS module material",
406            ),
407        ],
408        ArtifactKind::MaterializationPayload => &[
409            (
410                "machine_profile",
411                "materialization-payload artifacts must not declare machine-profile policy",
412            ),
413            (
414                "safety",
415                "materialization-payload artifacts must not declare machine safety policy",
416            ),
417            (
418                "allowed_targets",
419                "materialization-payload artifacts must not declare machine target policy",
420            ),
421            (
422                "requires_confirmation",
423                "materialization-payload artifacts must not declare confirmation policy",
424            ),
425            (
426                "requires_target_attestation",
427                "materialization-payload artifacts must not declare target attestation policy",
428            ),
429            (
430                "default_destructive",
431                "materialization-payload artifacts must not declare destructive-operation policy",
432            ),
433        ],
434    };
435
436    forbidden
437        .iter()
438        .filter(|(field, _)| object.contains_key(*field))
439        .map(|(field, message)| {
440            ArtifactDiagnostic::error(
441                "forbidden-boundary-field",
442                *message,
443                Some((*field).to_string()),
444            )
445        })
446        .collect()
447}
448
449fn validate_typed_semantics(kind: ArtifactKind, json: Value) -> Vec<ArtifactDiagnostic> {
450    let result: Result<()> = match kind {
451        ArtifactKind::MachineProfile => serde_json::from_value::<MachineProfileDocument>(json)
452            .context("decoding machine profile")
453            .and_then(|document| document.validate()),
454        ArtifactKind::MaterializationPayload => {
455            serde_json::from_value::<MaterializationPayload>(json)
456                .context("decoding materialization payload")
457                .and_then(|payload| payload.validate())
458        }
459    };
460
461    match result {
462        Ok(()) => Vec::new(),
463        Err(error) => vec![ArtifactDiagnostic::error(
464            "semantic-validation-failed",
465            format!("{error:#}"),
466            None,
467        )],
468    }
469}
470
471fn validate_armory_metadata(path: &Path, kind: ArtifactKind) -> Vec<ArtifactDiagnostic> {
472    let metadata_path = path.join("armory.toml");
473    if !metadata_path.is_file() {
474        return Vec::new();
475    }
476
477    let metadata = match std::fs::read_to_string(&metadata_path)
478        .with_context(|| format!("reading {}", metadata_path.display()))
479        .and_then(|content| {
480            toml::from_str::<ArmoryMetadata>(&content).context("parsing armory.toml")
481        }) {
482        Ok(metadata) => metadata,
483        Err(error) => {
484            return vec![ArtifactDiagnostic::error(
485                "armory-metadata-invalid",
486                format!("{error:#}"),
487                None,
488            )]
489        }
490    };
491
492    let mut diagnostics = Vec::new();
493    check_metadata_field(
494        &mut diagnostics,
495        "artifact.kind",
496        metadata.artifact.kind.as_deref(),
497        kind.as_str(),
498    );
499    check_metadata_field(
500        &mut diagnostics,
501        "artifact.source",
502        metadata.artifact.source.as_deref(),
503        kind.entrypoint(),
504    );
505    check_metadata_field(
506        &mut diagnostics,
507        "artifact.schema",
508        metadata.artifact.schema.as_deref(),
509        kind.schema(),
510    );
511    check_metadata_field(
512        &mut diagnostics,
513        "artifact.artifact_type",
514        metadata.artifact.artifact_type.as_deref(),
515        kind.artifact_type(),
516    );
517    diagnostics
518}
519
520fn check_metadata_field(
521    diagnostics: &mut Vec<ArtifactDiagnostic>,
522    field: &str,
523    actual: Option<&str>,
524    expected: &str,
525) {
526    match actual {
527        Some(actual) if actual == expected => {}
528        Some(actual) => diagnostics.push(ArtifactDiagnostic::error(
529            "armory-metadata-mismatch",
530            format!("{field} must be '{expected}', found '{actual}'"),
531            Some(field.to_string()),
532        )),
533        None => diagnostics.push(ArtifactDiagnostic::error(
534            "armory-metadata-missing-field",
535            format!("{field} is required when armory.toml is present"),
536            Some(field.to_string()),
537        )),
538    }
539}
540
541#[derive(Debug, Deserialize)]
542struct ArmoryMetadata {
543    artifact: ArmoryArtifactMetadata,
544}
545
546#[derive(Debug, Deserialize)]
547struct ArmoryArtifactMetadata {
548    kind: Option<String>,
549    source: Option<String>,
550    schema: Option<String>,
551    artifact_type: Option<String>,
552}