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