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;
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}
51
52/// Errors that can occur when loading a package manifest.
53#[derive(Debug, thiserror::Error)]
54pub enum PackageError {
55    /// The `package.toml` file was not found in the given directory.
56    #[error("package.toml not found in {path}")]
57    ManifestNotFound { path: String },
58
59    /// The manifest file could not be parsed as valid TOML or failed
60    /// schema validation (the `[metadata]` section didn't match `M`).
61    #[error("failed to parse package.toml: {0}")]
62    ParseError(#[from] toml::de::Error),
63
64    /// An I/O error occurred reading the manifest file.
65    #[error("io error reading package.toml: {0}")]
66    Io(#[from] std::io::Error),
67
68    /// Build failed.
69    #[error("package build failed: {0}")]
70    BuildFailed(String),
71
72    /// Package signature file not found.
73    #[error("package.sig not found in {path}")]
74    SignatureNotFound { path: String },
75
76    /// Package signature is invalid (no trusted key verified it).
77    #[error("package signature invalid for {path}")]
78    SignatureInvalid { path: String },
79}
80
81/// Load and parse a `package.toml` manifest from a package directory.
82///
83/// The type parameter `M` is the host's metadata schema. If the `[metadata]`
84/// section doesn't deserialize into `M`, this returns `PackageError::ParseError`.
85///
86/// # Example
87///
88/// ```ignore
89/// #[derive(Deserialize)]
90/// struct MySchema {
91///     category: String,
92///     min_host_version: String,
93/// }
94///
95/// let manifest = load_manifest::<MySchema>(Path::new("./my-package/"))?;
96/// println!("Package: {} v{}", manifest.package.name, manifest.package.version);
97/// println!("Category: {}", manifest.metadata.category);
98/// ```
99pub fn load_manifest<M: DeserializeOwned>(dir: &Path) -> Result<PackageManifest<M>, PackageError> {
100    let manifest_path = dir.join("package.toml");
101
102    if !manifest_path.exists() {
103        return Err(PackageError::ManifestNotFound {
104            path: dir.display().to_string(),
105        });
106    }
107
108    let content = std::fs::read_to_string(&manifest_path)?;
109    let manifest: PackageManifest<M> = toml::from_str(&content)?;
110    Ok(manifest)
111}
112
113/// Load a manifest validating only the fixed header (accepting any metadata).
114///
115/// Uses `toml::Value` as the metadata type so any `[metadata]` section is accepted.
116/// Useful for CLI tools that validate structure without knowing the host's schema.
117pub fn load_manifest_untyped(dir: &Path) -> Result<PackageManifest<toml::Value>, PackageError> {
118    load_manifest::<toml::Value>(dir)
119}
120
121/// Compute a deterministic SHA-256 digest over all package source files.
122///
123/// Walks the package directory, collects all files (excluding `target/`,
124/// `.git/`, and `*.sig` files), sorts by relative path, and feeds each
125/// file's relative path and contents into a SHA-256 hasher.
126///
127/// The resulting 32-byte digest covers the entire package contents.
128/// Sign this digest to protect against tampering.
129pub fn package_digest(dir: &Path) -> Result<[u8; 32], PackageError> {
130    use sha2::{Digest, Sha256};
131
132    let mut files = Vec::new();
133    collect_files(dir, dir, &mut files)?;
134    files.sort();
135
136    let mut hasher = Sha256::new();
137    for rel_path in &files {
138        let abs_path = dir.join(rel_path);
139        let contents = std::fs::read(&abs_path)?;
140        // Hash the relative path (as UTF-8 bytes) then the file contents.
141        // Length-prefix both to prevent ambiguity.
142        let path_bytes = rel_path.as_bytes();
143        hasher.update((path_bytes.len() as u64).to_le_bytes());
144        hasher.update(path_bytes);
145        hasher.update((contents.len() as u64).to_le_bytes());
146        hasher.update(&contents);
147    }
148
149    Ok(hasher.finalize().into())
150}
151
152/// Recursively collect file paths relative to `root`, skipping excluded dirs/files.
153fn collect_files(root: &Path, dir: &Path, out: &mut Vec<String>) -> Result<(), PackageError> {
154    let entries = std::fs::read_dir(dir)?;
155    for entry in entries {
156        let entry = entry?;
157        let path = entry.path();
158        let name = entry.file_name();
159        let name_str = name.to_string_lossy();
160
161        // Skip excluded directories
162        if path.is_dir() {
163            if name_str == "target" || name_str == ".git" {
164                continue;
165            }
166            collect_files(root, &path, out)?;
167            continue;
168        }
169
170        // Skip signature files
171        if name_str.ends_with(".sig") {
172            continue;
173        }
174
175        // Store relative path using forward slashes for cross-platform determinism
176        let rel = path
177            .strip_prefix(root)
178            .expect("path is under root")
179            .to_string_lossy()
180            .replace('\\', "/");
181        out.push(rel);
182    }
183    Ok(())
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use tempfile::TempDir;
190
191    fn write_manifest(dir: &Path, content: &str) {
192        std::fs::write(dir.join("package.toml"), content).unwrap();
193    }
194
195    #[derive(Debug, Deserialize, PartialEq)]
196    struct TestMeta {
197        category: String,
198        #[serde(default)]
199        tags: Vec<String>,
200    }
201
202    #[test]
203    fn valid_manifest_parses() {
204        let tmp = TempDir::new().unwrap();
205        write_manifest(
206            tmp.path(),
207            r#"
208            [package]
209            name = "test-pkg"
210            version = "1.0.0"
211            interface = "my-api"
212            interface_version = 1
213
214            [metadata]
215            category = "testing"
216            tags = ["a", "b"]
217            "#,
218        );
219
220        let m = load_manifest::<TestMeta>(tmp.path()).unwrap();
221        assert_eq!(m.package.name, "test-pkg");
222        assert_eq!(m.package.version, "1.0.0");
223        assert_eq!(m.package.interface, "my-api");
224        assert_eq!(m.package.interface_version, 1);
225        assert_eq!(m.metadata.category, "testing");
226        assert_eq!(m.metadata.tags, vec!["a", "b"]);
227    }
228
229    #[test]
230    fn missing_required_metadata_field_fails() {
231        let tmp = TempDir::new().unwrap();
232        write_manifest(
233            tmp.path(),
234            r#"
235            [package]
236            name = "bad-pkg"
237            version = "1.0.0"
238            interface = "my-api"
239            interface_version = 1
240
241            [metadata]
242            # missing required "category" field
243            tags = ["x"]
244            "#,
245        );
246
247        let result = load_manifest::<TestMeta>(tmp.path());
248        assert!(result.is_err());
249        let err = result.unwrap_err().to_string();
250        assert!(
251            err.contains("category"),
252            "error should mention missing field: {err}"
253        );
254    }
255
256    #[test]
257    fn missing_manifest_returns_not_found() {
258        let tmp = TempDir::new().unwrap();
259        let result = load_manifest::<TestMeta>(tmp.path());
260        assert!(matches!(result, Err(PackageError::ManifestNotFound { .. })));
261    }
262
263    #[test]
264    fn extra_metadata_fields_ignored() {
265        let tmp = TempDir::new().unwrap();
266        write_manifest(
267            tmp.path(),
268            r#"
269            [package]
270            name = "extra-pkg"
271            version = "1.0.0"
272            interface = "my-api"
273            interface_version = 1
274
275            [metadata]
276            category = "testing"
277            unknown_field = "ignored"
278            "#,
279        );
280
281        // TestMeta doesn't have unknown_field — should still parse (serde ignores unknown by default)
282        let m = load_manifest::<TestMeta>(tmp.path());
283        assert!(m.is_ok());
284        assert_eq!(m.unwrap().metadata.category, "testing");
285    }
286
287    #[test]
288    fn untyped_manifest_accepts_any_metadata() {
289        let tmp = TempDir::new().unwrap();
290        write_manifest(
291            tmp.path(),
292            r#"
293            [package]
294            name = "any-pkg"
295            version = "1.0.0"
296            interface = "my-api"
297            interface_version = 1
298
299            [metadata]
300            foo = "bar"
301            count = 42
302            nested = { a = 1, b = 2 }
303            "#,
304        );
305
306        let m = load_manifest_untyped(tmp.path()).unwrap();
307        assert_eq!(m.package.name, "any-pkg");
308        assert!(m.metadata.is_table());
309    }
310
311    #[test]
312    fn digest_is_deterministic() {
313        let tmp = TempDir::new().unwrap();
314        write_manifest(tmp.path(), "[package]\nname = \"test\"\nversion = \"1.0.0\"\ninterface = \"api\"\ninterface_version = 1\n\n[metadata]\nk = \"v\"\n");
315        std::fs::write(tmp.path().join("src.rs"), b"fn main() {}").unwrap();
316
317        let d1 = package_digest(tmp.path()).unwrap();
318        let d2 = package_digest(tmp.path()).unwrap();
319        assert_eq!(d1, d2);
320    }
321
322    #[test]
323    fn digest_changes_on_file_modification() {
324        let tmp = TempDir::new().unwrap();
325        write_manifest(tmp.path(), "[package]\nname = \"test\"\nversion = \"1.0.0\"\ninterface = \"api\"\ninterface_version = 1\n\n[metadata]\nk = \"v\"\n");
326        std::fs::write(tmp.path().join("src.rs"), b"fn main() {}").unwrap();
327
328        let d1 = package_digest(tmp.path()).unwrap();
329
330        std::fs::write(tmp.path().join("src.rs"), b"fn main() { evil() }").unwrap();
331        let d2 = package_digest(tmp.path()).unwrap();
332
333        assert_ne!(d1, d2);
334    }
335
336    #[test]
337    fn digest_excludes_target_and_sig() {
338        let tmp = TempDir::new().unwrap();
339        write_manifest(tmp.path(), "[package]\nname = \"test\"\nversion = \"1.0.0\"\ninterface = \"api\"\ninterface_version = 1\n\n[metadata]\nk = \"v\"\n");
340        std::fs::write(tmp.path().join("src.rs"), b"fn main() {}").unwrap();
341
342        let d1 = package_digest(tmp.path()).unwrap();
343
344        // Adding target/ dir and .sig file should not change digest
345        std::fs::create_dir(tmp.path().join("target")).unwrap();
346        std::fs::write(tmp.path().join("target/output.dylib"), b"binary").unwrap();
347        std::fs::write(tmp.path().join("package.sig"), b"sig bytes").unwrap();
348
349        let d2 = package_digest(tmp.path()).unwrap();
350        assert_eq!(d1, d2);
351    }
352}