1use 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
21pub 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 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
79pub 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
179pub 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
187pub 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
213pub 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
668pub 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
714pub 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}