1use std::collections::BTreeSet;
8use std::path::{Path, PathBuf};
9
10use anyhow::{bail, Context, Result};
11use serde::{Deserialize, Serialize};
12
13pub const FORGE_SCHEMA_VERSION: u16 = 1;
14pub const CANONICAL_DEFINITION_FORMAT: &str = "pkl";
15pub const CANONICAL_REQUEST_EXTENSION: &str = "pkl";
16pub const CANONICAL_MANIFEST_EXTENSION: &str = "pkl";
17
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
19pub struct ForgeRequest {
20 #[serde(default = "default_schema_version")]
21 pub schema_version: u16,
22 pub operation: ForgeOperation,
23 #[serde(default, skip_serializing_if = "Option::is_none")]
24 pub profile: Option<String>,
25 pub hostname: String,
26 pub arch: ForgeArch,
27 #[serde(default, skip_serializing_if = "Option::is_none")]
28 pub output_dir: Option<PathBuf>,
29 pub target: ForgeTarget,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
31 pub polymerize_defaults: Option<PolymerizeDefaults>,
32 #[serde(default)]
33 pub network: NetworkPolicy,
34 #[serde(default)]
35 pub safety: SafetyPolicy,
36 #[serde(default, skip_serializing_if = "Vec::is_empty")]
37 pub labels: Vec<String>,
38}
39
40impl ForgeRequest {
41 pub fn new(
42 operation: ForgeOperation,
43 hostname: impl Into<String>,
44 arch: ForgeArch,
45 target: ForgeTarget,
46 ) -> Self {
47 Self {
48 schema_version: FORGE_SCHEMA_VERSION,
49 operation,
50 profile: None,
51 hostname: hostname.into(),
52 arch,
53 output_dir: None,
54 target,
55 polymerize_defaults: None,
56 network: NetworkPolicy::default(),
57 safety: SafetyPolicy::default(),
58 labels: Vec::new(),
59 }
60 }
61
62 pub fn profile(mut self, profile: impl Into<String>) -> Self {
63 self.profile = Some(profile.into());
64 self
65 }
66
67 pub fn output_dir(mut self, output_dir: impl Into<PathBuf>) -> Self {
68 self.output_dir = Some(output_dir.into());
69 self
70 }
71}
72
73#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
74#[serde(rename_all = "kebab-case")]
75pub enum ForgeOperation {
76 Bundle,
77 UsbInstall,
78 Image,
79 Netboot,
80 RemotePolymerize,
81}
82
83#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
84#[serde(rename_all = "snake_case")]
85pub enum ForgeArch {
86 X86_64,
87 Aarch64,
88}
89
90impl ForgeArch {
91 pub fn label(self) -> &'static str {
92 match self {
93 Self::X86_64 => "x86_64",
94 Self::Aarch64 => "aarch64",
95 }
96 }
97
98 pub fn iso_url(self) -> &'static str {
99 match self {
100 Self::X86_64 => {
101 "https://channels.nixos.org/nixos-24.11/latest-nixos-minimal-x86_64-linux.iso"
102 }
103 Self::Aarch64 => {
104 "https://channels.nixos.org/nixos-24.11/latest-nixos-minimal-aarch64-linux.iso"
105 }
106 }
107 }
108
109 pub fn iso_filename(self) -> String {
110 format!("nixos-minimal-{}.iso", self.label())
111 }
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
115pub struct ForgeTarget {
116 pub kind: TargetKind,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub disk: Option<String>,
119}
120
121impl ForgeTarget {
122 pub fn bundle() -> Self {
123 Self {
124 kind: TargetKind::Bundle,
125 disk: None,
126 }
127 }
128
129 pub fn usb(disk: Option<impl Into<String>>) -> Self {
130 Self {
131 kind: TargetKind::Usb,
132 disk: disk.map(Into::into),
133 }
134 }
135}
136
137#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
138#[serde(rename_all = "kebab-case")]
139pub enum TargetKind {
140 Bundle,
141 Usb,
142 Image,
143 Netboot,
144 Remote,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
148pub struct PolymerizeDefaults {
149 pub username: String,
150 pub timezone: String,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub install_mode: Option<String>,
153 #[serde(default, skip_serializing_if = "Vec::is_empty")]
154 pub ssh_authorized_keys: Vec<String>,
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
158pub struct NetworkPolicy {
159 #[serde(default)]
160 pub require_wired: bool,
161 #[serde(default = "default_wifi_allowed")]
162 pub wifi_allowed: bool,
163}
164
165impl Default for NetworkPolicy {
166 fn default() -> Self {
167 Self {
168 require_wired: false,
169 wifi_allowed: true,
170 }
171 }
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
175pub struct SafetyPolicy {
176 #[serde(default)]
177 pub allow_destructive_flash: bool,
178 #[serde(default)]
179 pub allow_internal_disk_selection: bool,
180 #[serde(default = "default_require_operator_confirmation")]
181 pub require_operator_confirmation: bool,
182 #[serde(default)]
183 pub requires_target_attestation: bool,
184 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub target_attestation: Option<TargetAttestation>,
186 #[serde(default, skip_serializing_if = "Vec::is_empty")]
187 pub allowed_targets: Vec<TargetAttestation>,
188 #[serde(default, skip_serializing_if = "Vec::is_empty")]
189 pub forbidden_targets: Vec<TargetAttestation>,
190}
191
192#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
193#[serde(rename_all = "kebab-case")]
194pub enum TargetAttestation {
195 ExternalUsbSsd,
196 ExternalThunderboltSsd,
197 InternalAppleNvme,
198 InternalAppleStorage,
199}
200
201impl TargetAttestation {
202 fn is_internal_apple_storage(self) -> bool {
203 matches!(self, Self::InternalAppleNvme | Self::InternalAppleStorage)
204 }
205}
206
207impl Default for SafetyPolicy {
208 fn default() -> Self {
209 Self {
210 allow_destructive_flash: false,
211 allow_internal_disk_selection: false,
212 require_operator_confirmation: true,
213 requires_target_attestation: false,
214 target_attestation: None,
215 allowed_targets: Vec::new(),
216 forbidden_targets: Vec::new(),
217 }
218 }
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
222pub struct ForgePlan {
223 pub schema_version: u16,
224 pub operation: ForgeOperation,
225 pub hostname: String,
226 pub arch: ForgeArch,
227 pub output_dir: PathBuf,
228 pub iso: Option<ForgeIsoPlan>,
229 #[serde(default, skip_serializing_if = "Vec::is_empty")]
230 pub destructive_actions: Vec<DestructiveAction>,
231 #[serde(default, skip_serializing_if = "Vec::is_empty")]
232 pub warnings: Vec<ForgeDiagnostic>,
233 #[serde(default, skip_serializing_if = "Vec::is_empty")]
234 pub blockers: Vec<ForgeDiagnostic>,
235}
236
237impl ForgePlan {
238 pub fn is_blocked(&self) -> bool {
239 !self.blockers.is_empty()
240 }
241}
242
243pub fn load_request(path: impl Into<PathBuf>) -> Result<ForgeRequest> {
244 let path = path.into();
245 match path.extension().and_then(|ext| ext.to_str()) {
246 Some("json") => {
247 let content = std::fs::read_to_string(&path)
248 .with_context(|| format!("reading {}", path.display()))?;
249 serde_json::from_str(&content)
250 .with_context(|| format!("decoding JSON forge request {}", path.display()))
251 }
252 Some("pkl") => evaluate_pkl_request(&path)
253 .with_context(|| format!("evaluating Pkl forge request {}", path.display())),
254 Some(other) => {
255 bail!("unsupported forge request extension .{other}; canonical requests use .pkl")
256 }
257 None => bail!("forge request path must have an extension; canonical requests use .pkl"),
258 }
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
262pub struct ForgeIsoPlan {
263 pub url: String,
264 pub filename: String,
265 pub cache_path: PathBuf,
266}
267
268#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
269pub struct DestructiveAction {
270 pub code: String,
271 pub message: String,
272 #[serde(default, skip_serializing_if = "Option::is_none")]
273 pub target: Option<String>,
274}
275
276#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
277pub struct ForgeDiagnostic {
278 pub code: String,
279 pub message: String,
280}
281
282impl ForgeDiagnostic {
283 pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
284 Self {
285 code: code.into(),
286 message: message.into(),
287 }
288 }
289}
290
291#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
292pub struct ForgePreflightReport {
293 pub schema_version: u16,
294 pub valid: bool,
295 #[serde(default, skip_serializing_if = "Vec::is_empty")]
296 pub warnings: Vec<ForgeDiagnostic>,
297 #[serde(default, skip_serializing_if = "Vec::is_empty")]
298 pub errors: Vec<ForgeDiagnostic>,
299}
300
301impl ForgePreflightReport {
302 pub fn from_diagnostics(warnings: Vec<ForgeDiagnostic>, errors: Vec<ForgeDiagnostic>) -> Self {
303 Self {
304 schema_version: FORGE_SCHEMA_VERSION,
305 valid: errors.is_empty(),
306 warnings,
307 errors,
308 }
309 }
310}
311
312#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
313#[serde(tag = "type", rename_all = "snake_case")]
314pub enum ForgeEvent {
315 PhaseStarted {
316 schema_version: u16,
317 phase: String,
318 },
319 PhaseCompleted {
320 schema_version: u16,
321 phase: String,
322 },
323 Warning {
324 schema_version: u16,
325 code: String,
326 message: String,
327 },
328 Blocker {
329 schema_version: u16,
330 code: String,
331 message: String,
332 },
333 ArtifactCreated {
334 schema_version: u16,
335 path: PathBuf,
336 },
337 RunCompleted {
338 schema_version: u16,
339 },
340 RunFailed {
341 schema_version: u16,
342 message: String,
343 },
344}
345
346#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
347pub struct ForgeCheckReport {
348 pub schema: String,
349 pub evaluator: String,
350 pub valid: bool,
351 pub template: ForgeCheckTemplate,
352 pub capabilities: ForgeCheckCapabilities,
353 pub inputs: Vec<ForgeCheckInput>,
354 pub outputs: Vec<ForgeCheckOutput>,
355 pub warnings: Vec<ForgeDiagnostic>,
356 pub errors: Vec<ForgeDiagnostic>,
357}
358
359impl ForgeCheckReport {
360 pub fn exit_code(&self) -> i32 {
361 if self.valid {
362 return 0;
363 }
364 if self.errors.iter().any(|error| {
365 matches!(
366 error.code.as_str(),
367 "UNSAFE_RAW_DISK_TARGET"
368 | "UNSAFE_CLUSTER_INIT"
369 | "UNSAFE_JOIN_TOKEN"
370 | "UNSAFE_EXECUTION_HOOK"
371 | "UNSAFE_PRIVATE_IMPORT"
372 | "UNDECLARED_DESTRUCTIVE_CAPABILITY"
373 | "PLAN_ONLY_EXECUTE_CAPABLE"
374 )
375 }) {
376 3
377 } else {
378 1
379 }
380 }
381
382 pub fn evaluator_error(message: String) -> Self {
383 Self {
384 schema: FORGE_TEMPLATE_SCHEMA.to_string(),
385 evaluator: CANONICAL_DEFINITION_FORMAT.to_string(),
386 valid: false,
387 template: ForgeCheckTemplate::default(),
388 capabilities: ForgeCheckCapabilities::default(),
389 inputs: Vec::new(),
390 outputs: Vec::new(),
391 warnings: Vec::new(),
392 errors: vec![ForgeDiagnostic::new("EVALUATOR_ERROR", message)],
393 }
394 }
395}
396
397#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
398pub struct ForgeCheckTemplate {
399 pub id: String,
400 #[serde(default, skip_serializing_if = "Option::is_none")]
401 pub version: Option<String>,
402 #[serde(rename = "evaluationMode")]
403 pub evaluation_mode: String,
404 #[serde(rename = "safetyClass")]
405 pub safety_class: String,
406 #[serde(rename = "canonicalFormat")]
407 pub canonical_format: String,
408}
409
410impl Default for ForgeCheckTemplate {
411 fn default() -> Self {
412 Self {
413 id: "unknown".to_string(),
414 version: None,
415 evaluation_mode: "plan-only".to_string(),
416 safety_class: "unknown".to_string(),
417 canonical_format: CANONICAL_DEFINITION_FORMAT.to_string(),
418 }
419 }
420}
421
422#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
423pub struct ForgeCheckCapabilities {
424 pub destructive: Vec<String>,
425 pub network: Vec<String>,
426}
427
428#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
429pub struct ForgeCheckInput {
430 pub name: String,
431 pub kind: String,
432 pub required: bool,
433 pub sensitive: bool,
434}
435
436#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
437pub struct ForgeCheckOutput {
438 pub kind: String,
439}
440
441const FORGE_TEMPLATE_SCHEMA: &str = "dev.styrene.nex.forge-template.v1";
442
443pub fn check_template(
444 template_path: &Path,
445 metadata_path: Option<&Path>,
446 _no_execute: bool,
447) -> Result<ForgeCheckReport> {
448 if template_path.extension().and_then(|ext| ext.to_str()) != Some("pkl") {
449 bail!("forge template check requires a canonical .pkl payload");
450 }
451
452 let source = std::fs::read_to_string(template_path)
453 .with_context(|| format!("reading {}", template_path.display()))?;
454 let metadata = metadata_path
455 .map(load_forge_template_metadata)
456 .transpose()?;
457 let public_by_metadata = metadata
458 .as_ref()
459 .and_then(|metadata| metadata.visibility.as_deref())
460 == Some("public");
461 let mut source_errors = Vec::new();
462 if public_by_metadata {
463 validate_public_template_source_safety(&source, &mut source_errors);
464 }
465
466 let evaluated = crate::pkl::evaluate_json(template_path)?;
467 let request = request_from_evaluated_pkl(evaluated.clone())?;
468 if !public_by_metadata && evaluated_visibility_is_public(&evaluated) {
469 validate_public_template_source_safety(&source, &mut source_errors);
470 }
471 let mut report = build_check_report(&evaluated, &request, metadata.as_ref());
472 report.errors.extend(source_errors);
473 report.valid = report.errors.is_empty();
474 Ok(report)
475}
476
477fn build_check_report(
478 evaluated: &serde_json::Value,
479 request: &ForgeRequest,
480 metadata: Option<&ForgeTemplateMetadata>,
481) -> ForgeCheckReport {
482 let object = evaluated.as_object();
483 let template_id = object
484 .and_then(|object| value_string(object, &["id", "name"]))
485 .or_else(|| metadata.and_then(|metadata| metadata.id.clone()))
486 .unwrap_or_else(|| "unknown".to_string());
487 let evaluation_mode = metadata
488 .and_then(|metadata| metadata.evaluation_mode.clone())
489 .unwrap_or_else(|| "plan-only".to_string());
490 let safety_class = metadata
491 .and_then(|metadata| metadata.safety_class.clone())
492 .unwrap_or_else(|| safety_class_for_operation(request.operation).to_string());
493 let destructive = destructive_capabilities(request);
494 let network = network_capabilities(evaluated, request);
495 let mut warnings = Vec::new();
496 let mut errors = Vec::new();
497
498 let template = ForgeCheckTemplate {
499 id: template_id,
500 version: metadata.and_then(|metadata| metadata.version.clone()),
501 evaluation_mode,
502 safety_class,
503 canonical_format: CANONICAL_DEFINITION_FORMAT.to_string(),
504 };
505
506 if let Some(metadata) = metadata {
507 validate_metadata_agreement(
508 metadata,
509 evaluated,
510 &template,
511 &destructive,
512 &network,
513 &mut errors,
514 );
515 } else {
516 warnings.push(ForgeDiagnostic::new(
517 "METADATA_NOT_PROVIDED",
518 "No forge.toml metadata was provided; metadata agreement checks were skipped.",
519 ));
520 }
521
522 let evaluated_visibility = object.and_then(|object| value_string(object, &["visibility"]));
523 let is_public = metadata
524 .and_then(|metadata| metadata.visibility.as_deref())
525 .or(evaluated_visibility.as_deref())
526 == Some("public");
527 if is_public {
528 validate_public_template_safety(evaluated, &mut errors);
529 }
530 validate_evaluation_mode(&template, request, &destructive, &mut errors);
531
532 ForgeCheckReport {
533 schema: metadata
534 .and_then(|metadata| metadata.schema.clone())
535 .unwrap_or_else(|| FORGE_TEMPLATE_SCHEMA.to_string()),
536 evaluator: metadata
537 .and_then(|metadata| metadata.evaluator.clone())
538 .unwrap_or_else(|| CANONICAL_DEFINITION_FORMAT.to_string()),
539 valid: errors.is_empty(),
540 template,
541 capabilities: ForgeCheckCapabilities {
542 destructive,
543 network,
544 },
545 inputs: check_inputs(evaluated),
546 outputs: check_outputs(request),
547 warnings,
548 errors,
549 }
550}
551
552fn evaluated_visibility_is_public(evaluated: &serde_json::Value) -> bool {
553 evaluated
554 .as_object()
555 .and_then(|object| value_string(object, &["visibility"]))
556 .as_deref()
557 == Some("public")
558}
559
560#[derive(Debug, Clone, Default, Deserialize)]
561struct ForgeTemplateMetadataFile {
562 #[serde(default)]
563 forge_template: ForgeTemplateMetadata,
564}
565
566#[derive(Debug, Clone, Default, Deserialize)]
567struct ForgeTemplateMetadata {
568 id: Option<String>,
569 version: Option<String>,
570 schema: Option<String>,
571 evaluator: Option<String>,
572 evaluation_mode: Option<String>,
573 safety_class: Option<String>,
574 canonical_format: Option<String>,
575 visibility: Option<String>,
576 profile_class: Option<String>,
577 destructive_capabilities: Option<Vec<String>>,
578 network_requirements: Option<Vec<String>>,
579}
580
581fn load_forge_template_metadata(path: &Path) -> Result<ForgeTemplateMetadata> {
582 let content =
583 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
584 let parsed: ForgeTemplateMetadataFile = toml::from_str(&content)
585 .with_context(|| format!("decoding forge template metadata {}", path.display()))?;
586 Ok(parsed.forge_template)
587}
588
589fn validate_metadata_agreement(
590 metadata: &ForgeTemplateMetadata,
591 evaluated: &serde_json::Value,
592 template: &ForgeCheckTemplate,
593 destructive: &[String],
594 network: &[String],
595 errors: &mut Vec<ForgeDiagnostic>,
596) {
597 let object = evaluated.as_object();
598 compare_optional(
599 "id",
600 metadata.id.as_deref(),
601 Some(template.id.as_str()),
602 "METADATA_ID_MISMATCH",
603 errors,
604 );
605 compare_optional(
606 "canonical_format",
607 metadata.canonical_format.as_deref(),
608 Some(CANONICAL_DEFINITION_FORMAT),
609 "METADATA_CANONICAL_FORMAT_MISMATCH",
610 errors,
611 );
612 compare_optional(
613 "evaluator",
614 metadata.evaluator.as_deref(),
615 Some(CANONICAL_DEFINITION_FORMAT),
616 "METADATA_EVALUATOR_MISMATCH",
617 errors,
618 );
619 compare_optional(
620 "evaluation_mode",
621 metadata.evaluation_mode.as_deref(),
622 Some(template.evaluation_mode.as_str()),
623 "METADATA_EVALUATION_MODE_MISMATCH",
624 errors,
625 );
626 compare_optional(
627 "safety_class",
628 metadata.safety_class.as_deref(),
629 Some(template.safety_class.as_str()),
630 "METADATA_SAFETY_CLASS_MISMATCH",
631 errors,
632 );
633 if let Some(profile_class) = metadata.profile_class.as_deref() {
634 let evaluated_profile_class =
635 object.and_then(|object| value_string(object, &["profileClass", "profile_class"]));
636 compare_optional(
637 "profile_class",
638 Some(profile_class),
639 evaluated_profile_class.as_deref(),
640 "METADATA_PROFILE_CLASS_MISMATCH",
641 errors,
642 );
643 }
644 compare_string_sets(
645 "destructive_capabilities",
646 metadata.destructive_capabilities.as_deref(),
647 destructive,
648 "METADATA_DESTRUCTIVE_CAPABILITIES_MISMATCH",
649 errors,
650 );
651 compare_string_sets(
652 "network_requirements",
653 metadata.network_requirements.as_deref(),
654 network,
655 "METADATA_NETWORK_REQUIREMENTS_MISMATCH",
656 errors,
657 );
658}
659
660fn compare_optional(
661 field: &str,
662 declared: Option<&str>,
663 detected: Option<&str>,
664 code: &str,
665 errors: &mut Vec<ForgeDiagnostic>,
666) {
667 let Some(declared) = declared else {
668 return;
669 };
670 if detected != Some(declared) {
671 errors.push(ForgeDiagnostic::new(
672 code,
673 format!(
674 "Metadata field {field} declares {declared:?}, but Nex detected {:?}.",
675 detected.unwrap_or("<missing>")
676 ),
677 ));
678 }
679}
680
681fn compare_string_sets(
682 field: &str,
683 declared: Option<&[String]>,
684 detected: &[String],
685 code: &str,
686 errors: &mut Vec<ForgeDiagnostic>,
687) {
688 let Some(declared) = declared else {
689 return;
690 };
691 let declared: BTreeSet<&str> = declared.iter().map(String::as_str).collect();
692 let detected: BTreeSet<&str> = detected.iter().map(String::as_str).collect();
693 if declared != detected {
694 errors.push(ForgeDiagnostic::new(
695 code,
696 format!(
697 "Metadata field {field} declares {:?}, but Nex detected {:?}.",
698 declared, detected
699 ),
700 ));
701 }
702}
703
704fn validate_evaluation_mode(
705 template: &ForgeCheckTemplate,
706 request: &ForgeRequest,
707 destructive: &[String],
708 errors: &mut Vec<ForgeDiagnostic>,
709) {
710 if template.evaluation_mode != "plan-only" {
711 errors.push(ForgeDiagnostic::new(
712 "UNSUPPORTED_EVALUATION_MODE",
713 format!(
714 "Evaluation mode {:?} is not supported by forge check.",
715 template.evaluation_mode
716 ),
717 ));
718 }
719 if template.evaluation_mode == "plan-only"
720 && (request.operation == ForgeOperation::UsbInstall
721 || destructive.iter().any(|cap| cap == "usb-flash"))
722 {
723 errors.push(ForgeDiagnostic::new(
724 "PLAN_ONLY_EXECUTE_CAPABLE",
725 "Plan-only templates cannot imply USB flashing or direct execution.",
726 ));
727 }
728 for capability in destructive {
729 if template.safety_class != *capability {
730 errors.push(ForgeDiagnostic::new(
731 "UNDECLARED_DESTRUCTIVE_CAPABILITY",
732 format!(
733 "Safety class {:?} does not match detected capability {:?}.",
734 template.safety_class, capability
735 ),
736 ));
737 }
738 }
739}
740
741fn validate_public_template_safety(value: &serde_json::Value, errors: &mut Vec<ForgeDiagnostic>) {
742 walk_json(value, &mut Vec::new(), &mut |path, value| {
743 let key = path.last().map(String::as_str).unwrap_or_default();
744 match value {
745 serde_json::Value::String(text) => {
746 if is_raw_disk_target(text) {
747 errors.push(ForgeDiagnostic::new(
748 "UNSAFE_RAW_DISK_TARGET",
749 format!(
750 "Public forge template contains fixed raw disk target at {path:?}."
751 ),
752 ));
753 }
754 if is_private_import(text) {
755 errors.push(ForgeDiagnostic::new(
756 "UNSAFE_PRIVATE_IMPORT",
757 format!("Public forge template contains private/local import at {path:?}."),
758 ));
759 }
760 if key.to_ascii_lowercase().contains("token") || text.contains("join_token") {
761 errors.push(ForgeDiagnostic::new(
762 "UNSAFE_JOIN_TOKEN",
763 format!("Public forge template contains token-like field at {path:?}."),
764 ));
765 }
766 }
767 serde_json::Value::Bool(true) => {
768 let key = key.to_ascii_lowercase();
769 if key == "clusterinit" || key == "cluster_init" {
770 errors.push(ForgeDiagnostic::new(
771 "UNSAFE_CLUSTER_INIT",
772 format!(
773 "Public forge template enables cluster initialization at {path:?}."
774 ),
775 ));
776 }
777 }
778 _ => {}
779 }
780 let key = key.to_ascii_lowercase();
781 if matches!(
782 key.as_str(),
783 "hook" | "hooks" | "command" | "commands" | "script" | "scripts"
784 ) || key.ends_with("hook")
785 || key.ends_with("command")
786 || key.ends_with("script")
787 {
788 errors.push(ForgeDiagnostic::new(
789 "UNSAFE_EXECUTION_HOOK",
790 format!("Public forge template contains execution hook field at {path:?}."),
791 ));
792 }
793 });
794}
795
796fn validate_public_template_source_safety(source: &str, errors: &mut Vec<ForgeDiagnostic>) {
797 for (line_index, line) in source.lines().enumerate() {
798 let line_no = line_index + 1;
799 let trimmed = line.trim();
800 if trimmed.starts_with("import") && contains_private_import_text(trimmed) {
801 errors.push(ForgeDiagnostic::new(
802 "UNSAFE_PRIVATE_IMPORT",
803 format!("Public forge template contains private/local import on line {line_no}."),
804 ));
805 }
806 if contains_token_text(trimmed) {
807 errors.push(ForgeDiagnostic::new(
808 "UNSAFE_JOIN_TOKEN",
809 format!("Public forge template contains token-like source text on line {line_no}."),
810 ));
811 }
812 if contains_cluster_init_text(trimmed) {
813 errors.push(ForgeDiagnostic::new(
814 "UNSAFE_CLUSTER_INIT",
815 format!(
816 "Public forge template contains cluster-init source text on line {line_no}."
817 ),
818 ));
819 }
820 if contains_execution_hook_text(trimmed) {
821 errors.push(ForgeDiagnostic::new(
822 "UNSAFE_EXECUTION_HOOK",
823 format!(
824 "Public forge template contains execution-hook source text on line {line_no}."
825 ),
826 ));
827 }
828 if contains_raw_disk_text(trimmed) {
829 errors.push(ForgeDiagnostic::new(
830 "UNSAFE_RAW_DISK_TARGET",
831 format!("Public forge template contains fixed raw disk target on line {line_no}."),
832 ));
833 }
834 }
835}
836
837fn contains_private_import_text(line: &str) -> bool {
838 line.contains("file:")
839 || line.contains("import(\"/")
840 || line.contains("import(\"../")
841 || line.contains("import(\"./private")
842 || line.contains("ssh://")
843}
844
845fn contains_token_text(line: &str) -> bool {
846 let line = line.to_ascii_lowercase();
847 line.contains("join_token") || line.contains("bootstrap_token") || line.contains("token_file")
848}
849
850fn contains_cluster_init_text(line: &str) -> bool {
851 let line = line.to_ascii_lowercase();
852 line.contains("clusterinit") || line.contains("cluster_init")
853}
854
855fn contains_execution_hook_text(line: &str) -> bool {
856 let line = line.to_ascii_lowercase();
857 line.contains("postinstall")
858 || line.contains("preinstall")
859 || line.contains("exechook")
860 || line.contains("exec_hook")
861 || line.contains("shellhook")
862 || line.contains("shell_hook")
863}
864
865fn contains_raw_disk_text(line: &str) -> bool {
866 line.contains("/dev/sda")
867 || line.contains("/dev/vda")
868 || line.contains("/dev/xvda")
869 || line.contains("/dev/nvme")
870 || line.contains("/dev/disk/")
871}
872
873fn walk_json<F>(value: &serde_json::Value, path: &mut Vec<String>, visit: &mut F)
874where
875 F: FnMut(&[String], &serde_json::Value),
876{
877 visit(path, value);
878 match value {
879 serde_json::Value::Object(object) => {
880 for (key, child) in object {
881 path.push(key.clone());
882 walk_json(child, path, visit);
883 path.pop();
884 }
885 }
886 serde_json::Value::Array(values) => {
887 for (index, child) in values.iter().enumerate() {
888 path.push(index.to_string());
889 walk_json(child, path, visit);
890 path.pop();
891 }
892 }
893 _ => {}
894 }
895}
896
897fn is_raw_disk_target(value: &str) -> bool {
898 value == "/dev/sda"
899 || value == "/dev/vda"
900 || value == "/dev/xvda"
901 || value.starts_with("/dev/nvme")
902 || value.starts_with("/dev/disk/")
903}
904
905fn is_private_import(value: &str) -> bool {
906 value.starts_with("file:")
907 || (value.starts_with('/') && !value.starts_with("/dev/"))
908 || value.starts_with("../")
909 || value.starts_with("./private")
910 || value.contains("ssh://")
911}
912
913fn destructive_capabilities(request: &ForgeRequest) -> Vec<String> {
914 match request.operation {
915 ForgeOperation::Image => vec!["image-build".to_string()],
916 ForgeOperation::UsbInstall => vec!["usb-flash".to_string()],
917 _ => Vec::new(),
918 }
919}
920
921fn network_capabilities(evaluated: &serde_json::Value, request: &ForgeRequest) -> Vec<String> {
922 let mut network = BTreeSet::new();
923 if request.network.require_wired {
924 network.insert("wired-network".to_string());
925 }
926 if evaluated
927 .pointer("/plan/requiresNetwork")
928 .and_then(|value| value.as_bool())
929 == Some(true)
930 {
931 network.insert("package-download".to_string());
932 }
933 network.into_iter().collect()
934}
935
936fn safety_class_for_operation(operation: ForgeOperation) -> &'static str {
937 match operation {
938 ForgeOperation::Image => "image-build",
939 ForgeOperation::UsbInstall => "usb-flash",
940 ForgeOperation::Bundle => "bundle",
941 ForgeOperation::Netboot => "netboot",
942 ForgeOperation::RemotePolymerize => "remote-polymerize",
943 }
944}
945
946fn check_inputs(evaluated: &serde_json::Value) -> Vec<ForgeCheckInput> {
947 if evaluated
948 .pointer("/plan/target")
949 .and_then(|value| value.as_str())
950 == Some("operator-selected")
951 {
952 return vec![ForgeCheckInput {
953 name: "target".to_string(),
954 kind: "operator-selected".to_string(),
955 required: true,
956 sensitive: true,
957 }];
958 }
959 Vec::new()
960}
961
962fn check_outputs(request: &ForgeRequest) -> Vec<ForgeCheckOutput> {
963 let kind = match request.operation {
964 ForgeOperation::Image => "image-plan",
965 ForgeOperation::UsbInstall => "usb-install-plan",
966 ForgeOperation::Bundle => "bundle-plan",
967 ForgeOperation::Netboot => "netboot-plan",
968 ForgeOperation::RemotePolymerize => "remote-polymerize-plan",
969 };
970 vec![ForgeCheckOutput {
971 kind: kind.to_string(),
972 }]
973}
974
975pub fn plan_request(request: &ForgeRequest) -> Result<ForgePlan> {
976 if request.schema_version != FORGE_SCHEMA_VERSION {
977 bail!(
978 "unsupported forge request schema_version {}",
979 request.schema_version
980 );
981 }
982
983 let mut warnings = Vec::new();
984 let mut blockers = Vec::new();
985 validate_hostname(&request.hostname, &mut blockers);
986 validate_operation_target(request, &mut blockers);
987
988 if request.network.require_wired {
989 warnings.push(ForgeDiagnostic::new(
990 "WIRED_NETWORK_REQUIRED",
991 "This request requires wired network during install/polymerize.",
992 ));
993 }
994
995 if request.operation == ForgeOperation::RemotePolymerize {
996 blockers.push(ForgeDiagnostic::new(
997 "REMOTE_POLYMERIZE_NOT_IMPLEMENTED",
998 "Remote polymerize is a later forge target and is not implemented yet.",
999 ));
1000 }
1001
1002 let output_dir = request.output_dir.clone().unwrap_or_else(|| {
1003 std::env::temp_dir()
1004 .join("nex-forge")
1005 .join(bundle_name(request.profile.as_deref()))
1006 });
1007 let iso = uses_installer_iso(request.operation).then(|| ForgeIsoPlan {
1008 url: request.arch.iso_url().to_string(),
1009 filename: request.arch.iso_filename(),
1010 cache_path: output_dir.join(request.arch.iso_filename()),
1011 });
1012 let destructive_actions = destructive_actions(request);
1013
1014 if !destructive_actions.is_empty() && !request.safety.allow_destructive_flash {
1015 blockers.push(ForgeDiagnostic::new(
1016 "DESTRUCTIVE_FLASH_NOT_ALLOWED",
1017 "Request selects a destructive USB flash but safety.allow_destructive_flash is false.",
1018 ));
1019 }
1020 validate_target_attestation(request, &destructive_actions, &mut blockers);
1021
1022 Ok(ForgePlan {
1023 schema_version: FORGE_SCHEMA_VERSION,
1024 operation: request.operation,
1025 hostname: request.hostname.clone(),
1026 arch: request.arch,
1027 output_dir,
1028 iso,
1029 destructive_actions,
1030 warnings,
1031 blockers,
1032 })
1033}
1034
1035fn default_schema_version() -> u16 {
1036 FORGE_SCHEMA_VERSION
1037}
1038
1039fn default_wifi_allowed() -> bool {
1040 true
1041}
1042
1043fn default_require_operator_confirmation() -> bool {
1044 true
1045}
1046
1047fn validate_target_attestation(
1048 request: &ForgeRequest,
1049 destructive_actions: &[DestructiveAction],
1050 blockers: &mut Vec<ForgeDiagnostic>,
1051) {
1052 if destructive_actions.is_empty() {
1053 return;
1054 }
1055 let Some(attestation) = request.safety.target_attestation else {
1056 if request.safety.requires_target_attestation {
1057 blockers.push(ForgeDiagnostic::new(
1058 "TARGET_ATTESTATION_REQUIRED",
1059 "Request requires target attestation for destructive actions, but safety.target_attestation is not set.",
1060 ));
1061 }
1062 return;
1063 };
1064 if !request.safety.allowed_targets.is_empty()
1065 && !request.safety.allowed_targets.contains(&attestation)
1066 {
1067 blockers.push(ForgeDiagnostic::new(
1068 "TARGET_ATTESTATION_NOT_ALLOWED",
1069 format!("Target attestation {attestation:?} is not listed in safety.allowed_targets."),
1070 ));
1071 }
1072 if request.safety.forbidden_targets.contains(&attestation) {
1073 blockers.push(ForgeDiagnostic::new(
1074 "TARGET_ATTESTATION_FORBIDDEN",
1075 format!("Target attestation {attestation:?} is listed in safety.forbidden_targets."),
1076 ));
1077 }
1078 if attestation.is_internal_apple_storage() && !request.safety.allow_internal_disk_selection {
1079 blockers.push(ForgeDiagnostic::new(
1080 "INTERNAL_APPLE_STORAGE_FORBIDDEN",
1081 "Internal Apple storage targets require safety.allow_internal_disk_selection = true.",
1082 ));
1083 }
1084}
1085
1086fn uses_installer_iso(operation: ForgeOperation) -> bool {
1087 matches!(
1088 operation,
1089 ForgeOperation::Bundle | ForgeOperation::UsbInstall
1090 )
1091}
1092
1093fn bundle_name(profile: Option<&str>) -> String {
1094 profile
1095 .map(|r| r.replace('/', "_"))
1096 .unwrap_or_else(|| "styx".to_string())
1097}
1098
1099fn validate_hostname(hostname: &str, blockers: &mut Vec<ForgeDiagnostic>) {
1100 if hostname.is_empty() {
1101 blockers.push(ForgeDiagnostic::new(
1102 "INVALID_HOSTNAME",
1103 "Hostname cannot be empty.",
1104 ));
1105 return;
1106 }
1107 if hostname.starts_with('-') || hostname.ends_with('-') {
1108 blockers.push(ForgeDiagnostic::new(
1109 "INVALID_HOSTNAME",
1110 "Hostname cannot start or end with a hyphen.",
1111 ));
1112 }
1113 if !hostname
1114 .chars()
1115 .all(|c| c.is_ascii_alphanumeric() || c == '-')
1116 {
1117 blockers.push(ForgeDiagnostic::new(
1118 "INVALID_HOSTNAME",
1119 "Hostname must be alphanumeric with hyphens only.",
1120 ));
1121 }
1122}
1123
1124fn validate_operation_target(request: &ForgeRequest, blockers: &mut Vec<ForgeDiagnostic>) {
1125 let valid = matches!(
1126 (request.operation, request.target.kind),
1127 (ForgeOperation::Bundle, TargetKind::Bundle)
1128 | (ForgeOperation::UsbInstall, TargetKind::Usb)
1129 | (ForgeOperation::Image, TargetKind::Image)
1130 | (ForgeOperation::Netboot, TargetKind::Netboot)
1131 | (ForgeOperation::RemotePolymerize, TargetKind::Remote)
1132 );
1133
1134 if !valid {
1135 blockers.push(ForgeDiagnostic::new(
1136 "TARGET_OPERATION_MISMATCH",
1137 "Forge operation does not match target kind.",
1138 ));
1139 }
1140
1141 if request.operation == ForgeOperation::UsbInstall && request.target.disk.is_none() {
1142 blockers.push(ForgeDiagnostic::new(
1143 "USB_TARGET_DISK_REQUIRED",
1144 "USB install requests must specify target.disk.",
1145 ));
1146 }
1147}
1148
1149fn destructive_actions(request: &ForgeRequest) -> Vec<DestructiveAction> {
1150 if request.operation != ForgeOperation::UsbInstall {
1151 return Vec::new();
1152 }
1153
1154 let Some(disk) = request.target.disk.as_ref() else {
1155 return Vec::new();
1156 };
1157
1158 vec![DestructiveAction {
1159 code: "FLASH_USB".to_string(),
1160 message: format!("Flash installer media to {disk}."),
1161 target: Some(disk.clone()),
1162 }]
1163}
1164
1165#[derive(Default)]
1166struct PklRequestBuilder {
1167 schema_version: Option<u16>,
1168 operation: Option<ForgeOperation>,
1169 profile: Option<String>,
1170 hostname: Option<String>,
1171 arch: Option<ForgeArch>,
1172 output_dir: Option<PathBuf>,
1173 target_kind: Option<TargetKind>,
1174 disk: Option<String>,
1175 polymerize_defaults: Option<PolymerizeDefaults>,
1176 network: NetworkPolicy,
1177 safety: SafetyPolicy,
1178 labels: Vec<String>,
1179}
1180
1181impl PklRequestBuilder {
1182 fn finish(mut self) -> Result<ForgeRequest> {
1183 let operation = self.operation.unwrap_or(ForgeOperation::Bundle);
1184 let target_kind = self
1185 .target_kind
1186 .unwrap_or_else(|| default_target_kind(operation));
1187 let mut request = ForgeRequest::new(
1188 operation,
1189 self.hostname.unwrap_or_else(|| "nixos".to_string()),
1190 self.arch.unwrap_or(ForgeArch::X86_64),
1191 ForgeTarget {
1192 kind: target_kind,
1193 disk: self.disk.take(),
1194 },
1195 );
1196 request.schema_version = self.schema_version.unwrap_or(FORGE_SCHEMA_VERSION);
1197 request.profile = self.profile.take();
1198 request.output_dir = self.output_dir.take();
1199 request.polymerize_defaults = self.polymerize_defaults.take().map(|mut defaults| {
1200 if defaults.username.is_empty() {
1201 defaults.username = "user".to_string();
1202 }
1203 if defaults.timezone.is_empty() {
1204 defaults.timezone = "UTC".to_string();
1205 }
1206 defaults
1207 });
1208 request.network = self.network;
1209 request.safety = self.safety;
1210 request.labels = self.labels;
1211 Ok(request)
1212 }
1213}
1214
1215fn evaluate_pkl_request(path: &Path) -> Result<ForgeRequest> {
1216 let value = crate::pkl::evaluate_json(path)?;
1217 request_from_evaluated_pkl(value)
1218}
1219
1220fn request_from_evaluated_pkl(value: serde_json::Value) -> Result<ForgeRequest> {
1221 let object = value
1222 .as_object()
1223 .context("evaluated Pkl forge definition must be an object")?;
1224 let mut builder = PklRequestBuilder {
1225 schema_version: value_u16(object, &["schemaVersion", "schema_version"])?,
1226 operation: value_string(object, &["operation"])
1227 .map(|operation| parse_operation(&operation))
1228 .transpose()?,
1229 profile: value_string(object, &["profile"]),
1230 hostname: value_string(object, &["hostname"]),
1231 arch: value_string(object, &["arch"])
1232 .map(|arch| parse_arch(&arch))
1233 .transpose()?,
1234 output_dir: value_string(object, &["outputDir", "output_dir"]).map(PathBuf::from),
1235 labels: value_string_array(object, &["labels"]),
1236 ..Default::default()
1237 };
1238
1239 if let Some(target) = object.get("target").and_then(|value| value.as_object()) {
1240 builder.target_kind = value_string(target, &["kind"])
1241 .map(|kind| parse_target_kind(&kind))
1242 .transpose()?;
1243 builder.disk = value_string(target, &["disk"]);
1244 }
1245
1246 if let Some(network) = object.get("network").and_then(|value| value.as_object()) {
1247 if let Some(require_wired) = value_bool(network, &["requireWired", "require_wired"]) {
1248 builder.network.require_wired = require_wired;
1249 }
1250 if let Some(wifi_allowed) = value_bool(network, &["wifiAllowed", "wifi_allowed"]) {
1251 builder.network.wifi_allowed = wifi_allowed;
1252 }
1253 }
1254
1255 if let Some(safety) = object.get("safety").and_then(|value| value.as_object()) {
1256 if let Some(allow) = value_bool(
1257 safety,
1258 &["allowDestructiveFlash", "allow_destructive_flash"],
1259 ) {
1260 builder.safety.allow_destructive_flash = allow;
1261 }
1262 if let Some(allow) = value_bool(
1263 safety,
1264 &[
1265 "allowInternalDiskSelection",
1266 "allow_internal_disk_selection",
1267 ],
1268 ) {
1269 builder.safety.allow_internal_disk_selection = allow;
1270 }
1271 if let Some(require) = value_bool(
1272 safety,
1273 &[
1274 "requireOperatorConfirmation",
1275 "require_operator_confirmation",
1276 ],
1277 ) {
1278 builder.safety.require_operator_confirmation = require;
1279 }
1280 if let Some(require) = value_bool(
1281 safety,
1282 &["requiresTargetAttestation", "requires_target_attestation"],
1283 ) {
1284 builder.safety.requires_target_attestation = require;
1285 }
1286 builder.safety.target_attestation =
1287 value_string(safety, &["targetAttestation", "target_attestation"])
1288 .map(|value| parse_target_attestation(&value))
1289 .transpose()?;
1290 builder.safety.allowed_targets =
1291 value_string_array(safety, &["allowedTargets", "allowed_targets"])
1292 .into_iter()
1293 .map(|value| parse_target_attestation(&value))
1294 .collect::<Result<Vec<_>>>()?;
1295 builder.safety.forbidden_targets =
1296 value_string_array(safety, &["forbiddenTargets", "forbidden_targets"])
1297 .into_iter()
1298 .map(|value| parse_target_attestation(&value))
1299 .collect::<Result<Vec<_>>>()?;
1300 }
1301
1302 if let Some(defaults) = object
1303 .get("polymerizeDefaults")
1304 .or_else(|| object.get("polymerize_defaults"))
1305 .and_then(|value| value.as_object())
1306 {
1307 builder.polymerize_defaults = Some(PolymerizeDefaults {
1308 username: value_string(defaults, &["username"]).unwrap_or_default(),
1309 timezone: value_string(defaults, &["timezone"]).unwrap_or_default(),
1310 install_mode: value_string(defaults, &["installMode", "install_mode"]),
1311 ssh_authorized_keys: value_string_array(
1312 defaults,
1313 &["sshAuthorizedKeys", "ssh_authorized_keys"],
1314 ),
1315 });
1316 }
1317
1318 if let Some(plan) = object.get("plan").and_then(|value| value.as_object()) {
1319 if let Some(mode) = value_string(plan, &["mode"]) {
1320 if mode == "image-build" {
1321 builder.operation = Some(ForgeOperation::Image);
1322 builder.target_kind.get_or_insert(TargetKind::Image);
1323 } else if builder.operation.is_none() {
1324 builder.operation = Some(parse_operation(&mode)?);
1325 }
1326 }
1327 }
1328
1329 builder.finish()
1330}
1331
1332fn value_string(
1333 object: &serde_json::Map<String, serde_json::Value>,
1334 keys: &[&str],
1335) -> Option<String> {
1336 keys.iter()
1337 .find_map(|key| object.get(*key))
1338 .and_then(|value| value.as_str())
1339 .map(ToString::to_string)
1340}
1341
1342fn value_bool(object: &serde_json::Map<String, serde_json::Value>, keys: &[&str]) -> Option<bool> {
1343 keys.iter()
1344 .find_map(|key| object.get(*key))
1345 .and_then(|value| value.as_bool())
1346}
1347
1348fn value_u16(
1349 object: &serde_json::Map<String, serde_json::Value>,
1350 keys: &[&str],
1351) -> Result<Option<u16>> {
1352 let Some(value) = keys.iter().find_map(|key| object.get(*key)) else {
1353 return Ok(None);
1354 };
1355 let Some(value) = value.as_u64() else {
1356 bail!("expected integer for {}", keys[0]);
1357 };
1358 Ok(Some(
1359 u16::try_from(value).context("schema version does not fit in u16")?,
1360 ))
1361}
1362
1363fn value_string_array(
1364 object: &serde_json::Map<String, serde_json::Value>,
1365 keys: &[&str],
1366) -> Vec<String> {
1367 keys.iter()
1368 .find_map(|key| object.get(*key))
1369 .and_then(|value| value.as_array())
1370 .map(|values| {
1371 values
1372 .iter()
1373 .filter_map(|value| value.as_str())
1374 .map(ToString::to_string)
1375 .collect()
1376 })
1377 .unwrap_or_default()
1378}
1379
1380fn default_target_kind(operation: ForgeOperation) -> TargetKind {
1381 match operation {
1382 ForgeOperation::Bundle => TargetKind::Bundle,
1383 ForgeOperation::UsbInstall => TargetKind::Usb,
1384 ForgeOperation::Image => TargetKind::Image,
1385 ForgeOperation::Netboot => TargetKind::Netboot,
1386 ForgeOperation::RemotePolymerize => TargetKind::Remote,
1387 }
1388}
1389
1390fn parse_operation(value: &str) -> Result<ForgeOperation> {
1391 match value {
1392 "bundle" => Ok(ForgeOperation::Bundle),
1393 "usb-install" => Ok(ForgeOperation::UsbInstall),
1394 "image" | "image-build" => Ok(ForgeOperation::Image),
1395 "netboot" => Ok(ForgeOperation::Netboot),
1396 "remote-polymerize" => Ok(ForgeOperation::RemotePolymerize),
1397 other => bail!("unsupported forge operation {other}"),
1398 }
1399}
1400
1401fn parse_arch(value: &str) -> Result<ForgeArch> {
1402 match value {
1403 "x86_64" | "x86" | "amd64" => Ok(ForgeArch::X86_64),
1404 "aarch64" | "arm64" | "arm" => Ok(ForgeArch::Aarch64),
1405 other => bail!("unsupported forge arch {other}"),
1406 }
1407}
1408
1409fn parse_target_attestation(value: &str) -> Result<TargetAttestation> {
1410 match value {
1411 "external-usb-ssd" | "external_usb_ssd" => Ok(TargetAttestation::ExternalUsbSsd),
1412 "external-thunderbolt-ssd" | "external_thunderbolt_ssd" => {
1413 Ok(TargetAttestation::ExternalThunderboltSsd)
1414 }
1415 "internal-apple-nvme" | "internal_apple_nvme" => Ok(TargetAttestation::InternalAppleNvme),
1416 "internal-apple-storage" | "internal_apple_storage" => {
1417 Ok(TargetAttestation::InternalAppleStorage)
1418 }
1419 other => anyhow::bail!("unsupported target attestation {other:?}"),
1420 }
1421}
1422
1423fn parse_target_kind(value: &str) -> Result<TargetKind> {
1424 match value {
1425 "bundle" => Ok(TargetKind::Bundle),
1426 "usb" => Ok(TargetKind::Usb),
1427 "image" | "image-build" => Ok(TargetKind::Image),
1428 "netboot" => Ok(TargetKind::Netboot),
1429 "remote" | "remote-polymerize" => Ok(TargetKind::Remote),
1430 other => bail!("unsupported forge target kind {other}"),
1431 }
1432}
1433
1434#[cfg(test)]
1435mod tests {
1436 use super::*;
1437
1438 #[test]
1439 fn plans_bundle_request_without_destructive_actions() -> Result<()> {
1440 let request = ForgeRequest::new(
1441 ForgeOperation::Bundle,
1442 "seed",
1443 ForgeArch::X86_64,
1444 ForgeTarget::bundle(),
1445 )
1446 .profile("/Users/wilson/workspace/pig/nex-seed-substrate")
1447 .output_dir("/tmp/nex-forge/seed");
1448
1449 let plan = plan_request(&request)?;
1450
1451 assert!(!plan.is_blocked());
1452 assert_eq!(plan.output_dir, PathBuf::from("/tmp/nex-forge/seed"));
1453 assert_eq!(
1454 plan.iso.as_ref().map(|iso| iso.filename.as_str()),
1455 Some("nixos-minimal-x86_64.iso")
1456 );
1457 assert!(plan.destructive_actions.is_empty());
1458 Ok(())
1459 }
1460
1461 #[test]
1462 fn blocks_destructive_usb_flash_until_allowed() -> Result<()> {
1463 let request = ForgeRequest::new(
1464 ForgeOperation::UsbInstall,
1465 "seed",
1466 ForgeArch::X86_64,
1467 ForgeTarget::usb(Some("/dev/disk9")),
1468 );
1469
1470 let plan = plan_request(&request)?;
1471
1472 assert!(plan.is_blocked());
1473 assert_eq!(plan.destructive_actions.len(), 1);
1474 assert!(plan
1475 .blockers
1476 .iter()
1477 .any(|diag| diag.code == "DESTRUCTIVE_FLASH_NOT_ALLOWED"));
1478 Ok(())
1479 }
1480
1481 #[test]
1482 fn allows_destructive_usb_flash_when_policy_allows_it() -> Result<()> {
1483 let mut request = ForgeRequest::new(
1484 ForgeOperation::UsbInstall,
1485 "seed",
1486 ForgeArch::X86_64,
1487 ForgeTarget::usb(Some("/dev/disk9")),
1488 );
1489 request.safety.allow_destructive_flash = true;
1490
1491 let plan = plan_request(&request)?;
1492
1493 assert!(!plan.is_blocked());
1494 assert_eq!(
1495 plan.destructive_actions[0].target.as_deref(),
1496 Some("/dev/disk9")
1497 );
1498 Ok(())
1499 }
1500
1501 #[test]
1502 fn blocks_usb_install_without_target_disk() -> Result<()> {
1503 let request = ForgeRequest::new(
1504 ForgeOperation::UsbInstall,
1505 "seed",
1506 ForgeArch::X86_64,
1507 ForgeTarget::usb(None::<String>),
1508 );
1509
1510 let plan = plan_request(&request)?;
1511
1512 assert!(plan.is_blocked());
1513 assert!(plan
1514 .blockers
1515 .iter()
1516 .any(|diag| diag.code == "USB_TARGET_DISK_REQUIRED"));
1517 Ok(())
1518 }
1519
1520 #[test]
1521 fn blocks_invalid_hostname() -> Result<()> {
1522 let request = ForgeRequest::new(
1523 ForgeOperation::Bundle,
1524 "bad host",
1525 ForgeArch::X86_64,
1526 ForgeTarget::bundle(),
1527 );
1528
1529 let plan = plan_request(&request)?;
1530
1531 assert!(plan.is_blocked());
1532 assert!(plan
1533 .blockers
1534 .iter()
1535 .any(|diag| diag.code == "INVALID_HOSTNAME"));
1536 Ok(())
1537 }
1538
1539 #[test]
1540 fn names_pkl_as_canonical_definition_format() {
1541 assert_eq!(CANONICAL_DEFINITION_FORMAT, "pkl");
1542 assert_eq!(CANONICAL_REQUEST_EXTENSION, "pkl");
1543 assert_eq!(CANONICAL_MANIFEST_EXTENSION, "pkl");
1544 }
1545
1546 #[test]
1547 fn serializes_request_as_json_transport_not_canonical_definition() -> Result<()> {
1548 let request = ForgeRequest::new(
1549 ForgeOperation::UsbInstall,
1550 "seed",
1551 ForgeArch::X86_64,
1552 ForgeTarget::usb(Some("/dev/disk9")),
1553 )
1554 .profile("/Users/wilson/workspace/pig/nex-seed-substrate");
1555
1556 let encoded = serde_json::to_string(&request)?;
1557 let decoded: ForgeRequest = serde_json::from_str(&encoded)?;
1558
1559 assert_eq!(decoded.operation, ForgeOperation::UsbInstall);
1560 assert_eq!(decoded.target.kind, TargetKind::Usb);
1561 assert_eq!(decoded.profile.as_deref(), request.profile.as_deref());
1562 Ok(())
1563 }
1564
1565 #[test]
1566 fn normalizes_evaluated_pkl_usb_request() -> Result<()> {
1567 let request = request_from_evaluated_pkl(serde_json::json!({
1568 "schemaVersion": 1,
1569 "operation": "usb-install",
1570 "profile": "/Users/wilson/workspace/pig/nex-seed-substrate",
1571 "hostname": "seed",
1572 "arch": "x86_64",
1573 "outputDir": "/tmp/nex-forge/seed",
1574 "target": {
1575 "kind": "usb",
1576 "disk": "/dev/disk9"
1577 },
1578 "network": {
1579 "requireWired": true,
1580 "wifiAllowed": false
1581 },
1582 "safety": {
1583 "allowDestructiveFlash": true,
1584 "requireOperatorConfirmation": true
1585 },
1586 "polymerizeDefaults": {
1587 "username": "wilson",
1588 "timezone": "America/New_York",
1589 "installMode": "desktop",
1590 "sshAuthorizedKeys": [
1591 "ssh-ed25519 AAAAC3Nza first",
1592 "ssh-ed25519 AAAAC3Nza second"
1593 ]
1594 }
1595 }))?;
1596
1597 assert_eq!(request.operation, ForgeOperation::UsbInstall);
1598 assert_eq!(request.target.kind, TargetKind::Usb);
1599 assert_eq!(request.target.disk.as_deref(), Some("/dev/disk9"));
1600 assert_eq!(request.hostname, "seed");
1601 assert_eq!(request.arch, ForgeArch::X86_64);
1602 assert_eq!(
1603 request.profile.as_deref(),
1604 Some("/Users/wilson/workspace/pig/nex-seed-substrate")
1605 );
1606 assert_eq!(
1607 request.output_dir,
1608 Some(PathBuf::from("/tmp/nex-forge/seed"))
1609 );
1610 assert!(request.network.require_wired);
1611 assert!(!request.network.wifi_allowed);
1612 assert!(request.safety.allow_destructive_flash);
1613 let defaults = request
1614 .polymerize_defaults
1615 .as_ref()
1616 .expect("polymerize defaults");
1617 assert_eq!(defaults.username, "wilson");
1618 assert_eq!(defaults.timezone, "America/New_York");
1619 assert_eq!(defaults.install_mode.as_deref(), Some("desktop"));
1620 assert_eq!(
1621 defaults.ssh_authorized_keys,
1622 vec![
1623 "ssh-ed25519 AAAAC3Nza first".to_string(),
1624 "ssh-ed25519 AAAAC3Nza second".to_string(),
1625 ]
1626 );
1627 Ok(())
1628 }
1629
1630 #[test]
1631 fn normalizes_armory_style_template_as_image_plan() -> Result<()> {
1632 let request = request_from_evaluated_pkl(serde_json::json!({
1633 "name": "minimal-workstation",
1634 "description": "Build a non-destructive workstation image plan for operator review.",
1635 "profileClass": "desktop",
1636 "visibility": "public",
1637 "plan": {
1638 "mode": "image-build",
1639 "target": "operator-selected",
1640 "requiresNetwork": true
1641 }
1642 }))?;
1643
1644 assert_eq!(request.operation, ForgeOperation::Image);
1645 assert_eq!(request.target.kind, TargetKind::Image);
1646 assert_eq!(request.hostname, "nixos");
1647 assert_eq!(request.arch, ForgeArch::X86_64);
1648 Ok(())
1649 }
1650
1651 #[test]
1652 fn check_report_validates_armory_metadata_agreement() -> Result<()> {
1653 let evaluated = serde_json::json!({
1654 "name": "minimal-workstation",
1655 "profileClass": "desktop",
1656 "visibility": "public",
1657 "plan": {
1658 "mode": "image-build",
1659 "target": "operator-selected",
1660 "requiresNetwork": true
1661 }
1662 });
1663 let request = request_from_evaluated_pkl(evaluated.clone())?;
1664 let metadata = ForgeTemplateMetadata {
1665 id: Some("minimal-workstation".to_string()),
1666 version: Some("1.0.0".to_string()),
1667 canonical_format: Some("pkl".to_string()),
1668 visibility: Some("public".to_string()),
1669 profile_class: Some("desktop".to_string()),
1670 destructive_capabilities: Some(vec!["image-build".to_string()]),
1671 network_requirements: Some(vec!["package-download".to_string()]),
1672 ..ForgeTemplateMetadata::default()
1673 };
1674
1675 let report = build_check_report(&evaluated, &request, Some(&metadata));
1676
1677 assert!(report.valid);
1678 assert_eq!(report.exit_code(), 0);
1679 assert_eq!(report.template.id, "minimal-workstation");
1680 assert_eq!(report.capabilities.destructive, vec!["image-build"]);
1681 assert_eq!(report.capabilities.network, vec!["package-download"]);
1682 assert_eq!(report.inputs.len(), 1);
1683 assert_eq!(report.outputs[0].kind, "image-plan");
1684 Ok(())
1685 }
1686
1687 #[test]
1688 fn check_report_rejects_public_raw_disk_template() -> Result<()> {
1689 let evaluated = serde_json::json!({
1690 "name": "unsafe-usb",
1691 "visibility": "public",
1692 "operation": "usb-install",
1693 "hostname": "seed",
1694 "target": {
1695 "kind": "usb",
1696 "disk": "/dev/sda"
1697 }
1698 });
1699 let request = request_from_evaluated_pkl(evaluated.clone())?;
1700
1701 let report = build_check_report(&evaluated, &request, None);
1702
1703 assert!(!report.valid);
1704 assert_eq!(report.exit_code(), 3);
1705 assert!(report
1706 .errors
1707 .iter()
1708 .any(|error| error.code == "UNSAFE_RAW_DISK_TARGET"));
1709 assert!(report
1710 .errors
1711 .iter()
1712 .any(|error| error.code == "PLAN_ONLY_EXECUTE_CAPABLE"));
1713 Ok(())
1714 }
1715
1716 #[test]
1717 fn source_safety_catches_hidden_public_template_risks() {
1718 let mut errors = Vec::new();
1719 validate_public_template_source_safety(
1720 r#"
1721 local join_token = "secret"
1722 import("file:///private/site.pkl")
1723 local clusterInit = true
1724 local postInstall = "sh ./run"
1725 local disk = "/dev/nvme0n1"
1726 "#,
1727 &mut errors,
1728 );
1729
1730 for code in [
1731 "UNSAFE_JOIN_TOKEN",
1732 "UNSAFE_PRIVATE_IMPORT",
1733 "UNSAFE_CLUSTER_INIT",
1734 "UNSAFE_EXECUTION_HOOK",
1735 "UNSAFE_RAW_DISK_TARGET",
1736 ] {
1737 assert!(errors.iter().any(|error| error.code == code), "{code}");
1738 }
1739 }
1740
1741 #[test]
1742 fn evaluates_pkl_with_real_evaluator_when_available() -> Result<()> {
1743 let dir = tempfile::tempdir()?;
1744 let path = dir.path().join("request.pkl");
1745 std::fs::write(
1746 &path,
1747 r#"
1748 local computedHostname = "seed"
1749
1750 schemaVersion = 1
1751 operation = "usb-install"
1752 hostname = computedHostname
1753 arch = "x86_64"
1754
1755 target {
1756 kind = "usb"
1757 disk = "/dev/disk9"
1758 }
1759 "#,
1760 )?;
1761
1762 let request = match evaluate_pkl_request(&path) {
1763 Ok(request) => request,
1764 Err(error) if error.to_string().contains("Pkl evaluator unavailable") => return Ok(()),
1765 Err(error) => return Err(error),
1766 };
1767
1768 assert_eq!(request.hostname, "seed");
1769 assert_eq!(request.operation, ForgeOperation::UsbInstall);
1770 assert_eq!(request.target.disk.as_deref(), Some("/dev/disk9"));
1771 Ok(())
1772 }
1773}
1774
1775#[cfg(test)]
1776mod target_attestation_tests {
1777 use super::*;
1778
1779 #[test]
1780 fn blocks_destructive_flash_when_target_attestation_required_but_missing() -> Result<()> {
1781 let mut request = ForgeRequest::new(
1782 ForgeOperation::UsbInstall,
1783 "seed",
1784 ForgeArch::X86_64,
1785 ForgeTarget::usb(Some("TARGET")),
1786 );
1787 request.safety.allow_destructive_flash = true;
1788 request.safety.requires_target_attestation = true;
1789
1790 let plan = plan_request(&request)?;
1791
1792 assert!(plan.is_blocked());
1793 assert!(plan
1794 .blockers
1795 .iter()
1796 .any(|diag| diag.code == "TARGET_ATTESTATION_REQUIRED"));
1797 Ok(())
1798 }
1799
1800 #[test]
1801 fn allows_destructive_flash_with_allowed_external_attestation() -> Result<()> {
1802 let mut request = ForgeRequest::new(
1803 ForgeOperation::UsbInstall,
1804 "seed",
1805 ForgeArch::X86_64,
1806 ForgeTarget::usb(Some("TARGET")),
1807 );
1808 request.safety.allow_destructive_flash = true;
1809 request.safety.requires_target_attestation = true;
1810 request.safety.target_attestation = Some(TargetAttestation::ExternalUsbSsd);
1811 request.safety.allowed_targets = vec![
1812 TargetAttestation::ExternalUsbSsd,
1813 TargetAttestation::ExternalThunderboltSsd,
1814 ];
1815 request.safety.forbidden_targets = vec![TargetAttestation::InternalAppleNvme];
1816
1817 let plan = plan_request(&request)?;
1818
1819 assert!(!plan.is_blocked());
1820 Ok(())
1821 }
1822
1823 #[test]
1824 fn blocks_internal_apple_nvme_attestation_by_default() -> Result<()> {
1825 let mut request = ForgeRequest::new(
1826 ForgeOperation::UsbInstall,
1827 "seed",
1828 ForgeArch::X86_64,
1829 ForgeTarget::usb(Some("TARGET")),
1830 );
1831 request.safety.allow_destructive_flash = true;
1832 request.safety.requires_target_attestation = true;
1833 request.safety.target_attestation = Some(TargetAttestation::InternalAppleNvme);
1834 request.safety.allowed_targets = vec![
1835 TargetAttestation::ExternalUsbSsd,
1836 TargetAttestation::ExternalThunderboltSsd,
1837 ];
1838 request.safety.forbidden_targets = vec![TargetAttestation::InternalAppleNvme];
1839
1840 let plan = plan_request(&request)?;
1841
1842 assert!(plan.is_blocked());
1843 assert!(plan
1844 .blockers
1845 .iter()
1846 .any(|diag| diag.code == "TARGET_ATTESTATION_NOT_ALLOWED"));
1847 assert!(plan
1848 .blockers
1849 .iter()
1850 .any(|diag| diag.code == "TARGET_ATTESTATION_FORBIDDEN"));
1851 assert!(plan
1852 .blockers
1853 .iter()
1854 .any(|diag| diag.code == "INTERNAL_APPLE_STORAGE_FORBIDDEN"));
1855 Ok(())
1856 }
1857
1858 #[test]
1859 fn blocks_internal_apple_storage_attestation_by_default() -> Result<()> {
1860 let mut request = ForgeRequest::new(
1861 ForgeOperation::UsbInstall,
1862 "seed",
1863 ForgeArch::X86_64,
1864 ForgeTarget::usb(Some("TARGET")),
1865 );
1866 request.safety.allow_destructive_flash = true;
1867 request.safety.requires_target_attestation = true;
1868 request.safety.target_attestation = Some(TargetAttestation::InternalAppleStorage);
1869 request.safety.allowed_targets = vec![
1870 TargetAttestation::ExternalUsbSsd,
1871 TargetAttestation::ExternalThunderboltSsd,
1872 ];
1873 request.safety.forbidden_targets = vec![TargetAttestation::InternalAppleStorage];
1874
1875 let plan = plan_request(&request)?;
1876
1877 assert!(plan.is_blocked());
1878 assert!(plan
1879 .blockers
1880 .iter()
1881 .any(|diag| diag.code == "TARGET_ATTESTATION_NOT_ALLOWED"));
1882 assert!(plan
1883 .blockers
1884 .iter()
1885 .any(|diag| diag.code == "TARGET_ATTESTATION_FORBIDDEN"));
1886 assert!(plan
1887 .blockers
1888 .iter()
1889 .any(|diag| diag.code == "INTERNAL_APPLE_STORAGE_FORBIDDEN"));
1890 Ok(())
1891 }
1892
1893 #[test]
1894 fn parses_target_attestation_from_json_transport() -> Result<()> {
1895 let value = serde_json::json!({
1896 "operation": "usb-install",
1897 "hostname": "seed",
1898 "arch": "x86_64",
1899 "target": { "kind": "usb", "disk": "TARGET" },
1900 "safety": {
1901 "allow_destructive_flash": true,
1902 "requires_target_attestation": true,
1903 "target_attestation": "external-usb-ssd",
1904 "allowed_targets": ["external-usb-ssd", "external-thunderbolt-ssd"],
1905 "forbidden_targets": ["internal-apple-storage"]
1906 }
1907 });
1908
1909 let request = request_from_evaluated_pkl(value)?;
1910
1911 assert_eq!(
1912 request.safety.target_attestation,
1913 Some(TargetAttestation::ExternalUsbSsd)
1914 );
1915 assert_eq!(
1916 request.safety.allowed_targets,
1917 vec![
1918 TargetAttestation::ExternalUsbSsd,
1919 TargetAttestation::ExternalThunderboltSsd
1920 ]
1921 );
1922 assert_eq!(
1923 request.safety.forbidden_targets,
1924 vec![TargetAttestation::InternalAppleStorage]
1925 );
1926 Ok(())
1927 }
1928}