Skip to main content

hara_native/
package_manifest.rs

1//! Data-only validation and exact package-artifact resolution for generated
2//! `package.edn` manifests.
3//!
4//! This module deliberately stops before class loading, Wasm instantiation, or
5//! provider registration. It turns untrusted archive metadata into a verified,
6//! deterministic selection that a host loader can consume. Wasm modules are
7//! imports shared by all hosts; host artifacts live under named flavors.
8
9use crate::kernel::Form;
10use semver::Version;
11use sha2::{Digest, Sha256};
12use std::collections::{BTreeMap, BTreeSet};
13use std::fmt;
14use std::fs;
15use std::io::Read;
16use std::path::{Path, PathBuf};
17
18mod archive;
19mod catalog;
20mod parse;
21#[cfg(test)]
22mod tests;
23
24const PACKAGE_FORMAT: &str = "0.0.0-alpha";
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
27pub enum PackageRuntime {
28    Jvm,
29}
30
31impl PackageRuntime {
32    pub const fn keyword(self) -> &'static str {
33        match self {
34            Self::Jvm => "jvm",
35        }
36    }
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum PackageArtifactType {
41    Jar,
42    Wasm,
43    Hta,
44}
45
46impl PackageArtifactType {
47    pub const fn keyword(self) -> &'static str {
48        match self {
49            Self::Jar => "jar",
50            Self::Wasm => "wasm",
51            Self::Hta => "hta",
52        }
53    }
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct PackageManifestError {
58    pub code: &'static str,
59    pub detail: String,
60}
61
62impl PackageManifestError {
63    fn new(code: &'static str, detail: impl Into<String>) -> Self {
64        Self {
65            code,
66            detail: detail.into(),
67        }
68    }
69}
70
71impl fmt::Display for PackageManifestError {
72    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
73        write!(formatter, "{}: {}", self.code, self.detail)
74    }
75}
76
77impl std::error::Error for PackageManifestError {}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct PackageProvenance {
81    pub repository: String,
82    pub commit: String,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct PackageFile {
87    pub sha256: String,
88    pub size: u64,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct PackageCatalogDescriptor {
93    pub format: String,
94    pub path: PathBuf,
95    pub sha256: String,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct PackageBytecode {
100    pub format: String,
101    pub path: PathBuf,
102    pub sha256: String,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct PackageCatalogAdmission {
107    pub format: String,
108    pub report: String,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct PackageArtifact {
113    pub artifact_type: PackageArtifactType,
114    pub path: PathBuf,
115    pub sha256: String,
116    pub target: String,
117    pub abi: String,
118    pub entry_point: String,
119}
120
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub struct PackageLifecycle {
123    pub load_idempotent: bool,
124    pub close_idempotent: bool,
125    pub session_isolation: bool,
126    pub asynchronous: bool,
127    pub cancellation: bool,
128}
129
130#[derive(Debug, Clone, PartialEq)]
131pub struct PackageVariant {
132    pub artifact: PackageArtifact,
133    pub required_capabilities: BTreeSet<String>,
134    pub host_calls: BTreeSet<String>,
135    pub exports: BTreeSet<String>,
136    pub dependencies: Option<Form>,
137    pub lifecycle: Option<PackageLifecycle>,
138}
139
140#[derive(Debug, Clone, PartialEq, Eq, Default)]
141pub struct PackageRuntimeRequirements {
142    pub supported_targets: BTreeSet<String>,
143    pub supported_abis: BTreeSet<String>,
144    pub available_capabilities: BTreeSet<String>,
145    pub allowed_host_calls: BTreeSet<String>,
146}
147
148#[derive(Debug, Clone, PartialEq)]
149pub enum PackageSelection {
150    Portable,
151    Variant(PackageVariant),
152}
153
154/// A package archive whose complete declared file set has been digest-checked
155/// before exact-runtime preflight.
156#[derive(Debug, Clone, PartialEq)]
157pub struct VerifiedPackageSelection {
158    pub manifest: PackageManifest,
159    pub selection: PackageSelection,
160}
161
162#[derive(Debug, Clone, PartialEq)]
163pub struct PackageManifest {
164    pub format: String,
165    pub identity: String,
166    pub name: Option<String>,
167    pub version: Version,
168    pub provenance: Option<PackageProvenance>,
169    pub files: BTreeMap<PathBuf, PackageFile>,
170    /// Canonical namespace-to-resource declarations carried by the verified
171    /// package manifest. Native package registration consumes this map rather
172    /// than scanning every source file at process startup.
173    pub resources: BTreeMap<String, PathBuf>,
174    pub bytecode: Option<PackageBytecode>,
175    pub schema_catalog: Option<PackageCatalogDescriptor>,
176    pub wasm_imports: BTreeMap<String, PackageVariant>,
177    pub flavors: BTreeMap<String, PackageVariant>,
178    canonical_edn: String,
179}
180
181impl PackageManifest {
182    pub fn read(path: &Path) -> Result<Self, PackageManifestError> {
183        let source = fs::read_to_string(path).map_err(|error| {
184            PackageManifestError::new(
185                "package/invalid-manifest",
186                format!("cannot read {}: {error}", path.display()),
187            )
188        })?;
189        Self::parse(&source)
190    }
191
192    /// Opens a `.harp`, parses its data-only `package.edn`, rejects unsafe,
193    /// duplicate, or undeclared entries, and verifies every declared file
194    /// digest before returning the manifest.
195    pub fn read_archive(path: &Path) -> Result<Self, PackageManifestError> {
196        archive::read_archive(path)
197    }
198
199    /// Verifies the complete archive and then resolves only the requested
200    /// runtime. A loader must consume the installed content-addressed root or
201    /// reverify any artifact bytes it reads after this preflight.
202    pub fn select_archive(
203        path: &Path,
204        runtime: PackageRuntime,
205        requirements: &PackageRuntimeRequirements,
206    ) -> Result<VerifiedPackageSelection, PackageManifestError> {
207        let manifest = Self::read_archive(path)?;
208        let selection = manifest.select_variant(runtime, requirements)?;
209        Ok(VerifiedPackageSelection {
210            manifest,
211            selection,
212        })
213    }
214
215    pub fn select_wasm_import_archive(
216        path: &Path,
217        module: &str,
218        requirements: &PackageRuntimeRequirements,
219    ) -> Result<VerifiedPackageSelection, PackageManifestError> {
220        let manifest = Self::read_archive(path)?;
221        let selection = manifest.select_wasm_import(module, requirements)?;
222        Ok(VerifiedPackageSelection {
223            manifest,
224            selection,
225        })
226    }
227
228    pub fn select_hta_require_archive(
229        path: &Path,
230        module: &str,
231        requirements: &PackageRuntimeRequirements,
232    ) -> Result<VerifiedPackageSelection, PackageManifestError> {
233        let manifest = Self::read_archive(path)?;
234        let selection = manifest.select_hta_require(module, requirements)?;
235        Ok(VerifiedPackageSelection {
236            manifest,
237            selection,
238        })
239    }
240
241    pub fn parse(source: &str) -> Result<Self, PackageManifestError> {
242        parse::parse_manifest(source)
243    }
244
245    pub fn canonical_edn(&self) -> &str {
246        &self.canonical_edn
247    }
248
249    /// Returns the diagnostic emitted by non-JVM loaders when a package also
250    /// carries host-native flavor artifacts. Rust intentionally leaves those
251    /// artifacts untouched; only portable and Wasm routes are loaded here.
252    pub fn unsupported_host_flavors_warning(&self) -> Option<String> {
253        if self.flavors.is_empty() {
254            return None;
255        }
256        let flavors = self
257            .flavors
258            .keys()
259            .map(|flavor| format!(":{flavor}"))
260            .collect::<Vec<_>>()
261            .join(", ");
262        Some(format!(
263            "package/host-flavors-ignored: {} {} ({flavors}) are unavailable on the Rust/Wasm runtime",
264            self.identity, self.version
265        ))
266    }
267
268    pub fn admit_catalog_bytes(
269        &self,
270        bytes: &[u8],
271    ) -> Result<PackageCatalogAdmission, PackageManifestError> {
272        let descriptor = self.schema_catalog.as_ref().ok_or_else(|| {
273            PackageManifestError::new(
274                "package/catalog-missing",
275                "package does not declare :schema/catalog",
276            )
277        })?;
278        self.verify_file_bytes(&descriptor.path, bytes)?;
279        let source = std::str::from_utf8(bytes).map_err(|error| {
280            PackageManifestError::new(
281                "package/catalog-invalid",
282                format!(
283                    "catalog {} is not UTF-8 JSON: {error}",
284                    descriptor.path.display()
285                ),
286            )
287        })?;
288        catalog::admit(&descriptor.format, source)
289    }
290
291    pub fn admit_catalog_at(
292        &self,
293        root: &Path,
294    ) -> Result<Option<PackageCatalogAdmission>, PackageManifestError> {
295        let Some(descriptor) = &self.schema_catalog else {
296            return Ok(None);
297        };
298        let bytes = fs::read(root.join(&descriptor.path)).map_err(|error| {
299            PackageManifestError::new(
300                "package/catalog-missing",
301                format!("cannot read catalog {}: {error}", descriptor.path.display()),
302            )
303        })?;
304        self.admit_catalog_bytes(&bytes).map(Some)
305    }
306
307    pub fn select_variant(
308        &self,
309        runtime: PackageRuntime,
310        requirements: &PackageRuntimeRequirements,
311    ) -> Result<PackageSelection, PackageManifestError> {
312        let flavor = match runtime {
313            PackageRuntime::Jvm => "jvm",
314        };
315        self.select_flavor(flavor, requirements)
316    }
317
318    pub fn select_flavor(
319        &self,
320        flavor: &str,
321        requirements: &PackageRuntimeRequirements,
322    ) -> Result<PackageSelection, PackageManifestError> {
323        if self.flavors.is_empty() {
324            if self.wasm_imports.is_empty() {
325                return Ok(PackageSelection::Portable);
326            }
327            return Err(PackageManifestError::new(
328                "package/missing-flavor",
329                format!(
330                    "{} {} has no :{} host flavor",
331                    self.identity, self.version, flavor
332                ),
333            ));
334        }
335        let variant = self.flavors.get(flavor).ok_or_else(|| {
336            PackageManifestError::new(
337                "package/missing-flavor",
338                format!(
339                    "{} {} has no :{} host flavor",
340                    self.identity, self.version, flavor
341                ),
342            )
343        })?;
344        if variant.artifact.artifact_type != PackageArtifactType::Jar {
345            return Err(PackageManifestError::new(
346                "package/artifact-type-mismatch",
347                format!(
348                    ":{} flavor must select :jar, got :{}",
349                    flavor,
350                    variant.artifact.artifact_type.keyword()
351                ),
352            ));
353        }
354        self.preflight_variant(&format!(":{} flavor", flavor), variant, requirements)?;
355        Ok(PackageSelection::Variant(variant.clone()))
356    }
357
358    pub fn select_hta_require(
359        &self,
360        module: &str,
361        requirements: &PackageRuntimeRequirements,
362    ) -> Result<PackageSelection, PackageManifestError> {
363        if self.wasm_imports.is_empty() && self.flavors.is_empty() {
364            return Ok(PackageSelection::Portable);
365        }
366        let variant = self.wasm_imports.get(module).ok_or_else(|| {
367            PackageManifestError::new(
368                "package/missing-require-artifact",
369                format!(
370                    "{} {} has no HTA artifact for :require {module}",
371                    self.identity, self.version
372                ),
373            )
374        })?;
375        if variant.artifact.artifact_type != PackageArtifactType::Hta {
376            return Err(PackageManifestError::new(
377                "package/artifact-type-mismatch",
378                format!(
379                    ":require {module} must select :hta, got :{}",
380                    variant.artifact.artifact_type.keyword()
381                ),
382            ));
383        }
384        self.preflight_variant(&format!(":require {module}"), variant, requirements)?;
385        Ok(PackageSelection::Variant(variant.clone()))
386    }
387
388    fn preflight_variant(
389        &self,
390        route: &str,
391        variant: &PackageVariant,
392        requirements: &PackageRuntimeRequirements,
393    ) -> Result<(), PackageManifestError> {
394        if !requirements
395            .supported_targets
396            .contains(&variant.artifact.target)
397        {
398            return Err(PackageManifestError::new(
399                "package/target-mismatch",
400                format!(
401                    "{route} artifact target {} is not supported",
402                    variant.artifact.target
403                ),
404            ));
405        }
406        if !requirements.supported_abis.contains(&variant.artifact.abi) {
407            return Err(PackageManifestError::new(
408                "package/abi-mismatch",
409                format!(
410                    "{route} artifact ABI {} is not supported",
411                    variant.artifact.abi
412                ),
413            ));
414        }
415
416        let missing_capabilities = difference(
417            &variant.required_capabilities,
418            &requirements.available_capabilities,
419        );
420        if !missing_capabilities.is_empty() {
421            return Err(PackageManifestError::new(
422                "package/capability-denied",
423                format!("missing capabilities: {}", missing_capabilities.join(", ")),
424            ));
425        }
426        let denied_host_calls = difference(&variant.host_calls, &requirements.allowed_host_calls);
427        if !denied_host_calls.is_empty() {
428            return Err(PackageManifestError::new(
429                "package/host-call-denied",
430                format!("denied host calls: {}", denied_host_calls.join(", ")),
431            ));
432        }
433        Ok(())
434    }
435
436    pub fn select_wasm_import(
437        &self,
438        module: &str,
439        requirements: &PackageRuntimeRequirements,
440    ) -> Result<PackageSelection, PackageManifestError> {
441        let variant = self.wasm_imports.get(module).ok_or_else(|| {
442            PackageManifestError::new(
443                "package/missing-wasm-import",
444                format!(
445                    "{} {} has no Wasm import {module}",
446                    self.identity, self.version
447                ),
448            )
449        })?;
450        if variant.artifact.artifact_type != PackageArtifactType::Wasm {
451            return Err(PackageManifestError::new(
452                "package/artifact-type-mismatch",
453                format!(
454                    ":import {module} must select :wasm, got :{}",
455                    variant.artifact.artifact_type.keyword()
456                ),
457            ));
458        }
459        self.preflight_variant(&format!(":import {module}"), variant, requirements)?;
460        Ok(PackageSelection::Variant(variant.clone()))
461    }
462
463    pub fn verify_artifact_bytes(
464        &self,
465        selection: &PackageSelection,
466        bytes: &[u8],
467    ) -> Result<(), PackageManifestError> {
468        let PackageSelection::Variant(variant) = selection else {
469            return Err(PackageManifestError::new(
470                "package/missing-artifact",
471                "portable package selection has no runtime artifact",
472            ));
473        };
474        self.verify_file_bytes(&variant.artifact.path, bytes)
475    }
476
477    /// Verifies one declared archive-relative file without requiring the caller
478    /// to retain the full payload in memory.
479    pub fn verify_file_reader<R: Read>(
480        &self,
481        relative: &Path,
482        reader: &mut R,
483    ) -> Result<(), PackageManifestError> {
484        let expected = self.files.get(relative).ok_or_else(|| {
485            PackageManifestError::new(
486                "package/missing-artifact",
487                format!("file is not declared in :files: {}", relative.display()),
488            )
489        })?;
490        verify_reader(relative, expected, reader)
491    }
492
493    pub fn verify_file_bytes(
494        &self,
495        relative: &Path,
496        bytes: &[u8],
497    ) -> Result<(), PackageManifestError> {
498        self.verify_file_reader(relative, &mut std::io::Cursor::new(bytes))
499    }
500
501    pub fn verify_files_at(&self, root: &Path) -> Result<(), PackageManifestError> {
502        for (relative, expected) in &self.files {
503            let path = root.join(relative);
504            let metadata = fs::symlink_metadata(&path).map_err(|error| {
505                PackageManifestError::new(
506                    "package/missing-artifact",
507                    format!("cannot inspect {}: {error}", relative.display()),
508                )
509            })?;
510            if metadata.file_type().is_symlink() || !metadata.is_file() {
511                return Err(PackageManifestError::new(
512                    "package/missing-artifact",
513                    format!(
514                        "declared package file is not a regular file: {}",
515                        relative.display()
516                    ),
517                ));
518            }
519            if metadata.len() != expected.size {
520                return Err(PackageManifestError::new(
521                    "package/size-mismatch",
522                    format!(
523                        "{} has {} bytes, expected {}",
524                        relative.display(),
525                        metadata.len(),
526                        expected.size
527                    ),
528                ));
529            }
530            let mut file = fs::File::open(&path).map_err(|error| {
531                PackageManifestError::new(
532                    "package/missing-artifact",
533                    format!("cannot read {}: {error}", relative.display()),
534                )
535            })?;
536            verify_reader(relative, expected, &mut file)?;
537        }
538        Ok(())
539    }
540
541    /// Verifies an extracted package root and performs exact-runtime preflight.
542    /// This is the handoff used by runtime loaders after installation.
543    pub fn verify_selection_at(
544        &self,
545        root: &Path,
546        runtime: PackageRuntime,
547        requirements: &PackageRuntimeRequirements,
548    ) -> Result<PackageSelection, PackageManifestError> {
549        self.verify_files_at(root)?;
550        self.select_variant(runtime, requirements)
551    }
552}
553
554fn verify_reader<R: Read>(
555    relative: &Path,
556    expected: &PackageFile,
557    reader: &mut R,
558) -> Result<(), PackageManifestError> {
559    let mut hasher = Sha256::new();
560    let mut size = 0_u64;
561    let mut buffer = [0_u8; 64 * 1024];
562    loop {
563        let read = reader.read(&mut buffer).map_err(|error| {
564            PackageManifestError::new(
565                "package/missing-artifact",
566                format!("cannot read {}: {error}", relative.display()),
567            )
568        })?;
569        if read == 0 {
570            break;
571        }
572        size = size.checked_add(read as u64).ok_or_else(|| {
573            PackageManifestError::new(
574                "package/size-mismatch",
575                format!("{} is too large to verify", relative.display()),
576            )
577        })?;
578        hasher.update(&buffer[..read]);
579    }
580    if size != expected.size {
581        return Err(PackageManifestError::new(
582            "package/size-mismatch",
583            format!(
584                "{} has {} bytes, expected {}",
585                relative.display(),
586                size,
587                expected.size
588            ),
589        ));
590    }
591    let actual = digest_string(&hasher.finalize());
592    if actual != expected.sha256 {
593        return Err(PackageManifestError::new(
594            "package/digest-mismatch",
595            format!(
596                "{} has digest {}, expected {}",
597                relative.display(),
598                actual,
599                expected.sha256
600            ),
601        ));
602    }
603    Ok(())
604}
605
606fn digest_string(bytes: &[u8]) -> String {
607    let hexadecimal = bytes
608        .iter()
609        .map(|byte| format!("{byte:02x}"))
610        .collect::<String>();
611    format!("sha256:{hexadecimal}")
612}
613
614fn difference(required: &BTreeSet<String>, available: &BTreeSet<String>) -> Vec<String> {
615    required.difference(available).cloned().collect()
616}