Skip to main content

nex_pkg/
forge.rs

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