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::{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
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_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
193pub 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
204pub 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
230pub fn install_sealed(path: &Path) -> Result<Option<SealedInstallation>, String> {
234 install_sealed_at(path, &package::install_root())
235}
236
237pub 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
699pub 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
706pub 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
781pub 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}