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(path: impl AsRef<Path>) -> Result<Option<PatchManifest>, std::io::Error> {
57    let path = path.as_ref();
58
59    let content = match tokio::fs::read_to_string(path).await {
60        Ok(c) => c,
61        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
62        Err(e) => return Err(e),   // FIX: propagate actual I/O error
63    };
64
65    let parsed: serde_json::Value = match serde_json::from_str(&content) {
66        Ok(v) => v,
67        Err(e) => return Err(std::io::Error::new(
68            std::io::ErrorKind::InvalidData,
69            format!("Failed to parse manifest JSON: {}", e),
70        )),
71    };
72
73    match validate_manifest(&parsed) {
74        Ok(manifest) => Ok(Some(manifest)),
75        Err(e) => Err(std::io::Error::new(
76            std::io::ErrorKind::InvalidData,
77            e,
78        )),
79    }
80}
81
82/// Write a manifest to the filesystem with pretty-printed JSON.
83pub async fn write_manifest(
84    path: impl AsRef<Path>,
85    manifest: &PatchManifest,
86) -> Result<(), std::io::Error> {
87    let content = serde_json::to_string_pretty(manifest)
88        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
89    tokio::fs::write(path, content).await
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use crate::manifest::schema::{PatchFileInfo, PatchRecord};
96    use std::collections::HashMap;
97
98    const TEST_UUID_1: &str = "11111111-1111-4111-8111-111111111111";
99    const TEST_UUID_2: &str = "22222222-2222-4222-8222-222222222222";
100
101    const BEFORE_HASH_1: &str =
102        "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1111";
103    const AFTER_HASH_1: &str =
104        "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb1111";
105    const BEFORE_HASH_2: &str =
106        "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc2222";
107    const AFTER_HASH_2: &str =
108        "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd2222";
109    const BEFORE_HASH_3: &str =
110        "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee3333";
111    const AFTER_HASH_3: &str =
112        "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff3333";
113
114    fn create_test_manifest() -> PatchManifest {
115        let mut patches = HashMap::new();
116
117        let mut files_a = HashMap::new();
118        files_a.insert(
119            "package/index.js".to_string(),
120            PatchFileInfo {
121                before_hash: BEFORE_HASH_1.to_string(),
122                after_hash: AFTER_HASH_1.to_string(),
123            },
124        );
125        files_a.insert(
126            "package/lib/utils.js".to_string(),
127            PatchFileInfo {
128                before_hash: BEFORE_HASH_2.to_string(),
129                after_hash: AFTER_HASH_2.to_string(),
130            },
131        );
132
133        patches.insert(
134            "pkg:npm/pkg-a@1.0.0".to_string(),
135            PatchRecord {
136                uuid: TEST_UUID_1.to_string(),
137                exported_at: "2024-01-01T00:00:00Z".to_string(),
138                files: files_a,
139                vulnerabilities: HashMap::new(),
140                description: "Test patch 1".to_string(),
141                license: "MIT".to_string(),
142                tier: "free".to_string(),
143            },
144        );
145
146        let mut files_b = HashMap::new();
147        files_b.insert(
148            "package/main.js".to_string(),
149            PatchFileInfo {
150                before_hash: BEFORE_HASH_3.to_string(),
151                after_hash: AFTER_HASH_3.to_string(),
152            },
153        );
154
155        patches.insert(
156            "pkg:npm/pkg-b@2.0.0".to_string(),
157            PatchRecord {
158                uuid: TEST_UUID_2.to_string(),
159                exported_at: "2024-01-01T00:00:00Z".to_string(),
160                files: files_b,
161                vulnerabilities: HashMap::new(),
162                description: "Test patch 2".to_string(),
163                license: "MIT".to_string(),
164                tier: "free".to_string(),
165            },
166        );
167
168        PatchManifest { patches }
169    }
170
171    #[test]
172    fn test_get_after_hash_blobs() {
173        let manifest = create_test_manifest();
174        let blobs = get_after_hash_blobs(&manifest);
175
176        assert_eq!(blobs.len(), 3);
177        assert!(blobs.contains(AFTER_HASH_1));
178        assert!(blobs.contains(AFTER_HASH_2));
179        assert!(blobs.contains(AFTER_HASH_3));
180        assert!(!blobs.contains(BEFORE_HASH_1));
181        assert!(!blobs.contains(BEFORE_HASH_2));
182        assert!(!blobs.contains(BEFORE_HASH_3));
183    }
184
185    #[test]
186    fn test_get_after_hash_blobs_empty() {
187        let manifest = PatchManifest::new();
188        let blobs = get_after_hash_blobs(&manifest);
189        assert_eq!(blobs.len(), 0);
190    }
191
192    #[test]
193    fn test_get_before_hash_blobs() {
194        let manifest = create_test_manifest();
195        let blobs = get_before_hash_blobs(&manifest);
196
197        assert_eq!(blobs.len(), 3);
198        assert!(blobs.contains(BEFORE_HASH_1));
199        assert!(blobs.contains(BEFORE_HASH_2));
200        assert!(blobs.contains(BEFORE_HASH_3));
201        assert!(!blobs.contains(AFTER_HASH_1));
202        assert!(!blobs.contains(AFTER_HASH_2));
203        assert!(!blobs.contains(AFTER_HASH_3));
204    }
205
206    #[test]
207    fn test_get_before_hash_blobs_empty() {
208        let manifest = PatchManifest::new();
209        let blobs = get_before_hash_blobs(&manifest);
210        assert_eq!(blobs.len(), 0);
211    }
212
213
214    #[test]
215    fn test_validate_manifest_valid() {
216        let json = serde_json::json!({
217            "patches": {
218                "pkg:npm/test@1.0.0": {
219                    "uuid": "11111111-1111-4111-8111-111111111111",
220                    "exportedAt": "2024-01-01T00:00:00Z",
221                    "files": {},
222                    "vulnerabilities": {},
223                    "description": "test",
224                    "license": "MIT",
225                    "tier": "free"
226                }
227            }
228        });
229
230        let result = validate_manifest(&json);
231        assert!(result.is_ok());
232        let manifest = result.unwrap();
233        assert_eq!(manifest.patches.len(), 1);
234    }
235
236    #[test]
237    fn test_validate_manifest_invalid() {
238        let json = serde_json::json!({
239            "patches": "not-an-object"
240        });
241
242        let result = validate_manifest(&json);
243        assert!(result.is_err());
244    }
245
246    #[test]
247    fn test_validate_manifest_missing_fields() {
248        let json = serde_json::json!({
249            "patches": {
250                "pkg:npm/test@1.0.0": {
251                    "uuid": "test"
252                }
253            }
254        });
255
256        let result = validate_manifest(&json);
257        assert!(result.is_err());
258    }
259
260    #[tokio::test]
261    async fn test_read_manifest_not_found() {
262        let result = read_manifest("/nonexistent/path/manifest.json").await;
263        assert!(result.is_ok());
264        assert!(result.unwrap().is_none());
265    }
266
267    #[tokio::test]
268    async fn test_write_and_read_manifest() {
269        let dir = tempfile::tempdir().unwrap();
270        let path = dir.path().join("manifest.json");
271
272        let manifest = create_test_manifest();
273        write_manifest(&path, &manifest).await.unwrap();
274
275        let read_back = read_manifest(&path).await.unwrap();
276        assert!(read_back.is_some());
277        let read_back = read_back.unwrap();
278        assert_eq!(read_back.patches.len(), 2);
279    }
280
281    #[test]
282    fn test_resolve_manifest_path_relative_joins_cwd() {
283        let cwd = Path::new("/tmp/proj");
284        let resolved = resolve_manifest_path(cwd, ".socket/manifest.json");
285        assert_eq!(resolved, PathBuf::from("/tmp/proj/.socket/manifest.json"));
286    }
287
288    #[test]
289    fn test_resolve_manifest_path_absolute_unchanged() {
290        let cwd = Path::new("/tmp/proj");
291        let absolute = if cfg!(windows) {
292            r"C:\custom\manifest.json"
293        } else {
294            "/etc/custom/manifest.json"
295        };
296        let resolved = resolve_manifest_path(cwd, absolute);
297        assert_eq!(resolved, PathBuf::from(absolute));
298    }
299
300    #[test]
301    fn test_resolve_manifest_path_relative_dotted() {
302        let cwd = Path::new("/tmp/proj");
303        let resolved = resolve_manifest_path(cwd, "../manifest.json");
304        assert_eq!(resolved, PathBuf::from("/tmp/proj/../manifest.json"));
305    }
306}