Skip to main content

socket_patch_core/manifest/
operations.rs

1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3
4use crate::manifest::schema::PatchManifest;
5
6/// Resolve a manifest path: absolute paths are returned as-is, relative paths
7/// are joined to `cwd`. Centralizes the duplicate block previously inlined in
8/// apply/rollback/list/remove/repair commands.
9pub 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
17/// Get only afterHash blobs referenced by a manifest.
18/// Used for apply operations -- we only need the patched file content, not the original.
19/// This saves disk space since beforeHash blobs are not needed for applying patches.
20pub 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
32/// Get only beforeHash blobs referenced by a manifest.
33/// Used for rollback operations -- we need the original file content to restore.
34pub 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
46/// Validate a parsed JSON value as a PatchManifest.
47/// Returns Ok(manifest) if valid, or Err(message) if invalid.
48pub 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
53/// Read and parse a manifest from the filesystem.
54/// Returns Ok(None) if the file does not exist.
55/// Returns Err for I/O errors, JSON parse errors, or validation errors.
56pub 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), // FIX: propagate actual I/O error
65    };
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
83/// Write a manifest to the filesystem with pretty-printed JSON.
84pub 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    // Regression: a missing file maps to Ok(None), but malformed JSON must
262    // surface as an InvalidData error -- NOT be silently swallowed as Ok(None).
263    // The original implementation returned Ok(None) for every failure mode,
264    // which hid corrupt manifests from callers.
265    #[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    // Regression: well-formed JSON that doesn't satisfy the schema (missing
280    // required fields) must also surface as an InvalidData error.
281    #[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        // Valid JSON, but `patches` has the wrong shape.
286        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    // Regression: the two blob extractors must not be swapped. Each must return
299    // exactly its own side of the hash pair with zero cross-contamination.
300    #[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        // The two sets are disjoint for this fixture.
307        assert!(after.is_disjoint(&before));
308        // Every after-blob is an afterHash from the fixture, never a beforeHash.
309        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    // Regression: a non-NotFound I/O error must propagate as Err -- it must NOT
318    // be collapsed into Ok(None). Only a genuinely-missing file is Ok(None).
319    // Reading a directory as if it were a file produces such an I/O error, which
320    // directly exercises the `Err(e) => return Err(e)` arm. (The malformed-JSON
321    // and invalid-schema tests cover the parse/validate arms but not this one.)
322    #[tokio::test]
323    async fn test_read_manifest_io_error_propagates() {
324        let dir = tempfile::tempdir().unwrap();
325        // Path exists but is a directory, so read_to_string fails with an I/O
326        // error whose kind is NOT NotFound.
327        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    // Regression: write_manifest -> read_manifest must preserve the full record,
340    // not merely the patch count. Guards against a serializer that drops nested
341    // fields (file hashes, vulnerabilities) while still round-tripping the keys.
342    #[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        // Deep equality: every patch, file, hash, and vulnerability survives.
352        assert_eq!(read_back, manifest);
353
354        // Spot-check a nested hash to make the intent explicit.
355        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}