fidius_host/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//! Host-side package integration: manifest loading, discovery, and building.
16
17use std::path::{Path, PathBuf};
18
19use ed25519_dalek::{Signature, Verifier, VerifyingKey};
20use fidius_core::package::{PackageError, PackageManifest};
21use serde::de::DeserializeOwned;
22
23/// Load and validate a package manifest against a host-defined schema.
24///
25/// This is the primary entry point for host applications working with packages.
26/// The type parameter `M` is the host's metadata schema — if the manifest's
27/// `[metadata]` section doesn't deserialize into `M`, this returns an error.
28///
29/// # Example
30///
31/// ```ignore
32/// #[derive(Deserialize)]
33/// struct MySchema {
34/// category: String,
35/// min_host_version: String,
36/// }
37///
38/// let manifest = load_package_manifest::<MySchema>(Path::new("./packages/blur/"))?;
39/// assert_eq!(manifest.metadata.category, "image-processing");
40/// ```
41pub fn load_package_manifest<M: DeserializeOwned>(
42 dir: &Path,
43) -> Result<PackageManifest<M>, PackageError> {
44 fidius_core::package::load_manifest(dir)
45}
46
47/// Discover packages in a directory.
48///
49/// Scans `dir` for subdirectories containing a `package.toml` file.
50/// Returns the paths to each package directory found.
51pub fn discover_packages(dir: &Path) -> Result<Vec<PathBuf>, PackageError> {
52 let mut packages = Vec::new();
53
54 if !dir.is_dir() {
55 return Ok(packages);
56 }
57
58 let entries = std::fs::read_dir(dir).map_err(PackageError::Io)?;
59
60 for entry in entries {
61 let entry = entry.map_err(PackageError::Io)?;
62 let path = entry.path();
63 if path.is_dir() && path.join("package.toml").exists() {
64 packages.push(path);
65 }
66 }
67
68 packages.sort();
69 Ok(packages)
70}
71
72/// Verify a source package's signature against trusted public keys.
73///
74/// Recomputes the package digest from files on disk and verifies the
75/// `package.sig` signature against the provided trusted keys.
76///
77/// # Errors
78///
79/// - `PackageError::SignatureNotFound` — if `package.sig` doesn't exist
80/// - `PackageError::SignatureInvalid` — if no trusted key verifies the signature
81pub fn verify_package(dir: &Path, trusted_keys: &[VerifyingKey]) -> Result<(), PackageError> {
82 let sig_path = dir.join("package.sig");
83 if !sig_path.exists() {
84 return Err(PackageError::SignatureNotFound {
85 path: dir.display().to_string(),
86 });
87 }
88
89 let sig_bytes: [u8; 64] =
90 std::fs::read(&sig_path)?
91 .try_into()
92 .map_err(|_| PackageError::SignatureInvalid {
93 path: dir.display().to_string(),
94 })?;
95
96 let signature = Signature::from_bytes(&sig_bytes);
97 let digest = fidius_core::package::package_digest(dir)?;
98
99 for key in trusted_keys {
100 if key.verify(&digest, &signature).is_ok() {
101 return Ok(());
102 }
103 }
104
105 Err(PackageError::SignatureInvalid {
106 path: dir.display().to_string(),
107 })
108}
109
110/// Extract a `.fid` archive and validate its contents.
111///
112/// Delegates to [`fidius_core::package::unpack_package`], which applies strict
113/// safety defaults: entries with `..` components, absolute paths, symlinks, or
114/// hardlinks are rejected, and the cumulative decompressed size is capped to
115/// guard against decompression bombs. Emits a `tracing::warn!` if the unpacked
116/// package has no `package.sig`.
117///
118/// For archives that legitimately exceed the default caps, call
119/// [`fidius_core::package::unpack_package_with_options`] directly.
120///
121/// # Example
122///
123/// ```ignore
124/// let pkg_dir = unpack_fid(Path::new("blur-filter-1.0.0.fid"), Path::new("./plugins/"))?;
125/// let manifest = load_package_manifest::<MySchema>(&pkg_dir)?;
126/// ```
127pub fn unpack_fid(archive: &Path, dest: &Path) -> Result<PathBuf, PackageError> {
128 let pkg_dir = fidius_core::package::unpack_package(archive, dest)?;
129
130 if !pkg_dir.join("package.sig").exists() {
131 #[cfg(feature = "tracing")]
132 tracing::warn!(
133 package = %pkg_dir.display(),
134 "unpacked package is unsigned (no package.sig found)"
135 );
136 }
137
138 Ok(pkg_dir)
139}
140
141/// Build a package by running `cargo build` inside the package directory.
142///
143/// Returns the path to the compiled cdylib on success.
144pub fn build_package(dir: &Path, release: bool) -> Result<PathBuf, PackageError> {
145 let cargo_toml = dir.join("Cargo.toml");
146 if !cargo_toml.exists() {
147 return Err(PackageError::BuildFailed(format!(
148 "Cargo.toml not found in {}",
149 dir.display()
150 )));
151 }
152
153 let mut cmd = std::process::Command::new("cargo");
154 cmd.arg("build").arg("--manifest-path").arg(&cargo_toml);
155 if release {
156 cmd.arg("--release");
157 }
158
159 let output = cmd.output().map_err(PackageError::Io)?;
160
161 if !output.status.success() {
162 let stderr = String::from_utf8_lossy(&output.stderr);
163 return Err(PackageError::BuildFailed(stderr.to_string()));
164 }
165
166 let profile = if release { "release" } else { "debug" };
167 let target_dir = dir.join("target").join(profile);
168
169 // Find the cdylib in the target directory
170 let dylib_ext = if cfg!(target_os = "macos") {
171 "dylib"
172 } else if cfg!(target_os = "windows") {
173 "dll"
174 } else {
175 "so"
176 };
177
178 // Look for any file with the right extension
179 if let Ok(entries) = std::fs::read_dir(&target_dir) {
180 for entry in entries.flatten() {
181 let path = entry.path();
182 if path.extension().and_then(|e| e.to_str()) == Some(dylib_ext) {
183 return Ok(path);
184 }
185 }
186 }
187
188 Err(PackageError::BuildFailed(format!(
189 "build succeeded but no .{} file found in {}",
190 dylib_ext,
191 target_dir.display()
192 )))
193}