Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions crates/socket-patch-cli/CLI_CONTRACT.md

Large diffs are not rendered by default.

112 changes: 112 additions & 0 deletions crates/socket-patch-cli/tests/e2e_vendor_bun_build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1669,3 +1669,115 @@ fn bun_vendor_survives_a_digest_dropping_lock_resave() {
);
eprintln!("REVERT AFTER RESAVE OK (bun {})", fx.bun_raw);
}

// ── #1541: a repository that hands *.tgz to Git LFS ───────────────────

/// `git` in `cwd` with no user or system config, so an ambient
/// `filter.lfs` (or its absence) can't decide the outcome.
fn git(cwd: &Path, args: &[&str], env: &[(&str, &str)]) -> Output {
let mut cmd = Command::new("git");
cmd.args([
"-c",
"user.name=t",
"-c",
"user.email=t@example.com",
"-c",
"commit.gpgsign=false",
"-c",
"init.defaultBranch=main",
])
.args(args)
.current_dir(cwd)
.env("GIT_CONFIG_NOSYSTEM", "1")
.env(
"GIT_CONFIG_GLOBAL",
if cfg!(windows) { "NUL" } else { "/dev/null" },
);
for (k, v) in env {
cmd.env(k, v);
}
let out = cmd.output().expect("failed to run git");
assert!(
out.status.success(),
"git {args:?} failed:\n{}",
String::from_utf8_lossy(&out.stderr)
);
out
}

/// #1541: the project's `.gitattributes` tracks `*.tgz` with Git LFS. The
/// vendored tarball must still be committed as its own bytes, so a clone
/// that does not smudge LFS (`actions/checkout` defaults, no git-lfs,
/// `GIT_LFS_SKIP_SMUDGE=1`) frozen-installs the patched package.
#[test]
#[serial_test::serial]
fn bun_vendor_tarball_is_not_committed_to_git_lfs() {
let lfs_installed = Command::new("git")
.args(["lfs", "version"])
.output()
.is_ok_and(|o| o.status.success());
if !lfs_installed {
println!("SKIP bun_vendor_tarball_is_not_committed_to_git_lfs: git-lfs is not installed");
return;
}
let Some(fx) = bun_project("vendor-lfs", Target::LeftPad, None) else {
return;
};
let proj = &fx.proj;
git(proj, &["init", "-q"], &[]);
git(proj, &["lfs", "install", "--local"], &[]);
std::fs::write(
proj.join(".gitattributes"),
"*.tgz filter=lfs diff=lfs merge=lfs -text\n",
)
.unwrap();
std::fs::write(proj.join(".gitignore"), "node_modules/\n").unwrap();
stage_and_vendor(&fx);
git(proj, &["add", "-A"], &[]);
git(proj, &["commit", "-qm", "vendored"], &[]);

let tgz_rel = vendored_tgz(&fx)
.strip_prefix(proj)
.unwrap()
.to_string_lossy()
.replace('\\', "/");
let lfs_files = git(proj, &["lfs", "ls-files", "--name-only"], &[]);
assert!(
!String::from_utf8_lossy(&lfs_files.stdout).contains(&tgz_rel),
"the vendored tarball went into LFS: {}",
String::from_utf8_lossy(&lfs_files.stdout)
);

let clone = fx.tmp.path().join("lfs-less-clone");
git(
fx.tmp.path(),
&[
"clone",
"-q",
proj.to_str().unwrap(),
clone.to_str().unwrap(),
],
&[("GIT_LFS_SKIP_SMUDGE", "1")],
);
assert_eq!(
std::fs::read(clone.join(&tgz_rel)).unwrap(),
std::fs::read(vendored_tgz(&fx)).unwrap(),
"the clone must hold the tarball's bytes, not an LFS pointer"
);
let ci = bun(
&clone,
&["install", "--frozen-lockfile", "--ignore-scripts"],
&fx.tmp.path().join("lfs-less-bun-cache"),
);
assert!(
ci.status.success(),
"frozen install from the LFS-less clone failed.\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&ci.stdout),
String::from_utf8_lossy(&ci.stderr),
);
assert_eq!(
std::fs::read(fx.target.installed_dir(&clone).join("index.js")).unwrap(),
fx.patched,
"the clone installs the patched bytes"
);
}
2 changes: 1 addition & 1 deletion crates/socket-patch-cli/tests/e2e_vendor_vlt_build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ async fn vlt_pinned_matrix_vendored_scan_fresh_ci() {
);
assert_eq!(
read(&uuid_dir(&fx.proj, fx.t()).join(".gitattributes")),
"* -text\n"
"* -text -filter -ident -working-tree-encoding\n"
);
assert_fresh_vendored(&fx, fx.t(), "fresh-scan");
fx.leg.ran();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ async fn vlt_repair_restores_the_uuid_gitignore() {
assert_eq!(std::fs::read(uuid.join(".gitignore")).unwrap(), ignore);
assert_eq!(
std::fs::read_to_string(uuid.join(".gitattributes")).unwrap(),
"* -text\n"
"* -text -filter -ident -working-tree-encoding\n"
);
}
}
Expand Down
2 changes: 1 addition & 1 deletion crates/socket-patch-cli/tests/vlt_vendor_common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -457,7 +457,7 @@ pub fn ledger_entry(root: &Path, purl: &str) -> Value {
/// The exact `<uuid>/.gitignore` and `.gitattributes` bytes (DESIGN §4.2).
pub const UUID_GITIGNORE: &str =
"!*\n**/node_modules/*/node_modules/\n**/node_modules/@*/*/node_modules/\n";
pub const UUID_GITATTRIBUTES: &str = "* -text\n";
pub const UUID_GITATTRIBUTES: &str = "* -text -filter -ident -working-tree-encoding\n";

pub fn git() -> Option<PathBuf> {
socket_patch_core::utils::process::resolve_tool("git")
Expand Down
30 changes: 18 additions & 12 deletions crates/socket-patch-core/src/vendor/bun_binary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1065,10 +1065,6 @@ fn mirror_metadata(path: &Path) -> Vec<PathBuf> {
}) else {
return Vec::new();
};
let ours = [
(".gitignore", super::npm_dir::UUID_GITIGNORE),
(".gitattributes", super::npm_dir::UUID_GITATTRIBUTES),
];
let mut metadata = Vec::new();
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
Expand All @@ -1086,15 +1082,16 @@ fn mirror_metadata(path: &Path) -> Vec<PathBuf> {
}
continue;
}
let name = entry.file_name();
match ours.iter().find(|(n, _)| name == *n) {
Some((_, want))
if read_regular_to_bytes_sync(&child).is_ok_and(|b| b == want.as_bytes()) =>
{
metadata.push(child)
}
let ours: fn(&[u8]) -> bool = match entry.file_name().to_str() {
Some(".gitignore") => |b| b == super::npm_dir::UUID_GITIGNORE.as_bytes(),
// Current or pre-#1541 bytes.
Some(".gitattributes") => super::npm_dir::is_uuid_gitattributes,
_ => return Vec::new(),
};
if !read_regular_to_bytes_sync(&child).is_ok_and(|b| ours(&b)) {
return Vec::new();
}
metadata.push(child);
}
metadata
}
Expand Down Expand Up @@ -1710,8 +1707,17 @@ mod rebuild_tests {
"repair restores the mirrors' .gitignore"
);

// A mirror deleted by hand still takes its metadata on revert.
// A mirror deleted by hand still takes its metadata on revert, and
// so does a `.gitattributes` an older release wrote (#1541).
std::fs::remove_file(root.join(&mirrors[0])).unwrap();
for rel in &mirrors {
let attributes = Path::new(rel).parent().unwrap().join(".gitattributes");
std::fs::write(
root.join(attributes),
super::super::npm_dir::LEGACY_UUID_GITATTRIBUTES,
)
.unwrap();
}
ts::persist(root, PURL, entry.clone()).await;
let outcome = revert(&entry, root, RevertOpts::new(false)).await;
assert!(outcome.success, "{outcome:?}");
Expand Down
97 changes: 94 additions & 3 deletions crates/socket-patch-core/src/vendor/npm_dir.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,21 @@ use super::{VendorOutcome, VendorServiceConfig, VendorWarning};
/// `<uuid>/.gitignore`, exactly.
pub(crate) const UUID_GITIGNORE: &str =
"!*\n**/node_modules/*/node_modules/\n**/node_modules/@*/*/node_modules/\n";
/// `<uuid>/.gitattributes`, exactly.
pub(crate) const UUID_GITATTRIBUTES: &str = "* -text\n";
/// `<uuid>/.gitattributes`, exactly. It unsets every attribute that lets
/// git store or check out other bytes than the artifact's: EOL conversion
/// (`text`), clean/smudge filters such as a repository's `*.tgz
/// filter=lfs` rule (#1541), `ident` expansion and re-encoding. It beats
/// every `.gitattributes` above the uuid dir, but not `.git/info/attributes`
/// or a `.gitattributes` deeper in a vendored directory payload.
pub(crate) const UUID_GITATTRIBUTES: &str = "* -text -filter -ident -working-tree-encoding\n";
/// The `<uuid>/.gitattributes` releases before #1541 wrote: still ours,
/// and rewritten to [`UUID_GITATTRIBUTES`] by the next vendored run.
pub(crate) const LEGACY_UUID_GITATTRIBUTES: &str = "* -text\n";

/// `bytes` is a `<uuid>/.gitattributes` socket-patch wrote, current or legacy.
pub(crate) fn is_uuid_gitattributes(bytes: &[u8]) -> bool {
bytes == UUID_GITATTRIBUTES.as_bytes() || bytes == LEGACY_UUID_GITATTRIBUTES.as_bytes()
}

const GITIGNORE: &str = ".gitignore";
const GITATTRIBUTES: &str = ".gitattributes";
Expand Down Expand Up @@ -1015,8 +1028,86 @@ mod tests {
);
assert_eq!(
std::fs::read_to_string(tmp.path().join(GITATTRIBUTES)).unwrap(),
"* -text\n"
"* -text -filter -ident -working-tree-encoding\n"
);
}

/// #1541: a vendored tree written by an older release holds the
/// legacy `* -text` file; the next run rewrites it in place.
#[tokio::test]
async fn legacy_uuid_gitattributes_is_upgraded() {
let tmp = tempfile::tempdir().unwrap();
tokio::fs::write(tmp.path().join(GITATTRIBUTES), LEGACY_UUID_GITATTRIBUTES)
.await
.unwrap();
restore_uuid_metadata(tmp.path()).await.unwrap();
assert_eq!(
std::fs::read_to_string(tmp.path().join(GITATTRIBUTES)).unwrap(),
UUID_GITATTRIBUTES
);
assert!(is_uuid_gitattributes(LEGACY_UUID_GITATTRIBUTES.as_bytes()));
assert!(is_uuid_gitattributes(UUID_GITATTRIBUTES.as_bytes()));
assert!(!is_uuid_gitattributes(b"* -text filter=lfs\n"));
}

/// #1541: a repository rule that hands `*.tgz` (or every file) to Git
/// LFS, or re-encodes or expands it on checkout, does not reach the
/// vendored artifacts once the uuid `.gitattributes` is in place, so
/// the commit stores their exact bytes.
#[cfg(unix)]
#[tokio::test]
async fn the_uuid_gitattributes_overrides_repository_lfs_rules() {
let Some(git) = crate::utils::process::resolve_tool("git") else {
return;
};
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let status = std::process::Command::new(&git)
.args(["init", "-q"])
.current_dir(root)
.status()
.unwrap();
assert!(status.success());
std::fs::write(
root.join(".gitattributes"),
"*.tgz filter=lfs diff=lfs merge=lfs -text\n\
*.js filter=lfs working-tree-encoding=UTF-16 ident text eol=crlf\n",
)
.unwrap();
let uuid = root.join(".socket/vendor/npm/u");
std::fs::create_dir_all(&uuid).unwrap();
let paths = [
".socket/vendor/npm/u/left-pad-1.3.0.tgz",
".socket/vendor/npm/u/a-1.0.0/node_modules/a/index.js",
];
let attr = |path: &str| {
let out = std::process::Command::new(&git)
.args([
"check-attr",
"filter",
"text",
"ident",
"working-tree-encoding",
"--",
])
.arg(path)
.current_dir(root)
.output()
.unwrap();
assert!(out.status.success());
String::from_utf8(out.stdout).unwrap()
};
assert!(
attr(paths[0]).contains("filter: lfs"),
"the repo rule applies"
);
restore_uuid_metadata(&uuid).await.unwrap();
for path in paths {
let out = attr(path);
for name in ["filter", "text", "ident", "working-tree-encoding"] {
assert!(out.contains(&format!("{path}: {name}: unset")), "{out}");
}
}
}

fn tgz(entries: &[(&str, tar::EntryType, &[u8])]) -> Vec<u8> {
Expand Down
2 changes: 1 addition & 1 deletion crates/socket-patch-core/tests/vlt_locks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -742,7 +742,7 @@ async fn vendored_wiring_matches_what_vlt_ci_writes_and_reverts_byte_exact() {
);
assert_eq!(
fs::read_to_string(uuid_dir.join(".gitattributes")).unwrap(),
"* -text\n"
"* -text -filter -ident -working-tree-encoding\n"
);
let artifact = staged.root.join(&rel_dir);
assert_eq!(
Expand Down
7 changes: 5 additions & 2 deletions docs/ecosystems.md
Original file line number Diff line number Diff line change
Expand Up @@ -655,8 +655,11 @@ package that `require()`s its own name resolve itself. The payload's `devDepende
are dropped from its `package.json` (vlt would otherwise try to install them). The uuid
directory carries a `.gitignore` that re-includes the payload (`!*`) against the project's
own ignores (`node_modules`, `dist/`, `*.map`, …) while ignoring the links vlt creates
inside it, and a `.gitattributes` that turns EOL conversion off so an `autocrlf` checkout
stays byte-exact. The lock's node becomes a `file` node, the importer edges and the
inside it, and a `.gitattributes` (`* -text -filter -ident -working-tree-encoding`) that
turns off EOL conversion and overrides the project's clean/smudge filters, such as a Git
LFS `*.tgz` rule, so an `autocrlf` or LFS-less checkout stays byte-exact (#1541). A tarball
an older release already committed as an LFS pointer stays one until it is re-added:
after the next vendored run, `git add --renormalize .socket/vendor/npm` and commit. The lock's node becomes a `file` node, the importer edges and the
importers' `package.json` specs move to the `file:` path, and every moved entry is placed
where vlt's own serializer puts it, so `vlt ci` keeps the lock byte-identical. Transitive
targets are refused (`vendor_vlt_transitive_unsupported`: vlt silently reverts
Expand Down
Loading