Skip to content
Draft
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
14 changes: 13 additions & 1 deletion crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -661,7 +661,9 @@ are patched by `socket-patch apply` like every other ecosystem:
--check` is a read-only audit of the committed redirect.
The `go.work` in effect is resolved as go resolves it: `GOWORK` naming an absolute file, none under
`GOWORK=off`, else (unset, empty or `auto`) the nearest `go.work` at or above the project; a relative
`GOWORK` is invalid to go, so nothing is written or attested. A `-modfile` in `GOFLAGS` (#1799)
`GOWORK` is invalid to go, so nothing is written or attested. A `go.work` outside the repository
root is never read and refuses the same way (see "Reads bounded to the repository root").
A `-modfile` in `GOFLAGS` (#1799)
that names any file other than the project's `go.mod` (a relative one is taken from the project
directory) makes go build from that file and ignore `go.mod`'s replaces, and in workspace mode go
refuses `-modfile` outright: `apply`, `vendor` and `scan --mode hosted` write nothing
Expand Down Expand Up @@ -902,6 +904,16 @@ socket-patch creates `.socket/` and everything under it itself and never writes

Not covered: the agent-mode blob cache and manifest are checked from `.socket` down, so a `.socket` that is itself a link still redirects those writes (deciding that at lock time is deferred).

### Reads bounded to the repository root

VEX discovery, the lock inventory, hosted mode and vendored mode never read a PROJECT file outside the repository root: the git checkout enclosing the project (the nearest `.git`, by the same rules as the repository lookup), or the project directory itself when there is none. One check (core `utils::repo_root::ReadBoundary`) decides it; a level that resolves outside through `..` or a symlink counts as outside. A refused read behaves like a missing file to a reader; a writer refuses with the reason (`… is outside the repository root …; socket-patch does not read project files outside it`). Machine-level tool configuration (the user `~/.npmrc` / `.yarnrc.yml` / `NuGet.Config`, the `GOENV` file, `$CARGO_HOME`, module and package caches) is not a project file and is read as before.

- **Go**: a `go.work` outside the root (an ancestor's, or one an absolute `GOWORK` names) is only noticed, never read: the workspace is invalid, so vendored and hosted writes refuse with the Go workspace refusal and discovery withholds every Go ref. Inside the root, a `use` member or a `replace` directory outside it reads as missing, so the selection it would decide is unknown.
- **Requirements includes**: a `-r` include outside the root is never read. Discovery and the lock inventory see it as missing; vendored mode refuses `pypi_requirements_outside_root`, since the pins pip installs from it are unknowable.
- **NuGet parent configs**: a `nuget.config` in a directory above the root is never read. Vendoring refuses (`… also merges <path>, which is outside the repository root …`) and hosted mode skips each NuGet dep with `redirect_nuget_inherited_config_outside_root`, writing nothing for it.
- **Relative and absolute references read through the project view** (yarn `file:` / `portal:` / `link:` copies, include chains, any `../` path a lockfile names) and the hosted upstream restore's staged reads: outside the root they read as missing, and the restore refuses.
- **Vendored ownership**: writers, reverters and the GC claim a lockfile reference only when it is root-anchored into THIS project's `.socket/vendor/` (`./.socket/vendor/…`, or `../` from a file below the root back to exactly the root). A sibling checkout's `../.socket/vendor/<eco>/<uuid>/…`, even for the same patch uuid, is never rewritten, reverted or deleted; the revert leaves it with `vendor_lock_entry_drifted` and keeps the artifact. The one exception is pnpm <= 8, which records override specifiers as machine-absolute paths itself: an absolute `…/.socket/vendor/npm/<uuid>/<leaf>` naming the entry's own uuid and leaf is still the entry's.

## Vendor command contract

`vendor` converts the hosted patches pinned in the project's lockfiles into committable
Expand Down
16 changes: 13 additions & 3 deletions crates/socket-patch-cli/src/commands/vendored_backend/repair.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord};
use socket_patch_core::utils::fs::read_regular_to_string;
use socket_patch_core::utils::purl::normalize_purl;
use socket_patch_core::vendor::{
self, artifact_is_file_shaped, check_vendored_artifact, parse_vendor_path,
path::parse_vendor_reference, ArtifactHealth, VendorEntry, VendorState, VendorWarning,
self, artifact_is_file_shaped, check_vendored_artifact,
path::{parse_vendor_reference, recognize_vendor_path_anywhere},
ArtifactHealth, VendorEntry, VendorState, VendorWarning,
};

use super::VendoredBackend;
Expand All @@ -37,6 +38,13 @@ struct Candidate {
/// path parser. Used by repair (references the ledger does not cover), by
/// the orphan sweeps (`vendor --revert`, `scan --prune`: a dir a lockfile
/// still points at is never deleted) and by rollback.
///
/// RECOGNITION only: a reference counts wherever its `.socket/vendor/`
/// anchor sits (a sibling's `../.socket/vendor/…` too), which only ever
/// keeps one of THIS project's dirs from being swept or flags it as
/// wired. Nothing here rewrites the referencing file; the writers and
/// reverters claim wiring through the root-anchored
/// [`socket_patch_core::vendor::parse_vendor_path`].
pub(crate) async fn scan_vendor_references(project_root: &Path) -> Vec<(String, String, String)> {
let mut seen: HashSet<(String, String)> = HashSet::new();
let mut out = Vec::new();
Expand All @@ -54,7 +62,9 @@ pub(crate) async fn scan_vendor_references(project_root: &Path) -> Vec<(String,
socket_patch_core::vendor::bun_lock::binary_vendor_paths(project_root).await
{
for path in paths {
if let Some(parts) = parse_vendor_path(&path) {
if let Some(parts) =
recognize_vendor_path_anywhere(&path).filter(|p| !p.leaf.is_empty())
{
if seen.insert((parts.eco.to_string(), parts.uuid.clone())) {
let rel =
format!(".socket/vendor/{}/{}/{}", parts.eco, parts.uuid, parts.leaf);
Expand Down
13 changes: 9 additions & 4 deletions crates/socket-patch-core/src/formats/composer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,9 @@ impl<'a> ComposerLockPackage<'a> {
.map(LockIntegrity::Sha1Hex)
}

/// The Socket-vendored path `dist.url` names, anchored anywhere
/// ([`parse_vendor_path`]: the writers' ownership rule, not discovery's
/// root-anchored attestation grammar).
/// The path into THIS project's `.socket/vendor/` that `dist.url`
/// names ([`parse_vendor_path`]: the writers' root-anchored ownership
/// rule; a sibling's `../.socket/vendor/…` is not ours).
pub(crate) fn dist_vendor_path(&self) -> Option<VendorPathParts> {
self.dist_str("url").and_then(parse_vendor_path)
}
Expand Down Expand Up @@ -170,7 +170,12 @@ impl<'a> ComposerLock<'a> {
continue;
}
// Our own vendored entries use a path dist — skip.
if pkg.dist_str("type") == Some("path") || pkg.dist_vendor_path().is_some() {
if pkg.dist_str("type") == Some("path")
|| pkg
.dist_str("url")
.and_then(crate::vendor::path::recognize_vendor_path_anywhere)
.is_some()
{
continue;
}
let dist_url = pkg.dist_str("url").unwrap_or("");
Expand Down
4 changes: 3 additions & 1 deletion crates/socket-patch-core/src/formats/pnpm/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -450,7 +450,9 @@ impl<'t> PnpmLock<'t> {
.map(|i| LockIntegrity::Sri(i.to_string()))
.unwrap_or(LockIntegrity::None);
let tarball = resolution.and_then(|r| r.tarball());
if tarball.is_some_and(|t| parse_vendor_path(t).is_some()) {
if tarball
.is_some_and(|t| crate::vendor::path::recognize_vendor_path_anywhere(t).is_some())
{
continue;
}
out.push(LockfileEntry::npm(
Expand Down
8 changes: 8 additions & 0 deletions crates/socket-patch-core/src/hosted/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -807,6 +807,12 @@ pub async fn read_candidate_files(
String::new(),
);
}
if let Some(why) = inherited.outside_refusal() {
out.files.insert(
crate::patch::redirect::NUGET_INHERITED_OUTSIDE_KEY.to_string(),
why,
);
}
}
}

Expand Down Expand Up @@ -4403,6 +4409,8 @@ mod tests {
#[tokio::test]
async fn issue_1951_yarn_classic_copy_manifests_read_as_vendored_reads_them() {
let tmp = tempfile::tempdir().unwrap();
// The repository every path of the fixture lives in.
std::fs::create_dir_all(tmp.path().join(".git")).unwrap();
let root = tmp.path().join("app");
let manifest = r#"{"name":"left-pad","version":"1.3.0"}"#;
for dir in ["app/lpdir", "ext/lpdir", "abs/lpdir"] {
Expand Down
40 changes: 40 additions & 0 deletions crates/socket-patch-core/src/patch/redirect/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7069,6 +7069,11 @@ pub const NUGET_INHERITED_SOURCES_KEY: &str = "<socket-patch:nuget-inherited-sou
/// packages already (`packageSourceMapping`, which NuGet merges too).
pub const NUGET_INHERITED_MAPPING_KEY: &str = "<socket-patch:nuget-inherited-mapping>";

/// The synthetic candidate key present, holding the reason, when NuGet
/// would also merge a parent config above the repository root, which is
/// never read ([`crate::vendor::nuget_config::Inherited::outside`]).
pub const NUGET_INHERITED_OUTSIDE_KEY: &str = "<socket-patch:nuget-inherited-outside>";

/// A config with no sources, the base of a fresh one when the inherited
/// configs dropped nuget.org.
const EMPTY_NUGET_CONFIG: &str = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<configuration>\n <packageSources>\n </packageSources>\n</configuration>\n";
Expand Down Expand Up @@ -7296,6 +7301,16 @@ fn rewrite_nuget(
});
continue;
}
// A parent config above the repository root is merged by NuGet but
// never read here, so the sources and mappings this edit must
// account for are unknown: skip rather than guess.
if let Some(why) = files.get(NUGET_INHERITED_OUTSIDE_KEY) {
result.warnings.push(RewriteWarning {
code: "redirect_nuget_inherited_config_outside_root".into(),
detail: format!("{why}; {} not redirected", dep.name),
});
continue;
}

let unwritable = || RewriteWarning {
code: "redirect_nuget_config_unwritable".into(),
Expand Down Expand Up @@ -27581,6 +27596,31 @@ packages:
assert_eq!(nuget_catch_all(&r.files["nuget.config"]), ["nuget.org"]);
}

/// A parent config above the repository root, which the engine never
/// reads, skips the dep with a warning and writes nothing.
#[test]
fn nuget_inherited_config_outside_the_repository_skips_the_dep() {
let mut files = BTreeMap::new();
files.insert("nuget.config".to_string(), default_nuget_config());
files.insert(
NUGET_INHERITED_SOURCES_KEY.to_string(),
"nuget.org".to_string(),
);
files.insert(
NUGET_INHERITED_OUTSIDE_KEY.to_string(),
"NuGet also merges /x/nuget.config, which is outside the repository root".to_string(),
);
let r = rewrite_registry_redirect(&files, &[nuget_override()]);
assert!(r.files.is_empty(), "{:?}", r.files.keys());
assert_eq!(
r.warnings
.iter()
.map(|w| w.code.as_str())
.collect::<Vec<_>>(),
["redirect_nuget_inherited_config_outside_root"]
);
}

/// A PRESENT but unparseable packages.lock.json refuses the whole nuget
/// redirect up front: landing the source + mapping while the lock kept
/// the upstream contentHash would NU1403 every restore, with the ledger
Expand Down
34 changes: 33 additions & 1 deletion crates/socket-patch-core/src/patch/redirect/staged.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@ pub(super) type StagedBytes = BTreeMap<String, Vec<u8>>;
/// blocking open — the same posture as every other raw read in the patch
/// engine. Errors read `read <rel>: <cause>`.
pub(super) async fn read_rel(project_root: &Path, rel: &str) -> Result<Option<String>, String> {
match crate::utils::fs::read_regular_to_string(&project_root.join(rel)).await {
// A writer's read outside the repository root refuses rather than
// reading as missing: what it would write next depends on it.
let path = crate::utils::repo_root::bounded_project_path(project_root, rel)
.map_err(|refused| format!("read {rel}: {refused}"))?;
match crate::utils::fs::read_regular_to_string(&path).await {
Ok(content) => Ok(Some(content)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(format!("read {rel}: {e}")),
Expand Down Expand Up @@ -103,3 +107,31 @@ async fn refuse_non_regular(path: &Path, rel: &str) -> Result<(), String> {
}
Ok(())
}

#[cfg(test)]
mod tests {
use super::read_rel;

/// The restore's reads stay inside the repository root: a file outside
/// it refuses (the restore stops) instead of reading as missing.
#[tokio::test]
async fn reads_outside_the_repository_refuse() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("repo");
let root = repo.join("app");
std::fs::create_dir_all(repo.join(".git")).unwrap();
std::fs::create_dir_all(&root).unwrap();
std::fs::write(repo.join(".npmrc"), "x=1\n").unwrap();
std::fs::write(tmp.path().join(".npmrc"), "x=2\n").unwrap();
assert_eq!(
read_rel(&root, "../.npmrc").await,
Ok(Some("x=1\n".to_string()))
);
assert_eq!(read_rel(&root, "../missing").await, Ok(None));
let above = tmp.path().join(".npmrc");
for rel in ["../../.npmrc".to_string(), above.display().to_string()] {
let err = read_rel(&root, &rel).await.unwrap_err();
assert!(err.contains("outside the repository root"), "{rel}: {err}");
}
}
}
2 changes: 2 additions & 0 deletions crates/socket-patch-core/src/patch/redirect/upstream/npm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4410,6 +4410,8 @@ mod tests {
use super::{pnpm_settings_prefix, read_sibling};

let dir = tempfile::tempdir().unwrap();
// The repository every path of the fixture lives in.
std::fs::create_dir_all(dir.path().join(".git")).unwrap();
let ws = std::fs::canonicalize(dir.path()).unwrap();
write(
&ws,
Expand Down
Loading
Loading