Skip to content

Commit c9485bc

Browse files
mikolalysenkoclaudeMikola Lysenko
authored
Fix JSON writers dropping CRLF/BOM/indent (#324, #351) (#357)
* Start fix for #324, #351 Assisted-by: Claude Code:claude-opus-5-5 * Add tests for JSON writers losing file layout Regression tests for #324 (npm lock CRLF, tab indent and BOM through hosted rewrite, rollback, vendor and vendor --revert) and #351 (composer.json CRLF and string escapes through setup and setup --remove). The composer tests pass with the splice-based editor added in this commit; the npm ones fail until the next. Assisted-by: Claude Code:claude-opus-5-5 * Keep npm lock CRLF, tabs and BOM on rewrite The hosted and vendored npm lock rewriters, rollback and vendor --revert now render the lock in its own layout (BOM, indent, line ending, trailer) through JsonLayout, so a CRLF or tab-indented lock gets a two-line diff and undo restores the original bytes. A UTF-8 BOM lock, which npm installs from, is now read past its BOM instead of being skipped (hosted) or refused with the wrong code (vendored), and VEX discovery reads it too. Fixes #324 Assisted-by: Claude Code:claude-opus-5-5 * Note the layout-keeping JSON edits in CHANGELOG Assisted-by: Claude Code:claude-opus-5-5 * Add real-npm e2e for a CRLF + BOM package-lock Vendors a CRLF lock with a UTF-8 BOM, proves a fresh npm ci installs the patched bytes from it, and that vendor --revert restores the lock byte for byte (#324). Assisted-by: Claude Code:claude-opus-5-5 --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Mikola Lysenko <mik@socket.dev>
1 parent 6e7ef74 commit c9485bc

10 files changed

Lines changed: 307 additions & 27 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,15 @@ limits, and required install commands.
162162
- Transient apply locks are removed on normal command exit; no-op scans and full
163163
reversal avoid leaving unused `.socket/` state. Terminal output, telemetry
164164
timeouts, and update-check handling are more consistent.
165+
- npm locks keep their own layout when edited. `scan --mode hosted`,
166+
`scan --mode vendored`, `rollback` and `vendor --revert`
167+
re-serialized `package-lock.json` / `npm-shrinkwrap.json` with LF line
168+
endings (and, in hosted mode, a fixed 2-space indent), so a CRLF or
169+
tab-indented lock got a whole-file diff and the undo did not restore its
170+
bytes. A lock with a UTF-8 BOM, which npm installs from, was skipped as
171+
unparseable (hosted) or refused as `vendor_lockfile_version_unsupported`
172+
(vendored). The lock now keeps its BOM, indent and line endings, and the
173+
undo is byte-exact (#324).
165174

166175
### Maintenance
167176

‎crates/socket-patch-cli/tests/e2e_vendor_npm_build.rs‎

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,115 @@ fn npm_vendor_fresh_checkout_npm_ci_and_revert() {
495495
);
496496
}
497497

498+
/// #324 with the real npm: a CRLF lock with a UTF-8 BOM (npm installs from
499+
/// both) is vendored in its own layout, a fresh `npm ci` installs the
500+
/// patched bytes from it, and `vendor --revert` restores its exact bytes.
501+
#[test]
502+
fn npm_vendor_keeps_a_crlf_bom_lock_and_reverts_it_byte_for_byte() {
503+
let Some(major) = npm_major_or_skip("e2e_vendor_npm_build") else {
504+
return;
505+
};
506+
let tmp = tempfile::tempdir().unwrap();
507+
let proj = tmp.path().join("proj");
508+
std::fs::create_dir_all(&proj).unwrap();
509+
std::fs::write(
510+
proj.join("package.json"),
511+
r#"{"name":"vendor-crlf-bom","version":"0.0.0","private":true}"#,
512+
)
513+
.unwrap();
514+
let cache = tmp.path().join("npm-cache");
515+
if !npm_e2e_common::install_fixture(
516+
"e2e_vendor_npm_build",
517+
&proj,
518+
&cache,
519+
&format!("{DEP}@{DEP_VERSION}"),
520+
) {
521+
return;
522+
}
523+
let orig = std::fs::read(proj.join("node_modules").join(DEP).join("index.js")).unwrap();
524+
let patched: Vec<u8> = [MARKER.as_bytes(), orig.as_slice()].concat();
525+
let purl = format!("pkg:npm/{DEP}@{DEP_VERSION}");
526+
stage_patch_with_vuln(&proj, &purl, "package/index.js", &orig, &patched, TAIL_GHSA);
527+
if v1_lock_is_refused(&proj, major) {
528+
return;
529+
}
530+
531+
let lock_path = proj.join("package-lock.json");
532+
let lf = std::fs::read_to_string(&lock_path).unwrap();
533+
let pristine = format!("\u{feff}{}", lf.replace('\n', "\r\n"));
534+
std::fs::write(&lock_path, &pristine).unwrap();
535+
536+
let (code, stdout, stderr) = run_socket(
537+
&proj,
538+
&[
539+
"vendor",
540+
"--json",
541+
"--offline",
542+
"--cwd",
543+
proj.to_str().unwrap(),
544+
],
545+
);
546+
assert_eq!(
547+
code, 0,
548+
"vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"
549+
);
550+
let env = parse_envelope(&stdout);
551+
assert_eq!(env["summary"]["applied"], 1, "one package vendored: {env}");
552+
let wired = std::fs::read_to_string(&lock_path).unwrap();
553+
assert!(wired.starts_with('\u{feff}'), "the BOM is kept");
554+
assert!(
555+
!wired.replace("\r\n", "").contains('\n'),
556+
"every line stays CRLF:\n{wired:?}"
557+
);
558+
559+
let fresh = tmp.path().join("fresh");
560+
std::fs::create_dir_all(&fresh).unwrap();
561+
std::fs::copy(proj.join("package.json"), fresh.join("package.json")).unwrap();
562+
std::fs::copy(&lock_path, fresh.join("package-lock.json")).unwrap();
563+
copy_dir_recursive(&proj.join(".socket"), &fresh.join(".socket"));
564+
let fresh_cache = tmp.path().join("fresh-npm-cache");
565+
let ci = npm(
566+
&fresh,
567+
&[
568+
"ci",
569+
"--cache",
570+
fresh_cache.to_str().unwrap(),
571+
"--no-audit",
572+
"--no-fund",
573+
],
574+
);
575+
assert!(
576+
ci.status.success(),
577+
"`npm ci` must install from the CRLF/BOM lock.\nstderr:\n{}",
578+
String::from_utf8_lossy(&ci.stderr),
579+
);
580+
assert_eq!(
581+
std::fs::read(fresh.join("node_modules").join(DEP).join("index.js")).unwrap(),
582+
patched,
583+
"npm ci installs the PATCHED bytes"
584+
);
585+
586+
let (code, stdout, stderr) = run_socket(
587+
&proj,
588+
&[
589+
"vendor",
590+
"--revert",
591+
"--json",
592+
"--cwd",
593+
proj.to_str().unwrap(),
594+
],
595+
);
596+
assert_eq!(
597+
code, 0,
598+
"revert failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"
599+
);
600+
assert_eq!(
601+
std::fs::read_to_string(&lock_path).unwrap(),
602+
pristine,
603+
"revert restores the CRLF/BOM lock byte for byte"
604+
);
605+
}
606+
498607
/// Real-toolchain VEX capstone for npm: after a REAL install + `vendor`, the
499608
/// vendored `.tgz` is the on-disk evidence. `socket-patch vex` must attest the
500609
/// patch against that vendored tarball with the `(vendored)` marker — proving

‎crates/socket-patch-core/src/patch/redirect/mod.rs‎

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ use serde_json::{json, Value};
2525

2626
use crate::utils::digest::is_hex64_lower;
2727
use crate::utils::line_endings::{to_lf, LineEndings};
28+
use crate::vendor::common::{parse_json_text, JsonLayout};
2829
use crate::vendor::npm_origin::{legacy_packages_key, npm_non_registry_entries};
2930
use crate::vendor::yarn_berry_lock::yarnrc_compression_level;
3031

@@ -280,6 +281,17 @@ fn serialize_json(value: &Value) -> String {
280281
)
281282
}
282283

284+
/// `value` pretty-printed in the layout of `original`, the text it replaces
285+
/// (BOM, indent, line ending and trailer; see [`JsonLayout`]), so a rewrite
286+
/// and its revert change nothing but the edited values. npm keeps a lock's
287+
/// CRLF and tab indent on its own rewrites, and so must we.
288+
fn serialize_json_like(value: &Value, original: &str) -> String {
289+
let bytes = JsonLayout::of(original)
290+
.render(value)
291+
.expect("serde_json::Value serializes infallibly");
292+
String::from_utf8(bytes).expect("rendered JSON is UTF-8")
293+
}
294+
283295
/// The dep's registry override when it is of `kind`. `None` for an absent
284296
/// AND for a foreign-kind override alike — neither can drive this
285297
/// ecosystem's rewrite, so every rewriter warns its missing-override code
@@ -821,7 +833,8 @@ fn rewrite_one_npm_lock(
821833
npm: &[&DepOverride],
822834
result: &mut RewriteResult,
823835
) {
824-
let Ok(mut lock) = serde_json::from_str::<Value>(content) else {
836+
// npm reads past a leading UTF-8 BOM; so do we.
837+
let Ok(mut lock) = parse_json_text(content) else {
825838
// A corrupt lockfile is strictly worse than a missing one (which
826839
// warns in the caller) — never skip the whole npm redirect silently.
827840
result.warnings.push(RewriteWarning {
@@ -988,7 +1001,9 @@ fn rewrite_one_npm_lock(
9881001
),
9891002
});
9901003
}
991-
result.files.insert(lockfile.into(), serialize_json(&lock));
1004+
result
1005+
.files
1006+
.insert(lockfile.into(), serialize_json_like(&lock, content));
9921007
}
9931008
}
9941009

@@ -12103,6 +12118,43 @@ mod tests {
1210312118
);
1210412119
}
1210512120

12121+
/// #324: the hosted npm rewrite changes only the rewired values and keeps
12122+
/// the lock's layout: CRLF stays CRLF, a tab indent stays tabs, and a
12123+
/// UTF-8 BOM lock (npm strips the BOM and installs from it) is rewritten
12124+
/// with its BOM rather than skipped as unparseable.
12125+
#[test]
12126+
fn npm_lock_rewrite_keeps_crlf_tabs_and_bom() {
12127+
let ovr = npm_override(
12128+
"left-pad",
12129+
"1.3.0",
12130+
"http://patch.test/left-pad-1.3.0.tgz",
12131+
"sha512-PATCHED==",
12132+
);
12133+
let lf = "{\n \"name\": \"app\",\n \"lockfileVersion\": 3,\n \"packages\": {\n \"\": {\n \"name\": \"app\"\n },\n \"node_modules/left-pad\": {\n \"version\": \"1.3.0\",\n \"resolved\": \"https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz\",\n \"integrity\": \"sha512-UPSTREAM==\"\n }\n }\n}\n";
12134+
let shapes = [
12135+
("crlf", lf.replace('\n', "\r\n")),
12136+
("tabs", lf.replace(" ", "\t")),
12137+
("bom", format!("\u{feff}{lf}")),
12138+
("bom+crlf+tabs", format!("\u{feff}{}", lf.replace(" ", "\t").replace('\n', "\r\n"))),
12139+
];
12140+
for (shape, pristine) in shapes {
12141+
let mut files = BTreeMap::new();
12142+
files.insert("package-lock.json".to_string(), pristine.clone());
12143+
let r = rewrite_registry_redirect(&files, std::slice::from_ref(&ovr));
12144+
let out = r
12145+
.files
12146+
.get("package-lock.json")
12147+
.unwrap_or_else(|| panic!("{shape}: lock must be rewritten: {:?}", r.warnings));
12148+
let expected = pristine
12149+
.replace(
12150+
"https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz",
12151+
"http://patch.test/left-pad-1.3.0.tgz",
12152+
)
12153+
.replace("sha512-UPSTREAM==", "sha512-PATCHED==");
12154+
assert_eq!(out, &expected, "{shape}: only the rewired values may change");
12155+
}
12156+
}
12157+
1210612158
/// An unparseable package-lock.json must surface a warning, not silently
1210712159
/// skip the npm redirect entirely (missing-lockfile already warns; a
1210812160
/// corrupt lockfile is strictly worse).

‎crates/socket-patch-core/src/patch/redirect/upstream/npm.rs‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,8 @@ pub(crate) async fn restore_npm_locks(
149149
let Some(text) = read_or_refuse(view, rel, &pins, &mut result).await else {
150150
continue;
151151
};
152-
let Ok(mut lock) = serde_json::from_str::<Value>(&text) else {
152+
// npm reads past a leading UTF-8 BOM; so do we.
153+
let Ok(mut lock) = crate::vendor::common::parse_json_text(&text) else {
153154
refuse_all_in(&pins, rel, &mut result, format!("{rel} is not valid JSON"));
154155
continue;
155156
};
@@ -192,7 +193,8 @@ pub(crate) async fn restore_npm_locks(
192193
changed = true;
193194
}
194195
if changed {
195-
view.write(rel, super::super::serialize_json(&lock));
196+
// The lock's own BOM, indent and line endings (#324).
197+
view.write(rel, super::super::serialize_json_like(&lock, &text));
196198
}
197199
}
198200
result

‎crates/socket-patch-core/src/vendor/common.rs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,11 @@ pub(crate) fn parse_json_manifest(bytes: &[u8]) -> serde_json::Result<Value> {
182182
serde_json::from_slice(bytes.strip_prefix(b"\xef\xbb\xbf").unwrap_or(bytes))
183183
}
184184

185+
/// [`parse_json_manifest`] for text already decoded as UTF-8.
186+
pub(crate) fn parse_json_text(text: &str) -> serde_json::Result<Value> {
187+
serde_json::from_str(text.strip_prefix('\u{feff}').unwrap_or(text))
188+
}
189+
185190
/// The byte layout a re-serialized JSON manifest keeps from the text it
186191
/// replaces, so a vendor edit and its revert change nothing but the edited
187192
/// keys: the leading UTF-8 BOM, the indent unit ([`detect_indent`]), the

‎crates/socket-patch-core/src/vendor/lock_inventory/npm.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ pub(super) async fn inventory_package_lock_in(
164164
break;
165165
}
166166
}
167-
let doc: Value = serde_json::from_slice(&bytes?).ok()?;
167+
let doc: Value = crate::vendor::common::parse_json_manifest(&bytes?).ok()?;
168168
// v1 legacy locks have no `packages` map — no inventory (documented).
169169
doc.get("packages")?.as_object()?;
170170

0 commit comments

Comments
 (0)