1use serde::de::DeserializeOwned;
23use serde::{Deserialize, Serialize};
24use std::path::Path;
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}
51
52#[derive(Debug, thiserror::Error)]
54pub enum PackageError {
55 #[error("package.toml not found in {path}")]
57 ManifestNotFound { path: String },
58
59 #[error("failed to parse package.toml: {0}")]
62 ParseError(#[from] toml::de::Error),
63
64 #[error("io error reading package.toml: {0}")]
66 Io(#[from] std::io::Error),
67
68 #[error("package build failed: {0}")]
70 BuildFailed(String),
71
72 #[error("package.sig not found in {path}")]
74 SignatureNotFound { path: String },
75
76 #[error("package signature invalid for {path}")]
78 SignatureInvalid { path: String },
79}
80
81pub 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
113pub fn load_manifest_untyped(dir: &Path) -> Result<PackageManifest<toml::Value>, PackageError> {
118 load_manifest::<toml::Value>(dir)
119}
120
121pub 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 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
152fn 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 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 if name_str.ends_with(".sig") {
172 continue;
173 }
174
175 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 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 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}