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}