1use std::collections::BTreeSet;
8use std::path::{Path, PathBuf};
9
10
11use anyhow::{bail, Context, Result};
12use serde::{Deserialize, Serialize};
13
14pub const FORGE_SCHEMA_VERSION: u16 = 1;
15pub const CANONICAL_DEFINITION_FORMAT: &str = "pkl";
16pub const CANONICAL_REQUEST_EXTENSION: &str = "pkl";
17pub const CANONICAL_MANIFEST_EXTENSION: &str = "pkl";
18
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
20pub struct ForgeRequest {
21 #[serde(default = "default_schema_version")]
22 pub schema_version: u16,
23 pub operation: ForgeOperation,
24 #[serde(default, skip_serializing_if = "Option::is_none")]
25 pub profile: Option<String>,
26 pub hostname: String,
27 pub arch: ForgeArch,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub output_dir: Option<PathBuf>,
30 pub target: ForgeTarget,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub polymerize_defaults: Option<PolymerizeDefaults>,
33 #[serde(default)]
34 pub network: NetworkPolicy,
35 #[serde(default)]
36 pub safety: SafetyPolicy,
37 #[serde(default, skip_serializing_if = "Vec::is_empty")]
38 pub labels: Vec<String>,
39}
40
41impl ForgeRequest {
42 pub fn new(
43 operation: ForgeOperation,
44 hostname: impl Into<String>,
45 arch: ForgeArch,
46 target: ForgeTarget,
47 ) -> Self {
48 Self {
49 schema_version: FORGE_SCHEMA_VERSION,
50 operation,
51 profile: None,
52 hostname: hostname.into(),
53 arch,
54 output_dir: None,
55 target,
56 polymerize_defaults: None,
57 network: NetworkPolicy::default(),
58 safety: SafetyPolicy::default(),
59 labels: Vec::new(),
60 }
61 }
62
63 pub fn profile(mut self, profile: impl Into<String>) -> Self {
64 self.profile = Some(profile.into());
65 self
66 }
67
68 pub fn output_dir(mut self, output_dir: impl Into<PathBuf>) -> Self {
69 self.output_dir = Some(output_dir.into());
70 self
71 }
72}
73
74#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
75#[serde(rename_all = "kebab-case")]
76pub enum ForgeOperation {
77 Bundle,
78 UsbInstall,
79 Image,
80 Netboot,
81 RemotePolymerize,
82}
83
84#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
85#[serde(rename_all = "snake_case")]
86pub enum ForgeArch {
87 X86_64,
88 Aarch64,
89}
90
91impl ForgeArch {
92 pub fn label(self) -> &'static str {
93 match self {
94 Self::X86_64 => "x86_64",
95 Self::Aarch64 => "aarch64",
96 }
97 }
98
99 pub fn iso_url(self) -> &'static str {
100 match self {
101 Self::X86_64 => {
102 "https://channels.nixos.org/nixos-24.11/latest-nixos-minimal-x86_64-linux.iso"
103 }
104 Self::Aarch64 => {
105 "https://channels.nixos.org/nixos-24.11/latest-nixos-minimal-aarch64-linux.iso"
106 }
107 }
108 }
109
110 pub fn iso_filename(self) -> String {
111 format!("nixos-minimal-{}.iso", self.label())
112 }
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
116pub struct ForgeTarget {
117 pub kind: TargetKind,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub disk: Option<String>,
120}
121
122impl ForgeTarget {
123 pub fn bundle() -> Self {
124 Self {
125 kind: TargetKind::Bundle,
126 disk: None,
127 }
128 }
129
130 pub fn usb(disk: Option<impl Into<String>>) -> Self {
131 Self {
132 kind: TargetKind::Usb,
133 disk: disk.map(Into::into),
134 }
135 }
136}
137
138#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
139#[serde(rename_all = "kebab-case")]
140pub enum TargetKind {
141 Bundle,
142 Usb,
143 Image,
144 Netboot,
145 Remote,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
149pub struct PolymerizeDefaults {
150 pub username: String,
151 pub timezone: String,
152 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub install_mode: Option<String>,
154 #[serde(default, skip_serializing_if = "Vec::is_empty")]
155 pub ssh_authorized_keys: Vec<String>,
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
159pub struct NetworkPolicy {
160 #[serde(default)]
161 pub require_wired: bool,
162 #[serde(default = "default_wifi_allowed")]
163 pub wifi_allowed: bool,
164}
165
166impl Default for NetworkPolicy {
167 fn default() -> Self {
168 Self {
169 require_wired: false,
170 wifi_allowed: true,
171 }
172 }
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
176pub struct SafetyPolicy {
177 #[serde(default)]
178 pub allow_destructive_flash: bool,
179 #[serde(default)]
180 pub allow_internal_disk_selection: bool,
181 #[serde(default = "default_require_operator_confirmation")]
182 pub require_operator_confirmation: bool,
183}
184
185impl Default for SafetyPolicy {
186 fn default() -> Self {
187 Self {
188 allow_destructive_flash: false,
189 allow_internal_disk_selection: false,
190 require_operator_confirmation: true,
191 }
192 }
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
196pub struct ForgePlan {
197 pub schema_version: u16,
198 pub operation: ForgeOperation,
199 pub hostname: String,
200 pub arch: ForgeArch,
201 pub output_dir: PathBuf,
202 pub iso: Option<ForgeIsoPlan>,
203 #[serde(default, skip_serializing_if = "Vec::is_empty")]
204 pub destructive_actions: Vec<DestructiveAction>,
205 #[serde(default, skip_serializing_if = "Vec::is_empty")]
206 pub warnings: Vec<ForgeDiagnostic>,
207 #[serde(default, skip_serializing_if = "Vec::is_empty")]
208 pub blockers: Vec<ForgeDiagnostic>,
209}
210
211impl ForgePlan {
212 pub fn is_blocked(&self) -> bool {
213 !self.blockers.is_empty()
214 }
215}
216
217pub fn load_request(path: impl Into<PathBuf>) -> Result<ForgeRequest> {
218 let path = path.into();
219 match path.extension().and_then(|ext| ext.to_str()) {
220 Some("json") => {
221 let content = std::fs::read_to_string(&path)
222 .with_context(|| format!("reading {}", path.display()))?;
223 serde_json::from_str(&content)
224 .with_context(|| format!("decoding JSON forge request {}", path.display()))
225 }
226 Some("pkl") => evaluate_pkl_request(&path)
227 .with_context(|| format!("evaluating Pkl forge request {}", path.display())),
228 Some(other) => {
229 bail!("unsupported forge request extension .{other}; canonical requests use .pkl")
230 }
231 None => bail!("forge request path must have an extension; canonical requests use .pkl"),
232 }
233}
234
235#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
236pub struct ForgeIsoPlan {
237 pub url: String,
238 pub filename: String,
239 pub cache_path: PathBuf,
240}
241
242#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
243pub struct DestructiveAction {
244 pub code: String,
245 pub message: String,
246 #[serde(default, skip_serializing_if = "Option::is_none")]
247 pub target: Option<String>,
248}
249
250#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
251pub struct ForgeDiagnostic {
252 pub code: String,
253 pub message: String,
254}
255
256impl ForgeDiagnostic {
257 pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
258 Self {
259 code: code.into(),
260 message: message.into(),
261 }
262 }
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
266pub struct ForgePreflightReport {
267 pub schema_version: u16,
268 pub valid: bool,
269 #[serde(default, skip_serializing_if = "Vec::is_empty")]
270 pub warnings: Vec<ForgeDiagnostic>,
271 #[serde(default, skip_serializing_if = "Vec::is_empty")]
272 pub errors: Vec<ForgeDiagnostic>,
273}
274
275impl ForgePreflightReport {
276 pub fn from_diagnostics(warnings: Vec<ForgeDiagnostic>, errors: Vec<ForgeDiagnostic>) -> Self {
277 Self {
278 schema_version: FORGE_SCHEMA_VERSION,
279 valid: errors.is_empty(),
280 warnings,
281 errors,
282 }
283 }
284}
285
286#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
287#[serde(tag = "type", rename_all = "snake_case")]
288pub enum ForgeEvent {
289 PhaseStarted {
290 schema_version: u16,
291 phase: String,
292 },
293 PhaseCompleted {
294 schema_version: u16,
295 phase: String,
296 },
297 Warning {
298 schema_version: u16,
299 code: String,
300 message: String,
301 },
302 Blocker {
303 schema_version: u16,
304 code: String,
305 message: String,
306 },
307 ArtifactCreated {
308 schema_version: u16,
309 path: PathBuf,
310 },
311 RunCompleted {
312 schema_version: u16,
313 },
314 RunFailed {
315 schema_version: u16,
316 message: String,
317 },
318}
319
320#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
321pub struct ForgeCheckReport {
322 pub schema: String,
323 pub evaluator: String,
324 pub valid: bool,
325 pub template: ForgeCheckTemplate,
326 pub capabilities: ForgeCheckCapabilities,
327 pub inputs: Vec<ForgeCheckInput>,
328 pub outputs: Vec<ForgeCheckOutput>,
329 pub warnings: Vec<ForgeDiagnostic>,
330 pub errors: Vec<ForgeDiagnostic>,
331}
332
333impl ForgeCheckReport {
334 pub fn exit_code(&self) -> i32 {
335 if self.valid {
336 return 0;
337 }
338 if self.errors.iter().any(|error| {
339 matches!(
340 error.code.as_str(),
341 "UNSAFE_RAW_DISK_TARGET"
342 | "UNSAFE_CLUSTER_INIT"
343 | "UNSAFE_JOIN_TOKEN"
344 | "UNSAFE_EXECUTION_HOOK"
345 | "UNSAFE_PRIVATE_IMPORT"
346 | "UNDECLARED_DESTRUCTIVE_CAPABILITY"
347 | "PLAN_ONLY_EXECUTE_CAPABLE"
348 )
349 }) {
350 3
351 } else {
352 1
353 }
354 }
355
356 pub fn evaluator_error(message: String) -> Self {
357 Self {
358 schema: FORGE_TEMPLATE_SCHEMA.to_string(),
359 evaluator: CANONICAL_DEFINITION_FORMAT.to_string(),
360 valid: false,
361 template: ForgeCheckTemplate::default(),
362 capabilities: ForgeCheckCapabilities::default(),
363 inputs: Vec::new(),
364 outputs: Vec::new(),
365 warnings: Vec::new(),
366 errors: vec![ForgeDiagnostic::new("EVALUATOR_ERROR", message)],
367 }
368 }
369}
370
371#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
372pub struct ForgeCheckTemplate {
373 pub id: String,
374 #[serde(default, skip_serializing_if = "Option::is_none")]
375 pub version: Option<String>,
376 #[serde(rename = "evaluationMode")]
377 pub evaluation_mode: String,
378 #[serde(rename = "safetyClass")]
379 pub safety_class: String,
380 #[serde(rename = "canonicalFormat")]
381 pub canonical_format: String,
382}
383
384impl Default for ForgeCheckTemplate {
385 fn default() -> Self {
386 Self {
387 id: "unknown".to_string(),
388 version: None,
389 evaluation_mode: "plan-only".to_string(),
390 safety_class: "unknown".to_string(),
391 canonical_format: CANONICAL_DEFINITION_FORMAT.to_string(),
392 }
393 }
394}
395
396#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
397pub struct ForgeCheckCapabilities {
398 pub destructive: Vec<String>,
399 pub network: Vec<String>,
400}
401
402#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
403pub struct ForgeCheckInput {
404 pub name: String,
405 pub kind: String,
406 pub required: bool,
407 pub sensitive: bool,
408}
409
410#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
411pub struct ForgeCheckOutput {
412 pub kind: String,
413}
414
415const FORGE_TEMPLATE_SCHEMA: &str = "dev.styrene.nex.forge-template.v1";
416
417pub fn check_template(
418 template_path: &Path,
419 metadata_path: Option<&Path>,
420 _no_execute: bool,
421) -> Result<ForgeCheckReport> {
422 if template_path.extension().and_then(|ext| ext.to_str()) != Some("pkl") {
423 bail!("forge template check requires a canonical .pkl payload");
424 }
425
426 let source = std::fs::read_to_string(template_path)
427 .with_context(|| format!("reading {}", template_path.display()))?;
428 let metadata = metadata_path
429 .map(load_forge_template_metadata)
430 .transpose()?;
431 let public_by_metadata = metadata
432 .as_ref()
433 .and_then(|metadata| metadata.visibility.as_deref())
434 == Some("public");
435 let mut source_errors = Vec::new();
436 if public_by_metadata {
437 validate_public_template_source_safety(&source, &mut source_errors);
438 }
439
440 let evaluated = crate::pkl::evaluate_json(template_path)?;
441 let request = request_from_evaluated_pkl(evaluated.clone())?;
442 if !public_by_metadata && evaluated_visibility_is_public(&evaluated) {
443 validate_public_template_source_safety(&source, &mut source_errors);
444 }
445 let mut report = build_check_report(&evaluated, &request, metadata.as_ref());
446 report.errors.extend(source_errors);
447 report.valid = report.errors.is_empty();
448 Ok(report)
449}
450
451fn build_check_report(
452 evaluated: &serde_json::Value,
453 request: &ForgeRequest,
454 metadata: Option<&ForgeTemplateMetadata>,
455) -> ForgeCheckReport {
456 let object = evaluated.as_object();
457 let template_id = object
458 .and_then(|object| value_string(object, &["id", "name"]))
459 .or_else(|| metadata.and_then(|metadata| metadata.id.clone()))
460 .unwrap_or_else(|| "unknown".to_string());
461 let evaluation_mode = metadata
462 .and_then(|metadata| metadata.evaluation_mode.clone())
463 .unwrap_or_else(|| "plan-only".to_string());
464 let safety_class = metadata
465 .and_then(|metadata| metadata.safety_class.clone())
466 .unwrap_or_else(|| safety_class_for_operation(request.operation).to_string());
467 let destructive = destructive_capabilities(request);
468 let network = network_capabilities(evaluated, request);
469 let mut warnings = Vec::new();
470 let mut errors = Vec::new();
471
472 let template = ForgeCheckTemplate {
473 id: template_id,
474 version: metadata.and_then(|metadata| metadata.version.clone()),
475 evaluation_mode,
476 safety_class,
477 canonical_format: CANONICAL_DEFINITION_FORMAT.to_string(),
478 };
479
480 if let Some(metadata) = metadata {
481 validate_metadata_agreement(
482 metadata,
483 evaluated,
484 &template,
485 &destructive,
486 &network,
487 &mut errors,
488 );
489 } else {
490 warnings.push(ForgeDiagnostic::new(
491 "METADATA_NOT_PROVIDED",
492 "No forge.toml metadata was provided; metadata agreement checks were skipped.",
493 ));
494 }
495
496 let evaluated_visibility = object.and_then(|object| value_string(object, &["visibility"]));
497 let is_public = metadata
498 .and_then(|metadata| metadata.visibility.as_deref())
499 .or(evaluated_visibility.as_deref())
500 == Some("public");
501 if is_public {
502 validate_public_template_safety(evaluated, &mut errors);
503 }
504 validate_evaluation_mode(&template, request, &destructive, &mut errors);
505
506 ForgeCheckReport {
507 schema: metadata
508 .and_then(|metadata| metadata.schema.clone())
509 .unwrap_or_else(|| FORGE_TEMPLATE_SCHEMA.to_string()),
510 evaluator: metadata
511 .and_then(|metadata| metadata.evaluator.clone())
512 .unwrap_or_else(|| CANONICAL_DEFINITION_FORMAT.to_string()),
513 valid: errors.is_empty(),
514 template,
515 capabilities: ForgeCheckCapabilities {
516 destructive,
517 network,
518 },
519 inputs: check_inputs(evaluated),
520 outputs: check_outputs(request),
521 warnings,
522 errors,
523 }
524}
525
526fn evaluated_visibility_is_public(evaluated: &serde_json::Value) -> bool {
527 evaluated
528 .as_object()
529 .and_then(|object| value_string(object, &["visibility"]))
530 .as_deref()
531 == Some("public")
532}
533
534#[derive(Debug, Clone, Default, Deserialize)]
535struct ForgeTemplateMetadataFile {
536 #[serde(default)]
537 forge_template: ForgeTemplateMetadata,
538}
539
540#[derive(Debug, Clone, Default, Deserialize)]
541struct ForgeTemplateMetadata {
542 id: Option<String>,
543 version: Option<String>,
544 schema: Option<String>,
545 evaluator: Option<String>,
546 evaluation_mode: Option<String>,
547 safety_class: Option<String>,
548 canonical_format: Option<String>,
549 visibility: Option<String>,
550 profile_class: Option<String>,
551 destructive_capabilities: Option<Vec<String>>,
552 network_requirements: Option<Vec<String>>,
553}
554
555fn load_forge_template_metadata(path: &Path) -> Result<ForgeTemplateMetadata> {
556 let content =
557 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
558 let parsed: ForgeTemplateMetadataFile = toml::from_str(&content)
559 .with_context(|| format!("decoding forge template metadata {}", path.display()))?;
560 Ok(parsed.forge_template)
561}
562
563fn validate_metadata_agreement(
564 metadata: &ForgeTemplateMetadata,
565 evaluated: &serde_json::Value,
566 template: &ForgeCheckTemplate,
567 destructive: &[String],
568 network: &[String],
569 errors: &mut Vec<ForgeDiagnostic>,
570) {
571 let object = evaluated.as_object();
572 compare_optional(
573 "id",
574 metadata.id.as_deref(),
575 Some(template.id.as_str()),
576 "METADATA_ID_MISMATCH",
577 errors,
578 );
579 compare_optional(
580 "canonical_format",
581 metadata.canonical_format.as_deref(),
582 Some(CANONICAL_DEFINITION_FORMAT),
583 "METADATA_CANONICAL_FORMAT_MISMATCH",
584 errors,
585 );
586 compare_optional(
587 "evaluator",
588 metadata.evaluator.as_deref(),
589 Some(CANONICAL_DEFINITION_FORMAT),
590 "METADATA_EVALUATOR_MISMATCH",
591 errors,
592 );
593 compare_optional(
594 "evaluation_mode",
595 metadata.evaluation_mode.as_deref(),
596 Some(template.evaluation_mode.as_str()),
597 "METADATA_EVALUATION_MODE_MISMATCH",
598 errors,
599 );
600 compare_optional(
601 "safety_class",
602 metadata.safety_class.as_deref(),
603 Some(template.safety_class.as_str()),
604 "METADATA_SAFETY_CLASS_MISMATCH",
605 errors,
606 );
607 if let Some(profile_class) = metadata.profile_class.as_deref() {
608 let evaluated_profile_class =
609 object.and_then(|object| value_string(object, &["profileClass", "profile_class"]));
610 compare_optional(
611 "profile_class",
612 Some(profile_class),
613 evaluated_profile_class.as_deref(),
614 "METADATA_PROFILE_CLASS_MISMATCH",
615 errors,
616 );
617 }
618 compare_string_sets(
619 "destructive_capabilities",
620 metadata.destructive_capabilities.as_deref(),
621 destructive,
622 "METADATA_DESTRUCTIVE_CAPABILITIES_MISMATCH",
623 errors,
624 );
625 compare_string_sets(
626 "network_requirements",
627 metadata.network_requirements.as_deref(),
628 network,
629 "METADATA_NETWORK_REQUIREMENTS_MISMATCH",
630 errors,
631 );
632}
633
634fn compare_optional(
635 field: &str,
636 declared: Option<&str>,
637 detected: Option<&str>,
638 code: &str,
639 errors: &mut Vec<ForgeDiagnostic>,
640) {
641 let Some(declared) = declared else {
642 return;
643 };
644 if detected != Some(declared) {
645 errors.push(ForgeDiagnostic::new(
646 code,
647 format!(
648 "Metadata field {field} declares {declared:?}, but Nex detected {:?}.",
649 detected.unwrap_or("<missing>")
650 ),
651 ));
652 }
653}
654
655fn compare_string_sets(
656 field: &str,
657 declared: Option<&[String]>,
658 detected: &[String],
659 code: &str,
660 errors: &mut Vec<ForgeDiagnostic>,
661) {
662 let Some(declared) = declared else {
663 return;
664 };
665 let declared: BTreeSet<&str> = declared.iter().map(String::as_str).collect();
666 let detected: BTreeSet<&str> = detected.iter().map(String::as_str).collect();
667 if declared != detected {
668 errors.push(ForgeDiagnostic::new(
669 code,
670 format!(
671 "Metadata field {field} declares {:?}, but Nex detected {:?}.",
672 declared, detected
673 ),
674 ));
675 }
676}
677
678fn validate_evaluation_mode(
679 template: &ForgeCheckTemplate,
680 request: &ForgeRequest,
681 destructive: &[String],
682 errors: &mut Vec<ForgeDiagnostic>,
683) {
684 if template.evaluation_mode != "plan-only" {
685 errors.push(ForgeDiagnostic::new(
686 "UNSUPPORTED_EVALUATION_MODE",
687 format!(
688 "Evaluation mode {:?} is not supported by forge check.",
689 template.evaluation_mode
690 ),
691 ));
692 }
693 if template.evaluation_mode == "plan-only"
694 && (request.operation == ForgeOperation::UsbInstall
695 || destructive.iter().any(|cap| cap == "usb-flash"))
696 {
697 errors.push(ForgeDiagnostic::new(
698 "PLAN_ONLY_EXECUTE_CAPABLE",
699 "Plan-only templates cannot imply USB flashing or direct execution.",
700 ));
701 }
702 for capability in destructive {
703 if template.safety_class != *capability {
704 errors.push(ForgeDiagnostic::new(
705 "UNDECLARED_DESTRUCTIVE_CAPABILITY",
706 format!(
707 "Safety class {:?} does not match detected capability {:?}.",
708 template.safety_class, capability
709 ),
710 ));
711 }
712 }
713}
714
715fn validate_public_template_safety(value: &serde_json::Value, errors: &mut Vec<ForgeDiagnostic>) {
716 walk_json(value, &mut Vec::new(), &mut |path, value| {
717 let key = path.last().map(String::as_str).unwrap_or_default();
718 match value {
719 serde_json::Value::String(text) => {
720 if is_raw_disk_target(text) {
721 errors.push(ForgeDiagnostic::new(
722 "UNSAFE_RAW_DISK_TARGET",
723 format!(
724 "Public forge template contains fixed raw disk target at {path:?}."
725 ),
726 ));
727 }
728 if is_private_import(text) {
729 errors.push(ForgeDiagnostic::new(
730 "UNSAFE_PRIVATE_IMPORT",
731 format!("Public forge template contains private/local import at {path:?}."),
732 ));
733 }
734 if key.to_ascii_lowercase().contains("token") || text.contains("join_token") {
735 errors.push(ForgeDiagnostic::new(
736 "UNSAFE_JOIN_TOKEN",
737 format!("Public forge template contains token-like field at {path:?}."),
738 ));
739 }
740 }
741 serde_json::Value::Bool(true) => {
742 let key = key.to_ascii_lowercase();
743 if key == "clusterinit" || key == "cluster_init" {
744 errors.push(ForgeDiagnostic::new(
745 "UNSAFE_CLUSTER_INIT",
746 format!(
747 "Public forge template enables cluster initialization at {path:?}."
748 ),
749 ));
750 }
751 }
752 _ => {}
753 }
754 let key = key.to_ascii_lowercase();
755 if matches!(
756 key.as_str(),
757 "hook" | "hooks" | "command" | "commands" | "script" | "scripts"
758 ) || key.ends_with("hook")
759 || key.ends_with("command")
760 || key.ends_with("script")
761 {
762 errors.push(ForgeDiagnostic::new(
763 "UNSAFE_EXECUTION_HOOK",
764 format!("Public forge template contains execution hook field at {path:?}."),
765 ));
766 }
767 });
768}
769
770fn validate_public_template_source_safety(source: &str, errors: &mut Vec<ForgeDiagnostic>) {
771 for (line_index, line) in source.lines().enumerate() {
772 let line_no = line_index + 1;
773 let trimmed = line.trim();
774 if trimmed.starts_with("import") && contains_private_import_text(trimmed) {
775 errors.push(ForgeDiagnostic::new(
776 "UNSAFE_PRIVATE_IMPORT",
777 format!("Public forge template contains private/local import on line {line_no}."),
778 ));
779 }
780 if contains_token_text(trimmed) {
781 errors.push(ForgeDiagnostic::new(
782 "UNSAFE_JOIN_TOKEN",
783 format!("Public forge template contains token-like source text on line {line_no}."),
784 ));
785 }
786 if contains_cluster_init_text(trimmed) {
787 errors.push(ForgeDiagnostic::new(
788 "UNSAFE_CLUSTER_INIT",
789 format!(
790 "Public forge template contains cluster-init source text on line {line_no}."
791 ),
792 ));
793 }
794 if contains_execution_hook_text(trimmed) {
795 errors.push(ForgeDiagnostic::new(
796 "UNSAFE_EXECUTION_HOOK",
797 format!(
798 "Public forge template contains execution-hook source text on line {line_no}."
799 ),
800 ));
801 }
802 if contains_raw_disk_text(trimmed) {
803 errors.push(ForgeDiagnostic::new(
804 "UNSAFE_RAW_DISK_TARGET",
805 format!("Public forge template contains fixed raw disk target on line {line_no}."),
806 ));
807 }
808 }
809}
810
811fn contains_private_import_text(line: &str) -> bool {
812 line.contains("file:")
813 || line.contains("import(\"/")
814 || line.contains("import(\"../")
815 || line.contains("import(\"./private")
816 || line.contains("ssh://")
817}
818
819fn contains_token_text(line: &str) -> bool {
820 let line = line.to_ascii_lowercase();
821 line.contains("join_token") || line.contains("bootstrap_token") || line.contains("token_file")
822}
823
824fn contains_cluster_init_text(line: &str) -> bool {
825 let line = line.to_ascii_lowercase();
826 line.contains("clusterinit") || line.contains("cluster_init")
827}
828
829fn contains_execution_hook_text(line: &str) -> bool {
830 let line = line.to_ascii_lowercase();
831 line.contains("postinstall")
832 || line.contains("preinstall")
833 || line.contains("exechook")
834 || line.contains("exec_hook")
835 || line.contains("shellhook")
836 || line.contains("shell_hook")
837}
838
839fn contains_raw_disk_text(line: &str) -> bool {
840 line.contains("/dev/sda")
841 || line.contains("/dev/vda")
842 || line.contains("/dev/xvda")
843 || line.contains("/dev/nvme")
844 || line.contains("/dev/disk/")
845}
846
847fn walk_json<F>(value: &serde_json::Value, path: &mut Vec<String>, visit: &mut F)
848where
849 F: FnMut(&[String], &serde_json::Value),
850{
851 visit(path, value);
852 match value {
853 serde_json::Value::Object(object) => {
854 for (key, child) in object {
855 path.push(key.clone());
856 walk_json(child, path, visit);
857 path.pop();
858 }
859 }
860 serde_json::Value::Array(values) => {
861 for (index, child) in values.iter().enumerate() {
862 path.push(index.to_string());
863 walk_json(child, path, visit);
864 path.pop();
865 }
866 }
867 _ => {}
868 }
869}
870
871fn is_raw_disk_target(value: &str) -> bool {
872 value == "/dev/sda"
873 || value == "/dev/vda"
874 || value == "/dev/xvda"
875 || value.starts_with("/dev/nvme")
876 || value.starts_with("/dev/disk/")
877}
878
879fn is_private_import(value: &str) -> bool {
880 value.starts_with("file:")
881 || (value.starts_with('/') && !value.starts_with("/dev/"))
882 || value.starts_with("../")
883 || value.starts_with("./private")
884 || value.contains("ssh://")
885}
886
887fn destructive_capabilities(request: &ForgeRequest) -> Vec<String> {
888 match request.operation {
889 ForgeOperation::Image => vec!["image-build".to_string()],
890 ForgeOperation::UsbInstall => vec!["usb-flash".to_string()],
891 _ => Vec::new(),
892 }
893}
894
895fn network_capabilities(evaluated: &serde_json::Value, request: &ForgeRequest) -> Vec<String> {
896 let mut network = BTreeSet::new();
897 if request.network.require_wired {
898 network.insert("wired-network".to_string());
899 }
900 if evaluated
901 .pointer("/plan/requiresNetwork")
902 .and_then(|value| value.as_bool())
903 == Some(true)
904 {
905 network.insert("package-download".to_string());
906 }
907 network.into_iter().collect()
908}
909
910fn safety_class_for_operation(operation: ForgeOperation) -> &'static str {
911 match operation {
912 ForgeOperation::Image => "image-build",
913 ForgeOperation::UsbInstall => "usb-flash",
914 ForgeOperation::Bundle => "bundle",
915 ForgeOperation::Netboot => "netboot",
916 ForgeOperation::RemotePolymerize => "remote-polymerize",
917 }
918}
919
920fn check_inputs(evaluated: &serde_json::Value) -> Vec<ForgeCheckInput> {
921 if evaluated
922 .pointer("/plan/target")
923 .and_then(|value| value.as_str())
924 == Some("operator-selected")
925 {
926 return vec![ForgeCheckInput {
927 name: "target".to_string(),
928 kind: "operator-selected".to_string(),
929 required: true,
930 sensitive: true,
931 }];
932 }
933 Vec::new()
934}
935
936fn check_outputs(request: &ForgeRequest) -> Vec<ForgeCheckOutput> {
937 let kind = match request.operation {
938 ForgeOperation::Image => "image-plan",
939 ForgeOperation::UsbInstall => "usb-install-plan",
940 ForgeOperation::Bundle => "bundle-plan",
941 ForgeOperation::Netboot => "netboot-plan",
942 ForgeOperation::RemotePolymerize => "remote-polymerize-plan",
943 };
944 vec![ForgeCheckOutput {
945 kind: kind.to_string(),
946 }]
947}
948
949pub fn plan_request(request: &ForgeRequest) -> Result<ForgePlan> {
950 if request.schema_version != FORGE_SCHEMA_VERSION {
951 bail!(
952 "unsupported forge request schema_version {}",
953 request.schema_version
954 );
955 }
956
957 let mut warnings = Vec::new();
958 let mut blockers = Vec::new();
959 validate_hostname(&request.hostname, &mut blockers);
960 validate_operation_target(request, &mut blockers);
961
962 if request.network.require_wired {
963 warnings.push(ForgeDiagnostic::new(
964 "WIRED_NETWORK_REQUIRED",
965 "This request requires wired network during install/polymerize.",
966 ));
967 }
968
969 if request.operation == ForgeOperation::RemotePolymerize {
970 blockers.push(ForgeDiagnostic::new(
971 "REMOTE_POLYMERIZE_NOT_IMPLEMENTED",
972 "Remote polymerize is a later forge target and is not implemented yet.",
973 ));
974 }
975
976 let output_dir = request.output_dir.clone().unwrap_or_else(|| {
977 std::env::temp_dir()
978 .join("nex-forge")
979 .join(bundle_name(request.profile.as_deref()))
980 });
981 let iso = uses_installer_iso(request.operation).then(|| ForgeIsoPlan {
982 url: request.arch.iso_url().to_string(),
983 filename: request.arch.iso_filename(),
984 cache_path: output_dir.join(request.arch.iso_filename()),
985 });
986 let destructive_actions = destructive_actions(request);
987
988 if !destructive_actions.is_empty() && !request.safety.allow_destructive_flash {
989 blockers.push(ForgeDiagnostic::new(
990 "DESTRUCTIVE_FLASH_NOT_ALLOWED",
991 "Request selects a destructive USB flash but safety.allow_destructive_flash is false.",
992 ));
993 }
994
995 Ok(ForgePlan {
996 schema_version: FORGE_SCHEMA_VERSION,
997 operation: request.operation,
998 hostname: request.hostname.clone(),
999 arch: request.arch,
1000 output_dir,
1001 iso,
1002 destructive_actions,
1003 warnings,
1004 blockers,
1005 })
1006}
1007
1008fn default_schema_version() -> u16 {
1009 FORGE_SCHEMA_VERSION
1010}
1011
1012fn default_wifi_allowed() -> bool {
1013 true
1014}
1015
1016fn default_require_operator_confirmation() -> bool {
1017 true
1018}
1019
1020fn uses_installer_iso(operation: ForgeOperation) -> bool {
1021 matches!(
1022 operation,
1023 ForgeOperation::Bundle | ForgeOperation::UsbInstall
1024 )
1025}
1026
1027fn bundle_name(profile: Option<&str>) -> String {
1028 profile
1029 .map(|r| r.replace('/', "_"))
1030 .unwrap_or_else(|| "styx".to_string())
1031}
1032
1033fn validate_hostname(hostname: &str, blockers: &mut Vec<ForgeDiagnostic>) {
1034 if hostname.is_empty() {
1035 blockers.push(ForgeDiagnostic::new(
1036 "INVALID_HOSTNAME",
1037 "Hostname cannot be empty.",
1038 ));
1039 return;
1040 }
1041 if hostname.starts_with('-') || hostname.ends_with('-') {
1042 blockers.push(ForgeDiagnostic::new(
1043 "INVALID_HOSTNAME",
1044 "Hostname cannot start or end with a hyphen.",
1045 ));
1046 }
1047 if !hostname
1048 .chars()
1049 .all(|c| c.is_ascii_alphanumeric() || c == '-')
1050 {
1051 blockers.push(ForgeDiagnostic::new(
1052 "INVALID_HOSTNAME",
1053 "Hostname must be alphanumeric with hyphens only.",
1054 ));
1055 }
1056}
1057
1058fn validate_operation_target(request: &ForgeRequest, blockers: &mut Vec<ForgeDiagnostic>) {
1059 let valid = matches!(
1060 (request.operation, request.target.kind),
1061 (ForgeOperation::Bundle, TargetKind::Bundle)
1062 | (ForgeOperation::UsbInstall, TargetKind::Usb)
1063 | (ForgeOperation::Image, TargetKind::Image)
1064 | (ForgeOperation::Netboot, TargetKind::Netboot)
1065 | (ForgeOperation::RemotePolymerize, TargetKind::Remote)
1066 );
1067
1068 if !valid {
1069 blockers.push(ForgeDiagnostic::new(
1070 "TARGET_OPERATION_MISMATCH",
1071 "Forge operation does not match target kind.",
1072 ));
1073 }
1074
1075 if request.operation == ForgeOperation::UsbInstall && request.target.disk.is_none() {
1076 blockers.push(ForgeDiagnostic::new(
1077 "USB_TARGET_DISK_REQUIRED",
1078 "USB install requests must specify target.disk.",
1079 ));
1080 }
1081}
1082
1083fn destructive_actions(request: &ForgeRequest) -> Vec<DestructiveAction> {
1084 if request.operation != ForgeOperation::UsbInstall {
1085 return Vec::new();
1086 }
1087
1088 let Some(disk) = request.target.disk.as_ref() else {
1089 return Vec::new();
1090 };
1091
1092 vec![DestructiveAction {
1093 code: "FLASH_USB".to_string(),
1094 message: format!("Flash installer media to {disk}."),
1095 target: Some(disk.clone()),
1096 }]
1097}
1098
1099#[derive(Default)]
1100struct PklRequestBuilder {
1101 schema_version: Option<u16>,
1102 operation: Option<ForgeOperation>,
1103 profile: Option<String>,
1104 hostname: Option<String>,
1105 arch: Option<ForgeArch>,
1106 output_dir: Option<PathBuf>,
1107 target_kind: Option<TargetKind>,
1108 disk: Option<String>,
1109 polymerize_defaults: Option<PolymerizeDefaults>,
1110 network: NetworkPolicy,
1111 safety: SafetyPolicy,
1112 labels: Vec<String>,
1113}
1114
1115impl PklRequestBuilder {
1116 fn finish(mut self) -> Result<ForgeRequest> {
1117 let operation = self.operation.unwrap_or(ForgeOperation::Bundle);
1118 let target_kind = self
1119 .target_kind
1120 .unwrap_or_else(|| default_target_kind(operation));
1121 let mut request = ForgeRequest::new(
1122 operation,
1123 self.hostname.unwrap_or_else(|| "nixos".to_string()),
1124 self.arch.unwrap_or(ForgeArch::X86_64),
1125 ForgeTarget {
1126 kind: target_kind,
1127 disk: self.disk.take(),
1128 },
1129 );
1130 request.schema_version = self.schema_version.unwrap_or(FORGE_SCHEMA_VERSION);
1131 request.profile = self.profile.take();
1132 request.output_dir = self.output_dir.take();
1133 request.polymerize_defaults = self.polymerize_defaults.take().map(|mut defaults| {
1134 if defaults.username.is_empty() {
1135 defaults.username = "user".to_string();
1136 }
1137 if defaults.timezone.is_empty() {
1138 defaults.timezone = "UTC".to_string();
1139 }
1140 defaults
1141 });
1142 request.network = self.network;
1143 request.safety = self.safety;
1144 request.labels = self.labels;
1145 Ok(request)
1146 }
1147}
1148
1149fn evaluate_pkl_request(path: &Path) -> Result<ForgeRequest> {
1150 let value = crate::pkl::evaluate_json(path)?;
1151 request_from_evaluated_pkl(value)
1152}
1153
1154fn request_from_evaluated_pkl(value: serde_json::Value) -> Result<ForgeRequest> {
1155 let object = value
1156 .as_object()
1157 .context("evaluated Pkl forge definition must be an object")?;
1158 let mut builder = PklRequestBuilder::default();
1159
1160 builder.schema_version = value_u16(object, &["schemaVersion", "schema_version"])?;
1161 builder.operation = value_string(object, &["operation"])
1162 .map(|operation| parse_operation(&operation))
1163 .transpose()?;
1164 builder.profile = value_string(object, &["profile"]);
1165 builder.hostname = value_string(object, &["hostname"]);
1166 builder.arch = value_string(object, &["arch"])
1167 .map(|arch| parse_arch(&arch))
1168 .transpose()?;
1169 builder.output_dir = value_string(object, &["outputDir", "output_dir"]).map(PathBuf::from);
1170 builder.labels = value_string_array(object, &["labels"]);
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}