1use serde::de::DeserializeOwned;
23use serde::{Deserialize, Serialize};
24use std::path::{Path, PathBuf};
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct PackageManifest<M> {
33 pub package: PackageHeader,
35 pub metadata: M,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct PackageHeader {
42 pub name: String,
44 pub version: String,
46 pub interface: String,
48 pub interface_version: u32,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub extension: Option<String>,
54}
55
56impl PackageHeader {
57 pub fn extension(&self) -> &str {
59 self.extension.as_deref().unwrap_or("fid")
60 }
61}
62
63#[derive(Debug, thiserror::Error)]
65pub enum PackageError {
66 #[error("package.toml not found in {path}")]
68 ManifestNotFound { path: String },
69
70 #[error("failed to parse package.toml: {0}")]
73 ParseError(#[from] toml::de::Error),
74
75 #[error("io error reading package.toml: {0}")]
77 Io(#[from] std::io::Error),
78
79 #[error("package build failed: {0}")]
81 BuildFailed(String),
82
83 #[error("package.sig not found in {path}")]
85 SignatureNotFound { path: String },
86
87 #[error("package signature invalid for {path}")]
89 SignatureInvalid { path: String },
90
91 #[error("archive error: {0}")]
93 ArchiveError(String),
94
95 #[error("invalid archive: {0}")]
97 InvalidArchive(String),
98}
99
100pub fn load_manifest<M: DeserializeOwned>(dir: &Path) -> Result<PackageManifest<M>, PackageError> {
119 let manifest_path = dir.join("package.toml");
120
121 if !manifest_path.exists() {
122 return Err(PackageError::ManifestNotFound {
123 path: dir.display().to_string(),
124 });
125 }
126
127 let content = std::fs::read_to_string(&manifest_path)?;
128 let manifest: PackageManifest<M> = toml::from_str(&content)?;
129 Ok(manifest)
130}
131
132pub fn load_manifest_untyped(dir: &Path) -> Result<PackageManifest<toml::Value>, PackageError> {
137 load_manifest::<toml::Value>(dir)
138}
139
140pub fn package_digest(dir: &Path) -> Result<[u8; 32], PackageError> {
149 use sha2::{Digest, Sha256};
150
151 let mut files = Vec::new();
152 collect_files(dir, dir, &mut files)?;
153 files.sort();
154
155 let mut hasher = Sha256::new();
156 for rel_path in &files {
157 let abs_path = dir.join(rel_path);
158 let contents = std::fs::read(&abs_path)?;
159 let path_bytes = rel_path.as_bytes();
162 hasher.update((path_bytes.len() as u64).to_le_bytes());
163 hasher.update(path_bytes);
164 hasher.update((contents.len() as u64).to_le_bytes());
165 hasher.update(&contents);
166 }
167
168 Ok(hasher.finalize().into())
169}
170
171fn collect_files(root: &Path, dir: &Path, out: &mut Vec<String>) -> Result<(), PackageError> {
173 let entries = std::fs::read_dir(dir)?;
174 for entry in entries {
175 let entry = entry?;
176 let path = entry.path();
177 let name = entry.file_name();
178 let name_str = name.to_string_lossy();
179
180 if path.is_dir() {
182 if name_str == "target" || name_str == ".git" {
183 continue;
184 }
185 collect_files(root, &path, out)?;
186 continue;
187 }
188
189 if name_str.ends_with(".sig") {
191 continue;
192 }
193
194 let rel = path
196 .strip_prefix(root)
197 .expect("path is under root")
198 .to_string_lossy()
199 .replace('\\', "/");
200 out.push(rel);
201 }
202 Ok(())
203}
204
205fn collect_archive_files(
207 root: &Path,
208 dir: &Path,
209 out: &mut Vec<String>,
210) -> Result<(), PackageError> {
211 let entries = std::fs::read_dir(dir)?;
212 for entry in entries {
213 let entry = entry?;
214 let path = entry.path();
215 let name = entry.file_name();
216 let name_str = name.to_string_lossy();
217
218 if path.is_dir() {
219 if name_str == "target" || name_str == ".git" {
220 continue;
221 }
222 collect_archive_files(root, &path, out)?;
223 continue;
224 }
225
226 let rel = path
227 .strip_prefix(root)
228 .expect("path is under root")
229 .to_string_lossy()
230 .replace('\\', "/");
231 out.push(rel);
232 }
233 Ok(())
234}
235
236#[derive(Debug)]
238pub struct PackResult {
239 pub path: PathBuf,
241 pub unsigned: bool,
243}
244
245pub fn pack_package(dir: &Path, output: Option<&Path>) -> Result<PackResult, PackageError> {
254 use bzip2::write::BzEncoder;
255 use bzip2::Compression;
256
257 let manifest = load_manifest_untyped(dir)?;
258 let pkg = &manifest.package;
259 let prefix = format!("{}-{}", pkg.name, pkg.version);
260 let ext = pkg.extension();
261
262 let unsigned = !dir.join("package.sig").exists();
263
264 let out_path = match output {
265 Some(p) => p.to_path_buf(),
266 None => PathBuf::from(format!("{prefix}.{ext}")),
267 };
268
269 let file = std::fs::File::create(&out_path).map_err(|e| {
270 PackageError::ArchiveError(format!("failed to create {}: {e}", out_path.display()))
271 })?;
272
273 let encoder = BzEncoder::new(file, Compression::best());
274 let mut tar = tar::Builder::new(encoder);
275
276 let mut files = Vec::new();
277 collect_archive_files(dir, dir, &mut files)?;
278 files.sort();
279
280 for rel_path in &files {
281 let abs_path = dir.join(rel_path);
282 let archive_path = format!("{prefix}/{rel_path}");
283 tar.append_path_with_name(&abs_path, &archive_path)
284 .map_err(|e| PackageError::ArchiveError(format!("failed to add {rel_path}: {e}")))?;
285 }
286
287 tar.into_inner()
288 .map_err(|e| PackageError::ArchiveError(format!("failed to finish bz2 stream: {e}")))?
289 .finish()
290 .map_err(|e| PackageError::ArchiveError(format!("failed to finish bz2 stream: {e}")))?;
291
292 Ok(PackResult {
293 path: out_path,
294 unsigned,
295 })
296}
297
298pub fn unpack_package(archive: &Path, dest: &Path) -> Result<PathBuf, PackageError> {
303 use bzip2::read::BzDecoder;
304
305 let file = std::fs::File::open(archive).map_err(|e| {
306 PackageError::ArchiveError(format!("failed to open {}: {e}", archive.display()))
307 })?;
308
309 let decoder = BzDecoder::new(file);
310 let mut tar = tar::Archive::new(decoder);
311
312 tar.unpack(dest).map_err(|e| {
313 PackageError::ArchiveError(format!("failed to extract {}: {e}", archive.display()))
314 })?;
315
316 let entries = std::fs::read_dir(dest).map_err(PackageError::Io)?;
318 let mut pkg_dir: Option<PathBuf> = None;
319 for entry in entries {
320 let entry = entry.map_err(PackageError::Io)?;
321 let path = entry.path();
322 if path.is_dir() && path.join("package.toml").exists() {
323 pkg_dir = Some(path);
324 break;
325 }
326 }
327
328 let pkg_dir = pkg_dir.ok_or_else(|| {
329 PackageError::InvalidArchive("archive does not contain a package.toml".to_string())
330 })?;
331
332 Ok(pkg_dir)
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338 use tempfile::TempDir;
339
340 fn write_manifest(dir: &Path, content: &str) {
341 std::fs::write(dir.join("package.toml"), content).unwrap();
342 }
343
344 #[derive(Debug, Deserialize, PartialEq)]
345 struct TestMeta {
346 category: String,
347 #[serde(default)]
348 tags: Vec<String>,
349 }
350
351 #[test]
352 fn valid_manifest_parses() {
353 let tmp = TempDir::new().unwrap();
354 write_manifest(
355 tmp.path(),
356 r#"
357 [package]
358 name = "test-pkg"
359 version = "1.0.0"
360 interface = "my-api"
361 interface_version = 1
362
363 [metadata]
364 category = "testing"
365 tags = ["a", "b"]
366 "#,
367 );
368
369 let m = load_manifest::<TestMeta>(tmp.path()).unwrap();
370 assert_eq!(m.package.name, "test-pkg");
371 assert_eq!(m.package.version, "1.0.0");
372 assert_eq!(m.package.interface, "my-api");
373 assert_eq!(m.package.interface_version, 1);
374 assert_eq!(m.metadata.category, "testing");
375 assert_eq!(m.metadata.tags, vec!["a", "b"]);
376 }
377
378 #[test]
379 fn missing_required_metadata_field_fails() {
380 let tmp = TempDir::new().unwrap();
381 write_manifest(
382 tmp.path(),
383 r#"
384 [package]
385 name = "bad-pkg"
386 version = "1.0.0"
387 interface = "my-api"
388 interface_version = 1
389
390 [metadata]
391 # missing required "category" field
392 tags = ["x"]
393 "#,
394 );
395
396 let result = load_manifest::<TestMeta>(tmp.path());
397 assert!(result.is_err());
398 let err = result.unwrap_err().to_string();
399 assert!(
400 err.contains("category"),
401 "error should mention missing field: {err}"
402 );
403 }
404
405 #[test]
406 fn missing_manifest_returns_not_found() {
407 let tmp = TempDir::new().unwrap();
408 let result = load_manifest::<TestMeta>(tmp.path());
409 assert!(matches!(result, Err(PackageError::ManifestNotFound { .. })));
410 }
411
412 #[test]
413 fn extra_metadata_fields_ignored() {
414 let tmp = TempDir::new().unwrap();
415 write_manifest(
416 tmp.path(),
417 r#"
418 [package]
419 name = "extra-pkg"
420 version = "1.0.0"
421 interface = "my-api"
422 interface_version = 1
423
424 [metadata]
425 category = "testing"
426 unknown_field = "ignored"
427 "#,
428 );
429
430 let m = load_manifest::<TestMeta>(tmp.path());
432 assert!(m.is_ok());
433 assert_eq!(m.unwrap().metadata.category, "testing");
434 }
435
436 #[test]
437 fn untyped_manifest_accepts_any_metadata() {
438 let tmp = TempDir::new().unwrap();
439 write_manifest(
440 tmp.path(),
441 r#"
442 [package]
443 name = "any-pkg"
444 version = "1.0.0"
445 interface = "my-api"
446 interface_version = 1
447
448 [metadata]
449 foo = "bar"
450 count = 42
451 nested = { a = 1, b = 2 }
452 "#,
453 );
454
455 let m = load_manifest_untyped(tmp.path()).unwrap();
456 assert_eq!(m.package.name, "any-pkg");
457 assert!(m.metadata.is_table());
458 }
459
460 #[test]
461 fn digest_is_deterministic() {
462 let tmp = TempDir::new().unwrap();
463 write_manifest(tmp.path(), "[package]\nname = \"test\"\nversion = \"1.0.0\"\ninterface = \"api\"\ninterface_version = 1\n\n[metadata]\nk = \"v\"\n");
464 std::fs::write(tmp.path().join("src.rs"), b"fn main() {}").unwrap();
465
466 let d1 = package_digest(tmp.path()).unwrap();
467 let d2 = package_digest(tmp.path()).unwrap();
468 assert_eq!(d1, d2);
469 }
470
471 #[test]
472 fn digest_changes_on_file_modification() {
473 let tmp = TempDir::new().unwrap();
474 write_manifest(tmp.path(), "[package]\nname = \"test\"\nversion = \"1.0.0\"\ninterface = \"api\"\ninterface_version = 1\n\n[metadata]\nk = \"v\"\n");
475 std::fs::write(tmp.path().join("src.rs"), b"fn main() {}").unwrap();
476
477 let d1 = package_digest(tmp.path()).unwrap();
478
479 std::fs::write(tmp.path().join("src.rs"), b"fn main() { evil() }").unwrap();
480 let d2 = package_digest(tmp.path()).unwrap();
481
482 assert_ne!(d1, d2);
483 }
484
485 #[test]
486 fn digest_excludes_target_and_sig() {
487 let tmp = TempDir::new().unwrap();
488 write_manifest(tmp.path(), "[package]\nname = \"test\"\nversion = \"1.0.0\"\ninterface = \"api\"\ninterface_version = 1\n\n[metadata]\nk = \"v\"\n");
489 std::fs::write(tmp.path().join("src.rs"), b"fn main() {}").unwrap();
490
491 let d1 = package_digest(tmp.path()).unwrap();
492
493 std::fs::create_dir(tmp.path().join("target")).unwrap();
495 std::fs::write(tmp.path().join("target/output.dylib"), b"binary").unwrap();
496 std::fs::write(tmp.path().join("package.sig"), b"sig bytes").unwrap();
497
498 let d2 = package_digest(tmp.path()).unwrap();
499 assert_eq!(d1, d2);
500 }
501
502 fn make_package(dir: &Path) {
503 write_manifest(
504 dir,
505 r#"
506 [package]
507 name = "test-pkg"
508 version = "2.0.0"
509 interface = "my-api"
510 interface_version = 1
511
512 [metadata]
513 category = "testing"
514 "#,
515 );
516 std::fs::create_dir_all(dir.join("src")).unwrap();
517 std::fs::write(dir.join("src/lib.rs"), b"fn hello() {}").unwrap();
518 }
519
520 #[test]
521 fn pack_unpack_round_trip() {
522 let pkg_dir = TempDir::new().unwrap();
523 make_package(pkg_dir.path());
524
525 let out_dir = TempDir::new().unwrap();
526 let fid_path = out_dir.path().join("test-pkg-2.0.0.fid");
527
528 let result = pack_package(pkg_dir.path(), Some(&fid_path)).unwrap();
529 assert_eq!(result.path, fid_path);
530 assert!(fid_path.exists());
531 assert!(result.unsigned);
532
533 let extract_dir = TempDir::new().unwrap();
534 let extracted = unpack_package(&fid_path, extract_dir.path()).unwrap();
535
536 assert!(extracted.join("package.toml").exists());
537 assert!(extracted.join("src/lib.rs").exists());
538 assert_eq!(
539 extracted.file_name().unwrap().to_str().unwrap(),
540 "test-pkg-2.0.0"
541 );
542 }
543
544 #[test]
545 fn pack_includes_sig_file() {
546 let pkg_dir = TempDir::new().unwrap();
547 make_package(pkg_dir.path());
548 std::fs::write(pkg_dir.path().join("package.sig"), b"fake-sig").unwrap();
549
550 let out_dir = TempDir::new().unwrap();
551 let fid_path = out_dir.path().join("out.fid");
552
553 let result = pack_package(pkg_dir.path(), Some(&fid_path)).unwrap();
554 assert!(!result.unsigned);
555
556 let extract_dir = TempDir::new().unwrap();
557 let extracted = unpack_package(&fid_path, extract_dir.path()).unwrap();
558 assert!(extracted.join("package.sig").exists());
559 }
560
561 #[test]
562 fn pack_excludes_target_and_git() {
563 let pkg_dir = TempDir::new().unwrap();
564 make_package(pkg_dir.path());
565 std::fs::create_dir(pkg_dir.path().join("target")).unwrap();
566 std::fs::write(pkg_dir.path().join("target/out.dylib"), b"bin").unwrap();
567 std::fs::create_dir(pkg_dir.path().join(".git")).unwrap();
568 std::fs::write(pkg_dir.path().join(".git/HEAD"), b"ref").unwrap();
569
570 let out_dir = TempDir::new().unwrap();
571 let fid_path = out_dir.path().join("out.fid");
572 pack_package(pkg_dir.path(), Some(&fid_path)).unwrap();
573
574 let extract_dir = TempDir::new().unwrap();
575 let extracted = unpack_package(&fid_path, extract_dir.path()).unwrap();
576 assert!(!extracted.join("target").exists());
577 assert!(!extracted.join(".git").exists());
578 }
579
580 #[test]
581 fn unpack_invalid_archive_no_manifest() {
582 let pkg_dir = TempDir::new().unwrap();
583 std::fs::create_dir_all(pkg_dir.path().join("src")).unwrap();
585 std::fs::write(pkg_dir.path().join("src/lib.rs"), b"fn x() {}").unwrap();
586
587 let out_dir = TempDir::new().unwrap();
588 let fid_path = out_dir.path().join("bad.fid");
589
590 {
592 use bzip2::write::BzEncoder;
593 use bzip2::Compression;
594
595 let file = std::fs::File::create(&fid_path).unwrap();
596 let encoder = BzEncoder::new(file, Compression::default());
597 let mut tar = tar::Builder::new(encoder);
598 tar.append_path_with_name(
599 pkg_dir.path().join("src/lib.rs"),
600 "no-manifest-1.0.0/src/lib.rs",
601 )
602 .unwrap();
603 tar.into_inner().unwrap().finish().unwrap();
604 }
605
606 let extract_dir = TempDir::new().unwrap();
607 let result = unpack_package(&fid_path, extract_dir.path());
608 assert!(result.is_err());
609 let err = result.unwrap_err().to_string();
610 assert!(err.contains("package.toml"), "error was: {err}");
611 }
612
613 #[test]
614 fn pack_default_output_name() {
615 let pkg_dir = TempDir::new().unwrap();
616 make_package(pkg_dir.path());
617
618 let out_dir = TempDir::new().unwrap();
619 let out_path = out_dir.path().join("test-pkg-2.0.0.fid");
620
621 let result = pack_package(pkg_dir.path(), Some(&out_path)).unwrap();
622 assert_eq!(result.path, out_path);
623 assert!(out_path.exists());
624 }
625
626 #[test]
627 fn pack_custom_extension() {
628 let pkg_dir = TempDir::new().unwrap();
629 write_manifest(
630 pkg_dir.path(),
631 r#"
632 [package]
633 name = "my-plugin"
634 version = "0.3.0"
635 interface = "my-api"
636 interface_version = 1
637 extension = "cloacina"
638
639 [metadata]
640 category = "testing"
641 "#,
642 );
643 std::fs::create_dir_all(pkg_dir.path().join("src")).unwrap();
644 std::fs::write(pkg_dir.path().join("src/lib.rs"), b"fn hello() {}").unwrap();
645
646 let out_dir = TempDir::new().unwrap();
647 let out_path = out_dir.path().join("my-plugin-0.3.0.cloacina");
648
649 let result = pack_package(pkg_dir.path(), Some(&out_path)).unwrap();
650 assert_eq!(result.path, out_path);
651 assert!(out_path.exists());
652
653 let extract_dir = TempDir::new().unwrap();
655 let extracted = unpack_package(&out_path, extract_dir.path()).unwrap();
656 assert!(extracted.join("package.toml").exists());
657 }
658
659 #[test]
660 fn extension_defaults_to_fid() {
661 let header = PackageHeader {
662 name: "test".to_string(),
663 version: "1.0.0".to_string(),
664 interface: "api".to_string(),
665 interface_version: 1,
666 extension: None,
667 };
668 assert_eq!(header.extension(), "fid");
669
670 let header_custom = PackageHeader {
671 extension: Some("cloacina".to_string()),
672 ..header
673 };
674 assert_eq!(header_custom.extension(), "cloacina");
675 }
676}