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::{Read, Seek, SeekFrom, 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_permissions = fs::metadata(&spec.host)
93        .map_err(|error| {
94            format!(
95                "cannot inspect sealed executable host {}: {error}",
96                spec.host.display()
97            )
98        })?
99        .permissions();
100    let source_host = fs::read(&spec.host).map_err(|error| {
101        format!(
102            "cannot read sealed executable host {}: {error}",
103            spec.host.display()
104        )
105    })?;
106    let host = match parse_sealed_bytes(&source_host)? {
107        Some((_, footer)) => source_host[..footer.payload_start].to_vec(),
108        None => source_host,
109    };
110    if host.is_empty() {
111        return Err("sealed executable host has no native bytes".into());
112    }
113
114    let mut identities = HashSet::new();
115    let mut archives = Vec::with_capacity(spec.archives.len());
116    let mut contents = Vec::with_capacity(spec.archives.len());
117    for archive in &spec.archives {
118        let manifest = PackageManifest::read_archive(&archive.path).map_err(|error| {
119            format!(
120                "cannot seal HARP archive {}: {error}",
121                archive.path.display()
122            )
123        })?;
124        if !identities.insert(manifest.identity.clone()) {
125            return Err(format!(
126                "sealed executable declares duplicate package identity: {}",
127                manifest.identity
128            ));
129        }
130        let bytes = fs::read(&archive.path).map_err(|error| {
131            format!(
132                "cannot read sealed HARP archive {}: {error}",
133                archive.path.display()
134            )
135        })?;
136        archives.push(SealedArchive {
137            identity: manifest.identity,
138            version: manifest.version.to_string(),
139            sha256: checksum_bytes(&bytes),
140            offset: 0,
141            length: u64::try_from(bytes.len())
142                .map_err(|_| "sealed archive length exceeds u64".to_owned())?,
143            primary: archive.primary,
144        });
145        contents.push(bytes);
146    }
147
148    let descriptor = sealed_descriptor_fixed_point(&spec.entry, &mut archives)?;
149    let mut payload = Vec::with_capacity(
150        SEALED_PAYLOAD_HEADER_BYTES
151            .checked_add(descriptor.len())
152            .and_then(|value| {
153                contents
154                    .iter()
155                    .try_fold(value, |total, bytes| total.checked_add(bytes.len()))
156            })
157            .ok_or("sealed executable payload is too large")?,
158    );
159    payload.extend_from_slice(
160        &u64::try_from(descriptor.len())
161            .map_err(|_| "sealed executable descriptor is too large")?
162            .to_be_bytes(),
163    );
164    payload.extend_from_slice(&descriptor);
165    for bytes in &contents {
166        payload.extend_from_slice(bytes);
167    }
168
169    let host_sha256 = checksum_bytes(&host);
170    let payload_sha256 = checksum_bytes(&payload);
171    let manifest = SealedManifest {
172        entry: spec.entry.clone(),
173        archives,
174        host_sha256: host_sha256.clone(),
175        payload_sha256: payload_sha256.clone(),
176    };
177    let footer = sealed_footer(
178        host.len(),
179        payload.len(),
180        &checksum_digest(&host),
181        &checksum_digest(&payload),
182    )?;
183    write_sealed_atomically(
184        &spec.output,
185        &host,
186        &payload,
187        &footer,
188        source_host_permissions,
189    )?;
190    Ok(manifest)
191}
192
193/// Returns `None` for an ordinary native executable. A footer with the Hara
194/// magic is never ignored: malformed or tampered sealed binaries fail closed.
195pub fn inspect_sealed(path: &Path) -> Result<Option<SealedManifest>, String> {
196    if !has_sealed_footer(path)? {
197        return Ok(None);
198    }
199    let bytes = fs::read(path)
200        .map_err(|error| format!("cannot read sealed executable {}: {error}", path.display()))?;
201    parse_sealed_bytes(&bytes).map(|found| found.map(|(manifest, _)| manifest))
202}
203
204/// Revalidates a sealed executable's envelope and every embedded archive's
205/// package manifest. It does not mutate the package cache.
206pub fn verify_sealed(path: &Path) -> Result<Option<SealedManifest>, String> {
207    let bytes = fs::read(path)
208        .map_err(|error| format!("cannot read sealed executable {}: {error}", path.display()))?;
209    let Some((manifest, footer)) = parse_sealed_bytes(&bytes)? else {
210        return Ok(None);
211    };
212    let payload_end = footer
213        .payload_start
214        .checked_add(footer.payload_length)
215        .ok_or("sealed executable payload range overflows")?;
216    let payload = &bytes[footer.payload_start..payload_end];
217    for (index, archive) in manifest.archives.iter().enumerate() {
218        let path = temporary_archive_path(archive, index);
219        write_temporary_archive(&path, archive_bytes(payload, archive)?)?;
220        let checked = (|| {
221            let package = PackageManifest::read_archive(&path).map_err(|error| error.to_string())?;
222            verify_embedded_package(archive, &package)
223        })();
224        let _ = fs::remove_file(&path);
225        checked?;
226    }
227    Ok(Some(manifest))
228}
229
230/// Installs a sealed executable's archives into the normal content-addressed
231/// package cache. The only temporary HARP copies are deleted on every path;
232/// the executable itself has no adjacent package files.
233pub fn install_sealed(path: &Path) -> Result<Option<SealedInstallation>, String> {
234    install_sealed_at(path, &package::install_root())
235}
236
237/// Installs a sealed executable into an explicit package-cache root.
238///
239/// A launcher uses a payload-specific root to preserve the immutability of
240/// installed semantic-version registrations across independently rebuilt
241/// executable payloads.
242pub fn install_sealed_at(
243    path: &Path,
244    distribution_root: &Path,
245) -> Result<Option<SealedInstallation>, String> {
246    let bytes = fs::read(path)
247        .map_err(|error| format!("cannot read sealed executable {}: {error}", path.display()))?;
248    let Some((manifest, footer)) = parse_sealed_bytes(&bytes)? else {
249        return Ok(None);
250    };
251    let payload_end = footer
252        .payload_start
253        .checked_add(footer.payload_length)
254        .ok_or("sealed executable payload range overflows")?;
255    let payload = &bytes[footer.payload_start..payload_end];
256    let mut roots = Vec::with_capacity(manifest.archives.len());
257    let mut primary = None;
258    for (index, archive) in manifest.archives.iter().enumerate() {
259        let temporary = temporary_archive_path(archive, index);
260        write_temporary_archive(&temporary, archive_bytes(payload, archive)?)?;
261        let installed = (|| {
262            let package = PackageManifest::read_archive(&temporary).map_err(|error| error.to_string())?;
263            verify_embedded_package(archive, &package)?;
264            package::install_path_at(&temporary, distribution_root)
265        })();
266        let _ = fs::remove_file(&temporary);
267        let installed = installed?;
268        if archive.primary {
269            primary = Some(installed.clone());
270        }
271        roots.push(installed);
272    }
273    Ok(Some(SealedInstallation {
274        manifest,
275        roots,
276        primary: primary.expect("validated sealed manifest has one primary archive"),
277    }))
278}
279
280fn validate_seal_spec(spec: &SealSpec) -> Result<(), String> {
281    if !spec.host.is_file() {
282        return Err(format!(
283            "sealed executable host is not a regular file: {}",
284            spec.host.display()
285        ));
286    }
287    if !valid_entry(&spec.entry) {
288        return Err("sealed executable entry must name namespace/symbol".into());
289    }
290    if spec.archives.is_empty() {
291        return Err("sealed executable requires at least one HARP archive".into());
292    }
293    let primary = spec.archives.iter().filter(|archive| archive.primary).count();
294    if primary != 1 {
295        return Err("sealed executable requires exactly one primary HARP archive".into());
296    }
297    if spec.archives.iter().any(|archive| !archive.path.is_file()) {
298        return Err("sealed executable archives must be regular files".into());
299    }
300    Ok(())
301}
302
303fn sealed_descriptor_fixed_point(
304    entry: &str,
305    archives: &mut [SealedArchive],
306) -> Result<Vec<u8>, String> {
307    let mut descriptor_length = 0usize;
308    for _ in 0..16 {
309        let mut offset = SEALED_PAYLOAD_HEADER_BYTES
310            .checked_add(descriptor_length)
311            .ok_or("sealed executable descriptor is too large")?;
312        for archive in archives.iter_mut() {
313            archive.offset = u64::try_from(offset)
314                .map_err(|_| "sealed executable offset exceeds u64".to_owned())?;
315            offset = offset
316                .checked_add(usize::try_from(archive.length).map_err(|_| {
317                    "sealed executable archive length does not fit this platform".to_owned()
318                })?)
319                .ok_or("sealed executable payload is too large")?;
320        }
321        let descriptor = sealed_descriptor(entry, archives)?;
322        if descriptor.len() == descriptor_length {
323            return Ok(descriptor);
324        }
325        descriptor_length = descriptor.len();
326    }
327    Err("sealed executable descriptor offsets did not converge".into())
328}
329
330fn sealed_descriptor(entry: &str, archives: &[SealedArchive]) -> Result<Vec<u8>, String> {
331    let archives = archives
332        .iter()
333        .map(|archive| {
334            Ok(Form::Map(vec![
335                (Form::Keyword("identity".into()), Form::String(archive.identity.clone())),
336                (Form::Keyword("version".into()), Form::String(archive.version.clone())),
337                (Form::Keyword("sha256".into()), Form::String(archive.sha256.clone())),
338                (
339                    Form::Keyword("offset".into()),
340                    Form::Number(i64::try_from(archive.offset)
341                        .map_err(|_| "sealed executable offset exceeds i64")?),
342                ),
343                (
344                    Form::Keyword("length".into()),
345                    Form::Number(i64::try_from(archive.length)
346                        .map_err(|_| "sealed executable length exceeds i64")?),
347                ),
348                (Form::Keyword("primary".into()), Form::Bool(archive.primary)),
349            ]))
350        })
351        .collect::<Result<Vec<_>, String>>()?;
352    Ok(Form::Map(vec![
353        (
354            Form::Keyword("executable/format".into()),
355            Form::String(SEALED_FORMAT.into()),
356        ),
357        (Form::Keyword("entry".into()), Form::Symbol(entry.into())),
358        (Form::Keyword("archives".into()), Form::Vector(archives)),
359    ])
360    .to_string()
361    .into_bytes())
362}
363
364fn sealed_footer(
365    payload_start: usize,
366    payload_length: usize,
367    host_sha256: &[u8; 32],
368    payload_sha256: &[u8; 32],
369) -> Result<[u8; SEALED_FOOTER_BYTES], String> {
370    let mut footer = [0_u8; SEALED_FOOTER_BYTES];
371    footer[0..8].copy_from_slice(SEALED_MAGIC);
372    footer[8..12].copy_from_slice(&SEALED_VERSION.to_be_bytes());
373    footer[16..24].copy_from_slice(
374        &u64::try_from(payload_start)
375            .map_err(|_| "sealed executable payload start exceeds u64")?
376            .to_be_bytes(),
377    );
378    footer[24..32].copy_from_slice(
379        &u64::try_from(payload_length)
380            .map_err(|_| "sealed executable payload length exceeds u64")?
381            .to_be_bytes(),
382    );
383    footer[32..64].copy_from_slice(host_sha256);
384    footer[64..96].copy_from_slice(payload_sha256);
385    Ok(footer)
386}
387
388fn write_sealed_atomically(
389    output: &Path,
390    host: &[u8],
391    payload: &[u8],
392    footer: &[u8; SEALED_FOOTER_BYTES],
393    permissions: fs::Permissions,
394) -> Result<(), String> {
395    let parent = output
396        .parent()
397        .filter(|path| !path.as_os_str().is_empty())
398        .ok_or("sealed executable output has no parent directory")?;
399    fs::create_dir_all(parent).map_err(io_error)?;
400    let name = output
401        .file_name()
402        .and_then(|name| name.to_str())
403        .ok_or("sealed executable output must have a UTF-8 filename")?;
404    let temporary = parent.join(format!(".{name}.seal-{}", std::process::id()));
405    let result = (|| {
406        let mut file = File::options()
407            .write(true)
408            .create_new(true)
409            .open(&temporary)
410            .map_err(io_error)?;
411        file.write_all(host).map_err(io_error)?;
412        file.write_all(payload).map_err(io_error)?;
413        file.write_all(footer).map_err(io_error)?;
414        file.sync_all().map_err(io_error)?;
415        fs::set_permissions(&temporary, permissions).map_err(io_error)?;
416        fs::rename(&temporary, output).map_err(io_error)
417    })();
418    if result.is_err() {
419        let _ = fs::remove_file(&temporary);
420    }
421    result
422}
423
424fn parse_sealed_bytes(bytes: &[u8]) -> Result<Option<(SealedManifest, SealedFooter)>, String> {
425    let Some(footer) = parse_sealed_footer(bytes)? else {
426        return Ok(None);
427    };
428    let payload_end = footer
429        .payload_start
430        .checked_add(footer.payload_length)
431        .ok_or("sealed executable payload range overflows")?;
432    let payload = &bytes[footer.payload_start..payload_end];
433    if checksum_digest(&bytes[..footer.payload_start]) != footer.host_sha256 {
434        return Err("sealed executable host digest mismatch".into());
435    }
436    if checksum_digest(payload) != footer.payload_sha256 {
437        return Err("sealed executable payload digest mismatch".into());
438    }
439    if payload.len() < SEALED_PAYLOAD_HEADER_BYTES {
440        return Err("sealed executable payload is missing its descriptor length".into());
441    }
442    let descriptor_length = usize::try_from(u64::from_be_bytes(
443        payload[0..SEALED_PAYLOAD_HEADER_BYTES]
444            .try_into()
445            .expect("fixed sealed descriptor header length"),
446    ))
447    .map_err(|_| "sealed executable descriptor length exceeds this platform")?;
448    let descriptor_end = SEALED_PAYLOAD_HEADER_BYTES
449        .checked_add(descriptor_length)
450        .ok_or("sealed executable descriptor range overflows")?;
451    if descriptor_end > payload.len() {
452        return Err("sealed executable descriptor exceeds its payload".into());
453    }
454    let descriptor = std::str::from_utf8(&payload[SEALED_PAYLOAD_HEADER_BYTES..descriptor_end])
455        .map_err(|_| "sealed executable descriptor is not UTF-8")?;
456    let forms = parse_forms(descriptor)?;
457    let [Form::Map(entries)] = forms.as_slice() else {
458        return Err("sealed executable descriptor must contain one EDN map".into());
459    };
460    let mut manifest = sealed_manifest_from_entries(entries)?;
461    manifest.host_sha256 = checksum_bytes(&bytes[..footer.payload_start]);
462    manifest.payload_sha256 = checksum_bytes(payload);
463    validate_sealed_archives(payload, descriptor_end, &manifest.archives)?;
464    Ok(Some((manifest, footer)))
465}
466
467fn parse_sealed_footer(bytes: &[u8]) -> Result<Option<SealedFooter>, String> {
468    if bytes.len() < SEALED_FOOTER_BYTES {
469        return Ok(None);
470    }
471    let start = bytes.len() - SEALED_FOOTER_BYTES;
472    let footer = &bytes[start..];
473    if &footer[0..8] != SEALED_MAGIC {
474        return Ok(None);
475    }
476    let version = u32::from_be_bytes(footer[8..12].try_into().expect("fixed footer version"));
477    if version != SEALED_VERSION {
478        return Err(format!("unsupported sealed executable version: {version}"));
479    }
480    if footer[12..16] != [0, 0, 0, 0] {
481        return Err("sealed executable footer reserved bytes must be zero".into());
482    }
483    let payload_start = usize::try_from(u64::from_be_bytes(
484        footer[16..24].try_into().expect("fixed footer payload start"),
485    ))
486    .map_err(|_| "sealed executable payload start exceeds this platform")?;
487    let payload_length = usize::try_from(u64::from_be_bytes(
488        footer[24..32].try_into().expect("fixed footer payload length"),
489    ))
490    .map_err(|_| "sealed executable payload length exceeds this platform")?;
491    let expected_payload_end = bytes
492        .len()
493        .checked_sub(SEALED_FOOTER_BYTES)
494        .ok_or("sealed executable footer is truncated")?;
495    if payload_start
496        .checked_add(payload_length)
497        .filter(|end| *end == expected_payload_end)
498        .is_none()
499    {
500        return Err("sealed executable payload range does not end before its footer".into());
501    }
502    let mut host_sha256 = [0_u8; 32];
503    host_sha256.copy_from_slice(&footer[32..64]);
504    let mut payload_sha256 = [0_u8; 32];
505    payload_sha256.copy_from_slice(&footer[64..96]);
506    Ok(Some(SealedFooter {
507        payload_start,
508        payload_length,
509        host_sha256,
510        payload_sha256,
511    }))
512}
513
514fn sealed_manifest_from_entries(entries: &[(Form, Form)]) -> Result<SealedManifest, String> {
515    let format = string_field(entries, "executable/format")?;
516    if format != SEALED_FORMAT {
517        return Err(format!("unsupported sealed executable format: {format}"));
518    }
519    let entry = symbol_field(entries, "entry")?;
520    if !valid_entry(&entry) {
521        return Err("sealed executable entry must name namespace/symbol".into());
522    }
523    let values = match field(entries, "archives") {
524        Some(Form::Vector(values)) if !values.is_empty() => values,
525        Some(Form::Vector(_)) => return Err("sealed executable has no archives".into()),
526        Some(_) => return Err("sealed executable :archives must be a vector".into()),
527        None => return Err("sealed executable is missing :archives".into()),
528    };
529    let archives = values
530        .iter()
531        .map(sealed_archive_from_form)
532        .collect::<Result<Vec<_>, _>>()?;
533    let primary = archives.iter().filter(|archive| archive.primary).count();
534    if primary != 1 {
535        return Err("sealed executable requires exactly one primary archive".into());
536    }
537    let mut identities = HashSet::new();
538    for archive in &archives {
539        if !identities.insert(archive.identity.clone()) {
540            return Err(format!(
541                "sealed executable declares duplicate package identity: {}",
542                archive.identity
543            ));
544        }
545    }
546    Ok(SealedManifest {
547        entry,
548        archives,
549        host_sha256: String::new(),
550        payload_sha256: String::new(),
551    })
552}
553
554fn sealed_archive_from_form(value: &Form) -> Result<SealedArchive, String> {
555    let Form::Map(entries) = value else {
556        return Err("sealed executable archive must be an EDN map".into());
557    };
558    let identity = string_field(entries, "identity")?;
559    let version = string_field(entries, "version")?;
560    let sha256 = checksum_value(&string_field(entries, "sha256")?)?;
561    let offset = non_negative_number_field(entries, "offset")?;
562    let length = non_negative_number_field(entries, "length")?;
563    if length == 0 {
564        return Err("sealed executable archive length must be positive".into());
565    }
566    let primary = match field(entries, "primary") {
567        Some(Form::Bool(value)) => *value,
568        Some(_) => return Err("sealed executable archive :primary must be boolean".into()),
569        None => return Err("sealed executable archive is missing :primary".into()),
570    };
571    Ok(SealedArchive {
572        identity,
573        version,
574        sha256,
575        offset,
576        length,
577        primary,
578    })
579}
580
581fn non_negative_number_field(entries: &[(Form, Form)], key: &str) -> Result<u64, String> {
582    match field(entries, key) {
583        Some(Form::Number(value)) if *value >= 0 => Ok(*value as u64),
584        Some(Form::Number(_)) => Err(format!("sealed executable :{key} must be non-negative")),
585        Some(_) => Err(format!("sealed executable :{key} must be an integer")),
586        None => Err(format!("sealed executable archive is missing :{key}")),
587    }
588}
589
590fn validate_sealed_archives(
591    payload: &[u8],
592    descriptor_end: usize,
593    archives: &[SealedArchive],
594) -> Result<(), String> {
595    let mut previous_end = descriptor_end;
596    for archive in archives {
597        let offset = usize::try_from(archive.offset)
598            .map_err(|_| "sealed executable archive offset exceeds this platform")?;
599        let length = usize::try_from(archive.length)
600            .map_err(|_| "sealed executable archive length exceeds this platform")?;
601        if offset != previous_end {
602            return Err("sealed executable archives must be contiguous and ordered".into());
603        }
604        let end = offset
605            .checked_add(length)
606            .ok_or("sealed executable archive range overflows")?;
607        if end > payload.len() {
608            return Err("sealed executable archive exceeds its payload".into());
609        }
610        if checksum_bytes(&payload[offset..end]) != archive.sha256 {
611            return Err(format!(
612                "sealed executable archive digest mismatch: {}",
613                archive.identity
614            ));
615        }
616        previous_end = end;
617    }
618    if previous_end != payload.len() {
619        return Err("sealed executable payload has unclaimed trailing bytes".into());
620    }
621    Ok(())
622}
623
624fn archive_bytes<'a>(payload: &'a [u8], archive: &SealedArchive) -> Result<&'a [u8], String> {
625    let offset = usize::try_from(archive.offset)
626        .map_err(|_| "sealed executable archive offset exceeds this platform")?;
627    let length = usize::try_from(archive.length)
628        .map_err(|_| "sealed executable archive length exceeds this platform")?;
629    let end = offset
630        .checked_add(length)
631        .ok_or("sealed executable archive range overflows")?;
632    payload
633        .get(offset..end)
634        .ok_or_else(|| "sealed executable archive exceeds its payload".into())
635}
636
637fn temporary_archive_path(archive: &SealedArchive, index: usize) -> PathBuf {
638    let digest = archive.sha256.trim_start_matches("sha256:");
639    std::env::temp_dir().join(format!(
640        "hara-sealed-{}-{index}-{}.harp",
641        std::process::id(),
642        &digest[..12]
643    ))
644}
645
646fn write_temporary_archive(path: &Path, bytes: &[u8]) -> Result<(), String> {
647    let mut file = File::options()
648        .write(true)
649        .create_new(true)
650        .open(path)
651        .map_err(|error| format!("cannot create sealed archive temporary {}: {error}", path.display()))?;
652    let result = file.write_all(bytes).map_err(io_error);
653    if result.is_err() {
654        let _ = fs::remove_file(path);
655    }
656    result
657}
658
659fn verify_embedded_package(
660    archive: &SealedArchive,
661    package: &PackageManifest,
662) -> Result<(), String> {
663    if package.identity != archive.identity || package.version.to_string() != archive.version {
664        return Err(format!(
665            "sealed executable archive identity mismatch: expected {} {}, received {} {}",
666            archive.identity, archive.version, package.identity, package.version
667        ));
668    }
669    Ok(())
670}
671
672fn checksum_digest(bytes: &[u8]) -> [u8; 32] {
673    Sha256::digest(bytes).into()
674}
675
676fn checksum_bytes(bytes: &[u8]) -> String {
677    let digest = checksum_digest(bytes);
678    format!(
679        "sha256:{}",
680        digest
681            .iter()
682            .map(|byte| format!("{byte:02x}"))
683            .collect::<String>()
684    )
685}
686
687#[derive(Debug, Clone, PartialEq, Eq)]
688pub struct Manifest {
689    pub launcher: String,
690    pub entry: String,
691    pub archive: PathBuf,
692    pub archive_sha256: String,
693    pub source_identity: String,
694    pub source_version: String,
695    pub native_version: String,
696    pub native_sha256: String,
697}
698
699/// Builds a directory that can be relocated as one unit. The caller supplies
700/// the native executable that is copied as the launcher; the package is built
701/// from the declared project and verified again before this function succeeds.
702pub fn build(project_path: &Path, native_binary: &Path, output: &Path) -> Result<Manifest, String> {
703    build_with_options(project_path, native_binary, output, false)
704}
705
706/// Rebuilds a known, integrity-checked Hara distribution in place.
707///
708/// An unrelated non-empty directory is never removed. This lets a project
709/// refresh its generated companion launcher without making the output path a
710/// broad deletion capability.
711pub fn build_replace(
712    project_path: &Path,
713    native_binary: &Path,
714    output: &Path,
715) -> Result<Manifest, String> {
716    build_with_options(project_path, native_binary, output, true)
717}
718
719fn build_with_options(
720    project_path: &Path,
721    native_binary: &Path,
722    output: &Path,
723    replace: bool,
724) -> Result<Manifest, String> {
725    let project = project::read(project_path)?;
726    let declaration = project.distribution.as_ref().ok_or_else(|| {
727        "project.edn :project/distribution is required for distribution build".to_owned()
728    })?;
729    validate_output(output, replace)?;
730    if !native_binary.is_file() {
731        return Err(format!(
732            "distribution native binary is not a regular file: {}",
733            native_binary.display()
734        ));
735    }
736
737    let staging = create_staging_output(output)?;
738    let archive = staging.join(ARCHIVE_PATH);
739    let launcher = launcher_path(&staging, &declaration.launcher);
740    let archive_parent = archive
741        .parent()
742        .ok_or_else(|| "distribution archive path has no parent".to_owned())?;
743    let launcher_parent = launcher
744        .parent()
745        .ok_or_else(|| "distribution launcher path has no parent".to_owned())?;
746    let build = (|| {
747        fs::create_dir_all(archive_parent).map_err(io_error)?;
748        fs::create_dir_all(launcher_parent).map_err(io_error)?;
749        package::build_path(&project.root, Some(&archive))?;
750        fs::copy(native_binary, &launcher).map_err(io_error)?;
751
752        let package = PackageManifest::read_archive(&archive).map_err(|error| error.to_string())?;
753        let manifest = Manifest {
754            launcher: declaration.launcher.clone(),
755            entry: declaration.entry.clone(),
756            archive: PathBuf::from(ARCHIVE_PATH),
757            archive_sha256: checksum(&archive)?,
758            source_identity: package.identity,
759            source_version: package.version.to_string(),
760            native_version: env!("CARGO_PKG_VERSION").into(),
761            native_sha256: checksum(&launcher)?,
762        };
763        let manifest_path = staging.join(MANIFEST_PATH);
764        fs::write(&manifest_path, format!("{}\n", manifest.to_edn())).map_err(io_error)?;
765        verify(&staging, &launcher)?;
766        Ok(manifest)
767    })();
768    match build {
769        Ok(manifest) => {
770            publish_output(&staging, output, replace)?;
771            verify(output, &launcher_path(output, &manifest.launcher))?;
772            Ok(manifest)
773        }
774        Err(error) => {
775            let _ = fs::remove_dir_all(&staging);
776            Err(error)
777        }
778    }
779}
780
781/// Reads and verifies the local companion contract before a host loads any
782/// source from its HARP archive. A release archive's signature authenticates
783/// `release.edn`; this function enforces the exact digests it records.
784pub fn verify(root: &Path, native_binary: &Path) -> Result<Manifest, String> {
785    let manifest = read(root)?;
786    let expected_launcher = launcher_path(root, &manifest.launcher);
787    if native_binary != expected_launcher {
788        return Err(format!(
789            "distribution launcher path mismatch: expected {}, received {}",
790            expected_launcher.display(),
791            native_binary.display()
792        ));
793    }
794    verify_distribution_contents(root, &manifest)?;
795    if manifest.native_version != env!("CARGO_PKG_VERSION") {
796        return Err(format!(
797            "distribution native version mismatch: manifest {}, launcher {}",
798            manifest.native_version,
799            env!("CARGO_PKG_VERSION")
800        ));
801    }
802    Ok(manifest)
803}
804
805fn verify_distribution_contents(root: &Path, manifest: &Manifest) -> Result<(), String> {
806    let launcher = launcher_path(root, &manifest.launcher);
807    check_checksum(&launcher, &manifest.native_sha256, "native launcher")?;
808    let archive = root.join(&manifest.archive);
809    check_checksum(&archive, &manifest.archive_sha256, "source archive")?;
810    let package = PackageManifest::read_archive(&archive).map_err(|error| error.to_string())?;
811    if package.identity != manifest.source_identity
812        || package.version.to_string() != manifest.source_version
813    {
814        return Err(format!(
815            "distribution source package mismatch: manifest {} {}, archive {} {}",
816            manifest.source_identity, manifest.source_version, package.identity, package.version
817        ));
818    }
819    Ok(())
820}
821
822pub fn read(root: &Path) -> Result<Manifest, String> {
823    let path = root.join(MANIFEST_PATH);
824    let source = fs::read_to_string(&path).map_err(|error| {
825        format!(
826            "cannot read distribution manifest {}: {error}",
827            path.display()
828        )
829    })?;
830    let forms = parse_forms(&source)?;
831    let [Form::Map(entries)] = forms.as_slice() else {
832        return Err("distribution manifest must contain one EDN map".into());
833    };
834    let format = string_field(entries, "distribution/format")?;
835    if format != FORMAT {
836        return Err(format!(
837            "unsupported distribution manifest format: {format}"
838        ));
839    }
840    let launcher = string_field(entries, "launcher")?;
841    if !valid_launcher(&launcher) {
842        return Err("distribution manifest launcher is invalid".into());
843    }
844    let entry = symbol_field(entries, "entry")?;
845    if !valid_entry(&entry) {
846        return Err("distribution manifest entry must name namespace/symbol".into());
847    }
848    let archive = relative_path(
849        &string_field(entries, "archive")?,
850        "distribution manifest archive",
851    )?;
852    let archive_sha256 = checksum_value(&string_field(entries, "archive/sha256")?)?;
853    let source_identity = string_field(entries, "source/identity")?;
854    let source_version = string_field(entries, "source/version")?;
855    let native_version = string_field(entries, "native/version")?;
856    let native_sha256 = checksum_value(&string_field(entries, "native/sha256")?)?;
857    Ok(Manifest {
858        launcher,
859        entry,
860        archive,
861        archive_sha256,
862        source_identity,
863        source_version,
864        native_version,
865        native_sha256,
866    })
867}
868
869impl Manifest {
870    pub fn to_edn(&self) -> String {
871        Form::Map(vec![
872            (
873                Form::Keyword("distribution/format".into()),
874                Form::String(FORMAT.into()),
875            ),
876            (
877                Form::Keyword("launcher".into()),
878                Form::String(self.launcher.clone()),
879            ),
880            (
881                Form::Keyword("entry".into()),
882                Form::Symbol(self.entry.clone()),
883            ),
884            (
885                Form::Keyword("archive".into()),
886                Form::String(self.archive.to_string_lossy().into_owned()),
887            ),
888            (
889                Form::Keyword("archive/sha256".into()),
890                Form::String(self.archive_sha256.clone()),
891            ),
892            (
893                Form::Keyword("source/identity".into()),
894                Form::String(self.source_identity.clone()),
895            ),
896            (
897                Form::Keyword("source/version".into()),
898                Form::String(self.source_version.clone()),
899            ),
900            (
901                Form::Keyword("native/version".into()),
902                Form::String(self.native_version.clone()),
903            ),
904            (
905                Form::Keyword("native/sha256".into()),
906                Form::String(self.native_sha256.clone()),
907            ),
908        ])
909        .to_string()
910    }
911}
912
913fn validate_output(output: &Path, replace: bool) -> Result<(), String> {
914    if !output.exists() {
915        return Ok(());
916    }
917    let metadata = fs::symlink_metadata(output).map_err(io_error)?;
918    if metadata.file_type().is_symlink() || !metadata.is_dir() {
919        return Err(format!(
920            "distribution output must be a real directory: {}",
921            output.display()
922        ));
923    }
924    let mut entries = fs::read_dir(output).map_err(io_error)?;
925    if entries.next().is_none() {
926        return Ok(());
927    }
928    if !replace {
929        return Err(format!(
930            "distribution output already exists and is not empty: {}",
931            output.display()
932        ));
933    }
934    let manifest = read(output).map_err(|_| {
935        format!(
936            "distribution replace output is not a verified Hara distribution: {}",
937            output.display()
938        )
939    })?;
940    verify_distribution_contents(output, &manifest).map_err(|_| {
941        format!(
942            "distribution replace output is not a verified Hara distribution: {}",
943            output.display()
944        )
945    })
946}
947
948fn create_staging_output(output: &Path) -> Result<PathBuf, String> {
949    let parent = output
950        .parent()
951        .filter(|path| !path.as_os_str().is_empty())
952        .unwrap_or_else(|| Path::new("."));
953    let name = output
954        .file_name()
955        .ok_or_else(|| format!("distribution output must name a directory: {}", output.display()))?
956        .to_string_lossy();
957    fs::create_dir_all(parent).map_err(io_error)?;
958    for index in 0..1024 {
959        let candidate = parent.join(format!(".{name}.staging-{}-{index}", std::process::id()));
960        match fs::create_dir(&candidate) {
961            Ok(()) => return Ok(candidate),
962            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
963            Err(error) => return Err(io_error(error)),
964        }
965    }
966    Err(format!(
967        "cannot allocate a distribution staging directory beside {}",
968        output.display()
969    ))
970}
971
972fn publish_output(staging: &Path, output: &Path, replace: bool) -> Result<(), String> {
973    if output.exists() {
974        validate_output(output, replace)?;
975        fs::remove_dir_all(output).map_err(io_error)?;
976    }
977    fs::rename(staging, output).map_err(io_error)
978}
979
980fn has_sealed_footer(path: &Path) -> Result<bool, String> {
981    let metadata = fs::metadata(path)
982        .map_err(|error| format!("cannot inspect sealed executable {}: {error}", path.display()))?;
983    if metadata.len() < SEALED_FOOTER_BYTES as u64 {
984        return Ok(false);
985    }
986    let mut file = File::open(path)
987        .map_err(|error| format!("cannot read sealed executable {}: {error}", path.display()))?;
988    file.seek(SeekFrom::End(-(SEALED_FOOTER_BYTES as i64)))
989        .map_err(|error| format!("cannot seek sealed executable {}: {error}", path.display()))?;
990    let mut footer = [0_u8; SEALED_FOOTER_BYTES];
991    file.read_exact(&mut footer)
992        .map_err(|error| format!("cannot read sealed executable footer {}: {error}", path.display()))?;
993    Ok(footer[..SEALED_MAGIC.len()] == SEALED_MAGIC[..])
994}
995
996fn launcher_path(root: &Path, launcher: &str) -> PathBuf {
997    let name = if cfg!(windows) {
998        format!("{launcher}.exe")
999    } else {
1000        launcher.into()
1001    };
1002    root.join("bin").join(name)
1003}
1004
1005fn string_field(entries: &[(Form, Form)], key: &str) -> Result<String, String> {
1006    match field(entries, key) {
1007        Some(Form::String(value)) => Ok(value.clone()),
1008        Some(_) => Err(format!("distribution manifest :{key} must be a string")),
1009        None => Err(format!("distribution manifest is missing :{key}")),
1010    }
1011}
1012
1013fn symbol_field(entries: &[(Form, Form)], key: &str) -> Result<String, String> {
1014    match field(entries, key) {
1015        Some(Form::Symbol(value)) => Ok(value.clone()),
1016        Some(_) => Err(format!("distribution manifest :{key} must be a symbol")),
1017        None => Err(format!("distribution manifest is missing :{key}")),
1018    }
1019}
1020
1021fn field<'a>(entries: &'a [(Form, Form)], key: &str) -> Option<&'a Form> {
1022    entries
1023        .iter()
1024        .find_map(|(candidate, value)| match candidate {
1025            Form::Keyword(candidate) if candidate == key => Some(value),
1026            _ => None,
1027        })
1028}
1029
1030fn valid_launcher(value: &str) -> bool {
1031    !value.is_empty()
1032        && value
1033            .chars()
1034            .all(|value| value.is_ascii_lowercase() || value.is_ascii_digit() || value == '-')
1035}
1036
1037fn valid_entry(value: &str) -> bool {
1038    value.matches('/').count() == 1
1039        && value
1040            .split_once('/')
1041            .is_some_and(|(namespace, symbol)| !namespace.is_empty() && !symbol.is_empty())
1042}
1043
1044fn relative_path(value: &str, label: &str) -> Result<PathBuf, String> {
1045    let path = PathBuf::from(value);
1046    if path.as_os_str().is_empty()
1047        || path.components().any(|component| {
1048            matches!(
1049                component,
1050                Component::ParentDir | Component::RootDir | Component::Prefix(_)
1051            )
1052        })
1053    {
1054        return Err(format!("{label} must be a non-empty relative path"));
1055    }
1056    Ok(path)
1057}
1058
1059fn checksum(path: &Path) -> Result<String, String> {
1060    let bytes = fs::read(path).map_err(io_error)?;
1061    Ok(format!("sha256:{:x}", Sha256::digest(bytes)))
1062}
1063
1064fn checksum_value(value: &str) -> Result<String, String> {
1065    let Some(hex) = value.strip_prefix("sha256:") else {
1066        return Err("distribution checksum must use sha256:<lowercase-hex>".into());
1067    };
1068    if hex.len() != 64
1069        || !hex
1070            .bytes()
1071            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1072    {
1073        return Err("distribution checksum must use sha256:<lowercase-hex>".into());
1074    }
1075    Ok(value.into())
1076}
1077
1078fn check_checksum(path: &Path, expected: &str, label: &str) -> Result<(), String> {
1079    let actual = checksum(path)?;
1080    if actual != expected {
1081        return Err(format!(
1082            "distribution {label} digest mismatch: expected {expected}, received {actual}"
1083        ));
1084    }
1085    Ok(())
1086}
1087
1088fn io_error(error: std::io::Error) -> String {
1089    error.to_string()
1090}
1091
1092#[cfg(test)]
1093mod tests {
1094    use super::{
1095        build, build_replace, has_sealed_footer, inspect_sealed, install_sealed_at, read, seal,
1096        verify, verify_sealed, SealArchive, SealSpec, ARCHIVE_PATH, MANIFEST_PATH,
1097    };
1098    use crate::package;
1099    use std::fs;
1100    use std::path::PathBuf;
1101    use std::time::{SystemTime, UNIX_EPOCH};
1102
1103    fn temp(name: &str) -> PathBuf {
1104        std::env::temp_dir().join(format!(
1105            "hara-distribution-{name}-{}",
1106            SystemTime::now()
1107                .duration_since(UNIX_EPOCH)
1108                .unwrap()
1109                .as_nanos()
1110        ))
1111    }
1112
1113    fn fixture(root: &std::path::Path) {
1114        fs::create_dir_all(root.join("src/demo")).unwrap();
1115        fs::write(
1116            root.join("project.edn"),
1117            "{: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",
1118        )
1119        .unwrap();
1120        fs::write(
1121            root.join("src/demo/cli.hal"),
1122            "(ns demo.cli)\n(defn main [argv] argv)\n",
1123        )
1124        .unwrap();
1125    }
1126
1127    #[test]
1128    fn builds_and_verifies_a_relocatable_source_distribution() {
1129        let root = temp("build");
1130        let output = root.join("output");
1131        let native = root.join("native-host");
1132        fixture(&root);
1133        fs::write(&native, "native-host").unwrap();
1134
1135        let manifest = build(&root, &native, &output).unwrap();
1136        assert_eq!(manifest.launcher, "hara");
1137        assert_eq!(manifest.entry, "demo.cli/main");
1138        assert!(output.join(ARCHIVE_PATH).is_file());
1139        assert!(output.join(MANIFEST_PATH).is_file());
1140        assert_eq!(read(&output).unwrap(), manifest);
1141        assert_eq!(verify(&output, &output.join("bin/hara")).unwrap(), manifest);
1142
1143        fs::write(output.join(ARCHIVE_PATH), "modified").unwrap();
1144        assert!(verify(&output, &output.join("bin/hara"))
1145            .unwrap_err()
1146            .contains("source archive digest mismatch"));
1147        fs::remove_dir_all(root).unwrap();
1148    }
1149
1150    #[test]
1151    fn replaces_only_an_existing_verified_distribution() {
1152        let root = temp("replace");
1153        let output = root.join("output");
1154        let unrelated = root.join("unrelated");
1155        let native = root.join("native-host");
1156        fixture(&root);
1157        fs::write(&native, "native-host").unwrap();
1158
1159        build(&root, &native, &output).unwrap();
1160        let rebuilt = build_replace(&root, &native, &output).unwrap();
1161        assert_eq!(rebuilt.entry, "demo.cli/main");
1162
1163        fs::create_dir_all(&unrelated).unwrap();
1164        fs::write(unrelated.join("notes.txt"), "do not remove").unwrap();
1165        assert!(build_replace(&root, &native, &unrelated)
1166            .unwrap_err()
1167            .contains("not a verified Hara distribution"));
1168        assert_eq!(fs::read_to_string(unrelated.join("notes.txt")).unwrap(), "do not remove");
1169        fs::remove_dir_all(root).unwrap();
1170    }
1171
1172    #[test]
1173    fn seals_a_native_host_with_a_verified_harp_payload() {
1174        let root = temp("sealed");
1175        let native = root.join("native-host");
1176        let output = root.join("demo");
1177        fixture(&root);
1178        fs::write(&native, "native-host").unwrap();
1179        let archive = package::build_path(&root, None).unwrap();
1180
1181        let sealed = seal(&SealSpec {
1182            host: native.clone(),
1183            output: output.clone(),
1184            entry: "demo.cli/main".into(),
1185            archives: vec![SealArchive {
1186                path: archive,
1187                primary: true,
1188            }],
1189        })
1190        .unwrap();
1191        assert_eq!(sealed.entry, "demo.cli/main");
1192        assert_eq!(sealed.archives.len(), 1);
1193        assert!(sealed.archives[0].primary);
1194        assert!(!has_sealed_footer(&native).unwrap());
1195        assert_eq!(inspect_sealed(&native).unwrap(), None);
1196        assert_eq!(inspect_sealed(&output).unwrap(), Some(sealed.clone()));
1197        assert_eq!(verify_sealed(&output).unwrap(), Some(sealed));
1198        let installation = install_sealed_at(&output, &root.join("store"))
1199            .unwrap()
1200            .unwrap();
1201        assert!(installation.primary.starts_with(root.join("store")));
1202
1203        let mut tampered = fs::read(&output).unwrap();
1204        tampered[0] ^= 1;
1205        fs::write(&output, tampered).unwrap();
1206        assert!(inspect_sealed(&output)
1207            .unwrap_err()
1208            .contains("host digest mismatch"));
1209        fs::remove_dir_all(root).unwrap();
1210    }
1211}