Skip to main content

nex_pkg/
forge.rs

1//! Shared forge request, plan, and event model.
2//!
3//! This module is deliberately data-first. The existing CLI can translate flags
4//! into these structs, and richer TUIs can render the same plan without parsing
5//! human output.
6
7use 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}