Skip to main content

hara_native/
distribution.rs

1//! Relocatable source-package distributions.
2//!
3//! A distribution keeps the generic native host and canonical HAL separate:
4//! the copied host sits in `bin/`, while the verified HARP archive and its
5//! declarative launch contract sit in `lib/` next to it.
6
7use crate::kernel::{parse_forms, Form};
8use crate::package;
9use crate::package_manifest::PackageManifest;
10use crate::project;
11use sha2::{Digest, Sha256};
12use std::collections::HashSet;
13use std::fs::{self, File};
14use std::io::Write;
15use std::path::{Component, Path, PathBuf};
16
17pub const FORMAT: &str = "hara-distribution/v1";
18pub const ARCHIVE_PATH: &str = "lib/hara.harp";
19pub const MANIFEST_PATH: &str = "lib/release.edn";
20
21/// A self-contained native executable. The native loader treats the final
22/// fixed-size footer as an opt-in marker, so ordinary platform executables
23/// remain valid hosts without a Hara payload.
24pub const SEALED_FORMAT: &str = "hara-executable/v1";
25const SEALED_MAGIC: &[u8; 8] = b"HARAEXE1";
26const SEALED_VERSION: u32 = 1;
27const SEALED_FOOTER_BYTES: usize = 96;
28const SEALED_PAYLOAD_HEADER_BYTES: usize = 8;
29
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct SealArchive {
32    pub path: PathBuf,
33    pub primary: bool,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct SealSpec {
38    pub host: PathBuf,
39    pub output: PathBuf,
40    pub entry: String,
41    pub archives: Vec<SealArchive>,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct SealedArchive {
46    pub identity: String,
47    pub version: String,
48    pub sha256: String,
49    /// Offset from the first payload byte, never from the beginning of the
50    /// executable. This makes the descriptor independent of host location.
51    pub offset: u64,
52    pub length: u64,
53    pub primary: bool,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct SealedManifest {
58    pub entry: String,
59    pub archives: Vec<SealedArchive>,
60    pub host_sha256: String,
61    pub payload_sha256: String,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct SealedInstallation {
66    pub manifest: SealedManifest,
67    pub roots: Vec<PathBuf>,
68    pub primary: PathBuf,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
72struct SealedFooter {
73    payload_start: usize,
74    payload_length: usize,
75    host_sha256: [u8; 32],
76    payload_sha256: [u8; 32],
77}
78
79/// Writes a single executable that contains a verified native host and an
80/// ordered set of canonical HARP archives. The archive marked `:primary` owns
81/// the entry point; all other archives are installed first so its project lock
82/// can resolve them from the normal content-addressed package cache.
83pub fn seal(spec: &SealSpec) -> Result<SealedManifest, String> {
84    validate_seal_spec(spec)?;
85    if spec.output.exists() {
86        return Err(format!(
87            "sealed executable output already exists: {}; choose a new output path",
88            spec.output.display()
89        ));
90    }
91
92    let source_host = fs::read(&spec.host).map_err(|error| {
93        format!(
94            "cannot read sealed executable host {}: {error}",
95            spec.host.display()
96        )
97    })?;
98    let host = match parse_sealed_bytes(&source_host)? {
99        Some((_, footer)) => source_host[..footer.payload_start].to_vec(),
100        None => source_host,
101    };
102    if host.is_empty() {
103        return Err("sealed executable host has no native bytes".into());
104    }
105
106    let mut identities = HashSet::new();
107    let mut archives = Vec::with_capacity(spec.archives.len());
108    let mut contents = Vec::with_capacity(spec.archives.len());
109    for archive in &spec.archives {
110        let manifest = PackageManifest::read_archive(&archive.path).map_err(|error| {
111            format!(
112                "cannot seal HARP archive {}: {error}",
113                archive.path.display()
114            )
115        })?;
116        if !identities.insert(manifest.identity.clone()) {
117            return Err(format!(
118                "sealed executable declares duplicate package identity: {}",
119                manifest.identity
120            ));
121        }
122        let bytes = fs::read(&archive.path).map_err(|error| {
123            format!(
124                "cannot read sealed HARP archive {}: {error}",
125                archive.path.display()
126            )
127        })?;
128        archives.push(SealedArchive {
129            identity: manifest.identity,
130            version: manifest.version.to_string(),
131            sha256: checksum_bytes(&bytes),
132            offset: 0,
133            length: u64::try_from(bytes.len())
134                .map_err(|_| "sealed archive length exceeds u64".to_owned())?,
135            primary: archive.primary,
136        });
137        contents.push(bytes);
138    }
139
140    let descriptor = sealed_descriptor_fixed_point(&spec.entry, &mut archives)?;
141    let mut payload = Vec::with_capacity(
142        SEALED_PAYLOAD_HEADER_BYTES
143            .checked_add(descriptor.len())
144            .and_then(|value| {
145                contents
146                    .iter()
147                    .try_fold(value, |total, bytes| total.checked_add(bytes.len()))
148            })
149            .ok_or("sealed executable payload is too large")?,
150    );
151    payload.extend_from_slice(
152        &u64::try_from(descriptor.len())
153            .map_err(|_| "sealed executable descriptor is too large")?
154            .to_be_bytes(),
155    );
156    payload.extend_from_slice(&descriptor);
157    for bytes in &contents {
158        payload.extend_from_slice(bytes);
159    }
160
161    let host_sha256 = checksum_bytes(&host);
162    let payload_sha256 = checksum_bytes(&payload);
163    let manifest = SealedManifest {
164        entry: spec.entry.clone(),
165        archives,
166        host_sha256: host_sha256.clone(),
167        payload_sha256: payload_sha256.clone(),
168    };
169    let footer = sealed_footer(
170        host.len(),
171        payload.len(),
172        &checksum_digest(&host),
173        &checksum_digest(&payload),
174    )?;
175    write_sealed_atomically(&spec.output, &host, &payload, &footer)?;
176    Ok(manifest)
177}
178
179/// Returns `None` for an ordinary native executable. A footer with the Hara
180/// magic is never ignored: malformed or tampered sealed binaries fail closed.
181pub fn inspect_sealed(path: &Path) -> Result<Option<SealedManifest>, String> {
182    let bytes = fs::read(path)
183        .map_err(|error| format!("cannot read sealed executable {}: {error}", path.display()))?;
184    parse_sealed_bytes(&bytes).map(|found| found.map(|(manifest, _)| manifest))
185}
186
187/// Revalidates a sealed executable's envelope and every embedded archive's
188/// package manifest. It does not mutate the package cache.
189pub fn verify_sealed(path: &Path) -> Result<Option<SealedManifest>, String> {
190    let bytes = fs::read(path)
191        .map_err(|error| format!("cannot read sealed executable {}: {error}", path.display()))?;
192    let Some((manifest, footer)) = parse_sealed_bytes(&bytes)? else {
193        return Ok(None);
194    };
195    let payload_end = footer
196        .payload_start
197        .checked_add(footer.payload_length)
198        .ok_or("sealed executable payload range overflows")?;
199    let payload = &bytes[footer.payload_start..payload_end];
200    for (index, archive) in manifest.archives.iter().enumerate() {
201        let path = temporary_archive_path(archive, index);
202        write_temporary_archive(&path, archive_bytes(payload, archive)?)?;
203        let checked = (|| {
204            let package = PackageManifest::read_archive(&path).map_err(|error| error.to_string())?;
205            verify_embedded_package(archive, &package)
206        })();
207        let _ = fs::remove_file(&path);
208        checked?;
209    }
210    Ok(Some(manifest))
211}
212
213/// Installs a sealed executable's archives into the normal content-addressed
214/// package cache. The only temporary HARP copies are deleted on every path;
215/// the executable itself has no adjacent package files.
216pub fn install_sealed(path: &Path) -> Result<Option<SealedInstallation>, String> {
217    let bytes = fs::read(path)
218        .map_err(|error| format!("cannot read sealed executable {}: {error}", path.display()))?;
219    let Some((manifest, footer)) = parse_sealed_bytes(&bytes)? else {
220        return Ok(None);
221    };
222    let payload_end = footer
223        .payload_start
224        .checked_add(footer.payload_length)
225        .ok_or("sealed executable payload range overflows")?;
226    let payload = &bytes[footer.payload_start..payload_end];
227    let mut roots = Vec::with_capacity(manifest.archives.len());
228    let mut primary = None;
229    for (index, archive) in manifest.archives.iter().enumerate() {
230        let temporary = temporary_archive_path(archive, index);
231        write_temporary_archive(&temporary, archive_bytes(payload, archive)?)?;
232        let installed = (|| {
233            let package = PackageManifest::read_archive(&temporary).map_err(|error| error.to_string())?;
234            verify_embedded_package(archive, &package)?;
235            package::install_path(&temporary)
236        })();
237        let _ = fs::remove_file(&temporary);
238        let installed = installed?;
239        if archive.primary {
240            primary = Some(installed.clone());
241        }
242        roots.push(installed);
243    }
244    Ok(Some(SealedInstallation {
245        manifest,
246        roots,
247        primary: primary.expect("validated sealed manifest has one primary archive"),
248    }))
249}
250
251fn validate_seal_spec(spec: &SealSpec) -> Result<(), String> {
252    if !spec.host.is_file() {
253        return Err(format!(
254            "sealed executable host is not a regular file: {}",
255            spec.host.display()
256        ));
257    }
258    if !valid_entry(&spec.entry) {
259        return Err("sealed executable entry must name namespace/symbol".into());
260    }
261    if spec.archives.is_empty() {
262        return Err("sealed executable requires at least one HARP archive".into());
263    }
264    let primary = spec.archives.iter().filter(|archive| archive.primary).count();
265    if primary != 1 {
266        return Err("sealed executable requires exactly one primary HARP archive".into());
267    }
268    if spec.archives.iter().any(|archive| !archive.path.is_file()) {
269        return Err("sealed executable archives must be regular files".into());
270    }
271    Ok(())
272}
273
274fn sealed_descriptor_fixed_point(
275    entry: &str,
276    archives: &mut [SealedArchive],
277) -> Result<Vec<u8>, String> {
278    let mut descriptor_length = 0usize;
279    for _ in 0..16 {
280        let mut offset = SEALED_PAYLOAD_HEADER_BYTES
281            .checked_add(descriptor_length)
282            .ok_or("sealed executable descriptor is too large")?;
283        for archive in archives.iter_mut() {
284            archive.offset = u64::try_from(offset)
285                .map_err(|_| "sealed executable offset exceeds u64".to_owned())?;
286            offset = offset
287                .checked_add(usize::try_from(archive.length).map_err(|_| {
288                    "sealed executable archive length does not fit this platform".to_owned()
289                })?)
290                .ok_or("sealed executable payload is too large")?;
291        }
292        let descriptor = sealed_descriptor(entry, archives)?;
293        if descriptor.len() == descriptor_length {
294            return Ok(descriptor);
295        }
296        descriptor_length = descriptor.len();
297    }
298    Err("sealed executable descriptor offsets did not converge".into())
299}
300
301fn sealed_descriptor(entry: &str, archives: &[SealedArchive]) -> Result<Vec<u8>, String> {
302    let archives = archives
303        .iter()
304        .map(|archive| {
305            Ok(Form::Map(vec![
306                (Form::Keyword("identity".into()), Form::String(archive.identity.clone())),
307                (Form::Keyword("version".into()), Form::String(archive.version.clone())),
308                (Form::Keyword("sha256".into()), Form::String(archive.sha256.clone())),
309                (
310                    Form::Keyword("offset".into()),
311                    Form::Number(i64::try_from(archive.offset)
312                        .map_err(|_| "sealed executable offset exceeds i64")?),
313                ),
314                (
315                    Form::Keyword("length".into()),
316                    Form::Number(i64::try_from(archive.length)
317                        .map_err(|_| "sealed executable length exceeds i64")?),
318                ),
319                (Form::Keyword("primary".into()), Form::Bool(archive.primary)),
320            ]))
321        })
322        .collect::<Result<Vec<_>, String>>()?;
323    Ok(Form::Map(vec![
324        (
325            Form::Keyword("executable/format".into()),
326            Form::String(SEALED_FORMAT.into()),
327        ),
328        (Form::Keyword("entry".into()), Form::Symbol(entry.into())),
329        (Form::Keyword("archives".into()), Form::Vector(archives)),
330    ])
331    .to_string()
332    .into_bytes())
333}
334
335fn sealed_footer(
336    payload_start: usize,
337    payload_length: usize,
338    host_sha256: &[u8; 32],
339    payload_sha256: &[u8; 32],
340) -> Result<[u8; SEALED_FOOTER_BYTES], String> {
341    let mut footer = [0_u8; SEALED_FOOTER_BYTES];
342    footer[0..8].copy_from_slice(SEALED_MAGIC);
343    footer[8..12].copy_from_slice(&SEALED_VERSION.to_be_bytes());
344    footer[16..24].copy_from_slice(
345        &u64::try_from(payload_start)
346            .map_err(|_| "sealed executable payload start exceeds u64")?
347            .to_be_bytes(),
348    );
349    footer[24..32].copy_from_slice(
350        &u64::try_from(payload_length)
351            .map_err(|_| "sealed executable payload length exceeds u64")?
352            .to_be_bytes(),
353    );
354    footer[32..64].copy_from_slice(host_sha256);
355    footer[64..96].copy_from_slice(payload_sha256);
356    Ok(footer)
357}
358
359fn write_sealed_atomically(
360    output: &Path,
361    host: &[u8],
362    payload: &[u8],
363    footer: &[u8; SEALED_FOOTER_BYTES],
364) -> Result<(), String> {
365    let parent = output
366        .parent()
367        .filter(|path| !path.as_os_str().is_empty())
368        .ok_or("sealed executable output has no parent directory")?;
369    fs::create_dir_all(parent).map_err(io_error)?;
370    let name = output
371        .file_name()
372        .and_then(|name| name.to_str())
373        .ok_or("sealed executable output must have a UTF-8 filename")?;
374    let temporary = parent.join(format!(".{name}.seal-{}", std::process::id()));
375    let result = (|| {
376        let mut file = File::options()
377            .write(true)
378            .create_new(true)
379            .open(&temporary)
380            .map_err(io_error)?;
381        file.write_all(host).map_err(io_error)?;
382        file.write_all(payload).map_err(io_error)?;
383        file.write_all(footer).map_err(io_error)?;
384        file.sync_all().map_err(io_error)?;
385        fs::rename(&temporary, output).map_err(io_error)
386    })();
387    if result.is_err() {
388        let _ = fs::remove_file(&temporary);
389    }
390    result
391}
392
393fn parse_sealed_bytes(bytes: &[u8]) -> Result<Option<(SealedManifest, SealedFooter)>, String> {
394    let Some(footer) = parse_sealed_footer(bytes)? else {
395        return Ok(None);
396    };
397    let payload_end = footer
398        .payload_start
399        .checked_add(footer.payload_length)
400        .ok_or("sealed executable payload range overflows")?;
401    let payload = &bytes[footer.payload_start..payload_end];
402    if checksum_digest(&bytes[..footer.payload_start]) != footer.host_sha256 {
403        return Err("sealed executable host digest mismatch".into());
404    }
405    if checksum_digest(payload) != footer.payload_sha256 {
406        return Err("sealed executable payload digest mismatch".into());
407    }
408    if payload.len() < SEALED_PAYLOAD_HEADER_BYTES {
409        return Err("sealed executable payload is missing its descriptor length".into());
410    }
411    let descriptor_length = usize::try_from(u64::from_be_bytes(
412        payload[0..SEALED_PAYLOAD_HEADER_BYTES]
413            .try_into()
414            .expect("fixed sealed descriptor header length"),
415    ))
416    .map_err(|_| "sealed executable descriptor length exceeds this platform")?;
417    let descriptor_end = SEALED_PAYLOAD_HEADER_BYTES
418        .checked_add(descriptor_length)
419        .ok_or("sealed executable descriptor range overflows")?;
420    if descriptor_end > payload.len() {
421        return Err("sealed executable descriptor exceeds its payload".into());
422    }
423    let descriptor = std::str::from_utf8(&payload[SEALED_PAYLOAD_HEADER_BYTES..descriptor_end])
424        .map_err(|_| "sealed executable descriptor is not UTF-8")?;
425    let forms = parse_forms(descriptor)?;
426    let [Form::Map(entries)] = forms.as_slice() else {
427        return Err("sealed executable descriptor must contain one EDN map".into());
428    };
429    let mut manifest = sealed_manifest_from_entries(entries)?;
430    manifest.host_sha256 = checksum_bytes(&bytes[..footer.payload_start]);
431    manifest.payload_sha256 = checksum_bytes(payload);
432    validate_sealed_archives(payload, descriptor_end, &manifest.archives)?;
433    Ok(Some((manifest, footer)))
434}
435
436fn parse_sealed_footer(bytes: &[u8]) -> Result<Option<SealedFooter>, String> {
437    if bytes.len() < SEALED_FOOTER_BYTES {
438        return Ok(None);
439    }
440    let start = bytes.len() - SEALED_FOOTER_BYTES;
441    let footer = &bytes[start..];
442    if &footer[0..8] != SEALED_MAGIC {
443        return Ok(None);
444    }
445    let version = u32::from_be_bytes(footer[8..12].try_into().expect("fixed footer version"));
446    if version != SEALED_VERSION {
447        return Err(format!("unsupported sealed executable version: {version}"));
448    }
449    if footer[12..16] != [0, 0, 0, 0] {
450        return Err("sealed executable footer reserved bytes must be zero".into());
451    }
452    let payload_start = usize::try_from(u64::from_be_bytes(
453        footer[16..24].try_into().expect("fixed footer payload start"),
454    ))
455    .map_err(|_| "sealed executable payload start exceeds this platform")?;
456    let payload_length = usize::try_from(u64::from_be_bytes(
457        footer[24..32].try_into().expect("fixed footer payload length"),
458    ))
459    .map_err(|_| "sealed executable payload length exceeds this platform")?;
460    let expected_payload_end = bytes
461        .len()
462        .checked_sub(SEALED_FOOTER_BYTES)
463        .ok_or("sealed executable footer is truncated")?;
464    if payload_start
465        .checked_add(payload_length)
466        .filter(|end| *end == expected_payload_end)
467        .is_none()
468    {
469        return Err("sealed executable payload range does not end before its footer".into());
470    }
471    let mut host_sha256 = [0_u8; 32];
472    host_sha256.copy_from_slice(&footer[32..64]);
473    let mut payload_sha256 = [0_u8; 32];
474    payload_sha256.copy_from_slice(&footer[64..96]);
475    Ok(Some(SealedFooter {
476        payload_start,
477        payload_length,
478        host_sha256,
479        payload_sha256,
480    }))
481}
482
483fn sealed_manifest_from_entries(entries: &[(Form, Form)]) -> Result<SealedManifest, String> {
484    let format = string_field(entries, "executable/format")?;
485    if format != SEALED_FORMAT {
486        return Err(format!("unsupported sealed executable format: {format}"));
487    }
488    let entry = symbol_field(entries, "entry")?;
489    if !valid_entry(&entry) {
490        return Err("sealed executable entry must name namespace/symbol".into());
491    }
492    let values = match field(entries, "archives") {
493        Some(Form::Vector(values)) if !values.is_empty() => values,
494        Some(Form::Vector(_)) => return Err("sealed executable has no archives".into()),
495        Some(_) => return Err("sealed executable :archives must be a vector".into()),
496        None => return Err("sealed executable is missing :archives".into()),
497    };
498    let archives = values
499        .iter()
500        .map(sealed_archive_from_form)
501        .collect::<Result<Vec<_>, _>>()?;
502    let primary = archives.iter().filter(|archive| archive.primary).count();
503    if primary != 1 {
504        return Err("sealed executable requires exactly one primary archive".into());
505    }
506    let mut identities = HashSet::new();
507    for archive in &archives {
508        if !identities.insert(archive.identity.clone()) {
509            return Err(format!(
510                "sealed executable declares duplicate package identity: {}",
511                archive.identity
512            ));
513        }
514    }
515    Ok(SealedManifest {
516        entry,
517        archives,
518        host_sha256: String::new(),
519        payload_sha256: String::new(),
520    })
521}
522
523fn sealed_archive_from_form(value: &Form) -> Result<SealedArchive, String> {
524    let Form::Map(entries) = value else {
525        return Err("sealed executable archive must be an EDN map".into());
526    };
527    let identity = string_field(entries, "identity")?;
528    let version = string_field(entries, "version")?;
529    let sha256 = checksum_value(&string_field(entries, "sha256")?)?;
530    let offset = non_negative_number_field(entries, "offset")?;
531    let length = non_negative_number_field(entries, "length")?;
532    if length == 0 {
533        return Err("sealed executable archive length must be positive".into());
534    }
535    let primary = match field(entries, "primary") {
536        Some(Form::Bool(value)) => *value,
537        Some(_) => return Err("sealed executable archive :primary must be boolean".into()),
538        None => return Err("sealed executable archive is missing :primary".into()),
539    };
540    Ok(SealedArchive {
541        identity,
542        version,
543        sha256,
544        offset,
545        length,
546        primary,
547    })
548}
549
550fn non_negative_number_field(entries: &[(Form, Form)], key: &str) -> Result<u64, String> {
551    match field(entries, key) {
552        Some(Form::Number(value)) if *value >= 0 => Ok(*value as u64),
553        Some(Form::Number(_)) => Err(format!("sealed executable :{key} must be non-negative")),
554        Some(_) => Err(format!("sealed executable :{key} must be an integer")),
555        None => Err(format!("sealed executable archive is missing :{key}")),
556    }
557}
558
559fn validate_sealed_archives(
560    payload: &[u8],
561    descriptor_end: usize,
562    archives: &[SealedArchive],
563) -> Result<(), String> {
564    let mut previous_end = descriptor_end;
565    for archive in archives {
566        let offset = usize::try_from(archive.offset)
567            .map_err(|_| "sealed executable archive offset exceeds this platform")?;
568        let length = usize::try_from(archive.length)
569            .map_err(|_| "sealed executable archive length exceeds this platform")?;
570        if offset != previous_end {
571            return Err("sealed executable archives must be contiguous and ordered".into());
572        }
573        let end = offset
574            .checked_add(length)
575            .ok_or("sealed executable archive range overflows")?;
576        if end > payload.len() {
577            return Err("sealed executable archive exceeds its payload".into());
578        }
579        if checksum_bytes(&payload[offset..end]) != archive.sha256 {
580            return Err(format!(
581                "sealed executable archive digest mismatch: {}",
582                archive.identity
583            ));
584        }
585        previous_end = end;
586    }
587    if previous_end != payload.len() {
588        return Err("sealed executable payload has unclaimed trailing bytes".into());
589    }
590    Ok(())
591}
592
593fn archive_bytes<'a>(payload: &'a [u8], archive: &SealedArchive) -> Result<&'a [u8], String> {
594    let offset = usize::try_from(archive.offset)
595        .map_err(|_| "sealed executable archive offset exceeds this platform")?;
596    let length = usize::try_from(archive.length)
597        .map_err(|_| "sealed executable archive length exceeds this platform")?;
598    let end = offset
599        .checked_add(length)
600        .ok_or("sealed executable archive range overflows")?;
601    payload
602        .get(offset..end)
603        .ok_or_else(|| "sealed executable archive exceeds its payload".into())
604}
605
606fn temporary_archive_path(archive: &SealedArchive, index: usize) -> PathBuf {
607    let digest = archive.sha256.trim_start_matches("sha256:");
608    std::env::temp_dir().join(format!(
609        "hara-sealed-{}-{index}-{}.harp",
610        std::process::id(),
611        &digest[..12]
612    ))
613}
614
615fn write_temporary_archive(path: &Path, bytes: &[u8]) -> Result<(), String> {
616    let mut file = File::options()
617        .write(true)
618        .create_new(true)
619        .open(path)
620        .map_err(|error| format!("cannot create sealed archive temporary {}: {error}", path.display()))?;
621    let result = file.write_all(bytes).map_err(io_error);
622    if result.is_err() {
623        let _ = fs::remove_file(path);
624    }
625    result
626}
627
628fn verify_embedded_package(
629    archive: &SealedArchive,
630    package: &PackageManifest,
631) -> Result<(), String> {
632    if package.identity != archive.identity || package.version.to_string() != archive.version {
633        return Err(format!(
634            "sealed executable archive identity mismatch: expected {} {}, received {} {}",
635            archive.identity, archive.version, package.identity, package.version
636        ));
637    }
638    Ok(())
639}
640
641fn checksum_digest(bytes: &[u8]) -> [u8; 32] {
642    Sha256::digest(bytes).into()
643}
644
645fn checksum_bytes(bytes: &[u8]) -> String {
646    let digest = checksum_digest(bytes);
647    format!(
648        "sha256:{}",
649        digest
650            .iter()
651            .map(|byte| format!("{byte:02x}"))
652            .collect::<String>()
653    )
654}
655
656#[derive(Debug, Clone, PartialEq, Eq)]
657pub struct Manifest {
658    pub launcher: String,
659    pub entry: String,
660    pub archive: PathBuf,
661    pub archive_sha256: String,
662    pub source_identity: String,
663    pub source_version: String,
664    pub native_version: String,
665    pub native_sha256: String,
666}
667
668/// Builds a directory that can be relocated as one unit. The caller supplies
669/// the native executable that is copied as the launcher; the package is built
670/// from the declared project and verified again before this function succeeds.
671pub fn build(project_path: &Path, native_binary: &Path, output: &Path) -> Result<Manifest, String> {
672    let project = project::read(project_path)?;
673    let declaration = project.distribution.as_ref().ok_or_else(|| {
674        "project.edn :project/distribution is required for distribution build".to_owned()
675    })?;
676    ensure_empty_output(output)?;
677    if !native_binary.is_file() {
678        return Err(format!(
679            "distribution native binary is not a regular file: {}",
680            native_binary.display()
681        ));
682    }
683
684    let archive = output.join(ARCHIVE_PATH);
685    let launcher = launcher_path(output, &declaration.launcher);
686    let archive_parent = archive
687        .parent()
688        .ok_or_else(|| "distribution archive path has no parent".to_owned())?;
689    let launcher_parent = launcher
690        .parent()
691        .ok_or_else(|| "distribution launcher path has no parent".to_owned())?;
692    fs::create_dir_all(archive_parent).map_err(io_error)?;
693    fs::create_dir_all(launcher_parent).map_err(io_error)?;
694    package::build_path(&project.root, Some(&archive))?;
695    fs::copy(native_binary, &launcher).map_err(io_error)?;
696
697    let package = PackageManifest::read_archive(&archive).map_err(|error| error.to_string())?;
698    let manifest = Manifest {
699        launcher: declaration.launcher.clone(),
700        entry: declaration.entry.clone(),
701        archive: PathBuf::from(ARCHIVE_PATH),
702        archive_sha256: checksum(&archive)?,
703        source_identity: package.identity,
704        source_version: package.version.to_string(),
705        native_version: env!("CARGO_PKG_VERSION").into(),
706        native_sha256: checksum(&launcher)?,
707    };
708    let manifest_path = output.join(MANIFEST_PATH);
709    fs::write(&manifest_path, format!("{}\n", manifest.to_edn())).map_err(io_error)?;
710    verify(output, &launcher)?;
711    Ok(manifest)
712}
713
714/// Reads and verifies the local companion contract before a host loads any
715/// source from its HARP archive. A release archive's signature authenticates
716/// `release.edn`; this function enforces the exact digests it records.
717pub fn verify(root: &Path, native_binary: &Path) -> Result<Manifest, String> {
718    let manifest = read(root)?;
719    let expected_launcher = launcher_path(root, &manifest.launcher);
720    if native_binary != expected_launcher {
721        return Err(format!(
722            "distribution launcher path mismatch: expected {}, received {}",
723            expected_launcher.display(),
724            native_binary.display()
725        ));
726    }
727    check_checksum(native_binary, &manifest.native_sha256, "native launcher")?;
728    if manifest.native_version != env!("CARGO_PKG_VERSION") {
729        return Err(format!(
730            "distribution native version mismatch: manifest {}, launcher {}",
731            manifest.native_version,
732            env!("CARGO_PKG_VERSION")
733        ));
734    }
735    let archive = root.join(&manifest.archive);
736    check_checksum(&archive, &manifest.archive_sha256, "source archive")?;
737    let package = PackageManifest::read_archive(&archive).map_err(|error| error.to_string())?;
738    if package.identity != manifest.source_identity
739        || package.version.to_string() != manifest.source_version
740    {
741        return Err(format!(
742            "distribution source package mismatch: manifest {} {}, archive {} {}",
743            manifest.source_identity, manifest.source_version, package.identity, package.version
744        ));
745    }
746    Ok(manifest)
747}
748
749pub fn read(root: &Path) -> Result<Manifest, String> {
750    let path = root.join(MANIFEST_PATH);
751    let source = fs::read_to_string(&path).map_err(|error| {
752        format!(
753            "cannot read distribution manifest {}: {error}",
754            path.display()
755        )
756    })?;
757    let forms = parse_forms(&source)?;
758    let [Form::Map(entries)] = forms.as_slice() else {
759        return Err("distribution manifest must contain one EDN map".into());
760    };
761    let format = string_field(entries, "distribution/format")?;
762    if format != FORMAT {
763        return Err(format!(
764            "unsupported distribution manifest format: {format}"
765        ));
766    }
767    let launcher = string_field(entries, "launcher")?;
768    if !valid_launcher(&launcher) {
769        return Err("distribution manifest launcher is invalid".into());
770    }
771    let entry = symbol_field(entries, "entry")?;
772    if !valid_entry(&entry) {
773        return Err("distribution manifest entry must name namespace/symbol".into());
774    }
775    let archive = relative_path(
776        &string_field(entries, "archive")?,
777        "distribution manifest archive",
778    )?;
779    let archive_sha256 = checksum_value(&string_field(entries, "archive/sha256")?)?;
780    let source_identity = string_field(entries, "source/identity")?;
781    let source_version = string_field(entries, "source/version")?;
782    let native_version = string_field(entries, "native/version")?;
783    let native_sha256 = checksum_value(&string_field(entries, "native/sha256")?)?;
784    Ok(Manifest {
785        launcher,
786        entry,
787        archive,
788        archive_sha256,
789        source_identity,
790        source_version,
791        native_version,
792        native_sha256,
793    })
794}
795
796impl Manifest {
797    pub fn to_edn(&self) -> String {
798        Form::Map(vec![
799            (
800                Form::Keyword("distribution/format".into()),
801                Form::String(FORMAT.into()),
802            ),
803            (
804                Form::Keyword("launcher".into()),
805                Form::String(self.launcher.clone()),
806            ),
807            (
808                Form::Keyword("entry".into()),
809                Form::Symbol(self.entry.clone()),
810            ),
811            (
812                Form::Keyword("archive".into()),
813                Form::String(self.archive.to_string_lossy().into_owned()),
814            ),
815            (
816                Form::Keyword("archive/sha256".into()),
817                Form::String(self.archive_sha256.clone()),
818            ),
819            (
820                Form::Keyword("source/identity".into()),
821                Form::String(self.source_identity.clone()),
822            ),
823            (
824                Form::Keyword("source/version".into()),
825                Form::String(self.source_version.clone()),
826            ),
827            (
828                Form::Keyword("native/version".into()),
829                Form::String(self.native_version.clone()),
830            ),
831            (
832                Form::Keyword("native/sha256".into()),
833                Form::String(self.native_sha256.clone()),
834            ),
835        ])
836        .to_string()
837    }
838}
839
840fn ensure_empty_output(output: &Path) -> Result<(), String> {
841    if output.exists() {
842        let mut entries = fs::read_dir(output).map_err(io_error)?;
843        if entries.next().is_some() {
844            return Err(format!(
845                "distribution output already exists and is not empty: {}",
846                output.display()
847            ));
848        }
849    } else {
850        fs::create_dir_all(output).map_err(io_error)?;
851    }
852    Ok(())
853}
854
855fn launcher_path(root: &Path, launcher: &str) -> PathBuf {
856    let name = if cfg!(windows) {
857        format!("{launcher}.exe")
858    } else {
859        launcher.into()
860    };
861    root.join("bin").join(name)
862}
863
864fn string_field(entries: &[(Form, Form)], key: &str) -> Result<String, String> {
865    match field(entries, key) {
866        Some(Form::String(value)) => Ok(value.clone()),
867        Some(_) => Err(format!("distribution manifest :{key} must be a string")),
868        None => Err(format!("distribution manifest is missing :{key}")),
869    }
870}
871
872fn symbol_field(entries: &[(Form, Form)], key: &str) -> Result<String, String> {
873    match field(entries, key) {
874        Some(Form::Symbol(value)) => Ok(value.clone()),
875        Some(_) => Err(format!("distribution manifest :{key} must be a symbol")),
876        None => Err(format!("distribution manifest is missing :{key}")),
877    }
878}
879
880fn field<'a>(entries: &'a [(Form, Form)], key: &str) -> Option<&'a Form> {
881    entries
882        .iter()
883        .find_map(|(candidate, value)| match candidate {
884            Form::Keyword(candidate) if candidate == key => Some(value),
885            _ => None,
886        })
887}
888
889fn valid_launcher(value: &str) -> bool {
890    !value.is_empty()
891        && value
892            .chars()
893            .all(|value| value.is_ascii_lowercase() || value.is_ascii_digit() || value == '-')
894}
895
896fn valid_entry(value: &str) -> bool {
897    value.matches('/').count() == 1
898        && value
899            .split_once('/')
900            .is_some_and(|(namespace, symbol)| !namespace.is_empty() && !symbol.is_empty())
901}
902
903fn relative_path(value: &str, label: &str) -> Result<PathBuf, String> {
904    let path = PathBuf::from(value);
905    if path.as_os_str().is_empty()
906        || path.components().any(|component| {
907            matches!(
908                component,
909                Component::ParentDir | Component::RootDir | Component::Prefix(_)
910            )
911        })
912    {
913        return Err(format!("{label} must be a non-empty relative path"));
914    }
915    Ok(path)
916}
917
918fn checksum(path: &Path) -> Result<String, String> {
919    let bytes = fs::read(path).map_err(io_error)?;
920    Ok(format!("sha256:{:x}", Sha256::digest(bytes)))
921}
922
923fn checksum_value(value: &str) -> Result<String, String> {
924    let Some(hex) = value.strip_prefix("sha256:") else {
925        return Err("distribution checksum must use sha256:<lowercase-hex>".into());
926    };
927    if hex.len() != 64
928        || !hex
929            .bytes()
930            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
931    {
932        return Err("distribution checksum must use sha256:<lowercase-hex>".into());
933    }
934    Ok(value.into())
935}
936
937fn check_checksum(path: &Path, expected: &str, label: &str) -> Result<(), String> {
938    let actual = checksum(path)?;
939    if actual != expected {
940        return Err(format!(
941            "distribution {label} digest mismatch: expected {expected}, received {actual}"
942        ));
943    }
944    Ok(())
945}
946
947fn io_error(error: std::io::Error) -> String {
948    error.to_string()
949}
950
951#[cfg(test)]
952mod tests {
953    use super::{
954        build, inspect_sealed, read, seal, verify, verify_sealed, SealArchive, SealSpec,
955        ARCHIVE_PATH, MANIFEST_PATH,
956    };
957    use crate::package;
958    use std::fs;
959    use std::path::PathBuf;
960    use std::time::{SystemTime, UNIX_EPOCH};
961
962    fn temp(name: &str) -> PathBuf {
963        std::env::temp_dir().join(format!(
964            "hara-distribution-{name}-{}",
965            SystemTime::now()
966                .duration_since(UNIX_EPOCH)
967                .unwrap()
968                .as_nanos()
969        ))
970    }
971
972    fn fixture(root: &std::path::Path) {
973        fs::create_dir_all(root.join("src/demo")).unwrap();
974        fs::write(
975            root.join("project.edn"),
976            "{:hara/type :project :hara/version \"1.0.0\" :project/id demo/app :project/version \"1.2.3\" :project/source-paths [\"src\"] :project/test-paths [] :project/extension-paths [] :project/main demo.cli :project/distribution {:launcher \"hara\" :entry demo.cli/main} :project/capabilities #{}}\n",
977        )
978        .unwrap();
979        fs::write(
980            root.join("src/demo/cli.hal"),
981            "(ns demo.cli)\n(defn main [argv] argv)\n",
982        )
983        .unwrap();
984    }
985
986    #[test]
987    fn builds_and_verifies_a_relocatable_source_distribution() {
988        let root = temp("build");
989        let output = root.join("output");
990        let native = root.join("native-host");
991        fixture(&root);
992        fs::write(&native, "native-host").unwrap();
993
994        let manifest = build(&root, &native, &output).unwrap();
995        assert_eq!(manifest.launcher, "hara");
996        assert_eq!(manifest.entry, "demo.cli/main");
997        assert!(output.join(ARCHIVE_PATH).is_file());
998        assert!(output.join(MANIFEST_PATH).is_file());
999        assert_eq!(read(&output).unwrap(), manifest);
1000        assert_eq!(verify(&output, &output.join("bin/hara")).unwrap(), manifest);
1001
1002        fs::write(output.join(ARCHIVE_PATH), "modified").unwrap();
1003        assert!(verify(&output, &output.join("bin/hara"))
1004            .unwrap_err()
1005            .contains("source archive digest mismatch"));
1006        fs::remove_dir_all(root).unwrap();
1007    }
1008
1009    #[test]
1010    fn seals_a_native_host_with_a_verified_harp_payload() {
1011        let root = temp("sealed");
1012        let native = root.join("native-host");
1013        let output = root.join("demo");
1014        fixture(&root);
1015        fs::write(&native, "native-host").unwrap();
1016        let archive = package::build_path(&root, None).unwrap();
1017
1018        let sealed = seal(&SealSpec {
1019            host: native.clone(),
1020            output: output.clone(),
1021            entry: "demo.cli/main".into(),
1022            archives: vec![SealArchive {
1023                path: archive,
1024                primary: true,
1025            }],
1026        })
1027        .unwrap();
1028        assert_eq!(sealed.entry, "demo.cli/main");
1029        assert_eq!(sealed.archives.len(), 1);
1030        assert!(sealed.archives[0].primary);
1031        assert_eq!(inspect_sealed(&native).unwrap(), None);
1032        assert_eq!(inspect_sealed(&output).unwrap(), Some(sealed.clone()));
1033        assert_eq!(verify_sealed(&output).unwrap(), Some(sealed));
1034
1035        let mut tampered = fs::read(&output).unwrap();
1036        tampered[0] ^= 1;
1037        fs::write(&output, tampered).unwrap();
1038        assert!(inspect_sealed(&output)
1039            .unwrap_err()
1040            .contains("host digest mismatch"));
1041        fs::remove_dir_all(root).unwrap();
1042    }
1043}