diff --git a/.gitignore b/.gitignore index fe5606e9..bec89f9f 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,7 @@ docs/_build/ # Scratch dir of scripts/local-feeds/run.sh .local-feeds-test/ + +# python bytecode from the local-feeds rig +__pycache__/ +*.pyc diff --git a/scripts/local-feeds/contract-tests.sh b/scripts/local-feeds/contract-tests.sh new file mode 100755 index 00000000..7010163c --- /dev/null +++ b/scripts/local-feeds/contract-tests.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +# The feed edge contract, as executable assertions. +# +# contract-tests.sh --base-url URL --pat TOKEN --org NAME [--path OBJECT] +# +# Runs against ANY implementation of `edge-contract.md`: the local adaptor +# (edge.py), or a real staging distribution once CloudFront, Lambda@Edge and +# Connect are in place. That is the whole point — the assertions below name no +# implementation, only observable behaviour, so a divergence in production shows +# up here as a named failing row rather than as an incident. +# +# Covers S2 (authorization), S3 (token issuance) and S4 (rate limiting). +# S5 (caching) and S6 (attribution) are not covered yet; they have no client +# consumer, and the contract document records why they wait. +set -uo pipefail + +BASE_URL=""; PAT="test-pat"; ORG="acme"; OBJ="repodata/repomd.xml" +REL="2026"; BRANCH="main" +while [[ $# -gt 0 ]]; do + case $1 in + --base-url) BASE_URL=$2; shift 2 ;; + --pat) PAT=$2; shift 2 ;; + --org) ORG=$2; shift 2 ;; + --path) OBJ=$2; shift 2 ;; + --release) REL=$2; shift 2 ;; + --branch) BRANCH=$2; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done +[[ -n $BASE_URL ]] || { echo "--base-url is required" >&2; exit 2; } + +PASS=0; FAIL=0 +ok() { PASS=$((PASS+1)); printf 'ok %s\n' "$*"; } +bad() { FAIL=$((FAIL+1)); printf 'FAIL %s\n' "$*"; } +check() { # check + if [[ "$2" == "$3" ]]; then ok "$1"; else bad "$1 (expected $2, got $3)"; fi +} + +ua() { printf 'avocado-cli/1.0.0-test;key/%s;tier/%s' "$1" "$2"; } +status() { # status + local u=$1; shift + curl -s -o /dev/null -w '%{http_code}' -A "$u" "$@" +} +mint() { # mint -> response body + curl -s -X POST -H "Authorization: Bearer $PAT" -H 'Content-Type: application/json' \ + -d "$1" "$BASE_URL/api/orgs/$ORG/feed-tokens" +} +jfield() { python3 -c "import json,sys;d=json.load(sys.stdin);print(d.get('$1',''))"; } + +# The private tree mirrors the public one: the release precedes the org, so the +# org is a path SEGMENT rather than a prefix. Getting this wrong is how a scope +# check silently degrades into "any authenticated org can read any org". +FEED="$BASE_URL/private/$REL/orgs/$ORG/$BRANCH/$OBJ" + +echo "--- S3 token issuance" +BODY=$(mint '{}') +TOKEN=$(printf '%s' "$BODY" | jfield token) +TIER=$(printf '%s' "$BODY" | jfield tier) +[[ -n $TOKEN ]] && ok "mint returns a token" || bad "mint returned no token: $BODY" +[[ -n $TIER ]] && ok "mint returns the tier (contract, not decoration): tier=$TIER" \ + || bad "mint returned no tier" +# The tier is an entitlement, not a request field. A client that could ask for a +# tier could ask for a higher one; it buys only a rate-limit bucket, but it is +# still a privilege taken from client input. +ASKED=$(mint '{"tier": 99}' | jfield tier) +check "a client-requested tier is ignored" "$TIER" "$ASKED" +check "a bad account credential is refused" 403 \ + "$(curl -s -o /dev/null -w '%{http_code}' -X POST -H 'Authorization: Bearer wrong-pat' \ + -d '{}' "$BASE_URL/api/orgs/$ORG/feed-tokens")" + +echo "--- S2 request authorization" +# 401 with a Basic challenge, not 403: dnf authenticates only when challenged, +# so a 403 here fails every fetch outright and a Bearer challenge is ignored. +check "no credential is challenged, not refused" 401 "$(status "$(ua k1 1)" "$FEED")" +WWW=$(curl -s -D- -o /dev/null -A "$(ua k1 1)" "$FEED" | tr -d '\r' \ + | awk 'tolower($1)=="www-authenticate:"{print tolower($2)}') +check "the challenge says Basic" basic "$WWW" +check "a valid token is served" 200 "$(status "$(ua k1 1)" -u "k1:$TOKEN" "$FEED")" +check "a garbage token is refused" 403 "$(status "$(ua k1 1)" -u "k1:not-a-token" "$FEED")" + +SHORT=$(mint '{"ttl": 1}' | jfield token) +sleep 2 +check "an expired token is refused" 403 "$(status "$(ua k1 1)" -u "k1:$SHORT" "$FEED")" + +check "another org's path is refused with a valid token" 403 \ + "$(status "$(ua k1 1)" -u "k1:$TOKEN" "$BASE_URL/private/$REL/orgs/someone-else/$BRANCH/$OBJ")" + +ROTATED=$(mint '{"previous_key": true}' | jfield token) +check "a token signed with the previous key still works (rotation safety)" 200 \ + "$(status "$(ua k1 1)" -u "k1:$ROTATED" "$FEED")" + +echo "--- S4 rate limiting" +# Drive one client past its ceiling. The limit is unknown to this suite by +# design: the contract is the 429, not the number, so the loop stops on the +# first one and fails only if the ceiling is never reached. +LIMITED=""; TRIES=0 +for _ in $(seq 1 60); do + TRIES=$((TRIES+1)) + code=$(status "$(ua burst 0)" -u "burst:$TOKEN" "$FEED") + [[ $code == 429 ]] && { LIMITED=yes; break; } +done +[[ -n $LIMITED ]] && ok "an anonymous-tier client is limited (after $TRIES requests)" \ + || bad "no 429 after $TRIES requests — the ceiling was never reached" + +if [[ -n $LIMITED ]]; then + RA=$(curl -s -D- -o /dev/null -A "$(ua burst 0)" -u "burst:$TOKEN" "$FEED" \ + | tr -d '\r' | awk 'tolower($1)=="retry-after:"{print $2}') + [[ -n $RA ]] && ok "the 429 carries Retry-After: $RA" \ + || bad "the 429 carries no Retry-After — dnf sees an unexplained failure" +fi + +# The property that proves counters are per client rather than global. +check "a second client at the same tier is unaffected" 200 \ + "$(status "$(ua quiet 0)" -u "quiet:$TOKEN" "$FEED")" + +# And that the tier actually selects a different ceiling. A higher tier must +# survive a burst that limited tier 0. +HIGH_OK=yes +for _ in $(seq 1 $TRIES); do + code=$(status "$(ua highburst 1)" -u "highburst:$TOKEN" "$FEED") + [[ $code == 429 ]] && { HIGH_OK=""; break; } +done +[[ -n $HIGH_OK ]] && ok "a higher tier survives the burst that limited tier 0" \ + || bad "tier 1 was limited at the same point as tier 0 — tiers are not selecting a ceiling" + +echo +printf '%s\n' "-------------------------------------------" +if [[ $FAIL -eq 0 ]]; then + echo "ALL PASSED ($PASS assertions) against $BASE_URL" +else + echo "$FAIL FAILED, $PASS passed against $BASE_URL" +fi +exit $(( FAIL > 0 )) diff --git a/scripts/local-feeds/edge.py b/scripts/local-feeds/edge.py new file mode 100755 index 00000000..6afc915c --- /dev/null +++ b/scripts/local-feeds/edge.py @@ -0,0 +1,381 @@ +#!/usr/bin/env python3 +"""Local implementation of the feed edge contract — seams S2, S3 and S4. + +See `avocado-ai/features/secure-feeds/edge-contract.md`. This process stands in +for three production pieces at once so the client half can be built and tested +before any of them exist: + + S2 request authorization Lambda@Edge ES256/JWKS verifier on /private/* + (asymmetric: a CloudFront Function cannot do ECDSA) + S3 token issuance Connect POST /api/orgs/:org_id/feed-tokens + S4 rate limiting WAF rate-based rules keyed on the User-Agent + +It is deliberately NOT a WAF or a CDN. It implements the *observable contract* +those things must satisfy, so the acceptance suite can run unchanged against +either. What it cannot prove is listed in the contract document: distributed +counter semantics, real cache behaviour, shared-address effects, cold starts. + + edge.py --dir FEEDROOT --port N [--pat TOKEN] [--org NAME] + [--window SECONDS] [--limit-anon N] [--limit-tier N:M ...] + +ES256 is used because the production verifier is asymmetric and a symmetric +stand-in would not exercise the same failure modes. A keypair is generated at +startup, plus a *previous* key so token rotation is testable; both are published +at /.well-known/jwks.json. +""" + +import argparse +import base64 +import collections +import http.server +import json +import os +import sys +import threading +import time +import urllib.parse + +try: + from cryptography.hazmat.primitives import hashes + from cryptography.hazmat.primitives.asymmetric import ec + from cryptography.hazmat.primitives.asymmetric.utils import ( + decode_dss_signature, + encode_dss_signature, + ) +except ImportError: # pragma: no cover - reported by the caller, not guessed at + sys.stderr.write( + "edge.py needs the `cryptography` package for ES256 " + "(pip install cryptography). The token seams cannot be faked " + "symmetrically without changing what the test proves.\n" + ) + raise SystemExit(97) + + +def b64u(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).decode().rstrip("=") + + +def b64u_dec(txt: str) -> bytes: + return base64.urlsafe_b64decode(txt + "=" * (-len(txt) % 4)) + + +class Signer: + """An ES256 signing key, with a predecessor kept for rotation tests.""" + + def __init__(self): + self.current = ec.generate_private_key(ec.SECP256R1()) + self.previous = ec.generate_private_key(ec.SECP256R1()) + + def sign(self, claims: dict, *, previous: bool = False) -> str: + key = self.previous if previous else self.current + kid = "previous" if previous else "current" + header = {"alg": "ES256", "typ": "JWT", "kid": kid} + body = f"{b64u(json.dumps(header).encode())}.{b64u(json.dumps(claims).encode())}" + der = key.sign(body.encode(), ec.ECDSA(hashes.SHA256())) + r, s = decode_dss_signature(der) + raw = r.to_bytes(32, "big") + s.to_bytes(32, "big") + return f"{body}.{b64u(raw)}" + + def verify(self, token: str): + """Return the claims, or raise ValueError naming why it was refused. + + Both keys are accepted: rejecting the predecessor would drop tokens that + were valid when minted, which is the failure a rotation is supposed to + avoid. Production does the same thing via JWKS carrying both. + """ + try: + head_b64, body_b64, sig_b64 = token.split(".") + claims = json.loads(b64u_dec(body_b64)) + raw = b64u_dec(sig_b64) + der = encode_dss_signature( + int.from_bytes(raw[:32], "big"), int.from_bytes(raw[32:], "big") + ) + except Exception as exc: + raise ValueError(f"malformed token: {exc}") + signed = f"{head_b64}.{body_b64}".encode() + for key in (self.current, self.previous): + try: + key.public_key().verify(der, signed, ec.ECDSA(hashes.SHA256())) + break + except Exception: + continue + else: + raise ValueError("signature does not verify against any published key") + if claims.get("exp", 0) <= time.time(): + raise ValueError("token expired") + return claims + + def jwks(self) -> dict: + def entry(key, kid): + nums = key.public_key().public_numbers() + return { + "kty": "EC", + "crv": "P-256", + "kid": kid, + "x": b64u(nums.x.to_bytes(32, "big")), + "y": b64u(nums.y.to_bytes(32, "big")), + } + + return {"keys": [entry(self.current, "current"), entry(self.previous, "previous")]} + + +class Limiter: + """Per-client request counter. Keyed like the WAF rule: the client id from + the User-Agent when present, the source address when anonymous. + + A fixed window, not a sliding one, and a much shorter one than production — + the point is to make the *contract* (429 with Retry-After, per-client + isolation, tier ordering) testable in seconds. Thresholds here mean nothing + about production thresholds, which come from measured traffic. + """ + + def __init__(self, window: int, limits: dict): + self.window = window + self.limits = limits + self.hits = collections.defaultdict(collections.deque) + self.lock = threading.Lock() + + def check(self, client: str, tier: int): + limit = self.limits.get(tier, self.limits[0]) + now = time.time() + with self.lock: + seen = self.hits[client] + while seen and seen[0] <= now - self.window: + seen.popleft() + if len(seen) >= limit: + return int(seen[0] + self.window - now) + 1 # Retry-After + seen.append(now) + return None + + +def parse_ua(ua: str): + """`avocado-cli/;key/;tier/` -> (key_id or None, tier). + + Anything unparseable is anonymous at tier 0, which is the safe reading: a + client that does not identify itself does not get a raised limit. + """ + key_id, tier = None, 0 + for part in ua.split(";"): + part = part.strip() + if part.startswith("key/"): + key_id = part[4:] or None + elif part.startswith("tier/"): + try: + tier = int(part[5:]) + except ValueError: + tier = 0 + return key_id, tier + + +class Handler(http.server.SimpleHTTPRequestHandler): + signer: Signer = None + limiter: Limiter = None + pat = "" + org = "" + feed_root = "" + tier = 1 + max_ttl = 3600 + + # --- helpers --------------------------------------------------------- + def _send(self, code, body=b"", headers=()): + self.send_response(code) + for k, v in headers: + self.send_header(k, v) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + if body and self.command != "HEAD": + self.wfile.write(body) + + def _deny(self, reason): + """403 with the reason only in the log, never the body: a client must + not be able to tell 'wrong token' from 'no such object'.""" + self.denied_reason = reason + self._send(403, b"forbidden\n") + + # --- S3: token issuance ---------------------------------------------- + def do_POST(self): + path = urllib.parse.urlparse(self.path).path + if not path.endswith("/feed-tokens"): + return self._send(404, b"not found\n") + if self.headers.get("Authorization", "") != f"Bearer {self.pat}": + return self._deny("mint: bad account credential") + org = path.strip("/").split("/")[-2] if "/orgs/" in path else self.org + if org != self.org: + return self._deny(f"mint: not a member of {org!r}") + length = int(self.headers.get("Content-Length") or 0) + req = json.loads(self.rfile.read(length) or b"{}") if length else {} + # ttl is a client hint, clamped. `tier` is deliberately NOT read from the + # request: it is an entitlement derived from the organization, and a client + # that can ask for a tier can ask for a higher one. It buys only a + # rate-limit bucket, never access, but it is still a privilege decision + # taken from client input. Response-only. + ttl = max(1, min(int(req.get("ttl", 300)), self.max_ttl)) + tier = self.tier + claims = { + "org": org, + "tier": tier, + "iat": int(time.time()), + "exp": int(time.time()) + ttl, + } + token = self.signer.sign(claims, previous=bool(req.get("previous_key"))) + body = json.dumps( + { + "token": token, + "tier": tier, + "expires_at": claims["exp"], + "feed_url": f"http://{self.headers.get('Host')}/private", + } + ).encode() + self._send(200, body, [("Content-Type", "application/json")]) + + # --- S2 + S4: authorization and limiting on every read ---------------- + def do_GET(self): + path = urllib.parse.urlparse(self.path).path + ua = self.headers.get("User-Agent", "") + key_id, tier = parse_ua(ua) + + if path == "/.well-known/jwks.json": + return self._send( + 200, json.dumps(self.signer.jwks()).encode(), [("Content-Type", "application/json")] + ) + + # S4 first, deliberately: production evaluates WAF before the edge + # function, so a limited request never reaches authorization. + retry = self.limiter.check(key_id or self.client_address[0], tier) + if retry is not None: + self.limited = True + return self._send( + 429, + b"rate limited\n", + [("Retry-After", str(retry)), ("Content-Type", "text/plain")], + ) + + if path.startswith("/private/"): + auth = self.headers.get("Authorization", "") + if not auth.startswith("Basic "): + # 401 with a Basic challenge, NOT 403. dnf/librepo does not send + # credentials preemptively; it authenticates only after being + # challenged, and the challenge must say Basic. Answering 403 (or + # challenging Bearer) makes every object fetch fail outright — the + # classic "works in curl, dies in dnf". A *bad* credential still + # gets 403: the client already tried and must not retry. + self.denied_reason = "no credential (challenged)" + return self._send( + 401, + b"unauthorized\n", + [("WWW-Authenticate", 'Basic realm="avocado-feed"')], + ) + try: + _user, _, token = base64.b64decode(auth[6:]).decode().partition(":") + except Exception: + return self._deny("undecodable Basic header") + try: + claims = self.signer.verify(token) + except ValueError as exc: + return self._deny(str(exc)) + # /private//orgs/// + # The org is a path SEGMENT, not a prefix: the release precedes it so + # the private tree mirrors the public one. Compare the segment after + # `orgs/`, which is what the production verifier does. + parts = path.strip("/").split("/") + try: + org_at = parts.index("orgs") + path_org = parts[org_at + 1] + subpath = "/".join(parts[org_at + 3 :]) # skip org and branch + except (ValueError, IndexError): + return self._deny("path is not /private//orgs///...") + if path_org != claims.get("org"): + return self._deny( + f"path org {path_org!r} does not match the token's org {claims.get('org')!r}" + ) + # Keep the request path for the log: attribution has to record what + # the client asked for, not the rewritten path the file server sees. + self.logged_path = path + self.path = "/" + subpath + + if getattr(self, "head_only", False): + return super().do_HEAD() + return super().do_GET() + + def do_HEAD(self): + # Not an alias for do_GET: the base handler's do_GET always streams the + # body, so aliasing answers HEAD with a body. Run the same limiting and + # authorization, then delegate to the real HEAD. + self.head_only = True + return self.do_GET() + + # --- S6 (partial): one attributable line per request ------------------ + def log_error(self, *args): + # BaseHTTPRequestHandler logs errors through both send_error and + # send_response, which would record a failed request twice and inflate + # any per-client count derived from this log. One line per request. + pass + + def log_message(self, fmt, *args): + key_id, tier = parse_ua(self.headers.get("User-Agent", "")) + sys.stderr.write( + "%s %s status=%s key=%s tier=%s%s%s\n" + % ( + self.command, + getattr(self, "logged_path", None) or urllib.parse.urlparse(self.path).path, + args[1] if len(args) > 1 else "-", + key_id or "-", + tier, + " LIMITED" if getattr(self, "limited", False) else "", + " denied=%s" % getattr(self, "denied_reason", "") if getattr(self, "denied_reason", "") else "", + ) + ) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument( + "--dir", + required=True, + help="feed root. Objects are served at " + "/private//orgs///, and everything up to and " + "including is stripped when mapping to this directory — so a " + "target-scoped feed lives at /target//repodata/...", + ) + ap.add_argument("--port", type=int, required=True) + ap.add_argument("--pat", default="test-pat", help="the account credential the mint accepts") + ap.add_argument("--org", default="acme") + ap.add_argument("--tier", type=int, default=1, help="the org's entitlement, server-side") + ap.add_argument("--max-ttl", type=int, default=3600) + ap.add_argument("--window", type=int, default=10, help="rate-limit window, seconds") + ap.add_argument("--limit-anon", type=int, default=5) + ap.add_argument( + "--limit-tier", + action="append", + default=[], + metavar="TIER:N", + help="per-tier request ceiling, e.g. 1:20", + ) + args = ap.parse_args() + + limits = {0: args.limit_anon} + for spec in args.limit_tier: + tier, _, n = spec.partition(":") + limits[int(tier)] = int(n) + limits.setdefault(1, args.limit_anon * 4) + + Handler.signer = Signer() + Handler.limiter = Limiter(args.window, limits) + Handler.pat = args.pat + Handler.org = args.org + Handler.tier = args.tier + Handler.max_ttl = args.max_ttl + Handler.feed_root = os.path.abspath(args.dir) + + def build(*a, **kw): + return Handler(*a, directory=Handler.feed_root, **kw) + + srv = http.server.ThreadingHTTPServer(("0.0.0.0", args.port), build) + sys.stderr.write( + f"edge: dir={Handler.feed_root} org={args.org} window={args.window}s limits={limits}\n" + ) + srv.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/scripts/local-feeds/org-feed-rig.sh b/scripts/local-feeds/org-feed-rig.sh new file mode 100755 index 00000000..da9c9af7 --- /dev/null +++ b/scripts/local-feeds/org-feed-rig.sh @@ -0,0 +1,192 @@ +#!/usr/bin/env bash +# End-to-end proof that an `org:` feed works: the CLI mints a short-lived token +# from a stand-in Connect, injects it into a generated .repo, and dnf installs a +# package from a feed that refuses anonymous access. +# +# The stand-in is scripts/local-feeds/edge.py, which implements the same +# contract Connect and the edge lambda will (see edge-contract.md). When the real +# ones exist this script changes by one variable: AVOCADO_CONNECT_URL. +# +# Skips cleanly when python's `cryptography` is unavailable, so the stdlib-only +# rig (run.sh) stays runnable everywhere. +set -uo pipefail +cd "$(dirname "$0")" +HERE=$PWD +ROOT=$(cd ../.. && pwd) +AVOCADO=${AVOCADO:-$ROOT/target/debug/avocado} +# Under .local-feeds-test/, which is already gitignored: a scratch directory in +# the repo root shows up in `git status` for anyone who runs the rig. +WORK=$ROOT/.local-feeds-test/org-feed +ORG=${ORG:-acme} +REL=2026 +BRANCH=main +TARGET=${TARGET:-qemux86-64} +PAT=test-pat + +PASS=0; FAIL=0 +ok() { PASS=$((PASS+1)); echo "ok $*"; } +bad() { FAIL=$((FAIL+1)); echo "FAIL $*"; } +die() { echo "error: $*" >&2; exit 1; } + +python3 -c 'import cryptography' 2>/dev/null \ + || { echo "skipped: python3 `cryptography` is not installed (needed for ES256)"; exit 0; } +[[ -x $AVOCADO ]] || die "no avocado binary at $AVOCADO (cargo build first)" + +cleanup() { [[ -n ${EDGE_PID:-} ]] && kill "$EDGE_PID" 2>/dev/null; } +trap cleanup EXIT + +rm -rf "$WORK"; mkdir -p "$WORK" + +# --- a private feed with one package, at the production path layout ----------- +# The private tree mirrors the public one, so objects live under +# /orgs///target//. edge.py strips everything up to and +# including the branch, so the served root must still carry `target/`. +FEEDROOT=$WORK/feed +REPODIR=$FEEDROOT/target/$TARGET +mkdir -p "$REPODIR" +command -v rpmbuild >/dev/null || die "rpmbuild is required to build the test package" +cat > "$WORK/hello-private.spec" <<'SPEC' +Name: hello-private +Version: 1.0 +Release: 1 +Summary: a package that only exists behind auth +License: MIT +BuildArch: noarch +%description +Proof that the CLI reached a feed it had to authenticate to. +%install +mkdir -p %{buildroot}/usr/share/hello-private +echo private > %{buildroot}/usr/share/hello-private/marker +%files +/usr/share/hello-private/marker +SPEC +rpmbuild --quiet -bb --define "_topdir $WORK/rpmbuild" "$WORK/hello-private.spec" >/dev/null 2>&1 \ + || die "rpmbuild failed" +cp "$WORK"/rpmbuild/RPMS/noarch/*.rpm "$REPODIR/" +command -v createrepo_c >/dev/null || die "createrepo_c is required" +createrepo_c --quiet "$REPODIR" || die "createrepo_c failed" +ok "built hello-private 1.0 into a private feed with repodata" + +# --- the stand-in Connect + edge --------------------------------------------- +PORT=$(python3 -c "import socket;s=socket.socket();s.bind(('',0));print(s.getsockname()[1]);s.close()") +# --tier 3, deliberately not the default: it is what proves the User-Agent +# carries the tier the mint issued rather than a hard-coded floor. +python3 edge.py --dir "$FEEDROOT" --port "$PORT" --pat "$PAT" --org "$ORG" --tier 3 \ + --window 60 --limit-anon 1000 --limit-tier 1:5000 --limit-tier 3:5000 > "$WORK/edge.log" 2>&1 & +EDGE_PID=$! +for _ in $(seq 1 40); do + curl -sf -o /dev/null "http://127.0.0.1:$PORT/.well-known/jwks.json" && break + sleep 0.25 +done +BASE="http://127.0.0.1:$PORT" +OBJ="private/$REL/orgs/$ORG/$BRANCH/target/$TARGET/repodata/repomd.xml" +code=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/$OBJ") +[[ $code == 401 ]] && ok "the feed challenges anonymous access (401)" \ + || bad "expected 401 from the feed, got $code" + +# --- a project whose only feed is the private one ----------------------------- +PROJ=$WORK/project +mkdir -p "$PROJ" +cat > "$PROJ/avocado.yaml" < "$WORK/sdk-install.log" 2>&1; then + ok "sdk install" +else + tail -20 "$WORK/sdk-install.log" >&2 + bad "sdk install failed (see $WORK/sdk-install.log)" +fi + +if "$AVOCADO" --no-tui sdk dnf --dnf-arg --nogpgcheck repoquery hello-private \ + > "$WORK/dnf.log" 2>&1; then + grep -q "hello-private" "$WORK/dnf.log" \ + && ok "dnf resolved hello-private from the private feed" \ + || bad "dnf ran but did not find hello-private (see $WORK/dnf.log)" +else + bad "the dnf passthrough failed (see $WORK/dnf.log)" +fi + +# --- the canonical document must record the org, never the token -------------- +DOC="$PROJ/.avocado/feeds/$TARGET.json" +if [[ -f $DOC ]]; then + grep -q "connect:$ORG" "$DOC" && ok "canonical document records connect:$ORG" \ + || bad "canonical document has no org identity" + if grep -qE '"password"|eyJ[A-Za-z0-9_-]{10}' "$DOC"; then + bad "canonical document contains a token — it must never be written down" + else + ok "canonical document carries no token" + fi +else + bad "no canonical document at $DOC" +fi + +# --- the mint actually happened, and only once per invocation ---------------- +# Exactly one mint per CLI invocation, and this rig makes two that touch feeds +# (`sdk install`, then the dnf passthrough). Not a tidiness check: before feeds +# were materialized once per invocation this was five mints for a single build, +# because every container run re-minted. A per-minute limit on the mint endpoint +# would then refuse an ordinary build. +MINTS=$(grep -c "POST /api/orgs/$ORG/feed-tokens status=200" "$WORK/edge.log" || true) +[[ ${MINTS:-0} -eq 2 ]] && ok "one feed token per invocation ($MINTS mints for 2 invocations)" \ + || bad "expected 2 mints (one per invocation), got ${MINTS:-0}" +AUTHED=$(grep -c "GET /private/.* status=200" "$WORK/edge.log" || true) +[[ ${AUTHED:-0} -ge 1 ]] && ok "authenticated feed reads succeeded ($AUTHED)" \ + || bad "no authenticated reads reached the feed" +UA=$(grep -oE "key=[0-9a-f]+" "$WORK/edge.log" | head -1) +[[ -n $UA ]] && ok "requests carried the client identity ($UA)" \ + || bad "no client identity in the feed's request log" + +# The tier must be the one the mint issued, not a constant. edge.py is started +# with --tier 3, so a hard-coded tier/1 fails here. Without this the tier in the +# mint response is decorative and every authenticated client shares one +# rate-limit bucket whatever Connect assigned. +TIERS=$(grep -oE "tier=[0-9]+" "$WORK/edge.log" | sort -u | tr '\n' ' ') +if grep -qE "GET /private/.* tier=3" "$WORK/edge.log"; then + ok "feed requests carried the minted tier (saw: $TIERS)" +else + bad "feed requests did not carry the minted tier 3 (saw: $TIERS)" +fi + +echo +if [[ $FAIL -eq 0 ]]; then + echo "ALL PASSED ($PASS assertions) workdir: $WORK" +else + echo "$FAIL FAILED, $PASS passed workdir: $WORK" +fi +exit $(( FAIL > 0 )) diff --git a/src/commands/ext/dnf.rs b/src/commands/ext/dnf.rs index 8b126fd6..f80ed755 100644 --- a/src/commands/ext/dnf.rs +++ b/src/commands/ext/dnf.rs @@ -93,7 +93,9 @@ impl ExtDnfCommand { // Get repo_url and repo_release from config let repo_url = config.get_sdk_repo_url(); let repo_release = config.get_sdk_repo_release(); - let feeds = config.materialize_feeds(&target, FeedStage::Ext, &self.config_path)?; + let feeds = config + .materialize_feeds(&target, FeedStage::Ext, &self.config_path) + .await?; self.execute_dnf_command( parsed, diff --git a/src/commands/ext/fetch.rs b/src/commands/ext/fetch.rs index d26848f1..0966cb0c 100644 --- a/src/commands/ext/fetch.rs +++ b/src/commands/ext/fetch.rs @@ -203,11 +203,15 @@ impl ExtFetchCommand { self.verbose, ) .with_repo_url(config.get_sdk_repo_url()) - .with_feeds(config.materialize_feeds( - &target, - crate::utils::feeds::FeedStage::Ext, - &self.config_path, - )?) + .with_feeds( + config + .materialize_feeds( + &target, + crate::utils::feeds::FeedStage::Ext, + &self.config_path, + ) + .await?, + ) .with_repo_release(config.get_sdk_repo_release()) .with_container_args(effective_container_args) .with_sdk_arch(self.sdk_arch.clone()) diff --git a/src/commands/ext/install.rs b/src/commands/ext/install.rs index 1c8ea6a9..85515539 100644 --- a/src/commands/ext/install.rs +++ b/src/commands/ext/install.rs @@ -201,7 +201,9 @@ impl ExtInstallCommand { // Get repo_url and repo_release from config let repo_url = config.get_sdk_repo_url(); let repo_release = config.get_sdk_repo_release(); - let feeds = config.materialize_feeds(&target, FeedStage::Ext, &self.config_path)?; + let feeds = config + .materialize_feeds(&target, FeedStage::Ext, &self.config_path) + .await?; // Determine which extensions to install (with their locations) let extensions_to_install: Vec<(String, ExtensionLocation)> = diff --git a/src/commands/fetch.rs b/src/commands/fetch.rs index 0baf34db..271a5e1a 100644 --- a/src/commands/fetch.rs +++ b/src/commands/fetch.rs @@ -107,11 +107,13 @@ impl FetchCommand { // inline > legacy sdk.*); reading raw `sdk.repo_url` here missed all of those. let repo_url = config.get_sdk_repo_url(); let repo_release = config.get_sdk_repo_release(); - let feeds = config.materialize_feeds( - &target_arch, - crate::utils::feeds::FeedStage::Ext, - &self.config_path, - )?; + let feeds = config + .materialize_feeds( + &target_arch, + crate::utils::feeds::FeedStage::Ext, + &self.config_path, + ) + .await?; // Determine what to fetch based on arguments match (&self.extension, &self.runtime) { diff --git a/src/commands/initramfs/install.rs b/src/commands/initramfs/install.rs index 15be0bab..00cfb480 100644 --- a/src/commands/initramfs/install.rs +++ b/src/commands/initramfs/install.rs @@ -112,7 +112,9 @@ impl InitramfsInstallCommand { let repo_url = config.get_sdk_repo_url(); let repo_release = config.get_sdk_repo_release(); - let feeds = config.materialize_feeds(&target, FeedStage::Initramfs, &self.config_path)?; + let feeds = config + .materialize_feeds(&target, FeedStage::Initramfs, &self.config_path) + .await?; let container_helper = SdkContainer::from_config(&self.config_path, config)?.verbose(self.verbose); diff --git a/src/commands/rootfs/install.rs b/src/commands/rootfs/install.rs index 99a2060e..e062070b 100644 --- a/src/commands/rootfs/install.rs +++ b/src/commands/rootfs/install.rs @@ -1433,7 +1433,9 @@ impl RootfsInstallCommand { let repo_url = config.get_sdk_repo_url(); let repo_release = config.get_sdk_repo_release(); - let feeds = config.materialize_feeds(&target, FeedStage::Rootfs, &self.config_path)?; + let feeds = config + .materialize_feeds(&target, FeedStage::Rootfs, &self.config_path) + .await?; let container_helper = SdkContainer::from_config(&self.config_path, config)? .verbose(self.verbose) diff --git a/src/commands/runtime/dnf.rs b/src/commands/runtime/dnf.rs index 45443b40..16b7e965 100644 --- a/src/commands/runtime/dnf.rs +++ b/src/commands/runtime/dnf.rs @@ -76,7 +76,9 @@ impl RuntimeDnfCommand { // Get repo_url and repo_release from config let repo_url = config.get_sdk_repo_url(); let repo_release = config.get_sdk_repo_release(); - let feeds = config.materialize_feeds(&target, FeedStage::Runtime, &self.config_path)?; + let feeds = config + .materialize_feeds(&target, FeedStage::Runtime, &self.config_path) + .await?; self.execute_dnf_command( parsed, diff --git a/src/commands/runtime/install.rs b/src/commands/runtime/install.rs index cdd350ff..3da9bcb9 100644 --- a/src/commands/runtime/install.rs +++ b/src/commands/runtime/install.rs @@ -131,7 +131,9 @@ impl RuntimeInstallCommand { // Get repo_url and repo_release from config let repo_url = config.get_sdk_repo_url(); let repo_release = config.get_sdk_repo_release(); - let feeds = config.materialize_feeds(&target, FeedStage::Runtime, &self.config_path)?; + let feeds = config + .materialize_feeds(&target, FeedStage::Runtime, &self.config_path) + .await?; // Check if runtime section exists let runtime_section = match parsed.get("runtimes") { diff --git a/src/commands/sdk/dnf.rs b/src/commands/sdk/dnf.rs index 00e7e69b..2e05c479 100644 --- a/src/commands/sdk/dnf.rs +++ b/src/commands/sdk/dnf.rs @@ -99,7 +99,9 @@ impl SdkDnfCommand { // Resolve target with proper precedence let target = resolve_target_required(self.target.as_deref(), config)?; - let feeds = config.materialize_feeds(&target, FeedStage::Sdk, &self.config_path)?; + let feeds = config + .materialize_feeds(&target, FeedStage::Sdk, &self.config_path) + .await?; let container_helper = SdkContainer::new(); diff --git a/src/commands/sdk/install.rs b/src/commands/sdk/install.rs index 597d5cca..aa0c0cef 100644 --- a/src/commands/sdk/install.rs +++ b/src/commands/sdk/install.rs @@ -204,11 +204,14 @@ impl SdkInstallCommand { // Get repo_url and repo_release from config let repo_url = config.get_sdk_repo_url(); let repo_release = config.get_sdk_repo_release(); - let feeds = config.materialize_feeds(&target, FeedStage::Sdk, &self.config_path)?; + let feeds = config + .materialize_feeds(&target, FeedStage::Sdk, &self.config_path) + .await?; // The kernel resolver queries with the target repo conf, so it must see the // feed set the rootfs install will see — not the sdk-stage (host) set. - let kernel_feeds = - config.materialize_feeds(&target, FeedStage::Rootfs, &self.config_path)?; + let kernel_feeds = config + .materialize_feeds(&target, FeedStage::Rootfs, &self.config_path) + .await?; // Use the container helper to run the installation let container_helper = @@ -543,10 +546,12 @@ $DNF_SDK_HOST $DNF_NO_SCRIPTS $DNF_SDK_TARGET_REPO_CONF \ // These two run the rootfs/initramfs dnf transactions, so they take // their own stage's feeds rather than the sdk-stage set this fn holds. - let rootfs_feeds = - config.materialize_feeds(target, FeedStage::Rootfs, &self.config_path)?; - let initramfs_feeds = - config.materialize_feeds(target, FeedStage::Initramfs, &self.config_path)?; + let rootfs_feeds = config + .materialize_feeds(target, FeedStage::Rootfs, &self.config_path) + .await?; + let initramfs_feeds = config + .materialize_feeds(target, FeedStage::Initramfs, &self.config_path) + .await?; let mut rootfs_params = SysrootInstallParams { sysroot_type: SysrootType::Rootfs, config, diff --git a/src/utils/config.rs b/src/utils/config.rs index 0c862758..3923339c 100644 --- a/src/utils/config.rs +++ b/src/utils/config.rs @@ -4036,12 +4036,89 @@ impl Config { }; self.repos.as_ref()?.get(name) } +} + +/// One resolved, minted feed set per invocation, per target. +/// +/// Resolution is cheap and repeatable; minting is neither. A `.repo` set is +/// materialized once per stage, but `avocado build` runs many containers, and +/// re-minting for each one meant five feed tokens for a single build — enough +/// that a naive per-minute limit on the mint endpoint would refuse an ordinary +/// build. It also gave every dnf step a different bind mount, which is part of a +/// container's shape, so sharing a container between steps became impossible +/// exactly where it saves the most. +/// +/// The tempdir lives here, so it survives for the whole invocation and is removed +/// when the process ends. Credentials therefore live as long as the invocation +/// rather than as long as one step — see `materialize_in` for why that trade is +/// acceptable and what would invalidate it. +struct InvocationFeeds { + /// Behind a mutex because minting is now per stage: the first stage that + /// needs a private feed mints it and later stages reuse the token, so the set + /// is mutated after it is shared. + set: tokio::sync::Mutex, + root: std::sync::Arc, +} + +type FeedCell = std::sync::Arc>>; + +static INVOCATION_FEEDS: std::sync::OnceLock>> = + std::sync::OnceLock::new(); +/// Get this invocation's feed set for `target`, resolving it once. +/// +/// One set per target per invocation, and the first caller's set is the one that +/// wins — every later caller gets it back rather than its own. That is what makes +/// the invocation coherent: the canonical document, the stamp hash, the mint and +/// the `.repo` files all describe the same set. Writing the document on every +/// call while keeping only the first set was the incoherent version — resolution +/// can move mid-invocation (an on-disk feed's repodata digest, an env-driven +/// releasever) and the document would then describe a set the build was not +/// using. +async fn invocation_feeds( + target: &str, + set: crate::utils::feeds::ResolvedFeedSet, + project_root: &Path, +) -> Result> { + // The map lock is released before the mint starts; it is held only long enough + // to hand out this target's cell. The single-mint guarantee comes from the + // per-target `OnceCell`, not from the lock — concurrent callers for the same + // target await the same initialization, which matters because `sdk install` + // runs the rootfs and initramfs installs at once. A different target is never + // blocked behind someone else's network call. + let map = INVOCATION_FEEDS.get_or_init(|| tokio::sync::Mutex::new(HashMap::new())); + let cell = { + let mut guard = map.lock().await; + guard.entry(target.to_string()).or_default().clone() + }; + cell.get_or_try_init(|| async { + // The canonical document describes the set this invocation adopted, so it + // is written here, where that set is decided, and not by the caller. + set.write_canonical(project_root)?; + // No minting here any more: it is per stage, and this cell is per target. + let root = std::sync::Arc::new( + tempfile::Builder::new() + .prefix("avocado-feeds-") + .tempdir() + .context("creating the invocation's feeds directory")?, + ); + Ok(std::sync::Arc::new(InvocationFeeds { + set: tokio::sync::Mutex::new(set), + root, + })) + }) + .await + .cloned() +} + +impl Config { /// Resolve, record, and materialize the named feeds for one container run. - /// `None` when the project declares no feeds — the zero-cost path. Writes - /// the canonical document to `/.avocado/feeds/.json` - /// every time so the build cache always sees the current set. - pub fn materialize_feeds( + /// `None` when the project declares no feeds — the zero-cost path. The first + /// call for a target writes the canonical document to + /// `/.avocado/feeds/.json` and pins that set for the rest + /// of the invocation; later calls reuse it, so the document and the build + /// cannot describe different sets. + pub async fn materialize_feeds( &self, target: &str, stage: crate::utils::feeds::FeedStage, @@ -4060,8 +4137,15 @@ impl Config { else { return Ok(None); }; - set.write_canonical(&project_root)?; - Ok(Some(set.materialize(stage)?)) + // The canonical document is written inside `invocation_feeds`, before the + // mint, so it records what the build depended on (`connect:`) and + // never the short-lived token or the host it was served from today. + let shared = invocation_feeds(target, set, &project_root).await?; + // Mint inside the lock: two stages starting at once must not both mint the + // same feed, and the second must see the first one's token. + let mut guard = shared.set.lock().await; + guard.resolve_connect_credentials(stage).await?; + Ok(Some(guard.materialize_in(stage, &shared.root)?)) } /// Promote config-file repo TLS settings to the process env so the container diff --git a/src/utils/container.rs b/src/utils/container.rs index 41cd2a87..936e83c3 100644 --- a/src/utils/container.rs +++ b/src/utils/container.rs @@ -519,10 +519,16 @@ pub fn inject_feed_env( env_vars: &mut std::collections::HashMap, feeds: Option<&crate::utils::feeds::FeedMaterialization>, ) { - // Identity rides on every run, named feeds or not. + // Identity rides on every run, named feeds or not. The tier comes from the + // mint when there was one: a hard-coded tier/1 put every authenticated client + // in the same rate-limit bucket regardless of what Connect actually issued, + // which made the tier in the mint response decorative. env_vars.insert( "AVOCADO_FEED_UA".to_string(), - crate::utils::feeds::user_agent(), + crate::utils::feeds::user_agent_for( + feeds.and_then(|f| f.key_id.as_deref()), + feeds.and_then(|f| f.tier), + ), ); let Some(feeds) = feeds else { return }; for (k, v) in &feeds.env { diff --git a/src/utils/feeds.rs b/src/utils/feeds.rs index e36f9c2a..f2bfdbbb 100644 --- a/src/utils/feeds.rs +++ b/src/utils/feeds.rs @@ -67,12 +67,36 @@ pub const HOST_GATEWAY_ALIAS: &str = "host.docker.internal"; /// takes precedence over the profile store so CI runners identify without a /// `credentials.json`. pub fn user_agent() -> String { + user_agent_with_tier(None) +} + +/// The identity header, carrying the tier Connect actually issued. +/// +/// `tier/1` means "authenticated, tier not yet assigned": it is the floor for a +/// logged-in client, not a claim. The edge routes rate limits on `tier/`, so +/// a client that omits it shares the anonymous bucket — and one that reports the +/// wrong tier lands in the wrong bucket, which is why the value has to come from +/// the mint rather than from a constant. `None` keeps the floor, for a project +/// with no `org:` feed and therefore no minted tier. +pub fn user_agent_with_tier(tier: Option) -> String { + user_agent_for(None, tier) +} + +/// The identity header for a specific credential. +/// +/// `key_id` names the credential that actually made the request. It matters when +/// an `org:` feed resolved through a non-default Connect profile: the default +/// profile's id would attribute those requests, in both the rate limiter and the +/// access log, to a credential that never made them. +pub fn user_agent_for(key_id: Option<&str>, tier: Option) -> String { let base = concat!("avocado-cli/", env!("CARGO_PKG_VERSION")); - // tier/1 = authenticated, tier not yet assigned by Connect. The edge - // routes on `tier/`, so without it a logged-in client would share the - // anonymous bucket; Connect raises it once it hands the CLI a real tier. - match feed_key_id() { - Some(id) => format!("{base};key/{id};tier/1"), + let id = key_id.map(str::to_string).or_else(feed_key_id); + match id { + // Clamped here, at the point the header is rendered, so the floor holds + // whatever the tier came from. `tier/0` on an authenticated request would + // place it in the anonymous bucket, which is worse than an unassigned + // tier — and `tier/1` already means "authenticated, not yet assigned". + Some(id) => format!("{base};key/{id};tier/{}", tier.unwrap_or(1).max(1)), None => base.to_string(), } } @@ -113,6 +137,20 @@ impl FeedStage { fn is_host(self) -> bool { matches!(self, FeedStage::Sdk) } + + /// The stage's subdirectory under the invocation's feeds root. Stages share + /// one mount so every container has the same shape; this is what keeps their + /// `.repo` sets apart inside it, which is what makes `stages:` scoping real + /// rather than advisory. + fn dir_name(self) -> &'static str { + match self { + FeedStage::Sdk => "sdk", + FeedStage::Rootfs => "rootfs", + FeedStage::Runtime => "runtime", + FeedStage::Ext => "ext", + FeedStage::Initramfs => "initramfs", + } + } } impl std::fmt::Display for FeedStage { @@ -135,6 +173,9 @@ pub enum FeedKind { Distro, Url, Path, + /// A private feed hosted by Connect, addressed by `org:`. Its URL and its + /// credential are both issued at materialize time, so neither is a config input. + Connect, /// A built-in repo re-scoped by name (`avocado-ext`). Builtin, } @@ -147,6 +188,20 @@ pub enum Locality { ProjectLocal, } +/// What the feed-token mint returns. `tier` is an entitlement the server +/// computes from the organization; the CLI mirrors it into the User-Agent and +/// never requests one. +#[derive(Debug, serde::Deserialize)] +struct MintedFeedToken { + token: String, + #[serde(default)] + tier: Option, + feed_url: String, + #[serde(default)] + #[allow(dead_code)] + expires_at: Option, +} + /// A feed after resolution: everything dnf will see, plus what the cache needs. /// `#[serde(skip)]` marks the fields that must never reach the canonical document. #[derive(Debug, Clone, Serialize)] @@ -168,6 +223,10 @@ pub struct ResolvedFeed { /// raw username either, which is often an email or a token. pub credential_identity: String, pub tls_verify: bool, + /// The organization for an `org:` feed. Recorded because it is the stable + /// identity of the feed; the URL and token it resolves to are not. + #[serde(skip_serializing_if = "Option::is_none")] + pub org: Option, /// `path:` feeds only — the path as written in config (project-relative) and /// the full sha256 of its `repodata/repomd.xml`. The local analogue of the /// snapshot pin: a different directory or new RPMs must move the stamp hash. @@ -236,6 +295,17 @@ pub struct ResolvedFeedSet { /// be renumbered above it at runtime. #[serde(skip)] distro_priority_base: Option, + /// Key id of the account credential the mint actually used. When an `org:` + /// feed resolves through a non-default profile, that is a different token + /// than `feed_key_id()` would find, and reporting the default profile's id + /// would attribute the requests to a credential that never made them. + #[serde(skip)] + minted_key_id: Option, + /// Lowest tier the mint issued this invocation. Deliberately not serialized: + /// it is assigned by the server and can change between builds, so it must not + /// reach the canonical document or the stamp hash. + #[serde(skip)] + minted_tier: Option, } /// What a container run needs to see the resolved feeds. The tempdir holds @@ -248,6 +318,12 @@ pub struct FeedMaterialization { pub mounts: Vec<(PathBuf, String)>, pub env: Vec<(String, String)>, pub dnf_args: Vec, + /// The tier the mint issued, for the identity header. `None` when nothing was + /// minted, which keeps the authenticated floor. + pub tier: Option, + /// Key id of the credential the mint used, when it differs from the default + /// profile's. + pub key_id: Option, /// `--add-host` entries the container needs. pub add_hosts: Vec, /// SHA-256 of the stage projection this was materialized from. Lets @@ -451,15 +527,51 @@ impl ResolvedFeedSet { // key at all reaches `credential` as `(user, String::new())` and writes // a bare `password=` into the .repo, which is the same broken auth the // empty check exists to prevent — just arrived at differently. + if def.org.is_some() && (def.username.is_some() || def.password.is_some()) { + // Before the generic credential rules: "remove username/password, + // Connect provides it" is more use than "password is missing". + // Minting is skipped for a feed that already has a credential, so + // an `org:` feed carrying one would keep its `connect:///...` + // placeholder baseurl and dnf would fail to resolve it. + bail!( + "repos.{name}: an `org:` feed gets its credential from Connect; \ + remove `username`/`password` (they are for feeds Connect knows \ + nothing about)" + ); + } if def.username.is_some() && def.password.as_deref().is_none_or(str::is_empty) { bail!( "repos.{name}: `username` is set but `password` is empty or missing — \ an unset environment variable interpolates to \"\"" ); } + // `org` is interpolated into the mint URL, into the generated baseurl, + // and into the .repo file. A value with a slash, whitespace or a + // newline could reshape the request path or inject an extra line into + // the .repo, so it has to be one URL-safe segment. Same rule as feed + // names, for the same reason. if def.org.is_some() { - // ponytail: org feeds resolve through Connect (Phase 3); parse, don't serve. - bail!("repos.{name}: `org:` feeds are resolved through Connect and are not yet supported"); + // `channel` becomes the branch segment in + // `.../orgs///...`, so it needs exactly the same rule + // as the org: a slash or a space produces a malformed path, and the + // server parses those segments. + if let Some(branch) = &def.channel { + if !is_valid_feed_name(branch) { + bail!( + "repos.{name}: `channel: {branch}` is the branch segment of a \ + Connect feed path and must match [A-Za-z0-9][A-Za-z0-9._-]*" + ); + } + } + } + if let Some(org) = &def.org { + if !is_valid_feed_name(org) { + bail!( + "repos.{name}: `org: {org}` must be a single path segment matching \ + [A-Za-z0-9][A-Za-z0-9._-]* — it is interpolated into a URL and into \ + the generated .repo" + ); + } } if def.password.is_some() && def.username.is_none() { bail!("repos.{name}: `password` requires `username`"); @@ -519,6 +631,7 @@ impl ResolvedFeedSet { locality: Locality::Shared, credential_identity: "none".into(), tls_verify: !config.get_repo_insecure(), + org: None, source: None, content_digest: None, credential: None, @@ -547,12 +660,17 @@ impl ResolvedFeedSet { .username .as_ref() .map(|u| (u.clone(), def.password.clone().unwrap_or_default())); - let credential_identity = match &def.username { - Some(u) => format!("basic:{}", short_sha256(u.as_bytes())), - None => "none".to_string(), + let credential_identity = match (&def.org, &def.username) { + // The org is the stable identity of a Connect feed. The token it + // resolves to changes every build and must never appear here: this + // string goes into the canonical document and the stamp hash. + (Some(o), _) => format!("connect:{o}"), + (None, Some(u)) => format!("basic:{}", short_sha256(u.as_bytes())), + (None, None) => "none".to_string(), }; let common = |kind, baseurl, locality, mount: Option, loopback_rewritten| { ResolvedFeed { + org: def.org.clone(), source: def.path.clone(), // Digest of the repodata as it stands now. Absent repodata is reported // at materialize time; here it simply leaves the digest unset. @@ -590,6 +708,29 @@ impl ResolvedFeedSet { None, rewritten, )); + } else if let Some(org) = &def.org { + // The private tree mirrors the public one, so an org feed is just a + // distro-shaped feed whose releasever is `/orgs//`. + // That is why no new path construction is needed here. + let rel = def + .release + .clone() + .or_else(|| config.get_distro_release()) + .unwrap_or_else(|| "2026".to_string()); + let branch = def.channel.clone().unwrap_or_else(|| "main".to_string()); + let path = format!("{rel}/orgs/{org}/{branch}/target/{target}"); + // A placeholder host, replaced with the minted `feed_url` at + // materialize time. Recording it rather than the real URL keeps the + // canonical document stable across builds and keeps a server-side + // URL change out of the stamp hash — the org is the input, the host + // is a detail of how it was served today. + feeds.push(common( + FeedKind::Connect, + format!("connect://{org}/{path}"), + Locality::Shared, + None, + false, + )); } else if let Some(p) = &def.path { let host = resolve_relative(config_dir, p); let in_container = format!("{CONTAINER_FEEDS_DIR}/paths/{name}"); @@ -621,6 +762,7 @@ impl ResolvedFeedSet { locality: Locality::Shared, credential_identity: "none".into(), tls_verify: true, + org: None, source: None, content_digest: None, credential: None, @@ -631,6 +773,8 @@ impl ResolvedFeedSet { } Ok(Some(Self { + minted_tier: None, + minted_key_id: None, version: CANONICAL_VERSION, target: target.to_string(), any_project_local: feeds.iter().any(|f| f.locality == Locality::ProjectLocal), @@ -688,6 +832,193 @@ impl ResolvedFeedSet { serde_json::to_string_pretty(&v).context("serializing feed projection") } + /// Exchange the account credential for a short-lived feed token for every + /// `org:` feed, and adopt the base URL the mint hands back. + /// + /// Deliberately **not** part of `resolve`. The token changes on every mint, so + /// letting it near the stamp hash or the canonical document would invalidate + /// every cached sysroot once per build. `resolve` records `connect:` as + /// the credential identity and a `connect://` placeholder as the URL; this + /// fills in the real host and the secret, in memory, for the life of one + /// invocation. The canonical document is written before this runs. + /// + /// Runs per stage: only feeds in scope for `stage` are minted, and only when + /// they have no token yet. + /// + /// Returns the **lowest** tier issued, which the caller mirrors into the + /// User-Agent so the edge can pick a rate-limit bucket. Lowest, not highest: + /// dnf sends one header for every feed in a run, so claiming the best tier + /// would ask for a ceiling one of the feeds was never granted. The CLI never + /// *asks* for a tier either way — it is an entitlement the server computes. + pub async fn resolve_connect_credentials(&mut self, stage: FeedStage) -> Result> { + // Only what this stage will actually use, and only once per feed. Minting + // every `org:` feed regardless of stage made `stages:` mean less for a + // private feed than for any other kind: a command whose stage excluded the + // feed still needed a login and a network round trip. Later stages reuse + // what earlier ones minted, so a feed is minted at most once per + // invocation, and never at all if no stage needs it. + if !self + .feeds + .iter() + .any(|f| f.kind == FeedKind::Connect && f.applies_to(stage) && f.credential.is_none()) + { + return Ok(self.minted_tier); + } + let profiles = crate::commands::connect::client::load_config() + .ok() + .flatten(); + // No fixed User-Agent on the client: the header is set per request, from + // the credential that request actually uses. A client-wide `user_agent()` + // reads the default profile, so a mint performed with an org-specific + // profile would be attributed — and rate limited — as the default one. + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(20)) + .build() + .context("building the HTTP client for the feed-token mint")?; + + // The lowest tier, not the highest: the tier is a claim the edge uses to + // pick a rate-limit bucket, and over-claiming would ask for a ceiling one + // of the feeds was never granted. Under-claiming only costs throughput. + // Seeded from whatever an earlier stage already minted, so a later stage + // does not forget it. + let mut lowest: Option = self.minted_tier; + let mut self_key_id: Option = self.minted_key_id.clone(); + let mut key_seen = self.minted_key_id.is_some(); + for feed in self.feeds.iter_mut().filter(|f| { + f.kind == FeedKind::Connect && f.applies_to(stage) && f.credential.is_none() + }) { + let org = feed.org.clone().ok_or_else(|| { + anyhow::anyhow!("repos.{}: a Connect feed without an org", feed.name) + })?; + + // Env wins, so CI can authenticate without a stored profile — the same + // precedence the User-Agent key id already uses. + let env_token = std::env::var("AVOCADO_CONNECT_TOKEN") + .ok() + .filter(|t| !t.is_empty()); + let (api_url, account_token) = match env_token { + Some(t) => ( + std::env::var("AVOCADO_CONNECT_URL") + .unwrap_or_else(|_| "https://connect.peridio.com".to_string()), + t, + ), + None => { + let cfg = profiles.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "repos.{}: `org: {org}` is a private feed and you are not logged in.\n\ + Run `avocado login`, or set AVOCADO_CONNECT_TOKEN for CI.", + feed.name + ) + })?; + let (_, profile) = cfg + .find_profile_by_org(&org) + .or_else(|| cfg.resolve_profile(None, None).ok()) + .ok_or_else(|| { + anyhow::anyhow!( + "repos.{}: no Connect profile for org '{org}'.\n\ + Run `avocado login --org {org}`.", + feed.name + ) + })?; + (profile.api_url.clone(), profile.token.clone()) + } + }; + + let url = format!( + "{}/api/orgs/{org}/feed-tokens", + api_url.trim_end_matches('/') + ); + let resp = client + .post(&url) + .header( + reqwest::header::USER_AGENT, + user_agent_for(Some(&short_sha256(account_token.as_bytes())), None), + ) + .bearer_auth(&account_token) + .json(&serde_json::json!({})) + .send() + .await + .with_context(|| { + format!("repos.{}: requesting a feed token from {url}", feed.name) + })?; + + let status = resp.status(); + if !status.is_success() { + let hint = match status.as_u16() { + 401 => { + " — the stored credential was rejected. Run `avocado login` to refresh it." + } + 403 => " — this account is not entitled to that org's private feed", + 404 => " — this Connect deployment does not serve feed tokens yet", + _ => "", + }; + bail!( + "repos.{}: feed-token request returned {status}{hint}", + feed.name + ); + } + let minted: MintedFeedToken = resp + .json() + .await + .with_context(|| format!("repos.{}: parsing the feed-token response", feed.name))?; + + // `connect:///` -> `/`. + // Fail loudly. `unwrap_or_default()` here would yield an empty path and + // a baseurl pointing at the feed ROOT rather than the org's subtree — + // dnf would then read someone else's metadata, or nothing, and report + // it as a broken feed. A Connect feed reaching this point without its + // placeholder is a logic error in resolve, not a user mistake. + let prefix = format!("connect://{org}/"); + let path = feed + .baseurl + .strip_prefix(&prefix) + .ok_or_else(|| { + anyhow::anyhow!( + "repos.{}: internal error — expected the placeholder {prefix:?} but found {:?}", + feed.name, + feed.baseurl + ) + })? + .to_string(); + // The mint hands back a host the CLI can reach; dnf reaches it from + // inside the container, where loopback means the container itself. + // `url:` feeds are rewritten at resolve time, but a Connect feed has no + // URL until now, so it has to happen here or a local Connect silently + // resolves to nothing. + let (baseurl, rewritten) = + rewrite_loopback(&format!("{}/{path}", minted.feed_url.trim_end_matches('/'))); + feed.baseurl = baseurl; + feed.loopback_rewritten = rewritten; + // The username is for log correlation only; the verifier reads the + // password. Using the same key id the User-Agent carries lets an + // operator join a feed request to the client that made it. + let key_id = short_sha256(account_token.as_bytes()); + // The identity header must name the credential that actually made the + // request. `feed_key_id()` reads the default profile; an `org:` feed + // may have resolved through a different one, and then the rate limiter + // and the access log would both be attributing to the wrong client. + // + // dnf sends ONE User-Agent for every feed in a run, so with two `org:` + // feeds minted under different credentials there is no honest single + // answer. Claiming the last one attributes the other's traffic to a + // credential that never made it, so a disagreement clears the field + // and the header falls back to the default profile. + match &self_key_id { + None if !key_seen => self_key_id = Some(key_id.clone()), + Some(seen) if seen == &key_id => {} + _ => self_key_id = None, + } + key_seen = true; + feed.credential = Some((key_id, minted.token)); + if let Some(t) = minted.tier { + lowest = Some(lowest.map_or(t, |l: u32| l.min(t))); + } + } + self.minted_tier = lowest; + self.minted_key_id = self_key_id; + Ok(lowest) + } + /// Write the canonical document to `/.avocado/feeds/.json`. pub fn write_canonical(&self, config_dir: &Path) -> Result { let dir = config_dir.join(".avocado").join("feeds"); @@ -712,26 +1043,54 @@ impl ResolvedFeedSet { /// Generate the per-stage `.repo` files and everything the container run /// needs to see them. + /// Materialize into a fresh throwaway root. Tests want isolation; production + /// deliberately shares one root per invocation (see `materialize_in`). + #[cfg(test)] pub fn materialize(&self, stage: FeedStage) -> Result { - let tempdir = tempfile::Builder::new() - .prefix("avocado-feeds-") - .tempdir() - .context("creating feeds tempdir")?; + let root = Arc::new( + tempfile::Builder::new() + .prefix("avocado-feeds-test-") + .tempdir() + .context("creating a test feeds dir")?, + ); + self.materialize_in(stage, &root) + } + + /// Write this stage's `.repo` files under `root`, and describe the mount and + /// environment a container needs to see them. + /// + /// `root` is one directory per **invocation**, not per step, and it is the + /// mount for every container the invocation starts. That matters for more + /// than tidiness: the mount list is part of a container's shape, so a + /// per-step directory would give every dnf step a unique shape and defeat + /// container reuse exactly where it is most valuable. Each stage gets its own + /// subdirectory and `AVOCADO_FEEDS_DIR` selects it, so the shape is identical + /// across steps while dnf still sees only the feeds scoped to its stage. + /// + /// The trade this makes, deliberately: every step in an invocation can read + /// every stage's credentials from the mount, where a per-step directory + /// showed each step only its own. For a developer building their own project + /// that is the same trust boundary as the source tree already being executed. + /// It would not be acceptable if extension builds ever run untrusted code in + /// that container, which is the condition that would invalidate this. + pub fn materialize_in( + &self, + stage: FeedStage, + root: &Arc, + ) -> Result { + let root = root.clone(); + let root_path = root.path(); + let stage_dir = root_path.join(stage.dir_name()); // Both scope dirs always exist: the entrypoint appends them to the dnf // reposdir lists unconditionally whenever AVOCADO_FEEDS_DIR is set. let scope = if stage.is_host() { "host" } else { "target" }; - fs::create_dir_all(tempdir.path().join("host"))?; - fs::create_dir_all(tempdir.path().join("target"))?; - let dir = tempdir.path().join(scope); - - let mut mounts = vec![( - tempdir.path().to_path_buf(), - CONTAINER_FEEDS_DIR.to_string(), - )]; - let mut env = vec![( - "AVOCADO_FEEDS_DIR".to_string(), - CONTAINER_FEEDS_DIR.to_string(), - )]; + fs::create_dir_all(stage_dir.join("host"))?; + fs::create_dir_all(stage_dir.join("target"))?; + let dir = stage_dir.join(scope); + + let container_stage_dir = format!("{CONTAINER_FEEDS_DIR}/{}", stage.dir_name()); + let mut mounts = vec![(root_path.to_path_buf(), CONTAINER_FEEDS_DIR.to_string())]; + let mut env = vec![("AVOCADO_FEEDS_DIR".to_string(), container_stage_dir.clone())]; let mut dnf_args = Vec::new(); let mut add_hosts = Vec::new(); @@ -747,6 +1106,24 @@ impl ResolvedFeedSet { } continue; } + FeedKind::Connect => { + // Checked only for a feed this stage will actually write. One + // scoped away from this stage is never materialized and is + // deliberately never minted, so demanding a token for it would + // make `stages:` weaker for a private feed than for any other. + // + // For a feed that IS in scope, a missing token is a bug rather + // than a user error: minting runs first and fails loudly. A + // .repo pointing at `connect://` would simply fail to + // resolve, reading as a broken feed rather than a broken CLI. + if feed.applies_to(stage) && feed.credential.is_none() { + bail!( + "repos.{}: no feed token was issued before materialization \ + (internal error: resolve_connect_credentials did not run)", + feed.name + ); + } + } FeedKind::Url | FeedKind::Path => {} } if !feed.applies_to(stage) { @@ -758,7 +1135,7 @@ impl ResolvedFeedSet { fs::copy(ca, dir.join(&fname)).with_context(|| { format!("repos.{}: reading ca {}", feed.name, ca.display()) })?; - Some(format!("{CONTAINER_FEEDS_DIR}/{scope}/{fname}")) + Some(format!("{container_stage_dir}/{scope}/{fname}")) } None => None, }; @@ -781,7 +1158,10 @@ impl ResolvedFeedSet { } // The outer mount is read-only, so docker cannot create this // mountpoint itself; it has to exist in the tempdir already. - fs::create_dir_all(tempdir.path().join("paths").join(&feed.name))?; + // At the root, not under the stage dir: a path feed's bind is the + // same for every stage, so keeping it out of the per-stage tree + // keeps the mount list identical across steps. + fs::create_dir_all(root_path.join("paths").join(&feed.name))?; mounts.push(( host.clone(), format!("{CONTAINER_FEEDS_DIR}/paths/{}", feed.name), @@ -807,7 +1187,9 @@ impl ResolvedFeedSet { digest.iter().map(|b| format!("{b:02x}")).collect() }; Ok(FeedMaterialization { - _tempdir: Arc::new(tempdir), + tier: self.minted_tier, + key_id: self.minted_key_id.clone(), + _tempdir: root, mounts, env, dnf_args, @@ -977,6 +1359,184 @@ distro: channel: next "#; + /// dnf sends one User-Agent for every feed in a run, so two `org:` feeds + /// minted under different credentials have no honest single identity, and two + /// different tiers have no honest single claim. The header must not attribute + /// one feed's traffic to the other's credential, and must not ask for a + /// ceiling a feed was never granted. + #[test] + fn a_single_header_never_over_claims_across_feeds() { + // Same credential and tier: the header can speak for both. + assert!(user_agent_for(Some("abc"), Some(3)).contains("tier/3")); + // No minted tier at all keeps the authenticated floor rather than + // inventing one. + assert!(user_agent_for(Some("abc"), None).contains("tier/1")); + // The identity is the credential's, not the default profile's. + assert!(user_agent_for(Some("abc"), Some(2)).contains("key/abc")); + } + + /// `org` reaches a URL and the generated .repo, so it has to be one URL-safe + /// segment. A slash reshapes the request path; a newline injects a line into + /// the .repo. + #[test] + fn org_must_be_a_single_url_safe_segment() { + for bad in ["a/b", "a b", "a\nb", "../x", ""] { + let c = load(&format!( + "{BASE} feeds: [f]\nrepos:\n f:\n org: {:?}\n", + bad + )); + assert!( + ResolvedFeedSet::resolve(&c, "t", Path::new("."), None).is_err(), + "org {bad:?} should be rejected" + ); + } + } + + /// An `org:` feed resolves without contacting anything: a placeholder URL that + /// shows the layout, and the org as the credential identity. Both are stable + /// across builds, which is what keeps a per-build token out of the stamp hash. + #[test] + fn org_feed_resolves_to_a_placeholder_and_records_the_org() { + let c = load(&format!( + "{BASE} feeds: [acme]\nrepos:\n acme:\n org: 01a071ea\n channel: main\n" + )); + let set = ResolvedFeedSet::resolve(&c, "qemux86-64", Path::new("."), None) + .unwrap() + .unwrap(); + let feed = set.feeds.iter().find(|f| f.name == "acme").unwrap(); + assert_eq!(feed.kind, FeedKind::Connect); + assert_eq!(feed.credential_identity, "connect:01a071ea"); + // release before org, mirroring the public tree — see edge-contract.md S3. + assert_eq!( + feed.baseurl, + "connect://01a071ea/2026/orgs/01a071ea/main/target/qemux86-64" + ); + assert!(feed.credential.is_none(), "no token before the mint runs"); + } + + /// The canonical document is written before the mint and must never carry a + /// token, a host, or anything else that changes per build. + #[test] + fn org_feed_canonical_document_carries_no_secret() { + let c = load(&format!( + "{BASE} feeds: [acme]\nrepos:\n acme:\n org: acme\n" + )); + let set = ResolvedFeedSet::resolve(&c, "qemux86-64", Path::new("."), None) + .unwrap() + .unwrap(); + let doc = set.canonical_json().unwrap(); + assert!( + doc.contains("\"connect:acme\""), + "records the org identity: {doc}" + ); + assert!( + doc.contains("connect://acme/"), + "records the placeholder: {doc}" + ); + for leak in ["token", "Bearer", "password", "eyJ"] { + assert!( + !doc.contains(leak), + "canonical document leaked {leak:?}: {doc}" + ); + } + } + + /// Defaults: no `channel:` means the org's `main` branch, and the release + /// falls back to the distro's. + #[test] + fn org_feed_defaults_to_the_main_branch() { + let c = load(&format!("{BASE} feeds: [a]\nrepos:\n a:\n org: o\n")); + let set = ResolvedFeedSet::resolve(&c, "t", Path::new("."), None) + .unwrap() + .unwrap(); + let feed = set.feeds.iter().find(|f| f.name == "a").unwrap(); + assert_eq!(feed.baseurl, "connect://o/2026/orgs/o/main/target/t"); + } + + /// An `org:` feed with its own credential would skip minting and keep the + /// `connect://` placeholder as its baseurl, so dnf would fail to resolve a + /// config that looks perfectly reasonable. + #[test] + fn an_org_feed_cannot_carry_its_own_credential() { + for extra in ["username: u\n password: p", "password: p", "username: u"] { + let c = load(&format!( + "{BASE} feeds: [p]\nrepos:\n p:\n org: o\n {extra}\n" + )); + let err = ResolvedFeedSet::resolve(&c, "t", Path::new("."), None) + .unwrap_err() + .to_string(); + assert!( + err.contains("credential from Connect"), + "{extra:?} should be refused: {err}" + ); + } + } + + /// `channel` is the branch segment of a Connect feed path, so it needs the + /// same rule as the org: a slash or a space makes a malformed path, and the + /// server parses those segments. + #[test] + fn an_org_feeds_branch_must_be_a_single_segment() { + for bad in ["a/b", "a b", "a\nb", ".."] { + let c = load(&format!( + "{BASE} feeds: [p]\nrepos:\n p:\n org: o\n channel: {:?}\n", + bad + )); + assert!( + ResolvedFeedSet::resolve(&c, "t", Path::new("."), None).is_err(), + "channel {bad:?} should be rejected" + ); + } + let ok = load(&format!( + "{BASE} feeds: [p]\nrepos:\n p:\n org: o\n channel: main\n" + )); + assert!(ResolvedFeedSet::resolve(&ok, "t", Path::new("."), None).is_ok()); + } + + /// `tier/1` is the authenticated floor. A mint returning 0 must not drop an + /// authenticated client into the anonymous bucket. + #[test] + fn the_authenticated_tier_floor_holds() { + assert!(user_agent_for(Some("k"), Some(0)).contains("tier/1")); + assert!(user_agent_for(Some("k"), None).contains("tier/1")); + assert!(user_agent_for(Some("k"), Some(4)).contains("tier/4")); + } + + /// A private feed scoped away from a stage must not require a token there. + /// Otherwise `stages:` means less for an `org:` feed than for any other kind: + /// the command would demand a login and a network round trip for a feed it is + /// never going to write. + #[test] + fn a_connect_feed_out_of_scope_needs_no_token() { + let c = load(&format!( + "{BASE} feeds: [priv]\nrepos:\n priv:\n org: o\n stages: [ext]\n" + )); + let set = ResolvedFeedSet::resolve(&c, "t", Path::new("."), None) + .unwrap() + .unwrap(); + // sdk is out of scope: materializes fine with no token at all. + assert!(set.materialize(FeedStage::Sdk).is_ok()); + // ext is in scope, so a missing token there is still the internal error. + let err = set.materialize(FeedStage::Ext).unwrap_err().to_string(); + assert!(err.contains("no feed token was issued"), "got: {err}"); + } + + /// Materializing a Connect feed that never got a token is an internal error, + /// not a silently broken `.repo`: dnf would report "no more mirrors" and the + /// cause would look like a broken feed rather than a CLI bug. + #[test] + fn materialize_refuses_a_connect_feed_without_a_token() { + let c = load(&format!("{BASE} feeds: [a]\nrepos:\n a:\n org: o\n")); + let set = ResolvedFeedSet::resolve(&c, "t", Path::new("."), None) + .unwrap() + .unwrap(); + let err = set.materialize(FeedStage::Sdk).unwrap_err().to_string(); + assert!( + err.contains("no feed token was issued"), + "expected the internal-error message, got: {err}" + ); + } + /// A newline anywhere that reaches the generated .repo injects an INI option. /// The url and credential fields were checked; the release fields were not, /// and they reach the baseurl through `$releasever` substitution. @@ -1195,7 +1755,10 @@ distro: .unwrap(); let ext = set.materialize(FeedStage::Ext).unwrap(); assert!(ext.dnf_args.is_empty()); - assert!(ext.mounts[0].0.join("target/avocado-feed-v.repo").is_file()); + assert!(ext.mounts[0] + .0 + .join("ext/target/avocado-feed-v.repo") + .is_file()); let rootfs = set.materialize(FeedStage::Rootfs).unwrap(); assert_eq!(rootfs.dnf_args, vec!["--disablerepo=*-target-ext"]); assert!(!rootfs.mounts[0] @@ -1203,7 +1766,10 @@ distro: .join("target/avocado-feed-v.repo") .exists()); let sdk = set.materialize(FeedStage::Sdk).unwrap(); - assert!(!sdk.mounts[0].0.join("host/avocado-feed-v.repo").exists()); + assert!(!sdk.mounts[0] + .0 + .join("sdk/host/avocado-feed-v.repo") + .exists()); } #[test] @@ -1229,7 +1795,8 @@ distro: assert_eq!(json, set.canonical_json().unwrap()); let m = set.materialize(FeedStage::Rootfs).unwrap(); assert_eq!(m.add_hosts, vec!["host.docker.internal:host-gateway"]); - let repo = fs::read_to_string(m.mounts[0].0.join("target/avocado-feed-n.repo")).unwrap(); + let repo = + fs::read_to_string(m.mounts[0].0.join("rootfs/target/avocado-feed-n.repo")).unwrap(); assert!(repo.contains("password=hunter2")); assert!(repo.contains("priority=20")); } @@ -1283,11 +1850,16 @@ distro: .unwrap_err() .to_string() .contains("more than once")); - let org = load(&format!("{BASE}repos:\n acme:\n org: acme\n")); - assert!(ResolvedFeedSet::resolve(&org, "t", Path::new("."), None) + // `org:` is supported now, but it is still exactly one locator: naming a + // url alongside it is ambiguous about who decides the host, and the mint + // is the answer. + let both = load(&format!( + "{BASE}repos:\n acme:\n org: acme\n url: https://elsewhere\n" + )); + assert!(ResolvedFeedSet::resolve(&both, "t", Path::new("."), None) .unwrap_err() .to_string() - .contains("Connect")); + .contains("exactly one of")); let named = load("distro:\n repo: nope\n"); assert!(ResolvedFeedSet::resolve(&named, "t", Path::new("."), None) .unwrap_err() @@ -1530,7 +2102,12 @@ distro: .unwrap(); let m = set.materialize(FeedStage::Rootfs).unwrap(); let read = |n: &str| { - fs::read_to_string(m.mounts[0].0.join(format!("target/avocado-feed-{n}.repo"))).unwrap() + fs::read_to_string( + m.mounts[0] + .0 + .join(format!("rootfs/target/avocado-feed-{n}.repo")), + ) + .unwrap() }; let signed = read("signed"); assert!( @@ -1538,11 +2115,12 @@ distro: "$target expands: {signed}" ); assert!(signed.contains("gpgcheck=1\n") && signed.contains("gpgkey=https://s/KEY\n")); - assert!(signed.contains("sslcacert=/run/avocado-feeds/target/avocado-feed-signed.ca.pem\n")); + assert!(signed + .contains("sslcacert=/run/avocado-feeds/rootfs/target/avocado-feed-signed.ca.pem\n")); assert!(signed.contains("sslverify=0\n")); assert!(m.mounts[0] .0 - .join("target/avocado-feed-signed.ca.pem") + .join("rootfs/target/avocado-feed-signed.ca.pem") .is_file()); assert!(read("plain").contains("gpgcheck=0\n")); assert!(