Skip to main content

socket_patch_core/package_json/
detect.rs

1/// Package manager type for selecting the correct command prefix.
2#[derive(Debug, Clone, Copy, PartialEq)]
3pub enum PackageManager {
4    Npm,
5    Pnpm,
6}
7
8/// Get the socket-patch apply command for the given package manager.
9fn socket_patch_command(pm: PackageManager) -> &'static str {
10    match pm {
11        PackageManager::Npm => "npx @socketsecurity/socket-patch apply --silent --ecosystems npm",
12        PackageManager::Pnpm => {
13            "pnpm dlx @socketsecurity/socket-patch apply --silent --ecosystems npm"
14        }
15    }
16}
17
18/// Legacy command patterns to detect existing configurations.
19const LEGACY_PATCH_PATTERNS: &[&str] = &[
20    "socket-patch apply",
21    "npx @socketsecurity/socket-patch apply",
22    "socket patch apply",
23];
24
25/// Check if a script string contains any known socket-patch apply pattern.
26fn script_is_configured(script: &str) -> bool {
27    LEGACY_PATCH_PATTERNS
28        .iter()
29        .any(|pattern| script.contains(pattern))
30}
31
32/// Status of setup script configuration (both postinstall and dependencies).
33#[derive(Debug, Clone)]
34pub struct ScriptSetupStatus {
35    pub postinstall_configured: bool,
36    pub postinstall_script: String,
37    pub dependencies_configured: bool,
38    pub dependencies_script: String,
39    pub needs_update: bool,
40}
41
42/// Check if package.json scripts are properly configured for socket-patch.
43/// Checks both the postinstall and dependencies lifecycle scripts.
44pub fn is_setup_configured(package_json: &serde_json::Value) -> ScriptSetupStatus {
45    let scripts = package_json.get("scripts");
46
47    let postinstall_script = scripts
48        .and_then(|s| s.get("postinstall"))
49        .and_then(|v| v.as_str())
50        .unwrap_or("")
51        .to_string();
52    let postinstall_configured = script_is_configured(&postinstall_script);
53
54    let dependencies_script = scripts
55        .and_then(|s| s.get("dependencies"))
56        .and_then(|v| v.as_str())
57        .unwrap_or("")
58        .to_string();
59    let dependencies_configured = script_is_configured(&dependencies_script);
60
61    ScriptSetupStatus {
62        postinstall_configured,
63        postinstall_script,
64        dependencies_configured,
65        dependencies_script,
66        needs_update: !postinstall_configured || !dependencies_configured,
67    }
68}
69
70/// Check if a package.json content string is properly configured.
71pub fn is_setup_configured_str(content: &str) -> ScriptSetupStatus {
72    match serde_json::from_str::<serde_json::Value>(content) {
73        Ok(val) => is_setup_configured(&val),
74        Err(_) => ScriptSetupStatus {
75            postinstall_configured: false,
76            postinstall_script: String::new(),
77            dependencies_configured: false,
78            dependencies_script: String::new(),
79            needs_update: true,
80        },
81    }
82}
83
84/// Generate an updated script that includes the socket-patch apply command.
85/// If already configured, returns unchanged. Otherwise prepends the command.
86pub fn generate_updated_script(current_script: &str, pm: PackageManager) -> String {
87    let command = socket_patch_command(pm);
88    let trimmed = current_script.trim();
89
90    // If empty, just add the socket-patch command.
91    if trimmed.is_empty() {
92        return command.to_string();
93    }
94
95    // If any socket-patch variant is already present, return unchanged.
96    if script_is_configured(trimmed) {
97        return trimmed.to_string();
98    }
99
100    // Prepend socket-patch command so it runs first.
101    format!("{command} && {trimmed}")
102}
103
104/// Update a package.json Value with socket-patch in both postinstall and
105/// dependencies scripts.
106/// Returns (modified, new_postinstall, new_dependencies).
107pub fn update_package_json_object(
108    package_json: &mut serde_json::Value,
109    pm: PackageManager,
110) -> (bool, String, String) {
111    let status = is_setup_configured(package_json);
112
113    if !status.needs_update {
114        return (
115            false,
116            status.postinstall_script,
117            status.dependencies_script,
118        );
119    }
120
121    // Ensure scripts object exists
122    if package_json.get("scripts").is_none() {
123        package_json["scripts"] = serde_json::json!({});
124    }
125
126    let mut modified = false;
127
128    let new_postinstall = if !status.postinstall_configured {
129        modified = true;
130        let s = generate_updated_script(&status.postinstall_script, pm);
131        package_json["scripts"]["postinstall"] = serde_json::Value::String(s.clone());
132        s
133    } else {
134        status.postinstall_script
135    };
136
137    let new_dependencies = if !status.dependencies_configured {
138        modified = true;
139        let s = generate_updated_script(&status.dependencies_script, pm);
140        package_json["scripts"]["dependencies"] = serde_json::Value::String(s.clone());
141        s
142    } else {
143        status.dependencies_script
144    };
145
146    (modified, new_postinstall, new_dependencies)
147}
148
149/// Parse package.json content and update it with socket-patch scripts.
150/// Returns (modified, new_content, old_postinstall, new_postinstall,
151/// old_dependencies, new_dependencies).
152pub fn update_package_json_content(
153    content: &str,
154    pm: PackageManager,
155) -> Result<(bool, String, String, String, String, String), String> {
156    let mut package_json: serde_json::Value =
157        serde_json::from_str(content).map_err(|e| format!("Invalid package.json: {e}"))?;
158
159    let status = is_setup_configured(&package_json);
160
161    if !status.needs_update {
162        return Ok((
163            false,
164            content.to_string(),
165            status.postinstall_script.clone(),
166            status.postinstall_script,
167            status.dependencies_script.clone(),
168            status.dependencies_script,
169        ));
170    }
171
172    let old_postinstall = status.postinstall_script.clone();
173    let old_dependencies = status.dependencies_script.clone();
174
175    let (_, new_postinstall, new_dependencies) =
176        update_package_json_object(&mut package_json, pm);
177    let new_content = serde_json::to_string_pretty(&package_json).unwrap() + "\n";
178
179    Ok((
180        true,
181        new_content,
182        old_postinstall,
183        new_postinstall,
184        old_dependencies,
185        new_dependencies,
186    ))
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    // ── is_setup_configured ─────────────────────────────────────────
194
195    #[test]
196    fn test_not_configured() {
197        let pkg: serde_json::Value = serde_json::json!({
198            "name": "test",
199            "scripts": {
200                "build": "tsc"
201            }
202        });
203        let status = is_setup_configured(&pkg);
204        assert!(!status.postinstall_configured);
205        assert!(!status.dependencies_configured);
206        assert!(status.needs_update);
207    }
208
209    #[test]
210    fn test_postinstall_configured_dependencies_not() {
211        let pkg: serde_json::Value = serde_json::json!({
212            "name": "test",
213            "scripts": {
214                "postinstall": "npx @socketsecurity/socket-patch apply --silent --ecosystems npm"
215            }
216        });
217        let status = is_setup_configured(&pkg);
218        assert!(status.postinstall_configured);
219        assert!(!status.dependencies_configured);
220        assert!(status.needs_update);
221    }
222
223    #[test]
224    fn test_both_configured() {
225        let pkg: serde_json::Value = serde_json::json!({
226            "name": "test",
227            "scripts": {
228                "postinstall": "npx @socketsecurity/socket-patch apply --silent --ecosystems npm",
229                "dependencies": "npx @socketsecurity/socket-patch apply --silent --ecosystems npm"
230            }
231        });
232        let status = is_setup_configured(&pkg);
233        assert!(status.postinstall_configured);
234        assert!(status.dependencies_configured);
235        assert!(!status.needs_update);
236    }
237
238    #[test]
239    fn test_legacy_socket_patch_apply_recognized() {
240        let pkg: serde_json::Value = serde_json::json!({
241            "scripts": {
242                "postinstall": "socket patch apply --silent --ecosystems npm",
243                "dependencies": "socket-patch apply"
244            }
245        });
246        let status = is_setup_configured(&pkg);
247        assert!(status.postinstall_configured);
248        assert!(status.dependencies_configured);
249        assert!(!status.needs_update);
250    }
251
252    #[test]
253    fn test_no_scripts() {
254        let pkg: serde_json::Value = serde_json::json!({"name": "test"});
255        let status = is_setup_configured(&pkg);
256        assert!(!status.postinstall_configured);
257        assert!(status.postinstall_script.is_empty());
258        assert!(!status.dependencies_configured);
259        assert!(status.dependencies_script.is_empty());
260    }
261
262    #[test]
263    fn test_no_postinstall() {
264        let pkg: serde_json::Value = serde_json::json!({
265            "scripts": {"build": "tsc"}
266        });
267        let status = is_setup_configured(&pkg);
268        assert!(!status.postinstall_configured);
269        assert!(status.postinstall_script.is_empty());
270    }
271
272    // ── is_setup_configured_str ─────────────────────────────────────
273
274    #[test]
275    fn test_configured_str_invalid_json() {
276        let status = is_setup_configured_str("not json");
277        assert!(!status.postinstall_configured);
278        assert!(status.needs_update);
279    }
280
281    #[test]
282    fn test_configured_str_legacy_npx_pattern() {
283        let content = r#"{"scripts":{"postinstall":"npx @socketsecurity/socket-patch apply --silent"}}"#;
284        let status = is_setup_configured_str(content);
285        assert!(status.postinstall_configured);
286    }
287
288    #[test]
289    fn test_configured_str_socket_dash_patch() {
290        let content =
291            r#"{"scripts":{"postinstall":"socket-patch apply --silent --ecosystems npm"}}"#;
292        let status = is_setup_configured_str(content);
293        assert!(status.postinstall_configured);
294    }
295
296    #[test]
297    fn test_configured_str_pnpm_dlx_pattern() {
298        let content = r#"{"scripts":{"postinstall":"pnpm dlx @socketsecurity/socket-patch apply --silent --ecosystems npm"}}"#;
299        let status = is_setup_configured_str(content);
300        // "pnpm dlx @socketsecurity/socket-patch apply" contains "socket-patch apply"
301        assert!(status.postinstall_configured);
302    }
303
304    // ── generate_updated_script ─────────────────────────────────────
305
306    #[test]
307    fn test_generate_empty_npm() {
308        assert_eq!(
309            generate_updated_script("", PackageManager::Npm),
310            "npx @socketsecurity/socket-patch apply --silent --ecosystems npm"
311        );
312    }
313
314    #[test]
315    fn test_generate_empty_pnpm() {
316        assert_eq!(
317            generate_updated_script("", PackageManager::Pnpm),
318            "pnpm dlx @socketsecurity/socket-patch apply --silent --ecosystems npm"
319        );
320    }
321
322    #[test]
323    fn test_generate_prepend_npm() {
324        assert_eq!(
325            generate_updated_script("echo done", PackageManager::Npm),
326            "npx @socketsecurity/socket-patch apply --silent --ecosystems npm && echo done"
327        );
328    }
329
330    #[test]
331    fn test_generate_prepend_pnpm() {
332        assert_eq!(
333            generate_updated_script("echo done", PackageManager::Pnpm),
334            "pnpm dlx @socketsecurity/socket-patch apply --silent --ecosystems npm && echo done"
335        );
336    }
337
338    #[test]
339    fn test_generate_already_configured() {
340        let current = "socket-patch apply && echo done";
341        assert_eq!(
342            generate_updated_script(current, PackageManager::Npm),
343            current
344        );
345    }
346
347    #[test]
348    fn test_generate_whitespace_only() {
349        let result = generate_updated_script("  \t  ", PackageManager::Npm);
350        assert_eq!(
351            result,
352            "npx @socketsecurity/socket-patch apply --silent --ecosystems npm"
353        );
354    }
355
356    // ── update_package_json_object ──────────────────────────────────
357
358    #[test]
359    fn test_update_object_creates_scripts() {
360        let mut pkg: serde_json::Value = serde_json::json!({"name": "test"});
361        let (modified, new_postinstall, new_dependencies) =
362            update_package_json_object(&mut pkg, PackageManager::Npm);
363        assert!(modified);
364        assert!(new_postinstall.contains("npx @socketsecurity/socket-patch apply"));
365        assert!(new_dependencies.contains("npx @socketsecurity/socket-patch apply"));
366        assert!(pkg.get("scripts").is_some());
367        assert!(pkg["scripts"]["postinstall"].is_string());
368        assert!(pkg["scripts"]["dependencies"].is_string());
369    }
370
371    #[test]
372    fn test_update_object_creates_scripts_pnpm() {
373        let mut pkg: serde_json::Value = serde_json::json!({"name": "test"});
374        let (modified, new_postinstall, new_dependencies) =
375            update_package_json_object(&mut pkg, PackageManager::Pnpm);
376        assert!(modified);
377        assert!(new_postinstall.contains("pnpm dlx @socketsecurity/socket-patch apply"));
378        assert!(new_dependencies.contains("pnpm dlx @socketsecurity/socket-patch apply"));
379    }
380
381    #[test]
382    fn test_update_object_noop_when_both_configured() {
383        let mut pkg: serde_json::Value = serde_json::json!({
384            "scripts": {
385                "postinstall": "npx @socketsecurity/socket-patch apply --silent --ecosystems npm",
386                "dependencies": "npx @socketsecurity/socket-patch apply --silent --ecosystems npm"
387            }
388        });
389        let (modified, _, _) = update_package_json_object(&mut pkg, PackageManager::Npm);
390        assert!(!modified);
391    }
392
393    #[test]
394    fn test_update_object_adds_dependencies_when_postinstall_exists() {
395        let mut pkg: serde_json::Value = serde_json::json!({
396            "scripts": {
397                "postinstall": "npx @socketsecurity/socket-patch apply --silent --ecosystems npm"
398            }
399        });
400        let (modified, _, new_dependencies) =
401            update_package_json_object(&mut pkg, PackageManager::Npm);
402        assert!(modified);
403        assert!(new_dependencies.contains("npx @socketsecurity/socket-patch apply"));
404        // postinstall should remain unchanged
405        assert_eq!(
406            pkg["scripts"]["postinstall"].as_str().unwrap(),
407            "npx @socketsecurity/socket-patch apply --silent --ecosystems npm"
408        );
409    }
410
411    // ── update_package_json_content ─────────────────────────────────
412
413    #[test]
414    fn test_update_content_roundtrip_no_scripts() {
415        let content = r#"{"name": "test"}"#;
416        let (modified, new_content, old_pi, new_pi, old_dep, new_dep) =
417            update_package_json_content(content, PackageManager::Npm).unwrap();
418        assert!(modified);
419        assert!(old_pi.is_empty());
420        assert!(new_pi.contains("npx @socketsecurity/socket-patch apply"));
421        assert!(old_dep.is_empty());
422        assert!(new_dep.contains("npx @socketsecurity/socket-patch apply"));
423        let parsed: serde_json::Value = serde_json::from_str(&new_content).unwrap();
424        assert!(parsed["scripts"]["postinstall"].is_string());
425        assert!(parsed["scripts"]["dependencies"].is_string());
426    }
427
428    #[test]
429    fn test_update_content_already_configured() {
430        let content = r#"{"scripts":{"postinstall":"socket patch apply --silent --ecosystems npm","dependencies":"socket patch apply --silent --ecosystems npm"}}"#;
431        let (modified, _, _, _, _, _) =
432            update_package_json_content(content, PackageManager::Npm).unwrap();
433        assert!(!modified);
434    }
435
436    #[test]
437    fn test_update_content_invalid_json() {
438        let result = update_package_json_content("not json", PackageManager::Npm);
439        assert!(result.is_err());
440        assert!(result.unwrap_err().contains("Invalid package.json"));
441    }
442
443    #[test]
444    fn test_update_content_pnpm() {
445        let content = r#"{"name": "test"}"#;
446        let (modified, new_content, _, new_pi, _, new_dep) =
447            update_package_json_content(content, PackageManager::Pnpm).unwrap();
448        assert!(modified);
449        assert!(new_pi.contains("pnpm dlx @socketsecurity/socket-patch apply"));
450        assert!(new_dep.contains("pnpm dlx @socketsecurity/socket-patch apply"));
451        let parsed: serde_json::Value = serde_json::from_str(&new_content).unwrap();
452        assert!(parsed["scripts"]["postinstall"]
453            .as_str()
454            .unwrap()
455            .contains("pnpm dlx"));
456        assert!(parsed["scripts"]["dependencies"]
457            .as_str()
458            .unwrap()
459            .contains("pnpm dlx"));
460    }
461}