socket_patch_core/manifest/
operations.rs1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3
4use crate::manifest::schema::PatchManifest;
5
6pub fn resolve_manifest_path(cwd: &Path, manifest_path: &str) -> PathBuf {
10 if Path::new(manifest_path).is_absolute() {
11 PathBuf::from(manifest_path)
12 } else {
13 cwd.join(manifest_path)
14 }
15}
16
17pub fn get_after_hash_blobs(manifest: &PatchManifest) -> HashSet<String> {
21 let mut blobs = HashSet::new();
22
23 for record in manifest.patches.values() {
24 for file_info in record.files.values() {
25 blobs.insert(file_info.after_hash.clone());
26 }
27 }
28
29 blobs
30}
31
32pub fn get_before_hash_blobs(manifest: &PatchManifest) -> HashSet<String> {
35 let mut blobs = HashSet::new();
36
37 for record in manifest.patches.values() {
38 for file_info in record.files.values() {
39 blobs.insert(file_info.before_hash.clone());
40 }
41 }
42
43 blobs
44}
45
46pub fn validate_manifest(value: &serde_json::Value) -> Result<PatchManifest, String> {
49 serde_json::from_value::<PatchManifest>(value.clone())
50 .map_err(|e| format!("Invalid manifest: {}", e))
51}
52
53pub async fn read_manifest(
57 path: impl AsRef<Path>,
58) -> Result<Option<PatchManifest>, std::io::Error> {
59 let path = path.as_ref();
60
61 let content = match tokio::fs::read_to_string(path).await {
62 Ok(c) => c,
63 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
64 Err(e) => return Err(e), };
66
67 let parsed: serde_json::Value = match serde_json::from_str(&content) {
68 Ok(v) => v,
69 Err(e) => {
70 return Err(std::io::Error::new(
71 std::io::ErrorKind::InvalidData,
72 format!("Failed to parse manifest JSON: {}", e),
73 ))
74 }
75 };
76
77 match validate_manifest(&parsed) {
78 Ok(manifest) => Ok(Some(manifest)),
79 Err(e) => Err(std::io::Error::new(std::io::ErrorKind::InvalidData, e)),
80 }
81}
82
83pub async fn write_manifest(
85 path: impl AsRef<Path>,
86 manifest: &PatchManifest,
87) -> Result<(), std::io::Error> {
88 let content = serde_json::to_string_pretty(manifest)
89 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
90 tokio::fs::write(path, content).await
91}
92
93#[cfg(test)]
94mod tests {
95 use super::*;
96 use crate::manifest::schema::{PatchFileInfo, PatchRecord};
97 use std::collections::HashMap;
98
99 const TEST_UUID_1: &str = "11111111-1111-4111-8111-111111111111";
100 const TEST_UUID_2: &str = "22222222-2222-4222-8222-222222222222";
101
102 const BEFORE_HASH_1: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1111";
103 const AFTER_HASH_1: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb1111";
104 const BEFORE_HASH_2: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc2222";
105 const AFTER_HASH_2: &str = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd2222";
106 const BEFORE_HASH_3: &str = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee3333";
107 const AFTER_HASH_3: &str = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff3333";
108
109 fn create_test_manifest() -> PatchManifest {
110 let mut patches = HashMap::new();
111
112 let mut files_a = HashMap::new();
113 files_a.insert(
114 "package/index.js".to_string(),
115 PatchFileInfo {
116 before_hash: BEFORE_HASH_1.to_string(),
117 after_hash: AFTER_HASH_1.to_string(),
118 },
119 );
120 files_a.insert(
121 "package/lib/utils.js".to_string(),
122 PatchFileInfo {
123 before_hash: BEFORE_HASH_2.to_string(),
124 after_hash: AFTER_HASH_2.to_string(),
125 },
126 );
127
128 patches.insert(
129 "pkg:npm/pkg-a@1.0.0".to_string(),
130 PatchRecord {
131 uuid: TEST_UUID_1.to_string(),
132 exported_at: "2024-01-01T00:00:00Z".to_string(),
133 files: files_a,
134 vulnerabilities: HashMap::new(),
135 description: "Test patch 1".to_string(),
136 license: "MIT".to_string(),
137 tier: "free".to_string(),
138 },
139 );
140
141 let mut files_b = HashMap::new();
142 files_b.insert(
143 "package/main.js".to_string(),
144 PatchFileInfo {
145 before_hash: BEFORE_HASH_3.to_string(),
146 after_hash: AFTER_HASH_3.to_string(),
147 },
148 );
149
150 patches.insert(
151 "pkg:npm/pkg-b@2.0.0".to_string(),
152 PatchRecord {
153 uuid: TEST_UUID_2.to_string(),
154 exported_at: "2024-01-01T00:00:00Z".to_string(),
155 files: files_b,
156 vulnerabilities: HashMap::new(),
157 description: "Test patch 2".to_string(),
158 license: "MIT".to_string(),
159 tier: "free".to_string(),
160 },
161 );
162
163 PatchManifest { patches }
164 }
165
166 #[test]
167 fn test_get_after_hash_blobs() {
168 let manifest = create_test_manifest();
169 let blobs = get_after_hash_blobs(&manifest);
170
171 assert_eq!(blobs.len(), 3);
172 assert!(blobs.contains(AFTER_HASH_1));
173 assert!(blobs.contains(AFTER_HASH_2));
174 assert!(blobs.contains(AFTER_HASH_3));
175 assert!(!blobs.contains(BEFORE_HASH_1));
176 assert!(!blobs.contains(BEFORE_HASH_2));
177 assert!(!blobs.contains(BEFORE_HASH_3));
178 }
179
180 #[test]
181 fn test_get_after_hash_blobs_empty() {
182 let manifest = PatchManifest::new();
183 let blobs = get_after_hash_blobs(&manifest);
184 assert_eq!(blobs.len(), 0);
185 }
186
187 #[test]
188 fn test_get_before_hash_blobs() {
189 let manifest = create_test_manifest();
190 let blobs = get_before_hash_blobs(&manifest);
191
192 assert_eq!(blobs.len(), 3);
193 assert!(blobs.contains(BEFORE_HASH_1));
194 assert!(blobs.contains(BEFORE_HASH_2));
195 assert!(blobs.contains(BEFORE_HASH_3));
196 assert!(!blobs.contains(AFTER_HASH_1));
197 assert!(!blobs.contains(AFTER_HASH_2));
198 assert!(!blobs.contains(AFTER_HASH_3));
199 }
200
201 #[test]
202 fn test_get_before_hash_blobs_empty() {
203 let manifest = PatchManifest::new();
204 let blobs = get_before_hash_blobs(&manifest);
205 assert_eq!(blobs.len(), 0);
206 }
207
208 #[test]
209 fn test_validate_manifest_valid() {
210 let json = serde_json::json!({
211 "patches": {
212 "pkg:npm/test@1.0.0": {
213 "uuid": "11111111-1111-4111-8111-111111111111",
214 "exportedAt": "2024-01-01T00:00:00Z",
215 "files": {},
216 "vulnerabilities": {},
217 "description": "test",
218 "license": "MIT",
219 "tier": "free"
220 }
221 }
222 });
223
224 let result = validate_manifest(&json);
225 assert!(result.is_ok());
226 let manifest = result.unwrap();
227 assert_eq!(manifest.patches.len(), 1);
228 }
229
230 #[test]
231 fn test_validate_manifest_invalid() {
232 let json = serde_json::json!({
233 "patches": "not-an-object"
234 });
235
236 let result = validate_manifest(&json);
237 assert!(result.is_err());
238 }
239
240 #[test]
241 fn test_validate_manifest_missing_fields() {
242 let json = serde_json::json!({
243 "patches": {
244 "pkg:npm/test@1.0.0": {
245 "uuid": "test"
246 }
247 }
248 });
249
250 let result = validate_manifest(&json);
251 assert!(result.is_err());
252 }
253
254 #[tokio::test]
255 async fn test_read_manifest_not_found() {
256 let result = read_manifest("/nonexistent/path/manifest.json").await;
257 assert!(result.is_ok());
258 assert!(result.unwrap().is_none());
259 }
260
261 #[tokio::test]
266 async fn test_read_manifest_malformed_json_is_error() {
267 let dir = tempfile::tempdir().unwrap();
268 let path = dir.path().join("manifest.json");
269 tokio::fs::write(&path, "{ not valid json").await.unwrap();
270
271 let result = read_manifest(&path).await;
272 assert!(
273 result.is_err(),
274 "malformed JSON must be an error, not Ok(None)"
275 );
276 assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidData);
277 }
278
279 #[tokio::test]
282 async fn test_read_manifest_invalid_schema_is_error() {
283 let dir = tempfile::tempdir().unwrap();
284 let path = dir.path().join("manifest.json");
285 tokio::fs::write(&path, r#"{"patches": "not-an-object"}"#)
287 .await
288 .unwrap();
289
290 let result = read_manifest(&path).await;
291 assert!(
292 result.is_err(),
293 "schema-invalid manifest must be an error, not Ok(None)"
294 );
295 assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidData);
296 }
297
298 #[test]
301 fn test_blob_extractors_do_not_cross_contaminate() {
302 let manifest = create_test_manifest();
303 let after = get_after_hash_blobs(&manifest);
304 let before = get_before_hash_blobs(&manifest);
305
306 assert!(after.is_disjoint(&before));
308 for b in [BEFORE_HASH_1, BEFORE_HASH_2, BEFORE_HASH_3] {
310 assert!(!after.contains(b));
311 }
312 for a in [AFTER_HASH_1, AFTER_HASH_2, AFTER_HASH_3] {
313 assert!(!before.contains(a));
314 }
315 }
316
317 #[tokio::test]
323 async fn test_read_manifest_io_error_propagates() {
324 let dir = tempfile::tempdir().unwrap();
325 let result = read_manifest(dir.path()).await;
328 assert!(
329 result.is_err(),
330 "a non-NotFound I/O error must surface as Err, not Ok(None)"
331 );
332 assert_ne!(
333 result.unwrap_err().kind(),
334 std::io::ErrorKind::NotFound,
335 "an existing-but-unreadable path is not a 'missing file'"
336 );
337 }
338
339 #[tokio::test]
343 async fn test_write_manifest_preserves_full_content() {
344 let dir = tempfile::tempdir().unwrap();
345 let path = dir.path().join("manifest.json");
346
347 let manifest = create_test_manifest();
348 write_manifest(&path, &manifest).await.unwrap();
349
350 let read_back = read_manifest(&path).await.unwrap().unwrap();
351 assert_eq!(read_back, manifest);
353
354 let record = read_back.patches.get("pkg:npm/pkg-a@1.0.0").unwrap();
356 let file_info = record.files.get("package/index.js").unwrap();
357 assert_eq!(file_info.before_hash, BEFORE_HASH_1);
358 assert_eq!(file_info.after_hash, AFTER_HASH_1);
359 }
360
361 #[tokio::test]
362 async fn test_write_and_read_manifest() {
363 let dir = tempfile::tempdir().unwrap();
364 let path = dir.path().join("manifest.json");
365
366 let manifest = create_test_manifest();
367 write_manifest(&path, &manifest).await.unwrap();
368
369 let read_back = read_manifest(&path).await.unwrap();
370 assert!(read_back.is_some());
371 let read_back = read_back.unwrap();
372 assert_eq!(read_back.patches.len(), 2);
373 }
374
375 #[test]
376 fn test_resolve_manifest_path_relative_joins_cwd() {
377 let cwd = Path::new("/tmp/proj");
378 let resolved = resolve_manifest_path(cwd, ".socket/manifest.json");
379 assert_eq!(resolved, PathBuf::from("/tmp/proj/.socket/manifest.json"));
380 }
381
382 #[test]
383 fn test_resolve_manifest_path_absolute_unchanged() {
384 let cwd = Path::new("/tmp/proj");
385 let absolute = if cfg!(windows) {
386 r"C:\custom\manifest.json"
387 } else {
388 "/etc/custom/manifest.json"
389 };
390 let resolved = resolve_manifest_path(cwd, absolute);
391 assert_eq!(resolved, PathBuf::from(absolute));
392 }
393
394 #[test]
395 fn test_resolve_manifest_path_relative_dotted() {
396 let cwd = Path::new("/tmp/proj");
397 let resolved = resolve_manifest_path(cwd, "../manifest.json");
398 assert_eq!(resolved, PathBuf::from("/tmp/proj/../manifest.json"));
399 }
400}