socket_patch_core/package_json/
detect.rs1#[derive(Debug, Clone, Copy, PartialEq)]
3pub enum PackageManager {
4 Npm,
5 Pnpm,
6}
7
8fn 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
18const LEGACY_PATCH_PATTERNS: &[&str] = &[
20 "socket-patch apply",
21 "npx @socketsecurity/socket-patch apply",
22 "socket patch apply",
23];
24
25fn script_is_configured(script: &str) -> bool {
27 LEGACY_PATCH_PATTERNS
28 .iter()
29 .any(|pattern| script.contains(pattern))
30}
31
32#[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
42pub 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
70pub 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
84pub 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 trimmed.is_empty() {
92 return command.to_string();
93 }
94
95 if script_is_configured(trimmed) {
97 return trimmed.to_string();
98 }
99
100 format!("{command} && {trimmed}")
102}
103
104pub 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 if !package_json.is_object() {
121 return (false, status.postinstall_script, status.dependencies_script);
122 }
123
124 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
157pub 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 if !package_json.is_object() {
170 return Err("Invalid package.json: root is not a JSON object".to_string());
171 }
172
173 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 #[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 #[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 assert!(status.postinstall_configured);
324 }
325
326 #[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 #[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 assert_eq!(
428 pkg["scripts"]["postinstall"].as_str().unwrap(),
429 "npx @socketsecurity/socket-patch apply --silent --ecosystems npm"
430 );
431 }
432
433 #[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 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 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 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 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 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 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 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}