Skip to content
Open
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
11 changes: 10 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,12 @@ jobs:
suite: e2e_maven
- os: ubuntu-latest
suite: e2e_composer
# composer is a shipped ecosystem, so e2e_composer's tests are
# NOT `#[ignore]`-gated the way the experimental-ecosystem suites
# (maven, nuget) are — the matrix default `--ignored` filter
# selected zero tests here and the leg passed vacuously.
# `--include-ignored` runs them, plus any capstone added later.
test_filter: --include-ignored
- os: ubuntu-latest
suite: e2e_nuget
# Host vendor build-proof capstones: fresh-checkout install +
Expand Down Expand Up @@ -741,7 +747,10 @@ jobs:
tools: composer:2

- name: Run e2e tests
run: cargo test -p socket-patch-cli --all-features --test ${{ matrix.suite }} -- --ignored
# Suites are `#[ignore]`-gated out of the unpinned `test` job by
# default, hence `--ignored`; an entry that sets `test_filter`
# overrides the selector for itself only.
run: cargo test -p socket-patch-cli --all-features --test ${{ matrix.suite }} -- ${{ matrix.test_filter || '--ignored' }}

# ----------------------------------------------------------------------
# Docker-driven real-package e2e suite.
Expand Down
9 changes: 8 additions & 1 deletion crates/socket-patch-cli/src/commands/scan/hosted.rs
Original file line number Diff line number Diff line change
Expand Up @@ -482,7 +482,14 @@ pub(super) async fn run_redirect(
.filter(|(_, _, artifact_url, index_url, suffixed_version)| {
let encoded = socket_patch_core::utils::uri::encode_uri_component(artifact_url);
final_texts.iter().any(|text| {
text.contains(artifact_url.as_str())
// The rewriters' own predicate — raw, or the `\/`-escaped
// slashes an old composer.lock spells them with — so a
// writer's spelling can never be one this probe misses. It
// was: the composer rewriter emitted `\/`-escaped urls this
// probe never looked for, so a fully successful composer
// redirect reported `redirected: 0`, fetched no patch record
// into the ledger, and left the patch unattestable by `vex`.
socket_patch_core::patch::redirect::artifact_url_present(text, artifact_url)
// The berry rewriter writes the URL percent-encoded into the
// lock's `::__archiveUrl=` binding, so the raw form is absent.
|| text.contains(encoded.as_str())
Expand Down
100 changes: 75 additions & 25 deletions crates/socket-patch-cli/src/commands/vendor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,33 +198,68 @@ async fn dispatch_in_use_one(entry: &VendorEntry, project_root: &Path) -> Option
}
}

/// What the orphan sweep did with the uuid dirs no ledger entry owns.
#[derive(Default)]
struct OrphanSweep {
/// Un-ledgered AND unreferenced — deleted (unless `dry_run`).
removed: Vec<vendor::path::SweptVendorDir>,
/// Un-ledgered but a project lockfile still points into them — kept.
still_wired: Vec<vendor::path::SweptVendorDir>,
}

/// Uuid dirs under `.socket/vendor/<eco>/` with no owning `(eco, uuid)`
/// ledger entry (a hand-edited state file, or artifacts left by an
/// interrupted run). The lockfile wiring for these is already gone or
/// owned by a recorded entry, so removal is safe; removed unless
/// `dry_run`. Unparseable dirs are never returned (and never deleted).
/// Returns the orphans so callers can emit events / counts.
async fn sweep_orphan_vendor_dirs(
cwd: &Path,
state: &VendorState,
dry_run: bool,
) -> Vec<vendor::path::SweptVendorDir> {
/// interrupted run). Unparseable dirs are never returned (and never
/// deleted). Returns the orphans so callers can emit events / counts.
///
/// A missing ledger entry does NOT prove missing wiring: `repair`
/// reconstructs entries from lockfiles that still point into
/// `.socket/vendor/` precisely because that state occurs (a deleted
/// state.json, a partial commit). Deleting such a dir would break the next
/// install, so every candidate is checked against the wiring-bearing files
/// first — the same lockfile scan `repair` reconstructs from — and a
/// referenced dir is kept for the caller to warn about.
async fn sweep_orphan_vendor_dirs(cwd: &Path, state: &VendorState, dry_run: bool) -> OrphanSweep {
let recorded_units: HashSet<(&str, &str)> = state
.entries
.values()
.map(|e| (e.ecosystem.as_str(), e.uuid.as_str()))
.collect();
let mut orphans = Vec::new();
for unit in vendor::path::sweep_vendor_dirs(cwd).await {
if recorded_units.contains(&(unit.eco.as_str(), unit.uuid.as_str())) {
let candidates: Vec<vendor::path::SweptVendorDir> = vendor::path::sweep_vendor_dirs(cwd)
.await
.into_iter()
.filter(|unit| !recorded_units.contains(&(unit.eco.as_str(), unit.uuid.as_str())))
.collect();
let mut out = OrphanSweep::default();
if candidates.is_empty() {
return out;
}
let wired: HashSet<(String, String)> =
crate::commands::repair_vendor::scan_vendor_references(cwd)
.await
.into_iter()
.map(|(eco, uuid, _path)| (eco, uuid))
.collect();
for unit in candidates {
if wired.contains(&(unit.eco.clone(), unit.uuid.clone())) {
out.still_wired.push(unit);
continue;
}
if !dry_run {
let _ = remove_tree(&unit.dir).await;
}
orphans.push(unit);
out.removed.push(unit);
}
orphans
out
}

/// How an orphan uuid dir is named in events: the PURL recovered from its
/// leaf when the layout is recognizable, else `<eco>/<uuid>`.
fn orphan_label(unit: &vendor::path::SweptVendorDir) -> String {
unit.purls
.first()
.cloned()
.unwrap_or_else(|| format!("{}/{}", unit.eco, unit.uuid))
}

/// Does `eco` fall inside this run's `--ecosystems` scope?
Expand Down Expand Up @@ -1228,17 +1263,30 @@ async fn run_revert(args: &VendorArgs, env: &mut Envelope) -> i32 {
}

// Orphan sweep: uuid dirs on disk with no ledger entry (a hand-edited
// state file, or artifacts left by an interrupted run). The lockfile
// wiring for these is already gone or owned by a recorded entry, so
// removal is safe; unparseable dirs are reported, never deleted.
for unit in sweep_orphan_vendor_dirs(&common.cwd, &state, common.dry_run).await {
let label = unit
.purls
.first()
.cloned()
.unwrap_or_else(|| format!("{}/{}", unit.eco, unit.uuid));
// state file, or artifacts left by an interrupted run). Unparseable dirs
// are reported, never deleted — and neither are dirs a lockfile still
// points at (their wiring outlived the ledger).
let sweep = sweep_orphan_vendor_dirs(&common.cwd, &state, common.dry_run).await;
for unit in &sweep.still_wired {
let label = orphan_label(unit);
record_warning(
env,
&label,
&VendorWarning::new(
"vendor_orphan_still_wired",
format!(
"a project lockfile still points at .socket/vendor/{}/{}, which no ledger \
entry owns; the artifacts were kept (run `socket-patch repair` to re-adopt \
them into the ledger, then revert again)",
unit.eco, unit.uuid
),
),
common,
);
}
for unit in &sweep.removed {
env.record(
PatchEvent::new(PatchAction::Removed, label)
PatchEvent::new(PatchAction::Removed, orphan_label(unit))
.with_reason("vendor_orphan_removed", "vendored dir had no ledger entry"),
);
}
Expand Down Expand Up @@ -1419,9 +1467,11 @@ pub(crate) async fn run_vendor_gc(
}
}

// (c) orphan uuid dirs, against the post-removal ledger.
// (c) orphan uuid dirs, against the post-removal ledger. Dirs a lockfile
// still points at are kept, so they are not counted as reclaimed.
out.orphan_dirs = sweep_orphan_vendor_dirs(&common.cwd, &state, dry_run)
.await
.removed
.len();
out
}
Expand Down
11 changes: 9 additions & 2 deletions crates/socket-patch-cli/tests/docker_e2e_vendor_composer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,15 @@ cat > composer.json <<'EOF'
EOF

# 1. REAL fixture: composer update resolves + installs psr/log from packagist.
composer update --no-interaction > /tmp/install.log 2>&1 || {
cat /tmp/install.log >&2; fail "composer update (fixture install) failed"; }
# psr/log arrives over the real network (packagist metadata + the GitHub
# zipball); transient stream/connection errors are the dominant flake in this
# suite — retry with backoff before declaring the fixture broken.
for attempt in 1 2 3; do
composer update --no-interaction > /tmp/install.log 2>&1 && break
if [ "$attempt" = 3 ]; then cat /tmp/install.log >&2; fail "composer update (fixture install) failed"; fi
echo "composer update attempt $attempt failed; retrying" >&2
sleep $((attempt * 5))
done

PSR_VER=$(php -r '
$l = json_decode(file_get_contents("composer.lock"), true);
Expand Down
Loading