Skip to main content

socket_patch_core/package_json/
detect.rs

1/// The command to run for applying patches via socket CLI.
2const SOCKET_PATCH_COMMAND: &str = "socket patch apply --silent --ecosystems npm";
3
4/// Legacy command patterns to detect existing configurations.
5const LEGACY_PATCH_PATTERNS: &[&str] = &[
6    "socket-patch apply",
7    "npx @socketsecurity/socket-patch apply",
8    "socket patch apply",
9];
10
11/// Status of postinstall script configuration.
12#[derive(Debug, Clone)]
13pub struct PostinstallStatus {
14    pub configured: bool,
15    pub current_script: String,
16    pub needs_update: bool,
17}
18
19/// Check if a postinstall script is properly configured for socket-patch.
20pub fn is_postinstall_configured(package_json: &serde_json::Value) -> PostinstallStatus {
21    let current_script = package_json
22        .get("scripts")
23        .and_then(|s| s.get("postinstall"))
24        .and_then(|v| v.as_str())
25        .unwrap_or("")
26        .to_string();
27
28    let configured = LEGACY_PATCH_PATTERNS
29        .iter()
30        .any(|pattern| current_script.contains(pattern));
31
32    PostinstallStatus {
33        configured,
34        current_script,
35        needs_update: !configured,
36    }
37}
38
39/// Check if a postinstall script string is configured for socket-patch.
40pub fn is_postinstall_configured_str(content: &str) -> PostinstallStatus {
41    match serde_json::from_str::<serde_json::Value>(content) {
42        Ok(val) => is_postinstall_configured(&val),
43        Err(_) => PostinstallStatus {
44            configured: false,
45            current_script: String::new(),
46            needs_update: true,
47        },
48    }
49}
50
51/// Generate an updated postinstall script that includes socket-patch.
52pub fn generate_updated_postinstall(current_postinstall: &str) -> String {
53    let trimmed = current_postinstall.trim();
54
55    // If empty, just add the socket-patch command.
56    if trimmed.is_empty() {
57        return SOCKET_PATCH_COMMAND.to_string();
58    }
59
60    // If any socket-patch variant is already present, return unchanged.
61    let already_configured = LEGACY_PATCH_PATTERNS
62        .iter()
63        .any(|pattern| trimmed.contains(pattern));
64    if already_configured {
65        return trimmed.to_string();
66    }
67
68    // Prepend socket-patch command so it runs first.
69    format!("{SOCKET_PATCH_COMMAND} && {trimmed}")
70}
71
72/// Update a package.json Value with the new postinstall script.
73/// Returns (modified, new_script).
74pub fn update_package_json_object(
75    package_json: &mut serde_json::Value,
76) -> (bool, String) {
77    let status = is_postinstall_configured(package_json);
78
79    if !status.needs_update {
80        return (false, status.current_script);
81    }
82
83    let new_postinstall = generate_updated_postinstall(&status.current_script);
84
85    // Ensure scripts object exists
86    if package_json.get("scripts").is_none() {
87        package_json["scripts"] = serde_json::json!({});
88    }
89
90    package_json["scripts"]["postinstall"] =
91        serde_json::Value::String(new_postinstall.clone());
92
93    (true, new_postinstall)
94}
95
96/// Parse package.json content and update it with socket-patch postinstall.
97/// Returns (modified, new_content, old_script, new_script).
98pub fn update_package_json_content(
99    content: &str,
100) -> Result<(bool, String, String, String), String> {
101    let mut package_json: serde_json::Value =
102        serde_json::from_str(content).map_err(|e| format!("Invalid package.json: {e}"))?;
103
104    let status = is_postinstall_configured(&package_json);
105
106    if !status.needs_update {
107        return Ok((
108            false,
109            content.to_string(),
110            status.current_script.clone(),
111            status.current_script,
112        ));
113    }
114
115    let (_, new_script) = update_package_json_object(&mut package_json);
116    let new_content = serde_json::to_string_pretty(&package_json).unwrap() + "\n";
117
118    Ok((true, new_content, status.current_script, new_script))
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn test_not_configured() {
127        let pkg: serde_json::Value = serde_json::json!({
128            "name": "test",
129            "scripts": {
130                "build": "tsc"
131            }
132        });
133        let status = is_postinstall_configured(&pkg);
134        assert!(!status.configured);
135        assert!(status.needs_update);
136    }
137
138    #[test]
139    fn test_already_configured() {
140        let pkg: serde_json::Value = serde_json::json!({
141            "name": "test",
142            "scripts": {
143                "postinstall": "socket patch apply --silent --ecosystems npm"
144            }
145        });
146        let status = is_postinstall_configured(&pkg);
147        assert!(status.configured);
148        assert!(!status.needs_update);
149    }
150
151    #[test]
152    fn test_generate_empty() {
153        assert_eq!(
154            generate_updated_postinstall(""),
155            "socket patch apply --silent --ecosystems npm"
156        );
157    }
158
159    #[test]
160    fn test_generate_prepend() {
161        assert_eq!(
162            generate_updated_postinstall("echo done"),
163            "socket patch apply --silent --ecosystems npm && echo done"
164        );
165    }
166
167    #[test]
168    fn test_generate_already_configured() {
169        let current = "socket-patch apply && echo done";
170        assert_eq!(generate_updated_postinstall(current), current);
171    }
172
173    // ── Group 4: expanded edge cases ─────────────────────────────────
174
175    #[test]
176    fn test_is_postinstall_configured_str_invalid_json() {
177        let status = is_postinstall_configured_str("not json");
178        assert!(!status.configured);
179        assert!(status.needs_update);
180    }
181
182    #[test]
183    fn test_is_postinstall_configured_str_legacy_npx_pattern() {
184        let content = r#"{"scripts":{"postinstall":"npx @socketsecurity/socket-patch apply --silent"}}"#;
185        let status = is_postinstall_configured_str(content);
186        // "npx @socketsecurity/socket-patch apply" contains "socket-patch apply"
187        assert!(status.configured);
188        assert!(!status.needs_update);
189    }
190
191    #[test]
192    fn test_is_postinstall_configured_str_socket_dash_patch() {
193        let content =
194            r#"{"scripts":{"postinstall":"socket-patch apply --silent --ecosystems npm"}}"#;
195        let status = is_postinstall_configured_str(content);
196        assert!(status.configured);
197        assert!(!status.needs_update);
198    }
199
200    #[test]
201    fn test_is_postinstall_configured_no_scripts() {
202        let pkg: serde_json::Value = serde_json::json!({"name": "test"});
203        let status = is_postinstall_configured(&pkg);
204        assert!(!status.configured);
205        assert!(status.current_script.is_empty());
206    }
207
208    #[test]
209    fn test_is_postinstall_configured_no_postinstall() {
210        let pkg: serde_json::Value = serde_json::json!({
211            "scripts": {"build": "tsc"}
212        });
213        let status = is_postinstall_configured(&pkg);
214        assert!(!status.configured);
215        assert!(status.current_script.is_empty());
216    }
217
218    #[test]
219    fn test_update_object_creates_scripts() {
220        let mut pkg: serde_json::Value = serde_json::json!({"name": "test"});
221        let (modified, new_script) = update_package_json_object(&mut pkg);
222        assert!(modified);
223        assert!(new_script.contains("socket patch apply"));
224        assert!(pkg.get("scripts").is_some());
225        assert!(pkg["scripts"]["postinstall"].is_string());
226    }
227
228    #[test]
229    fn test_update_object_noop_when_configured() {
230        let mut pkg: serde_json::Value = serde_json::json!({
231            "scripts": {
232                "postinstall": "socket patch apply --silent --ecosystems npm"
233            }
234        });
235        let (modified, existing) = update_package_json_object(&mut pkg);
236        assert!(!modified);
237        assert!(existing.contains("socket patch apply"));
238    }
239
240    #[test]
241    fn test_update_content_roundtrip_no_scripts() {
242        let content = r#"{"name": "test"}"#;
243        let (modified, new_content, old_script, new_script) =
244            update_package_json_content(content).unwrap();
245        assert!(modified);
246        assert!(old_script.is_empty());
247        assert!(new_script.contains("socket patch apply"));
248        // new_content should be valid JSON
249        let parsed: serde_json::Value = serde_json::from_str(&new_content).unwrap();
250        assert!(parsed["scripts"]["postinstall"].is_string());
251    }
252
253    #[test]
254    fn test_update_content_already_configured() {
255        let content = r#"{"scripts":{"postinstall":"socket patch apply --silent --ecosystems npm"}}"#;
256        let (modified, _new_content, _old, _new) =
257            update_package_json_content(content).unwrap();
258        assert!(!modified);
259    }
260
261    #[test]
262    fn test_update_content_invalid_json() {
263        let result = update_package_json_content("not json");
264        assert!(result.is_err());
265        assert!(result.unwrap_err().contains("Invalid package.json"));
266    }
267
268    #[test]
269    fn test_generate_whitespace_only() {
270        // Whitespace-only string should be treated as empty after trim
271        let result = generate_updated_postinstall("  \t  ");
272        assert_eq!(result, "socket patch apply --silent --ecosystems npm");
273    }
274}