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