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    pub bytecode: Option<PackageBytecode>,
171    pub schema_catalog: Option<PackageCatalogDescriptor>,
172    pub wasm_imports: BTreeMap<String, PackageVariant>,
173    pub flavors: BTreeMap<String, PackageVariant>,
174    canonical_edn: String,
175}
176
177impl PackageManifest {
178    pub fn read(path: &Path) -> Result<Self, PackageManifestError> {
179        let source = fs::read_to_string(path).map_err(|error| {
180            PackageManifestError::new(
181                "package/invalid-manifest",
182                format!("cannot read {}: {error}", path.display()),
183            )
184        })?;
185        Self::parse(&source)
186    }
187
188    /// Opens a `.harp`, parses its data-only `package.edn`, rejects unsafe,
189    /// duplicate, or undeclared entries, and verifies every declared file
190    /// digest before returning the manifest.
191    pub fn read_archive(path: &Path) -> Result<Self, PackageManifestError> {
192        archive::read_archive(path)
193    }
194
195    /// Verifies the complete archive and then resolves only the requested
196    /// runtime. A loader must consume the installed content-addressed root or
197    /// reverify any artifact bytes it reads after this preflight.
198    pub fn select_archive(
199        path: &Path,
200        runtime: PackageRuntime,
201        requirements: &PackageRuntimeRequirements,
202    ) -> Result<VerifiedPackageSelection, PackageManifestError> {
203        let manifest = Self::read_archive(path)?;
204        let selection = manifest.select_variant(runtime, requirements)?;
205        Ok(VerifiedPackageSelection {
206            manifest,
207            selection,
208        })
209    }
210
211    pub fn select_wasm_import_archive(
212        path: &Path,
213        module: &str,
214        requirements: &PackageRuntimeRequirements,
215    ) -> Result<VerifiedPackageSelection, PackageManifestError> {
216        let manifest = Self::read_archive(path)?;
217        let selection = manifest.select_wasm_import(module, requirements)?;
218        Ok(VerifiedPackageSelection {
219            manifest,
220            selection,
221        })
222    }
223
224    pub fn select_hta_require_archive(
225        path: &Path,
226        module: &str,
227        requirements: &PackageRuntimeRequirements,
228    ) -> Result<VerifiedPackageSelection, PackageManifestError> {
229        let manifest = Self::read_archive(path)?;
230        let selection = manifest.select_hta_require(module, requirements)?;
231        Ok(VerifiedPackageSelection {
232            manifest,
233            selection,
234        })
235    }
236
237    pub fn parse(source: &str) -> Result<Self, PackageManifestError> {
238        parse::parse_manifest(source)
239    }
240
241    pub fn canonical_edn(&self) -> &str {
242        &self.canonical_edn
243    }
244
245    /// Returns the diagnostic emitted by non-JVM loaders when a package also
246    /// carries host-native flavor artifacts. Rust intentionally leaves those
247    /// artifacts untouched; only portable and Wasm routes are loaded here.
248    pub fn unsupported_host_flavors_warning(&self) -> Option<String> {
249        if self.flavors.is_empty() {
250            return None;
251        }
252        let flavors = self
253            .flavors
254            .keys()
255            .map(|flavor| format!(":{flavor}"))
256            .collect::<Vec<_>>()
257            .join(", ");
258        Some(format!(
259            "package/host-flavors-ignored: {} {} ({flavors}) are unavailable on the Rust/Wasm runtime",
260            self.identity, self.version
261        ))
262    }
263
264    pub fn admit_catalog_bytes(
265        &self,
266        bytes: &[u8],
267    ) -> Result<PackageCatalogAdmission, PackageManifestError> {
268        let descriptor = self.schema_catalog.as_ref().ok_or_else(|| {
269            PackageManifestError::new(
270                "package/catalog-missing",
271                "package does not declare :schema/catalog",
272            )
273        })?;
274        self.verify_file_bytes(&descriptor.path, bytes)?;
275        let source = std::str::from_utf8(bytes).map_err(|error| {
276            PackageManifestError::new(
277                "package/catalog-invalid",
278                format!(
279                    "catalog {} is not UTF-8 JSON: {error}",
280                    descriptor.path.display()
281                ),
282            )
283        })?;
284        catalog::admit(&descriptor.format, source)
285    }
286
287    pub fn admit_catalog_at(
288        &self,
289        root: &Path,
290    ) -> Result<Option<PackageCatalogAdmission>, PackageManifestError> {
291        let Some(descriptor) = &self.schema_catalog else {
292            return Ok(None);
293        };
294        let bytes = fs::read(root.join(&descriptor.path)).map_err(|error| {
295            PackageManifestError::new(
296                "package/catalog-missing",
297                format!("cannot read catalog {}: {error}", descriptor.path.display()),
298            )
299        })?;
300        self.admit_catalog_bytes(&bytes).map(Some)
301    }
302
303    pub fn select_variant(
304        &self,
305        runtime: PackageRuntime,
306        requirements: &PackageRuntimeRequirements,
307    ) -> Result<PackageSelection, PackageManifestError> {
308        let flavor = match runtime {
309            PackageRuntime::Jvm => "jvm",
310        };
311        self.select_flavor(flavor, requirements)
312    }
313
314    pub fn select_flavor(
315        &self,
316        flavor: &str,
317        requirements: &PackageRuntimeRequirements,
318    ) -> Result<PackageSelection, PackageManifestError> {
319        if self.flavors.is_empty() {
320            if self.wasm_imports.is_empty() {
321                return Ok(PackageSelection::Portable);
322            }
323            return Err(PackageManifestError::new(
324                "package/missing-flavor",
325                format!(
326                    "{} {} has no :{} host flavor",
327                    self.identity, self.version, flavor
328                ),
329            ));
330        }
331        let variant = self.flavors.get(flavor).ok_or_else(|| {
332            PackageManifestError::new(
333                "package/missing-flavor",
334                format!(
335                    "{} {} has no :{} host flavor",
336                    self.identity, self.version, flavor
337                ),
338            )
339        })?;
340        if variant.artifact.artifact_type != PackageArtifactType::Jar {
341            return Err(PackageManifestError::new(
342                "package/artifact-type-mismatch",
343                format!(
344                    ":{} flavor must select :jar, got :{}",
345                    flavor,
346                    variant.artifact.artifact_type.keyword()
347                ),
348            ));
349        }
350        self.preflight_variant(&format!(":{} flavor", flavor), variant, requirements)?;
351        Ok(PackageSelection::Variant(variant.clone()))
352    }
353
354    pub fn select_hta_require(
355        &self,
356        module: &str,
357        requirements: &PackageRuntimeRequirements,
358    ) -> Result<PackageSelection, PackageManifestError> {
359        if self.wasm_imports.is_empty() && self.flavors.is_empty() {
360            return Ok(PackageSelection::Portable);
361        }
362        let variant = self.wasm_imports.get(module).ok_or_else(|| {
363            PackageManifestError::new(
364                "package/missing-require-artifact",
365                format!(
366                    "{} {} has no HTA artifact for :require {module}",
367                    self.identity, self.version
368                ),
369            )
370        })?;
371        if variant.artifact.artifact_type != PackageArtifactType::Hta {
372            return Err(PackageManifestError::new(
373                "package/artifact-type-mismatch",
374                format!(
375                    ":require {module} must select :hta, got :{}",
376                    variant.artifact.artifact_type.keyword()
377                ),
378            ));
379        }
380        self.preflight_variant(&format!(":require {module}"), variant, requirements)?;
381        Ok(PackageSelection::Variant(variant.clone()))
382    }
383
384    fn preflight_variant(
385        &self,
386        route: &str,
387        variant: &PackageVariant,
388        requirements: &PackageRuntimeRequirements,
389    ) -> Result<(), PackageManifestError> {
390        if !requirements
391            .supported_targets
392            .contains(&variant.artifact.target)
393        {
394            return Err(PackageManifestError::new(
395                "package/target-mismatch",
396                format!(
397                    "{route} artifact target {} is not supported",
398                    variant.artifact.target
399                ),
400            ));
401        }
402        if !requirements.supported_abis.contains(&variant.artifact.abi) {
403            return Err(PackageManifestError::new(
404                "package/abi-mismatch",
405                format!(
406                    "{route} artifact ABI {} is not supported",
407                    variant.artifact.abi
408                ),
409            ));
410        }
411
412        let missing_capabilities = difference(
413            &variant.required_capabilities,
414            &requirements.available_capabilities,
415        );
416        if !missing_capabilities.is_empty() {
417            return Err(PackageManifestError::new(
418                "package/capability-denied",
419                format!("missing capabilities: {}", missing_capabilities.join(", ")),
420            ));
421        }
422        let denied_host_calls = difference(&variant.host_calls, &requirements.allowed_host_calls);
423        if !denied_host_calls.is_empty() {
424            return Err(PackageManifestError::new(
425                "package/host-call-denied",
426                format!("denied host calls: {}", denied_host_calls.join(", ")),
427            ));
428        }
429        Ok(())
430    }
431
432    pub fn select_wasm_import(
433        &self,
434        module: &str,
435        requirements: &PackageRuntimeRequirements,
436    ) -> Result<PackageSelection, PackageManifestError> {
437        let variant = self.wasm_imports.get(module).ok_or_else(|| {
438            PackageManifestError::new(
439                "package/missing-wasm-import",
440                format!(
441                    "{} {} has no Wasm import {module}",
442                    self.identity, self.version
443                ),
444            )
445        })?;
446        if variant.artifact.artifact_type != PackageArtifactType::Wasm {
447            return Err(PackageManifestError::new(
448                "package/artifact-type-mismatch",
449                format!(
450                    ":import {module} must select :wasm, got :{}",
451                    variant.artifact.artifact_type.keyword()
452                ),
453            ));
454        }
455        self.preflight_variant(&format!(":import {module}"), variant, requirements)?;
456        Ok(PackageSelection::Variant(variant.clone()))
457    }
458
459    pub fn verify_artifact_bytes(
460        &self,
461        selection: &PackageSelection,
462        bytes: &[u8],
463    ) -> Result<(), PackageManifestError> {
464        let PackageSelection::Variant(variant) = selection else {
465            return Err(PackageManifestError::new(
466                "package/missing-artifact",
467                "portable package selection has no runtime artifact",
468            ));
469        };
470        self.verify_file_bytes(&variant.artifact.path, bytes)
471    }
472
473    /// Verifies one declared archive-relative file without requiring the caller
474    /// to retain the full payload in memory.
475    pub fn verify_file_reader<R: Read>(
476        &self,
477        relative: &Path,
478        reader: &mut R,
479    ) -> Result<(), PackageManifestError> {
480        let expected = self.files.get(relative).ok_or_else(|| {
481            PackageManifestError::new(
482                "package/missing-artifact",
483                format!("file is not declared in :files: {}", relative.display()),
484            )
485        })?;
486        verify_reader(relative, expected, reader)
487    }
488
489    pub fn verify_file_bytes(
490        &self,
491        relative: &Path,
492        bytes: &[u8],
493    ) -> Result<(), PackageManifestError> {
494        self.verify_file_reader(relative, &mut std::io::Cursor::new(bytes))
495    }
496
497    pub fn verify_files_at(&self, root: &Path) -> Result<(), PackageManifestError> {
498        for (relative, expected) in &self.files {
499            let path = root.join(relative);
500            let metadata = fs::symlink_metadata(&path).map_err(|error| {
501                PackageManifestError::new(
502                    "package/missing-artifact",
503                    format!("cannot inspect {}: {error}", relative.display()),
504                )
505            })?;
506            if metadata.file_type().is_symlink() || !metadata.is_file() {
507                return Err(PackageManifestError::new(
508                    "package/missing-artifact",
509                    format!(
510                        "declared package file is not a regular file: {}",
511                        relative.display()
512                    ),
513                ));
514            }
515            if metadata.len() != expected.size {
516                return Err(PackageManifestError::new(
517                    "package/size-mismatch",
518                    format!(
519                        "{} has {} bytes, expected {}",
520                        relative.display(),
521                        metadata.len(),
522                        expected.size
523                    ),
524                ));
525            }
526            let mut file = fs::File::open(&path).map_err(|error| {
527                PackageManifestError::new(
528                    "package/missing-artifact",
529                    format!("cannot read {}: {error}", relative.display()),
530                )
531            })?;
532            verify_reader(relative, expected, &mut file)?;
533        }
534        Ok(())
535    }
536
537    /// Verifies an extracted package root and performs exact-runtime preflight.
538    /// This is the handoff used by runtime loaders after installation.
539    pub fn verify_selection_at(
540        &self,
541        root: &Path,
542        runtime: PackageRuntime,
543        requirements: &PackageRuntimeRequirements,
544    ) -> Result<PackageSelection, PackageManifestError> {
545        self.verify_files_at(root)?;
546        self.select_variant(runtime, requirements)
547    }
548}
549
550fn verify_reader<R: Read>(
551    relative: &Path,
552    expected: &PackageFile,
553    reader: &mut R,
554) -> Result<(), PackageManifestError> {
555    let mut hasher = Sha256::new();
556    let mut size = 0_u64;
557    let mut buffer = [0_u8; 64 * 1024];
558    loop {
559        let read = reader.read(&mut buffer).map_err(|error| {
560            PackageManifestError::new(
561                "package/missing-artifact",
562                format!("cannot read {}: {error}", relative.display()),
563            )
564        })?;
565        if read == 0 {
566            break;
567        }
568        size = size.checked_add(read as u64).ok_or_else(|| {
569            PackageManifestError::new(
570                "package/size-mismatch",
571                format!("{} is too large to verify", relative.display()),
572            )
573        })?;
574        hasher.update(&buffer[..read]);
575    }
576    if size != expected.size {
577        return Err(PackageManifestError::new(
578            "package/size-mismatch",
579            format!(
580                "{} has {} bytes, expected {}",
581                relative.display(),
582                size,
583                expected.size
584            ),
585        ));
586    }
587    let actual = digest_string(&hasher.finalize());
588    if actual != expected.sha256 {
589        return Err(PackageManifestError::new(
590            "package/digest-mismatch",
591            format!(
592                "{} has digest {}, expected {}",
593                relative.display(),
594                actual,
595                expected.sha256
596            ),
597        ));
598    }
599    Ok(())
600}
601
602fn digest_string(bytes: &[u8]) -> String {
603    let hexadecimal = bytes
604        .iter()
605        .map(|byte| format!("{byte:02x}"))
606        .collect::<String>();
607    format!("sha256:{hexadecimal}")
608}
609
610fn difference(required: &BTreeSet<String>, available: &BTreeSet<String>) -> Vec<String> {
611    required.difference(available).cloned().collect()
612}