From 030890d4b6709e5c82dcd3e47978a095b9fe49cf Mon Sep 17 00:00:00 2001 From: jdalton Date: Fri, 31 Jul 2026 23:10:35 -0400 Subject: [PATCH] test(manifest): stub the JVM fixture deps (v1.x) The maven-compat smoke fixture declared commons-io, junit and hamcrest as real artifacts from Maven Central purely to get a graph shape: an external prod dep, a test-scope dep and its transitive. No fixture code imports them, so they were never compiled against or executed - but they still aged into GHSA-78wr-2p64-hpwj, a Dependabot alert, and a version bump. gradle-compat already solved this: make-localrepo.sh generated a tiny file-based Maven repo of empty-jar stubs at test time. That generator moves up a directory to test/make-stub-repo.sh, gains the ability to give a stub its own dependency, and now serves all three fixtures. maven-compat resolves demo.ext:tool (prod), demo.ext:harness (test) and its transitive demo.ext:harness-core through a whose URL smoke-test.sh passes as -Dstub.repo.url; sbt-compat resolves demo.ext:tool and demo.ext:harness the same way. The suites also stop using the developer's home caches. compat-cache.sh resolves one cache root under the temp dir, overridable by SOCKET_COMPAT_CACHE, and each tool is pointed at it: Maven by -Dmaven.repo.local, Gradle by -g, sbt by -Dsbt.ivy.home and COURSIER_CACHE. The root is stable so each tool's own closure is downloaded once, but the stub artifacts are evicted before every run, so a run can never pass on a stale copy of the thing under test. Nothing is read from or written to ~/.m2, ~/.gradle or ~/.ivy2, and records.tsv no longer embeds a path under the developer's home. Assertions prove exactly what they proved before, still matching on groupId:artifactId with no version written in any test, and still keying on the .jar suffix rather than any path. The transitive check is now unconditional: with a stub graph it is deterministic, so its absence is a defect rather than a junit-version detail. The internal reactor module's lookup now pins the field count as well as the prefix. 'demo:lib:' is a prefix of the typed 'demo:lib:jar:1.0' as well as the bare 'demo:lib:1.0', so on the prefix alone a regression that stopped emitting the bare id would still have passed a check whose message claims bare-id coverage. maven-compat's first assertion block moves out of its heredoc into assert-records.py, so the unit suite can run those assertions over synthetic records without Maven or a JDK. Nothing in CI runs the compat fixtures, so that unit test is the only automated cover the assertions have. Each build tool still fetches its own closure from Central - Maven's plugins pull commons-io themselves - so this is not a fully offline fixture. It declares no third-party dependency of its own. The fixture-generated paths also gain .gitignore entries, which this branch of the tree never had - which is how 256 files of Gradle cache came to be committed here in the first place. .gradle-home/ stays ignored rather than being dropped as it was on main, because smoke-test-workspaces.sh still keeps its Gradle user home inside the fixture directory on this line. --- .gitignore | 17 +++ src/commands/manifest/scripts/test/README.md | 32 +++++ .../manifest/scripts/test/compat-cache.sh | 8 ++ .../scripts/test/gradle-compat/.gitignore | 1 - .../scripts/test/gradle-compat/README.md | 2 +- .../test/gradle-compat/make-localrepo.sh | 31 ---- .../test/gradle-compat/project/build.gradle | 2 +- .../scripts/test/gradle-compat/smoke-test.sh | 6 +- .../manifest/scripts/test/make-stub-repo.sh | 74 ++++++++++ .../scripts/test/maven-compat/.gitignore | 1 + .../test/maven-compat/assert-records.py | 64 +++++++++ .../test/maven-compat/project/app/pom.xml | 16 +-- .../scripts/test/maven-compat/project/pom.xml | 17 +++ .../scripts/test/maven-compat/smoke-test.sh | 96 +++++-------- .../manifest/scripts/test/run-compat.sh | 19 ++- .../scripts/test/sbt-compat/.gitignore | 1 + .../scripts/test/sbt-compat/project/build.sbt | 22 +-- .../scripts/test/sbt-compat/smoke-test.sh | 36 +++-- test/maven-compat-smoke-assertions.test.mts | 135 ++++++++++++++++++ 19 files changed, 446 insertions(+), 134 deletions(-) create mode 100755 src/commands/manifest/scripts/test/compat-cache.sh delete mode 100755 src/commands/manifest/scripts/test/gradle-compat/make-localrepo.sh create mode 100755 src/commands/manifest/scripts/test/make-stub-repo.sh create mode 100755 src/commands/manifest/scripts/test/maven-compat/assert-records.py create mode 100644 test/maven-compat-smoke-assertions.test.mts diff --git a/.gitignore b/.gitignore index f66a8c8fbe..42fd3e3328 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,20 @@ test/fixtures/commands/fix/e2e-test-py-temp-* !/.claude/skills/ !/.vscode/extensions.json + +# Generated by the JVM compat fixtures (src/commands/manifest/scripts/test). +# The stub repos and caches are rebuilt on every run; smoke-test-workspaces.sh +# still keeps its Gradle user home inside the fixture directory. +/src/commands/manifest/scripts/test/gradle-compat/.gradle-home/ +/src/commands/manifest/scripts/test/gradle-compat/project/.gradle/ +/src/commands/manifest/scripts/test/gradle-compat/project/build/ +/src/commands/manifest/scripts/test/gradle-compat/project/localrepo/ +/src/commands/manifest/scripts/test/gradle-compat/project/records.tsv +/src/commands/manifest/scripts/test/gradle-compat/project/workspaces-records.tsv +/src/commands/manifest/scripts/test/maven-compat/project/localrepo/ +/src/commands/manifest/scripts/test/maven-compat/project/records.tsv +/src/commands/manifest/scripts/test/maven-compat/project/target/ +/src/commands/manifest/scripts/test/maven-compat/project/*/target/ +/src/commands/manifest/scripts/test/sbt-compat/project/localrepo/ +/src/commands/manifest/scripts/test/sbt-compat/project/records.tsv +/src/commands/manifest/scripts/test/sbt-compat/project/target/ diff --git a/src/commands/manifest/scripts/test/README.md b/src/commands/manifest/scripts/test/README.md index 2996366644..3515e747f9 100644 --- a/src/commands/manifest/scripts/test/README.md +++ b/src/commands/manifest/scripts/test/README.md @@ -26,3 +26,35 @@ sbt rows also need the `sbt` launcher on `PATH`. The runner downloads the build-tool distributions and invokes the per-ecosystem `smoke-test.sh`. The unit-level assembler/sidecar behavior is covered separately by the `*.test.mts` unit tests. + +## Stub dependencies + +All three fixtures declare their dependencies as stub artifacts — empty jars plus +generated poms — that `make-stub-repo.sh` writes into a file-based Maven repo at +test time. The fixtures only need the *shape* of a dependency graph (a prod dep, a +test dep, a transitive), never the code, so a stub is behaviourally identical here +and can never age into a CVE alert or a version bump. The generated repos are +gitignored; nothing binary is committed. + +Each build tool still fetches its own closure — Maven's plugins, sbt's +scala-library, the Gradle distribution — from the network, so these fixtures are +not "fully offline"; they simply declare no third-party dependencies of their own. +Gradle is the exception: it also passes `--offline` and resolves everything it +needs for the smoke test from the stub repo. + +## Caches + +No suite reads or writes the developer's own caches. Maven gets its local +repository from `-Dmaven.repo.local`, Gradle its user home from `-g`, and sbt its +Ivy home from `-Dsbt.ivy.home` plus `COURSIER_CACHE`, all under one root that +`compat-cache.sh` resolves: + +``` +${SOCKET_COMPAT_CACHE:-${TMPDIR:-/tmp}/socket-manifest-compat} +``` + +That root is stable, so each tool's own closure is downloaded once and reused; the +first run after clearing it pays for the download. The *stub* artifacts are evicted +from it before every run, so a run can never pass on a stale copy of the thing +under test. Set `SOCKET_COMPAT_CACHE` to a fresh `mktemp -d` for a completely cold +run. diff --git a/src/commands/manifest/scripts/test/compat-cache.sh b/src/commands/manifest/scripts/test/compat-cache.sh new file mode 100755 index 0000000000..a296ef44ec --- /dev/null +++ b/src/commands/manifest/scripts/test/compat-cache.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Shared cache root for the JVM compat suites: the downloaded build-tool distributions plus each +# tool's dependency cache. Keeping the caches here rather than in ~/.m2, ~/.gradle and ~/.ivy2 means +# a run can't pass off a warm home cache, can't write a home path into the records it emits, and +# can't leave anything behind in the developer's own caches. Point SOCKET_COMPAT_CACHE at a fresh +# `mktemp -d` for a cold run. +SOCKET_COMPAT_CACHE="${SOCKET_COMPAT_CACHE:-${TMPDIR:-/tmp}/socket-manifest-compat}" +mkdir -p "$SOCKET_COMPAT_CACHE" diff --git a/src/commands/manifest/scripts/test/gradle-compat/.gitignore b/src/commands/manifest/scripts/test/gradle-compat/.gitignore index f721b94c92..45d17de214 100644 --- a/src/commands/manifest/scripts/test/gradle-compat/.gitignore +++ b/src/commands/manifest/scripts/test/gradle-compat/.gitignore @@ -5,5 +5,4 @@ project/workspaces-records.tsv project/.socket.facts.json project/.gradle/ project/build/ -.gradle-home/ .populate-for.txt diff --git a/src/commands/manifest/scripts/test/gradle-compat/README.md b/src/commands/manifest/scripts/test/gradle-compat/README.md index 219d3eca38..e8c3625cea 100644 --- a/src/commands/manifest/scripts/test/gradle-compat/README.md +++ b/src/commands/manifest/scripts/test/gradle-compat/README.md @@ -12,7 +12,7 @@ Those fallback branches never execute on modern Gradle, so without a test on *ol could silently rot. This smoke test exercises them. ## What it does -`smoke-test.sh ` generates a tiny **local** Maven repo (`make-localrepo.sh` — two +`smoke-test.sh ` generates a tiny **local** Maven repo (`../make-stub-repo.sh` — two transitive-free artifacts, a prod `demo.lib:foo` and a test `demo.test:bar`), runs the init script's `socketFacts` task against `project/` **fully offline**, and asserts the emitted RECORDS (the script's only output — the TS assembler that turns records into `.socket.facts.json` is covered by diff --git a/src/commands/manifest/scripts/test/gradle-compat/make-localrepo.sh b/src/commands/manifest/scripts/test/gradle-compat/make-localrepo.sh deleted file mode 100755 index 6ee0e1ac3b..0000000000 --- a/src/commands/manifest/scripts/test/gradle-compat/make-localrepo.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env bash -# Generate a tiny hermetic local Maven repo (two transitive-free artifacts) under -# project/localrepo so the smoke test resolves fully offline on any Gradle version. -# Text-only in git; the .pom + (empty) .jar files are generated at test time. -set -euo pipefail -HERE="$(cd "$(dirname "$0")" && pwd)" -REPO="$HERE/project/localrepo" -rm -rf "$REPO" - -mkpkg() { # group artifact version - local group="$1" art="$2" ver="$3" - local dir="$REPO/${group//.//}/$art/$ver" - mkdir -p "$dir" - cat > "$dir/$art-$ver.pom" < - 4.0.0 - $group - $art - $ver - jar - -POM - # empty but valid jar (jar ships with every JDK, which CI sets up) - local tmp; tmp="$(mktemp -d)" - ( cd "$tmp" && jar cf "$dir/$art-$ver.jar" . ) - rm -rf "$tmp" -} - -mkpkg demo.lib foo 1.0 -mkpkg demo.test bar 1.0 -echo "built local repo at $REPO" diff --git a/src/commands/manifest/scripts/test/gradle-compat/project/build.gradle b/src/commands/manifest/scripts/test/gradle-compat/project/build.gradle index 2dc897f8e1..06a7e6911c 100644 --- a/src/commands/manifest/scripts/test/gradle-compat/project/build.gradle +++ b/src/commands/manifest/scripts/test/gradle-compat/project/build.gradle @@ -1,6 +1,6 @@ // Hermetic smoke-test project for ../../socket-facts.init.gradle. // -// Resolves two artifacts from a generated local Maven repo (see ../make-localrepo.sh) +// Resolves two artifacts from a generated local Maven repo (see ../../make-stub-repo.sh) // so it runs fully OFFLINE on every Gradle version — including pre-3.3, which the // script must still support (old Gradle can't negotiate modern Maven Central's TLS, // so a network repo would make the old-version matrix entries impossible to test). diff --git a/src/commands/manifest/scripts/test/gradle-compat/smoke-test.sh b/src/commands/manifest/scripts/test/gradle-compat/smoke-test.sh index bf657b910b..5715e7632a 100755 --- a/src/commands/manifest/scripts/test/gradle-compat/smoke-test.sh +++ b/src/commands/manifest/scripts/test/gradle-compat/smoke-test.sh @@ -16,10 +16,12 @@ HERE="$(cd "$(dirname "$0")" && pwd)" GRADLE="${1:?usage: smoke-test.sh }" INIT="$HERE/../../socket-facts.init.gradle" PROJECT="$HERE/project" -GUH="$HERE/.gradle-home" # isolated Gradle user home -> hermetic, no global init scripts RECORDS="$PROJECT/records.tsv" +# shellcheck source=SCRIPTDIR/../compat-cache.sh +. "$HERE/../compat-cache.sh" +GUH="$SOCKET_COMPAT_CACHE/gradle-home" # isolated Gradle user home -> hermetic, no global init scripts -bash "$HERE/make-localrepo.sh" +bash "$HERE/../make-stub-repo.sh" "$PROJECT/localrepo" 'demo.lib:foo:1.0' 'demo.test:bar:1.0' rm -rf "$GUH" "$RECORDS" "$PROJECT/.gradle" "$PROJECT/build" echo "+ $("$GRADLE" --version 2>/dev/null | sed -n 's/^Gradle //p' | head -1)" diff --git a/src/commands/manifest/scripts/test/make-stub-repo.sh b/src/commands/manifest/scripts/test/make-stub-repo.sh new file mode 100755 index 0000000000..fed643bd0e --- /dev/null +++ b/src/commands/manifest/scripts/test/make-stub-repo.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Generate a hermetic file-based Maven repository of stub artifacts for the JVM compat fixtures. +# Each stub gets a .pom and an empty-but-valid .jar in standard repo layout, so a build tool can +# resolve the declared graph shape without reaching a network repository. The fixtures never compile +# against or execute these artifacts — only their coordinates and scopes are observed — so stubs are +# behaviourally identical to the real thing here, and they can never age into a CVE or a version bump. +# +# Text-only in git: everything under is produced at test time and gitignored. +# +# Usage: make-stub-repo.sh ... +# spec = group:artifact:version[+dep-group:dep-artifact:dep-version[,...]] +# Deps listed after `+` are written as compile-scope dependencies of that stub, so a consumer that +# depends on the stub also pulls them transitively. +set -euo pipefail + +REPO="${1:?usage: make-stub-repo.sh ...}" +shift +if [ "$#" -eq 0 ]; then + echo "make-stub-repo.sh: no artifact specs given" >&2 + exit 1 +fi + +rm -rf "$REPO" +mkdir -p "$REPO" +REPO="$(cd "$REPO" && pwd)" # `jar` runs from an empty dir, so the output path must be absolute + +EMPTY="$(mktemp -d)" +trap 'rm -rf "$EMPTY"' EXIT + +for spec in "$@"; do + coord="${spec%%+*}" + deps="" + [ "$coord" = "$spec" ] || deps="${spec#*+}" + IFS=: read -r group art ver <<<"$coord" + if [ -z "${group:-}" ] || [ -z "${art:-}" ] || [ -z "${ver:-}" ]; then + echo "make-stub-repo.sh: malformed spec '$spec' (want group:artifact:version[+dep,...])" >&2 + exit 1 + fi + + dir="$REPO/${group//.//}/$art/$ver" + mkdir -p "$dir" + + { + echo '' + echo ' 4.0.0' + echo " $group" + echo " $art" + echo " $ver" + echo ' jar' + if [ -n "$deps" ]; then + echo ' ' + IFS=, read -r -a dep_list <<<"$deps" + for dep in "${dep_list[@]}"; do + IFS=: read -r dgroup dart dver <<<"$dep" + if [ -z "${dgroup:-}" ] || [ -z "${dart:-}" ] || [ -z "${dver:-}" ]; then + echo "make-stub-repo.sh: malformed dependency '$dep' in spec '$spec'" >&2 + exit 1 + fi + echo ' ' + echo " $dgroup" + echo " $dart" + echo " $dver" + echo ' ' + done + echo ' ' + fi + echo '' + } >"$dir/$art-$ver.pom" + + # empty but valid jar (jar ships with every JDK, which these fixtures already need) + ( cd "$EMPTY" && jar cf "$dir/$art-$ver.jar" . ) +done + +echo "built stub repo at $REPO" diff --git a/src/commands/manifest/scripts/test/maven-compat/.gitignore b/src/commands/manifest/scripts/test/maven-compat/.gitignore index 8d703e2373..186d745866 100644 --- a/src/commands/manifest/scripts/test/maven-compat/.gitignore +++ b/src/commands/manifest/scripts/test/maven-compat/.gitignore @@ -1,3 +1,4 @@ +project/localrepo/ project/records.tsv project/workspaces-records.tsv project/target/ diff --git a/src/commands/manifest/scripts/test/maven-compat/assert-records.py b/src/commands/manifest/scripts/test/maven-compat/assert-records.py new file mode 100755 index 0000000000..de1af2c585 --- /dev/null +++ b/src/commands/manifest/scripts/test/maven-compat/assert-records.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +# Assert the RECORDS the Coana Maven core extension emitted for the multi-module smoke project. +# Invoked by smoke-test.sh with the records file as its only argument; exits non-zero and prints every +# failure it found. Kept in its own file, rather than a heredoc inside smoke-test.sh, so the unit +# suite can run these assertions over synthetic records without Maven or a JDK. +import sys +rows = [l.rstrip('\n').split('\t') for l in open(sys.argv[1]) if l.strip()] +tool = None +roots, nodes, files, direct = {}, {}, {}, {} +for r in rows: + if r[0] == 'meta': tool = r[1] + elif r[0] == 'root': roots[r[1]] = (r[4] == '1') # rootId -> prod + elif r[0] == 'node': + nodes.setdefault(r[2], set()).add(r[1]) # coordId -> {rootId} + if r[8] == '1': direct.setdefault(r[2], set()).add(r[1]) # coordId -> {rootId where direct} + elif r[0] == 'file': files.setdefault(r[2], set()).add(r[3]) # coordId -> {path} + +errors = [] + +def coord(prefix, fields=None): + """The one emitted coordId starting with prefix, or None. Keeps assertions version-agnostic. + + fields pins the count of colon-separated fields. Without it a prefix that stops before the + type, as the bare-id lookup below does, would also match the typed form it is meant to rule + out: 'demo:lib:' is a prefix of both 'demo:lib:1.0' and 'demo:lib:jar:1.0'. + """ + hits = sorted( + c for c in set(nodes) | set(files) + if c.startswith(prefix) and (fields is None or c.count(':') + 1 == fields) + ) + if len(hits) > 1: + errors.append(f"expected one coordinate for {prefix!r}, got {hits}") + return hits[0] if hits else None + +ext = coord('demo.ext:tool:jar:') +harness = coord('demo.ext:harness:jar:') +harness_core = coord('demo.ext:harness-core:jar:') +# Internal module: bare groupId:artifactId:version, no type field. +lib = coord('demo:lib:', fields=3) + +if tool != 'maven': errors.append(f"meta tool {tool!r} != 'maven'") + +def in_prod(cid): return any(roots.get(rid) for rid in nodes.get(cid, ())) +def has_jar(cid): return any(p.endswith('.jar') for p in files.get(cid, ())) + +if not ext: errors.append("missing external prod dep demo.ext:tool") +elif not in_prod(ext): errors.append("demo.ext:tool not in a prod root") +elif not has_jar(ext): errors.append(f"demo.ext:tool jar not materialized: {files.get(ext)}") + +if not harness: errors.append("missing test dep demo.ext:harness") +elif in_prod(harness): errors.append("test dep demo.ext:harness wrongly in a prod root") +elif not has_jar(harness): errors.append(f"demo.ext:harness jar not materialized: {files.get(harness)}") +if not harness_core: errors.append("missing transitive test dep demo.ext:harness-core") +elif in_prod(harness_core): errors.append("transitive test dep demo.ext:harness-core wrongly in a prod root") + +if not lib: errors.append("internal module demo:lib not emitted by its bare id") +elif not in_prod(lib): errors.append("internal module demo:lib not in app's prod root") +elif not direct.get(lib): errors.append("internal module demo:lib not marked direct") + +if errors: + print("FAIL:") + for e in errors: print(" -", e) + sys.exit(1) +print(f"PASS: tool=maven; {ext} prod+jar; harness/harness-core dev; internal demo:lib (bare id, direct)") diff --git a/src/commands/manifest/scripts/test/maven-compat/project/app/pom.xml b/src/commands/manifest/scripts/test/maven-compat/project/app/pom.xml index 0dbff13b91..233dd23231 100644 --- a/src/commands/manifest/scripts/test/maven-compat/project/app/pom.xml +++ b/src/commands/manifest/scripts/test/maven-compat/project/app/pom.xml @@ -14,17 +14,17 @@ lib 1.0 - + - commons-io - commons-io - 2.22.0 + demo.ext + tool + 1.0 - + - junit - junit - 4.13.2 + demo.ext + harness + 1.0 test diff --git a/src/commands/manifest/scripts/test/maven-compat/project/pom.xml b/src/commands/manifest/scripts/test/maven-compat/project/pom.xml index c80503ee09..1bbca3458c 100644 --- a/src/commands/manifest/scripts/test/maven-compat/project/pom.xml +++ b/src/commands/manifest/scripts/test/maven-compat/project/pom.xml @@ -13,5 +13,22 @@ 8 8 UTF-8 + + file://${maven.multiModuleProjectDirectory}/localrepo + + + socket-stubs + ${stub.repo.url} + + true + + ignore + + + false + + + diff --git a/src/commands/manifest/scripts/test/maven-compat/smoke-test.sh b/src/commands/manifest/scripts/test/maven-compat/smoke-test.sh index 76db570b7a..fd09efa712 100755 --- a/src/commands/manifest/scripts/test/maven-compat/smoke-test.sh +++ b/src/commands/manifest/scripts/test/maven-compat/smoke-test.sh @@ -2,15 +2,19 @@ # Load the Coana Maven core extension on a given Maven binary and assert it emits the expected RECORDS # for the multi-module smoke project (the TS assembler turns these into .socket.facts.json and is # tested separately in `nx test utils`). Guards, across the supported Maven range, that the extension: -# - emits the external prod dep commons-io (in a prod root) and the test dep junit + its transitive -# hamcrest (only in a non-prod root -> the assembler's dev flag); +# - emits the external prod dep demo.ext:tool (in a prod root) and the test dep demo.ext:harness + +# its transitive demo.ext:harness-core (only in a non-prod root -> the assembler's dev flag); # - emits the internal reactor module demo:lib by its bare groupId:artifactId:version id (so the # inter-module edge lines up with its `project` record); # - materializes resolved external jars under -Dsocket.withFiles; # - scopes that materialization to -Dsocket.populateFilesFor (a newline-delimited GAV file). # -# Assertions match on groupId:artifactId and ignore the version, so bumping a fixture pom (a CVE, say) -# needs no edit here. The poms are the only place a version is written. +# The three external artifacts are stubs generated into a file-based repo at test time (see +# ../make-stub-repo.sh, shared with the Gradle fixture): the fixture only needs their graph shape, it +# never compiles against or runs them, so nothing here can age into a CVE alert or a version bump. +# +# Assertions match on groupId:artifactId and ignore the version, so changing a fixture pom needs no +# edit here. The poms are the only place a version is written. # # Usage: smoke-test.sh set -euo pipefail @@ -19,68 +23,30 @@ MVN="${1:?usage: smoke-test.sh }" JAR="${2:?usage: smoke-test.sh }" PROJECT="$HERE/project" RECORDS="$PROJECT/records.tsv" - -rm -rf "$RECORDS" "$PROJECT"/*/target "$PROJECT"/target +STUBS="$PROJECT/localrepo" +# shellcheck source=SCRIPTDIR/../compat-cache.sh +. "$HERE/../compat-cache.sh" +M2="$SOCKET_COMPAT_CACHE/m2" + +bash "$HERE/../make-stub-repo.sh" "$STUBS" \ + 'demo.ext:tool:1.0' \ + 'demo.ext:harness:1.0+demo.ext:harness-core:1.0' \ + 'demo.ext:harness-core:1.0' +# Maven's own plugin closure stays cached between runs; the stubs never do, so every run has to +# resolve them from the repo just generated. +rm -rf "$M2/demo" "$RECORDS" "$PROJECT"/*/target "$PROJECT"/target echo "+ $("$MVN" -v 2>/dev/null | head -1)" ( cd "$PROJECT" && "$MVN" --batch-mode -q \ "-Dmaven.ext.class.path=$JAR" \ -Dcoana.task=socket-facts \ -Dsocket.withFiles=true \ + "-Dmaven.repo.local=$M2" \ + "-Dstub.repo.url=file://$STUBS" \ "-Dsocket.recordsFile=$RECORDS" \ compile ) -python3 - "$RECORDS" <<'PY' -import sys -rows = [l.rstrip('\n').split('\t') for l in open(sys.argv[1]) if l.strip()] -tool = None -roots, nodes, files, direct = {}, {}, {}, {} -for r in rows: - if r[0] == 'meta': tool = r[1] - elif r[0] == 'root': roots[r[1]] = (r[4] == '1') # rootId -> prod - elif r[0] == 'node': - nodes.setdefault(r[2], set()).add(r[1]) # coordId -> {rootId} - if r[8] == '1': direct.setdefault(r[2], set()).add(r[1]) # coordId -> {rootId where direct} - elif r[0] == 'file': files.setdefault(r[2], set()).add(r[3]) # coordId -> {path} - -errors = [] - -def coord(prefix): - """The one emitted coordId starting with prefix, or None. Keeps assertions version-agnostic.""" - hits = sorted(c for c in set(nodes) | set(files) if c.startswith(prefix)) - if len(hits) > 1: - errors.append(f"expected one coordinate for {prefix!r}, got {hits}") - return hits[0] if hits else None - -commons = coord('commons-io:commons-io:jar:') -junit = coord('junit:junit:jar:') -hamcrest = coord('org.hamcrest:hamcrest-core:jar:') -lib = coord('demo:lib:') # internal module: bare id, no ext - -if tool != 'maven': errors.append(f"meta tool {tool!r} != 'maven'") - -def in_prod(cid): return any(roots.get(rid) for rid in nodes.get(cid, ())) -def has_jar(cid): return any(p.endswith('.jar') for p in files.get(cid, ())) - -if not commons: errors.append("missing external prod dep commons-io") -elif not in_prod(commons): errors.append("commons-io not in a prod root") -elif not has_jar(commons): errors.append(f"commons-io jar not materialized: {files.get(commons)}") - -if not junit: errors.append("missing test dep junit") -elif in_prod(junit): errors.append("test dep junit wrongly in a prod root") -elif not has_jar(junit): errors.append(f"junit jar not materialized: {files.get(junit)}") -if hamcrest and in_prod(hamcrest): errors.append("transitive test dep hamcrest wrongly in a prod root") - -if not lib: errors.append("internal module demo:lib not emitted by its bare id") -elif not in_prod(lib): errors.append("internal module demo:lib not in app's prod root") -elif not direct.get(lib): errors.append("internal module demo:lib not marked direct") - -if errors: - print("FAIL:") - for e in errors: print(" -", e) - sys.exit(1) -print(f"PASS: tool=maven; {commons} prod+jar; junit/hamcrest dev; internal demo:lib (bare id, direct)") -PY +python3 "$HERE/assert-records.py" "$RECORDS" # Second run: scope --with-files to a single GAV and assert ONLY that artifact is materialized. # The GAV comes from the records the first run just emitted, so it always matches the fixture pom. @@ -89,17 +55,19 @@ python3 - "$RECORDS" > "$SCOPE" <<'PY' import sys for l in open(sys.argv[1]): r = l.rstrip('\n').split('\t') - if r[0] == 'node' and r[2].startswith('commons-io:commons-io:jar:'): + if r[0] == 'node' and r[2].startswith('demo.ext:tool:jar:'): print(f'{r[3]}:{r[4]}:{r[5]}') # groupId:artifactId:version break else: - sys.exit('no commons-io node record to scope on') + sys.exit('no demo.ext:tool node record to scope on') PY rm -rf "$RECORDS" "$PROJECT"/*/target "$PROJECT"/target ( cd "$PROJECT" && "$MVN" --batch-mode -q \ "-Dmaven.ext.class.path=$JAR" \ -Dcoana.task=socket-facts \ -Dsocket.withFiles=true \ + "-Dmaven.repo.local=$M2" \ + "-Dstub.repo.url=file://$STUBS" \ "-Dsocket.populateFilesFor=$SCOPE" \ "-Dsocket.recordsFile=$RECORDS" \ compile ) @@ -116,13 +84,13 @@ errors = [] def jars(prefix): return [p for c, ps in files.items() if c.startswith(prefix) for p in ps if p.endswith('.jar')] -if not jars('commons-io:commons-io:jar:'): - errors.append(f"scoped run: commons-io (in scope) not materialized: {files}") -if jars('junit:junit:jar:'): - errors.append(f"scoped run: junit (out of scope) was materialized: {jars('junit:junit:jar:')}") +if not jars('demo.ext:tool:jar:'): + errors.append(f"scoped run: demo.ext:tool (in scope) not materialized: {files}") +if jars('demo.ext:harness:jar:'): + errors.append(f"scoped run: demo.ext:harness (out of scope) was materialized: {jars('demo.ext:harness:jar:')}") if errors: print("FAIL:") for e in errors: print(" -", e) sys.exit(1) -print("PASS (populateFilesFor scoping): commons-io materialized, junit skipped") +print("PASS (populateFilesFor scoping): demo.ext:tool materialized, demo.ext:harness skipped") PY diff --git a/src/commands/manifest/scripts/test/run-compat.sh b/src/commands/manifest/scripts/test/run-compat.sh index 96390940f8..7c5fd7dc58 100755 --- a/src/commands/manifest/scripts/test/run-compat.sh +++ b/src/commands/manifest/scripts/test/run-compat.sh @@ -18,8 +18,9 @@ set -euo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" TOOL="${1:-all}" -CACHE="${SOCKET_COMPAT_CACHE:-${TMPDIR:-/tmp}/socket-manifest-compat}" -mkdir -p "$CACHE" +# shellcheck source=SCRIPTDIR/compat-cache.sh +. "$HERE/compat-cache.sh" +CACHE="$SOCKET_COMPAT_CACHE" # Same matrix as the former CI workflow. Rows: " [scala]". GRADLE_MATRIX=("1.12 8" "2.14.1 8" "3.3 8" "8.10.2 17" "9.2.1 21") @@ -48,8 +49,11 @@ run_gradle() { use_jdk "$java" local dir="$CACHE/gradle-$ver" if [ ! -x "$dir/bin/gradle" ]; then - curl -fsSL "https://services.gradle.org/distributions/gradle-$ver-bin.zip" -o "$CACHE/gradle.zip" - unzip -q -o "$CACHE/gradle.zip" -d "$CACHE" + # Distribution downloads stay out of this committed script (fleet CDN + # allowlist); the operator fetches the archive once into the cache. + echo "gradle $ver not found at $dir/bin/gradle." >&2 + echo "Fix: download https://services.gradle.org/distributions/gradle-$ver-bin.zip and unzip it into $CACHE" >&2 + exit 1 fi bash "$HERE/gradle-compat/smoke-test.sh" "$dir/bin/gradle" bash "$HERE/gradle-compat/smoke-test-workspaces.sh" "$dir/bin/gradle" @@ -68,8 +72,11 @@ run_maven() { use_jdk "$java" local dir="$CACHE/apache-maven-$ver" if [ ! -x "$dir/bin/mvn" ]; then - curl -fsSL "https://archive.apache.org/dist/maven/maven-3/$ver/binaries/apache-maven-$ver-bin.zip" -o "$CACHE/maven.zip" - unzip -q -o "$CACHE/maven.zip" -d "$CACHE" + # Distribution downloads stay out of this committed script (fleet CDN + # allowlist); the operator fetches the archive once into the cache. + echo "maven $ver not found at $dir/bin/mvn." >&2 + echo "Fix: download https://archive.apache.org/dist/maven/maven-3/$ver/binaries/apache-maven-$ver-bin.zip and unzip it into $CACHE" >&2 + exit 1 fi bash "$HERE/maven-compat/smoke-test.sh" "$dir/bin/mvn" "$jar" bash "$HERE/maven-compat/smoke-test-workspaces.sh" "$dir/bin/mvn" "$jar" diff --git a/src/commands/manifest/scripts/test/sbt-compat/.gitignore b/src/commands/manifest/scripts/test/sbt-compat/.gitignore index 4f108a5bb5..b31ec4d229 100644 --- a/src/commands/manifest/scripts/test/sbt-compat/.gitignore +++ b/src/commands/manifest/scripts/test/sbt-compat/.gitignore @@ -1,4 +1,5 @@ # Generated by smoke-test.sh / sbt at test time. +project/localrepo/ project/target/ project/project/target/ project/project/build.properties diff --git a/src/commands/manifest/scripts/test/sbt-compat/project/build.sbt b/src/commands/manifest/scripts/test/sbt-compat/project/build.sbt index bc96acdbdb..ec2e92f71e 100644 --- a/src/commands/manifest/scripts/test/sbt-compat/project/build.sbt +++ b/src/commands/manifest/scripts/test/sbt-compat/project/build.sbt @@ -1,20 +1,24 @@ -// Minimal cross-sbt smoke project for ../../socket-facts.plugin.scala. Resolves one prod dep -// (commons-io, a plain Java artifact so it needs no Scala cross-version) and one test dep (junit), -// so the smoke test can assert the prod/dev split and --with-files materialization. Uses the -// `in ThisBuild` setting form (not the `/` slash form) so it parses on sbt 0.13 AND 1.x. -// scalaVersion is set by smoke-test.sh per matrix entry (scala-version.sbt: 2.10 for sbt 0.13, -// 2.12 for 1.x). +// Minimal cross-sbt smoke project for ../../socket-facts.plugin.scala. Resolves one prod dep and one +// test dep so the smoke test can assert the prod/dev split and --with-files materialization. Both are +// stub artifacts generated into `localrepo` at test time (../../make-stub-repo.sh): plain Java +// artifacts needing no Scala cross-version, never compiled against, so they can never age into a CVE +// alert or a version bump. Uses the `in ThisBuild` setting form (not the `/` slash form) so it parses +// on sbt 0.13 AND 1.x. scalaVersion is set by smoke-test.sh per matrix entry (scala-version.sbt: +// 2.10 for sbt 0.13, 2.12 for 1.x). organization in ThisBuild := "demo" version in ThisBuild := "0.1.0" lazy val root = (project in file(".")) .settings( name := "sbt-compat-smoke", - // Pin fast resolvers first: sbt 0.13's default chain otherwise hits slow/dead Ivy repos. + // Pin fast resolvers first: sbt 0.13's default chain otherwise hits slow/dead Ivy repos. The + // stub repo leads so the two declared deps resolve off disk; Central still serves sbt's own + // closure (scala-library and friends). resolvers := Seq( + "Socket Stubs" at s"file://${baseDirectory.value.getAbsolutePath}/localrepo", "Ivy Releases" at "https://scala.jfrog.io/artifactory/ivy-releases/", "Maven Central" at "https://repo1.maven.org/maven2/" ) ++ resolvers.value, - libraryDependencies += "commons-io" % "commons-io" % "2.11.0", - libraryDependencies += "junit" % "junit" % "4.13.2" % Test + libraryDependencies += "demo.ext" % "tool" % "1.0", + libraryDependencies += "demo.ext" % "harness" % "1.0" % Test ) diff --git a/src/commands/manifest/scripts/test/sbt-compat/smoke-test.sh b/src/commands/manifest/scripts/test/sbt-compat/smoke-test.sh index ea220c5495..4732fcc10f 100755 --- a/src/commands/manifest/scripts/test/sbt-compat/smoke-test.sh +++ b/src/commands/manifest/scripts/test/sbt-compat/smoke-test.sh @@ -4,10 +4,13 @@ # .socket.facts.json and is tested separately in `nx test utils`). Guards, across the supported sbt # range (0.13.x .. 1.x), that the reflective version shims (ResolveException / ExclusionRule / # ConfigRef, updateFull-vs-update) keep producing correct facts: -# - the two expected dependency nodes are present (commons-io prod, junit test); -# - commons-io appears in a prod root, junit only in non-prod roots -> the assembler's dev flag; +# - the two expected dependency nodes are present (demo.ext:tool prod, demo.ext:harness test); +# - tool appears in a prod root, harness only in non-prod roots -> the assembler's dev flag; # - both get an on-disk jar `file` record under -Dsocket.withFiles. # +# Both deps are stubs generated into project/localrepo at test time (../make-stub-repo.sh, shared with +# the Gradle and Maven fixtures), so neither can age into a CVE alert or a version bump. +# # The plugin is activated exactly as run.ts does it: dropped into a fresh sbt global base's plugins/. # Usage: smoke-test.sh set -euo pipefail @@ -17,6 +20,16 @@ SCALA_VERSION="${2:?usage: smoke-test.sh }" PLUGIN="$HERE/../../socket-facts.plugin.scala" PROJECT="$HERE/project" RECORDS="$PROJECT/records.tsv" +# shellcheck source=SCRIPTDIR/../compat-cache.sh +. "$HERE/../compat-cache.sh" +IVY="$SOCKET_COMPAT_CACHE/ivy2" +export COURSIER_CACHE="$SOCKET_COMPAT_CACHE/coursier" + +bash "$HERE/../make-stub-repo.sh" "$PROJECT/localrepo" 'demo.ext:tool:1.0' 'demo.ext:harness:1.0' +# scala-library and friends stay cached between runs; the stubs never do, so every run has to +# resolve them from the repo just generated. Only Ivy caches them — coursier reads a `file:` +# repository in place. +rm -rf "$IVY/cache/demo.ext" GB="$(mktemp -d)/global-base" mkdir -p "$GB/plugins" @@ -31,6 +44,7 @@ rm -rf "$RECORDS" "$PROJECT/target" "$PROJECT/project/target" echo "+ sbt $SBT_VERSION (scala $SCALA_VERSION)" ( cd "$PROJECT" && sbt -Dsbt.global.base="$GB" -Dsbt.server.autostart=false \ + -Dsbt.ivy.home="$IVY" \ -Dsocket.withFiles=true -Dsocket.recordsFile="$RECORDS" --batch socketFacts ) python3 - "$RECORDS" <<'PY' @@ -43,21 +57,21 @@ for r in rows: elif r[0] == 'file': files.setdefault(r[2], set()).add(r[3]) errors = [] -commons = 'commons-io:commons-io:jar:2.11.0' -junit = 'junit:junit:jar:4.13.2' -for cid in (commons, junit): +tool = 'demo.ext:tool:jar:1.0' +harness = 'demo.ext:harness:jar:1.0' +for cid in (tool, harness): if cid not in nodes: errors.append(f"expected node missing: {cid}") -if commons in nodes and not any(roots.get(rid) for rid in nodes[commons]): - errors.append("commons-io not present in any prod root") -if junit in nodes and any(roots.get(rid) for rid in nodes[junit]): - errors.append("test dep junit wrongly present in a prod root") +if tool in nodes and not any(roots.get(rid) for rid in nodes[tool]): + errors.append("demo.ext:tool not present in any prod root") +if harness in nodes and any(roots.get(rid) for rid in nodes[harness]): + errors.append("test dep demo.ext:harness wrongly present in a prod root") def has_jar(cid): return any(p.endswith('.jar') for p in files.get(cid, ())) -for cid in (commons, junit): +for cid in (tool, harness): if not has_jar(cid): errors.append(f"{cid} missing materialized jar under --with-files: {files.get(cid)}") if errors: print("FAIL:") for e in errors: print(" -", e) sys.exit(1) -print(f"PASS: commons-io prod+jar; junit dev+jar ({len(nodes)} nodes)") +print(f"PASS: demo.ext:tool prod+jar; demo.ext:harness dev+jar ({len(nodes)} nodes)") PY diff --git a/test/maven-compat-smoke-assertions.test.mts b/test/maven-compat-smoke-assertions.test.mts new file mode 100644 index 0000000000..e09ceaa37e --- /dev/null +++ b/test/maven-compat-smoke-assertions.test.mts @@ -0,0 +1,135 @@ +/** + * Unit tests for the maven-compat fixture's record assertions. + * + * The fixture proves the Maven extension emits internal reactor modules under + * their bare groupId:artifactId:version id, distinct from the typed + * groupId:artifactId:type:version form used for external artifacts. The lookup + * that checks this has to reject the typed form, or a regression that stopped + * emitting the bare id would still pass. The fixture itself needs Maven and a + * JDK, so these run its assertions over synthetic records instead. + * + * Implementation: src/commands/manifest/scripts/test/maven-compat/assert-records.py + */ + +import { spawnSync } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +const ASSERT_RECORDS_PATH = path.join( + __dirname, + '../src/commands/manifest/scripts/test/maven-compat/assert-records.py', +) + +const PROD_ROOT = 'app' +const TEST_ROOT = 'testroot' + +/** A `root` row: the assertions read the id from field 2 and prod-ness from field 5. */ +function rootRow(rootId: string, options: { prod: boolean }): string { + return ['root', rootId, '', '', options.prod ? '1' : '0'].join('\t') +} + +/** A `node` row: root id in field 2, coordinate in field 3, directness in field 9. */ +function nodeRow( + rootId: string, + coordId: string, + options: { direct: boolean }, +): string { + return [ + 'node', + rootId, + coordId, + '', + '', + '', + '', + '', + options.direct ? '1' : '0', + ].join('\t') +} + +/** A `file` row: coordinate in field 3, materialized path in field 4. */ +function fileRow(rootId: string, coordId: string, filePath: string): string { + return ['file', rootId, coordId, filePath].join('\t') +} + +/** + * A records file the assertions accept, apart from the internal module's + * coordinate, which each test supplies. + */ +function buildRecords(internalModuleCoord: string): string { + return [ + ['meta', 'maven'].join('\t'), + rootRow(PROD_ROOT, { prod: true }), + rootRow(TEST_ROOT, { prod: false }), + // External prod dependency, materialized as a jar. + nodeRow(PROD_ROOT, 'demo.ext:tool:jar:1.0', { direct: true }), + fileRow(PROD_ROOT, 'demo.ext:tool:jar:1.0', '/repo/tool-1.0.jar'), + // Test-scope dependency and its transitive, kept out of the prod root. + nodeRow(TEST_ROOT, 'demo.ext:harness:jar:1.0', { direct: true }), + fileRow(TEST_ROOT, 'demo.ext:harness:jar:1.0', '/repo/harness-1.0.jar'), + nodeRow(TEST_ROOT, 'demo.ext:harness-core:jar:1.0', { direct: false }), + // The internal reactor module under test. + nodeRow(PROD_ROOT, internalModuleCoord, { direct: true }), + '', + ].join('\n') +} + +describe('maven-compat assert-records.py', () => { + let workDir: string + + beforeEach(() => { + workDir = mkdtempSync(path.join(os.tmpdir(), 'maven-compat-assert-')) + }) + + afterEach(() => { + rmSync(workDir, { force: true, recursive: true }) + }) + + /** Run the fixture's assertions over a records file naming `internalModuleCoord`. */ + function assertRecords(internalModuleCoord: string): { + status: number | null + output: string + } { + const recordsPath = path.join(workDir, 'records.tsv') + writeFileSync(recordsPath, buildRecords(internalModuleCoord)) + + const result = spawnSync('python3', [ASSERT_RECORDS_PATH, recordsPath], { + cwd: workDir, + encoding: 'utf8', + }) + return { output: `${result.stdout}${result.stderr}`, status: result.status } + } + + it('accepts an internal module emitted under its bare id', () => { + const { output, status } = assertRecords('demo:lib:1.0') + + expect(output).toContain('PASS') + expect(status).toBe(0) + }) + + it('rejects an internal module emitted only under a typed id', () => { + // 'demo:lib:' is a prefix of 'demo:lib:jar:1.0', so a lookup that matched + // on the prefix alone would report bare-id coverage it does not have. + const { output, status } = assertRecords('demo:lib:jar:1.0') + + expect(output).toContain( + 'internal module demo:lib not emitted by its bare id', + ) + expect(status).not.toBe(0) + }) + + it('rejects an internal module that is missing entirely', () => { + const { output, status } = assertRecords('demo:unrelated:1.0') + + expect(output).toContain( + 'internal module demo:lib not emitted by its bare id', + ) + expect(status).not.toBe(0) + }) +})