mirror of
https://github.com/Twigpine/node.git
synced 2026-11-09 18:08:02 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1dc42a73a | ||
|
|
9a8ddc96e9 | ||
|
|
ecee615adf |
No files matched your search
+2
-2
@@ -156,8 +156,8 @@ GITLAWB_ENFORCE_OWNER_PUSH=true
|
||||
GITLAWB_P2P_BOOTSTRAP=
|
||||
|
||||
# ── Access control ────────────────────────────────────────────────────────
|
||||
# Reserved and currently inert. Repo reads are gated per repository by
|
||||
# is_public and path-scoped visibility rules; this flag changes nothing.
|
||||
# Reserved for private-read mode. Public/private repo read enforcement is not
|
||||
# wired in the current live release; do not rely on this for private repositories.
|
||||
GITLAWB_PUBLIC_READ=true
|
||||
|
||||
# Maximum git smart-HTTP pack request size, in bytes.
|
||||
|
||||
@@ -68,22 +68,6 @@ jobs:
|
||||
- name: Test release tag resolver
|
||||
run: scripts/test-resolve-release-tag.sh
|
||||
|
||||
triage-detector:
|
||||
name: triage detector matrix
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# pr-triage.yml runs on pull_request_target and never checks out PR
|
||||
# code, so its inline detector can only be exercised here, where the
|
||||
# proposed workflow body IS the checked-out one.
|
||||
- name: Test inline-test detector
|
||||
run: node scripts/test-pr-triage-detect.mjs
|
||||
|
||||
test:
|
||||
name: test (${{ matrix.toolchain }})
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -76,221 +76,9 @@ jobs:
|
||||
// line after horizontal whitespace, covering bare, cfg(test), namespaced,
|
||||
// optional leading ::, and raw-identifier (r#test) forms. No head-file fetch
|
||||
// or lexer: needs-tests is advisory, not a merge gate.
|
||||
//
|
||||
// The final path component may also carry `test` as an underscore-delimited
|
||||
// segment, so third-party harnesses land without enumerating them one by one
|
||||
// (#[test_case(1)], #[wasm_bindgen_test]). It is deliberately a segment and
|
||||
// not a substring: a substring would also match #[contest] / #[latest], and a
|
||||
// false positive here SUPPRESSES needs-tests rather than adding it. This
|
||||
// heuristic has to fail loud, because a wrongly labeled PR gets corrected by
|
||||
// the author while a wrongly cleared one is invisible. That trade costs the
|
||||
// no-underscore harness names (#[rstest]), which stay unmatched on purpose.
|
||||
//
|
||||
// The segment classes are [A-Za-z0-9]+ rather than \w+ on purpose, so that `_`
|
||||
// is only ever the literal delimiter. \w+ contains `_`, which makes the repeat
|
||||
// ambiguous and backtracks exponentially: on this workflow's pull_request_target
|
||||
// trigger f.patch is fork-controlled, so `#[test` followed by a long `_a` run is
|
||||
// a permissionless stall of the triage job.
|
||||
// Rust allows whitespace, line breaks, and comments between the
|
||||
// attribute path and ] or (. A flat regex cannot decide that
|
||||
// boundary: `//` runs to end of line, and block comments NEST, which
|
||||
// is beyond regular languages — every enumeration of comment
|
||||
// spellings so far has either dropped a legal separator (regressing
|
||||
// real tests into needs-tests) or let unrelated patch records
|
||||
// complete each other (clearing needs-tests from a testless PR, the
|
||||
// invisible direction). So the closer is found by a token-aware
|
||||
// scan instead: it consumes horizontal whitespace, `//`-to-newline,
|
||||
// and depth-counted `/* */` runs, and accepts only when the first
|
||||
// real token after the attribute path is `]` or `(`.
|
||||
//
|
||||
// Association is positional: the scan starts on the attribute's own
|
||||
// added line and may continue ONLY through the immediately following
|
||||
// added lines (a context line, hunk header, or removal breaks the
|
||||
// chain). Unrelated `]`/`(` lines elsewhere in the patch can never
|
||||
// complete a path they do not adjoin. The continuation is bounded;
|
||||
// past the bound the scan gives up and reports NO inline test, which
|
||||
// fails toward the loud label rather than the silent clearance.
|
||||
//
|
||||
// The detector body is fenced by the TRIAGE_DETECTOR markers so
|
||||
// scripts/test-pr-triage-detect.mjs can extract and run it against
|
||||
// the committed case matrix. Keep the fenced block self-contained.
|
||||
// TRIAGE_DETECTOR_BEGIN
|
||||
const TEST_ATTR_PATH =
|
||||
"(?:cfg[ \\t]*\\([ \\t]*test[ \\t]*\\)|(?:::[ \\t]*)?(?:[\\w-]+[ \\t]*::[ \\t]*)*(?:r#)?(?:[A-Za-z0-9]+_)*test(?:_[A-Za-z0-9]+)*)";
|
||||
// Anchored to the start of one added line; group 1 is whatever
|
||||
// follows the path on that line, handed to the separator scan.
|
||||
const TEST_ATTR_LINE = new RegExp(
|
||||
"^[ \\t]*#\\[[ \\t]*" + TEST_ATTR_PATH + "([\\s\\S]*)$"
|
||||
);
|
||||
// An attribute split across more added lines than this is not a
|
||||
// spelling anyone writes; refusing to scan further keeps the walk
|
||||
// linear in the patch and fails toward the loud label.
|
||||
const MAX_ATTR_CONTINUATION_LINES = 16;
|
||||
// segs[0] is the remainder of the attribute's own line; each later
|
||||
// entry is the text of one immediately following added line. True
|
||||
// only when the first non-separator token across them is ] or (.
|
||||
// Single forward pass, no backtracking: fork-controlled input.
|
||||
function attrCloserFollows(segs) {
|
||||
let depth = 0; // open block-comment nesting carried across lines
|
||||
for (const seg of segs) {
|
||||
let i = 0;
|
||||
while (i < seg.length) {
|
||||
if (depth > 0) {
|
||||
if (seg.startsWith("/*", i)) { depth += 1; i += 2; }
|
||||
else if (seg.startsWith("*/", i)) { depth -= 1; i += 2; }
|
||||
else { i += 1; }
|
||||
continue;
|
||||
}
|
||||
const c = seg[i];
|
||||
if (c === " " || c === "\t") { i += 1; continue; }
|
||||
if (seg.startsWith("//", i)) break; // comment to end of line
|
||||
if (seg.startsWith("/*", i)) { depth = 1; i += 2; continue; }
|
||||
return c === "]" || c === "(";
|
||||
}
|
||||
// Line exhausted inside separators; the newline is itself a
|
||||
// separator, so continue on the next added line.
|
||||
}
|
||||
return false; // out of adjoined lines (or bound hit): stay loud
|
||||
}
|
||||
// Before the candidate scan, a pre-pass walks added patch lines
|
||||
// tracking Rust lexical state: raw strings (r#*", br#*", cr#*"),
|
||||
// block comments (/* */ with nesting), regular strings ("...",
|
||||
// which can span newlines), and line comments (//). An added line
|
||||
// that starts inside any of these is skipped as a candidate,
|
||||
// because a #[test_case] appearing there is lexical non-code, not
|
||||
// a real test attribute. State is only tracked across consecutive
|
||||
// added lines and reset at any break (context line, removal,
|
||||
// hunk header): context lines carry a partial view of the file,
|
||||
// so a " or r#" or /* there might be inside a string that opened
|
||||
// before the hunk's visible window, and treating it as an opener
|
||||
// would mark every later added line as non-code (a false negative
|
||||
// on a real #[test]). The tradeoff is that a non-code region
|
||||
// opened by a context line is invisible: an added #[test] inside
|
||||
// an existing raw string or block comment is scanned as code and
|
||||
// may match (a false positive, silent direction). This is accepted
|
||||
// because (1) the old detector had the same behavior, (2) the
|
||||
// pattern is rare, and (3) needs-tests is advisory, not a merge
|
||||
// gate. Scanning context lines to close this gap would re-introduce
|
||||
// the false negative, which affects the far more common case of a
|
||||
// context line carrying a stray " from a string that opened before
|
||||
// the visible window. Single forward pass, O(n) in patch length.
|
||||
function patchAddsInlineTest(patch) {
|
||||
const lines = patch.split("\n");
|
||||
const nonCodeStart = new Set();
|
||||
let rawHashes = -1; // -1 = not in raw string; >=0 = # count
|
||||
let blockDepth = 0;
|
||||
let inString = false;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (!line) continue;
|
||||
const prefix = line[0];
|
||||
// Only scan added lines. Context and removal lines carry
|
||||
// a partial view of the file: a " or r#" or /* on a context
|
||||
// line might be inside a string that opened before the
|
||||
// hunk's visible window, and treating it as an opener would
|
||||
// mark every later added line as non-code (a false negative
|
||||
// on a real #[test]). So lexical state is only tracked across
|
||||
// consecutive added lines and reset at any break. A non-code
|
||||
// region opened by a context line is invisible; the candidate
|
||||
// is still scanned, which fails toward the loud label.
|
||||
if (prefix !== "+") {
|
||||
rawHashes = -1; blockDepth = 0; inString = false;
|
||||
continue;
|
||||
}
|
||||
const text = line.slice(1);
|
||||
if (rawHashes >= 0 || blockDepth > 0 || inString)
|
||||
nonCodeStart.add(i);
|
||||
let j = 0;
|
||||
while (j < text.length) {
|
||||
if (rawHashes >= 0) {
|
||||
if (text[j] === '"') {
|
||||
let h = 1;
|
||||
while (h <= rawHashes && text[j + h] === "#") h++;
|
||||
if (h > rawHashes) { rawHashes = -1; j += h; continue; }
|
||||
}
|
||||
j += 1; continue;
|
||||
}
|
||||
if (blockDepth > 0) {
|
||||
if (text.startsWith("/*", j)) { blockDepth += 1; j += 2; }
|
||||
else if (text.startsWith("*/", j)) { blockDepth -= 1; j += 2; }
|
||||
else { j += 1; }
|
||||
continue;
|
||||
}
|
||||
if (inString) {
|
||||
if (text[j] === "\\") { j += 2; continue; }
|
||||
if (text[j] === '"') { inString = false; j += 1; continue; }
|
||||
j += 1; continue;
|
||||
}
|
||||
// Recognize Rust char and byte-char literals ('X', '\X',
|
||||
// '\u{...}', b'X') so their contents never open string or
|
||||
// comment state. A char literal has a closing ' within a
|
||||
// bounded distance; a lifetime or label ('a, 'static,
|
||||
// 'label:) does not, so it falls through as a regular char.
|
||||
// This is not a skip-to-next-' rule: the lookahead is 2-4
|
||||
// chars, or bounded to 20 for \u escapes (at most 6 hex
|
||||
// digits), never unbounded.
|
||||
if (text[j] === "'") {
|
||||
let k = j + 1;
|
||||
if (text[k] === "\\") {
|
||||
k += 1;
|
||||
if (text[k] === "u" && text[k + 1] === "{") {
|
||||
const uBound = k + 20;
|
||||
while (k < uBound && text[k] && text[k] !== "}") k++;
|
||||
if (text[k] === "}") k++;
|
||||
} else {
|
||||
k += 1;
|
||||
}
|
||||
} else {
|
||||
k += 1;
|
||||
}
|
||||
if (text[k] === "'") { j = k + 1; continue; }
|
||||
// lifetime or label: just skip the apostrophe
|
||||
}
|
||||
if (text[j] === '"') { inString = true; j += 1; continue; }
|
||||
if (text.startsWith("//", j)) break;
|
||||
if (text.startsWith("/*", j)) { blockDepth = 1; j += 2; continue; }
|
||||
const prev = j > 0 ? text[j - 1] : "";
|
||||
const isIdent = /[A-Za-z0-9_]/.test(prev);
|
||||
if (!isIdent && text[j] === "r") {
|
||||
let k = j + 1, h = 0;
|
||||
while (text[k + h] === "#") h++;
|
||||
if (text[k + h] === '"') {
|
||||
rawHashes = h; j = k + h + 1; continue;
|
||||
}
|
||||
}
|
||||
if (!isIdent && (text[j] === "b" || text[j] === "c") && text[j + 1] === "r") {
|
||||
let k = j + 2, h = 0;
|
||||
while (text[k + h] === "#") h++;
|
||||
if (text[k + h] === '"') {
|
||||
rawHashes = h; j = k + h + 1; continue;
|
||||
}
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i][0] !== "+") continue;
|
||||
if (nonCodeStart.has(i)) continue;
|
||||
const m = TEST_ATTR_LINE.exec(lines[i].slice(1));
|
||||
if (m === null) continue;
|
||||
const segs = [m[1]];
|
||||
for (
|
||||
let j = i + 1;
|
||||
j < lines.length &&
|
||||
lines[j][0] === "+" &&
|
||||
segs.length <= MAX_ATTR_CONTINUATION_LINES;
|
||||
j++
|
||||
) {
|
||||
segs.push(lines[j].slice(1));
|
||||
}
|
||||
if (attrCloserFollows(segs)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// TRIAGE_DETECTOR_END
|
||||
const addsInlineTest = files.some(
|
||||
f => f.filename.endsWith(".rs") && !!f.patch && patchAddsInlineTest(f.patch)
|
||||
);
|
||||
const addsInlineTest = files.some(f =>
|
||||
f.filename.endsWith(".rs") && f.patch &&
|
||||
/^\+[ \t]*#\[[ \t]*(?:cfg[ \t]*\([ \t]*test[ \t]*\)|(?:::[ \t]*)?(?:[\w-]+[ \t]*::[ \t]*)*(?:r#)?test\b)/m.test(f.patch));
|
||||
const touchedTests =
|
||||
names.some(n => n.includes("/tests/") || n.endsWith("_test.rs")) || addsInlineTest;
|
||||
if (changedRust && !touchedTests) want.add("needs-tests");
|
||||
|
||||
+34
-231
@@ -20,15 +20,7 @@ on:
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
# Not keyed on the ref: two runs racing `imagetools create` on the same
|
||||
# moving tags could interleave, so runs of this workflow serialize outright.
|
||||
# GitHub keeps at most one running and one pending run per group; a newer
|
||||
# queued run replaces the pending one, and cancel-in-progress: false
|
||||
# protects only the run already executing. So a backfill dispatched while a
|
||||
# release is running, followed by a push to main, is replaced silently: the
|
||||
# remedy is to re-dispatch it once the running release completes, which the
|
||||
# push-only gate on the moving-tag step makes safe in any ordering.
|
||||
group: release
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
# Least privilege by default; every job opts in to exactly what it needs.
|
||||
@@ -45,7 +37,6 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
environment: release
|
||||
outputs:
|
||||
release_created: ${{ steps.release.outputs.release_created }}
|
||||
tag_name: ${{ steps.release.outputs.tag_name }}
|
||||
@@ -87,7 +78,6 @@ jobs:
|
||||
# dispatch the release PR's head carries no check runs and the --locked
|
||||
# gate never validates it.
|
||||
actions: write
|
||||
environment: release
|
||||
steps:
|
||||
# Prefer the fresh `pr` output when this run just created/updated the
|
||||
# release PR; otherwise fall back to querying for an open release-please
|
||||
@@ -225,7 +215,6 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
environment: release
|
||||
steps:
|
||||
- name: Check out workflow scripts
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
@@ -237,7 +226,6 @@ jobs:
|
||||
env:
|
||||
DISPATCH_TAG: ${{ inputs.docker_backfill_tag }}
|
||||
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}"
|
||||
@@ -249,9 +237,7 @@ jobs:
|
||||
- name: Checkout release tag
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
# Pin the commit the resolver verified; a tag ref re-resolved here
|
||||
# could have been moved between resolve and checkout.
|
||||
ref: ${{ steps.rel.outputs.tag_commit || format('refs/tags/{0}', steps.rel.outputs.tag) }}
|
||||
ref: ${{ steps.rel.outputs.tag }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
@@ -326,7 +312,6 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
environment: release
|
||||
steps:
|
||||
- name: Check out workflow scripts
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
@@ -338,14 +323,9 @@ jobs:
|
||||
env:
|
||||
DISPATCH_TAG: ${{ inputs.docker_backfill_tag }}
|
||||
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}"
|
||||
# ghcr requires a lowercase repository path, and unlike metadata-action,
|
||||
# buildx's `--output name=` does no lowercasing, so a mixed-case owner
|
||||
# makes the digest push fail with "invalid reference format".
|
||||
echo "image=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
@@ -365,80 +345,24 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create and push multi-arch manifest
|
||||
id: manifest
|
||||
env:
|
||||
VERSION: ${{ steps.rel.outputs.version }}
|
||||
IMAGE: ${{ steps.rel.outputs.image }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The docker matrix pushes one digest per arch leg; anything else is
|
||||
# a broken set, not a smaller multi-arch image.
|
||||
count="$(find /tmp/digests -maxdepth 1 -type f | wc -l)"
|
||||
if [ "$count" -ne 2 ]; then
|
||||
echo "::error::expected 2 arch digests in /tmp/digests, found $count"
|
||||
exit 1
|
||||
fi
|
||||
# ghcr requires a lowercase repository path.
|
||||
IMAGE="ghcr.io/${GITHUB_REPOSITORY,,}"
|
||||
MAJOR_MINOR="${VERSION%.*}"
|
||||
digests=""
|
||||
for f in /tmp/digests/*; do
|
||||
d="$(basename "$f")"
|
||||
case "$d" in
|
||||
*[!0-9a-f]*)
|
||||
echo "::error::digest filename is not lowercase sha256 hex: $d"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
if [ "${#d}" -ne 64 ]; then
|
||||
echo "::error::digest is not 64 hex chars: $d"
|
||||
exit 1
|
||||
fi
|
||||
digests="$digests $IMAGE@sha256:$d"
|
||||
digests="$digests $IMAGE@sha256:$(basename "$f")"
|
||||
done
|
||||
# The immutable tag always publishes. The moving tags are applied by
|
||||
# the gated step below, which never runs on workflow_dispatch.
|
||||
# shellcheck disable=SC2086
|
||||
docker buildx imagetools create -t "$IMAGE:$VERSION" $digests
|
||||
docker buildx imagetools create \
|
||||
-t "$IMAGE:$VERSION" \
|
||||
-t "$IMAGE:$MAJOR_MINOR" \
|
||||
-t "$IMAGE:latest" \
|
||||
$digests
|
||||
docker buildx imagetools inspect "$IMAGE:$VERSION"
|
||||
echo "digests=${digests# }" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Move floating tags
|
||||
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
env:
|
||||
VERSION: ${{ steps.rel.outputs.version }}
|
||||
IMAGE: ${{ steps.rel.outputs.image }}
|
||||
DIGESTS: ${{ steps.manifest.outputs.digests }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
MAJOR_MINOR="${VERSION%.*}"
|
||||
# :latest and :X.Y track the newest release. Only a push to main
|
||||
# carries a release-please-cut version, and release-please versions
|
||||
# are monotonic, so on this path the tags only ever advance. A
|
||||
# workflow_dispatch backfill republishes an existing tag and must
|
||||
# never move a pointer, so this step skips on dispatch. After a
|
||||
# backfill that is genuinely newest in its minor line, advance :X.Y
|
||||
# by hand: docker buildx imagetools create -t "$IMAGE:X.Y" <digests>.
|
||||
# The event gate alone does not bound a stale run: re-running an
|
||||
# older push run replays its stored needs outputs under the
|
||||
# original event and ref, and would move the pointers backward to
|
||||
# that run's version. Floor against live state instead: this run's
|
||||
# release already exists by now, so VERSION must be the newest
|
||||
# published release or the run is stale and the tags stay put. The
|
||||
# releases list is ordered by creation, not semver, so read every
|
||||
# page and take the semver maximum, not the first page's.
|
||||
tags="$(gh api --paginate "repos/$GITHUB_REPOSITORY/releases?per_page=100" \
|
||||
-q '.[] | select(.draft == false) | .tag_name')" || {
|
||||
echo "::error::could not list releases to bound the moving tags"
|
||||
exit 1
|
||||
}
|
||||
floor="$(printf '%s\n' "$tags" \
|
||||
| { grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' || true; } \
|
||||
| sed 's/^v//' | sort -V | tail -n 1)"
|
||||
if [ -n "$floor" ] && [ "$floor" != "$VERSION" ]; then
|
||||
echo "::error::release v$floor is newer than this run's v$VERSION; refusing to move the moving tags backward (stale re-run?)"
|
||||
exit 1
|
||||
fi
|
||||
# shellcheck disable=SC2086
|
||||
docker buildx imagetools create -t "$IMAGE:latest" -t "$IMAGE:$MAJOR_MINOR" $DIGESTS
|
||||
|
||||
- name: Release summary
|
||||
env:
|
||||
@@ -462,7 +386,6 @@ jobs:
|
||||
continue-on-error: ${{ startsWith(matrix.target, 'x86_64-pc-windows') }}
|
||||
permissions:
|
||||
contents: write
|
||||
environment: release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -482,27 +405,10 @@ jobs:
|
||||
- target: x86_64-pc-windows-msvc
|
||||
os: windows-latest
|
||||
steps:
|
||||
- name: Check out workflow scripts
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Resolve release tag
|
||||
id: rel
|
||||
env:
|
||||
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash # matrix includes windows-latest, where run: defaults to pwsh
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/resolve-release-tag.sh "$RELEASE_TAG"
|
||||
|
||||
- name: Checkout release tag
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
# Pin the commit the resolver verified; a tag ref re-resolved here
|
||||
# could have been moved between resolve and checkout.
|
||||
ref: ${{ steps.rel.outputs.tag_commit || format('refs/tags/{0}', steps.rel.outputs.tag) }}
|
||||
ref: ${{ needs.release-please.outputs.tag_name }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Rust toolchain
|
||||
@@ -548,7 +454,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${{ steps.rel.outputs.version }}"
|
||||
VERSION="${{ needs.release-please.outputs.version }}"
|
||||
BIN_DIR="target/${{ matrix.target }}/release"
|
||||
EXE=""
|
||||
case "${{ matrix.target }}" in *windows*) EXE=".exe" ;; esac
|
||||
@@ -568,7 +474,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TARGET="${{ matrix.target }}"
|
||||
NAME="gitlawb-node-${{ steps.rel.outputs.version }}-${TARGET}"
|
||||
NAME="gitlawb-node-${{ needs.release-please.outputs.version }}-${TARGET}"
|
||||
BIN_DIR="target/${TARGET}/release"
|
||||
EXE=""
|
||||
case "$TARGET" in *windows*) EXE=".exe" ;; esac
|
||||
@@ -609,7 +515,7 @@ jobs:
|
||||
- name: Attach to release
|
||||
uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2
|
||||
with:
|
||||
tag_name: ${{ steps.rel.outputs.tag }}
|
||||
tag_name: ${{ needs.release-please.outputs.tag_name }}
|
||||
files: |
|
||||
dist/*.tar.gz
|
||||
dist/*.zip
|
||||
@@ -622,9 +528,7 @@ jobs:
|
||||
# (release-binaries is skipped on dispatch, hence !cancelled()). Publishing
|
||||
# auth is npm Trusted Publishing (GitHub OIDC) — no NPM_TOKEN. Each
|
||||
# @gitlawb package must have this repo + workflow (release.yml) configured
|
||||
# as its trusted publisher on npmjs.com, scoped to the release environment
|
||||
# so a dispatched copy of this file on another ref cannot mint a token, or
|
||||
# publish fails loudly here —
|
||||
# as its trusted publisher on npmjs.com, or publish fails loudly here —
|
||||
# deliberately loud: the silent secret-guard skip is how 0.4.x–0.6.0
|
||||
# never reached npm.
|
||||
if: >-
|
||||
@@ -636,7 +540,6 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # npm trusted publishing (OIDC) + provenance
|
||||
environment: release
|
||||
steps:
|
||||
- name: Check out workflow scripts
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
@@ -648,7 +551,6 @@ jobs:
|
||||
env:
|
||||
DISPATCH_TAG: ${{ inputs.npm_backfill_tag }}
|
||||
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}"
|
||||
@@ -656,9 +558,7 @@ jobs:
|
||||
- name: Checkout release tag
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
# Pin the commit the resolver verified; a tag ref re-resolved here
|
||||
# could have been moved between resolve and checkout.
|
||||
ref: ${{ steps.rel.outputs.tag_commit || format('refs/tags/{0}', steps.rel.outputs.tag) }}
|
||||
ref: ${{ steps.rel.outputs.tag }}
|
||||
persist-credentials: false
|
||||
|
||||
# npm >= 11.5.1 performs the OIDC token exchange automatically when the
|
||||
@@ -671,23 +571,14 @@ jobs:
|
||||
- name: Ensure npm supports trusted publishing
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm install -g npm@11.19.1
|
||||
# Assert the pin took, so an accidental unpin or a drift off 11.19.1
|
||||
# fails loudly here instead of silently running a different npm on
|
||||
# the OIDC path.
|
||||
installed="$(npm --version)"
|
||||
echo "npm installed: $installed"
|
||||
if [ "$installed" != "11.19.1" ]; then
|
||||
echo "::error::npm is not the pinned 11.19.1 (got: $installed)"
|
||||
exit 1
|
||||
fi
|
||||
npm install -g npm@^11.5.1
|
||||
npm --version
|
||||
|
||||
- name: Lay in release binaries
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ steps.rel.outputs.version }}
|
||||
TAG: ${{ steps.rel.outputs.tag }}
|
||||
ASSETS: ${{ steps.rel.outputs.assets }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# npm platform package -> Rust target triple (unix only; Windows is not
|
||||
@@ -704,18 +595,8 @@ jobs:
|
||||
target="${entry#*:}"
|
||||
archive="gitlawb-node-${VERSION}-${target}.tar.gz"
|
||||
echo "==> $pkg <- $archive"
|
||||
# Download by the asset id the resolver captured and uploader-
|
||||
# checked, not by name: release assets are mutable, and an id can
|
||||
# only ever point at the exact blob captured at resolve time.
|
||||
# The map keys are base64-encoded asset names.
|
||||
want="$(printf '%s' "$archive" | base64 -w0)"
|
||||
asset_id="$(printf '%s\n' "$ASSETS" | awk -v n="$want" '$1 == n {print $2; exit}')"
|
||||
if [ -z "$asset_id" ]; then
|
||||
echo "::error::release $TAG has no asset $archive captured at resolve time"
|
||||
exit 1
|
||||
fi
|
||||
gh api "repos/$GITHUB_REPOSITORY/releases/assets/$asset_id" \
|
||||
-H 'Accept: application/octet-stream' > "_dl/$archive"
|
||||
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \
|
||||
--pattern "$archive" --dir _dl --clobber
|
||||
tar -xzf "_dl/$archive" -C _dl
|
||||
src="_dl/gitlawb-node-${VERSION}-${target}"
|
||||
cp "$src/gl" "npm/packages/$pkg/gl"
|
||||
@@ -754,39 +635,14 @@ jobs:
|
||||
# Platform packages first, then the wrapper (so its optionalDependencies resolve).
|
||||
# Skip versions already on the registry so a rerun after a partial publish
|
||||
# is idempotent instead of erroring on the first existing package.
|
||||
# npm publish moves the latest dist-tag to whatever it publishes, so a
|
||||
# backfilled older version would hand :latest to stale code. Decide
|
||||
# the dist-tag per package from that package's own registry latest:
|
||||
# a partial publish can leave the platform packages ahead of the
|
||||
# wrapper. E404 means the package has never been published, so this
|
||||
# release gets latest; any other lookup failure must fail closed
|
||||
# rather than guess at the dist-tag and risk moving latest backward.
|
||||
# stderr goes to a file so a warning on a successful lookup cannot
|
||||
# leak into registry_latest; it is read only when the lookup fails.
|
||||
npm_err="$(mktemp)"
|
||||
trap 'rm -f -- "$npm_err"' EXIT
|
||||
for pkg in gl-darwin-arm64 gl-darwin-x64 gl-linux-arm64 gl-linux-x64 gl; do
|
||||
name="@gitlawb/$pkg"
|
||||
if npm view "$name@$VERSION" version >/dev/null 2>&1; then
|
||||
echo "==> $name@$VERSION already published, skipping"
|
||||
continue
|
||||
fi
|
||||
registry_latest="$(npm view "$name" dist-tags.latest 2>"$npm_err")" || {
|
||||
if ! grep -q E404 "$npm_err"; then
|
||||
cat "$npm_err" >&2
|
||||
echo "::error::npm dist-tags lookup failed for $name; not guessing the dist-tag"
|
||||
exit 1
|
||||
fi
|
||||
registry_latest=""
|
||||
}
|
||||
dist_tag="latest"
|
||||
if [ -n "$registry_latest" ] && \
|
||||
[ "$VERSION" != "$(printf '%s\n%s\n' "$registry_latest" "$VERSION" | sort -V | tail -1)" ]; then
|
||||
dist_tag="backfill"
|
||||
echo "::notice::$VERSION is older than $name@$registry_latest; publishing under dist-tag backfill"
|
||||
fi
|
||||
echo "==> npm publish $name@$VERSION (dist-tag $dist_tag)"
|
||||
npm publish "npm/packages/$pkg" --provenance --access public --tag "$dist_tag"
|
||||
echo "==> npm publish $name@$VERSION"
|
||||
npm publish "npm/packages/$pkg" --provenance --access public
|
||||
done
|
||||
|
||||
homebrew-bump:
|
||||
@@ -796,7 +652,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # GITHUB_TOKEN downloads release assets; the tap push uses HOMEBREW_TAP_PAT
|
||||
environment: release
|
||||
steps:
|
||||
- name: Guard on secret
|
||||
id: guard
|
||||
@@ -810,22 +665,6 @@ jobs:
|
||||
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Check out workflow scripts
|
||||
if: ${{ steps.guard.outputs.enabled == 'true' }}
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Resolve release tag
|
||||
if: ${{ steps.guard.outputs.enabled == 'true' }}
|
||||
id: rel
|
||||
env:
|
||||
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/resolve-release-tag.sh "$RELEASE_TAG"
|
||||
|
||||
- name: Checkout tap repo
|
||||
if: ${{ steps.guard.outputs.enabled == 'true' }}
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
@@ -839,32 +678,15 @@ jobs:
|
||||
if: ${{ steps.guard.outputs.enabled == 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ steps.rel.outputs.version }}
|
||||
TAG: ${{ steps.rel.outputs.tag }}
|
||||
ASSETS: ${{ steps.rel.outputs.assets }}
|
||||
VERSION: ${{ needs.release-please.outputs.version }}
|
||||
TAG: ${{ needs.release-please.outputs.tag_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
base="https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}"
|
||||
mkdir -p _sums
|
||||
# Hash the tarballs themselves, fetched by the asset id the resolver
|
||||
# captured and uploader-checked. The *.sha256 sidecars are mutable
|
||||
# release assets too; trusting them would trust the same surface.
|
||||
sha() {
|
||||
archive="gitlawb-node-${VERSION}-$1.tar.gz"
|
||||
want="$(printf '%s' "$archive" | base64 -w0)"
|
||||
asset_id="$(printf '%s\n' "$ASSETS" | awk -v n="$want" '$1 == n {print $2; exit}')"
|
||||
if [ -z "$asset_id" ]; then
|
||||
echo "::error::release $TAG has no asset $archive captured at resolve time" >&2
|
||||
exit 1
|
||||
fi
|
||||
# errexit is off inside the $(...) this runs under, so a failed
|
||||
# download must exit the function itself instead of hashing a
|
||||
# truncated file.
|
||||
gh api "repos/$GITHUB_REPOSITORY/releases/assets/$asset_id" \
|
||||
-H 'Accept: application/octet-stream' > "_sums/$archive" \
|
||||
|| exit 1
|
||||
sha256sum "_sums/$archive" | awk '{print $1}'
|
||||
}
|
||||
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \
|
||||
--pattern '*.tar.gz.sha256' --dir _sums --clobber
|
||||
sha() { awk '{print $1}' "_sums/gitlawb-node-${VERSION}-$1.tar.gz.sha256"; }
|
||||
SHA_MAC_ARM="$(sha aarch64-apple-darwin)"
|
||||
SHA_MAC_X64="$(sha x86_64-apple-darwin)"
|
||||
SHA_LNX_ARM="$(sha aarch64-unknown-linux-musl)"
|
||||
@@ -925,7 +747,7 @@ jobs:
|
||||
working-directory: tap
|
||||
env:
|
||||
HOMEBREW_TAP_PAT: ${{ secrets.HOMEBREW_TAP_PAT }}
|
||||
VERSION: ${{ steps.rel.outputs.version }}
|
||||
VERSION: ${{ needs.release-please.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
@@ -947,7 +769,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # GITHUB_TOKEN checks out node; the web PR uses WEB_SYNC_PAT
|
||||
environment: release
|
||||
steps:
|
||||
- name: Guard on secret
|
||||
id: guard
|
||||
@@ -961,29 +782,11 @@ jobs:
|
||||
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Check out workflow scripts
|
||||
if: ${{ steps.guard.outputs.enabled == 'true' }}
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Resolve release tag
|
||||
if: ${{ steps.guard.outputs.enabled == 'true' }}
|
||||
id: rel
|
||||
env:
|
||||
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/resolve-release-tag.sh "$RELEASE_TAG"
|
||||
|
||||
- name: Checkout node (release tag)
|
||||
if: ${{ steps.guard.outputs.enabled == 'true' }}
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
# Pin the commit the resolver verified; a tag ref re-resolved here
|
||||
# could have been moved between resolve and checkout.
|
||||
ref: ${{ steps.rel.outputs.tag_commit || format('refs/tags/{0}', steps.rel.outputs.tag) }}
|
||||
ref: ${{ needs.release-please.outputs.tag_name }}
|
||||
path: node
|
||||
persist-credentials: false
|
||||
|
||||
@@ -1003,8 +806,8 @@ jobs:
|
||||
if: ${{ steps.guard.outputs.enabled == 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ steps.rel.outputs.version }}
|
||||
TAG: ${{ steps.rel.outputs.tag }}
|
||||
VERSION: ${{ needs.release-please.outputs.version }}
|
||||
TAG: ${{ needs.release-please.outputs.tag_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets \
|
||||
@@ -1022,8 +825,8 @@ jobs:
|
||||
if: ${{ steps.guard.outputs.enabled == 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.WEB_SYNC_PAT }}
|
||||
VERSION: ${{ steps.rel.outputs.version }}
|
||||
TAG: ${{ steps.rel.outputs.tag }}
|
||||
VERSION: ${{ needs.release-please.outputs.version }}
|
||||
TAG: ${{ needs.release-please.outputs.tag_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Canonical install scripts (source of truth lives in the node repo).
|
||||
|
||||
@@ -287,10 +287,8 @@ fn handle_connect<R: Read>(
|
||||
// this into one POST deadlocks a multi-round fetch (#117).
|
||||
//
|
||||
// git-receive-pack (push):
|
||||
// Git sends the ref-update command list (pkt-lines ending in a flush),
|
||||
// then the complete PACK blob only when a command has a non-zero new-oid.
|
||||
// A delete-only push sends no pack, and git holds its write pipe open for
|
||||
// the report-status response, so EOF cannot delimit the request (#369).
|
||||
// Git sends ref-update commands + the complete PACK blob, then closes its
|
||||
// write pipe. read_to_end is safe and correct here, a single POST.
|
||||
|
||||
let post_url = format!("{}/{}", repo_base, service);
|
||||
|
||||
@@ -298,7 +296,10 @@ fn handle_connect<R: Read>(
|
||||
return negotiate_upload_pack(&client, &post_url, service, signing_key, stdin, &mut stdout);
|
||||
}
|
||||
|
||||
let request_body = read_receive_pack_request(stdin)?;
|
||||
let mut request_body = Vec::new();
|
||||
stdin
|
||||
.read_to_end(&mut request_body)
|
||||
.context("reading receive-pack request")?;
|
||||
tracing::debug!("pack request: {} bytes from git", request_body.len());
|
||||
|
||||
if request_body.is_empty() {
|
||||
@@ -534,108 +535,6 @@ fn read_upload_pack_round<R: Read>(stdin: &mut R) -> Result<(Vec<u8>, RoundEnd)>
|
||||
}
|
||||
}
|
||||
|
||||
/// Read one git-receive-pack request: optional `shallow` lines, then EITHER a
|
||||
/// command list (pkt-lines terminated by a flush) or a push-cert block
|
||||
/// (pkt-lines terminated by a "push-cert-end" line), then the negotiated
|
||||
/// push-options block, then the pack ONLY when a command carries a non-zero
|
||||
/// new-oid.
|
||||
///
|
||||
/// A delete-only push sends no pack, and git holds its write pipe open waiting
|
||||
/// for the report-status response, so a plain read_to_end deadlocks the two
|
||||
/// (#369). A signed push is the other trap: when the client sends a push-cert
|
||||
/// it sends NO command list at all (the commands live inside the cert), so the
|
||||
/// request starts with a "push-cert" line and no flush ever arrives. The
|
||||
/// negotiated capability list is on the push-cert line, or on the first
|
||||
/// command line after its NUL when there is no cert. EOF at any point also
|
||||
/// ends the read, covering git versions that close the pipe early.
|
||||
fn read_receive_pack_request<R: Read>(stdin: &mut R) -> Result<Vec<u8>> {
|
||||
/// Read one pkt-line into `body` (length prefix + data), returning its data
|
||||
/// or `None` on flush or EOF. Malformed lengths bail rather than collapse
|
||||
/// into a synthesized flush (#192 F5).
|
||||
fn read_pkt<R: Read>(stdin: &mut R, body: &mut Vec<u8>) -> Result<Option<Vec<u8>>> {
|
||||
let mut len_bytes = [0u8; 4];
|
||||
match stdin.read_exact(&mut len_bytes) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None),
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
let Ok(len_hex) = std::str::from_utf8(&len_bytes) else {
|
||||
bail!("malformed pkt-line length prefix: non-UTF-8 bytes {len_bytes:02x?}");
|
||||
};
|
||||
let Ok(pkt_len) = usize::from_str_radix(len_hex, 16) else {
|
||||
bail!("malformed pkt-line length prefix: non-hex {len_hex:?}");
|
||||
};
|
||||
body.extend_from_slice(&len_bytes);
|
||||
if pkt_len == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
if pkt_len < 4 {
|
||||
bail!("invalid pkt-line length: {pkt_len}");
|
||||
}
|
||||
let mut data = vec![0u8; pkt_len - 4];
|
||||
stdin
|
||||
.read_exact(&mut data)
|
||||
.context("reading pkt-line data")?;
|
||||
body.extend_from_slice(&data);
|
||||
Ok(Some(data))
|
||||
}
|
||||
|
||||
/// "<40-hex old> <40-hex new> <refname>" — the command shape, whether it
|
||||
/// appears in a command list or inside a push-cert.
|
||||
fn is_command(data: &[u8]) -> bool {
|
||||
data.len() >= 82
|
||||
&& data[..40].iter().all(|b| b.is_ascii_hexdigit())
|
||||
&& data[40] == b' '
|
||||
&& data[41..81].iter().all(|b| b.is_ascii_hexdigit())
|
||||
&& data[81] == b' '
|
||||
}
|
||||
|
||||
let mut body = Vec::new();
|
||||
let mut caps = String::new();
|
||||
let mut pack_expected = false;
|
||||
let mut in_cert = false;
|
||||
loop {
|
||||
let Some(data) = read_pkt(stdin, &mut body)? else {
|
||||
// Flush ends a command list; EOF ends anything. A cert has no
|
||||
// legal flush inside it, so the same break covers malformed
|
||||
// input without hanging.
|
||||
break;
|
||||
};
|
||||
if !in_cert && data.starts_with(b"push-cert") {
|
||||
in_cert = true;
|
||||
if let Some(nul) = data.iter().position(|&b| b == 0) {
|
||||
caps = String::from_utf8_lossy(&data[nul + 1..]).into_owned();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if in_cert && data == b"push-cert-end\n" {
|
||||
break;
|
||||
}
|
||||
if is_command(&data) {
|
||||
if caps.is_empty() {
|
||||
if let Some(nul) = data.iter().position(|&b| b == 0) {
|
||||
caps = String::from_utf8_lossy(&data[nul + 1..]).into_owned();
|
||||
}
|
||||
}
|
||||
if data[41..81].iter().any(|&b| b != b'0') {
|
||||
pack_expected = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// When both are negotiated the push-options block follows the cert; when
|
||||
// only options are negotiated it follows the command flush. Either way it
|
||||
// is pkt-lines to a flush.
|
||||
if caps.split(' ').any(|c| c.starts_with("push-options")) {
|
||||
while read_pkt(stdin, &mut body)?.is_some() {}
|
||||
}
|
||||
if pack_expected {
|
||||
stdin
|
||||
.read_to_end(&mut body)
|
||||
.context("reading receive-pack pack")?;
|
||||
}
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// POST one self-contained stateless-RPC request body and stream the response to
|
||||
/// `stdout`. Signs the request when `signing_key` is present, so every round of a
|
||||
/// private-repo fetch carries the caller's signature (the node visibility-gates
|
||||
@@ -2118,184 +2017,6 @@ mod tests {
|
||||
assert!(help.ends_with('\n'));
|
||||
}
|
||||
|
||||
// ── #369 delete-only push deadlock ───────────────────────────────────────
|
||||
|
||||
/// A delete-only receive-pack request ends at the command flush; git then
|
||||
/// holds the write pipe open for the report and sends no pack. A reader that
|
||||
/// blocks after the command list reproduces the hang that a Cursor (EOF)
|
||||
/// hides. The read must return at the flush, inside the timeout.
|
||||
#[test]
|
||||
fn delete_only_receive_pack_request_returns_at_command_flush() {
|
||||
let old = "a".repeat(40);
|
||||
let zero = "0".repeat(40);
|
||||
let mut seed = Vec::new();
|
||||
seed.extend_from_slice(&pkt(&format!("{old} {zero} refs/heads/gone\n")));
|
||||
seed.extend_from_slice(b"0000"); // command-list flush; git then blocks
|
||||
|
||||
let (tx, rx) = std::sync::mpsc::channel::<()>();
|
||||
let mut reader = BlockAfterSeed {
|
||||
seed: io::Cursor::new(seed),
|
||||
gate: rx,
|
||||
};
|
||||
|
||||
let (done_tx, done_rx) = std::sync::mpsc::channel::<Vec<u8>>();
|
||||
let handle = std::thread::spawn(move || {
|
||||
let _ = done_tx.send(read_receive_pack_request(&mut reader).unwrap());
|
||||
});
|
||||
|
||||
let got = done_rx.recv_timeout(std::time::Duration::from_secs(2));
|
||||
let _ = tx.send(()); // unblock the reader so the thread can exit
|
||||
let _ = handle.join();
|
||||
|
||||
let body = got.expect(
|
||||
"read_receive_pack_request blocked past the command flush waiting for \
|
||||
a pack a delete-only push never sends (#369)",
|
||||
);
|
||||
assert!(
|
||||
body.ends_with(b"0000"),
|
||||
"the request body keeps the command list and its flush terminator"
|
||||
);
|
||||
}
|
||||
|
||||
/// A push carrying objects still reads the pack through to EOF, and the
|
||||
/// command flush sits between the commands and the pack in the body.
|
||||
#[test]
|
||||
fn receive_pack_request_with_pack_reads_through_eof() {
|
||||
let old = "a".repeat(40);
|
||||
let new = "b".repeat(40);
|
||||
let mut stream = Vec::new();
|
||||
stream.extend_from_slice(&pkt(&format!(
|
||||
"{old} {new} refs/heads/main\0 report-status-v2\n"
|
||||
)));
|
||||
stream.extend_from_slice(b"0000");
|
||||
stream.extend_from_slice(b"PACK\x00\x00\x00\x02fakepack");
|
||||
|
||||
let mut stdin = io::Cursor::new(stream);
|
||||
let body = read_receive_pack_request(&mut stdin).unwrap();
|
||||
let pos = body.windows(4).position(|w| w == b"0000").unwrap();
|
||||
assert_eq!(
|
||||
&body[pos + 4..],
|
||||
b"PACK\x00\x00\x00\x02fakepack",
|
||||
"a non-zero new-oid means the pack follows the flush"
|
||||
);
|
||||
}
|
||||
|
||||
/// A signed delete-only push sends NO command list: the request starts
|
||||
/// with the "push-cert" line, the commands live inside the cert, and the
|
||||
/// cert ends with a "push-cert-end" pkt-line (not a flush). The reader
|
||||
/// must return after push-cert-end without waiting on the pipe.
|
||||
#[test]
|
||||
fn signed_delete_only_push_returns_after_push_cert_end() {
|
||||
let old = "a".repeat(40);
|
||||
let zero = "0".repeat(40);
|
||||
let mut seed = Vec::new();
|
||||
seed.extend_from_slice(&pkt("push-cert\0 report-status-v2 push-cert\n"));
|
||||
seed.extend_from_slice(&pkt("certificate version 0.1\n"));
|
||||
seed.extend_from_slice(&pkt("pusher did:key:z6Mk\n"));
|
||||
seed.extend_from_slice(&pkt("\n")); // empty line ends the headers
|
||||
seed.extend_from_slice(&pkt(&format!("{old} {zero} refs/heads/gone\n")));
|
||||
seed.extend_from_slice(&pkt("sig-line\n"));
|
||||
seed.extend_from_slice(&pkt("push-cert-end\n"));
|
||||
// git now blocks for the report; the pipe stays open.
|
||||
|
||||
let (tx, rx) = std::sync::mpsc::channel::<()>();
|
||||
let mut reader = BlockAfterSeed {
|
||||
seed: io::Cursor::new(seed),
|
||||
gate: rx,
|
||||
};
|
||||
|
||||
let (done_tx, done_rx) = std::sync::mpsc::channel::<Vec<u8>>();
|
||||
let handle = std::thread::spawn(move || {
|
||||
let _ = done_tx.send(read_receive_pack_request(&mut reader).unwrap());
|
||||
});
|
||||
let got = done_rx.recv_timeout(std::time::Duration::from_secs(2));
|
||||
let _ = tx.send(());
|
||||
let _ = handle.join();
|
||||
|
||||
let body = got.expect(
|
||||
"read_receive_pack_request blocked past push-cert-end on a signed \
|
||||
delete-only push (#369)",
|
||||
);
|
||||
assert!(
|
||||
body.windows(6).any(|w| w == b"pusher"),
|
||||
"the cert block is part of the request body"
|
||||
);
|
||||
}
|
||||
|
||||
/// A signed pack-bearing push: cert with the commands inside, then the
|
||||
/// pack. Everything must land in the body.
|
||||
#[test]
|
||||
fn signed_pack_push_keeps_cert_and_pack() {
|
||||
let old = "a".repeat(40);
|
||||
let new = "b".repeat(40);
|
||||
let mut stream = Vec::new();
|
||||
stream.extend_from_slice(&pkt("push-cert\0 report-status-v2 push-cert\n"));
|
||||
stream.extend_from_slice(&pkt("certificate version 0.1\n"));
|
||||
stream.extend_from_slice(&pkt("\n"));
|
||||
stream.extend_from_slice(&pkt(&format!("{old} {new} refs/heads/main\n")));
|
||||
stream.extend_from_slice(&pkt("push-cert-end\n"));
|
||||
stream.extend_from_slice(b"PACK\x00\x00\x00\x02fakepack");
|
||||
|
||||
let mut stdin = io::Cursor::new(stream);
|
||||
let body = read_receive_pack_request(&mut stdin).unwrap();
|
||||
assert!(
|
||||
body.windows(5).any(|w| w == b"PACK\x00"),
|
||||
"the pack follows the cert into the body"
|
||||
);
|
||||
}
|
||||
|
||||
/// A negotiated push-options block is pkt-lines ending in its own flush.
|
||||
/// With no cert it follows the command flush; with a cert it follows
|
||||
/// push-cert-end.
|
||||
#[test]
|
||||
fn receive_pack_request_consumes_a_push_options_block() {
|
||||
let old = "a".repeat(40);
|
||||
let new = "b".repeat(40);
|
||||
let mut stream = Vec::new();
|
||||
stream.extend_from_slice(&pkt(&format!(
|
||||
"{old} {new} refs/heads/main\0 report-status-v2 push-options\n"
|
||||
)));
|
||||
stream.extend_from_slice(b"0000"); // command flush
|
||||
stream.extend_from_slice(&pkt("ci.skip\n"));
|
||||
stream.extend_from_slice(b"0000"); // options flush
|
||||
stream.extend_from_slice(b"PACK\x00\x00\x00\x02fakepack");
|
||||
|
||||
let mut stdin = io::Cursor::new(stream);
|
||||
let body = read_receive_pack_request(&mut stdin).unwrap();
|
||||
assert!(body.windows(7).any(|w| w == b"ci.skip"), "options survived");
|
||||
assert!(
|
||||
body.ends_with(b"PACK\x00\x00\x00\x02fakepack"),
|
||||
"the pack lands after the options block"
|
||||
);
|
||||
}
|
||||
|
||||
/// Cert and options together: the cert comes first and the options block
|
||||
/// sits between push-cert-end and the pack.
|
||||
#[test]
|
||||
fn signed_push_with_options_reads_cert_then_options_then_pack() {
|
||||
let old = "a".repeat(40);
|
||||
let new = "b".repeat(40);
|
||||
let mut stream = Vec::new();
|
||||
stream.extend_from_slice(&pkt(
|
||||
"push-cert\0 report-status-v2 push-cert push-options\n",
|
||||
));
|
||||
stream.extend_from_slice(&pkt("certificate version 0.1\n"));
|
||||
stream.extend_from_slice(&pkt("\n"));
|
||||
stream.extend_from_slice(&pkt(&format!("{old} {new} refs/heads/main\n")));
|
||||
stream.extend_from_slice(&pkt("push-cert-end\n"));
|
||||
stream.extend_from_slice(&pkt("ci.skip\n"));
|
||||
stream.extend_from_slice(b"0000"); // options flush
|
||||
stream.extend_from_slice(b"PACK\x00\x00\x00\x02fakepack");
|
||||
|
||||
let mut stdin = io::Cursor::new(stream);
|
||||
let body = read_receive_pack_request(&mut stdin).unwrap();
|
||||
assert!(body.windows(7).any(|w| w == b"ci.skip"), "options survived");
|
||||
assert!(
|
||||
body.ends_with(b"PACK\x00\x00\x00\x02fakepack"),
|
||||
"the pack lands after the options block"
|
||||
);
|
||||
}
|
||||
|
||||
// ── #117 multi-round fetch negotiation ───────────────────────────────────
|
||||
|
||||
/// Encode a git pkt-line: 4-byte hex length (incl. the 4 bytes) + data.
|
||||
|
||||
@@ -95,36 +95,6 @@ fn upload_pack(repo: &Path, advertise: bool, input: &[u8]) -> Vec<u8> {
|
||||
out.stdout
|
||||
}
|
||||
|
||||
/// Run `git receive-pack --stateless-rpc [--advertise-refs] <repo>`, optionally
|
||||
/// feeding `input` on stdin. Returns raw stdout (the wire bytes). The receive
|
||||
/// half of `upload_pack`, used so the shim can serve a real push.
|
||||
fn receive_pack(repo: &Path, advertise: bool, input: &[u8]) -> Vec<u8> {
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.arg("receive-pack").arg("--stateless-rpc");
|
||||
if advertise {
|
||||
cmd.arg("--advertise-refs");
|
||||
}
|
||||
cmd.arg(repo)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
let mut child = cmd.spawn().expect("spawn git receive-pack");
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
let input = input.to_vec();
|
||||
let writer = std::thread::spawn(move || {
|
||||
let _ = stdin.write_all(&input);
|
||||
// drop closes stdin
|
||||
});
|
||||
let out = child.wait_with_output().expect("wait receive-pack");
|
||||
writer.join().ok();
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"git receive-pack failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
out.stdout
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum ShimMode {
|
||||
/// Faithful v0 serving: pipe each POST to `git upload-pack --stateless-rpc`.
|
||||
@@ -248,21 +218,7 @@ fn handle_conn(stream: TcpStream, repo: &Path, mode: ShimMode, posts: &AtomicUsi
|
||||
reader.read_exact(&mut body).ok();
|
||||
}
|
||||
|
||||
let (content_type, payload) = if method == "GET" && target.contains("service=git-receive-pack")
|
||||
{
|
||||
// Receive-pack advertisement, same wrapper shape as upload-pack.
|
||||
let adv = receive_pack(repo, true, b"");
|
||||
let mut wrapped = pkt(b"# service=git-receive-pack\n");
|
||||
wrapped.extend_from_slice(b"0000");
|
||||
wrapped.extend_from_slice(&adv);
|
||||
("application/x-git-receive-pack-advertisement", wrapped)
|
||||
} else if method == "POST" && target.ends_with("/git-receive-pack") {
|
||||
posts.fetch_add(1, Ordering::SeqCst);
|
||||
(
|
||||
"application/x-git-receive-pack-result",
|
||||
receive_pack(repo, false, &body),
|
||||
)
|
||||
} else if method == "GET" && target.contains("/info/refs") {
|
||||
let (content_type, payload) = if method == "GET" && target.contains("/info/refs") {
|
||||
// v0 advertisement, wrapped exactly as the node's info_refs does.
|
||||
let adv = upload_pack(repo, true, b"");
|
||||
let mut wrapped = pkt(b"# service=git-upload-pack\n");
|
||||
@@ -337,34 +293,6 @@ fn fetch_with_helper(clone: &Path, node_url: &str) -> (bool, std::process::Outpu
|
||||
run_bounded(cmd, Duration::from_secs(30))
|
||||
}
|
||||
|
||||
/// Run `git push` in `local` through the helper, same wiring and hard timeout as
|
||||
/// [`fetch_with_helper`]. `args` carries the push spec, e.g.
|
||||
/// `["--delete", "doomed"]`.
|
||||
fn push_with_helper(local: &Path, node_url: &str, args: &[&str]) -> (bool, std::process::Output) {
|
||||
let helper_bin = PathBuf::from(env!("CARGO_BIN_EXE_git-remote-gitlawb"));
|
||||
let helper_dir = helper_bin.parent().unwrap().to_path_buf();
|
||||
let path_env = match std::env::var_os("PATH") {
|
||||
Some(p) => {
|
||||
let mut dirs = vec![helper_dir.clone()];
|
||||
dirs.extend(std::env::split_paths(&p));
|
||||
std::env::join_paths(dirs).unwrap()
|
||||
}
|
||||
None => helper_dir.clone().into_os_string(),
|
||||
};
|
||||
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.arg("-C")
|
||||
.arg(local)
|
||||
.arg("push")
|
||||
.arg("origin")
|
||||
.args(args)
|
||||
.env("PATH", path_env)
|
||||
.env("LC_ALL", "C")
|
||||
.env("GITLAWB_NODE", node_url)
|
||||
.env("GITLAWB_KEY", "/nonexistent-key-for-anon-push");
|
||||
run_bounded(cmd, Duration::from_secs(30))
|
||||
}
|
||||
|
||||
/// A synthetic non-success `ExitStatus` used only on the unix timeout path, where
|
||||
/// `reap_group` has already consumed the child and the later `wait` returns ECHILD.
|
||||
/// The timeout branch already reports `completed == false`, so the exact status is
|
||||
@@ -1314,77 +1242,3 @@ fn run_bounded_bounds_join_when_leader_exits_leaving_a_pipe_holder() {
|
||||
held pipes do not stall the joins)"
|
||||
);
|
||||
}
|
||||
|
||||
/// #369 end-to-end: a real `git push --delete` through the helper completes.
|
||||
/// The receive-pack request for a delete-only push is commands + flush with NO
|
||||
/// pack, and git holds its write pipe open for the report — so a helper that
|
||||
/// reads stdin to EOF deadlocks against it. The shim pipes the POST to a real
|
||||
/// `git receive-pack --stateless-rpc`, so the branch actually has to be deleted
|
||||
/// server-side for the assertions to pass.
|
||||
#[test]
|
||||
fn real_git_push_delete_completes() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let server = dir.path().join("server.git");
|
||||
let local = dir.path().join("local");
|
||||
|
||||
// A bare repo holding the doomed branch, plus a live one.
|
||||
git(
|
||||
dir.path(),
|
||||
&[
|
||||
"init",
|
||||
"--bare",
|
||||
server.to_str().unwrap(),
|
||||
"--initial-branch=main",
|
||||
],
|
||||
);
|
||||
let work = dir.path().join("work");
|
||||
git(
|
||||
dir.path(),
|
||||
&["init", work.to_str().unwrap(), "--initial-branch=main"],
|
||||
);
|
||||
git(&work, &["config", "user.email", "t@t"]);
|
||||
git(&work, &["config", "user.name", "t"]);
|
||||
std::fs::write(work.join("f"), "x").unwrap();
|
||||
git(&work, &["add", "f"]);
|
||||
git(&work, &["commit", "-m", "c"]);
|
||||
git(&work, &["branch", "doomed"]);
|
||||
git(&work, &["push", server.to_str().unwrap(), "main", "doomed"]);
|
||||
|
||||
// The pusher: a local repo whose origin is the gitlawb remote pointing at
|
||||
// the shim. Nothing needs to be fetched first; git takes the old-oid from
|
||||
// the receive-pack advertisement.
|
||||
git(
|
||||
dir.path(),
|
||||
&["init", local.to_str().unwrap(), "--initial-branch=main"],
|
||||
);
|
||||
git(
|
||||
&local,
|
||||
&["remote", "add", "origin", "gitlawb://did:key:z6MkTest/repo"],
|
||||
);
|
||||
|
||||
let shim = start_shim(server.clone(), ShimMode::Normal);
|
||||
let (completed, out) = push_with_helper(&local, &shim.base_url, &["--delete", "doomed"]);
|
||||
let posts = shim.posts.load(Ordering::SeqCst);
|
||||
|
||||
assert!(
|
||||
completed,
|
||||
"git push --delete did not complete within the timeout (deadlock signature). stderr:\n{}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"git push --delete failed. stderr:\n{}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
assert_eq!(posts, 1, "a delete-only push is exactly one POST");
|
||||
let gone = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&server)
|
||||
.args(["rev-parse", "--verify", "--quiet", "refs/heads/doomed"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
!gone.status.success(),
|
||||
"refs/heads/doomed must be deleted in the server repo"
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -39,11 +39,11 @@ pub async fn register_replica(
|
||||
) -> Result<(StatusCode, Json<serde_json::Value>)> {
|
||||
validate_replica_url(&req.url)?;
|
||||
|
||||
// The mutation response carries repo metadata (name, replica count), so
|
||||
// registration applies the same read-visibility decision as the listing:
|
||||
// a caller who may not read the repo gets the same 404 as a missing one.
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &repo, Some(auth.0.as_str()), "/").await?;
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
|
||||
let replica_did = &auth.0;
|
||||
|
||||
@@ -93,10 +93,11 @@ pub async fn unregister_replica(
|
||||
Extension(auth): Extension<AuthenticatedDid>,
|
||||
Path((owner, repo)): Path<(String, String)>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
// Same gate as register_replica: removal mutates repo-scoped metadata and
|
||||
// its response discloses the replica count.
|
||||
let (record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &repo, Some(auth.0.as_str()), "/").await?;
|
||||
let record = state
|
||||
.db
|
||||
.get_repo(&owner, &repo)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
|
||||
|
||||
let replica_did = &auth.0;
|
||||
state.db.unregister_replica(&record.id, replica_did).await?;
|
||||
|
||||
@@ -3186,21 +3186,10 @@ pub async fn list_refs(
|
||||
auth: Option<Extension<AuthenticatedDid>>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
|
||||
let (record, _rules) =
|
||||
let (_record, _rules) =
|
||||
crate::api::authorize_repo_read(&state, &owner, &repo, caller, "/").await?;
|
||||
|
||||
// Build the filter from the canonical stored slug, NOT from the raw path
|
||||
// segments. `branch_cids.repo` is written as
|
||||
// `{normalize_owner_key(owner_did)}/{name}` (the push path builds the same
|
||||
// slug before calling `upsert_branch_cid`), and
|
||||
// `get_repo` resolves both `did:key:zX` and bare `zX` to the same record, so
|
||||
// a request in the full-DID form authorizes and then matches zero rows,
|
||||
// returning an empty page with a 200.
|
||||
let repo_slug = format!(
|
||||
"{}/{}",
|
||||
crate::db::normalize_owner_key(&record.owner_did),
|
||||
record.name
|
||||
);
|
||||
let repo_slug = format!("{owner}/{repo}");
|
||||
let refs = state.db.list_branch_cids(&repo_slug).await?;
|
||||
|
||||
Ok(Json(
|
||||
@@ -3877,12 +3866,6 @@ struct RefUpdate {
|
||||
ref_name: String,
|
||||
}
|
||||
|
||||
/// A git object id is 40 hex chars; length alone is not enough, since a peer
|
||||
/// can send 40 arbitrary bytes (#398).
|
||||
fn is_hex_sha(s: &str) -> bool {
|
||||
s.len() == 40 && s.bytes().all(|b| b.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
/// Parse git receive-pack pkt-line ref updates from the request body.
|
||||
/// Format per line: `<40-hex-old> <40-hex-new> <refname>[NUL capabilities]\n`
|
||||
fn parse_ref_updates(body: &[u8]) -> Vec<RefUpdate> {
|
||||
@@ -3924,7 +3907,7 @@ fn parse_ref_updates(body: &[u8]) -> Vec<RefUpdate> {
|
||||
.trim_end_matches('\n');
|
||||
|
||||
let parts: Vec<&str> = line.splitn(3, ' ').collect();
|
||||
if parts.len() == 3 && is_hex_sha(parts[0]) && is_hex_sha(parts[1]) {
|
||||
if parts.len() == 3 && parts[0].len() == 40 && parts[1].len() == 40 {
|
||||
updates.push(RefUpdate {
|
||||
old_sha: parts[0].to_string(),
|
||||
new_sha: parts[1].to_string(),
|
||||
@@ -4444,45 +4427,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// #398: a peer-supplied pkt-line whose 40-byte "SHA" fields carry non-hex
|
||||
// content must be dropped, not forwarded into RefUpdate events, branch_cid
|
||||
// rows, and sync-notify JSON as a canonical-looking identifier.
|
||||
#[test]
|
||||
fn parse_ref_updates_requires_hex_shas() {
|
||||
let sha_a = "a".repeat(40);
|
||||
let sha_b = "b".repeat(40);
|
||||
let pkt = |old: &str, new: &str, refname: &str| {
|
||||
let line = format!("{old} {new} {refname}");
|
||||
format!("{:04x}{}0000", line.len() + 4, line).into_bytes()
|
||||
};
|
||||
|
||||
// Valid 40-hex old + new -> accepted.
|
||||
let updates = parse_ref_updates(&pkt(&sha_a, &sha_b, "refs/heads/main"));
|
||||
assert_eq!(updates.len(), 1);
|
||||
assert_eq!(updates[0].old_sha, sha_a);
|
||||
assert_eq!(updates[0].new_sha, sha_b);
|
||||
assert_eq!(updates[0].ref_name, "refs/heads/main");
|
||||
|
||||
// 40-byte non-hex fields -> dropped.
|
||||
let junk = "Z".repeat(40);
|
||||
assert!(parse_ref_updates(&pkt(&junk, &sha_b, "refs/heads/main")).is_empty());
|
||||
assert!(parse_ref_updates(&pkt(&sha_a, &junk, "refs/heads/main")).is_empty());
|
||||
|
||||
// Wrong lengths -> dropped.
|
||||
assert!(parse_ref_updates(&pkt(&"a".repeat(39), &sha_b, "refs/heads/main")).is_empty());
|
||||
assert!(parse_ref_updates(&pkt(&sha_a, &"b".repeat(41), "refs/heads/main")).is_empty());
|
||||
|
||||
// A bad line among good ones drops only the bad line.
|
||||
let sha_c = "c".repeat(40);
|
||||
let bad = pkt(&junk, &sha_b, "refs/heads/bad");
|
||||
let good = pkt(&sha_a, &sha_c, "refs/heads/good");
|
||||
let mut mixed = bad[..bad.len() - 4].to_vec(); // strip trailing flush
|
||||
mixed.extend_from_slice(&good);
|
||||
let updates = parse_ref_updates(&mixed);
|
||||
assert_eq!(updates.len(), 1);
|
||||
assert_eq!(updates[0].ref_name, "refs/heads/good");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_service_app_error_classifies_timeout_bad_request_and_git() {
|
||||
// GitServiceTimeout carried through anyhow -> 504 Timeout.
|
||||
@@ -8289,7 +8233,7 @@ mod tests {
|
||||
let add = server
|
||||
.mock("POST", mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_body(r#"{"Hash":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4"}"#)
|
||||
.with_body(r#"{"Hash":"QmShouldNotHappen"}"#)
|
||||
.expect(0)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
//! #26 Split PR 2 — three-outcome recovery policy types.
|
||||
//!
|
||||
//! This module owns the recovery classification policy the reviewer
|
||||
//! demanded: only a trustworthy, protocol-defined absence authorizes
|
||||
//! a paid re-upload. Everything else stays non-terminal.
|
||||
//!
|
||||
//! Deliberately narrow: the ANS-104 codec lives in `ans104`, and the
|
||||
//! gateway probe, the `verify_anchor` path, and the public verify
|
||||
//! endpoint are deferred until the uploader, retrieval proof, and
|
||||
//! recovery consumer can be proven as one vertical slice (reviewer 2,
|
||||
//! round 5). A probe against a real provider contract — same-network
|
||||
//! upload/read pair, redirect-safe client, envelope-supplying read
|
||||
//! API, provider-documented absence evidence — belongs in that slice,
|
||||
//! not here. What ships here is the policy type plus the rule that
|
||||
//! only `DefinitivelyAbsent` permits spending another upload.
|
||||
|
||||
/// Outcome of a gateway probe for a persisted `item_id`.
|
||||
// Policy surface for the recovery slice: no construction on this
|
||||
// head (the probe arrives with the provider contract); the unit
|
||||
// test pins the rule meanwhile.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ProbeOutcome {
|
||||
/// The persisted item was served back with a verifiable envelope
|
||||
/// bound to the requested id. No re-upload allowed.
|
||||
Present,
|
||||
/// Trustworthy, protocol-defined proof the item was never
|
||||
/// accepted. Authorizes re-upload. The exact evidence that
|
||||
/// establishes this is defined by the provider contract in the
|
||||
/// vertical slice that introduces the probe — never by a
|
||||
/// mock-only body shape.
|
||||
DefinitivelyAbsent,
|
||||
/// Anything else: transport failure, oversized body, bad
|
||||
/// signature, id mismatch, ambiguous gateway response. The
|
||||
/// outbox stays non-terminal.
|
||||
Indeterminate,
|
||||
}
|
||||
|
||||
impl ProbeOutcome {
|
||||
/// True iff the recovery code is allowed to spend another paid
|
||||
/// upload request. Only `DefinitivelyAbsent` qualifies.
|
||||
#[allow(dead_code)] // the recovery consumer lives in a later slice
|
||||
pub fn permits_reupload(self) -> bool {
|
||||
matches!(self, ProbeOutcome::DefinitivelyAbsent)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Only `DefinitivelyAbsent` authorizes a paid re-upload. This is
|
||||
/// the policy the reviewer named: collapsing any ambiguous outcome
|
||||
/// to absent spends a second immutable upload for the same
|
||||
/// transition.
|
||||
#[test]
|
||||
fn only_definitively_absent_authorizes_reupload() {
|
||||
assert!(!ProbeOutcome::Present.permits_reupload());
|
||||
assert!(ProbeOutcome::DefinitivelyAbsent.permits_reupload());
|
||||
assert!(!ProbeOutcome::Indeterminate.permits_reupload());
|
||||
}
|
||||
}
|
||||
@@ -55,10 +55,7 @@ pub struct Config {
|
||||
#[arg(long, env = "GITLAWB_KEY", default_value = "~/.gitlawb/identity.pem")]
|
||||
pub key_path: String,
|
||||
|
||||
/// Reserved and currently inert: read access is enforced per repository
|
||||
/// through `is_public` and path-scoped visibility rules, not this flag.
|
||||
/// Setting it to false changes nothing; make a repo private through its
|
||||
/// own visibility instead.
|
||||
/// Reserved for private-read mode; per-repo read enforcement is not wired in alpha
|
||||
#[arg(long, env = "GITLAWB_PUBLIC_READ", default_value_t = true)]
|
||||
pub public_read: bool,
|
||||
|
||||
@@ -1637,28 +1634,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// #338: GITLAWB_PUBLIC_READ is inert, so its help must not claim read
|
||||
/// enforcement is missing; it must tell the operator the flag changes
|
||||
/// nothing and where the real control lives.
|
||||
#[test]
|
||||
fn public_read_help_describes_the_flag_as_inert() {
|
||||
use clap::CommandFactory;
|
||||
let cmd = Config::command();
|
||||
let arg = cmd
|
||||
.get_arguments()
|
||||
.find(|a| a.get_id() == "public_read")
|
||||
.expect("the argument must exist");
|
||||
let help = arg.get_help().map(|h| h.to_string()).unwrap_or_default();
|
||||
assert!(
|
||||
!help.contains("not wired"),
|
||||
"the help still claims read enforcement is unwired: {help}"
|
||||
);
|
||||
assert!(
|
||||
help.contains("inert"),
|
||||
"the help must say the flag is inert so an operator does not rely on it: {help}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The flip must not strand an operator mid-upgrade.
|
||||
///
|
||||
/// Turning the gate on is a breaking change for any deployment whose pushers are
|
||||
|
||||
@@ -1508,6 +1508,9 @@ pub async fn pin_git_object(
|
||||
return Ok(String::new());
|
||||
}
|
||||
|
||||
// Compute the expected CIDv1 from the content bytes
|
||||
let expected_cid = Cid::from_git_object_bytes(data).to_string();
|
||||
|
||||
let url = format!(
|
||||
"{}/api/v0/add?cid-version=1&raw-leaves=true&pin=true",
|
||||
ipfs_api.trim_end_matches('/')
|
||||
@@ -1554,10 +1557,6 @@ pub async fn pin_git_object(
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("IPFS add response body read failed: {e}"))?;
|
||||
// A 200 without a parseable Hash is not a successful pin. Fabricating the
|
||||
// caller-computed CID would record an address the backend never confirmed;
|
||||
// above its chunk threshold that raw CID is not even a stored block, so
|
||||
// every later cat fails permanently.
|
||||
let cid = body
|
||||
.lines()
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
@@ -1566,17 +1565,7 @@ pub async fn pin_git_object(
|
||||
v["Hash"].as_str().map(|s| s.to_string())
|
||||
})
|
||||
.next_back()
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"IPFS /api/v0/add returned 200 without a usable Hash: {:.200}",
|
||||
body
|
||||
)
|
||||
})?;
|
||||
// The string still has to be a CID. A garbage Hash would be recorded and
|
||||
// later cat-ed the same way a missing one was, so reject it here.
|
||||
Cid::from_str(&cid).map_err(|e| {
|
||||
anyhow::anyhow!("IPFS /api/v0/add returned a Hash that is not a CID ({cid:.200}): {e}")
|
||||
})?;
|
||||
.unwrap_or(expected_cid.clone());
|
||||
|
||||
tracing::debug!(sha256 = %sha256_hex, %cid, "pinned git object to IPFS");
|
||||
Ok(cid)
|
||||
@@ -2234,16 +2223,16 @@ mod tests {
|
||||
endpoint
|
||||
}
|
||||
|
||||
/// A sleeping-but-live endpoint. Answers `200` with a conformant NDJSON add
|
||||
/// response after `delays[i]` for the i-th request it accepts (the last
|
||||
/// entry repeats), so a test can make one add slow and the next fast.
|
||||
/// Drains the full request, headers plus the declared `Content-Length`
|
||||
/// body, before sleeping: exactly as in `rejecting_endpoint`, answering
|
||||
/// early and closing would surface as a write failure on the client and
|
||||
/// turn a slow-but-healthy add into a different failure shape.
|
||||
/// A sleeping-but-live endpoint. Answers `200` with an empty body after
|
||||
/// `delays[i]` for the i-th request it accepts (the last entry repeats), so
|
||||
/// a test can make one add slow and the next fast. Drains the full request,
|
||||
/// headers plus the declared `Content-Length` body, before sleeping: exactly
|
||||
/// as in `rejecting_endpoint`, answering early and closing would surface as
|
||||
/// a write failure on the client and turn a slow-but-healthy add into a
|
||||
/// different failure shape.
|
||||
///
|
||||
/// The Hash is a fixed, real CID: the timing tests assert on pin counts,
|
||||
/// not on the recorded address.
|
||||
/// An empty body is a successful pin: `pin_git_object` falls back to the CID
|
||||
/// it computed from the bytes when the response carries no `Hash`.
|
||||
async fn delaying_endpoint(delays: Vec<Duration>) -> String {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
@@ -2280,14 +2269,9 @@ mod tests {
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(delay).await;
|
||||
let body = br#"{"Hash":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4","Size":"12"}
|
||||
"#;
|
||||
let resp = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/x-ndjson\r\nContent-Length: {}\r\n\r\n",
|
||||
body.len()
|
||||
);
|
||||
let _ = sock.write_all(resp.as_bytes()).await;
|
||||
let _ = sock.write_all(body).await;
|
||||
let _ = sock
|
||||
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
|
||||
.await;
|
||||
let _ = sock.flush().await;
|
||||
});
|
||||
}
|
||||
@@ -2389,71 +2373,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A 200 add response with no parseable Hash must surface as an add
|
||||
/// failure. Falling back to the caller-computed CID records an
|
||||
/// unverifiable address: above the backend's chunk threshold that raw
|
||||
/// CID is not a stored block, so every later cat 500s.
|
||||
#[tokio::test]
|
||||
async fn pin_git_object_rejects_a_200_without_a_hash() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
|
||||
.with_status(200)
|
||||
.with_body(r#"{"Name":"object","Size":"19"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
let result = pin_git_object(&server.url(), "deadbeef", b"some object bytes\n", None).await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"a 200 without a Hash must be an add failure, not a fabricated CID: {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A Hash that is a string but not a CID must also fail. It would be
|
||||
/// recorded into `encrypted_blobs.cid` and every later cat would miss.
|
||||
#[tokio::test]
|
||||
async fn pin_git_object_rejects_a_malformed_hash() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
|
||||
.with_status(200)
|
||||
.with_body(r#"{"Hash":"not-a-cid","Size":"19"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
let result = pin_git_object(&server.url(), "deadbeef", b"some object bytes\n", None).await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"a Hash that is not a CID must be an add failure: {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The Hash text goes into the error and `encrypted_pin` logs that error,
|
||||
/// so a backend answering with a huge invalid Hash must not produce an
|
||||
/// unbounded log entry.
|
||||
#[tokio::test]
|
||||
async fn pin_git_object_bounds_an_oversized_invalid_hash_in_the_error() {
|
||||
let long = "x".repeat(5000);
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
|
||||
.with_status(200)
|
||||
.with_body(format!(r#"{{"Hash":"{long}","Size":"19"}}"#))
|
||||
.create_async()
|
||||
.await;
|
||||
let result = pin_git_object(&server.url(), "deadbeef", b"some object bytes\n", None).await;
|
||||
let err = result.expect_err("an invalid Hash must be an add failure");
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.len() < 1000,
|
||||
"the error must bound the echoed Hash, got {} bytes",
|
||||
msg.len()
|
||||
);
|
||||
assert!(
|
||||
!msg.contains(&long),
|
||||
"the error must not carry the full invalid Hash"
|
||||
);
|
||||
}
|
||||
|
||||
/// The permit-hold bound. `pin_new_objects` runs under a deferring
|
||||
/// `pin_semaphore`, so without a batch deadline the hold is O(N) with N
|
||||
/// chosen by the pusher. Five objects against an endpoint that takes 2s
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
mod ans104;
|
||||
mod api;
|
||||
mod arweave;
|
||||
mod arweave_v2;
|
||||
mod auth;
|
||||
mod bootstrap;
|
||||
mod cert;
|
||||
@@ -86,7 +84,7 @@ async fn main() -> Result<()> {
|
||||
|
||||
if !config.public_read {
|
||||
warn!(
|
||||
"GITLAWB_PUBLIC_READ=false has no effect; reads are gated per repository by is_public and path-scoped visibility rules, not this flag"
|
||||
"GITLAWB_PUBLIC_READ=false is reserved; per-repository private-read enforcement is not wired in alpha"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -261,10 +261,6 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.merge(Router::new().route("/api/v1/ipfs/pins", get(ipfs::list_pins)));
|
||||
|
||||
// ── Arweave permanent anchors ──────────────────────────────────────────
|
||||
// List endpoint only (public; issue #134 tracks surfacing
|
||||
// visibility rules on list). The verify endpoint and its probe
|
||||
// are deferred to the vertical slice with the uploader and a
|
||||
// real provider contract (reviewer 2, #26 split 2/4 round 5).
|
||||
let arweave_routes = Router::new().route("/api/v1/arweave/anchors", get(arweave::list_anchors));
|
||||
|
||||
// ── Bounty routes (write — require HTTP Signature) ─────────────────
|
||||
|
||||
@@ -207,136 +207,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `list_refs` must resolve both owner-DID forms to the SAME refs.
|
||||
/// `branch_cids.repo` is written as `{normalize_owner_key(owner_did)}/{name}`
|
||||
/// (`api/repos.rs:2643` -> `:2754`), and `get_repo` resolves `did:key:zX` and
|
||||
/// bare `zX` to one repo, so both path forms authorize. Reading the table with
|
||||
/// the raw path segments instead of the canonical slug returns an empty page
|
||||
/// with a 200, which is a denial rendered as success. The second repo pins the
|
||||
/// other direction: the filter must not widen to another owner's refs.
|
||||
#[sqlx::test]
|
||||
async fn list_refs_resolves_both_owner_did_forms_to_the_canonical_slug(pool: PgPool) {
|
||||
let owner_did = "did:key:zREFSCANON";
|
||||
let other_did = "did:key:zREFSOTHER";
|
||||
let state = test_state(pool).await;
|
||||
|
||||
for did in [owner_did, other_did] {
|
||||
let mut repo = seed_repo(did, "refs-canon");
|
||||
repo.is_public = true;
|
||||
// Same repo name under two owners, so the name-derived disk_path
|
||||
// from the helper would collide on its unique constraint.
|
||||
repo.disk_path = format!("/tmp/refs-canon-{}", crate::db::normalize_owner_key(did));
|
||||
state.db.create_repo(&repo).await.expect("seed repo");
|
||||
// Exactly the slug the production push path writes.
|
||||
let slug = format!("{}/{}", crate::db::normalize_owner_key(did), "refs-canon");
|
||||
let cid = format!("bafy{}", crate::db::normalize_owner_key(did));
|
||||
state
|
||||
.db
|
||||
.upsert_branch_cid(
|
||||
&slug,
|
||||
"refs/heads/main",
|
||||
&"1".repeat(64),
|
||||
&cid,
|
||||
"did:key:zNODE",
|
||||
)
|
||||
.await
|
||||
.expect("seed branch cid");
|
||||
}
|
||||
|
||||
async fn body_for(state: &crate::state::AppState, owner: &str) -> String {
|
||||
let router = crate::server::build_router(state.clone());
|
||||
let resp = router
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(format!("/api/v1/repos/{owner}/refs-canon/refs"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"both owner-DID forms resolve to the same repo, so both must authorize"
|
||||
);
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
String::from_utf8_lossy(&bytes).into_owned()
|
||||
}
|
||||
|
||||
for owner in ["zREFSCANON", "did:key:zREFSCANON"] {
|
||||
let body = body_for(&state, owner).await;
|
||||
assert!(
|
||||
body.contains("bafyzREFSCANON"),
|
||||
"?owner={owner} must return the ref written under the canonical slug, \
|
||||
not an empty page with a 200. body was: {body}"
|
||||
);
|
||||
assert!(
|
||||
!body.contains("bafyzREFSOTHER"),
|
||||
"?owner={owner} must not return another owner's refs. body was: {body}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The read gate on `list_refs`, driven in the deny direction. Without this
|
||||
/// the slug regression above proves only that the query key is right: every
|
||||
/// repo it seeds is public, so deleting `authorize_repo_read` outright leaves
|
||||
/// it green. A private repo must answer an anonymous caller with the same 404
|
||||
/// a missing repo gets, and must not put a CID in the body on the way out.
|
||||
#[sqlx::test]
|
||||
async fn list_refs_denies_an_anonymous_caller_on_a_private_repo(pool: PgPool) {
|
||||
let owner_did = "did:key:zREFSPRIV";
|
||||
let state = test_state(pool).await;
|
||||
|
||||
let mut repo = seed_repo(owner_did, "refs-private");
|
||||
repo.is_public = false;
|
||||
state.db.create_repo(&repo).await.expect("seed repo");
|
||||
state
|
||||
.db
|
||||
.upsert_branch_cid(
|
||||
&format!(
|
||||
"{}/{}",
|
||||
crate::db::normalize_owner_key(owner_did),
|
||||
"refs-private"
|
||||
),
|
||||
"refs/heads/main",
|
||||
&"1".repeat(64),
|
||||
"bafyzREFSPRIVSECRET",
|
||||
"did:key:zNODE",
|
||||
)
|
||||
.await
|
||||
.expect("seed branch cid");
|
||||
|
||||
for owner in ["zREFSPRIV", "did:key:zREFSPRIV"] {
|
||||
let router = crate::server::build_router(state.clone());
|
||||
let resp = router
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(format!("/api/v1/repos/{owner}/refs-private/refs"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::NOT_FOUND,
|
||||
"anonymous read of a private repo must 404, not disclose it (owner form {owner})"
|
||||
);
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let body = String::from_utf8_lossy(&bytes);
|
||||
assert!(
|
||||
!body.contains("bafyzREFSPRIVSECRET"),
|
||||
"the deny body must not carry a pinned CID. body was: {body}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Proves the harness end to end: a migrated DB, a seeded repo, and the
|
||||
/// owner gate on an ALREADY-gated endpoint (`PUT /visibility`, gated by
|
||||
/// `require_owner`). Non-owner is rejected; owner succeeds. Mounts the
|
||||
@@ -3735,300 +3605,6 @@ mod tests {
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND, "absent repo → 404");
|
||||
}
|
||||
|
||||
/// #435: register_replica / unregister_replica apply the same read-visibility
|
||||
/// gate as list_replicas. Both mutate caller-bound replica metadata and their
|
||||
/// responses disclose the replica count, so a non-reader of a private repo
|
||||
/// gets the same 404 as a missing repo and nothing is written. Authorized
|
||||
/// readers keep self-registering and self-removing.
|
||||
#[sqlx::test]
|
||||
async fn replica_register_unregister_are_read_visibility_gated(pool: PgPool) {
|
||||
use crate::db::VisibilityMode;
|
||||
let owner = "did:key:zREPMUTOWNERRRRRRRRRRRRRRRRRRRRRRRRRR";
|
||||
let reader = "did:key:zREPMUTREADERRRRRRRRRRRRRRRRRRRRRRRRR";
|
||||
let stranger = "did:key:zREPMUTSTRGRRRRRRRRRRRRRRRRRRRRRRRRR";
|
||||
let state = test_state(pool).await;
|
||||
|
||||
let mut priv_repo = seed_repo(owner, "repmut-priv");
|
||||
priv_repo.is_public = false;
|
||||
state
|
||||
.db
|
||||
.create_repo(&priv_repo)
|
||||
.await
|
||||
.expect("seed private repo");
|
||||
let pub_repo = seed_repo(owner, "repmut-pub");
|
||||
state
|
||||
.db
|
||||
.create_repo(&pub_repo)
|
||||
.await
|
||||
.expect("seed public repo");
|
||||
// A listed reader of the private repo keeps mutation rights.
|
||||
state
|
||||
.db
|
||||
.set_visibility_rule(
|
||||
&priv_repo.id,
|
||||
"/",
|
||||
VisibilityMode::B,
|
||||
&[reader.to_string()],
|
||||
owner,
|
||||
)
|
||||
.await
|
||||
.expect("seed reader rule");
|
||||
|
||||
let router = || {
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/v1/repos/{owner}/{repo}/replicas",
|
||||
axum::routing::put(crate::api::replicas::register_replica)
|
||||
.delete(crate::api::replicas::unregister_replica),
|
||||
)
|
||||
.with_state(state.clone())
|
||||
};
|
||||
let replica_url = "https://replica.example.com/me";
|
||||
let body = || Body::from(format!(r#"{{"url":"{replica_url}"}}"#));
|
||||
let leaks = |bytes: &[u8]| {
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
text.contains(replica_url) || text.contains(&priv_repo.id)
|
||||
};
|
||||
|
||||
// Private repo, non-reader stranger: register → 404, nothing written.
|
||||
let resp = router()
|
||||
.oneshot(signed_request_as(
|
||||
stranger,
|
||||
Method::PUT,
|
||||
&format!("/api/v1/repos/{owner}/repmut-priv/replicas"),
|
||||
body(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::NOT_FOUND,
|
||||
"a non-reader must not register on a private repo"
|
||||
);
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
!leaks(&bytes),
|
||||
"a denied register body must not leak the replica url or repo data"
|
||||
);
|
||||
assert!(
|
||||
state
|
||||
.db
|
||||
.list_replicas(&priv_repo.id)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty(),
|
||||
"a denied register must not create a replica row"
|
||||
);
|
||||
|
||||
// Private repo, non-reader stranger: unregister → 404.
|
||||
let resp = router()
|
||||
.oneshot(signed_request_as(
|
||||
stranger,
|
||||
Method::DELETE,
|
||||
&format!("/api/v1/repos/{owner}/repmut-priv/replicas"),
|
||||
Body::empty(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::NOT_FOUND,
|
||||
"a non-reader must not unregister on a private repo"
|
||||
);
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
!leaks(&bytes),
|
||||
"a denied unregister body must not leak the replica url or repo data"
|
||||
);
|
||||
|
||||
// Private repo, listed reader: register → 201, unregister → 200.
|
||||
let resp = router()
|
||||
.oneshot(signed_request_as(
|
||||
reader,
|
||||
Method::PUT,
|
||||
&format!("/api/v1/repos/{owner}/repmut-priv/replicas"),
|
||||
body(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::CREATED,
|
||||
"a listed reader keeps self-registration on a private repo"
|
||||
);
|
||||
let resp = router()
|
||||
.oneshot(signed_request_as(
|
||||
reader,
|
||||
Method::DELETE,
|
||||
&format!("/api/v1/repos/{owner}/repmut-priv/replicas"),
|
||||
Body::empty(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"a listed reader keeps self-removal on a private repo"
|
||||
);
|
||||
|
||||
// Public repo, authenticated stranger: register → 201.
|
||||
let resp = router()
|
||||
.oneshot(signed_request_as(
|
||||
stranger,
|
||||
Method::PUT,
|
||||
&format!("/api/v1/repos/{owner}/repmut-pub/replicas"),
|
||||
body(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::CREATED,
|
||||
"public-repo registration stays open to any authenticated replica"
|
||||
);
|
||||
|
||||
// Owner self-registration is still refused.
|
||||
let resp = router()
|
||||
.oneshot(signed_request_as(
|
||||
owner,
|
||||
Method::PUT,
|
||||
&format!("/api/v1/repos/{owner}/repmut-pub/replicas"),
|
||||
body(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::BAD_REQUEST,
|
||||
"the owner still cannot register as their own replica"
|
||||
);
|
||||
}
|
||||
|
||||
/// #435 end-to-end: drive the replica mutations through the PRODUCTION
|
||||
/// router (`app` → require_signature → handler) with real RFC-9421
|
||||
/// signatures, so the whole verify-then-authorize stack is exercised, not
|
||||
/// just the handler with an injected DID. A stranger on a private repo
|
||||
/// gets the same 404 as a missing repo with nothing written; a listed
|
||||
/// reader registers and removes itself.
|
||||
#[sqlx::test]
|
||||
async fn replica_mutations_enforce_visibility_through_real_signature_e2e(pool: PgPool) {
|
||||
use crate::db::VisibilityMode;
|
||||
use gitlawb_core::http_sig::sign_request;
|
||||
use gitlawb_core::identity::Keypair;
|
||||
|
||||
let owner_kp = Keypair::generate();
|
||||
let owner_did = owner_kp.did().to_string();
|
||||
// Short owner form in the path keeps the signed @path byte-identical
|
||||
// to what the node sees (no colons) while did_matches still resolves.
|
||||
let short = owner_did.split(':').next_back().unwrap().to_string();
|
||||
let stranger_kp = Keypair::generate();
|
||||
let reader_kp = Keypair::generate();
|
||||
let reader_did = reader_kp.did().to_string();
|
||||
|
||||
let state = test_state(pool.clone()).await;
|
||||
let mut repo = seed_repo(&owner_did, "sig-repl-priv");
|
||||
repo.is_public = false;
|
||||
state
|
||||
.db
|
||||
.create_repo(&repo)
|
||||
.await
|
||||
.expect("seed private repo");
|
||||
state
|
||||
.db
|
||||
.set_visibility_rule(&repo.id, "/", VisibilityMode::B, &[reader_did], &owner_did)
|
||||
.await
|
||||
.expect("seed reader rule");
|
||||
|
||||
let router = app(pool).await;
|
||||
let path = format!("/api/v1/repos/{short}/sig-repl-priv/replicas");
|
||||
let replica_url = "https://replica.example.com/e2e";
|
||||
let reg_body: &[u8] = br#"{"url":"https://replica.example.com/e2e"}"#;
|
||||
let leaks = |bytes: &[u8]| {
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
text.contains(replica_url) || text.contains(&repo.id)
|
||||
};
|
||||
let signed_req = |kp: &Keypair, method: &str, body: &'static [u8]| {
|
||||
let signed = sign_request(kp, method, &path, body);
|
||||
Request::builder()
|
||||
.method(method)
|
||||
.uri(&path)
|
||||
.header(axum::http::header::CONTENT_TYPE, "application/json")
|
||||
.header("content-digest", signed.content_digest)
|
||||
.header("signature-input", signed.signature_input)
|
||||
.header("signature", signed.signature)
|
||||
.body(Body::from(body))
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
// Stranger (verified signature, not a reader) → 404, no row written.
|
||||
let resp = router
|
||||
.clone()
|
||||
.oneshot(signed_req(&stranger_kp, "PUT", reg_body))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::NOT_FOUND,
|
||||
"a verified non-reader must not register on a private repo"
|
||||
);
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
!leaks(&bytes),
|
||||
"a denied register body must not leak the replica url or repo data"
|
||||
);
|
||||
assert!(
|
||||
state.db.list_replicas(&repo.id).await.unwrap().is_empty(),
|
||||
"a denied register must not create a replica row"
|
||||
);
|
||||
|
||||
// Stranger DELETE → 404.
|
||||
let resp = router
|
||||
.clone()
|
||||
.oneshot(signed_req(&stranger_kp, "DELETE", b""))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::NOT_FOUND,
|
||||
"a verified non-reader must not unregister on a private repo"
|
||||
);
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
!leaks(&bytes),
|
||||
"a denied unregister body must not leak the replica url or repo data"
|
||||
);
|
||||
|
||||
// Listed reader: PUT → 201, DELETE → 200.
|
||||
let resp = router
|
||||
.clone()
|
||||
.oneshot(signed_req(&reader_kp, "PUT", reg_body))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::CREATED,
|
||||
"a listed reader registers through the real middleware"
|
||||
);
|
||||
let resp = router
|
||||
.clone()
|
||||
.oneshot(signed_req(&reader_kp, "DELETE", b""))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"a listed reader unregisters through the real middleware"
|
||||
);
|
||||
}
|
||||
|
||||
/// #94 sibling: list_labels is read-visibility-gated. A public repo's labels
|
||||
/// stay anonymously listable; a private repo's label names must not leak to a
|
||||
/// non-reader (404). A listed reader of the private repo reads the label; the
|
||||
@@ -6374,7 +5950,7 @@ mod tests {
|
||||
let m = server
|
||||
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
|
||||
.with_status(200)
|
||||
.with_body(r#"{"Hash":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4"}"#)
|
||||
.with_body(r#"{"Hash":"bafyshouldnothappen"}"#)
|
||||
.expect(0)
|
||||
.create_async()
|
||||
.await;
|
||||
@@ -6500,7 +6076,7 @@ mod tests {
|
||||
let m = server
|
||||
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
|
||||
.with_status(200)
|
||||
.with_body(r#"{"Hash":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4"}"#)
|
||||
.with_body(r#"{"Hash":"bafyshouldnothappen"}"#)
|
||||
.expect(0)
|
||||
.create_async()
|
||||
.await;
|
||||
@@ -6690,7 +6266,7 @@ mod tests {
|
||||
let m = server
|
||||
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
|
||||
.with_status(200)
|
||||
.with_body(r#"{"Hash":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4"}"#)
|
||||
.with_body(r#"{"Hash":"bafyshouldnothappen"}"#)
|
||||
.expect(0)
|
||||
.create_async()
|
||||
.await;
|
||||
@@ -8510,7 +8086,7 @@ mod tests {
|
||||
let m = server
|
||||
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
|
||||
.with_status(200)
|
||||
.with_body(r#"{"Hash":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4"}"#)
|
||||
.with_body(r#"{"Hash":"bafyprovtest"}"#)
|
||||
.expect_at_least(1)
|
||||
.create_async()
|
||||
.await;
|
||||
@@ -8570,7 +8146,7 @@ mod tests {
|
||||
let m = server
|
||||
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
|
||||
.with_status(200)
|
||||
.with_body(r#"{"Hash":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4"}"#)
|
||||
.with_body(r#"{"Hash":"bafyproviderhash"}"#)
|
||||
.expect(2)
|
||||
.create_async()
|
||||
.await;
|
||||
@@ -8829,7 +8405,7 @@ mod tests {
|
||||
let m = server
|
||||
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
|
||||
.with_status(200)
|
||||
.with_body(r#"{"Hash":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4"}"#)
|
||||
.with_body(r#"{"Hash":"bafyshouldnothappen"}"#)
|
||||
.expect(0)
|
||||
.create_async()
|
||||
.await;
|
||||
@@ -8952,7 +8528,7 @@ mod tests {
|
||||
let m = server
|
||||
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
|
||||
.with_status(200)
|
||||
.with_body(r#"{"Hash":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4"}"#)
|
||||
.with_body(r#"{"Hash":"bafyshouldnothappen"}"#)
|
||||
.expect(0)
|
||||
.create_async()
|
||||
.await;
|
||||
@@ -9054,7 +8630,7 @@ mod tests {
|
||||
let m = server
|
||||
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
|
||||
.with_status(200)
|
||||
.with_body(r#"{"Hash":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4"}"#)
|
||||
.with_body(r#"{"Hash":"x"}"#)
|
||||
.expect(0)
|
||||
.create_async()
|
||||
.await;
|
||||
@@ -9118,7 +8694,7 @@ mod tests {
|
||||
server
|
||||
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
|
||||
.with_status(200)
|
||||
.with_body(r#"{"Hash":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4"}"#)
|
||||
.with_body(r#"{"Hash":"x"}"#)
|
||||
.expect(0)
|
||||
.create_async()
|
||||
.await;
|
||||
@@ -18483,9 +18059,7 @@ mod tests {
|
||||
let _m = server
|
||||
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
|
||||
.with_status(200)
|
||||
.with_body(
|
||||
r#"{"Hash":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4"}"#,
|
||||
)
|
||||
.with_body(r#"{"Hash":"bafyprovider"}"#)
|
||||
.expect_at_least(1)
|
||||
.create_async()
|
||||
.await;
|
||||
@@ -18553,9 +18127,7 @@ mod tests {
|
||||
let _m = server
|
||||
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
|
||||
.with_status(200)
|
||||
.with_body(
|
||||
r#"{"Hash":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4"}"#,
|
||||
)
|
||||
.with_body(r#"{"Hash":"bafyprovider"}"#)
|
||||
.expect_at_least(1)
|
||||
.create_async()
|
||||
.await;
|
||||
@@ -18623,9 +18195,7 @@ mod tests {
|
||||
let _m = server
|
||||
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
|
||||
.with_status(200)
|
||||
.with_body(
|
||||
r#"{"Hash":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4"}"#,
|
||||
)
|
||||
.with_body(r#"{"Hash":"bafyprovider"}"#)
|
||||
.expect_at_least(1)
|
||||
.create_async()
|
||||
.await;
|
||||
@@ -18695,9 +18265,7 @@ mod tests {
|
||||
let _m = server
|
||||
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
|
||||
.with_status(200)
|
||||
.with_body(
|
||||
r#"{"Hash":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4"}"#,
|
||||
)
|
||||
.with_body(r#"{"Hash":"bafyprovider"}"#)
|
||||
.expect_at_least(1)
|
||||
.create_async()
|
||||
.await;
|
||||
@@ -18761,9 +18329,7 @@ mod tests {
|
||||
let _m = server
|
||||
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
|
||||
.with_status(200)
|
||||
.with_body(
|
||||
r#"{"Hash":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4"}"#,
|
||||
)
|
||||
.with_body(r#"{"Hash":"bafyprovider"}"#)
|
||||
.expect_at_least(1)
|
||||
.create_async()
|
||||
.await;
|
||||
@@ -18825,9 +18391,7 @@ mod tests {
|
||||
let _m = server
|
||||
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
|
||||
.with_status(200)
|
||||
.with_body(
|
||||
r#"{"Hash":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4"}"#,
|
||||
)
|
||||
.with_body(r#"{"Hash":"bafyprovider"}"#)
|
||||
.expect_at_least(1)
|
||||
.create_async()
|
||||
.await;
|
||||
@@ -18963,9 +18527,7 @@ mod tests {
|
||||
let _m = server
|
||||
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
|
||||
.with_status(200)
|
||||
.with_body(
|
||||
r#"{"Hash":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4"}"#,
|
||||
)
|
||||
.with_body(r#"{"Hash":"bafyprovider"}"#)
|
||||
.expect_at_least(1)
|
||||
.create_async()
|
||||
.await;
|
||||
@@ -19044,9 +18606,7 @@ mod tests {
|
||||
let _m = server
|
||||
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
|
||||
.with_status(200)
|
||||
.with_body(
|
||||
r#"{"Hash":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4"}"#,
|
||||
)
|
||||
.with_body(r#"{"Hash":"bafyprovider"}"#)
|
||||
.expect_at_least(1)
|
||||
.create_async()
|
||||
.await;
|
||||
@@ -19107,9 +18667,7 @@ mod tests {
|
||||
let _m = server
|
||||
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
|
||||
.with_status(200)
|
||||
.with_body(
|
||||
r#"{"Hash":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4"}"#,
|
||||
)
|
||||
.with_body(r#"{"Hash":"bafyprovider"}"#)
|
||||
.expect_at_least(1)
|
||||
.create_async()
|
||||
.await;
|
||||
@@ -19208,9 +18766,7 @@ mod tests {
|
||||
let _m = server
|
||||
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
|
||||
.with_status(200)
|
||||
.with_body(
|
||||
r#"{"Hash":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4"}"#,
|
||||
)
|
||||
.with_body(r#"{"Hash":"bafyprovider"}"#)
|
||||
.expect_at_least(1)
|
||||
.create_async()
|
||||
.await;
|
||||
@@ -19296,9 +18852,7 @@ mod tests {
|
||||
let _m = server
|
||||
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
|
||||
.with_status(200)
|
||||
.with_body(
|
||||
r#"{"Hash":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4"}"#,
|
||||
)
|
||||
.with_body(r#"{"Hash":"bafyprovider"}"#)
|
||||
.expect_at_least(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
+8
-94
@@ -84,7 +84,14 @@ pub async fn run(args: MirrorArgs) -> Result<()> {
|
||||
let _guard = TmpGuard(tmp_root);
|
||||
|
||||
println!("Cloning source (this may take a while for large repos)...");
|
||||
clone_mirror(&source, &mirror_path)?;
|
||||
let clone_status = Command::new("git")
|
||||
.args(["clone", "--mirror", &source, mirror_path.to_str().unwrap()])
|
||||
.status()
|
||||
.context("failed to run git clone — is git installed?")?;
|
||||
|
||||
if !clone_status.success() {
|
||||
bail!("git clone --mirror failed\nCheck that the source URL is accessible: {source}");
|
||||
}
|
||||
|
||||
// ── 4. Create the repo on Twigpine ─────────────────────────────────────
|
||||
println!("Creating repo on Twigpine node...");
|
||||
@@ -136,25 +143,6 @@ pub async fn run(args: MirrorArgs) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `git clone --mirror <source> <dest>`. The destination is passed as an
|
||||
/// OS-native path so a non-UTF-8 temp dir (e.g. a TMPDIR with non-UTF-8 bytes)
|
||||
/// does not panic on a `to_str()` conversion.
|
||||
fn clone_mirror(source: &str, dest: &std::path::Path) -> Result<()> {
|
||||
// allow-unbounded-git: gl is the client CLI; this clone runs in the user's
|
||||
// terminal with no server-side permit or disconnect to survive. The
|
||||
// bounded-runner rule governs node request handlers. Same spawn as before,
|
||||
// only relocated into this helper.
|
||||
let status = Command::new("git")
|
||||
.args(["clone", "--mirror", source])
|
||||
.arg(dest)
|
||||
.status()
|
||||
.context("failed to run git clone — is git installed?")?;
|
||||
if !status.success() {
|
||||
bail!("git clone --mirror failed\nCheck that the source URL is accessible: {source}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extract the repo name from a git URL.
|
||||
/// - `https://github.com/owner/repo` → `"repo"`
|
||||
/// - `https://github.com/owner/repo.git` → `"repo"`
|
||||
@@ -237,80 +225,6 @@ mod tests {
|
||||
assert_eq!(extract_repo_name("https://example.com/.git"), None);
|
||||
}
|
||||
|
||||
// #417: a non-UTF-8 destination path must reach git as an OS-native arg, not
|
||||
// panic on a `to_str()` conversion.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn test_clone_mirror_non_utf8_dest() {
|
||||
use std::ffi::OsStr;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let src = tmp.path().join("src.git");
|
||||
let st = Command::new("git")
|
||||
.args(["init", "--bare"])
|
||||
.arg(&src)
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(st.success());
|
||||
|
||||
let dest = tmp.path().join(OsStr::from_bytes(b"mirror-\xffdest"));
|
||||
clone_mirror(src.to_str().unwrap(), &dest).unwrap();
|
||||
assert!(dest.join("HEAD").exists());
|
||||
}
|
||||
|
||||
// #417 end-to-end: the real trigger is `std::env::temp_dir()` itself
|
||||
// returning a non-UTF-8 path (a TMPDIR with non-UTF-8 bytes), because the
|
||||
// mirror dest is built from it. Env is process-global, so this test
|
||||
// re-execs the test binary in a child with a non-UTF-8 TMPDIR rather than
|
||||
// mutating this process's env.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn test_clone_mirror_under_non_utf8_tmpdir() {
|
||||
use std::ffi::OsStr;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
|
||||
if std::env::var_os("GL_MIRROR_NONUTF8_TMPDIR").is_none() {
|
||||
// Parent: a source repo under a normal path, a TMPDIR whose name
|
||||
// is not UTF-8, and a child run of this same test under that env.
|
||||
let parent = tempfile::TempDir::new().unwrap();
|
||||
let src = parent.path().join("src.git");
|
||||
let st = Command::new("git")
|
||||
.args(["init", "--bare"])
|
||||
.arg(&src)
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(st.success());
|
||||
let bad_tmp = parent.path().join(OsStr::from_bytes(b"tmp-\xff"));
|
||||
std::fs::create_dir_all(&bad_tmp).unwrap();
|
||||
|
||||
let status = Command::new(std::env::current_exe().unwrap())
|
||||
.args([
|
||||
"mirror::tests::test_clone_mirror_under_non_utf8_tmpdir",
|
||||
"--exact",
|
||||
])
|
||||
.env("GL_MIRROR_NONUTF8_TMPDIR", "1")
|
||||
.env("GL_MIRROR_SRC", &src)
|
||||
.env("TMPDIR", &bad_tmp)
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(status.success(), "child failed under non-UTF-8 TMPDIR");
|
||||
return;
|
||||
}
|
||||
|
||||
// Child: the same path shape run() computes — temp_dir() joined with
|
||||
// the mirror dest. It must be non-UTF-8 or the test proves nothing.
|
||||
let src = std::env::var("GL_MIRROR_SRC").unwrap();
|
||||
let tmp_root = std::env::temp_dir().join("gl-mirror-child");
|
||||
let dest = tmp_root.join("repo");
|
||||
assert!(
|
||||
dest.to_str().is_none(),
|
||||
"fixture broken: dest under a non-UTF-8 TMPDIR must be non-UTF-8"
|
||||
);
|
||||
clone_mirror(&src, &dest).unwrap();
|
||||
assert!(dest.join("HEAD").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_repo_conflict_error() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
@@ -355,13 +355,7 @@ fn submit_answer(
|
||||
/// Returns `None` when stdin isn't a usable interactive source (e.g. an agent),
|
||||
/// so the caller surfaces a clear "couldn't auto-solve" error instead.
|
||||
fn interactive_prompt(challenge: &Challenge) -> Option<String> {
|
||||
use std::io::{stderr, stdin, IsTerminal, Write};
|
||||
// An open but silent stdin (a pipe an orchestrator holds open, an agent
|
||||
// harness) has no one to answer: `read_line` would block forever. Only a
|
||||
// terminal can be interactive.
|
||||
if !stdin().is_terminal() {
|
||||
return None;
|
||||
}
|
||||
use std::io::{stderr, stdin, Write};
|
||||
let mut err = stderr();
|
||||
let _ = writeln!(
|
||||
err,
|
||||
@@ -767,68 +761,4 @@ mod tests {
|
||||
);
|
||||
assert_eq!(non_empty.api_key.as_deref(), Some("secret-bearer"));
|
||||
}
|
||||
|
||||
/// #345: an open-but-silent non-TTY stdin must not hang the prompt. The
|
||||
/// child runs `interactive_prompt` with a held-open, empty pipe as stdin:
|
||||
/// without the terminal check `read_line` blocks forever; with it the
|
||||
/// child returns `None` and exits.
|
||||
#[test]
|
||||
fn interactive_prompt_skips_open_silent_pipe() {
|
||||
const CHILD_ENV: &str = "ICAPTCHA_PROMPT_PIPE_CHILD";
|
||||
if std::env::var_os(CHILD_ENV).is_some() {
|
||||
let ch = Challenge {
|
||||
challenge_id: "c".into(),
|
||||
kind: "arithmetic".into(),
|
||||
difficulty: 1,
|
||||
prompt: "What is 1 + 1?".into(),
|
||||
token: "t".into(),
|
||||
pow: None,
|
||||
};
|
||||
assert!(interactive_prompt(&ch).is_none());
|
||||
println!("prompt-skipped");
|
||||
return;
|
||||
}
|
||||
let exe = std::env::current_exe().expect("current test binary");
|
||||
let mut child = std::process::Command::new(exe)
|
||||
.args([
|
||||
"--exact",
|
||||
"tests::interactive_prompt_skips_open_silent_pipe",
|
||||
"--nocapture",
|
||||
])
|
||||
.env(CHILD_ENV, "1")
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.expect("spawn child test");
|
||||
// The parent keeps the write end open with no data, which is exactly
|
||||
// the shape that hung: an open, silent, non-terminal stdin.
|
||||
let _held_stdin = child.stdin.take();
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
loop {
|
||||
match child.try_wait().expect("poll child") {
|
||||
Some(status) => {
|
||||
assert!(status.success(), "child exited with {status}");
|
||||
let mut out = String::new();
|
||||
std::io::Read::read_to_string(
|
||||
&mut child.stdout.take().expect("child stdout"),
|
||||
&mut out,
|
||||
)
|
||||
.expect("read child stdout");
|
||||
assert!(
|
||||
out.contains("prompt-skipped"),
|
||||
"child test body did not run: {out}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
None if std::time::Instant::now() < deadline => {
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
}
|
||||
None => {
|
||||
let _ = child.kill();
|
||||
panic!("interactive_prompt blocked on an open, silent non-TTY stdin");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,8 +30,8 @@ fn solve_arithmetic(prompt: &str) -> Option<i64> {
|
||||
while let Some(op) = tokens.next() {
|
||||
let n: i64 = tokens.next()?.parse().ok()?;
|
||||
match op {
|
||||
"+" => acc = acc.checked_add(n)?,
|
||||
"-" => acc = acc.checked_sub(n)?,
|
||||
"+" => acc += n,
|
||||
"-" => acc -= n,
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
@@ -45,15 +45,15 @@ fn solve_algebra(prompt: &str) -> Option<i64> {
|
||||
let (lhs, rhs) = eq.split_once('=')?;
|
||||
let (al, cl) = parse_linear(lhs.trim())?;
|
||||
let (ar, cr) = parse_linear(rhs.trim())?;
|
||||
let denom = al.checked_sub(ar)?;
|
||||
let denom = al - ar;
|
||||
if denom == 0 {
|
||||
return None;
|
||||
}
|
||||
let num = cr.checked_sub(cl)?;
|
||||
if num.checked_rem(denom)? != 0 {
|
||||
let num = cr - cl;
|
||||
if num % denom != 0 {
|
||||
return None;
|
||||
}
|
||||
num.checked_div(denom)
|
||||
Some(num / denom)
|
||||
}
|
||||
|
||||
/// Parse a linear expression in `x` into `(coeff_of_x, constant)`.
|
||||
@@ -76,12 +76,12 @@ fn parse_linear(s: &str) -> Option<(i64, i64)> {
|
||||
let m: i64 = it.next()?.parse().ok()?;
|
||||
match op {
|
||||
"+" => (1, m),
|
||||
"-" => (1, m.checked_neg()?),
|
||||
"-" => (1, -m),
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
};
|
||||
return Some((a.checked_mul(coeff_inner)?, a.checked_mul(const_inner)?));
|
||||
return Some((a * coeff_inner, a * const_inner));
|
||||
}
|
||||
|
||||
// Sum of `±`-separated terms.
|
||||
@@ -99,10 +99,10 @@ fn parse_linear(s: &str) -> Option<(i64, i64)> {
|
||||
"-" => -1,
|
||||
_ => cpart.parse().ok()?,
|
||||
};
|
||||
coeff = coeff.checked_add(sign.checked_mul(c)?)?;
|
||||
coeff += sign * c;
|
||||
} else {
|
||||
let n: i64 = t.parse().ok()?;
|
||||
konst = konst.checked_add(sign.checked_mul(n)?)?;
|
||||
konst += sign * n;
|
||||
}
|
||||
sign = 1;
|
||||
}
|
||||
@@ -127,26 +127,23 @@ fn solve_sequence(prompt: &str) -> Option<i64> {
|
||||
fn next_in_sequence(n: &[i64]) -> Option<i64> {
|
||||
let last = *n.last()?;
|
||||
|
||||
// Arithmetic: constant first difference. An overflowing difference means
|
||||
// the pattern doesn't fit, not that the answer wraps.
|
||||
if let Some(d) = n[1].checked_sub(n[0]) {
|
||||
if n.windows(2).all(|w| w[1].checked_sub(w[0]) == Some(d)) {
|
||||
return last.checked_add(d);
|
||||
}
|
||||
// Arithmetic: constant first difference.
|
||||
let d = n[1] - n[0];
|
||||
if n.windows(2).all(|w| w[1] - w[0] == d) {
|
||||
return Some(last + d);
|
||||
}
|
||||
|
||||
// Geometric: constant integer ratio.
|
||||
if n.iter().all(|&v| v != 0) && n[1].checked_rem(n[0]) == Some(0) {
|
||||
if let Some(r) = n[1].checked_div(n[0]) {
|
||||
if r != 0 && n.windows(2).all(|w| w[0].checked_mul(r) == Some(w[1])) {
|
||||
return last.checked_mul(r);
|
||||
}
|
||||
if n.iter().all(|&v| v != 0) && n[0] != 0 && n[1] % n[0] == 0 {
|
||||
let r = n[1] / n[0];
|
||||
if r != 0 && n.windows(2).all(|w| w[1] == w[0] * r) {
|
||||
return Some(last * r);
|
||||
}
|
||||
}
|
||||
|
||||
// Fibonacci-like: each term is the sum of the two before it.
|
||||
if n.len() >= 3 && (2..n.len()).all(|i| n[i - 1].checked_add(n[i - 2]) == Some(n[i])) {
|
||||
return n[n.len() - 1].checked_add(n[n.len() - 2]);
|
||||
if n.len() >= 3 && (2..n.len()).all(|i| n[i] == n[i - 1] + n[i - 2]) {
|
||||
return Some(n[n.len() - 1] + n[n.len() - 2]);
|
||||
}
|
||||
|
||||
// Squares: all perfect squares with consecutive roots.
|
||||
@@ -154,7 +151,7 @@ fn next_in_sequence(n: &[i64]) -> Option<i64> {
|
||||
if let Some(roots) = roots {
|
||||
if roots.windows(2).all(|w| w[1] == w[0] + 1) {
|
||||
let nr = roots[roots.len() - 1] + 1;
|
||||
return nr.checked_mul(nr);
|
||||
return Some(nr * nr);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,16 +160,12 @@ fn next_in_sequence(n: &[i64]) -> Option<i64> {
|
||||
.iter()
|
||||
.enumerate()
|
||||
.all(|(i, &v)| if i % 2 == 0 { v >= 0 } else { v < 0 });
|
||||
let mags: Option<Vec<i64>> = n.iter().map(|v| v.checked_abs()).collect();
|
||||
if signs_alternate {
|
||||
if let Some(mags) = mags {
|
||||
if let Some(md) = mags[1].checked_sub(mags[0]) {
|
||||
if mags.windows(2).all(|w| w[1].checked_sub(w[0]) == Some(md)) {
|
||||
let next_sign: i64 = if last >= 0 { -1 } else { 1 };
|
||||
return next_sign.checked_mul(mags[mags.len() - 1].checked_add(md)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mags: Vec<i64> = n.iter().map(|v| v.abs()).collect();
|
||||
let md = mags[1] - mags[0];
|
||||
if signs_alternate && mags.windows(2).all(|w| w[1] - w[0] == md) {
|
||||
let next_mag = mags[mags.len() - 1] + md;
|
||||
let next_sign = if last >= 0 { -1 } else { 1 };
|
||||
return Some(next_sign * next_mag);
|
||||
}
|
||||
|
||||
None
|
||||
@@ -186,7 +179,7 @@ fn isqrt_exact(v: i64) -> Option<i64> {
|
||||
let r = (v as f64).sqrt().round() as i64;
|
||||
[r - 1, r, r + 1]
|
||||
.into_iter()
|
||||
.find(|&cand| cand >= 0 && cand.checked_mul(cand) == Some(v))
|
||||
.find(|&cand| cand >= 0 && cand * cand == v)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -274,80 +267,4 @@ mod tests {
|
||||
assert_eq!(solve("anagram", "Unscramble: tca"), None);
|
||||
assert_eq!(solve("riddle", "What has keys but no locks?"), None);
|
||||
}
|
||||
|
||||
// #345: prompts are attacker/service-controlled and every literal can be a
|
||||
// valid i64 while the evaluation still overflows. Overflow must return
|
||||
// None (unsolvable), never a debug panic or a wrapped wrong answer.
|
||||
|
||||
#[test]
|
||||
fn arithmetic_overflow_returns_none() {
|
||||
assert_eq!(
|
||||
solve("arithmetic", "What is 9223372036854775807 + 1?"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
solve("arithmetic", "What is -9223372036854775808 - 1?"),
|
||||
None
|
||||
);
|
||||
// Boundary-adjacent values still solve.
|
||||
assert_eq!(
|
||||
solve("arithmetic", "What is 9223372036854775806 + 1?").as_deref(),
|
||||
Some("9223372036854775807")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn algebra_overflow_returns_none() {
|
||||
// num = i64::MIN, denom = -1: the quotient overflows.
|
||||
assert_eq!(
|
||||
solve("algebra", "Solve for x: x + 1 = 2x + -9223372036854775807"),
|
||||
None
|
||||
);
|
||||
// sign * coefficient overflows at i64::MIN.
|
||||
assert_eq!(
|
||||
solve("algebra", "Solve for x: x - -9223372036854775808x = 1"),
|
||||
None
|
||||
);
|
||||
// A large but solvable equation still solves.
|
||||
assert_eq!(
|
||||
solve("algebra", "Solve for x: 4611686018427387904x + 0 = 0").as_deref(),
|
||||
Some("0")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequence_overflow_returns_none() {
|
||||
// First difference overflows (1 - i64::MIN).
|
||||
assert_eq!(
|
||||
solve(
|
||||
"sequence",
|
||||
"What is the next number in this sequence? -9223372036854775808, 1, 9223372036854775806, ?"
|
||||
),
|
||||
None
|
||||
);
|
||||
// i64::MIN in an alternating-sign candidate: abs() overflows.
|
||||
assert_eq!(
|
||||
solve(
|
||||
"sequence",
|
||||
"What is the next number in this sequence? 1, -9223372036854775808, 3, ?"
|
||||
),
|
||||
None
|
||||
);
|
||||
// Perfect-square check near i64::MAX: candidate root squared overflows.
|
||||
assert_eq!(
|
||||
solve(
|
||||
"sequence",
|
||||
"What is the next number in this sequence? 9223372036854775807, 9223372036854775800, 9223372036854775801, ?"
|
||||
),
|
||||
None
|
||||
);
|
||||
// Geometric next term overflows (r = 2, last * 2 > i64::MAX).
|
||||
assert_eq!(
|
||||
solve(
|
||||
"sequence",
|
||||
"What is the next number in this sequence? 2305843009213693951, 4611686018427387902, 9223372036854775804, ?"
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
//! #345 end-to-end: `obtain_proof` must not block or crash when stdin is an
|
||||
//! open, silent non-TTY and the challenge can't be solved deterministically.
|
||||
//!
|
||||
//! The unit seam can't fake a process stdin, so the parent spawns this test
|
||||
//! binary with a held-open pipe as stdin and the child runs the real
|
||||
//! `obtain_proof` against a mocked iCaptcha service over HTTP. Before the fix
|
||||
//! the child blocked in `read_line` forever (unsolvable type) or panicked /
|
||||
//! submitted a wrapped answer (overflowing arithmetic). After the fix it
|
||||
//! exits promptly with the "cannot solve" error.
|
||||
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use icaptcha_client::{obtain_proof, IcaptchaCfg};
|
||||
|
||||
const CHILD_ENV: &str = "ICAPTCHA_STDIN_E2E_CHILD";
|
||||
const URL_ENV: &str = "ICAPTCHA_STDIN_E2E_URL";
|
||||
const CHILD_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// Child entry point: fetch the challenge from the mock service and run the
|
||||
/// full solve loop. Both scenarios must reach the same clean error.
|
||||
fn run_child() {
|
||||
let url = std::env::var(URL_ENV).expect("child needs the mock url");
|
||||
let cfg = IcaptchaCfg {
|
||||
url,
|
||||
did: "did:key:zTEST".to_string(),
|
||||
level: 1,
|
||||
api_key: None,
|
||||
};
|
||||
let err = obtain_proof(&cfg, None).expect_err("the challenge is unsolvable");
|
||||
assert!(
|
||||
err.to_string().contains("cannot solve iCaptcha challenge"),
|
||||
"expected the unsolvable-challenge error, got: {err}"
|
||||
);
|
||||
println!("child-finished-cleanly");
|
||||
}
|
||||
|
||||
/// Spawn the child with a held-open, empty pipe as stdin, wait with a
|
||||
/// deadline, and assert it printed the clean-exit marker.
|
||||
fn run_scenario(prompt_type: &str, prompt: &str, expect_answer_calls: usize) {
|
||||
let mut server = mockito::Server::new();
|
||||
let challenge = server
|
||||
.mock("POST", "/v1/challenge")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(format!(
|
||||
r#"{{"challengeId":"c1","type":"{prompt_type}","difficulty":1,"prompt":"{prompt}","token":"tok-1"}}"#
|
||||
))
|
||||
.expect(1)
|
||||
.create();
|
||||
// No answer may be submitted: an unsolvable prompt must produce no
|
||||
// request to /v1/answer (a wrapped wrong answer would still hit it).
|
||||
let answer = server
|
||||
.mock("POST", "/v1/answer")
|
||||
.expect(expect_answer_calls)
|
||||
.create();
|
||||
|
||||
let exe = std::env::current_exe().expect("current test binary");
|
||||
let mut cmd = Command::new(exe);
|
||||
cmd.args([
|
||||
"--exact",
|
||||
"obtain_proof_does_not_block_on_silent_non_tty_stdin",
|
||||
"--nocapture",
|
||||
])
|
||||
.env(CHILD_ENV, "1")
|
||||
.env(URL_ENV, server.url())
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
// The solver client honors proxy env; force direct connections so the
|
||||
// loopback mock is always reachable regardless of the runner's env.
|
||||
for var in [
|
||||
"HTTP_PROXY",
|
||||
"http_proxy",
|
||||
"HTTPS_PROXY",
|
||||
"https_proxy",
|
||||
"ALL_PROXY",
|
||||
"all_proxy",
|
||||
"REQUEST_METHOD",
|
||||
] {
|
||||
cmd.env_remove(var);
|
||||
}
|
||||
cmd.env("NO_PROXY", "*");
|
||||
let mut child = cmd.spawn().expect("spawn child");
|
||||
|
||||
// Hold the write end open with no data: exactly the shape that hung.
|
||||
let _held_stdin = child.stdin.take();
|
||||
|
||||
let deadline = Instant::now() + CHILD_TIMEOUT;
|
||||
loop {
|
||||
match child.try_wait().expect("poll child") {
|
||||
Some(_) => break,
|
||||
None if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(50)),
|
||||
None => {
|
||||
let _ = child.kill();
|
||||
panic!("obtain_proof blocked on an open, silent non-TTY stdin");
|
||||
}
|
||||
}
|
||||
}
|
||||
let out = child.wait_with_output().expect("collect child output");
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"child failed: status {:?}\nstdout: {stdout}\nstderr: {stderr}",
|
||||
out.status.code()
|
||||
);
|
||||
assert!(
|
||||
stdout.contains("child-finished-cleanly"),
|
||||
"child body did not run to completion: {stdout}"
|
||||
);
|
||||
assert!(
|
||||
!stderr.contains("panicked"),
|
||||
"child panicked instead of returning a clean error: {stderr}"
|
||||
);
|
||||
challenge.assert();
|
||||
answer.assert();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn obtain_proof_does_not_block_on_silent_non_tty_stdin() {
|
||||
if std::env::var_os(CHILD_ENV).is_some() {
|
||||
run_child();
|
||||
return;
|
||||
}
|
||||
|
||||
// An unsolvable challenge type falls through to the interactive prompt,
|
||||
// which must decline the non-TTY stdin instead of blocking on read_line.
|
||||
run_scenario("anagram", "listen", 0);
|
||||
|
||||
// An arithmetic prompt whose evaluation overflows i64 must also end at
|
||||
// the unsolvable error: no panic, and no wrapped answer submitted.
|
||||
run_scenario("arithmetic", "What is 9223372036854775807 + 1?", 0);
|
||||
}
|
||||
@@ -95,7 +95,7 @@ Live-network blockers to prioritize:
|
||||
- GraphQL POST is still open for compatibility; GraphQL mutations should get mutation-aware auth before it becomes a public write API surface.
|
||||
- Push authorization is still not capability-complete. A valid DID signature is authentication, not authorization. Owner checks are now enforced on every branch, protected or not (`GITLAWB_ENFORCE_OWNER_PUSH`, on by default); what remains is that a UCAN `git/push` capability is not yet honored, so a delegated or CI key cannot push.
|
||||
- UCAN chain validation is incomplete and UCAN revocation/blocklisting is not implemented as an operator feature.
|
||||
- Private repository reads are enforced per repository through `is_public` and path-scoped visibility rules. `GITLAWB_PUBLIC_READ` remains reserved and inert.
|
||||
- Private repository reads are not enforced. `is_public` and `GITLAWB_PUBLIC_READ` exist, but per-repository private-read behavior is not wired.
|
||||
- Peer URLs are self-asserted by DIDs. Signatures prove control of the DID key when present, not ownership/safety of the announced URL.
|
||||
- Outbound peer fetch/ping/sync paths should be reviewed for SSRF protections before accepting arbitrary public peer registrations.
|
||||
|
||||
@@ -146,7 +146,7 @@ Risks:
|
||||
## Obvious live-network priorities
|
||||
|
||||
1. Implement repo write authorization: owner checks, UCAN capability checks, and clear delegation semantics for push/PR/issue/bounty operations.
|
||||
2. Private reads are already enforced per repository (`is_public` plus path-scoped rules); retire the reserved and inert `GITLAWB_PUBLIC_READ` flag or document it as permanently reserved.
|
||||
2. Implement private-read enforcement or remove private repo affordances until it exists.
|
||||
3. Add UCAN revocation/blocklisting and operator docs for emergency key compromise.
|
||||
4. Harden peer registration and outbound fetch behavior against SSRF and peer-list poisoning.
|
||||
5. Add Docker/installer/release smoke tests to CI.
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { createData, EthereumSigner } from "arbundles";
|
||||
import { createHash } from "node:crypto";
|
||||
import base64url from "base64url";
|
||||
|
||||
const data = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+-=[]{};':\",./<>?`~";
|
||||
const tags = [
|
||||
{ name: "tag1", value: "value1" },
|
||||
{ name: "tag2", value: "value2" },
|
||||
];
|
||||
const anchor = "thisSentenceIs32BytesLongTrustMe";
|
||||
const target = "OXcT1sVRSA5eGwt2k6Yuz8-3e3g9WJi5uSE99CWqsBs";
|
||||
const signer = new EthereumSigner("8da4ef21b864d2cc526dbdb2a120bd2874c36c9d0a1fb7f8c63d7f7a8b41de8f");
|
||||
|
||||
const item = createData(data, signer, { anchor, target, tags });
|
||||
// Sign so the captured signature is real: `createData` alone leaves a
|
||||
// zeroed placeholder, whose id is just sha256(zeros).
|
||||
await item.sign(signer);
|
||||
const raw = item.getRaw();
|
||||
const id = item.id;
|
||||
|
||||
// `item.signature` is base64url(rawSignature). Decode and sha256.
|
||||
const sigBytes = base64url.toBuffer(item.signature);
|
||||
const idCheck = base64url.encode(createHash("sha256").update(sigBytes).digest());
|
||||
if (idCheck !== id) {
|
||||
console.error(`MISMATCH: id=${id} sha256(sig)=${idCheck}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("id =", id);
|
||||
console.log("signature_b64=", item.signature);
|
||||
console.log("signature_len=", sigBytes.length);
|
||||
console.log("binary_len =", raw.length);
|
||||
console.log("binary_hex =", raw.toString("hex"));
|
||||
@@ -1,40 +0,0 @@
|
||||
import { createData, SolanaSigner } from "arbundles";
|
||||
import bs58 from "bs58";
|
||||
import { getPublicKey } from "@noble/ed25519";
|
||||
import { createHash } from "node:crypto";
|
||||
import base64url from "base64url";
|
||||
|
||||
// Deterministic Ed25519 keypair from seed 0x01 * 32.
|
||||
const seed = Buffer.alloc(32, 0x01);
|
||||
const pub = await getPublicKey(seed);
|
||||
const secret64 = Buffer.concat([seed, Buffer.from(pub)]);
|
||||
const signer = new SolanaSigner(bs58.encode(secret64));
|
||||
|
||||
const data = "hello gitlawb ed25519 golden";
|
||||
const tags = [
|
||||
{ name: "App-Name", value: "gitlawb" },
|
||||
{ name: "Schema", value: "gitlawb/ref-update/v1" },
|
||||
];
|
||||
|
||||
const item = createData(data, signer, { tags });
|
||||
await item.sign(signer);
|
||||
const raw = Buffer.from(item.getRaw());
|
||||
const id = item.id;
|
||||
const sigBytes = base64url.toBuffer(item.signature);
|
||||
const idCheck = base64url.encode(createHash("sha256").update(sigBytes).digest());
|
||||
if (idCheck !== id) {
|
||||
console.error(`MISMATCH: id=${id} sha256(sig)=${idCheck}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const sigData = await item.getSignatureData();
|
||||
console.log("id =", id);
|
||||
console.log("signature_b64=", item.signature);
|
||||
console.log("signature_len=", sigBytes.length);
|
||||
console.log("owner_b64 =", item.owner);
|
||||
console.log("owner_len =", base64url.toBuffer(item.owner).length);
|
||||
console.log("sigtype =", item.signatureType);
|
||||
console.log("binary_len =", raw.length);
|
||||
console.log("binary_hex =", raw.toString("hex"));
|
||||
console.log("deephash_hex =", Buffer.from(sigData).toString("hex"));
|
||||
console.log("data_b64 =", base64url.encode(Buffer.from(data)));
|
||||
console.log("isValid =", await item.isValid());
|
||||
@@ -1,39 +0,0 @@
|
||||
ANS-104 golden vectors, captured from arbundles 0.10.x.
|
||||
|
||||
To regenerate: run `npm ci` in scripts/, then `node ans104_golden_ed25519.mjs`.
|
||||
|
||||
Ed25519 vector (the interop pin used by
|
||||
`ans104::tests::dataitem_matches_arbundles_golden_vector`):
|
||||
captured by = scripts/ans104_golden_ed25519.mjs (deterministic seed
|
||||
0x01 * 32 via SolanaSigner, sigtype 2, `await item.sign`)
|
||||
data = "hello gitlawb ed25519 golden"
|
||||
tags = [{name:"App-Name",value:"gitlawb"},
|
||||
{name:"Schema",value:"gitlawb/ref-update/v1"}]
|
||||
target = absent
|
||||
anchor = absent
|
||||
|
||||
Outputs:
|
||||
signature_type = 2 (Ed25519)
|
||||
signature_len = 64 bytes
|
||||
owner_len = 32 bytes
|
||||
binary_len = 192 bytes
|
||||
id = SGrcBs-ITTyzvd7eIB5kk2GdBWoxB_iTi9iJ6KOe_RE (base64url of sha256(signature))
|
||||
deephash_hex = f15c82431767f14ac9e66ab8e995a8cd08e094be3773245163b53c12feb50aefc55d9f8c1098fabcfbf11a462706d347
|
||||
(arbundles `getSignatureData` 8-element fold)
|
||||
|
||||
binary_hex:
|
||||
0200da41825fd44ca3b2705af18fce86ed6d04d0204331965d9af5d5cb1a740fcc6587ee81501b1d7928c54c0f174fde8893560d785db4d988a3161113b10a2028038a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c00000200000000000000300000000000000004104170702d4e616d650e6769746c6177620c536368656d612a6769746c6177622f7265662d7570646174652f76310068656c6c6f206769746c617762206564323535313920676f6c64656e
|
||||
|
||||
The Rust side will:
|
||||
1. Parse binary via DataItem::from_binary (signature_type, signature,
|
||||
owner, target, anchor, tags, data).
|
||||
2. Compute deep_hash via DataItem::deep_hash (the 8-element arbundles
|
||||
fold) and compare to deephash_hex above.
|
||||
3. Verify the Ed25519 signature via DataItem::verify_data_item WITHOUT
|
||||
re-signing.
|
||||
4. Assert sha256(signature_bytes) base64url-encodes to the captured id.
|
||||
5. Assert to_binary round-trips byte-exact (signature slot preserved).
|
||||
|
||||
Legacy Ethereum script (scripts/ans104_golden.mjs) now signs before
|
||||
capture as well; its output is illustrative only — the node verifies
|
||||
Ed25519 (sigtype 2) on the verify path.
|
||||
Generated
-1624
File diff suppressed because it is too large.
Load diff
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"name": "ans104-golden",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@noble/ed25519": "^1.7.5",
|
||||
"arbundles": "^0.10.0",
|
||||
"base64url": "^3.0.1",
|
||||
"bs58": "^4.0.1"
|
||||
},
|
||||
"overrides": {
|
||||
"ws": "^8.21.0"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
shopt -s extglob
|
||||
|
||||
tag="${1-}"
|
||||
: "${GITHUB_OUTPUT:?GITHUB_OUTPUT must be set}"
|
||||
@@ -12,79 +11,13 @@ case "$tag" in
|
||||
;;
|
||||
esac
|
||||
|
||||
# Right-anchored on purpose: v[0-9]* leaves the tail unbounded and accepts
|
||||
# v1.2.3.4.5, v1_x, v1-any-branch-name, and bare v1. Every existing tag is
|
||||
# plain vX.Y.Z (release-please, release-type: simple, no prerelease channel).
|
||||
case "$tag" in
|
||||
v+([0-9]).+([0-9]).+([0-9])) ;;
|
||||
v[0-9]*) ;;
|
||||
*)
|
||||
printf '%s\n' "::error::'$tag' does not look like a release tag (vX.Y.Z)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Provenance, enforced only in CI where a token is present. The environment
|
||||
# gate restricts which REF a dispatch may run from; this restricts which TAG
|
||||
# the input may name: it must be a tag the release flow already published,
|
||||
# pointing at a commit main already contains. Otherwise a write-access user
|
||||
# could plant a v99.99.99 tag on unreviewed content and backfill-publish it.
|
||||
if [ -n "${GH_TOKEN:-}" ] && [ -n "${GITHUB_REPOSITORY:-}" ]; then
|
||||
release_meta="$(gh release view "$tag" --repo "$GITHUB_REPOSITORY" \
|
||||
--json author,targetCommitish -q '.author.login + " " + .targetCommitish' \
|
||||
2>/dev/null)" || {
|
||||
printf '%s\n' "::error::no GitHub release exists for tag $tag"
|
||||
exit 1
|
||||
}
|
||||
release_author="${release_meta%% *}"
|
||||
release_target="${release_meta##* }"
|
||||
# A hand-created release on a valid tag does not qualify. Not a hard bound
|
||||
# (a workflow run can mint a bot-authored release), but it removes the
|
||||
# cheapest path to publishing attacker-uploaded release assets.
|
||||
if [ "$release_author" != "github-actions[bot]" ]; then
|
||||
printf '%s\n' "::error::release $tag was authored by $release_author, not the release automation"
|
||||
exit 1
|
||||
fi
|
||||
# Same boundary for the assets the npm backfill republishes: a release
|
||||
# asset replaced by a collaborator login is attacker-mutable content. The
|
||||
# captured name/id pairs go to GITHUB_OUTPUT so the download step fetches
|
||||
# by immutable asset id; a delete+reupload between here and the download
|
||||
# produces a new id and fails closed. Names are emitted base64-encoded:
|
||||
# an asset name is attacker-controlled text, and whitespace in it would
|
||||
# otherwise let a crafted name shift the positional fields.
|
||||
assets="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$tag" \
|
||||
-q '.assets[] | (.name | @base64) + " " + (.id | tostring) + " " + (.uploader.login // "unknown")')"
|
||||
uploaders="$(printf '%s\n' "$assets" | awk 'NF {print $3}' | sort -u)"
|
||||
if [ -n "$uploaders" ] && [ "$uploaders" != "github-actions[bot]" ]; then
|
||||
printf '%s\n' "::error::release $tag has assets not uploaded by the release automation: $uploaders"
|
||||
exit 1
|
||||
fi
|
||||
{
|
||||
printf '%s\n' 'assets<<GHAE'
|
||||
printf '%s\n' "$assets" | awk 'NF {print $1, $2}'
|
||||
printf '%s\n' 'GHAE'
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
# Resolve through the fully-qualified tag ref to a commit SHA: an
|
||||
# unqualified name can resolve to a same-named branch and vouch for the
|
||||
# wrong commit, the same shadowing the refs/tags/ checkout prefix avoids.
|
||||
# The SHA is emitted so checkouts pin the verified commit instead of
|
||||
# re-resolving a tag that could be moved between resolve and checkout.
|
||||
tag_commit="$(gh api "repos/$GITHUB_REPOSITORY/commits/refs/tags/$tag" -q .sha)"
|
||||
# The tag must still point at the commit the release was created against.
|
||||
# Reachable-from-main alone accepts a tag moved to any other main commit.
|
||||
if [ "$tag_commit" != "$release_target" ]; then
|
||||
printf '%s\n' "::error::tag $tag points at $tag_commit but release $tag was created against $release_target (tag moved)"
|
||||
exit 1
|
||||
fi
|
||||
status="$(gh api "repos/$GITHUB_REPOSITORY/compare/main...$tag_commit" -q .status)"
|
||||
case "$status" in
|
||||
identical|behind) ;;
|
||||
*)
|
||||
printf '%s\n' "::error::tag $tag is not reachable from main (compare: $status)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
printf '%s\n' "tag_commit=$tag_commit" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
printf '%s\n' "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
printf '%s\n' "version=${tag#v}" >> "$GITHUB_OUTPUT"
|
||||
@@ -1,297 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// Case matrix for the inline-test detector embedded in
|
||||
// .github/workflows/pr-triage.yml. The workflow runs on pull_request_target
|
||||
// and deliberately never checks out PR code, so the detector cannot be tested
|
||||
// where it runs; this script extracts the fenced TRIAGE_DETECTOR block from
|
||||
// the committed workflow body and exercises it here, where pr-checks.yml DOES
|
||||
// check out the proposed workflow. If the fence markers move or the block
|
||||
// stops being self-contained, this script fails loudly rather than testing a
|
||||
// stale copy.
|
||||
//
|
||||
// The matrix encodes the review contract for the detector
|
||||
// (Gitlawb/node#277): legal Rust separators between the attribute path and
|
||||
// its ]/( delimiter must be accepted (line comments, nested block comments,
|
||||
// splits onto immediately following added lines), while unrelated patch
|
||||
// records - delimiter-looking lines before the path, later in the hunk, or in
|
||||
// another hunk - must never complete a path they do not adjoin. False
|
||||
// positives here SUPPRESS the needs-tests label silently (they set
|
||||
// touchedTests, which clears the label), while false negatives apply it
|
||||
// (the loud, corrigible direction). So every uncertain path in the detector
|
||||
// is required to answer "no inline test".
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const workflow = readFileSync(
|
||||
join(repoRoot, ".github/workflows/pr-triage.yml"),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const BEGIN = "// TRIAGE_DETECTOR_BEGIN";
|
||||
const END = "// TRIAGE_DETECTOR_END";
|
||||
const begin = workflow.indexOf(BEGIN);
|
||||
const end = workflow.indexOf(END);
|
||||
if (begin === -1 || end === -1 || end <= begin) {
|
||||
console.error("FAIL: TRIAGE_DETECTOR fence not found in pr-triage.yml");
|
||||
process.exit(1);
|
||||
}
|
||||
const block = workflow.slice(begin + BEGIN.length, end);
|
||||
|
||||
let patchAddsInlineTest;
|
||||
try {
|
||||
const factory = new Function(`${block}\nreturn patchAddsInlineTest;`);
|
||||
patchAddsInlineTest = factory();
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"FAIL: fenced detector block is not self-contained JavaScript:",
|
||||
err.message
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Each patch is the `patch` field GitHub's listFiles API returns: hunk
|
||||
// headers plus +/-/space-prefixed lines, no ---/+++ file headers.
|
||||
const cases = [
|
||||
// ── Accepted spellings ────────────────────────────────────────────────
|
||||
["bare same-line", "@@ -1,0 +1,2 @@\n+#[test]\n+fn a() {}", true],
|
||||
["cfg(test)", "@@ -1,0 +1,1 @@\n+#[cfg(test)]", true],
|
||||
["indented with inner space", "@@ -1,0 +1,1 @@\n+ #[ test ]", true],
|
||||
[
|
||||
"namespaced with args",
|
||||
'@@ -1,0 +1,1 @@\n+#[tokio::test(flavor = "multi_thread")]',
|
||||
true,
|
||||
],
|
||||
["test_case harness", "@@ -1,0 +1,1 @@\n+#[test_case(1)]", true],
|
||||
["wasm_bindgen_test harness", "@@ -1,0 +1,1 @@\n+#[wasm_bindgen_test]", true],
|
||||
["raw identifier", "@@ -1,0 +1,1 @@\n+#[r#test]", true],
|
||||
[
|
||||
"line comment then closer on next added line",
|
||||
"@@ -1,0 +1,2 @@\n+#[test // rationale\n+]",
|
||||
true,
|
||||
],
|
||||
[
|
||||
"nested block comment, same line",
|
||||
"@@ -1,0 +1,1 @@\n+#[test /* outer /* inner */ outer */]",
|
||||
true,
|
||||
],
|
||||
[
|
||||
"block comment spanning added lines",
|
||||
"@@ -1,0 +1,3 @@\n+#[test /* why\n+ still why */ ]\n+fn a() {}",
|
||||
true,
|
||||
],
|
||||
[
|
||||
"path-only line, ( on the immediately following added line",
|
||||
"@@ -1,0 +1,2 @@\n+#[test_case\n+(1)]",
|
||||
true,
|
||||
],
|
||||
[
|
||||
"whitespace-only continuation before closer",
|
||||
"@@ -1,0 +1,3 @@\n+#[test\n+\t\n+]",
|
||||
true,
|
||||
],
|
||||
// ── Rejected spellings and adversarial shapes ─────────────────────────
|
||||
["rstest stays excluded", "@@ -1,0 +1,1 @@\n+#[rstest]", false],
|
||||
["substring #[testable]", "@@ -1,0 +1,1 @@\n+#[testable]", false],
|
||||
["substring #[contest]", "@@ -1,0 +1,1 @@\n+#[contest]", false],
|
||||
[
|
||||
"delimiter-looking line BEFORE the path",
|
||||
"@@ -1,0 +1,2 @@\n+(\n+#[test_case",
|
||||
false,
|
||||
],
|
||||
[
|
||||
"raw-string fixture path + unrelated ( later in the same hunk",
|
||||
'@@ -1,0 +1,5 @@\n+let s = r#"\n+#[test_case\n+not a separator token\n+"#;\n+let t = (1);',
|
||||
false,
|
||||
],
|
||||
[
|
||||
"path at end of one hunk, closer in another hunk",
|
||||
"@@ -1,0 +1,1 @@\n+#[test_case\n@@ -10,0 +11,1 @@\n+(1)]",
|
||||
false,
|
||||
],
|
||||
[
|
||||
"closer only on a context line",
|
||||
"@@ -1,1 +1,1 @@\n+#[test_case\n (1)]",
|
||||
false,
|
||||
],
|
||||
[
|
||||
"closer only on a removed line",
|
||||
"@@ -1,1 +1,1 @@\n+#[test_case\n-(1)]",
|
||||
false,
|
||||
],
|
||||
[
|
||||
"unfinished block comment never closes",
|
||||
"@@ -1,0 +1,2 @@\n+#[test /*\n+ still open",
|
||||
false,
|
||||
],
|
||||
[
|
||||
"continuation bound exceeded stays loud",
|
||||
"@@ -1,0 +1,40 @@\n+#[test /*\n" + "+ filler\n".repeat(30) + "+ */ ]",
|
||||
false,
|
||||
],
|
||||
[
|
||||
"raw-string fixture with adjoining closer",
|
||||
'@@ -1,0 +1,4 @@\n+const FIXTURE: &str = r#"\n+#[test_case\n+(1)]\n+"#;',
|
||||
false,
|
||||
],
|
||||
[
|
||||
"block comment with adjoining closer",
|
||||
'@@ -1,0 +1,4 @@\n+/* this is a comment\n+#[test_case\n+(1)]\n+end of comment */',
|
||||
false,
|
||||
],
|
||||
[
|
||||
"raw string with no hash delimiters",
|
||||
'@@ -1,0 +1,4 @@\n+let s = r"\n+#[test_case\n+(1)]\n+";',
|
||||
false,
|
||||
],
|
||||
[
|
||||
"raw string closes then real test attribute on next line",
|
||||
'@@ -1,0 +1,2 @@\n+let s = r#""#;\n+#[test_case(1)]',
|
||||
true,
|
||||
],
|
||||
[
|
||||
"block comment closes then real test attribute on next line",
|
||||
'@@ -1,0 +1,2 @@\n+/* c */\n+#[test]',
|
||||
true,
|
||||
],
|
||||
[
|
||||
"multiline regular string with test attribute inside",
|
||||
'@@ -1,0 +1,3 @@\n+const S: &str = "\n+#[test]\n+";',
|
||||
false,
|
||||
],
|
||||
[
|
||||
"regular string closes then real test on next line",
|
||||
'@@ -1,0 +1,2 @@\n+let s = "text";\n+#[test]',
|
||||
true,
|
||||
],
|
||||
[
|
||||
"char literal with double quote then real test",
|
||||
"@@ -1,0 +1,4 @@\n+const QUOTE: char = '\"';\n+#[test]\n+fn real_test() {\n+ assert_eq!(QUOTE as u32, 34);\n+}",
|
||||
true,
|
||||
],
|
||||
[
|
||||
"byte char literal with double quote then real test",
|
||||
"@@ -1,0 +1,4 @@\n+const QUOTE: u8 = b'\"';\n+#[test]\n+fn real_test() {\n+ assert_eq!(QUOTE, 34);\n+}",
|
||||
true,
|
||||
],
|
||||
[
|
||||
"char literal with escaped quote then real test",
|
||||
"@@ -1,0 +1,4 @@\n+const Q: char = '\\'';\n+#[test]\n+fn real_test() {\n+ assert_eq!(Q as u32, 39);\n+}",
|
||||
true,
|
||||
],
|
||||
[
|
||||
"char literal with escaped backslash then real test",
|
||||
"@@ -1,0 +1,4 @@\n+const Q: char = '\\\\';\n+#[test]\n+fn real_test() {\n+ assert_eq!(Q as u32, 92);\n+}",
|
||||
true,
|
||||
],
|
||||
[
|
||||
"char literal with unicode escape then real test",
|
||||
"@@ -1,0 +1,4 @@\n+const Q: char = '\\u{2764}';\n+#[test]\n+fn real_test() {\n+ assert_eq!(Q as u32, 10084);\n+}",
|
||||
true,
|
||||
],
|
||||
[
|
||||
"lifetime in type then real test",
|
||||
"@@ -1,0 +1,3 @@\n+fn foo<'a>(x: &'a str) {}\n+#[test]\n+fn real_test() {}",
|
||||
true,
|
||||
],
|
||||
[
|
||||
"label then real test",
|
||||
"@@ -1,0 +1,3 @@\n+'label: loop {}\n+#[test]\n+fn real_test() {}",
|
||||
true,
|
||||
],
|
||||
[
|
||||
"static lifetime then real test",
|
||||
"@@ -1,0 +1,3 @@\n+const S: &'static str = \"hi\";\n+#[test]\n+fn real_test() {}",
|
||||
true,
|
||||
],
|
||||
[
|
||||
"context line with stray closing quote then real test",
|
||||
'@@ -1,1 +1,2 @@\n );"\n+#[test]',
|
||||
true,
|
||||
],
|
||||
[
|
||||
"context line with stray opening quote then real test",
|
||||
'@@ -1,1 +1,2 @@\n let s = "\n+#[test]',
|
||||
true,
|
||||
],
|
||||
[
|
||||
"r# inside string on context line then real test",
|
||||
'@@ -1,1 +1,2 @@\n let s = "r#";\n+#[test]',
|
||||
true,
|
||||
],
|
||||
[
|
||||
"/* inside string on context line then real test",
|
||||
'@@ -1,1 +1,2 @@\n let s = "/*";\n+#[test]',
|
||||
true,
|
||||
],
|
||||
// Known limitation: a non-code region opened by a context line is invisible
|
||||
// to the pre-pass (context lines are not scanned). An added #[test] inside
|
||||
// an existing raw string or block comment is scanned as code and may match.
|
||||
// This is a false positive (silent direction), accepted because the old
|
||||
// detector had the same behavior, the pattern is rare, and needs-tests is
|
||||
// advisory. Scanning context lines to close this gap would re-introduce a
|
||||
// false negative on the far more common case of a context line with a stray
|
||||
// " from a string that opened before the visible window.
|
||||
[
|
||||
"known limitation: #[test] inside context-opened raw string (FP)",
|
||||
'@@ -1,1 +1,2 @@\n const FIX: &str = r#"\n+#[test]\n "#;',
|
||||
true,
|
||||
],
|
||||
[
|
||||
"known limitation: #[test_case] inside context-opened raw string (FP)",
|
||||
'@@ -1,1 +1,3 @@\n const FIX: &str = r#"\n+#[test_case\n+(1)]\n "#;',
|
||||
true,
|
||||
],
|
||||
[
|
||||
"known limitation: #[test] inside context-opened block comment (FP)",
|
||||
'@@ -1,1 +1,2 @@\n /*\n+#[test]\n */',
|
||||
true,
|
||||
],
|
||||
];
|
||||
|
||||
let failures = 0;
|
||||
for (const [name, patch, expected] of cases) {
|
||||
const got = patchAddsInlineTest(patch);
|
||||
if (got !== expected) {
|
||||
failures += 1;
|
||||
console.error(`FAIL: ${name}: expected ${expected}, got ${got}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Runtime probe: the detector walks fork-controlled input on
|
||||
// pull_request_target, so a pathological head must not stall the job. The
|
||||
// long `_a` run is the historical exponential-backtracking shape for the
|
||||
// attribute-path regex; the comment run exercises the scanner loop.
|
||||
const probes = [
|
||||
["long _a run", "@@ -1,0 +1,1 @@\n+#[test" + "_a".repeat(30000), false],
|
||||
[
|
||||
"long unclosed comment line",
|
||||
"@@ -1,0 +1,1 @@\n+#[test /*" + " *".repeat(30000),
|
||||
false,
|
||||
],
|
||||
[
|
||||
"long malformed \\u{ escape run (quadratic guard)",
|
||||
"@@ -1,0 +1,1 @@\n+" + "'\\u{".repeat(30000),
|
||||
false,
|
||||
],
|
||||
];
|
||||
for (const [name, patch, expected] of probes) {
|
||||
const t0 = process.hrtime.bigint();
|
||||
const got = patchAddsInlineTest(patch);
|
||||
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
|
||||
if (got !== expected) {
|
||||
failures += 1;
|
||||
console.error(`FAIL: probe ${name}: expected ${expected}, got ${got}`);
|
||||
}
|
||||
if (ms > 1000) {
|
||||
failures += 1;
|
||||
console.error(`FAIL: probe ${name}: took ${ms.toFixed(0)}ms (>1000ms)`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures) {
|
||||
console.error(`${failures} failure(s) across ${cases.length + probes.length} cases`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`ok: ${cases.length + probes.length} detector cases passed`);
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user