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