Skip to main content

socket_patch_core/package_json/
update.rs

1use std::path::Path;
2use tokio::fs;
3
4use super::detect::{is_setup_configured_str, update_package_json_content, PackageManager};
5
6/// Result of updating a single package.json.
7#[derive(Debug, Clone)]
8pub struct UpdateResult {
9    pub path: String,
10    pub status: UpdateStatus,
11    pub old_script: String,
12    pub new_script: String,
13    pub old_dependencies_script: String,
14    pub new_dependencies_script: String,
15    pub error: Option<String>,
16}
17
18#[derive(Debug, Clone, PartialEq)]
19pub enum UpdateStatus {
20    Updated,
21    AlreadyConfigured,
22    Error,
23}
24
25/// Update a single package.json file with socket-patch lifecycle scripts.
26pub async fn update_package_json(
27    package_json_path: &Path,
28    dry_run: bool,
29    pm: PackageManager,
30) -> UpdateResult {
31    let path_str = package_json_path.display().to_string();
32
33    let content = match fs::read_to_string(package_json_path).await {
34        Ok(c) => c,
35        Err(e) => {
36            return UpdateResult {
37                path: path_str,
38                status: UpdateStatus::Error,
39                old_script: String::new(),
40                new_script: String::new(),
41                old_dependencies_script: String::new(),
42                new_dependencies_script: String::new(),
43                error: Some(e.to_string()),
44            };
45        }
46    };
47
48    let status = is_setup_configured_str(&content);
49    if !status.needs_update {
50        return UpdateResult {
51            path: path_str,
52            status: UpdateStatus::AlreadyConfigured,
53            old_script: status.postinstall_script.clone(),
54            new_script: status.postinstall_script,
55            old_dependencies_script: status.dependencies_script.clone(),
56            new_dependencies_script: status.dependencies_script,
57            error: None,
58        };
59    }
60
61    match update_package_json_content(&content, pm) {
62        Ok((modified, new_content, old_pi, new_pi, old_dep, new_dep)) => {
63            if !modified {
64                return UpdateResult {
65                    path: path_str,
66                    status: UpdateStatus::AlreadyConfigured,
67                    old_script: old_pi,
68                    new_script: new_pi,
69                    old_dependencies_script: old_dep,
70                    new_dependencies_script: new_dep,
71                    error: None,
72                };
73            }
74
75            if !dry_run {
76                if let Err(e) = fs::write(package_json_path, &new_content).await {
77                    return UpdateResult {
78                        path: path_str,
79                        status: UpdateStatus::Error,
80                        old_script: old_pi,
81                        new_script: new_pi,
82                        old_dependencies_script: old_dep,
83                        new_dependencies_script: new_dep,
84                        error: Some(e.to_string()),
85                    };
86                }
87            }
88
89            UpdateResult {
90                path: path_str,
91                status: UpdateStatus::Updated,
92                old_script: old_pi,
93                new_script: new_pi,
94                old_dependencies_script: old_dep,
95                new_dependencies_script: new_dep,
96                error: None,
97            }
98        }
99        Err(e) => UpdateResult {
100            path: path_str,
101            status: UpdateStatus::Error,
102            old_script: String::new(),
103            new_script: String::new(),
104            old_dependencies_script: String::new(),
105            new_dependencies_script: String::new(),
106            error: Some(e),
107        },
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[tokio::test]
116    async fn test_update_file_not_found() {
117        let dir = tempfile::tempdir().unwrap();
118        let missing = dir.path().join("nonexistent.json");
119        let result = update_package_json(&missing, false, PackageManager::Npm).await;
120        assert_eq!(result.status, UpdateStatus::Error);
121        assert!(result.error.is_some());
122    }
123
124    #[tokio::test]
125    async fn test_update_already_configured() {
126        let dir = tempfile::tempdir().unwrap();
127        let pkg = dir.path().join("package.json");
128        fs::write(
129            &pkg,
130            r#"{"name":"test","scripts":{"postinstall":"npx @socketsecurity/socket-patch apply --silent --ecosystems npm","dependencies":"npx @socketsecurity/socket-patch apply --silent --ecosystems npm"}}"#,
131        )
132        .await
133        .unwrap();
134        let result = update_package_json(&pkg, false, PackageManager::Npm).await;
135        assert_eq!(result.status, UpdateStatus::AlreadyConfigured);
136    }
137
138    #[tokio::test]
139    async fn test_update_dry_run_does_not_write() {
140        let dir = tempfile::tempdir().unwrap();
141        let pkg = dir.path().join("package.json");
142        let original = r#"{"name":"test","scripts":{"build":"tsc"}}"#;
143        fs::write(&pkg, original).await.unwrap();
144        let result = update_package_json(&pkg, true, PackageManager::Npm).await;
145        assert_eq!(result.status, UpdateStatus::Updated);
146        // File should remain unchanged
147        let content = fs::read_to_string(&pkg).await.unwrap();
148        assert_eq!(content, original);
149    }
150
151    #[tokio::test]
152    async fn test_update_writes_file() {
153        let dir = tempfile::tempdir().unwrap();
154        let pkg = dir.path().join("package.json");
155        fs::write(&pkg, r#"{"name":"test","scripts":{"build":"tsc"}}"#)
156            .await
157            .unwrap();
158        let result = update_package_json(&pkg, false, PackageManager::Npm).await;
159        assert_eq!(result.status, UpdateStatus::Updated);
160        let content = fs::read_to_string(&pkg).await.unwrap();
161        assert!(content.contains("npx @socketsecurity/socket-patch apply"));
162        assert!(content.contains("postinstall"));
163        assert!(content.contains("dependencies"));
164    }
165
166    #[tokio::test]
167    async fn test_update_invalid_json() {
168        let dir = tempfile::tempdir().unwrap();
169        let pkg = dir.path().join("package.json");
170        fs::write(&pkg, "not json!!!").await.unwrap();
171        let result = update_package_json(&pkg, false, PackageManager::Npm).await;
172        assert_eq!(result.status, UpdateStatus::Error);
173        assert!(result.error.is_some());
174    }
175
176    #[tokio::test]
177    async fn test_update_no_scripts_key() {
178        let dir = tempfile::tempdir().unwrap();
179        let pkg = dir.path().join("package.json");
180        fs::write(&pkg, r#"{"name":"x"}"#).await.unwrap();
181        let result = update_package_json(&pkg, false, PackageManager::Npm).await;
182        assert_eq!(result.status, UpdateStatus::Updated);
183        let content = fs::read_to_string(&pkg).await.unwrap();
184        assert!(content.contains("postinstall"));
185        assert!(content.contains("dependencies"));
186        assert!(content.contains("npx @socketsecurity/socket-patch apply"));
187    }
188
189    #[tokio::test]
190    async fn test_update_pnpm() {
191        let dir = tempfile::tempdir().unwrap();
192        let pkg = dir.path().join("package.json");
193        fs::write(&pkg, r#"{"name":"x"}"#).await.unwrap();
194        let result = update_package_json(&pkg, false, PackageManager::Pnpm).await;
195        assert_eq!(result.status, UpdateStatus::Updated);
196        let content = fs::read_to_string(&pkg).await.unwrap();
197        assert!(content.contains("pnpm dlx @socketsecurity/socket-patch apply"));
198    }
199
200    #[tokio::test]
201    async fn test_update_adds_dependencies_when_postinstall_exists() {
202        let dir = tempfile::tempdir().unwrap();
203        let pkg = dir.path().join("package.json");
204        fs::write(
205            &pkg,
206            r#"{"name":"test","scripts":{"postinstall":"npx @socketsecurity/socket-patch apply --silent --ecosystems npm"}}"#,
207        )
208        .await
209        .unwrap();
210        let result = update_package_json(&pkg, false, PackageManager::Npm).await;
211        assert_eq!(result.status, UpdateStatus::Updated);
212        let content = fs::read_to_string(&pkg).await.unwrap();
213        assert!(content.contains("dependencies"));
214    }
215
216    /// Writing back the user's package.json must not reorder their existing
217    /// keys. Without `serde_json/preserve_order` the value map is sorted
218    /// alphabetically, so a file like `{"version":..,"name":..}` would be
219    /// rewritten as `{"name":..,"version":..}` — a destructive, noisy diff
220    /// over something the tool only meant to append two scripts to.
221    #[tokio::test]
222    async fn test_update_preserves_top_level_key_order() {
223        let dir = tempfile::tempdir().unwrap();
224        let pkg = dir.path().join("package.json");
225        // Deliberately non-alphabetical key order.
226        fs::write(
227            &pkg,
228            r#"{"version":"1.0.0","name":"x","private":true,"scripts":{"build":"tsc"}}"#,
229        )
230        .await
231        .unwrap();
232        let result = update_package_json(&pkg, false, PackageManager::Npm).await;
233        assert_eq!(result.status, UpdateStatus::Updated);
234
235        let content = fs::read_to_string(&pkg).await.unwrap();
236        let pos_version = content.find("\"version\"").unwrap();
237        let pos_name = content.find("\"name\"").unwrap();
238        let pos_private = content.find("\"private\"").unwrap();
239        let pos_scripts = content.find("\"scripts\"").unwrap();
240        assert!(
241            pos_version < pos_name && pos_name < pos_private && pos_private < pos_scripts,
242            "original top-level key order must be preserved, got:\n{content}"
243        );
244    }
245
246    /// The pre-existing `build` script (and its position) must survive an
247    /// update that only appends the lifecycle scripts.
248    #[tokio::test]
249    async fn test_update_preserves_existing_scripts() {
250        let dir = tempfile::tempdir().unwrap();
251        let pkg = dir.path().join("package.json");
252        fs::write(
253            &pkg,
254            r#"{"name":"x","scripts":{"build":"tsc","test":"jest"}}"#,
255        )
256        .await
257        .unwrap();
258        let result = update_package_json(&pkg, false, PackageManager::Npm).await;
259        assert_eq!(result.status, UpdateStatus::Updated);
260
261        let parsed: serde_json::Value =
262            serde_json::from_str(&fs::read_to_string(&pkg).await.unwrap()).unwrap();
263        assert_eq!(parsed["scripts"]["build"], "tsc");
264        assert_eq!(parsed["scripts"]["test"], "jest");
265        assert!(parsed["scripts"]["postinstall"].is_string());
266        assert!(parsed["scripts"]["dependencies"].is_string());
267    }
268
269    /// Running setup twice must be idempotent: the second run reports
270    /// `AlreadyConfigured` and leaves the file byte-for-byte unchanged (no
271    /// duplicated `socket-patch apply` commands).
272    #[tokio::test]
273    async fn test_update_is_idempotent() {
274        let dir = tempfile::tempdir().unwrap();
275        let pkg = dir.path().join("package.json");
276        fs::write(&pkg, r#"{"name":"x","scripts":{"build":"tsc"}}"#)
277            .await
278            .unwrap();
279
280        let r1 = update_package_json(&pkg, false, PackageManager::Npm).await;
281        assert_eq!(r1.status, UpdateStatus::Updated);
282        let after_first = fs::read_to_string(&pkg).await.unwrap();
283
284        let r2 = update_package_json(&pkg, false, PackageManager::Npm).await;
285        assert_eq!(r2.status, UpdateStatus::AlreadyConfigured);
286        let after_second = fs::read_to_string(&pkg).await.unwrap();
287
288        assert_eq!(after_first, after_second);
289        assert_eq!(after_first.matches("socket-patch apply").count(), 2);
290    }
291
292    /// Valid JSON whose root is not an object cannot hold lifecycle scripts;
293    /// it must surface an error rather than panicking or silently succeeding.
294    #[tokio::test]
295    async fn test_update_non_object_root_errors() {
296        let dir = tempfile::tempdir().unwrap();
297        for (i, body) in ["[1,2,3]", "42", "\"hi\"", "true", "null"]
298            .iter()
299            .enumerate()
300        {
301            let pkg = dir.path().join(format!("pkg{i}.json"));
302            fs::write(&pkg, body).await.unwrap();
303            let result = update_package_json(&pkg, false, PackageManager::Npm).await;
304            assert_eq!(result.status, UpdateStatus::Error, "body={body}");
305            assert!(result.error.is_some(), "body={body}");
306        }
307    }
308
309    /// A present-but-non-object `scripts` is malformed; refuse to clobber it.
310    #[tokio::test]
311    async fn test_update_non_object_scripts_errors_and_leaves_file() {
312        let dir = tempfile::tempdir().unwrap();
313        let pkg = dir.path().join("package.json");
314        let original = r#"{"name":"x","scripts":"build"}"#;
315        fs::write(&pkg, original).await.unwrap();
316        let result = update_package_json(&pkg, false, PackageManager::Npm).await;
317        assert_eq!(result.status, UpdateStatus::Error);
318        // File must be left untouched.
319        assert_eq!(fs::read_to_string(&pkg).await.unwrap(), original);
320    }
321
322    /// An empty file is invalid JSON and must error without writing.
323    #[tokio::test]
324    async fn test_update_empty_file_errors() {
325        let dir = tempfile::tempdir().unwrap();
326        let pkg = dir.path().join("package.json");
327        fs::write(&pkg, "").await.unwrap();
328        let result = update_package_json(&pkg, false, PackageManager::Npm).await;
329        assert_eq!(result.status, UpdateStatus::Error);
330        assert!(result.error.is_some());
331    }
332
333    /// Dry-run on a file that needs updating reports `Updated` but must not
334    /// touch the bytes on disk — the consumer relies on this for its preview.
335    #[tokio::test]
336    async fn test_update_dry_run_reports_updated_without_writing_scripts() {
337        let dir = tempfile::tempdir().unwrap();
338        let pkg = dir.path().join("package.json");
339        let original = r#"{"name":"x","scripts":{"postinstall":"echo hi"}}"#;
340        fs::write(&pkg, original).await.unwrap();
341        let result = update_package_json(&pkg, true, PackageManager::Npm).await;
342        assert_eq!(result.status, UpdateStatus::Updated);
343        // old_script reflects the existing script; new_script the prepended one.
344        assert_eq!(result.old_script, "echo hi");
345        assert!(result.new_script.contains("socket-patch apply"));
346        assert!(result.new_script.contains("echo hi"));
347        assert_eq!(fs::read_to_string(&pkg).await.unwrap(), original);
348    }
349}