Skip to main content

fidius_core/
package.rs

1// Copyright 2026 Colliery, Inc.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Source package manifest types and parsing.
16//!
17//! A package is a directory containing plugin source code and a `package.toml`
18//! manifest. The manifest has a fixed header (name, version, interface) and
19//! an extensible `[metadata]` section validated via serde against a
20//! host-defined schema type.
21
22use serde::de::DeserializeOwned;
23use serde::{Deserialize, Serialize};
24use std::path::{Path, PathBuf};
25
26/// A parsed package manifest, generic over the host-defined metadata schema.
27///
28/// The `M` type parameter is the host's metadata schema. If the `[metadata]`
29/// section of `package.toml` doesn't deserialize into `M`, parsing fails —
30/// this is how schema validation works.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct PackageManifest<M> {
33    /// Fixed header fields required by fidius.
34    pub package: PackageHeader,
35    /// Host-defined metadata. Must deserialize from the `[metadata]` section.
36    pub metadata: M,
37}
38
39/// Fixed header fields that every package manifest must have.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct PackageHeader {
42    /// Package name (e.g., `"blur-filter"`).
43    pub name: String,
44    /// Package version (e.g., `"1.2.0"`).
45    pub version: String,
46    /// Name of the interface crate this package implements.
47    pub interface: String,
48    /// Expected interface version.
49    pub interface_version: u32,
50    /// Custom file extension for `.fid` archives (e.g., `"cloacina"`).
51    /// Defaults to `"fid"` when absent.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub extension: Option<String>,
54}
55
56impl PackageHeader {
57    /// Returns the package extension, defaulting to `"fid"`.
58    pub fn extension(&self) -> &str {
59        self.extension.as_deref().unwrap_or("fid")
60    }
61}
62
63/// Errors that can occur when loading a package manifest.
64#[derive(Debug, thiserror::Error)]
65pub enum PackageError {
66    /// The `package.toml` file was not found in the given directory.
67    #[error("package.toml not found in {path}")]
68    ManifestNotFound { path: String },
69
70    /// The manifest file could not be parsed as valid TOML or failed
71    /// schema validation (the `[metadata]` section didn't match `M`).
72    #[error("failed to parse package.toml: {0}")]
73    ParseError(#[from] toml::de::Error),
74
75    /// An I/O error occurred reading the manifest file.
76    #[error("io error reading package.toml: {0}")]
77    Io(#[from] std::io::Error),
78
79    /// Build failed.
80    #[error("package build failed: {0}")]
81    BuildFailed(String),
82
83    /// Package signature file not found.
84    #[error("package.sig not found in {path}")]
85    SignatureNotFound { path: String },
86
87    /// Package signature is invalid (no trusted key verified it).
88    #[error("package signature invalid for {path}")]
89    SignatureInvalid { path: String },
90
91    /// An error occurred creating or reading an archive.
92    #[error("archive error: {0}")]
93    ArchiveError(String),
94
95    /// The archive does not contain a valid package.
96    #[error("invalid archive: {0}")]
97    InvalidArchive(String),
98}
99
100/// Load and parse a `package.toml` manifest from a package directory.
101///
102/// The type parameter `M` is the host's metadata schema. If the `[metadata]`
103/// section doesn't deserialize into `M`, this returns `PackageError::ParseError`.
104///
105/// # Example
106///
107/// ```ignore
108/// #[derive(Deserialize)]
109/// struct MySchema {
110///     category: String,
111///     min_host_version: String,
112/// }
113///
114/// let manifest = load_manifest::<MySchema>(Path::new("./my-package/"))?;
115/// println!("Package: {} v{}", manifest.package.name, manifest.package.version);
116/// println!("Category: {}", manifest.metadata.category);
117/// ```
118pub 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
132/// Load a manifest validating only the fixed header (accepting any metadata).
133///
134/// Uses `toml::Value` as the metadata type so any `[metadata]` section is accepted.
135/// Useful for CLI tools that validate structure without knowing the host's schema.
136pub fn load_manifest_untyped(dir: &Path) -> Result<PackageManifest<toml::Value>, PackageError> {
137    load_manifest::<toml::Value>(dir)
138}
139
140/// Compute a deterministic SHA-256 digest over all package source files.
141///
142/// Walks the package directory, collects all files (excluding `target/`,
143/// `.git/`, and `*.sig` files), sorts by relative path, and feeds each
144/// file's relative path and contents into a SHA-256 hasher.
145///
146/// The resulting 32-byte digest covers the entire package contents.
147/// Sign this digest to protect against tampering.
148pub 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        // Hash the relative path (as UTF-8 bytes) then the file contents.
160        // Length-prefix both to prevent ambiguity.
161        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
171/// Recursively collect file paths relative to `root`, skipping excluded dirs/files.
172fn 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        // Skip excluded directories
181        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        // Skip signature files
190        if name_str.ends_with(".sig") {
191            continue;
192        }
193
194        // Store relative path using forward slashes for cross-platform determinism
195        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
205/// Recursively collect file paths for archiving (includes `.sig` files).
206fn 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/// Result of packing a package, including any warnings.
237#[derive(Debug)]
238pub struct PackResult {
239    /// Path to the created `.fid` archive.
240    pub path: PathBuf,
241    /// Whether the package was unsigned (no `package.sig` found).
242    pub unsigned: bool,
243}
244
245/// Create a `.fid` archive (tar + bzip2) from a package directory.
246///
247/// The archive contains a single top-level directory `{name}-{version}/`
248/// with all source files. Excludes `target/` and `.git/` directories.
249/// Includes `package.sig` if present.
250///
251/// If `output` is `None`, the archive is written to the current directory
252/// as `{name}-{version}.fid`.
253pub 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
298/// Extract a `.fid` archive (tar + bzip2) to a destination directory.
299///
300/// Returns the path to the extracted top-level package directory.
301/// Validates that a `package.toml` exists in the extracted contents.
302pub 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    // Find the top-level directory that was extracted
317    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        // TestMeta doesn't have unknown_field — should still parse (serde ignores unknown by default)
431        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        // Adding target/ dir and .sig file should not change digest
494        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        // Create a valid bz2 tar but with no package.toml
584        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        // Manually create a tar.bz2 without package.toml
591        {
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        // Verify it unpacks correctly
654        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}