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 (false, status.postinstall_script, status.dependencies_script);
115    }
116
117    // We can only attach scripts to an object root. Anything else (array,
118    // string, number, bool, null) cannot hold a "scripts" key, so indexing it
119    // below would panic. Bail out as a no-op instead.
120    if !package_json.is_object() {
121        return (false, status.postinstall_script, status.dependencies_script);
122    }
123
124    // Ensure `scripts` exists *and* is an object. A present-but-non-object
125    // `scripts` (e.g. a string or array) would otherwise panic when indexed.
126    if !package_json
127        .get("scripts")
128        .map(serde_json::Value::is_object)
129        .unwrap_or(false)
130    {
131        package_json["scripts"] = serde_json::json!({});
132    }
133
134    let mut modified = false;
135
136    let new_postinstall = if !status.postinstall_configured {
137        modified = true;
138        let s = generate_updated_script(&status.postinstall_script, pm);
139        package_json["scripts"]["postinstall"] = serde_json::Value::String(s.clone());
140        s
141    } else {
142        status.postinstall_script
143    };
144
145    let new_dependencies = if !status.dependencies_configured {
146        modified = true;
147        let s = generate_updated_script(&status.dependencies_script, pm);
148        package_json["scripts"]["dependencies"] = serde_json::Value::String(s.clone());
149        s
150    } else {
151        status.dependencies_script
152    };
153
154    (modified, new_postinstall, new_dependencies)
155}
156
157/// Parse package.json content and update it with socket-patch scripts.
158/// Returns (modified, new_content, old_postinstall, new_postinstall,
159/// old_dependencies, new_dependencies).
160pub fn update_package_json_content(
161    content: &str,
162    pm: PackageManager,
163) -> Result<(bool, String, String, String, String, String), String> {
164    let mut package_json: serde_json::Value =
165        serde_json::from_str(content).map_err(|e| format!("Invalid package.json: {e}"))?;
166
167    // A package.json must be a JSON object; otherwise there is nowhere to add
168    // lifecycle scripts.
169    if !package_json.is_object() {
170        return Err("Invalid package.json: root is not a JSON object".to_string());
171    }
172
173    // Refuse to clobber a malformed (present but non-object) `scripts` value.
174    // `null` is treated as absent and replaced with a fresh object downstream.
175    if let Some(scripts) = package_json.get("scripts") {
176        if !scripts.is_null() && !scripts.is_object() {
177            return Err("Invalid package.json: \"scripts\" is not a JSON object".to_string());
178        }
179    }
180
181    let status = is_setup_configured(&package_json);
182
183    if !status.needs_update {
184        return Ok((
185            false,
186            content.to_string(),
187            status.postinstall_script.clone(),
188            status.postinstall_script,
189            status.dependencies_script.clone(),
190            status.dependencies_script,
191        ));
192    }
193
194    let old_postinstall = status.postinstall_script.clone();
195    let old_dependencies = status.dependencies_script.clone();
196
197    let (_, new_postinstall, new_dependencies) = update_package_json_object(&mut package_json, pm);
198    let new_content = serde_json::to_string_pretty(&package_json).unwrap() + "\n";
199
200    Ok((
201        true,
202        new_content,
203        old_postinstall,
204        new_postinstall,
205        old_dependencies,
206        new_dependencies,
207    ))
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    // ── is_setup_configured ─────────────────────────────────────────
215
216    #[test]
217    fn test_not_configured() {
218        let pkg: serde_json::Value = serde_json::json!({
219            "name": "test",
220            "scripts": {
221                "build": "tsc"
222            }
223        });
224        let status = is_setup_configured(&pkg);
225        assert!(!status.postinstall_configured);
226        assert!(!status.dependencies_configured);
227        assert!(status.needs_update);
228    }
229
230    #[test]
231    fn test_postinstall_configured_dependencies_not() {
232        let pkg: serde_json::Value = serde_json::json!({
233            "name": "test",
234            "scripts": {
235                "postinstall": "npx @socketsecurity/socket-patch apply --silent --ecosystems npm"
236            }
237        });
238        let status = is_setup_configured(&pkg);
239        assert!(status.postinstall_configured);
240        assert!(!status.dependencies_configured);
241        assert!(status.needs_update);
242    }
243
244    #[test]
245    fn test_both_configured() {
246        let pkg: serde_json::Value = serde_json::json!({
247            "name": "test",
248            "scripts": {
249                "postinstall": "npx @socketsecurity/socket-patch apply --silent --ecosystems npm",
250                "dependencies": "npx @socketsecurity/socket-patch apply --silent --ecosystems npm"
251            }
252        });
253        let status = is_setup_configured(&pkg);
254        assert!(status.postinstall_configured);
255        assert!(status.dependencies_configured);
256        assert!(!status.needs_update);
257    }
258
259    #[test]
260    fn test_legacy_socket_patch_apply_recognized() {
261        let pkg: serde_json::Value = serde_json::json!({
262            "scripts": {
263                "postinstall": "socket patch apply --silent --ecosystems npm",
264                "dependencies": "socket-patch apply"
265            }
266        });
267        let status = is_setup_configured(&pkg);
268        assert!(status.postinstall_configured);
269        assert!(status.dependencies_configured);
270        assert!(!status.needs_update);
271    }
272
273    #[test]
274    fn test_no_scripts() {
275        let pkg: serde_json::Value = serde_json::json!({"name": "test"});
276        let status = is_setup_configured(&pkg);
277        assert!(!status.postinstall_configured);
278        assert!(status.postinstall_script.is_empty());
279        assert!(!status.dependencies_configured);
280        assert!(status.dependencies_script.is_empty());
281    }
282
283    #[test]
284    fn test_no_postinstall() {
285        let pkg: serde_json::Value = serde_json::json!({
286            "scripts": {"build": "tsc"}
287        });
288        let status = is_setup_configured(&pkg);
289        assert!(!status.postinstall_configured);
290        assert!(status.postinstall_script.is_empty());
291    }
292
293    // ── is_setup_configured_str ─────────────────────────────────────
294
295    #[test]
296    fn test_configured_str_invalid_json() {
297        let status = is_setup_configured_str("not json");
298        assert!(!status.postinstall_configured);
299        assert!(status.needs_update);
300    }
301
302    #[test]
303    fn test_configured_str_legacy_npx_pattern() {
304        let content =
305            r#"{"scripts":{"postinstall":"npx @socketsecurity/socket-patch apply --silent"}}"#;
306        let status = is_setup_configured_str(content);
307        assert!(status.postinstall_configured);
308    }
309
310    #[test]
311    fn test_configured_str_socket_dash_patch() {
312        let content =
313            r#"{"scripts":{"postinstall":"socket-patch apply --silent --ecosystems npm"}}"#;
314        let status = is_setup_configured_str(content);
315        assert!(status.postinstall_configured);
316    }
317
318    #[test]
319    fn test_configured_str_pnpm_dlx_pattern() {
320        let content = r#"{"scripts":{"postinstall":"pnpm dlx @socketsecurity/socket-patch apply --silent --ecosystems npm"}}"#;
321        let status = is_setup_configured_str(content);
322        // "pnpm dlx @socketsecurity/socket-patch apply" contains "socket-patch apply"
323        assert!(status.postinstall_configured);
324    }
325
326    // ── generate_updated_script ─────────────────────────────────────
327
328    #[test]
329    fn test_generate_empty_npm() {
330        assert_eq!(
331            generate_updated_script("", PackageManager::Npm),
332            "npx @socketsecurity/socket-patch apply --silent --ecosystems npm"
333        );
334    }
335
336    #[test]
337    fn test_generate_empty_pnpm() {
338        assert_eq!(
339            generate_updated_script("", PackageManager::Pnpm),
340            "pnpm dlx @socketsecurity/socket-patch apply --silent --ecosystems npm"
341        );
342    }
343
344    #[test]
345    fn test_generate_prepend_npm() {
346        assert_eq!(
347            generate_updated_script("echo done", PackageManager::Npm),
348            "npx @socketsecurity/socket-patch apply --silent --ecosystems npm && echo done"
349        );
350    }
351
352    #[test]
353    fn test_generate_prepend_pnpm() {
354        assert_eq!(
355            generate_updated_script("echo done", PackageManager::Pnpm),
356            "pnpm dlx @socketsecurity/socket-patch apply --silent --ecosystems npm && echo done"
357        );
358    }
359
360    #[test]
361    fn test_generate_already_configured() {
362        let current = "socket-patch apply && echo done";
363        assert_eq!(
364            generate_updated_script(current, PackageManager::Npm),
365            current
366        );
367    }
368
369    #[test]
370    fn test_generate_whitespace_only() {
371        let result = generate_updated_script("  \t  ", PackageManager::Npm);
372        assert_eq!(
373            result,
374            "npx @socketsecurity/socket-patch apply --silent --ecosystems npm"
375        );
376    }
377
378    // ── update_package_json_object ──────────────────────────────────
379
380    #[test]
381    fn test_update_object_creates_scripts() {
382        let mut pkg: serde_json::Value = serde_json::json!({"name": "test"});
383        let (modified, new_postinstall, new_dependencies) =
384            update_package_json_object(&mut pkg, PackageManager::Npm);
385        assert!(modified);
386        assert!(new_postinstall.contains("npx @socketsecurity/socket-patch apply"));
387        assert!(new_dependencies.contains("npx @socketsecurity/socket-patch apply"));
388        assert!(pkg.get("scripts").is_some());
389        assert!(pkg["scripts"]["postinstall"].is_string());
390        assert!(pkg["scripts"]["dependencies"].is_string());
391    }
392
393    #[test]
394    fn test_update_object_creates_scripts_pnpm() {
395        let mut pkg: serde_json::Value = serde_json::json!({"name": "test"});
396        let (modified, new_postinstall, new_dependencies) =
397            update_package_json_object(&mut pkg, PackageManager::Pnpm);
398        assert!(modified);
399        assert!(new_postinstall.contains("pnpm dlx @socketsecurity/socket-patch apply"));
400        assert!(new_dependencies.contains("pnpm dlx @socketsecurity/socket-patch apply"));
401    }
402
403    #[test]
404    fn test_update_object_noop_when_both_configured() {
405        let mut pkg: serde_json::Value = serde_json::json!({
406            "scripts": {
407                "postinstall": "npx @socketsecurity/socket-patch apply --silent --ecosystems npm",
408                "dependencies": "npx @socketsecurity/socket-patch apply --silent --ecosystems npm"
409            }
410        });
411        let (modified, _, _) = update_package_json_object(&mut pkg, PackageManager::Npm);
412        assert!(!modified);
413    }
414
415    #[test]
416    fn test_update_object_adds_dependencies_when_postinstall_exists() {
417        let mut pkg: serde_json::Value = serde_json::json!({
418            "scripts": {
419                "postinstall": "npx @socketsecurity/socket-patch apply --silent --ecosystems npm"
420            }
421        });
422        let (modified, _, new_dependencies) =
423            update_package_json_object(&mut pkg, PackageManager::Npm);
424        assert!(modified);
425        assert!(new_dependencies.contains("npx @socketsecurity/socket-patch apply"));
426        // postinstall should remain unchanged
427        assert_eq!(
428            pkg["scripts"]["postinstall"].as_str().unwrap(),
429            "npx @socketsecurity/socket-patch apply --silent --ecosystems npm"
430        );
431    }
432
433    // ── update_package_json_content ─────────────────────────────────
434
435    #[test]
436    fn test_update_content_roundtrip_no_scripts() {
437        let content = r#"{"name": "test"}"#;
438        let (modified, new_content, old_pi, new_pi, old_dep, new_dep) =
439            update_package_json_content(content, PackageManager::Npm).unwrap();
440        assert!(modified);
441        assert!(old_pi.is_empty());
442        assert!(new_pi.contains("npx @socketsecurity/socket-patch apply"));
443        assert!(old_dep.is_empty());
444        assert!(new_dep.contains("npx @socketsecurity/socket-patch apply"));
445        let parsed: serde_json::Value = serde_json::from_str(&new_content).unwrap();
446        assert!(parsed["scripts"]["postinstall"].is_string());
447        assert!(parsed["scripts"]["dependencies"].is_string());
448    }
449
450    #[test]
451    fn test_update_content_already_configured() {
452        let content = r#"{"scripts":{"postinstall":"socket patch apply --silent --ecosystems npm","dependencies":"socket patch apply --silent --ecosystems npm"}}"#;
453        let (modified, _, _, _, _, _) =
454            update_package_json_content(content, PackageManager::Npm).unwrap();
455        assert!(!modified);
456    }
457
458    #[test]
459    fn test_update_content_invalid_json() {
460        let result = update_package_json_content("not json", PackageManager::Npm);
461        assert!(result.is_err());
462        assert!(result.unwrap_err().contains("Invalid package.json"));
463    }
464
465    #[test]
466    fn test_update_object_scripts_is_string_does_not_panic() {
467        // Regression: a present-but-non-object `scripts` previously panicked
468        // when indexed (`cannot access key "postinstall" in JSON string`).
469        let mut pkg: serde_json::Value = serde_json::json!({
470            "name": "test",
471            "scripts": "build"
472        });
473        let (modified, _, _) = update_package_json_object(&mut pkg, PackageManager::Npm);
474        // Root is an object but `scripts` is malformed; the object-level helper
475        // replaces it rather than panicking.
476        assert!(modified);
477        assert!(pkg["scripts"]["postinstall"].is_string());
478        assert!(pkg["scripts"]["dependencies"].is_string());
479    }
480
481    #[test]
482    fn test_update_object_scripts_is_array_does_not_panic() {
483        let mut pkg: serde_json::Value = serde_json::json!({
484            "name": "test",
485            "scripts": ["build"]
486        });
487        let (modified, _, _) = update_package_json_object(&mut pkg, PackageManager::Npm);
488        assert!(modified);
489        assert!(pkg["scripts"].is_object());
490    }
491
492    #[test]
493    fn test_update_object_scripts_is_null() {
494        // `null` scripts is treated as absent and replaced with an object.
495        let mut pkg: serde_json::Value = serde_json::json!({
496            "name": "test",
497            "scripts": null
498        });
499        let (modified, _, _) = update_package_json_object(&mut pkg, PackageManager::Npm);
500        assert!(modified);
501        assert!(pkg["scripts"]["postinstall"].is_string());
502    }
503
504    #[test]
505    fn test_update_object_non_object_root_is_noop() {
506        // Regression: a non-object root previously panicked on `["scripts"] = ...`.
507        let mut arr: serde_json::Value = serde_json::json!([1, 2, 3]);
508        let (modified, _, _) = update_package_json_object(&mut arr, PackageManager::Npm);
509        assert!(!modified);
510        assert_eq!(arr, serde_json::json!([1, 2, 3]));
511    }
512
513    #[test]
514    fn test_update_content_non_object_root_errors() {
515        // Regression: valid JSON that is not an object must error, not panic.
516        for content in ["[1,2,3]", "42", "\"hello\"", "true", "null"] {
517            let result = update_package_json_content(content, PackageManager::Npm);
518            assert!(result.is_err(), "expected error for content {content:?}");
519            assert!(result.unwrap_err().contains("root is not a JSON object"));
520        }
521    }
522
523    #[test]
524    fn test_update_content_non_object_scripts_errors() {
525        // Regression: a present-but-non-object `scripts` must error rather than
526        // silently clobbering the user's value or panicking.
527        let content = r#"{"name":"test","scripts":"build"}"#;
528        let result = update_package_json_content(content, PackageManager::Npm);
529        assert!(result.is_err());
530        assert!(result
531            .unwrap_err()
532            .contains("\"scripts\" is not a JSON object"));
533    }
534
535    #[test]
536    fn test_update_content_null_scripts_creates_object() {
537        // `null` scripts is benign: treated as absent and populated.
538        let content = r#"{"name":"test","scripts":null}"#;
539        let (modified, new_content, _, new_pi, _, new_dep) =
540            update_package_json_content(content, PackageManager::Npm).unwrap();
541        assert!(modified);
542        assert!(new_pi.contains("npx @socketsecurity/socket-patch apply"));
543        assert!(new_dep.contains("npx @socketsecurity/socket-patch apply"));
544        let parsed: serde_json::Value = serde_json::from_str(&new_content).unwrap();
545        assert!(parsed["scripts"]["postinstall"].is_string());
546        assert!(parsed["scripts"]["dependencies"].is_string());
547    }
548
549    #[test]
550    fn test_update_content_pnpm() {
551        let content = r#"{"name": "test"}"#;
552        let (modified, new_content, _, new_pi, _, new_dep) =
553            update_package_json_content(content, PackageManager::Pnpm).unwrap();
554        assert!(modified);
555        assert!(new_pi.contains("pnpm dlx @socketsecurity/socket-patch apply"));
556        assert!(new_dep.contains("pnpm dlx @socketsecurity/socket-patch apply"));
557        let parsed: serde_json::Value = serde_json::from_str(&new_content).unwrap();
558        assert!(parsed["scripts"]["postinstall"]
559            .as_str()
560            .unwrap()
561            .contains("pnpm dlx"));
562        assert!(parsed["scripts"]["dependencies"]
563            .as_str()
564            .unwrap()
565            .contains("pnpm dlx"));
566    }
567}