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