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};
9use std::process::{Command, Stdio};
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 = evaluate_pkl_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 = evaluate_pkl_json(path)?;
1151    request_from_evaluated_pkl(value)
1152}
1153
1154fn evaluate_pkl_json(path: &Path) -> Result<serde_json::Value> {
1155    let output = run_pkl_eval(path)?;
1156    serde_json::from_slice(&output)
1157        .with_context(|| format!("Pkl evaluator did not emit JSON for {}", path.display()))
1158}
1159
1160fn run_pkl_eval(path: &Path) -> Result<Vec<u8>> {
1161    let path_arg = path.to_string_lossy().to_string();
1162    let mut attempts = Vec::new();
1163
1164    if let Ok(bin) = std::env::var("NEX_PKL") {
1165        attempts.push(PklCommand {
1166            program: bin,
1167            args: vec![
1168                "eval".into(),
1169                "--format".into(),
1170                "json".into(),
1171                path_arg.clone(),
1172            ],
1173        });
1174    }
1175    attempts.push(PklCommand {
1176        program: "pkl".into(),
1177        args: vec![
1178            "eval".into(),
1179            "--format".into(),
1180            "json".into(),
1181            path_arg.clone(),
1182        ],
1183    });
1184    attempts.push(PklCommand {
1185        program: "nix".into(),
1186        args: vec![
1187            "shell".into(),
1188            "nixpkgs#pkl".into(),
1189            "-c".into(),
1190            "pkl".into(),
1191            "eval".into(),
1192            "--format".into(),
1193            "json".into(),
1194            path_arg,
1195        ],
1196    });
1197
1198    let mut missing = Vec::new();
1199    for attempt in attempts {
1200        let output = Command::new(&attempt.program)
1201            .args(&attempt.args)
1202            .stdin(Stdio::null())
1203            .output();
1204        match output {
1205            Ok(output) if output.status.success() => return Ok(output.stdout),
1206            Ok(output) => {
1207                let stderr = String::from_utf8_lossy(&output.stderr);
1208                bail!(
1209                    "Pkl evaluator command failed: {} {}\n{}",
1210                    attempt.program,
1211                    attempt.args.join(" "),
1212                    stderr.trim()
1213                );
1214            }
1215            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
1216                missing.push(attempt.program);
1217            }
1218            Err(err) => {
1219                return Err(err).with_context(|| {
1220                    format!(
1221                        "starting Pkl evaluator command: {} {}",
1222                        attempt.program,
1223                        attempt.args.join(" ")
1224                    )
1225                });
1226            }
1227        }
1228    }
1229
1230    bail!(
1231        "Pkl evaluator unavailable; install `pkl`, set NEX_PKL, or provide `nix` so Nex can run nixpkgs#pkl (tried: {})",
1232        missing.join(", ")
1233    )
1234}
1235
1236struct PklCommand {
1237    program: String,
1238    args: Vec<String>,
1239}
1240
1241fn request_from_evaluated_pkl(value: serde_json::Value) -> Result<ForgeRequest> {
1242    let object = value
1243        .as_object()
1244        .context("evaluated Pkl forge definition must be an object")?;
1245    let mut builder = PklRequestBuilder::default();
1246
1247    builder.schema_version = value_u16(object, &["schemaVersion", "schema_version"])?;
1248    builder.operation = value_string(object, &["operation"])
1249        .map(|operation| parse_operation(&operation))
1250        .transpose()?;
1251    builder.profile = value_string(object, &["profile"]);
1252    builder.hostname = value_string(object, &["hostname"]);
1253    builder.arch = value_string(object, &["arch"])
1254        .map(|arch| parse_arch(&arch))
1255        .transpose()?;
1256    builder.output_dir = value_string(object, &["outputDir", "output_dir"]).map(PathBuf::from);
1257    builder.labels = value_string_array(object, &["labels"]);
1258
1259    if let Some(target) = object.get("target").and_then(|value| value.as_object()) {
1260        builder.target_kind = value_string(target, &["kind"])
1261            .map(|kind| parse_target_kind(&kind))
1262            .transpose()?;
1263        builder.disk = value_string(target, &["disk"]);
1264    }
1265
1266    if let Some(network) = object.get("network").and_then(|value| value.as_object()) {
1267        if let Some(require_wired) = value_bool(network, &["requireWired", "require_wired"]) {
1268            builder.network.require_wired = require_wired;
1269        }
1270        if let Some(wifi_allowed) = value_bool(network, &["wifiAllowed", "wifi_allowed"]) {
1271            builder.network.wifi_allowed = wifi_allowed;
1272        }
1273    }
1274
1275    if let Some(safety) = object.get("safety").and_then(|value| value.as_object()) {
1276        if let Some(allow) = value_bool(
1277            safety,
1278            &["allowDestructiveFlash", "allow_destructive_flash"],
1279        ) {
1280            builder.safety.allow_destructive_flash = allow;
1281        }
1282        if let Some(allow) = value_bool(
1283            safety,
1284            &[
1285                "allowInternalDiskSelection",
1286                "allow_internal_disk_selection",
1287            ],
1288        ) {
1289            builder.safety.allow_internal_disk_selection = allow;
1290        }
1291        if let Some(require) = value_bool(
1292            safety,
1293            &[
1294                "requireOperatorConfirmation",
1295                "require_operator_confirmation",
1296            ],
1297        ) {
1298            builder.safety.require_operator_confirmation = require;
1299        }
1300    }
1301
1302    if let Some(defaults) = object
1303        .get("polymerizeDefaults")
1304        .or_else(|| object.get("polymerize_defaults"))
1305        .and_then(|value| value.as_object())
1306    {
1307        builder.polymerize_defaults = Some(PolymerizeDefaults {
1308            username: value_string(defaults, &["username"]).unwrap_or_default(),
1309            timezone: value_string(defaults, &["timezone"]).unwrap_or_default(),
1310            install_mode: value_string(defaults, &["installMode", "install_mode"]),
1311            ssh_authorized_keys: value_string_array(
1312                defaults,
1313                &["sshAuthorizedKeys", "ssh_authorized_keys"],
1314            ),
1315        });
1316    }
1317
1318    if let Some(plan) = object.get("plan").and_then(|value| value.as_object()) {
1319        if let Some(mode) = value_string(plan, &["mode"]) {
1320            if mode == "image-build" {
1321                builder.operation = Some(ForgeOperation::Image);
1322                builder.target_kind.get_or_insert(TargetKind::Image);
1323            } else if builder.operation.is_none() {
1324                builder.operation = Some(parse_operation(&mode)?);
1325            }
1326        }
1327    }
1328
1329    builder.finish()
1330}
1331
1332fn value_string(
1333    object: &serde_json::Map<String, serde_json::Value>,
1334    keys: &[&str],
1335) -> Option<String> {
1336    keys.iter()
1337        .find_map(|key| object.get(*key))
1338        .and_then(|value| value.as_str())
1339        .map(ToString::to_string)
1340}
1341
1342fn value_bool(object: &serde_json::Map<String, serde_json::Value>, keys: &[&str]) -> Option<bool> {
1343    keys.iter()
1344        .find_map(|key| object.get(*key))
1345        .and_then(|value| value.as_bool())
1346}
1347
1348fn value_u16(
1349    object: &serde_json::Map<String, serde_json::Value>,
1350    keys: &[&str],
1351) -> Result<Option<u16>> {
1352    let Some(value) = keys.iter().find_map(|key| object.get(*key)) else {
1353        return Ok(None);
1354    };
1355    let Some(value) = value.as_u64() else {
1356        bail!("expected integer for {}", keys[0]);
1357    };
1358    Ok(Some(
1359        u16::try_from(value).context("schema version does not fit in u16")?,
1360    ))
1361}
1362
1363fn value_string_array(
1364    object: &serde_json::Map<String, serde_json::Value>,
1365    keys: &[&str],
1366) -> Vec<String> {
1367    keys.iter()
1368        .find_map(|key| object.get(*key))
1369        .and_then(|value| value.as_array())
1370        .map(|values| {
1371            values
1372                .iter()
1373                .filter_map(|value| value.as_str())
1374                .map(ToString::to_string)
1375                .collect()
1376        })
1377        .unwrap_or_default()
1378}
1379
1380fn default_target_kind(operation: ForgeOperation) -> TargetKind {
1381    match operation {
1382        ForgeOperation::Bundle => TargetKind::Bundle,
1383        ForgeOperation::UsbInstall => TargetKind::Usb,
1384        ForgeOperation::Image => TargetKind::Image,
1385        ForgeOperation::Netboot => TargetKind::Netboot,
1386        ForgeOperation::RemotePolymerize => TargetKind::Remote,
1387    }
1388}
1389
1390fn parse_operation(value: &str) -> Result<ForgeOperation> {
1391    match value {
1392        "bundle" => Ok(ForgeOperation::Bundle),
1393        "usb-install" => Ok(ForgeOperation::UsbInstall),
1394        "image" | "image-build" => Ok(ForgeOperation::Image),
1395        "netboot" => Ok(ForgeOperation::Netboot),
1396        "remote-polymerize" => Ok(ForgeOperation::RemotePolymerize),
1397        other => bail!("unsupported forge operation {other}"),
1398    }
1399}
1400
1401fn parse_arch(value: &str) -> Result<ForgeArch> {
1402    match value {
1403        "x86_64" | "x86" | "amd64" => Ok(ForgeArch::X86_64),
1404        "aarch64" | "arm64" | "arm" => Ok(ForgeArch::Aarch64),
1405        other => bail!("unsupported forge arch {other}"),
1406    }
1407}
1408
1409fn parse_target_kind(value: &str) -> Result<TargetKind> {
1410    match value {
1411        "bundle" => Ok(TargetKind::Bundle),
1412        "usb" => Ok(TargetKind::Usb),
1413        "image" | "image-build" => Ok(TargetKind::Image),
1414        "netboot" => Ok(TargetKind::Netboot),
1415        "remote" | "remote-polymerize" => Ok(TargetKind::Remote),
1416        other => bail!("unsupported forge target kind {other}"),
1417    }
1418}
1419
1420#[cfg(test)]
1421mod tests {
1422    use super::*;
1423
1424    #[test]
1425    fn plans_bundle_request_without_destructive_actions() -> Result<()> {
1426        let request = ForgeRequest::new(
1427            ForgeOperation::Bundle,
1428            "seed",
1429            ForgeArch::X86_64,
1430            ForgeTarget::bundle(),
1431        )
1432        .profile("/Users/wilson/workspace/pig/nex-seed-substrate")
1433        .output_dir("/tmp/nex-forge/seed");
1434
1435        let plan = plan_request(&request)?;
1436
1437        assert!(!plan.is_blocked());
1438        assert_eq!(plan.output_dir, PathBuf::from("/tmp/nex-forge/seed"));
1439        assert_eq!(
1440            plan.iso.as_ref().map(|iso| iso.filename.as_str()),
1441            Some("nixos-minimal-x86_64.iso")
1442        );
1443        assert!(plan.destructive_actions.is_empty());
1444        Ok(())
1445    }
1446
1447    #[test]
1448    fn blocks_destructive_usb_flash_until_allowed() -> Result<()> {
1449        let request = ForgeRequest::new(
1450            ForgeOperation::UsbInstall,
1451            "seed",
1452            ForgeArch::X86_64,
1453            ForgeTarget::usb(Some("/dev/disk9")),
1454        );
1455
1456        let plan = plan_request(&request)?;
1457
1458        assert!(plan.is_blocked());
1459        assert_eq!(plan.destructive_actions.len(), 1);
1460        assert!(plan
1461            .blockers
1462            .iter()
1463            .any(|diag| diag.code == "DESTRUCTIVE_FLASH_NOT_ALLOWED"));
1464        Ok(())
1465    }
1466
1467    #[test]
1468    fn allows_destructive_usb_flash_when_policy_allows_it() -> Result<()> {
1469        let mut request = ForgeRequest::new(
1470            ForgeOperation::UsbInstall,
1471            "seed",
1472            ForgeArch::X86_64,
1473            ForgeTarget::usb(Some("/dev/disk9")),
1474        );
1475        request.safety.allow_destructive_flash = true;
1476
1477        let plan = plan_request(&request)?;
1478
1479        assert!(!plan.is_blocked());
1480        assert_eq!(
1481            plan.destructive_actions[0].target.as_deref(),
1482            Some("/dev/disk9")
1483        );
1484        Ok(())
1485    }
1486
1487    #[test]
1488    fn blocks_usb_install_without_target_disk() -> Result<()> {
1489        let request = ForgeRequest::new(
1490            ForgeOperation::UsbInstall,
1491            "seed",
1492            ForgeArch::X86_64,
1493            ForgeTarget::usb(None::<String>),
1494        );
1495
1496        let plan = plan_request(&request)?;
1497
1498        assert!(plan.is_blocked());
1499        assert!(plan
1500            .blockers
1501            .iter()
1502            .any(|diag| diag.code == "USB_TARGET_DISK_REQUIRED"));
1503        Ok(())
1504    }
1505
1506    #[test]
1507    fn blocks_invalid_hostname() -> Result<()> {
1508        let request = ForgeRequest::new(
1509            ForgeOperation::Bundle,
1510            "bad host",
1511            ForgeArch::X86_64,
1512            ForgeTarget::bundle(),
1513        );
1514
1515        let plan = plan_request(&request)?;
1516
1517        assert!(plan.is_blocked());
1518        assert!(plan
1519            .blockers
1520            .iter()
1521            .any(|diag| diag.code == "INVALID_HOSTNAME"));
1522        Ok(())
1523    }
1524
1525    #[test]
1526    fn names_pkl_as_canonical_definition_format() {
1527        assert_eq!(CANONICAL_DEFINITION_FORMAT, "pkl");
1528        assert_eq!(CANONICAL_REQUEST_EXTENSION, "pkl");
1529        assert_eq!(CANONICAL_MANIFEST_EXTENSION, "pkl");
1530    }
1531
1532    #[test]
1533    fn serializes_request_as_json_transport_not_canonical_definition() -> Result<()> {
1534        let request = ForgeRequest::new(
1535            ForgeOperation::UsbInstall,
1536            "seed",
1537            ForgeArch::X86_64,
1538            ForgeTarget::usb(Some("/dev/disk9")),
1539        )
1540        .profile("/Users/wilson/workspace/pig/nex-seed-substrate");
1541
1542        let encoded = serde_json::to_string(&request)?;
1543        let decoded: ForgeRequest = serde_json::from_str(&encoded)?;
1544
1545        assert_eq!(decoded.operation, ForgeOperation::UsbInstall);
1546        assert_eq!(decoded.target.kind, TargetKind::Usb);
1547        assert_eq!(decoded.profile.as_deref(), request.profile.as_deref());
1548        Ok(())
1549    }
1550
1551    #[test]
1552    fn normalizes_evaluated_pkl_usb_request() -> Result<()> {
1553        let request = request_from_evaluated_pkl(serde_json::json!({
1554            "schemaVersion": 1,
1555            "operation": "usb-install",
1556            "profile": "/Users/wilson/workspace/pig/nex-seed-substrate",
1557            "hostname": "seed",
1558            "arch": "x86_64",
1559            "outputDir": "/tmp/nex-forge/seed",
1560            "target": {
1561                "kind": "usb",
1562                "disk": "/dev/disk9"
1563            },
1564            "network": {
1565                "requireWired": true,
1566                "wifiAllowed": false
1567            },
1568            "safety": {
1569                "allowDestructiveFlash": true,
1570                "requireOperatorConfirmation": true
1571            },
1572            "polymerizeDefaults": {
1573                "username": "wilson",
1574                "timezone": "America/New_York",
1575                "installMode": "desktop",
1576                "sshAuthorizedKeys": [
1577                    "ssh-ed25519 AAAAC3Nza first",
1578                    "ssh-ed25519 AAAAC3Nza second"
1579                ]
1580            }
1581        }))?;
1582
1583        assert_eq!(request.operation, ForgeOperation::UsbInstall);
1584        assert_eq!(request.target.kind, TargetKind::Usb);
1585        assert_eq!(request.target.disk.as_deref(), Some("/dev/disk9"));
1586        assert_eq!(request.hostname, "seed");
1587        assert_eq!(request.arch, ForgeArch::X86_64);
1588        assert_eq!(
1589            request.profile.as_deref(),
1590            Some("/Users/wilson/workspace/pig/nex-seed-substrate")
1591        );
1592        assert_eq!(
1593            request.output_dir,
1594            Some(PathBuf::from("/tmp/nex-forge/seed"))
1595        );
1596        assert!(request.network.require_wired);
1597        assert!(!request.network.wifi_allowed);
1598        assert!(request.safety.allow_destructive_flash);
1599        let defaults = request
1600            .polymerize_defaults
1601            .as_ref()
1602            .expect("polymerize defaults");
1603        assert_eq!(defaults.username, "wilson");
1604        assert_eq!(defaults.timezone, "America/New_York");
1605        assert_eq!(defaults.install_mode.as_deref(), Some("desktop"));
1606        assert_eq!(
1607            defaults.ssh_authorized_keys,
1608            vec![
1609                "ssh-ed25519 AAAAC3Nza first".to_string(),
1610                "ssh-ed25519 AAAAC3Nza second".to_string(),
1611            ]
1612        );
1613        Ok(())
1614    }
1615
1616    #[test]
1617    fn normalizes_armory_style_template_as_image_plan() -> Result<()> {
1618        let request = request_from_evaluated_pkl(serde_json::json!({
1619            "name": "minimal-workstation",
1620            "description": "Build a non-destructive workstation image plan for operator review.",
1621            "profileClass": "desktop",
1622            "visibility": "public",
1623            "plan": {
1624                "mode": "image-build",
1625                "target": "operator-selected",
1626                "requiresNetwork": true
1627            }
1628        }))?;
1629
1630        assert_eq!(request.operation, ForgeOperation::Image);
1631        assert_eq!(request.target.kind, TargetKind::Image);
1632        assert_eq!(request.hostname, "nixos");
1633        assert_eq!(request.arch, ForgeArch::X86_64);
1634        Ok(())
1635    }
1636
1637    #[test]
1638    fn check_report_validates_armory_metadata_agreement() -> Result<()> {
1639        let evaluated = serde_json::json!({
1640            "name": "minimal-workstation",
1641            "profileClass": "desktop",
1642            "visibility": "public",
1643            "plan": {
1644                "mode": "image-build",
1645                "target": "operator-selected",
1646                "requiresNetwork": true
1647            }
1648        });
1649        let request = request_from_evaluated_pkl(evaluated.clone())?;
1650        let metadata = ForgeTemplateMetadata {
1651            id: Some("minimal-workstation".to_string()),
1652            version: Some("1.0.0".to_string()),
1653            canonical_format: Some("pkl".to_string()),
1654            visibility: Some("public".to_string()),
1655            profile_class: Some("desktop".to_string()),
1656            destructive_capabilities: Some(vec!["image-build".to_string()]),
1657            network_requirements: Some(vec!["package-download".to_string()]),
1658            ..ForgeTemplateMetadata::default()
1659        };
1660
1661        let report = build_check_report(&evaluated, &request, Some(&metadata));
1662
1663        assert!(report.valid);
1664        assert_eq!(report.exit_code(), 0);
1665        assert_eq!(report.template.id, "minimal-workstation");
1666        assert_eq!(report.capabilities.destructive, vec!["image-build"]);
1667        assert_eq!(report.capabilities.network, vec!["package-download"]);
1668        assert_eq!(report.inputs.len(), 1);
1669        assert_eq!(report.outputs[0].kind, "image-plan");
1670        Ok(())
1671    }
1672
1673    #[test]
1674    fn check_report_rejects_public_raw_disk_template() -> Result<()> {
1675        let evaluated = serde_json::json!({
1676            "name": "unsafe-usb",
1677            "visibility": "public",
1678            "operation": "usb-install",
1679            "hostname": "seed",
1680            "target": {
1681                "kind": "usb",
1682                "disk": "/dev/sda"
1683            }
1684        });
1685        let request = request_from_evaluated_pkl(evaluated.clone())?;
1686
1687        let report = build_check_report(&evaluated, &request, None);
1688
1689        assert!(!report.valid);
1690        assert_eq!(report.exit_code(), 3);
1691        assert!(report
1692            .errors
1693            .iter()
1694            .any(|error| error.code == "UNSAFE_RAW_DISK_TARGET"));
1695        assert!(report
1696            .errors
1697            .iter()
1698            .any(|error| error.code == "PLAN_ONLY_EXECUTE_CAPABLE"));
1699        Ok(())
1700    }
1701
1702    #[test]
1703    fn source_safety_catches_hidden_public_template_risks() {
1704        let mut errors = Vec::new();
1705        validate_public_template_source_safety(
1706            r#"
1707            local join_token = "secret"
1708            import("file:///private/site.pkl")
1709            local clusterInit = true
1710            local postInstall = "sh ./run"
1711            local disk = "/dev/nvme0n1"
1712            "#,
1713            &mut errors,
1714        );
1715
1716        for code in [
1717            "UNSAFE_JOIN_TOKEN",
1718            "UNSAFE_PRIVATE_IMPORT",
1719            "UNSAFE_CLUSTER_INIT",
1720            "UNSAFE_EXECUTION_HOOK",
1721            "UNSAFE_RAW_DISK_TARGET",
1722        ] {
1723            assert!(errors.iter().any(|error| error.code == code), "{code}");
1724        }
1725    }
1726
1727    #[test]
1728    fn evaluates_pkl_with_real_evaluator_when_available() -> Result<()> {
1729        let dir = tempfile::tempdir()?;
1730        let path = dir.path().join("request.pkl");
1731        std::fs::write(
1732            &path,
1733            r#"
1734            local computedHostname = "seed"
1735
1736            schemaVersion = 1
1737            operation = "usb-install"
1738            hostname = computedHostname
1739            arch = "x86_64"
1740
1741            target {
1742              kind = "usb"
1743              disk = "/dev/disk9"
1744            }
1745            "#,
1746        )?;
1747
1748        let request = match evaluate_pkl_request(&path) {
1749            Ok(request) => request,
1750            Err(error) if error.to_string().contains("Pkl evaluator unavailable") => return Ok(()),
1751            Err(error) => return Err(error),
1752        };
1753
1754        assert_eq!(request.hostname, "seed");
1755        assert_eq!(request.operation, ForgeOperation::UsbInstall);
1756        assert_eq!(request.target.disk.as_deref(), Some("/dev/disk9"));
1757        Ok(())
1758    }
1759}