diff --git a/README.md b/README.md
index f54b4472..eae44fa4 100644
--- a/README.md
+++ b/README.md
@@ -324,7 +324,7 @@ flowchart LR
| **Virtual Network** | ARM path (`Microsoft.Network`) | VNet, subnet, NIC, public IP, NSG, private DNS zone (+ virtual network links, record sets), private endpoint (+ private DNS zone groups), and private link service ARM resources; subnet listing is scoped to the parent VNet; NIC private IPs are synthesized for VM/Terraform compatibility |
| **Virtual Machines** | ARM path (`Microsoft.Compute`) | VM lifecycle (create/start/stop/deallocate/restart/delete/list), instanceView power state; mocked: no Docker (container backing planned) |
| **Azure Cache for Redis** | ARM path (`Microsoft.Cache`) | Cache CRUD, `listKeys`/`regenerateKey`; real `valkey/valkey:8-alpine` containers (data plane, primary key as password) or mocked; non-SSL port |
-| **Azure Container Registry** | ARM path (`Microsoft.ContainerRegistry`) | Registry CRUD, `listCredentials`/`regenerateCredential`, `checkNameAvailability`; one shared `registry:2` (Docker Registry V2 push/pull, path-style `loginServer`, anonymous) or mocked |
+| **Azure Container Registry** | ARM path (`Microsoft.ContainerRegistry`) | Registry CRUD, `listCredentials`/`regenerateCredential`, `checkNameAvailability`; one shared `registry:2` (Docker Registry V2 push/pull, anonymous on its published port); `az acr login` through the Entra token exchange on `{name}.azurecr.io`; or mocked |
| **Azure Container Instances** | ARM path (`Microsoft.ContainerInstance`) | Container group lifecycle (create/update/delete/list by rg + subscription), `start`/`stop`/`restart` with spec-exact LRO shapes, container logs, instanceView, azurerm-safe read-backs (ports/resources always present, canonical enum casing, secrets never echoed); mocked: no Docker (container backing planned) |
| **Event Grid** | ARM path (`Microsoft.EventGrid`) + `/{topic}-eventgrid/api/events` | Custom Topics, `listKeys`/`regenerateKey`, webhook `eventSubscriptions` with subject/eventType filters; publish in Event Grid + CloudEvents 1.0 schemas; async webhook delivery with retry; `SubscriptionValidationEvent` handshake; HTTP-only (no sidecar) |
| **Azure Monitor / Log Analytics** | ARM path (`Microsoft.OperationalInsights` / `Microsoft.Insights`) + `/dataCollectionRules/{id}/streams/{stream}` + `/v1/workspaces/{id}/query` | Workspaces, Data Collection Endpoints/Rules; Logs Ingestion API; Log Analytics query with a KQL subset (`where`/`project`/`take`/`limit` + timespan); HTTP-only (no sidecar) |
diff --git a/compatibility-tests/compat-azcli/run-bats-in-container.sh b/compatibility-tests/compat-azcli/run-bats-in-container.sh
index cabf3488..cba5d62c 100755
--- a/compatibility-tests/compat-azcli/run-bats-in-container.sh
+++ b/compatibility-tests/compat-azcli/run-bats-in-container.sh
@@ -49,9 +49,13 @@ cat /tmp/floci-az.crt >> "$COMBINED_CA"
export REQUESTS_CA_BUNDLE="$COMBINED_CA"
export SSL_CERT_FILE="$COMBINED_CA"
-# *.vault.azure.net data-plane interception: map the test vault DNS to loopback and
-# forward 443 to floci-az, mirroring the terraform suite so `az keyvault secret` works.
+# *.vault.azure.net and *.azurecr.io data-plane interception: map the test vault and
+# registry DNS to loopback and forward 443 to floci-az, mirroring the terraform suite so
+# `az keyvault secret` and `az acr login` reach the emulator on the hostnames the clients
+# build from the service endpoints.
echo "127.0.0.1 floci-test-kv.vault.azure.net" >> /etc/hosts
+# Keep this name in sync with ACR_NAME in test/test_helper/common-setup.bash.
+echo "127.0.0.1 flocitestacr.azurecr.io" >> /etc/hosts
socat TCP-LISTEN:443,bind=127.0.0.1,fork,reuseaddr TCP:"${FLOCI_AZ_HOST}" &
sleep 1
@@ -76,7 +80,8 @@ az cloud register -n floci-az \
--endpoint-active-directory-resource-id "${HTTPS_BASE}/" \
--endpoint-active-directory-graph-resource-id "${HTTPS_BASE}/" \
--suffix-storage-endpoint "core.windows.net" \
- --suffix-keyvault-dns ".vault.azure.net"
+ --suffix-keyvault-dns ".vault.azure.net" \
+ --suffix-acr-login-server-endpoint ".azurecr.io"
az cloud set -n floci-az
diff --git a/compatibility-tests/compat-azcli/test/acr-login.bats b/compatibility-tests/compat-azcli/test/acr-login.bats
new file mode 100644
index 00000000..acbc7817
--- /dev/null
+++ b/compatibility-tests/compat-azcli/test/acr-login.bats
@@ -0,0 +1,141 @@
+#!/usr/bin/env bats
+# ACR data plane: `az acr login` performs the Entra token exchange against
+# {name}.azurecr.io, and the token it returns pushes and pulls an image through the
+# Docker Registry HTTP API V2 on the same host.
+#
+# `az acr login` without --expose-token shells out to `docker`, which this container does
+# not have, so the token flow is exercised with --expose-token (the same challenge and
+# /oauth2/exchange calls) and the push/pull is driven over the registry API directly.
+
+setup_file() {
+ load 'test_helper/common-setup'
+
+ az group create -n "$RG_NAME" -l "$LOCATION" -o none
+ az acr create -n "$ACR_NAME" -g "$RG_NAME" -l "$LOCATION" --sku Basic -o none
+}
+
+setup() {
+ load 'test_helper/common-setup'
+
+ export LOGIN_SERVER="${ACR_NAME}.azurecr.io"
+ export REPO="compat/app"
+ export CA_BUNDLE=/tmp/floci-az.crt
+
+ if [ ! -f "$CA_BUNDLE" ]; then
+ skip "floci-az TLS certificate was not fetched; the registry host cannot be verified"
+ fi
+}
+
+# base64url payload of a JWT, on stdin, decoded to stdout.
+decode_base64url() {
+ python3 -c 'import base64, sys; s = sys.stdin.read().strip(); sys.stdout.write(base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)).decode())'
+}
+
+# curl against the registry host, verifying floci-az's own certificate: the generated cert
+# must carry *.azurecr.io for this to succeed.
+registry_curl() {
+ curl -sS --cacert "$CA_BUNDLE" "$@"
+}
+
+# An ACR refresh token from `az acr login`, exchanged for an access token scoped to $REPO.
+acr_access_token() {
+ local refresh_token
+ refresh_token="$(az acr login -n "$ACR_NAME" --expose-token -o json 2>/dev/null | jq -r '.refreshToken')"
+ [ -n "$refresh_token" ] && [ "$refresh_token" != "null" ] || return 1
+
+ registry_curl -X POST "https://${LOGIN_SERVER}/oauth2/token" \
+ --data-urlencode "grant_type=refresh_token" \
+ --data-urlencode "service=${LOGIN_SERVER}" \
+ --data-urlencode "scope=repository:${REPO}:pull,push" \
+ --data-urlencode "refresh_token=${refresh_token}" | jq -r '.access_token'
+}
+
+@test "az acr: loginServer is the Azure host name" {
+ run az_json acr show -n "$ACR_NAME" -g "$RG_NAME"
+ assert_success
+ assert_equal "$(echo "$output" | jq -r '.loginServer')" "$LOGIN_SERVER"
+}
+
+@test "acr data plane: GET /v2/ challenges with the bearer realm and service" {
+ run registry_curl -o /dev/null -D - "https://${LOGIN_SERVER}/v2/"
+ assert_success
+ assert_output --partial "401"
+ assert_output --partial "realm=\"https://${LOGIN_SERVER}/oauth2/token\""
+ assert_output --partial "service=\"${LOGIN_SERVER}\""
+}
+
+@test "az acr login: exchanges an Entra token for an ACR refresh token" {
+ run az_json acr login -n "$ACR_NAME" --expose-token
+ assert_success
+ assert_equal "$(echo "$output" | jq -r '.loginServer')" "$LOGIN_SERVER"
+ assert_equal "$(echo "$output" | jq -r '.username')" "00000000-0000-0000-0000-000000000000"
+ # The refresh token is a JWT: clients decode it, so it must have three segments.
+ # (The azure-cli image ships no awk, so count in bash.)
+ IFS='.' read -ra segments <<< "$(echo "$output" | jq -r '.refreshToken')"
+ assert_equal "${#segments[@]}" "3"
+}
+
+@test "acr data plane: the refresh token buys a scoped access token" {
+ run acr_access_token
+ assert_success
+ # The Azure CLI decodes this token and reads its access claim when verifying permissions.
+ claims="$(echo "$output" | cut -d. -f2 | decode_base64url)"
+ assert_equal "$(echo "$claims" | jq -r '.access[0].name')" "$REPO"
+ assert_equal "$(echo "$claims" | jq -r '.access[0].actions | join(",")')" "pull,push"
+}
+
+@test "acr data plane: push and pull an image through the login server" {
+ token="$(acr_access_token)"
+ [ -n "$token" ] && [ "$token" != "null" ] || fail "could not obtain an ACR access token"
+ auth=(-H "Authorization: Bearer ${token}")
+
+ config='{}'
+ digest="sha256:$(printf '%s' "$config" | sha256sum | cut -d' ' -f1)"
+
+ # Start an upload session; the Location must come back without the internal prefix.
+ location="$(registry_curl -o /dev/null -D - -X POST "${auth[@]}" \
+ "https://${LOGIN_SERVER}/v2/${REPO}/blobs/uploads/" \
+ | grep -i '^location:' | sed 's/^[^:]*: *//' | tr -d '\r')"
+ [ -n "$location" ] || fail "no upload Location header"
+ case "$location" in
+ "/v2/${REPO}/blobs/uploads/"*) ;;
+ *) fail "upload Location leaked the internal repository prefix: $location" ;;
+ esac
+
+ separator="?"
+ case "$location" in *"?"*) separator="&" ;; esac
+ run registry_curl -o /dev/null -w '%{http_code}' -X PUT "${auth[@]}" \
+ -H "Content-Type: application/octet-stream" --data-binary "$config" \
+ "https://${LOGIN_SERVER}${location}${separator}digest=${digest}"
+ assert_success
+ assert_output "201"
+
+ manifest="{\"schemaVersion\":2,\"mediaType\":\"application/vnd.docker.distribution.manifest.v2+json\",\"config\":{\"mediaType\":\"application/vnd.docker.container.image.v1+json\",\"size\":${#config},\"digest\":\"${digest}\"},\"layers\":[]}"
+ run registry_curl -o /dev/null -w '%{http_code}' -X PUT "${auth[@]}" \
+ -H "Content-Type: application/vnd.docker.distribution.manifest.v2+json" \
+ --data-binary "$manifest" "https://${LOGIN_SERVER}/v2/${REPO}/manifests/v1"
+ assert_success
+ assert_output "201"
+
+ # Pull it back: manifest, then the config blob it references.
+ run registry_curl "${auth[@]}" \
+ -H "Accept: application/vnd.docker.distribution.manifest.v2+json" \
+ "https://${LOGIN_SERVER}/v2/${REPO}/manifests/v1"
+ assert_success
+ assert_equal "$(echo "$output" | jq -r '.config.digest')" "$digest"
+
+ run registry_curl "${auth[@]}" "https://${LOGIN_SERVER}/v2/${REPO}/blobs/${digest}"
+ assert_success
+ assert_output "$config"
+
+ run registry_curl "${auth[@]}" "https://${LOGIN_SERVER}/v2/${REPO}/tags/list"
+ assert_success
+ assert_equal "$(echo "$output" | jq -r '.name')" "$REPO"
+ assert_output --partial "v1"
+
+ # The shared registry stores this as {registry}/{repo}; the catalog must not say so.
+ run registry_curl "${auth[@]}" "https://${LOGIN_SERVER}/v2/_catalog"
+ assert_success
+ assert_output --partial "\"${REPO}\""
+ refute_output --partial "${ACR_NAME}/${REPO}"
+}
diff --git a/compatibility-tests/sdk-test-python/tests/test_acr.py b/compatibility-tests/sdk-test-python/tests/test_acr.py
index 854c9db7..8a346a40 100644
--- a/compatibility-tests/sdk-test-python/tests/test_acr.py
+++ b/compatibility-tests/sdk-test-python/tests/test_acr.py
@@ -1,13 +1,15 @@
"""Azure Container Registry compatibility test.
Provisions a registry through the ARM management plane, then (when the backing
-``registry:2`` sidecar is reachable) pushes a minimal image through the standard
-Docker Registry HTTP API V2 and reads it back.
-
-The data-plane assertions are skipped when the registry never becomes connectable
-(e.g. the emulator runs in mocked mode where ``loginServer`` is the cosmetic
-``{name}.azurecr.io``). Run floci-az with ``floci-az.services.acr.mocked=false``
-to exercise them.
+``registry:2`` sidecar is reachable) pushes a minimal image anonymously through the
+container's own published port and reads it back.
+
+``loginServer`` is ``{name}.azurecr.io``, which needs name resolution and TLS, so
+Azure-native clients are covered by the Azure CLI suite. What this asserts is the
+convenience path: the shared container stays published and anonymous, the registry
+name is the repository prefix there, and ``properties.localPort`` reports the port it
+was published on. The data-plane assertions are skipped when the container is not
+reachable (mocked mode, or no Docker).
"""
import hashlib
import json
@@ -23,6 +25,12 @@
ACR = "sdktestacr"
API = "2025-11-01"
+# Hosts the shared registry container may answer on. The port comes from the registry
+# resource's localPort, so only the host has to be guessed: the container name on the compat
+# Docker network, or the loopback address for a local run. ACR_REGISTRY_ENDPOINT overrides the
+# whole host:port when neither applies.
+REGISTRY_HOSTS = ["floci-az-acr-registry", "localhost"]
+
ARM_BASE = (
f"{EMULATOR_BASE}/subscriptions/{SUB}/resourceGroups/{RG}"
f"/providers/Microsoft.ContainerRegistry/registries/{ACR}"
@@ -64,7 +72,7 @@ def provisioned_registry():
def test_arm_response_shape(provisioned_registry):
props = provisioned_registry
- assert props["loginServer"]
+ assert props["loginServer"] == f"{ACR}.azurecr.io"
assert props["adminUserEnabled"] is True
assert props["username"] == ACR
assert props["password"]
@@ -84,27 +92,51 @@ def test_check_name_availability():
assert "nameAvailable" in taken
-def _registry_reachable(host):
+def _reachable(endpoint):
try:
- r = requests.get(f"http://{host}/v2/", timeout=3)
- return r.status_code in (200, 401)
+ return requests.get(f"http://{endpoint}/v2/", timeout=3).status_code in (200, 401)
except requests.RequestException:
return False
-def test_push_and_pull_via_registry_v2(provisioned_registry):
- props = provisioned_registry
- login = props["loginServer"]
- # Shared registry: loginServer is host:port/{registryName}; the V2 API is at the host root and
- # the registry name is the repo prefix. In mocked mode loginServer is {name}.azurecr.io (no path).
- if "/" not in login:
- pytest.skip("registry runs in mocked mode (no data plane)")
- host, prefix = login.split("/", 1)
- if not _registry_reachable(host):
+def _published_registry(props):
+ """The shared container's published endpoint, or None when it is not running.
+
+ loginServer is the Azure host name, so it does not carry the port. properties.localPort
+ does, the same way a PostgreSQL or MySQL server reports the port its container published.
+ """
+ override = os.environ.get("ACR_REGISTRY_ENDPOINT")
+ if override:
+ return override if _reachable(override) else None
+
+ port = props.get("localPort")
+ if not port:
+ return None
+ for host in REGISTRY_HOSTS:
+ endpoint = f"{host}:{port}"
+ if _reachable(endpoint):
+ return endpoint
+ return None
+
+
+def test_arm_response_reports_the_published_port(provisioned_registry):
+ """localPort is how a client finds the anonymous port, now that loginServer cannot say."""
+ port = provisioned_registry.get("localPort")
+ if port is None:
+ pytest.skip("registry sidecar not running (mocked mode or no Docker)")
+ assert isinstance(port, int) and port > 0
+
+
+def test_push_and_pull_anonymously_via_the_published_port(provisioned_registry):
+ """The published port keeps working without authenticating, over plain HTTP."""
+ host = _published_registry(provisioned_registry)
+ if host is None:
pytest.skip("registry sidecar not reachable (mocked mode or no Docker)")
base = f"http://{host}/v2"
- repo = f"{prefix}/sdk/minimal"
+ # On the published port the registry name is the repository prefix, the same storage
+ # {ACR}.azurecr.io/v2/sdk/minimal/... addresses through the emulator.
+ repo = f"{ACR}/sdk/minimal"
# Push a config blob, then a manifest referencing it (a minimal but valid image).
config = b"{}"
diff --git a/docs/services/acr.md b/docs/services/acr.md
index 4afe0e29..29781a01 100644
--- a/docs/services/acr.md
+++ b/docs/services/acr.md
@@ -1,16 +1,20 @@
# Azure Container Registry
Compatible with the `azure-mgmt-containerregistry` SDK, the `az acr` CLI, Terraform's
-`azurerm_container_registry`, and any ARM-speaking client for the management plane — plus **any
-standard Docker client** (`docker login` / `push` / `pull`, OCI tooling) for the data plane.
+`azurerm_container_registry`, and any ARM-speaking client for the management plane, plus **any
+standard Docker client** (`docker login` / `push` / `pull`, OCI tooling) for the data plane,
+either anonymously on the registry container's published port or with an Entra token on
+`{name}.azurecr.io`.
## Features
-- **Registry lifecycle** — create, get, update, delete; list by resource group and by subscription
-- **Admin credentials** — `listCredentials` / `regenerateCredential` (username + two passwords)
-- **Name availability** — `checkNameAvailability`
-- **Usages** — `listUsages` (static quota report)
-- **Data plane** — a single shared `registry:2` sidecar exposing the **Docker Registry HTTP API V2**
+- **Registry lifecycle**: create, get, update, delete; list by resource group and by subscription
+- **Admin credentials**: `listCredentials` / `regenerateCredential` (username + two passwords)
+- **Name availability**: `checkNameAvailability`
+- **Usages**: `listUsages` (static quota report)
+- **Entra token exchange**: the bearer challenge on `GET /v2/`, `POST /oauth2/exchange` and
+ `/oauth2/token`, so `az acr login` and the Azure SDK container-registry clients work
+- **Data plane**: a single shared `registry:2` sidecar exposing the **Docker Registry HTTP API V2**
(`/v2/…`), so images push and pull with the standard Docker client. All registries are backed by
one container and isolated by an internal repository prefix (`{registryName}/{repo}`)
@@ -25,35 +29,124 @@ GET .../registries/{name}/listUsages
POST /subscriptions/{s}/providers/Microsoft.ContainerRegistry/checkNameAvailability
```
-The **data plane** is served directly by the registry sidecar (not proxied through `4577`).
+The data plane has two surfaces onto the same storage.
-> **`loginServer` deviation (path style).** Because one shared registry backs every ACR, the registry
-> name moves from the host into the path: `loginServer` is `localhost:{port}/{registryName}` natively
-> (or `{container}:5000/{registryName}` when floci-az runs in Docker) — **not** `{name}.azurecr.io`.
-> Docker handles this transparently: `docker login localhost:{port}` ignores the path, and an image
-> ref like `localhost:{port}/{registryName}/app` parses as registry `localhost:{port}` + repo
-> `{registryName}/app`. Docker treats `localhost:PORT` as insecure (plain-HTTP) automatically, so no
-> daemon config is needed. The data plane is on by default (`mocked: false`). In **mocked** mode
-> (no Docker) `loginServer` is the cosmetic `{name}.azurecr.io` for management-plane fidelity.
+**The registry container's published port** is the zero-configuration one. It is plain HTTP,
+anonymous, and Docker treats `localhost:PORT` as insecure automatically, so nothing has to be
+configured:
+
+```
+localhost:5000/{registryName}/{repo}
+```
+
+The port comes from the `base-port`-`max-port` range below and is allocated when the shared
+container starts, on the first registry you create. It is `5000` unless that port is taken, and
+because `loginServer` names the Azure host it cannot carry the port, so the registry resource
+reports it as `properties.localPort`:
+
+```bash
+az acr show -n myregistry -g my-rg --query properties.localPort # 5000
+```
+
+This is a floci-az convenience field, not part of the real ARM contract, and it is the same field
+[PostgreSQL](postgresql.md) and [MySQL](mysql.md) servers use to report the port their container
+published. It is absent in `mocked` mode, where no container runs. The emulator also logs the port
+at startup, and `docker port floci-az-acr-registry 5000` reports it:
+
+```text
+INFO [io.flo.az.ser.acr.AcrRegistryManager] Started shared ACR registry floci-az-acr-registry on host port 5000
+```
+
+**`{name}.azurecr.io`** is what `loginServer` reports and what Azure-native clients use. It is
+served by floci-az on 443 when TLS is on (`FLOCI_AZ_TLS_ENABLED=true`), and carries the endpoints
+those clients need:
+
+```
+GET {name}.azurecr.io/v2/ bearer challenge
+POST {name}.azurecr.io/oauth2/exchange Entra access token → ACR refresh token
+GET|POST {name}.azurecr.io/oauth2/token ACR refresh token → scoped access token
+* {name}.azurecr.io/v2/** Docker Registry HTTP API V2
+```
+
+The registry name is not part of a repository reference here: `{name}.azurecr.io/app` is the
+repository `app`, exactly as in Azure. floci-az adds the `{registryName}/` prefix internally when it
+proxies to the shared container, and strips it again from upload `Location` headers, `tags/list` and
+`_catalog`.
+
+> **Name resolution is required.** `{name}.azurecr.io` must resolve to floci-az, the same
+> requirement `*.vault.azure.net` already carries for Key Vault. Add a hosts entry (or point your
+> resolver at the emulator) and trust the emulator's certificate, which carries `*.azurecr.io`:
+>
+> ```bash
+> echo "127.0.0.1 myregistry.azurecr.io" | sudo tee -a /etc/hosts
+> curl -s http://localhost:4577/_floci/tls-cert -o floci-az.crt # then trust it
+> ```
+>
+> Docker needs the certificate in `/etc/docker/certs.d/myregistry.azurecr.io/ca.crt`. Without name
+> resolution, use the published port instead: it needs none of this.
+>
+> A hosts entry points one name at the emulator, but a wildcard resolver points all of them, so
+> floci-az serves `{name}.azurecr.io` only for a registry that was actually created. A name with no
+> registry behind it answers `404 NAME_UNKNOWN` at every endpoint, challenge and token included,
+> rather than minting tokens for something that does not exist.
+>
+> TLS is off by default. The challenge realm carries the scheme the request arrived with (honouring
+> `X-Forwarded-Proto` behind a terminating proxy), so the token flow is exercisable over plain HTTP,
+> but `az acr login` and Docker both address a named registry as `https://` and need TLS on.
## Authentication
-The shared backing registry runs **anonymous** (mirroring the AWS ECR design in the sibling emulator):
-`docker push`/`pull` work without logging in. The management plane still issues admin credentials
-(`listCredentials` / `regenerateCredential`, username = registry name), and `docker login` with them
-succeeds — but the credentials are **not enforced** at the data plane. Real ACR enforces admin-user
-basic auth and AAD tokens; reproducing per-registry auth on a single shared registry is out of scope.
+`az acr login` and the SDK container-registry clients perform Azure's Entra token exchange rather
+than a Docker login: `GET /v2/` answers `401` with a `Bearer` challenge naming the realm and
+service, an Entra access token is traded for a registry refresh token at `/oauth2/exchange`, and
+that refresh token is traded for a scoped access token at `/oauth2/token`. Docker then logs in with
+the username `00000000-0000-0000-0000-000000000000` and the access token as the password. floci-az
+serves all of it, in both `mocked` modes.
+
+Intentional deviations, all of them a consequence of the emulator's existing stance that ARM and
+Shared Key credentials are accepted without verification:
+
+- **Tokens are issued, not verified.** Both tokens are real RS256 JWTs signed by the emulator's
+ Entra signing key, so clients that decode them keep working (the Azure CLI reads the access
+ token's `access` claim). Nothing checks the Entra token presented at `/oauth2/exchange`, and
+ nothing checks the refresh token presented at `/oauth2/token`.
+- **No authorization is enforced.** The requested scope is recorded in the access token's `access`
+ claim and then ignored: any token, and any identity, reaches every repository. Only `GET /v2/`
+ challenges; repository paths are served whether or not a token is presented. Admin credentials
+ are accepted at the token endpoint so the `password` grant is truthful, but they are not required.
+- **The shared backing registry runs anonymous** (mirroring the AWS ECR design in the sibling
+ emulator), so its published port serves push and pull with no credentials at all.
+
+Real ACR enforces per-registry auth and repository-scoped permissions; reproducing that on a single
+shared registry is out of scope.
+
+## Example
-## Example (non-mocked)
+Anonymous, through the published port. `5000` below is the first free port in the configured range,
+so substitute the one the startup log reports (`Started shared ACR registry ... on host port`):
```bash
-# create (az / terraform / raw ARM) → loginServer like localhost:5000/myregistry
docker tag busybox localhost:5000/myregistry/demo/busybox:v1
docker push localhost:5000/myregistry/demo/busybox:v1
curl http://localhost:5000/v2/_catalog # {"repositories":["myregistry/demo/busybox", ...]}
docker pull localhost:5000/myregistry/demo/busybox:v1
```
+The same image, through the Azure login server (hosts entry and trusted certificate in place):
+
+```bash
+az acr create -n myregistry -g my-rg --sku Basic # loginServer: myregistry.azurecr.io
+az acr login -n myregistry
+docker tag busybox myregistry.azurecr.io/demo/busybox:v1
+docker push myregistry.azurecr.io/demo/busybox:v1
+docker pull myregistry.azurecr.io/demo/busybox:v1
+```
+
+`az acr login --expose-token` returns the refresh token without needing a Docker daemon.
+
+In **mocked** mode (no Docker) the management plane and the whole auth surface still work;
+repository operations answer with the registry's `UNSUPPORTED` error, since no container is running.
+
## Configuration
```yaml
@@ -77,8 +170,9 @@ floci-az:
## Out of scope (future work)
-- AAD token auth (`/oauth2/token`) and the `az acr login` token flow — use the admin user + `docker login`.
+- Token signature verification, repository-level authorization and scope enforcement.
+- Scope maps and tokens as ACR resources, content trust, quarantine, anonymous pull configuration.
- `importImage` actual layer copy (accepted as a `202` no-op).
-- Geo-replication, webhooks, ACR Tasks, private link, content trust, retention/quarantine policies
+- Geo-replication, webhooks, ACR Tasks, private link, retention policies
(accepted and echoed as static properties, not enforced).
- SKU behavioral differences (Basic/Standard/Premium accepted; no functional difference).
diff --git a/docs/services/index.md b/docs/services/index.md
index f7fa172c..a1a831d6 100644
--- a/docs/services/index.md
+++ b/docs/services/index.md
@@ -25,7 +25,7 @@ Floci-AZ provides emulation for several core Azure services.
| **Virtual Network** | ARM path (`Microsoft.Network`) | ✅ VNets, subnets, NICs, public IPs, NSGs, private DNS zones (+ virtual network links, record sets), private endpoints (+ private DNS zone groups), private link services; in-process ARM state for Terraform/OpenTofu and VM dependencies |
| **Virtual Machines** | ARM path (`Microsoft.Compute`) | ✅ VM lifecycle (create/start/stop/deallocate/restart/delete/list), instanceView; mocked (Docker backing planned) |
| **Azure Cache for Redis** | ARM path (`Microsoft.Cache`) | ✅ Cache CRUD, listKeys/regenerateKey; real Redis containers (data plane) or mocked |
-| **Azure Container Registry** | ARM path (`Microsoft.ContainerRegistry`) | ✅ Registry CRUD, admin credentials, checkNameAvailability; one shared `registry:2` (Docker Registry V2 push/pull) or mocked |
+| **Azure Container Registry** | ARM path (`Microsoft.ContainerRegistry`) | ✅ Registry CRUD, admin credentials, checkNameAvailability; `az acr login` via the Entra token exchange on `{name}.azurecr.io`; one shared `registry:2` (Docker Registry V2 push/pull) or mocked |
| **Azure Container Instances** | ARM path (`Microsoft.ContainerInstance`) | ✅ Container group lifecycle (create/update/delete/list), start/stop/restart, container logs, instanceView; mocked (Docker backing planned) |
| **Microsoft Entra ID** | `/{tenant}/oauth2/...` + `/.well-known/openid-configuration` | ✅ OpenID Connect provider — RS256-signed tokens, JWKS, discovery; client-credentials, ROPC, and authorization-code+PKCE grants (app registration management still planned) |
| **Microsoft Graph** | `/v1.0/...` | ✅ Narrow slice: service principal discovery, group-membership management (`getMemberGroups`, `members/$ref`); full Graph CRUD out of scope |
diff --git a/src/main/java/io/floci/az/core/AzureRequest.java b/src/main/java/io/floci/az/core/AzureRequest.java
index 8e89bf26..c09993bf 100644
--- a/src/main/java/io/floci/az/core/AzureRequest.java
+++ b/src/main/java/io/floci/az/core/AzureRequest.java
@@ -18,7 +18,8 @@ public record AzureRequest(
boolean secure, // true when the request arrived over HTTPS
String host, // host captured before async/blocking dispatch; may be null for direct/internal requests
String remoteAddress, // transport peer address; never derived from forwarded headers
- String rawPath // original encoded request path, without a leading slash
+ String rawPath, // original encoded request path, without a leading slash
+ String rawQuery // original encoded query string, without the leading "?"; null when absent
) {
public AzureRequest(String method, String accountName, String serviceType, String resourcePath,
@@ -26,7 +27,7 @@ public AzureRequest(String method, String accountName, String serviceType, Strin
Map> queryParamsMulti, AuthContext authContext,
boolean secure) {
this(method, accountName, serviceType, resourcePath, headers, bodyStream,
- queryParams, queryParamsMulti, authContext, secure, null, null, resourcePath);
+ queryParams, queryParamsMulti, authContext, secure, null, null, resourcePath, null);
}
/**
@@ -38,7 +39,7 @@ public AzureRequest(String method, String accountName, String serviceType, Strin
HttpHeaders headers, InputStream bodyStream, Map queryParams,
AuthContext authContext, boolean secure) {
this(method, accountName, serviceType, resourcePath, headers, bodyStream,
- queryParams, Map.of(), authContext, secure, null, null, resourcePath);
+ queryParams, Map.of(), authContext, secure, null, null, resourcePath, null);
}
/**
@@ -49,14 +50,14 @@ public AzureRequest(String method, String accountName, String serviceType, Strin
Map> queryParamsMulti, AuthContext authContext, boolean secure,
String host) {
this(method, accountName, serviceType, resourcePath, headers, bodyStream,
- queryParams, queryParamsMulti, authContext, secure, host, null, resourcePath);
+ queryParams, queryParamsMulti, authContext, secure, host, null, resourcePath, null);
}
public AzureRequest(String method, String accountName, String serviceType, String resourcePath,
HttpHeaders headers, InputStream bodyStream, Map queryParams,
AuthContext authContext, boolean secure, String remoteAddress) {
this(method, accountName, serviceType, resourcePath, headers, bodyStream,
- queryParams, Map.of(), authContext, secure, null, remoteAddress, resourcePath);
+ queryParams, Map.of(), authContext, secure, null, remoteAddress, resourcePath, null);
}
/**
@@ -66,6 +67,6 @@ public AzureRequest(String method, String accountName, String serviceType, Strin
*/
public AzureRequest withAuthContext(AuthContext resolved) {
return new AzureRequest(method, accountName, serviceType, resourcePath, headers, bodyStream,
- queryParams, queryParamsMulti, resolved, secure, host, remoteAddress, rawPath);
+ queryParams, queryParamsMulti, resolved, secure, host, remoteAddress, rawPath, rawQuery);
}
}
diff --git a/src/main/java/io/floci/az/core/AzureRoutingFilter.java b/src/main/java/io/floci/az/core/AzureRoutingFilter.java
index 6d0ed7d7..9c748512 100644
--- a/src/main/java/io/floci/az/core/AzureRoutingFilter.java
+++ b/src/main/java/io/floci/az/core/AzureRoutingFilter.java
@@ -95,6 +95,7 @@ private record RoutingContext(
ContainerRequestContext requestContext,
String path,
String rawPath,
+ String rawQuery,
HttpHeaders headers,
String host,
boolean secure,
@@ -327,6 +328,9 @@ public Uni filter(ContainerRequestContext requestContext, @Context Htt
// Capture context before switching threads
String path0 = requestContext.getUriInfo().getPath();
String rawPath0 = serverRequest.path();
+ // The query exactly as the client sent it. Proxying handlers forward it verbatim rather
+ // than re-encoding the decoded parameters, which would not round-trip opaque values.
+ String rawQuery0 = serverRequest.query();
HttpHeaders headers = httpHeaders;
// Capture the request authority/host now (JAX-RS request scope may not propagate to the
// blocking thread). Under HTTP/2 the wire protocol uses :authority instead of a Host
@@ -345,13 +349,14 @@ public Uni filter(ContainerRequestContext requestContext, @Context Htt
return Uni.createFrom().completionStage(
vertx.executeBlocking(() -> doFilter(
- requestContext, path0, rawPath0, headers, capturedHost, remoteAddress))
+ requestContext, path0, rawPath0, rawQuery0, headers, capturedHost, remoteAddress))
.toCompletionStage()
);
}
private Response doFilter(ContainerRequestContext requestContext, String decodedPath, String rawPath,
- HttpHeaders headers, String capturedHost, String remoteAddress) {
+ String rawQuery, HttpHeaders headers, String capturedHost,
+ String remoteAddress) {
// Never trust a client-supplied account-suffix header: only dispatchByAccountSuffix may set it.
// Header names are case-insensitive on the wire, so match keys case-insensitively.
for (String header : new ArrayList<>(requestContext.getHeaders().keySet())) {
@@ -368,7 +373,7 @@ private Response doFilter(ContainerRequestContext requestContext, String decoded
LOGGER.infof("Incoming request: %s %s", requestContext.getMethod(), path);
- RoutingContext ctx = new RoutingContext(requestContext, path, encodedPath, headers,
+ RoutingContext ctx = new RoutingContext(requestContext, path, encodedPath, rawQuery, headers,
hostWithoutPort(capturedHost), requestContext.getSecurityContext().isSecure(), remoteAddress);
for (Function stage : stages) {
@@ -751,7 +756,7 @@ private Outcome dispatchWithoutAuth(RoutingContext ctx, String serviceType, Stri
}
AzureRequest request = new AzureRequest(ctx.method(), serviceType, serviceType, ctx.path(),
ctx.headers(), ctx.requestContext().getEntityStream(), singleValueQueryParams(ctx.requestContext()),
- Map.of(), null, ctx.secure(), ctx.host(), ctx.remoteAddress(), ctx.rawPath());
+ Map.of(), null, ctx.secure(), ctx.host(), ctx.remoteAddress(), ctx.rawPath(), ctx.rawQuery());
LOGGER.infof("Dispatching %s request to %s: %s %s", label,
handler.get().getClass().getSimpleName(), ctx.method(), ctx.path());
return new Handled(handler.get().handle(request));
@@ -768,7 +773,7 @@ private AzureRequest buildRequest(RoutingContext ctx, String account, String ser
AzureRequest request = new AzureRequest(ctx.method(), account, serviceType, path, ctx.headers(),
ctx.requestContext().getEntityStream(), queryParams, queryParamsMulti, null, ctx.secure(),
- ctx.host(), ctx.remoteAddress(), ctx.rawPath());
+ ctx.host(), ctx.remoteAddress(), ctx.rawPath(), ctx.rawQuery());
return request.withAuthContext(authPipeline.resolve(request));
}
diff --git a/src/main/java/io/floci/az/core/FormBody.java b/src/main/java/io/floci/az/core/FormBody.java
new file mode 100644
index 00000000..0bd56d94
--- /dev/null
+++ b/src/main/java/io/floci/az/core/FormBody.java
@@ -0,0 +1,55 @@
+package io.floci.az.core;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Parses {@code application/x-www-form-urlencoded} request bodies. OAuth-style endpoints
+ * (Entra's token endpoint, the ACR token exchange) are the only form-encoded surfaces in the
+ * emulator, and both read the body as a flat map of decoded parameters.
+ */
+public final class FormBody {
+
+ private FormBody() {
+ }
+
+ /**
+ * Decoded form parameters; an unreadable or empty body yields an empty map.
+ *
+ * A pair that will not decode is dropped rather than thrown: a malformed percent escape is
+ * client input, and the endpoints answer a parameter that did not arrive with their own
+ * documented error. Letting {@link URLDecoder} throw here would escape the handler instead,
+ * because nothing on the dispatch path catches it, and a 500 would replace that error.
+ */
+ public static Map parse(InputStream body) {
+ Map result = new HashMap<>();
+ byte[] bytes;
+ try {
+ bytes = body == null ? new byte[0] : body.readAllBytes();
+ } catch (IOException e) {
+ return result;
+ }
+ String content = new String(bytes, StandardCharsets.UTF_8);
+ if (content.isBlank()) {
+ return result;
+ }
+ for (String pair : content.split("&")) {
+ int eq = pair.indexOf('=');
+ if (eq < 0) {
+ continue;
+ }
+ try {
+ String key = URLDecoder.decode(pair.substring(0, eq), StandardCharsets.UTF_8);
+ String value = URLDecoder.decode(pair.substring(eq + 1), StandardCharsets.UTF_8);
+ result.put(key, value);
+ } catch (IllegalArgumentException e) {
+ // A malformed escape such as "%zz"; the parameter is treated as absent.
+ }
+ }
+ return result;
+ }
+}
diff --git a/src/main/java/io/floci/az/core/RequestUrls.java b/src/main/java/io/floci/az/core/RequestUrls.java
index 798fc45f..80987799 100644
--- a/src/main/java/io/floci/az/core/RequestUrls.java
+++ b/src/main/java/io/floci/az/core/RequestUrls.java
@@ -2,12 +2,33 @@
import io.floci.az.config.EmulatorConfig;
+import java.util.Locale;
+
/** URL helpers for handlers that echo the caller's origin back in generated URLs. */
public final class RequestUrls {
private RequestUrls() {
}
+ /**
+ * The scheme the caller used: {@code X-Forwarded-Proto} when a TLS-terminating reverse proxy
+ * fronts the emulator (the proxy-to-emulator hop is plaintext, but the client's scheme is what
+ * generated URLs must carry), and the transport otherwise. Same rule as the Cosmos, PostgreSQL
+ * and SQL handlers apply to the endpoints they advertise.
+ */
+ public static String resolveScheme(AzureRequest request) {
+ String forwarded = request.headers() == null
+ ? null : request.headers().getHeaderString("X-Forwarded-Proto");
+ if (forwarded != null && !forwarded.isBlank()) {
+ // A proxy chain may send a comma-separated list; the first value is the client's.
+ String scheme = forwarded.split(",")[0].trim().toLowerCase(Locale.ROOT);
+ if (scheme.equals("http") || scheme.equals("https")) {
+ return scheme;
+ }
+ }
+ return request.secure() ? "https" : "http";
+ }
+
/** Base URL as seen by the caller — Host header when present, configured base URL otherwise. */
public static String resolveBaseUrl(AzureRequest request, EmulatorConfig config) {
String host = request.headers() == null ? null : request.headers().getHeaderString("Host");
diff --git a/src/main/java/io/floci/az/core/tls/TlsConfigSource.java b/src/main/java/io/floci/az/core/tls/TlsConfigSource.java
index cc6409e3..b8ef46b6 100644
--- a/src/main/java/io/floci/az/core/tls/TlsConfigSource.java
+++ b/src/main/java/io/floci/az/core/tls/TlsConfigSource.java
@@ -195,7 +195,8 @@ private static List buildSanList(List customHostnames) {
// (not in a container).
all.addAll(List.of("localhost", "127.0.0.1", "0.0.0.0", "*.localhost",
"localhost.floci-az.io", "*.localhost.floci-az.io",
- "*.vault.azure.net", "*.managedhsm.azure.net", "host.docker.internal"));
+ "*.vault.azure.net", "*.managedhsm.azure.net", "*.azurecr.io",
+ "host.docker.internal"));
all.addAll(customHostnames);
return all;
}
diff --git a/src/main/java/io/floci/az/services/acr/AcrErrors.java b/src/main/java/io/floci/az/services/acr/AcrErrors.java
new file mode 100644
index 00000000..42bdf365
--- /dev/null
+++ b/src/main/java/io/floci/az/services/acr/AcrErrors.java
@@ -0,0 +1,40 @@
+package io.floci.az.services.acr;
+
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Errors in the Docker Registry HTTP API V2 envelope,
+ * {@code {"errors":[{"code":"...","message":"..."}]}}. The registry data plane and the ACR token
+ * endpoints in front of it both answer in this shape; the ARM management plane keeps using
+ * {@link io.floci.az.core.arm.ArmErrors}.
+ */
+final class AcrErrors {
+
+ /** The requested operation is not implemented here (OCI distribution-spec {@code UNSUPPORTED}). */
+ static final String UNSUPPORTED = "UNSUPPORTED";
+ /** The registry data plane is not currently reachable (OCI distribution-spec {@code UNAVAILABLE}). */
+ static final String UNAVAILABLE = "UNAVAILABLE";
+ /** The named repository or registry is unknown (OCI distribution-spec {@code NAME_UNKNOWN}). */
+ static final String NAME_UNKNOWN = "NAME_UNKNOWN";
+ /** Authentication is required or the credential presented is unusable ({@code UNAUTHORIZED}). */
+ static final String UNAUTHORIZED = "UNAUTHORIZED";
+
+ private AcrErrors() {
+ }
+
+ static Response error(int status, String code, String message) {
+ return Response.status(status)
+ .entity(Map.of("errors", List.of(Map.of("code", code, "message", message))))
+ .type(MediaType.APPLICATION_JSON)
+ .build();
+ }
+
+ /** 400 for a malformed or unsupported token request. */
+ static Response badRequest(String message) {
+ return error(400, UNSUPPORTED, message);
+ }
+}
diff --git a/src/main/java/io/floci/az/services/acr/AcrHandler.java b/src/main/java/io/floci/az/services/acr/AcrHandler.java
index 03d73985..60596712 100644
--- a/src/main/java/io/floci/az/services/acr/AcrHandler.java
+++ b/src/main/java/io/floci/az/services/acr/AcrHandler.java
@@ -8,6 +8,7 @@
import io.floci.az.core.AzureRequest;
import io.floci.az.core.AzureServiceHandler;
import io.floci.az.core.ServiceRoutes;
+import io.floci.az.core.RequestUrls;
import io.floci.az.core.Resettable;
import io.floci.az.core.StoredObject;
import io.floci.az.core.storage.StorageBackend;
@@ -28,6 +29,7 @@
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
@@ -55,11 +57,23 @@
*
*
* Data plane
- * Each registry is backed by a real {@code registry:2} sidecar exposing the standard Docker
- * Registry HTTP API V2. {@code loginServer} returns the actually-reachable host:port of that
- * sidecar (localhost natively, the container name when floci-az runs in Docker), so {@code docker
- * login/push/pull} work against it directly. In {@code mocked} mode (default) no sidecar is started
- * and {@code loginServer} is the cosmetic {@code {name}.azurecr.io} for management-plane fidelity.
+ *
+ * GET {name}.azurecr.io/v2/ challenge
+ * POST {name}.azurecr.io/oauth2/exchange Entra access token -> ACR refresh token
+ * * {name}.azurecr.io/oauth2/token ACR refresh token -> scoped access token
+ * * {name}.azurecr.io/v2/** Docker Registry HTTP API V2
+ *
+ *
+ * {@code loginServer} is {@code {name}.azurecr.io}, exactly as Azure reports it, and the host
+ * suffix routes the data plane here. One shared {@code registry:2} sidecar backs every registry, so
+ * the registry name becomes an internal repository prefix when {@link AcrRegistryProxy} forwards
+ * {@code /v2/} requests to it; the container's own published port keeps serving the same storage
+ * anonymously over plain HTTP.
+ *
+ * The auth surface works in either mode. In {@code mocked} mode no sidecar is started, so
+ * repository operations answer with the registry's {@code UNSUPPORTED} error. A host naming a
+ * registry that was never created answers {@code NAME_UNKNOWN} at every endpoint: the caller's
+ * resolver points the whole {@code .azurecr.io} space here, but only created registries exist.
*/
@ApplicationScoped
public class AcrHandler implements AzureServiceHandler, Resettable {
@@ -75,8 +89,13 @@ public class AcrHandler implements AzureServiceHandler, Resettable {
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
private static final String PROVIDER = "/providers/Microsoft.ContainerRegistry/";
+ /** The Azure host suffix the registry data plane is served on. */
+ public static final String REGISTRY_HOST_SUFFIX = ".azurecr.io";
+
private final EmulatorConfig config;
private final AcrRegistryManager registryManager;
+ private final AcrTokenService tokenService;
+ private final AcrRegistryProxy registryProxy;
private final StorageBackend storage;
private final ScheduledExecutorService poller = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "acr-readiness-poller");
@@ -87,9 +106,13 @@ public class AcrHandler implements AzureServiceHandler, Resettable {
@Inject
public AcrHandler(EmulatorConfig config,
AcrRegistryManager registryManager,
+ AcrTokenService tokenService,
+ AcrRegistryProxy registryProxy,
StorageFactory storageFactory) {
this.config = config;
this.registryManager = registryManager;
+ this.tokenService = tokenService;
+ this.registryProxy = registryProxy;
this.storage = storageFactory.create("acr");
}
@@ -119,6 +142,7 @@ public boolean enabled(String serviceType) {
@Override
public ServiceRoutes routes() {
return ServiceRoutes.builder()
+ .host(REGISTRY_HOST_SUFFIX)
.provider("Microsoft.ContainerRegistry")
.build();
}
@@ -133,6 +157,11 @@ public Response handle(AzureRequest req) {
LOG.debugf("AcrHandler: %s %s", method, fullPath);
+ String registryHost = registryFromHost(req.host());
+ if (registryHost != null) {
+ return handleDataPlane(req, registryHost);
+ }
+
String tail = extractAcrPath(fullPath);
// ── checkNameAvailability (provider root) ──────────────────────────
@@ -189,6 +218,98 @@ public Response handle(AzureRequest req) {
return notFound("Unknown ACR path: " + tail);
}
+ // ── Data plane ({name}.azurecr.io) ───────────────────────────────────────────
+
+ /**
+ * The registry a request is addressed to, taken from the {@code {name}.azurecr.io} host, or
+ * {@code null} when the request did not arrive on the registry host. The challenge and token
+ * endpoints exist only there, never on the shared container's published port.
+ */
+ static String registryFromHost(String host) {
+ if (host == null) {
+ return null;
+ }
+ String lower = host.toLowerCase(Locale.ROOT);
+ if (!lower.endsWith(REGISTRY_HOST_SUFFIX)) {
+ return null;
+ }
+ String name = lower.substring(0, lower.length() - REGISTRY_HOST_SUFFIX.length());
+ return name.isEmpty() ? null : name;
+ }
+
+ /** {@code {name}.azurecr.io}, the login server Azure itself reports. */
+ static String loginServer(String registryName) {
+ return registryName.toLowerCase(Locale.ROOT) + REGISTRY_HOST_SUFFIX;
+ }
+
+ private Response handleDataPlane(AzureRequest req, String registryName) {
+ String path = stripTrailingSlash(req.resourcePath());
+ String loginServer = loginServer(registryName);
+
+ // Azure resolves {name}.azurecr.io only for a registry that exists. Here the host is
+ // whatever the caller's resolver points at the emulator, so an unknown name would
+ // otherwise be handed tokens and a repository prefix of its own.
+ if (!registryExists(registryName)) {
+ return AcrErrors.error(404, AcrErrors.NAME_UNKNOWN,
+ "the registry '" + loginServer + "' does not exist");
+ }
+
+ if (path.equals("oauth2/exchange")) {
+ return tokenService.handleExchange(req, loginServer);
+ }
+ if (path.equals("oauth2/token")) {
+ return tokenService.handleToken(req, loginServer);
+ }
+ if (path.equals("v2") || path.startsWith("v2/")) {
+ return handleRepository(req, registryName, loginServer, path);
+ }
+ return AcrErrors.error(404, AcrErrors.NAME_UNKNOWN,
+ "unknown registry path '" + path + "' on " + loginServer);
+ }
+
+ /**
+ * {@code GET /v2/} without credentials answers the bearer challenge that starts the token
+ * exchange; everything else is proxied to the shared container. Tokens are issued, not verified,
+ * so any {@code Authorization} header satisfies this check.
+ */
+ private Response handleRepository(AzureRequest req, String registryName, String loginServer, String path) {
+ boolean authenticated = req.headers() != null
+ && req.headers().getHeaderString("Authorization") != null;
+ if (path.equals("v2") && !authenticated) {
+ return AcrTokenService.challengeResponse(RequestUrls.resolveScheme(req), loginServer);
+ }
+ if (config.services().acr().mocked()) {
+ return AcrErrors.error(405, AcrErrors.UNSUPPORTED,
+ "the registry data plane is not available in mocked mode; "
+ + "set floci-az.services.acr.mocked=false to start the shared registry");
+ }
+ String endpoint = dataPlaneEndpoint();
+ if (endpoint == null) {
+ return AcrErrors.error(502, AcrErrors.UNAVAILABLE,
+ "the shared registry container is not running");
+ }
+ return registryProxy.proxy(req, registryName, endpoint);
+ }
+
+ /** The shared container's endpoint, starting it on first use as the management plane does. */
+ private String dataPlaneEndpoint() {
+ try {
+ registryManager.ensureStarted();
+ } catch (Exception e) {
+ LOG.errorf(e, "Failed to start the shared ACR registry for a data-plane request");
+ return null;
+ }
+ return registryManager.dataPlaneEndpoint();
+ }
+
+ private static String stripTrailingSlash(String path) {
+ String trimmed = path == null ? "" : path;
+ while (trimmed.endsWith("/")) {
+ trimmed = trimmed.substring(0, trimmed.length() - 1);
+ }
+ return trimmed;
+ }
+
// ── CRUD operations ──────────────────────────────────────────────────────────
private Response handleCreateOrUpdate(String sub, String rg, String registryName, AzureRequest req) {
@@ -222,13 +343,12 @@ private Response handleCreateOrUpdate(String sub, String rg, String registryName
registry.setAdminUserEnabled(props.path("adminUserEnabled").asBoolean(false));
registry.setTags(parseStringMap(body.path("tags")));
+ registry.setLoginServer(loginServer(registryName));
if (config.services().acr().mocked()) {
registry.setProvisioningState("Succeeded");
- registry.setLoginServer(registryName.toLowerCase() + ".azurecr.io");
} else if (isNew) {
try {
registryManager.ensureStarted();
- registry.setLoginServer(registryManager.loginServer(registryName));
registry.setProvisioningState(registryManager.isReady() ? "Succeeded" : "Creating");
} catch (Exception e) {
LOG.errorf(e, "Failed to start shared ACR registry for %s", registryName);
@@ -377,20 +497,9 @@ private void startReadinessPoller() {
registryManager.ensureStarted();
if (registryManager.isReady()) {
LOG.infov("ACR registry {0} is now ready", reg.getName());
- reg.setLoginServer(registryManager.loginServer(reg.getName()));
reg.setProvisioningState("Succeeded");
putRegistry(reg.storageKey(), reg);
}
- } else if ("Succeeded".equals(reg.getProvisioningState()) && registryManager.isStarted()) {
- // A recovery restart may have moved the shared registry to a new
- // host port; converge stored records to the live endpoint.
- String liveLoginServer = registryManager.loginServer(reg.getName());
- if (!liveLoginServer.equals(reg.getLoginServer())) {
- LOG.infov("ACR registry {0} loginServer refreshed to {1}",
- reg.getName(), liveLoginServer);
- reg.setLoginServer(liveLoginServer);
- putRegistry(reg.storageKey(), reg);
- }
}
});
} catch (Exception e) {
@@ -421,6 +530,14 @@ private void putRegistry(String key, Registry registry) {
}
}
+ /**
+ * Whether a registry of this name was created, in any subscription or resource group. The data
+ * plane knows only the name: {@code {name}.azurecr.io} carries no ARM scope.
+ */
+ private boolean registryExists(String registryName) {
+ return scanAll().stream().anyMatch(r -> registryName.equalsIgnoreCase(r.getName()));
+ }
+
private List scanAll() {
List result = new ArrayList<>();
storage.scan(k -> true).forEach(so -> {
@@ -455,6 +572,13 @@ private Map toArmResponse(Registry registry) {
props.put("zoneRedundancy", "Disabled");
props.put("dataEndpointEnabled", false);
props.put("networkRuleBypassOptions", "AzureServices");
+ // floci-az convenience, not in the real spec: the host port the shared registry container
+ // publishes, which serves the same storage anonymously over plain HTTP. loginServer names
+ // the Azure host, so nothing else reports it. Same field PostgreSQL and MySQL servers carry.
+ int localPort = registryManager.publishedPort();
+ if (localPort > 0) {
+ props.put("localPort", localPort);
+ }
Map out = new LinkedHashMap<>();
out.put("id", registry.armId());
diff --git a/src/main/java/io/floci/az/services/acr/AcrModels.java b/src/main/java/io/floci/az/services/acr/AcrModels.java
index f8d39402..d71df5a2 100644
--- a/src/main/java/io/floci/az/services/acr/AcrModels.java
+++ b/src/main/java/io/floci/az/services/acr/AcrModels.java
@@ -29,7 +29,7 @@ public static class Registry {
private String password; // primary admin password
private String password2; // secondary admin password
- /** Path-prefixed registry endpoint: {@code localhost:{port}/{name}} (shared registry). */
+ /** The registry's Azure login server: {@code {name}.azurecr.io}. */
private String loginServer;
private String provisioningState;
diff --git a/src/main/java/io/floci/az/services/acr/AcrRegistryManager.java b/src/main/java/io/floci/az/services/acr/AcrRegistryManager.java
index 30798a87..0312437e 100644
--- a/src/main/java/io/floci/az/services/acr/AcrRegistryManager.java
+++ b/src/main/java/io/floci/az/services/acr/AcrRegistryManager.java
@@ -18,12 +18,14 @@
/**
* Manages the lifecycle of the single shared {@code registry:2} container that backs every emulated
* Azure Container Registry. There is one container per floci-az instance, started lazily on first use
- * and reused across all registries — mirroring the AWS ECR design in the sibling emulator.
+ * and reused across all registries, mirroring the AWS ECR design in the sibling emulator.
*
* Registries are isolated within the shared registry by an internal repository prefix
- * ({@code {registryName}/{repo}}), so {@code loginServer} carries the registry name as a path segment:
- * {@code localhost:{port}/{registryName}}. The backing registry runs anonymous — admin
- * credentials are returned by the management plane but not enforced at the data plane.
+ * ({@code {registryName}/{repo}}), which {@link AcrRegistryProxy} applies when it forwards requests
+ * from {@code {name}.azurecr.io}. The container's own port stays published, so
+ * {@code localhost:{port}/{registryName}/{repo}} keeps addressing the same storage directly. The
+ * backing registry runs anonymous: admin credentials are returned by the management plane
+ * but not enforced at the data plane.
*/
@ApplicationScoped
public class AcrRegistryManager {
@@ -41,9 +43,9 @@ private String sharedName() {
private final EmulatorConfig config;
private volatile boolean started;
- private volatile int hostPort;
private volatile String containerId;
private volatile String internalEndpoint;
+ private volatile int publishedPort;
@Inject
public AcrRegistryManager(ContainerBuilder containerBuilder,
@@ -88,7 +90,7 @@ public synchronized void ensureStarted() {
ContainerLifecycleManager.ContainerInfo info = lifecycleManager.createAndStart(spec);
this.containerId = info.containerId();
- this.hostPort = chosenPort;
+ this.publishedPort = chosenPort;
ContainerLifecycleManager.EndpointInfo ep = info.getEndpoint(REGISTRY_PORT);
if (containerDetector.isRunningInContainer()) {
@@ -103,12 +105,22 @@ public synchronized void ensureStarted() {
}
}
- /** The path-prefixed {@code loginServer} for a registry (host[:port]/{registryName}). */
- public String loginServer(String registryName) {
- String host = containerDetector.isRunningInContainer()
- ? sharedName() + ":" + REGISTRY_PORT
- : "localhost:" + hostPort;
- return host + "/" + registryName;
+ /**
+ * The shared registry's {@code host:port} as reachable from floci-az itself, or {@code null}
+ * before it has started. This is what the {@code /v2/} proxy forwards to.
+ */
+ public String dataPlaneEndpoint() {
+ return started ? internalEndpoint : null;
+ }
+
+ /**
+ * The host port the shared container publishes {@code registry:5000} on, or {@code 0} before it
+ * has started. Reported to clients as the registry resource's {@code localPort}, the way
+ * PostgreSQL and MySQL report theirs: {@code loginServer} names the Azure host, so it is the only
+ * thing that says which port serves the same storage anonymously.
+ */
+ public int publishedPort() {
+ return started ? publishedPort : 0;
}
/** Polls the shared registry's V2 base endpoint to detect readiness. */
@@ -129,10 +141,6 @@ public boolean isReady() {
}
}
- public boolean isStarted() {
- return started;
- }
-
/** Stops and removes the shared registry container (emulator shutdown). */
public synchronized void shutdown() {
if (started && containerId != null) {
diff --git a/src/main/java/io/floci/az/services/acr/AcrRegistryProxy.java b/src/main/java/io/floci/az/services/acr/AcrRegistryProxy.java
new file mode 100644
index 00000000..6605741b
--- /dev/null
+++ b/src/main/java/io/floci/az/services/acr/AcrRegistryProxy.java
@@ -0,0 +1,431 @@
+package io.floci.az.services.acr;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import io.floci.az.core.AzureRequest;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+import org.jboss.logging.Logger;
+
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URLEncoder;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Proxies the Docker Registry HTTP API V2 from {@code {name}.azurecr.io/v2/…} to the single shared
+ * {@code registry:2} container, namespacing each repository with the registry name.
+ *
+ * One container backs every emulated registry, so the registry name is an internal repository
+ * prefix: {@code {name}.azurecr.io/v2/app/manifests/v1} reaches the container as
+ * {@code /v2/{name}/app/manifests/v1}. The prefix is stripped again from anything the client reads
+ * back (upload {@code Location} headers, the catalog), so it never leaks into the client's view.
+ */
+@ApplicationScoped
+public class AcrRegistryProxy {
+
+ private static final Logger LOG = Logger.getLogger(AcrRegistryProxy.class);
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ /** Headers that belong to one hop and must not be forwarded; also the ones HttpClient rejects. */
+ private static final Set HOP_BY_HOP = Set.of(
+ "connection", "content-length", "expect", "host", "keep-alive",
+ "proxy-authenticate", "proxy-authorization", "te", "trailer",
+ "transfer-encoding", "upgrade");
+
+ /** The shared container is anonymous: the client's registry token is meaningless to it. */
+ private static final Set DROPPED_REQUEST_HEADERS = Set.of("authorization");
+
+ /** Response headers the emulator re-derives rather than copying from the backend. */
+ private static final Set REWRITTEN_RESPONSE_HEADERS = Set.of("location");
+
+ /** Methods that can carry a request body, and so may stream one of undeclared length. */
+ private static final Set BODY_METHODS = Set.of("POST", "PUT", "PATCH");
+
+ /** {@link #pageSize} for a client that asked for no pagination at all. */
+ static final int UNPAGINATED = -1;
+
+ /** How long the shared container has to answer one request. */
+ private static final Duration BACKEND_TIMEOUT = Duration.ofMinutes(5);
+
+ private static final String V2 = "v2/";
+ private static final String CATALOG = "_catalog";
+ private static final String TAGS_LIST = "/tags/list";
+
+ private final HttpClient httpClient = HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(5))
+ .followRedirects(HttpClient.Redirect.NEVER)
+ .build();
+
+ /**
+ * The path the shared container sees for a client path under {@code /v2/}. Repository-scoped
+ * paths gain the registry prefix; the registry-scoped {@code _catalog} does not.
+ */
+ static String backendPath(String registryName, String clientPath) {
+ String tail = clientPath.startsWith(V2) ? clientPath.substring(V2.length()) : clientPath;
+ if (tail.isEmpty() || tail.startsWith("_")) {
+ return V2 + tail;
+ }
+ return V2 + registryName + "/" + tail;
+ }
+
+ /**
+ * Strips the registry prefix (and any backend authority) from an upload session {@code Location},
+ * so the client's next request addresses the repository by the name it used.
+ */
+ static String clientLocation(String registryName, String location) {
+ if (location == null) {
+ return null;
+ }
+ String path = location;
+ int schemeEnd = path.indexOf("://");
+ if (schemeEnd >= 0) {
+ int authorityEnd = path.indexOf('/', schemeEnd + 3);
+ path = authorityEnd < 0 ? "/" : path.substring(authorityEnd);
+ }
+ String prefixed = "/" + V2 + registryName + "/";
+ if (path.startsWith(prefixed)) {
+ return "/" + V2 + path.substring(prefixed.length());
+ }
+ return path;
+ }
+
+ /** True when the response body names repositories and must be translated to the client's view. */
+ static boolean rewritesBody(String clientPath) {
+ return clientPath.endsWith(TAGS_LIST);
+ }
+
+ /**
+ * This registry's repositories, in the order the container reported them, with the internal
+ * prefix removed.
+ *
+ * Reading stops at the first repository outside the prefix. The container walks the
+ * repository directory tree, so one registry's repositories are a subtree and therefore
+ * contiguous: the first outsider ends this registry's block, and nothing of ours follows it.
+ */
+ static List ownRepositories(String prefix, byte[] body) {
+ List repositories = new ArrayList<>();
+ try {
+ for (JsonNode repository : MAPPER.readTree(body).path("repositories")) {
+ String name = repository.asText("");
+ if (!name.startsWith(prefix)) {
+ break;
+ }
+ repositories.add(name.substring(prefix.length()));
+ }
+ } catch (Exception e) {
+ LOG.debugv("Could not read the registry catalog: {0}", e.getMessage());
+ }
+ return repositories;
+ }
+
+ /**
+ * The page size the client asked for, or {@link #UNPAGINATED} when it asked for none.
+ *
+ * Zero is a request for an empty page, which is not the same as a request for the whole
+ * catalog, so the two do not collapse. A size that will not parse, or that is negative, is
+ * treated as absent.
+ */
+ static int pageSize(String declared) {
+ if (declared == null || declared.isBlank()) {
+ return UNPAGINATED;
+ }
+ try {
+ int declaredSize = Integer.parseInt(declared.trim());
+ return declaredSize < 0 ? UNPAGINATED : declaredSize;
+ } catch (NumberFormatException e) {
+ return UNPAGINATED;
+ }
+ }
+
+ /** The {@code Link} advertising the next page, in the shape the registry itself uses. */
+ static String nextLink(String last, int pageSize) {
+ return "" + V2 + CATALOG + "?last=" + encode(last) + "&n=" + pageSize + ">; rel=\"next\"";
+ }
+
+ /**
+ * Strips the internal prefix from the {@code name} a repository response reports, so
+ * {@code /v2/{repo}/tags/list} names the repository the client asked about.
+ */
+ static byte[] unprefixRepositoryName(String registryName, byte[] body) {
+ try {
+ JsonNode root = MAPPER.readTree(body);
+ if (!root.isObject()) {
+ return body;
+ }
+ String name = root.path("name").asText("");
+ String prefix = registryName + "/";
+ if (!name.startsWith(prefix)) {
+ return body;
+ }
+ ObjectNode object = (ObjectNode) root;
+ object.put("name", name.substring(prefix.length()));
+ return MAPPER.writeValueAsBytes(object);
+ } catch (Exception e) {
+ LOG.debugv("Could not rewrite the repository name in a registry response: {0}", e.getMessage());
+ return body;
+ }
+ }
+
+ /**
+ * Forwards one {@code /v2/} request to the shared container. Bodies stream in both directions, so
+ * a layer is never held in memory: the request keeps the length the client declared, and the
+ * response streams back. The exceptions are bodies that name repositories, which are rewritten,
+ * and {@code HEAD}, which carries the backend's {@code Content-Length} and no body: Docker reads
+ * that length to size a blob it is about to push.
+ */
+ public Response proxy(AzureRequest request, String registryName, String backendEndpoint) {
+ String clientPath = trimLeadingSlash(request.rawPath());
+ if (clientPath.equals(V2 + CATALOG)) {
+ return catalog(request, registryName, backendEndpoint);
+ }
+ URI target = URI.create("http://" + backendEndpoint + "/"
+ + backendPath(registryName, clientPath) + queryString(request));
+ // Subscribed to at most once: only one of the branches below runs.
+ HttpRequest.BodyPublisher body = bodyPublisher(request);
+ try {
+ if ("HEAD".equals(request.method())) {
+ return response(send(request, target, body, HttpResponse.BodyHandlers.discarding()),
+ registryName, null, true);
+ }
+ if (rewritesBody(clientPath)) {
+ HttpResponse backend =
+ send(request, target, body, HttpResponse.BodyHandlers.ofByteArray());
+ return response(backend, registryName,
+ unprefixRepositoryName(registryName, backend.body()), false);
+ }
+ HttpResponse backend =
+ send(request, target, body, HttpResponse.BodyHandlers.ofInputStream());
+ return response(backend, registryName, backend.body(), false);
+ } catch (Exception e) {
+ return unavailable(target, e);
+ }
+ }
+
+ /** Forwards one request to the shared container, carrying the client's headers over. */
+ private HttpResponse send(AzureRequest request, URI target, HttpRequest.BodyPublisher body,
+ HttpResponse.BodyHandler handler) throws Exception {
+ HttpRequest.Builder outgoing = HttpRequest.newBuilder(target)
+ .timeout(BACKEND_TIMEOUT)
+ .method(request.method(), body);
+ forwardRequestHeaders(request, outgoing);
+ return httpClient.send(outgoing.build(), handler);
+ }
+
+ /** The registry error for a container that could not be reached or did not answer. */
+ private static Response unavailable(URI target, Exception e) {
+ LOG.warnv("ACR data-plane request to {0} failed: {1}", target, e.getMessage());
+ return AcrErrors.error(502, AcrErrors.UNAVAILABLE,
+ "the registry data plane is unavailable: " + e.getMessage());
+ }
+
+ /**
+ * Serves {@code /v2/_catalog} as this registry's own catalog, one backend request per page.
+ *
+ * The container has no filter parameter: the catalog's whole query vocabulary is {@code n}
+ * and {@code last}. The filter is therefore expressed as a range, which works because a
+ * registry's repositories are a contiguous subtree of the container's walk. Seeding
+ * {@code last} with {@code {registry}/} lands exactly on the first of ours, so {@code n} then
+ * counts ours rather than everyone's and a page comes back full.
+ *
+ * One extra repository is requested beyond the page. The container advertises a next page
+ * whenever the page it returned was full, not when more results actually exist, so its
+ * {@code Link} cannot say whether this registry has more. That extra entry can: outside the
+ * prefix it means the block ended here, and the page is the last one.
+ *
+ * Verified against {@code registry:2} (Distribution 2.8.3). {@code last} is a position in
+ * that walk rather than a repository that has to exist, which is what lets the seed name
+ * nothing. None of this is in the distribution spec, so
+ * {@code AcrCatalogPaginationDockerTest} pins it.
+ */
+ private Response catalog(AzureRequest request, String registryName, String backendEndpoint) {
+ String prefix = registryName + "/";
+ int pageSize = pageSize(firstQueryValue(request, "n"));
+ URI target = catalogTarget(backendEndpoint, prefix, firstQueryValue(request, "last"), pageSize);
+ try {
+ if (pageSize == 0) {
+ // A page of nothing is what was asked for, so there is nothing to ask the container.
+ return catalogPage(List.of(), false, pageSize);
+ }
+ HttpResponse backend = send(request, target,
+ HttpRequest.BodyPublishers.noBody(), HttpResponse.BodyHandlers.ofByteArray());
+ if (backend.statusCode() != 200) {
+ return response(backend, registryName, backend.body(), false);
+ }
+
+ List repositories = ownRepositories(prefix, backend.body());
+ boolean more = pageSize > 0 && repositories.size() > pageSize;
+ return catalogPage(more ? repositories.subList(0, pageSize) : repositories, more, pageSize);
+ } catch (Exception e) {
+ return unavailable(target, e);
+ }
+ }
+
+ /** The container request behind one catalog page: this registry's range, one repository over. */
+ private static URI catalogTarget(String backendEndpoint, String prefix, String clientLast,
+ int pageSize) {
+ StringBuilder query = new StringBuilder("?last=")
+ .append(encode(prefix + (clientLast == null ? "" : clientLast)));
+ if (pageSize > 0) {
+ query.append("&n=").append((long) pageSize + 1);
+ }
+ return URI.create("http://" + backendEndpoint + "/" + V2 + CATALOG + query);
+ }
+
+ /** One catalog page, carrying the cursor to the next only when this registry has more. */
+ private static Response catalogPage(List repositories, boolean more, int pageSize)
+ throws JsonProcessingException {
+ Response.ResponseBuilder page = Response
+ .ok(MAPPER.writeValueAsBytes(Map.of("repositories", repositories)))
+ .type(MediaType.APPLICATION_JSON);
+ if (more) {
+ page.header("Link", nextLink(repositories.get(repositories.size() - 1), pageSize));
+ }
+ return page.build();
+ }
+
+ /** The first value of a query parameter, or {@code null} when the client sent none. */
+ private static String firstQueryValue(AzureRequest request, String name) {
+ Map> parameters = request.queryParamsMulti();
+ if (parameters == null) {
+ return null;
+ }
+ List values = parameters.get(name);
+ return values == null || values.isEmpty() ? null : values.get(0);
+ }
+
+ /**
+ * Streams the request body through, never buffering it: a layer is pushed whole and would
+ * otherwise be held in memory in its entirety.
+ *
+ * A declared {@code Content-Length} is carried over, because that length is what frames a
+ * blob upload. A body with no declared length is chunked, which the clients here do not do
+ * but an OCI client streaming a layer of unknown size may, and is forwarded chunked in turn.
+ * Only methods that can carry a body take that path; a bodyless {@code GET} or {@code DELETE}
+ * that simply declared no length must not acquire a chunked frame it never had.
+ *
+ * The supplier hands out the client's own stream, so it can only be subscribed to once. That
+ * is safe because {@code HttpClient} retries only idempotent methods by default, and a blob
+ * upload is {@code POST}/{@code PATCH}/{@code PUT}. Do not enable
+ * {@code jdk.httpclient.enableAllMethodRetry}: a retry would resubscribe to a stream that has
+ * already been drained and push a truncated layer.
+ */
+ private static HttpRequest.BodyPublisher bodyPublisher(AzureRequest request) {
+ long length = declaredContentLength(request);
+ if (length == 0 || request.bodyStream() == null) {
+ return HttpRequest.BodyPublishers.noBody();
+ }
+ if (length > 0) {
+ return HttpRequest.BodyPublishers.fromPublisher(
+ HttpRequest.BodyPublishers.ofInputStream(request::bodyStream), length);
+ }
+ if (!BODY_METHODS.contains(request.method())) {
+ return HttpRequest.BodyPublishers.noBody();
+ }
+ return HttpRequest.BodyPublishers.ofInputStream(request::bodyStream);
+ }
+
+ /** The client's {@code Content-Length}, or {@code -1} when it declared none. */
+ private static long declaredContentLength(AzureRequest request) {
+ if (request.headers() == null) {
+ return -1;
+ }
+ String declared = request.headers().getHeaderString("Content-Length");
+ if (declared == null || declared.isBlank()) {
+ return -1;
+ }
+ try {
+ return Long.parseLong(declared.trim());
+ } catch (NumberFormatException e) {
+ return -1;
+ }
+ }
+
+ private static void forwardRequestHeaders(AzureRequest request, HttpRequest.Builder outgoing) {
+ if (request.headers() == null) {
+ return;
+ }
+ request.headers().getRequestHeaders().forEach((name, values) -> {
+ String lower = name.toLowerCase(Locale.ROOT);
+ if (HOP_BY_HOP.contains(lower) || DROPPED_REQUEST_HEADERS.contains(lower)) {
+ return;
+ }
+ values.forEach(value -> outgoing.header(name, value));
+ });
+ }
+
+ private static Response response(HttpResponse> backend, String registryName, Object entity,
+ boolean keepContentLength) {
+ Response.ResponseBuilder response = Response.status(backend.statusCode()).entity(entity);
+ backend.headers().map().forEach((name, values) -> {
+ String lower = name.toLowerCase(Locale.ROOT);
+ if (REWRITTEN_RESPONSE_HEADERS.contains(lower)) {
+ return;
+ }
+ // Content-Length describes the backend's framing, not ours: a streamed or rewritten body
+ // is re-framed on the way out, and only HEAD reports the backend's length verbatim.
+ if ("content-length".equals(lower)) {
+ if (keepContentLength) {
+ values.forEach(value -> response.header(name, value));
+ }
+ return;
+ }
+ if (HOP_BY_HOP.contains(lower)) {
+ return;
+ }
+ values.forEach(value -> response.header(name, value));
+ });
+ backend.headers().firstValue("Location")
+ .map(location -> clientLocation(registryName, location))
+ .ifPresent(location -> response.header("Location", location));
+ return response.build();
+ }
+
+ /**
+ * The query to forward, including the leading {@code ?}. The client's raw query goes through
+ * byte for byte: registry:2 mints an opaque {@code _state} for an upload session and expects it
+ * back exactly as issued, and rebuilding the query from the decoded parameters does not
+ * round-trip it. Rebuilding is only the fallback for a request that carried no raw query.
+ */
+ private static String queryString(AzureRequest request) {
+ String raw = request.rawQuery();
+ if (raw != null && !raw.isEmpty()) {
+ return "?" + raw;
+ }
+ return queryString(request.queryParamsMulti());
+ }
+
+ private static String queryString(Map> parameters) {
+ if (parameters == null || parameters.isEmpty()) {
+ return "";
+ }
+ StringBuilder query = new StringBuilder("?");
+ parameters.forEach((name, values) -> values.forEach(value -> {
+ if (query.length() > 1) {
+ query.append('&');
+ }
+ query.append(encode(name)).append('=').append(encode(value));
+ }));
+ return query.toString();
+ }
+
+ private static String encode(String value) {
+ return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8);
+ }
+
+ private static String trimLeadingSlash(String path) {
+ return path == null ? "" : path.replaceFirst("^/+", "");
+ }
+}
diff --git a/src/main/java/io/floci/az/services/acr/AcrTokenService.java b/src/main/java/io/floci/az/services/acr/AcrTokenService.java
new file mode 100644
index 00000000..b87d7c49
--- /dev/null
+++ b/src/main/java/io/floci/az/services/acr/AcrTokenService.java
@@ -0,0 +1,281 @@
+package io.floci.az.services.acr;
+
+import io.floci.az.config.EmulatorConfig;
+import io.floci.az.core.AzureRequest;
+import io.floci.az.core.FormBody;
+import io.floci.az.services.entra.TokenIssuer;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+
+import java.time.Instant;
+import java.time.format.DateTimeFormatter;
+import java.time.temporal.ChronoUnit;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * Azure Container Registry's Entra token exchange: the two {@code /oauth2/} endpoints that
+ * {@code az acr login} and the Azure SDK container-registry clients use instead of a plain Docker
+ * login.
+ *
+ *
+ * GET /v2/ → 401 + Bearer challenge naming the realm and service
+ * POST /oauth2/exchange → Entra access token → ACR refresh token
+ * POST /oauth2/token → ACR refresh token → scoped ACR access token
+ * GET /oauth2/token → the Docker Registry token endpoint form (service + scope)
+ *
+ *
+ * Both tokens are RS256 JWTs minted by the emulator's {@link TokenIssuer}, so clients that
+ * decode them keep working: the Azure CLI reads the access token's {@code access} claim when it
+ * verifies permissions. Tokens are issued, not verified, and the scopes they carry are not
+ * enforced at the data plane. See {@code docs/services/acr.md}.
+ */
+@ApplicationScoped
+public class AcrTokenService {
+
+ /** The username Docker logs in with when the password is an ACR token. */
+ public static final String TOKEN_USERNAME = "00000000-0000-0000-0000-000000000000";
+
+ private static final String ISSUER = "Azure Container Registry";
+ /** The grant types the clients send to the exchange endpoint, in the order they are documented. */
+ private static final List EXCHANGE_GRANTS =
+ List.of("access_token", "refresh_token", "access_token_refresh_token");
+
+ private final EmulatorConfig config;
+ private final TokenIssuer tokenIssuer;
+
+ @Inject
+ public AcrTokenService(EmulatorConfig config, TokenIssuer tokenIssuer) {
+ this.config = config;
+ this.tokenIssuer = tokenIssuer;
+ }
+
+ /**
+ * One entry of an access token's {@code access} claim: what the client asked for, echoed back
+ * with the bare repository name it used.
+ */
+ public record Access(String type, String name, List actions) {
+
+ Map toClaim() {
+ Map claim = new LinkedHashMap<>();
+ claim.put("type", type);
+ claim.put("name", name);
+ claim.put("actions", actions);
+ return claim;
+ }
+ }
+
+ // ── Challenge ────────────────────────────────────────────────────────────────
+
+ /**
+ * The {@code WWW-Authenticate} value that starts the flow. The client parses {@code realm} and
+ * {@code service} out of it and fails with a connectivity error if either is missing.
+ *
+ * The realm carries the scheme the caller actually used. TLS is off by default here, and a
+ * realm hardcoded to {@code https://} would send the client to a port nothing is listening on.
+ * {@code service} is always the login server itself, as in Azure.
+ */
+ public static String challenge(String scheme, String loginServer) {
+ return "Bearer realm=\"" + scheme + "://" + loginServer + "/oauth2/token\""
+ + ",service=\"" + loginServer + "\"";
+ }
+
+ /** {@code GET /v2/} answered before authentication: {@code 401} plus the bearer challenge. */
+ public static Response challengeResponse(String scheme, String loginServer) {
+ return Response.status(401)
+ .header("WWW-Authenticate", challenge(scheme, loginServer))
+ .header("Docker-Distribution-Api-Version", "registry/2.0")
+ .entity(Map.of("errors", List.of(Map.of(
+ "code", "UNAUTHORIZED",
+ "message", "authentication required"))))
+ .type(MediaType.APPLICATION_JSON)
+ .build();
+ }
+
+ // ── Scope parsing ────────────────────────────────────────────────────────────
+
+ /**
+ * Parses one scope string into an access entry. The Azure CLI sends
+ * {@code repository:{name}:{actions}}, {@code artifact-repository:{name}:{actions}} or
+ * {@code registry:{permission}:*}; Docker sends the same repository form. Actions are comma
+ * separated. A repository name may itself contain {@code /} but never {@code :}, so the first
+ * and last separators bound the name.
+ */
+ public static Optional parseScope(String scope) {
+ if (scope == null || scope.isBlank()) {
+ return Optional.empty();
+ }
+ int firstColon = scope.indexOf(':');
+ int lastColon = scope.lastIndexOf(':');
+ if (firstColon <= 0 || lastColon <= firstColon || lastColon == scope.length() - 1) {
+ return Optional.empty();
+ }
+ String type = scope.substring(0, firstColon);
+ String name = scope.substring(firstColon + 1, lastColon);
+ List actions = Arrays.stream(scope.substring(lastColon + 1).split(","))
+ .map(String::trim)
+ .filter(action -> !action.isEmpty())
+ .toList();
+ if (name.isEmpty() || actions.isEmpty()) {
+ return Optional.empty();
+ }
+ return Optional.of(new Access(type, name, actions));
+ }
+
+ /** Parses every scope in a request: whitespace separates scopes, and the parameter may repeat. */
+ public static List parseScopes(List scopes) {
+ List access = new ArrayList<>();
+ if (scopes == null) {
+ return access;
+ }
+ for (String scope : scopes) {
+ if (scope == null) {
+ continue;
+ }
+ for (String single : scope.split("\\s+")) {
+ parseScope(single).ifPresent(access::add);
+ }
+ }
+ return access;
+ }
+
+ // ── Token building ───────────────────────────────────────────────────────────
+
+ /**
+ * An ACR refresh token: audience is the login server, tenant is the emulator's. It carries no
+ * {@code access} claim; it is exchanged for scoped access tokens at the token endpoint.
+ */
+ public String refreshToken(String loginServer) {
+ return tokenIssuer.issue(spec(loginServer), Map.of("grant_type", "refresh_token"));
+ }
+
+ /**
+ * A scoped ACR access token: the {@code access} claim carries one entry per requested scope,
+ * holding the type, the bare name the client asked for, and its actions.
+ */
+ public String accessToken(String loginServer, List access, String grantType) {
+ Map claims = new LinkedHashMap<>();
+ claims.put("access", access.stream().map(Access::toClaim).toList());
+ if (grantType != null) {
+ claims.put("grant_type", grantType);
+ }
+ return tokenIssuer.issue(spec(loginServer), claims);
+ }
+
+ private TokenIssuer.TokenSpec spec(String loginServer) {
+ String tenantId = config.services().entra().defaultTenantId();
+ String subject = TokenIssuer.deterministicGuid("acr:" + loginServer);
+ return new TokenIssuer.TokenSpec(tenantId, ISSUER, loginServer, subject, subject,
+ TOKEN_USERNAME, null, "1.0", null, lifetimeSeconds());
+ }
+
+ private long lifetimeSeconds() {
+ return config.services().entra().tokenLifetimeSeconds();
+ }
+
+ // ── Endpoints ────────────────────────────────────────────────────────────────
+
+ /**
+ * {@code POST /oauth2/exchange}: trades an Entra access token for an ACR refresh token. The
+ * Entra token is accepted without verification, so only the request shape is checked.
+ */
+ public Response handleExchange(AzureRequest request, String loginServer) {
+ if (!"POST".equals(request.method())) {
+ return AcrErrors.error(405, AcrErrors.UNSUPPORTED,
+ "the exchange endpoint accepts POST only");
+ }
+ Map form = FormBody.parse(request.bodyStream());
+ String grantType = form.getOrDefault("grant_type", "");
+ if (!EXCHANGE_GRANTS.contains(grantType)) {
+ return AcrErrors.badRequest("grant_type '" + grantType + "' is not supported at the "
+ + "exchange endpoint; expected one of " + String.join(", ", EXCHANGE_GRANTS));
+ }
+ if (isBlank(form.get("service"))) {
+ return AcrErrors.badRequest("service is required");
+ }
+ return Response.ok(Map.of("refresh_token", refreshToken(loginServer)))
+ .type(MediaType.APPLICATION_JSON)
+ .build();
+ }
+
+ /**
+ * {@code /oauth2/token}: {@code POST} with {@code grant_type=refresh_token} for the Entra flow
+ * or {@code grant_type=password} for admin credentials, and {@code GET} with {@code service}
+ * and {@code scope} query parameters for Docker's own client.
+ */
+ public Response handleToken(AzureRequest request, String loginServer) {
+ return switch (request.method()) {
+ // Docker's own client sends no grant type, only service and scope.
+ case "GET" -> accessTokenResponse(loginServer,
+ parseScopes(request.queryParamsMulti().get("scope")), null);
+ case "POST" -> handleTokenPost(request, loginServer);
+ default -> AcrErrors.error(405, AcrErrors.UNSUPPORTED,
+ "the token endpoint accepts GET and POST only");
+ };
+ }
+
+ private Response handleTokenPost(AzureRequest request, String loginServer) {
+ Map form = FormBody.parse(request.bodyStream());
+ String grantType = form.getOrDefault("grant_type", "");
+ switch (grantType) {
+ case "refresh_token" -> {
+ String refreshToken = form.get("refresh_token");
+ if (isBlank(refreshToken)) {
+ return AcrErrors.badRequest("refresh_token is required");
+ }
+ // Nothing verifies the token, but a token that is not even a JWT is rejected so
+ // clients still exercise the re-authentication path they would take against Azure.
+ if (!looksLikeJwt(refreshToken)) {
+ return AcrErrors.error(401, AcrErrors.UNAUTHORIZED, "the refresh token is malformed");
+ }
+ }
+ case "password" -> {
+ if (isBlank(form.get("password"))) {
+ return AcrErrors.badRequest("password is required");
+ }
+ }
+ default -> {
+ return AcrErrors.badRequest("grant_type '" + grantType + "' is not supported at the "
+ + "token endpoint; expected refresh_token or password");
+ }
+ }
+ if (isBlank(form.get("service"))) {
+ return AcrErrors.badRequest("service is required");
+ }
+ return accessTokenResponse(loginServer,
+ parseScopes(List.of(form.getOrDefault("scope", ""))), grantType);
+ }
+
+ private Response accessTokenResponse(String loginServer, List access, String grantType) {
+ Map body = new LinkedHashMap<>();
+ body.put("access_token", accessToken(loginServer, access, grantType));
+ body.put("expires_in", lifetimeSeconds());
+ body.put("issued_at", DateTimeFormatter.ISO_INSTANT.format(
+ Instant.now().truncatedTo(ChronoUnit.MILLIS)));
+ return Response.ok(body).type(MediaType.APPLICATION_JSON).build();
+ }
+
+ private static boolean isBlank(String value) {
+ return value == null || value.isBlank();
+ }
+
+ /** A compact JWT: three non-empty dot-separated segments. */
+ private static boolean looksLikeJwt(String token) {
+ String[] segments = token.split("\\.", -1);
+ if (segments.length != 3) {
+ return false;
+ }
+ for (String segment : segments) {
+ if (segment.isEmpty()) {
+ return false;
+ }
+ }
+ return true;
+ }
+}
diff --git a/src/main/java/io/floci/az/services/entra/EntraServiceHandler.java b/src/main/java/io/floci/az/services/entra/EntraServiceHandler.java
index 24c61cca..4a8de6ce 100644
--- a/src/main/java/io/floci/az/services/entra/EntraServiceHandler.java
+++ b/src/main/java/io/floci/az/services/entra/EntraServiceHandler.java
@@ -4,6 +4,7 @@
import io.floci.az.config.EmulatorConfig;
import io.floci.az.core.AzureRequest;
import io.floci.az.core.AzureServiceHandler;
+import io.floci.az.core.FormBody;
import io.floci.az.core.RequestUrls;
import io.floci.az.services.entra.EntraModels.AppRegistration;
import io.floci.az.services.entra.EntraModels.AuthorizationCode;
@@ -14,9 +15,7 @@
import jakarta.ws.rs.core.Response;
import org.jboss.logging.Logger;
-import java.io.IOException;
import java.net.URI;
-import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
@@ -25,7 +24,6 @@
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.Base64;
-import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -361,27 +359,7 @@ private String normalizeScopes(String scope) {
}
private Map parseForm(AzureRequest request) {
- Map result = new HashMap<>();
- byte[] bytes;
- try {
- bytes = request.bodyStream() == null ? new byte[0] : request.bodyStream().readAllBytes();
- } catch (IOException e) {
- return result;
- }
- String body = new String(bytes, StandardCharsets.UTF_8);
- if (body.isBlank()) {
- return result;
- }
- for (String pair : body.split("&")) {
- int eq = pair.indexOf('=');
- if (eq < 0) {
- continue;
- }
- String key = URLDecoder.decode(pair.substring(0, eq), StandardCharsets.UTF_8);
- String value = URLDecoder.decode(pair.substring(eq + 1), StandardCharsets.UTF_8);
- result.put(key, value);
- }
- return result;
+ return FormBody.parse(request.bodyStream());
}
/** First path segment is the tenant; defaults to {@code common} for safety. */
diff --git a/src/main/java/io/floci/az/services/entra/TokenIssuer.java b/src/main/java/io/floci/az/services/entra/TokenIssuer.java
index 8f50c9a0..f66593ba 100644
--- a/src/main/java/io/floci/az/services/entra/TokenIssuer.java
+++ b/src/main/java/io/floci/az/services/entra/TokenIssuer.java
@@ -88,7 +88,11 @@ private static void putIfPresent(Map claims, String key, String
}
}
- private String issue(TokenSpec spec, Map extraClaims) {
+ /**
+ * Mints a token carrying {@code extraClaims} alongside the standard claim set. ACR's registry
+ * tokens use this for the {@code access} claim that clients decode to read their permissions.
+ */
+ public String issue(TokenSpec spec, Map extraClaims) {
Instant now = Instant.now();
long iat = now.getEpochSecond();
long exp = iat + spec.lifetimeSeconds();
diff --git a/src/test/java/io/floci/az/core/FormBodyTest.java b/src/test/java/io/floci/az/core/FormBodyTest.java
new file mode 100644
index 00000000..3dd1c1aa
--- /dev/null
+++ b/src/test/java/io/floci/az/core/FormBodyTest.java
@@ -0,0 +1,75 @@
+package io.floci.az.core;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Form parsing for the OAuth-style endpoints. The body is client input, so the parser never throws:
+ * a caller reads a parameter it cannot use as absent and answers with its own error.
+ */
+@DisplayName("FormBody: form-encoded request bodies")
+class FormBodyTest {
+
+ private static Map parse(String body) {
+ return FormBody.parse(new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8)));
+ }
+
+ @Test
+ void decodesParameters() {
+ Map form = parse("grant_type=refresh_token&service=myreg.azurecr.io");
+ assertEquals("refresh_token", form.get("grant_type"));
+ assertEquals("myreg.azurecr.io", form.get("service"));
+ }
+
+ @Test
+ void decodesPercentEscapesAndPlusAsSpace() {
+ Map form = parse("scope=repository%3Ateam%2Fapp%3Apull%2Cpush¬e=a+b");
+ assertEquals("repository:team/app:pull,push", form.get("scope"));
+ assertEquals("a b", form.get("note"));
+ }
+
+ @Test
+ void dropsAPairWithAMalformedEscapeInsteadOfThrowing() {
+ // "%zz" is not a percent escape; URLDecoder throws on it. The pair is dropped so the
+ // endpoint sees a missing parameter and answers its own 400 rather than a 500.
+ Map form = parse("grant_type=refresh_token&service=%zz");
+ assertFalse(form.containsKey("service"));
+ assertEquals("refresh_token", form.get("grant_type"));
+ }
+
+ @Test
+ void dropsAPairWithATruncatedEscape() {
+ assertFalse(parse("service=%").containsKey("service"));
+ assertFalse(parse("service=abc%2").containsKey("service"));
+ assertFalse(parse("%zz=value").containsKey("%zz"));
+ }
+
+ @Test
+ void ignoresPairsWithNoSeparatorAndBlankBodies() {
+ assertTrue(parse("").isEmpty());
+ assertTrue(parse(" ").isEmpty());
+ assertTrue(parse("novalue").isEmpty());
+ assertEquals(Map.of("a", "1"), parse("novalue&a=1"));
+ }
+
+ @Test
+ void treatsAnUnreadableOrAbsentBodyAsEmpty() {
+ assertTrue(FormBody.parse(null).isEmpty());
+ assertTrue(FormBody.parse(new InputStream() {
+ @Override
+ public int read() throws IOException {
+ throw new IOException("stream closed");
+ }
+ }).isEmpty());
+ }
+}
diff --git a/src/test/java/io/floci/az/core/RoutingTableAssemblyTest.java b/src/test/java/io/floci/az/core/RoutingTableAssemblyTest.java
index 717c112e..0aa8d787 100644
--- a/src/test/java/io/floci/az/core/RoutingTableAssemblyTest.java
+++ b/src/test/java/io/floci/az/core/RoutingTableAssemblyTest.java
@@ -42,7 +42,8 @@ class RoutingTableAssemblyTest {
Map.entry(".queue.core.windows.net", "queue"),
Map.entry(".table.core.windows.net", "table"),
Map.entry(".servicebus.windows.net", "servicebus"),
- Map.entry(".azurecontainerapps.io", "containerapps")
+ Map.entry(".azurecontainerapps.io", "containerapps"),
+ Map.entry(".azurecr.io", "acr")
);
/** A4's ACCOUNT_SUFFIX_ROUTES, verbatim (pre-sort). */
diff --git a/src/test/java/io/floci/az/core/tls/TlsConfigSourceCertificateGenerationTest.java b/src/test/java/io/floci/az/core/tls/TlsConfigSourceCertificateGenerationTest.java
index d74072de..2a8f1ee4 100644
--- a/src/test/java/io/floci/az/core/tls/TlsConfigSourceCertificateGenerationTest.java
+++ b/src/test/java/io/floci/az/core/tls/TlsConfigSourceCertificateGenerationTest.java
@@ -90,8 +90,10 @@ void certificateWithDefaultConfigHasDefaultSans() throws Exception {
"SANs should include 'host.docker.internal' so function containers can reach floci-az on the host");
assertTrue(sans.contains("*.managedhsm.azure.net"),
"SANs should include '*.managedhsm.azure.net' for Managed-HSM-flavored vault URLs");
- assertEquals(9, sans.size(),
- "Default cert should have exactly 9 SANs (localhost, 127.0.0.1, 0.0.0.0, *.localhost, localhost.floci-az.io, *.localhost.floci-az.io, *.vault.azure.net, *.managedhsm.azure.net, host.docker.internal)");
+ assertTrue(sans.contains("*.azurecr.io"),
+ "SANs should include '*.azurecr.io' so registry clients trust {name}.azurecr.io");
+ assertEquals(10, sans.size(),
+ "Default cert should have exactly 10 SANs (localhost, 127.0.0.1, 0.0.0.0, *.localhost, localhost.floci-az.io, *.localhost.floci-az.io, *.vault.azure.net, *.managedhsm.azure.net, *.azurecr.io, host.docker.internal)");
}
@Test
diff --git a/src/test/java/io/floci/az/services/acr/AcrCatalogPaginationDockerTest.java b/src/test/java/io/floci/az/services/acr/AcrCatalogPaginationDockerTest.java
new file mode 100644
index 00000000..1c9e6f1a
--- /dev/null
+++ b/src/test/java/io/floci/az/services/acr/AcrCatalogPaginationDockerTest.java
@@ -0,0 +1,237 @@
+package io.floci.az.services.acr;
+
+import io.quarkus.test.junit.QuarkusTest;
+import io.quarkus.test.junit.QuarkusTestProfile;
+import io.quarkus.test.junit.TestProfile;
+import io.restassured.response.Response;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.security.MessageDigest;
+import java.util.ArrayList;
+import java.util.HexFormat;
+import java.util.List;
+import java.util.Map;
+
+import static io.restassured.RestAssured.given;
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.Matchers.anyOf;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+/**
+ * Catalog pagination against a real {@code registry:2}, which is the only thing that can confirm
+ * the cursor behaviour the proxy is built on.
+ *
+ * The proxy serves one client page from one backend request by seeding {@code last} with
+ * {@code {registry}/} and asking for one repository beyond the page. That rests on three things
+ * the distribution spec does not state: the container walks its repositories as a directory tree,
+ * so one registry's repositories are contiguous; {@code last} is a position in that walk rather
+ * than a repository that has to exist; and the container advertises a next page whenever the page
+ * it returned was full rather than when more results remain. If any of those change under a
+ * {@code registry:2} pull, this test fails rather than the behaviour silently degrading.
+ *
+ *
Skipped automatically when Docker is unavailable.
+ */
+@QuarkusTest
+@TestProfile(AcrCatalogPaginationDockerTest.RealModeProfile.class)
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+@DisplayName("ACR catalog pagination against a real registry (Docker required)")
+class AcrCatalogPaginationDockerTest {
+
+ public static class RealModeProfile implements QuarkusTestProfile {
+ @Override
+ public Map getConfigOverrides() {
+ return Map.of("floci-az.services.acr.mocked", "false");
+ }
+ }
+
+ /**
+ * Ours sits between two neighbours in the shared container, which is what makes the cursor
+ * seed load-bearing: without it the container would spend the first page on {@link #BEFORE}
+ * and answer with repositories that are all filtered away.
+ */
+ private static final String BEFORE = "catalogpagea";
+ private static final String REGISTRY = "catalogpageb";
+ private static final String AFTER = "catalogpagec";
+ private static final String SUB = "00000000-0000-0000-0000-000000000001";
+ private static final String RG = "test-rg-catalog";
+
+ /** Pushed in an order that does not match the catalog's, so ordering is the registry's own. */
+ private static final List OURS = List.of("web", "app", "team/api", "zebra");
+
+ private boolean pushed;
+
+ /** Pure filesystem check, which is all that is safe before Quarkus is listening. */
+ @BeforeAll
+ void checkDockerAvailable() {
+ assumeTrue(Files.exists(Paths.get("/var/run/docker.sock")) || System.getenv("DOCKER_HOST") != null,
+ "Docker socket not available, skipping the real registry catalog tests");
+ }
+
+ /**
+ * Pushes into both registries once, on the first test to need it. This cannot be a
+ * {@code @BeforeAll}: the HTTP port is not serving yet when that runs.
+ */
+ @BeforeEach
+ void pushRepositoriesToBothRegistries() {
+ if (pushed) {
+ return;
+ }
+ createRegistry(BEFORE);
+ createRegistry(REGISTRY);
+ createRegistry(AFTER);
+ // Neighbours on both sides, so a page can be spoiled from either end. Their repositories
+ // must never appear in our catalog, whatever page they would have fallen on.
+ push(BEFORE, "app");
+ push(BEFORE, "other");
+ push(BEFORE, "third");
+ for (String repository : OURS) {
+ push(REGISTRY, repository);
+ }
+ push(AFTER, "app");
+ push(AFTER, "other");
+ pushed = true;
+ }
+
+ @Test
+ void theUnpaginatedCatalogIsThisRegistrysOwn() {
+ assertEquals(List.of("app", "team/api", "web", "zebra"), catalog(""));
+ }
+
+ @Test
+ void pagingWalksEveryRepositoryExactlyOnceAndStops() {
+ List seen = new ArrayList<>();
+ String query = "?n=2";
+ int pages = 0;
+
+ while (query != null) {
+ Response page = catalogResponse(query);
+ seen.addAll(repositories(page));
+ String link = page.getHeader("Link");
+ if (link != null) {
+ assertFalse(link.contains(REGISTRY + "/"),
+ "the next cursor leaked the internal repository prefix: " + link);
+ }
+ query = nextQuery(link);
+ assertTrue(++pages <= 10, "paging did not terminate");
+ }
+
+ assertEquals(List.of("app", "team/api", "web", "zebra"), seen);
+ }
+
+ @Test
+ void aPageIsFullEvenThoughTheNeighboursRepositoriesShareTheContainer() {
+ // Three repositories precede ours in the container. Unseeded, the first page would be
+ // spent on those and come back empty; seeded, the container counts ours and fills it.
+ assertEquals(List.of("app", "team/api"), catalog("?n=2"));
+ }
+
+ @Test
+ void aPageThatExactlyExhaustsTheCatalogCarriesNoNextLink() {
+ Response page = catalogResponse("?n=4");
+
+ assertEquals(List.of("app", "team/api", "web", "zebra"), repositories(page));
+ assertEquals(null, page.getHeader("Link"),
+ "the container advertises a next page after a full one, so we must not echo it");
+ }
+
+ @Test
+ void aCursorNamingTheLastRepositoryEndsTheWalk() {
+ Response page = catalogResponse("?n=2&last=zebra");
+
+ assertEquals(List.of(), repositories(page));
+ assertEquals(null, page.getHeader("Link"));
+ }
+
+ // ── Helpers ──────────────────────────────────────────────────────────────────
+
+ private static String loginServer(String registry) {
+ return registry + ".azurecr.io";
+ }
+
+ private static void createRegistry(String name) {
+ given().contentType("application/json")
+ .body("{\"location\":\"eastus\",\"sku\":{\"name\":\"Basic\"}}")
+ .when().put("/subscriptions/" + SUB + "/resourceGroups/" + RG
+ + "/providers/Microsoft.ContainerRegistry/registries/" + name
+ + "?api-version=2025-11-01")
+ .then().statusCode(anyOf(is(200), is(201)));
+ }
+
+ /** Pushes an empty image, which is enough to make the repository exist in the catalog. */
+ private static void push(String registry, String repository) {
+ String host = loginServer(registry);
+ String config = "{}";
+ String digest = "sha256:" + sha256(config);
+
+ String location = given().header("Host", host)
+ .when().post("/v2/" + repository + "/blobs/uploads/")
+ .then().statusCode(202)
+ .extract().header("Location");
+ assertNotNull(location, "no upload Location for " + registry + "/" + repository);
+ assertFalse(location.contains(registry + "/"),
+ "the upload Location leaked the internal prefix: " + location);
+
+ // The upload session carries an opaque _state that the registry expects back byte for byte,
+ // so the query must not be re-encoded on the way out.
+ given().header("Host", host).urlEncodingEnabled(false)
+ .contentType("application/octet-stream")
+ .body(config.getBytes(StandardCharsets.UTF_8))
+ .when().put(location + (location.contains("?") ? "&" : "?") + "digest=" + digest)
+ .then().statusCode(201);
+
+ String manifest = "{\"schemaVersion\":2,"
+ + "\"mediaType\":\"application/vnd.docker.distribution.manifest.v2+json\","
+ + "\"config\":{\"mediaType\":\"application/vnd.docker.container.image.v1+json\","
+ + "\"size\":" + config.length() + ",\"digest\":\"" + digest + "\"},\"layers\":[]}";
+ given().header("Host", host)
+ .contentType("application/vnd.docker.distribution.manifest.v2+json").body(manifest)
+ .when().put("/v2/" + repository + "/manifests/v1")
+ .then().statusCode(201);
+ }
+
+ private static Response catalogResponse(String query) {
+ return given().header("Host", loginServer(REGISTRY))
+ .when().get("/v2/_catalog" + query)
+ .then().statusCode(200)
+ .extract().response();
+ }
+
+ private static List catalog(String query) {
+ return repositories(catalogResponse(query));
+ }
+
+ private static List repositories(Response response) {
+ List repositories = response.jsonPath().getList("repositories");
+ return repositories == null ? List.of() : repositories;
+ }
+
+ /** The query of a {@code Link} header's next page, or {@code null} when there is none. */
+ private static String nextQuery(String link) {
+ if (link == null) {
+ return null;
+ }
+ int start = link.indexOf('?');
+ int end = link.indexOf('>', start);
+ return start < 0 || end < 0 ? null : link.substring(start, end);
+ }
+
+ private static String sha256(String content) {
+ try {
+ return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
+ .digest(content.getBytes(StandardCharsets.UTF_8)));
+ } catch (Exception e) {
+ throw new IllegalStateException(e);
+ }
+ }
+}
diff --git a/src/test/java/io/floci/az/services/acr/AcrHandlerTest.java b/src/test/java/io/floci/az/services/acr/AcrHandlerTest.java
index 5e7e9463..457622d4 100644
--- a/src/test/java/io/floci/az/services/acr/AcrHandlerTest.java
+++ b/src/test/java/io/floci/az/services/acr/AcrHandlerTest.java
@@ -10,11 +10,14 @@
import java.util.Map;
import static io.restassured.RestAssured.given;
-import static org.hamcrest.CoreMatchers.endsWith;
import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.Matchers.hasKey;
import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.not;
import static org.hamcrest.Matchers.notNullValue;
+import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
/**
* Management-plane coverage for Azure Container Registry in mocked mode (no Docker):
@@ -70,10 +73,13 @@ void createReturnsRegistryResource() {
.body("sku.name", is("Basic"))
.body("properties.provisioningState", is("Succeeded"))
.body("properties.adminUserEnabled", is(true))
- .body("properties.loginServer", endsWith(".azurecr.io"))
+ .body("properties.loginServer", is("acrcreate.azurecr.io"))
// Fields the azurerm provider dereferences without nil checks — must be present.
.body("properties.zoneRedundancy", is("Disabled"))
- .body("properties.publicNetworkAccess", is("Enabled"));
+ .body("properties.publicNetworkAccess", is("Enabled"))
+ // localPort reports the shared container's published port. Mocked mode starts no
+ // container, so there is no port to report and the field is left out entirely.
+ .body("properties", not(hasKey("localPort")));
}
@Test
@@ -145,6 +151,22 @@ void replicationsReturnsEmptyList() {
.body("value", hasSize(0));
}
+ @Test
+ void loginServerIsTheAzureHostnameForTheRegistry() {
+ assertEquals("myregistry.azurecr.io", AcrHandler.loginServer("myregistry"));
+ // Azure lowercases the login server even when the resource name is mixed case.
+ assertEquals("myregistry.azurecr.io", AcrHandler.loginServer("MyRegistry"));
+ }
+
+ @Test
+ void theRegistryIsResolvedFromTheRequestHost() {
+ assertEquals("myregistry", AcrHandler.registryFromHost("myregistry.azurecr.io"));
+ assertEquals("myregistry", AcrHandler.registryFromHost("MyRegistry.AzureCr.Io"));
+ assertNull(AcrHandler.registryFromHost("localhost"));
+ assertNull(AcrHandler.registryFromHost(".azurecr.io"));
+ assertNull(AcrHandler.registryFromHost(null));
+ }
+
@Test
void deleteRemovesTheRegistry() {
createRegistry("acrdelete");
diff --git a/src/test/java/io/floci/az/services/acr/AcrRegistryProxyHttpTest.java b/src/test/java/io/floci/az/services/acr/AcrRegistryProxyHttpTest.java
new file mode 100644
index 00000000..a12c2d29
--- /dev/null
+++ b/src/test/java/io/floci/az/services/acr/AcrRegistryProxyHttpTest.java
@@ -0,0 +1,412 @@
+package io.floci.az.services.acr;
+
+import com.sun.net.httpserver.HttpServer;
+import io.floci.az.core.AzureRequest;
+import jakarta.ws.rs.core.HttpHeaders;
+import jakarta.ws.rs.core.MultivaluedHashMap;
+import jakarta.ws.rs.core.MultivaluedMap;
+import jakarta.ws.rs.core.Response;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * The proxy against a stand-in registry: what the shared container actually receives, and what the
+ * client gets back. {@link AcrRegistryProxyTest} covers the path and body rewriting in isolation.
+ */
+@DisplayName("AcrRegistryProxy: proxying to the shared registry")
+class AcrRegistryProxyHttpTest {
+
+ @Test
+ void prefixesTheRepositoryAndStripsThePrefixFromTheUploadLocation() throws Exception {
+ AtomicReference receivedPath = new AtomicReference<>();
+ HttpServer registry = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ int port = registry.getAddress().getPort();
+ registry.createContext("/", exchange -> {
+ receivedPath.set(exchange.getRequestURI().getRawPath());
+ // registry:2 answers with an absolute URL built from the Host it was called on.
+ exchange.getResponseHeaders().add("Location",
+ "http://127.0.0.1:" + port + "/v2/myreg/app/blobs/uploads/abc-123?_state=opaque");
+ exchange.sendResponseHeaders(202, -1);
+ exchange.close();
+ });
+ registry.start();
+
+ try {
+ Response response = proxy(request("POST", "v2/app/blobs/uploads/", null, Map.of()), port);
+
+ assertEquals(202, response.getStatus());
+ assertEquals("/v2/myreg/app/blobs/uploads/", receivedPath.get());
+ assertEquals("/v2/app/blobs/uploads/abc-123?_state=opaque",
+ response.getHeaderString("Location"));
+ } finally {
+ registry.stop(0);
+ }
+ }
+
+ @Test
+ void streamsTheRequestBodyWithTheLengthTheClientDeclared() throws Exception {
+ byte[] blob = "a-layer-worth-of-bytes".getBytes(StandardCharsets.UTF_8);
+ AtomicReference receivedBody = new AtomicReference<>();
+ AtomicReference receivedLength = new AtomicReference<>();
+ AtomicReference receivedQuery = new AtomicReference<>();
+ HttpServer registry = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ registry.createContext("/", exchange -> {
+ receivedLength.set(exchange.getRequestHeaders().getFirst("Content-Length"));
+ receivedQuery.set(exchange.getRequestURI().getQuery());
+ receivedBody.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
+ exchange.sendResponseHeaders(201, -1);
+ exchange.close();
+ });
+ registry.start();
+
+ try {
+ AzureRequest request = request("PUT", "v2/app/blobs/uploads/abc-123",
+ new ByteArrayInputStream(blob),
+ Map.of("Content-Length", String.valueOf(blob.length)),
+ Map.of("digest", List.of("sha256:abc")));
+
+ Response response = proxy(request, registry.getAddress().getPort());
+
+ assertEquals(201, response.getStatus());
+ assertEquals(new String(blob, StandardCharsets.UTF_8), receivedBody.get());
+ // Streamed, not chunked: a blob upload is framed by its length.
+ assertEquals(String.valueOf(blob.length), receivedLength.get());
+ // Query parameters survive the hop: the registry reads the digest it was sent.
+ assertEquals("digest=sha256:abc", receivedQuery.get());
+ } finally {
+ registry.stop(0);
+ }
+ }
+
+ @Test
+ void streamsAnUploadThatDeclaredNoLengthInsteadOfBufferingIt() throws Exception {
+ // A client that streams a layer of unknown size sends it chunked. Forwarding it chunked in
+ // turn keeps it out of memory; reading it whole to re-declare a length would not.
+ byte[] blob = "a-layer-of-unknown-size".getBytes(StandardCharsets.UTF_8);
+ AtomicReference receivedBody = new AtomicReference<>();
+ AtomicReference receivedEncoding = new AtomicReference<>();
+ AtomicReference receivedLength = new AtomicReference<>();
+ HttpServer registry = server(exchange -> {
+ receivedEncoding.set(exchange.getRequestHeaders().getFirst("Transfer-Encoding"));
+ receivedLength.set(exchange.getRequestHeaders().getFirst("Content-Length"));
+ receivedBody.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
+ exchange.sendResponseHeaders(202, -1);
+ exchange.close();
+ });
+
+ try {
+ AzureRequest request = request("PATCH", "v2/app/blobs/uploads/abc-123",
+ new ByteArrayInputStream(blob), Map.of());
+
+ Response response = proxy(request, registry.getAddress().getPort());
+
+ assertEquals(202, response.getStatus());
+ assertEquals(new String(blob, StandardCharsets.UTF_8), receivedBody.get());
+ assertEquals("chunked", receivedEncoding.get());
+ assertNull(receivedLength.get(), "a chunked body declares no length");
+ } finally {
+ registry.stop(0);
+ }
+ }
+
+ @Test
+ void doesNotChunkABodylessRequestThatDeclaredNoLength() {
+ // A GET carries nothing to stream. Declaring no length must not turn it into a chunked
+ // request the client never made.
+ AtomicReference receivedEncoding = new AtomicReference<>();
+ HttpServer registry = server(exchange -> {
+ receivedEncoding.set(exchange.getRequestHeaders().getFirst("Transfer-Encoding"));
+ exchange.sendResponseHeaders(200, -1);
+ exchange.close();
+ });
+
+ try {
+ AzureRequest request = request("GET", "v2/app/blobs/sha256:abc",
+ new ByteArrayInputStream(new byte[0]), Map.of());
+
+ assertEquals(200, proxy(request, registry.getAddress().getPort()).getStatus());
+ assertNull(receivedEncoding.get());
+ } finally {
+ registry.stop(0);
+ }
+ }
+
+ @Test
+ void catalogReportsOnlyThisRegistrysRepositories() {
+ AtomicReference receivedQuery = new AtomicReference<>();
+ HttpServer registry = catalogServer(receivedQuery, "myreg/app", "otherreg/app");
+
+ try {
+ Response response = proxy(request("GET", "v2/_catalog", null, Map.of()),
+ registry.getAddress().getPort());
+
+ assertEquals(200, response.getStatus());
+ assertEquals("{\"repositories\":[\"app\"]}", new String((byte[]) response.getEntity()));
+ // Unpaginated: seeded at the head of our block, with no page size and so no next link.
+ assertEquals("last=myreg%2F", receivedQuery.get());
+ assertNull(response.getHeaderString("Link"));
+ } finally {
+ registry.stop(0);
+ }
+ }
+
+ @Test
+ void catalogSeedsTheCursorAtThisRegistrysBlockAndAsksForOneExtra() {
+ // The seed makes the container count our repositories rather than everyone's, so a page
+ // comes back full. The extra one is what tells us whether a next page exists.
+ AtomicReference receivedQuery = new AtomicReference<>();
+ HttpServer registry = catalogServer(receivedQuery, "myreg/app", "myreg/web", "myreg/zebra");
+
+ try {
+ Response response = proxy(request("GET", "v2/_catalog", null, Map.of(),
+ Map.of("n", List.of("2"))), registry.getAddress().getPort());
+
+ assertEquals(200, response.getStatus());
+ assertEquals("last=myreg%2F&n=3", receivedQuery.get());
+ assertEquals("{\"repositories\":[\"app\",\"web\"]}", new String((byte[]) response.getEntity()));
+ assertEquals("; rel=\"next\"", response.getHeaderString("Link"));
+ } finally {
+ registry.stop(0);
+ }
+ }
+
+ @Test
+ void catalogAnswersAnEmptyPageWithoutAskingTheContainer() {
+ AtomicReference receivedQuery = new AtomicReference<>();
+ HttpServer registry = catalogServer(receivedQuery, "myreg/app", "myreg/web");
+
+ try {
+ Response response = proxy(request("GET", "v2/_catalog", null, Map.of(),
+ Map.of("n", List.of("0"))), registry.getAddress().getPort());
+
+ assertEquals(200, response.getStatus());
+ assertEquals("{\"repositories\":[]}", new String((byte[]) response.getEntity()));
+ assertNull(response.getHeaderString("Link"));
+ assertNull(receivedQuery.get(), "a page of nothing needs no container request");
+ } finally {
+ registry.stop(0);
+ }
+ }
+
+ @Test
+ void catalogPrefixesTheCursorTheClientSendsBack() {
+ AtomicReference receivedQuery = new AtomicReference<>();
+ HttpServer registry = catalogServer(receivedQuery, "myreg/zebra", "otherreg/db");
+
+ try {
+ Response response = proxy(request("GET", "v2/_catalog", null, Map.of(),
+ Map.of("n", List.of("2"), "last", List.of("web"))),
+ registry.getAddress().getPort());
+
+ assertEquals(200, response.getStatus());
+ assertEquals("last=myreg%2Fweb&n=3", receivedQuery.get());
+ // The block ends inside this page, so it is the last one and carries no next link.
+ assertEquals("{\"repositories\":[\"zebra\"]}", new String((byte[]) response.getEntity()));
+ assertNull(response.getHeaderString("Link"));
+ } finally {
+ registry.stop(0);
+ }
+ }
+
+ @Test
+ void catalogEndsThePageWhenTheExtraRepositoryIsOutsideThisRegistry() {
+ // The container advertises a next page whenever the page it returned was full, so its own
+ // Link cannot say whether we have more. The extra repository can, and it does not leak.
+ AtomicReference receivedQuery = new AtomicReference<>();
+ HttpServer registry = catalogServer(receivedQuery, "myreg/app", "myreg/web", "otherreg/db");
+
+ try {
+ Response response = proxy(request("GET", "v2/_catalog", null, Map.of(),
+ Map.of("n", List.of("2"))), registry.getAddress().getPort());
+
+ assertEquals("{\"repositories\":[\"app\",\"web\"]}", new String((byte[]) response.getEntity()));
+ assertNull(response.getHeaderString("Link"));
+ } finally {
+ registry.stop(0);
+ }
+ }
+
+ @Test
+ void catalogNeverForwardsTheBackendsOwnNextLink() {
+ AtomicReference receivedQuery = new AtomicReference<>();
+ HttpServer registry = server(exchange -> {
+ receivedQuery.set(exchange.getRequestURI().getRawQuery());
+ byte[] body = "{\"repositories\":[\"myreg/app\"]}".getBytes(StandardCharsets.UTF_8);
+ // The backend cursor names the internal repository and must never reach the client.
+ exchange.getResponseHeaders().add("Link", "; rel=\"next\"");
+ exchange.getResponseHeaders().add("Content-Type", "application/json");
+ exchange.sendResponseHeaders(200, body.length);
+ exchange.getResponseBody().write(body);
+ exchange.close();
+ });
+
+ try {
+ Response response = proxy(request("GET", "v2/_catalog", null, Map.of()),
+ registry.getAddress().getPort());
+
+ assertNull(response.getHeaderString("Link"));
+ } finally {
+ registry.stop(0);
+ }
+ }
+
+ /** A stand-in catalog answering every request with {@code repositories}, recording its query. */
+ private static HttpServer catalogServer(AtomicReference receivedQuery, String... repositories) {
+ String names = String.join(",", List.of(repositories).stream().map(r -> "\"" + r + "\"").toList());
+ byte[] body = ("{\"repositories\":[" + names + "]}").getBytes(StandardCharsets.UTF_8);
+ return server(exchange -> {
+ receivedQuery.set(exchange.getRequestURI().getRawQuery());
+ exchange.getResponseHeaders().add("Content-Type", "application/json");
+ exchange.sendResponseHeaders(200, body.length);
+ exchange.getResponseBody().write(body);
+ exchange.close();
+ });
+ }
+
+ @Test
+ void streamsTheResponseBodyBack() throws Exception {
+ HttpServer registry = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ registry.createContext("/", exchange -> {
+ byte[] body = "{}".getBytes(StandardCharsets.UTF_8);
+ exchange.getResponseHeaders().add("Docker-Content-Digest", "sha256:abc");
+ exchange.sendResponseHeaders(200, body.length);
+ exchange.getResponseBody().write(body);
+ exchange.close();
+ });
+ registry.start();
+
+ try {
+ Response response = proxy(request("GET", "v2/app/blobs/sha256:abc", null, Map.of()),
+ registry.getAddress().getPort());
+
+ assertEquals(200, response.getStatus());
+ assertEquals("sha256:abc", response.getHeaderString("Docker-Content-Digest"));
+ assertEquals("{}", new String(((InputStream) response.getEntity()).readAllBytes(),
+ StandardCharsets.UTF_8));
+ } finally {
+ registry.stop(0);
+ }
+ }
+
+ @Test
+ void reportsTheRegistryUnavailableInsteadOfFailingTheRequest() throws Exception {
+ // A port nothing is listening on: the container died, or never started.
+ HttpServer closed = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ int port = closed.getAddress().getPort();
+ closed.start();
+ closed.stop(0);
+
+ Response response = proxy(request("GET", "v2/app/tags/list", null, Map.of()), port);
+
+ assertEquals(502, response.getStatus());
+ assertTrue(response.getEntity().toString().contains("UNAVAILABLE"), "expected the registry error shape");
+ }
+
+ @Test
+ void headReportsTheBackendsStatusAndContentLength() {
+ // Docker sizes a blob it is about to push from this response: the length is the point of
+ // the request, and HEAD carries no body to re-derive it from.
+ AtomicReference receivedMethod = new AtomicReference<>();
+ HttpServer registry = server(exchange -> {
+ receivedMethod.set(exchange.getRequestMethod());
+ exchange.getResponseHeaders().add("Content-Length", "4096");
+ exchange.getResponseHeaders().add("Docker-Content-Digest", "sha256:abc");
+ exchange.sendResponseHeaders(200, -1);
+ exchange.close();
+ });
+
+ try {
+ Response response = proxy(request("HEAD", "v2/app/blobs/sha256:abc", null, Map.of()),
+ registry.getAddress().getPort());
+
+ assertEquals(200, response.getStatus());
+ assertEquals("HEAD", receivedMethod.get());
+ assertEquals("4096", response.getHeaderString("Content-Length"));
+ assertEquals("sha256:abc", response.getHeaderString("Docker-Content-Digest"));
+ } finally {
+ registry.stop(0);
+ }
+ }
+
+ @Test
+ void forwardsTheClientsQueryStringVerbatim() {
+ // registry:2 mints an opaque _state for an upload session and expects it back byte for byte.
+ // Rebuilding the query from decoded parameters would not round-trip it.
+ String rawQuery = "_state=bXlyZWcvYXBw_9-x.Y%3D%3D&digest=sha256%3Aabc";
+ AtomicReference receivedRawQuery = new AtomicReference<>();
+ HttpServer registry = server(exchange -> {
+ receivedRawQuery.set(exchange.getRequestURI().getRawQuery());
+ exchange.sendResponseHeaders(204, -1);
+ exchange.close();
+ });
+
+ try {
+ AzureRequest request = request("PUT", "v2/app/blobs/uploads/abc-123", null, Map.of(),
+ Map.of("_state", List.of("bXlyZWcvYXBw_9-x.Y=="), "digest", List.of("sha256:abc")),
+ rawQuery);
+
+ Response response = proxy(request, registry.getAddress().getPort());
+
+ assertEquals(204, response.getStatus());
+ assertEquals(rawQuery, receivedRawQuery.get());
+ } finally {
+ registry.stop(0);
+ }
+ }
+
+ // ── Helpers ──────────────────────────────────────────────────────────────────
+
+ /** A started stand-in registry answering every path with {@code handler}. */
+ private static HttpServer server(com.sun.net.httpserver.HttpHandler handler) {
+ try {
+ HttpServer registry = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ registry.createContext("/", handler);
+ registry.start();
+ return registry;
+ } catch (java.io.IOException e) {
+ throw new IllegalStateException("could not start the stand-in registry", e);
+ }
+ }
+
+ private static Response proxy(AzureRequest request, int port) {
+ return new AcrRegistryProxy().proxy(request, "myreg", "127.0.0.1:" + port);
+ }
+
+ private static AzureRequest request(String method, String path, InputStream body,
+ Map headers) {
+ return request(method, path, body, headers, Map.of(), null);
+ }
+
+ private static AzureRequest request(String method, String path, InputStream body,
+ Map headers,
+ Map> queryParams) {
+ return request(method, path, body, headers, queryParams, null);
+ }
+
+ private static AzureRequest request(String method, String path, InputStream body,
+ Map headers,
+ Map> queryParams, String rawQuery) {
+ MultivaluedMap requestHeaders = new MultivaluedHashMap<>();
+ headers.forEach(requestHeaders::add);
+ HttpHeaders httpHeaders = mock(HttpHeaders.class);
+ when(httpHeaders.getRequestHeaders()).thenReturn(requestHeaders);
+ headers.forEach((name, value) -> when(httpHeaders.getHeaderString(name)).thenReturn(value));
+
+ return new AzureRequest(method, "myreg", "acr", path, httpHeaders, body,
+ Map.of(), queryParams, null, true, "myreg.azurecr.io", "127.0.0.1", path, rawQuery);
+ }
+}
diff --git a/src/test/java/io/floci/az/services/acr/AcrRegistryProxyTest.java b/src/test/java/io/floci/az/services/acr/AcrRegistryProxyTest.java
new file mode 100644
index 00000000..f6465176
--- /dev/null
+++ b/src/test/java/io/floci/az/services/acr/AcrRegistryProxyTest.java
@@ -0,0 +1,143 @@
+package io.floci.az.services.acr;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The repository namespacing the proxy applies: one shared container backs every registry, so the
+ * registry name is prefixed on the way in and stripped from everything the client reads back.
+ */
+@DisplayName("AcrRegistryProxy: repository prefixing")
+class AcrRegistryProxyTest {
+
+ @Test
+ void prefixesRepositoryScopedPathsWithTheRegistryName() {
+ assertEquals("v2/myreg/app/manifests/v1",
+ AcrRegistryProxy.backendPath("myreg", "v2/app/manifests/v1"));
+ assertEquals("v2/myreg/team/app/blobs/sha256:abc",
+ AcrRegistryProxy.backendPath("myreg", "v2/team/app/blobs/sha256:abc"));
+ assertEquals("v2/myreg/app/blobs/uploads/",
+ AcrRegistryProxy.backendPath("myreg", "v2/app/blobs/uploads/"));
+ assertEquals("v2/myreg/app/tags/list",
+ AcrRegistryProxy.backendPath("myreg", "v2/app/tags/list"));
+ }
+
+ @Test
+ void leavesRegistryScopedPathsAlone() {
+ assertEquals("v2/", AcrRegistryProxy.backendPath("myreg", "v2/"));
+ assertEquals("v2/_catalog", AcrRegistryProxy.backendPath("myreg", "v2/_catalog"));
+ }
+
+ @Test
+ void stripsThePrefixFromAnUploadSessionLocation() {
+ assertEquals("/v2/app/blobs/uploads/abc-123?_state=xyz",
+ AcrRegistryProxy.clientLocation("myreg", "/v2/myreg/app/blobs/uploads/abc-123?_state=xyz"));
+ }
+
+ @Test
+ void rewritesAnAbsoluteLocationToAPathTheClientCanFollow() {
+ assertEquals("/v2/app/blobs/uploads/abc-123",
+ AcrRegistryProxy.clientLocation("myreg",
+ "http://floci-az-acr-registry:5000/v2/myreg/app/blobs/uploads/abc-123"));
+ }
+
+ @Test
+ void leavesALocationThatCarriesNoPrefixUntouched() {
+ assertEquals("/v2/other/manifests/v1",
+ AcrRegistryProxy.clientLocation("myreg", "/v2/other/manifests/v1"));
+ assertEquals(null, AcrRegistryProxy.clientLocation("myreg", null));
+ }
+
+ @Test
+ void catalogShowsOnlyThisRegistrysRepositoriesWithoutThePrefix() {
+ byte[] shared = ("{\"repositories\":[\"myreg/app\",\"myreg/team/api\"]}")
+ .getBytes(StandardCharsets.UTF_8);
+
+ assertEquals(List.of("app", "team/api"), AcrRegistryProxy.ownRepositories("myreg/", shared));
+ }
+
+ @Test
+ void catalogStopsReadingAtTheFirstRepositoryOutsideThisRegistry() {
+ // The container walks a directory tree, so a registry's repositories are a contiguous
+ // subtree. The first outsider ends the block and nothing of ours follows it, which is what
+ // lets one page be answered by one backend request.
+ byte[] shared = ("{\"repositories\":[\"myreg/app\",\"myreg/web\",\"myreg-1/app\","
+ + "\"myreg0/x\",\"otherreg/db\"]}").getBytes(StandardCharsets.UTF_8);
+
+ assertEquals(List.of("app", "web"), AcrRegistryProxy.ownRepositories("myreg/", shared));
+ }
+
+ @Test
+ void catalogStopsAtTheBlockEdgeRatherThanSkippingPastIt() {
+ // A real container cannot produce this, because the block is a subtree. Stopping rather
+ // than skipping is what makes that an invariant: collecting across a gap would count
+ // repositories the page size did not account for and mint a cursor that skips the gap.
+ byte[] shared = ("{\"repositories\":[\"myreg/app\",\"otherreg/db\",\"myreg/web\"]}")
+ .getBytes(StandardCharsets.UTF_8);
+
+ assertEquals(List.of("app"), AcrRegistryProxy.ownRepositories("myreg/", shared));
+ }
+
+ @Test
+ void catalogReadsNothingWhenTheBlockHasNotStarted() {
+ byte[] shared = "{\"repositories\":[\"otherreg/db\"]}".getBytes(StandardCharsets.UTF_8);
+
+ assertEquals(List.of(), AcrRegistryProxy.ownRepositories("myreg/", shared));
+ }
+
+ @Test
+ void aPageSizeIsReadOnlyWhenTheClientAskedForOne() {
+ assertEquals(AcrRegistryProxy.UNPAGINATED, AcrRegistryProxy.pageSize(null));
+ assertEquals(AcrRegistryProxy.UNPAGINATED, AcrRegistryProxy.pageSize(""));
+ assertEquals(AcrRegistryProxy.UNPAGINATED, AcrRegistryProxy.pageSize("not-a-number"));
+ assertEquals(AcrRegistryProxy.UNPAGINATED, AcrRegistryProxy.pageSize("-4"));
+ assertEquals(10, AcrRegistryProxy.pageSize(" 10 "));
+ }
+
+ @Test
+ void askingForNoRepositoriesIsNotAskingForAllOfThem() {
+ // n=0 and no n at all used to collapse to the same answer, which handed a client that
+ // asked for an empty page the entire catalog.
+ assertEquals(0, AcrRegistryProxy.pageSize("0"));
+ assertNotEquals(AcrRegistryProxy.pageSize("0"), AcrRegistryProxy.pageSize(null));
+ }
+
+ @Test
+ void theNextLinkCarriesAnUnprefixedCursorInTheRegistrysOwnShape() {
+ assertEquals("; rel=\"next\"",
+ AcrRegistryProxy.nextLink("team/api", 2));
+ }
+
+ @Test
+ void tagsListNamesTheRepositoryTheClientAskedAbout() {
+ byte[] backend = "{\"name\":\"myreg/team/api\",\"tags\":[\"v1\"]}".getBytes(StandardCharsets.UTF_8);
+
+ assertEquals("{\"name\":\"team/api\",\"tags\":[\"v1\"]}",
+ new String(AcrRegistryProxy.unprefixRepositoryName("myreg", backend),
+ StandardCharsets.UTF_8));
+ }
+
+ @Test
+ void onlyBodiesThatNameRepositoriesAreRewritten() {
+ // The catalog is not among them: it is built from the container's answer rather than
+ // rewritten in place, because it is also paginated.
+ assertFalse(AcrRegistryProxy.rewritesBody("v2/_catalog"));
+ assertTrue(AcrRegistryProxy.rewritesBody("v2/app/tags/list"));
+ assertFalse(AcrRegistryProxy.rewritesBody("v2/app/manifests/v1"));
+ assertFalse(AcrRegistryProxy.rewritesBody("v2/app/blobs/sha256:abc"));
+ }
+
+ @Test
+ void catalogReadsNothingWhenTheBodyIsNotTheExpectedShape() {
+ assertEquals(List.of(),
+ AcrRegistryProxy.ownRepositories("myreg/", "".getBytes(StandardCharsets.UTF_8)));
+ }
+}
diff --git a/src/test/java/io/floci/az/services/acr/AcrTokenEndpointsTest.java b/src/test/java/io/floci/az/services/acr/AcrTokenEndpointsTest.java
new file mode 100644
index 00000000..a08cd98e
--- /dev/null
+++ b/src/test/java/io/floci/az/services/acr/AcrTokenEndpointsTest.java
@@ -0,0 +1,348 @@
+package io.floci.az.services.acr;
+
+import io.quarkus.test.junit.QuarkusTest;
+import io.quarkus.test.junit.QuarkusTestProfile;
+import io.quarkus.test.junit.TestProfile;
+import io.restassured.path.json.JsonPath;
+import io.restassured.response.Response;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.List;
+import java.util.Map;
+
+import static io.restassured.RestAssured.given;
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.CoreMatchers.notNullValue;
+import static org.hamcrest.Matchers.anyOf;
+import static org.hamcrest.Matchers.greaterThan;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The ACR Entra token exchange as the clients speak it: the bearer challenge, both grant types on
+ * each {@code /oauth2/} endpoint, Docker's {@code GET} form, and the error shapes. The protocol is
+ * taken from Azure CLI 2.89.1 ({@code azure/cli/command_modules/acr/_docker_utils.py}).
+ *
+ * Runs in mocked mode: the auth surface needs no Docker. Repository operations do, so here they
+ * answer with the registry's {@code UNSUPPORTED} error.
+ */
+@QuarkusTest
+@TestProfile(AcrTokenEndpointsTest.MockedProfile.class)
+@DisplayName("ACR: Entra token exchange protocol")
+public class AcrTokenEndpointsTest {
+
+ public static class MockedProfile implements QuarkusTestProfile {
+ @Override
+ public Map getConfigOverrides() {
+ return Map.of("floci-az.services.acr.mocked", "true");
+ }
+ }
+
+ private static final String REGISTRY = "acrproto";
+ private static final String LOGIN_SERVER = REGISTRY + ".azurecr.io";
+ private static final String FORM = "application/x-www-form-urlencoded";
+ /** Stands in for a token from /oauth2/exchange: not verified, but it must be JWT-shaped. */
+ private static final String REFRESH_TOKEN = "header.payload.signature";
+ private static final String ARM_REGISTRY =
+ "/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/test-rg"
+ + "/providers/Microsoft.ContainerRegistry/registries/" + REGISTRY;
+
+ /** The data plane serves a registry that exists, so create it the way a client would. */
+ @BeforeEach
+ void createTheRegistry() {
+ given().when().post("/_admin/reset").then().statusCode(204);
+ given().contentType("application/json")
+ .body("{\"location\":\"eastus\",\"sku\":{\"name\":\"Basic\"},"
+ + "\"properties\":{\"adminUserEnabled\":true}}")
+ .when().put(ARM_REGISTRY + "?api-version=2025-11-01")
+ .then().statusCode(201);
+ }
+
+ // ── Challenge ────────────────────────────────────────────────────────────────
+
+ @Test
+ void getV2AnswersTheBearerChallengeThatStartsTheFlow() {
+ // The test client speaks plain HTTP, so the realm must too: pointing a client at https://
+ // when TLS is off sends it to a port nothing is listening on.
+ given().header("Host", LOGIN_SERVER)
+ .when().get("/v2/")
+ .then().statusCode(401)
+ .header("WWW-Authenticate", is("Bearer realm=\"http://" + LOGIN_SERVER
+ + "/oauth2/token\",service=\"" + LOGIN_SERVER + "\""))
+ .body("errors[0].code", is("UNAUTHORIZED"));
+ }
+
+ @Test
+ void theChallengeRealmFollowsAForwardedProto() {
+ // A TLS-terminating proxy in front of the emulator: the caller reached it over https, even
+ // though this hop is plaintext.
+ given().header("Host", LOGIN_SERVER).header("X-Forwarded-Proto", "https")
+ .when().get("/v2/")
+ .then().statusCode(401)
+ .header("WWW-Authenticate", is("Bearer realm=\"https://" + LOGIN_SERVER
+ + "/oauth2/token\",service=\"" + LOGIN_SERVER + "\""));
+ }
+
+ @Test
+ void theTokenEndpointsExistOnlyOnTheRegistryHost() {
+ Response offHost = given().contentType(FORM)
+ .formParam("grant_type", "access_token")
+ .formParam("service", LOGIN_SERVER)
+ .formParam("tenant", "00000000-0000-0000-0000-000000000002")
+ .formParam("access_token", "entra-token")
+ .when().post("/oauth2/exchange");
+
+ assertNotEquals(200, offHost.statusCode(),
+ "the exchange endpoint must not answer on the emulator's own host");
+ }
+
+ // ── /oauth2/exchange ─────────────────────────────────────────────────────────
+
+ @Test
+ void exchangeTradesAnEntraAccessTokenForARefreshToken() {
+ String refreshToken = exchange("access_token").then().statusCode(200)
+ .body("refresh_token", notNullValue())
+ .extract().path("refresh_token");
+
+ assertEquals(3, refreshToken.split("\\.").length, "the refresh token must be a JWT");
+ assertEquals(LOGIN_SERVER, claims(refreshToken).getString("aud"));
+ }
+
+ @Test
+ void exchangeAcceptsEveryGrantTypeTheClientsSend() {
+ exchange("refresh_token").then().statusCode(200).body("refresh_token", notNullValue());
+ exchange("access_token_refresh_token").then().statusCode(200).body("refresh_token", notNullValue());
+ }
+
+ @Test
+ void exchangeRejectsAnUnknownGrantTypeInTheRegistryErrorShape() {
+ exchange("client_credentials").then().statusCode(400)
+ .body("errors[0].code", is("UNSUPPORTED"))
+ .body("errors[0].message", notNullValue());
+ }
+
+ @Test
+ void exchangeRequiresTheServiceParameter() {
+ given().header("Host", LOGIN_SERVER)
+ .contentType(FORM)
+ .formParam("grant_type", "access_token")
+ .formParam("access_token", "entra-token")
+ .when().post("/oauth2/exchange")
+ .then().statusCode(400)
+ .body("errors[0].code", is("UNSUPPORTED"));
+ }
+
+ @Test
+ void aMalformedFormBodyIsAMalformedRequestNotAServerError() {
+ // "%zz" is not a percent escape. The parameter is treated as absent, so both endpoints
+ // answer their own 400 in the registry error shape rather than failing to parse.
+ given().header("Host", LOGIN_SERVER)
+ .contentType(FORM)
+ .body("grant_type=access_token&service=%zz")
+ .when().post("/oauth2/exchange")
+ .then().statusCode(400)
+ .body("errors[0].code", is("UNSUPPORTED"));
+
+ given().header("Host", LOGIN_SERVER)
+ .contentType(FORM)
+ .body("grant_type=refresh_token&refresh_token=" + REFRESH_TOKEN + "&service=%zz")
+ .when().post("/oauth2/token")
+ .then().statusCode(400)
+ .body("errors[0].code", is("UNSUPPORTED"));
+ }
+
+ @Test
+ void exchangeAcceptsPostOnly() {
+ given().header("Host", LOGIN_SERVER)
+ .when().get("/oauth2/exchange")
+ .then().statusCode(405)
+ .body("errors[0].code", is("UNSUPPORTED"));
+ }
+
+ // ── /oauth2/token ────────────────────────────────────────────────────────────
+
+ @Test
+ void tokenTradesARefreshTokenForAScopedAccessToken() {
+ String accessToken = given().header("Host", LOGIN_SERVER)
+ .contentType(FORM)
+ .formParam("grant_type", "refresh_token")
+ .formParam("service", LOGIN_SERVER)
+ .formParam("scope", "repository:app:pull,push")
+ .formParam("refresh_token", REFRESH_TOKEN)
+ .when().post("/oauth2/token")
+ .then().statusCode(200)
+ .body("expires_in", greaterThan(0))
+ .body("issued_at", notNullValue())
+ .extract().path("access_token");
+
+ JsonPath claims = claims(accessToken);
+ assertEquals(LOGIN_SERVER, claims.getString("aud"));
+ assertEquals("repository", claims.getString("access[0].type"));
+ assertEquals("app", claims.getString("access[0].name"));
+ assertEquals(List.of("pull", "push"), claims.getList("access[0].actions"));
+ }
+
+ @Test
+ void tokenAcceptsTheRegistryScopeWithoutARepository() {
+ String accessToken = tokenForScope("registry:catalog:*");
+
+ JsonPath claims = claims(accessToken);
+ assertEquals("registry", claims.getString("access[0].type"));
+ assertEquals("catalog", claims.getString("access[0].name"));
+ }
+
+ @Test
+ void tokenAcceptsAdminCredentialsThroughThePasswordGrant() {
+ given().header("Host", LOGIN_SERVER)
+ .contentType(FORM)
+ .formParam("grant_type", "password")
+ .formParam("service", LOGIN_SERVER)
+ .formParam("scope", "repository:app:pull")
+ .formParam("username", REGISTRY)
+ .formParam("password", "an-admin-password")
+ .when().post("/oauth2/token")
+ .then().statusCode(200)
+ .body("access_token", notNullValue());
+ }
+
+ @Test
+ void tokenAnswersDockersOwnGetForm() {
+ String accessToken = given().header("Host", LOGIN_SERVER)
+ .queryParam("service", LOGIN_SERVER)
+ .queryParam("scope", "repository:app:pull,push")
+ .when().get("/oauth2/token")
+ .then().statusCode(200)
+ .body("expires_in", greaterThan(0))
+ .extract().path("access_token");
+
+ assertEquals("app", claims(accessToken).getString("access[0].name"));
+ }
+
+ @Test
+ void tokenRejectsMalformedRequestsInTheRegistryErrorShape() {
+ // Unknown grant type.
+ token("authorization_code", Map.of("service", LOGIN_SERVER))
+ .then().statusCode(400).body("errors[0].code", is("UNSUPPORTED"));
+ // refresh_token grant without a token.
+ token("refresh_token", Map.of("service", LOGIN_SERVER))
+ .then().statusCode(400).body("errors[0].code", is("UNSUPPORTED"));
+ // password grant without a password.
+ token("password", Map.of("service", LOGIN_SERVER, "username", REGISTRY))
+ .then().statusCode(400).body("errors[0].code", is("UNSUPPORTED"));
+ // No service.
+ token("refresh_token", Map.of("refresh_token", REFRESH_TOKEN))
+ .then().statusCode(400).body("errors[0].code", is("UNSUPPORTED"));
+ }
+
+ @Test
+ void tokenRejectsARefreshTokenThatIsNotEvenAJwt() {
+ // Nothing verifies the token, but a malformed one still gets the 401 that makes a client
+ // re-authenticate rather than an access token it cannot use.
+ token("refresh_token", Map.of("service", LOGIN_SERVER, "refresh_token", "not-a-jwt"))
+ .then().statusCode(401).body("errors[0].code", is("UNAUTHORIZED"));
+ }
+
+ // ── Repository operations ────────────────────────────────────────────────────
+
+ @Test
+ void repositoryOperationsReportUnsupportedInMockedMode() {
+ given().header("Host", LOGIN_SERVER).header("Authorization", "Bearer a-token")
+ .when().get("/v2/app/tags/list")
+ .then().statusCode(405)
+ .body("errors[0].code", is("UNSUPPORTED"));
+
+ given().header("Host", LOGIN_SERVER).header("Authorization", "Bearer a-token")
+ .when().get("/v2/")
+ .then().statusCode(405)
+ .body("errors[0].code", is("UNSUPPORTED"));
+ }
+
+ @Test
+ void unknownPathsOnTheRegistryHostUseTheRegistryErrorShape() {
+ given().header("Host", LOGIN_SERVER)
+ .when().get("/v1/repositories")
+ .then().statusCode(404)
+ .body("errors[0].code", is("NAME_UNKNOWN"));
+ }
+
+ @Test
+ void aRegistryThatWasNeverCreatedIsNotServed() {
+ // The hosts entry points every *.azurecr.io name at the emulator, so a name nobody created
+ // must be refused rather than handed tokens and a repository prefix of its own.
+ String unknown = "acrneverexisted.azurecr.io";
+
+ given().header("Host", unknown)
+ .when().get("/v2/")
+ .then().statusCode(404)
+ .body("errors[0].code", is("NAME_UNKNOWN"));
+
+ given().header("Host", unknown).contentType(FORM)
+ .formParam("grant_type", "access_token")
+ .formParam("service", unknown)
+ .when().post("/oauth2/exchange")
+ .then().statusCode(404)
+ .body("errors[0].code", is("NAME_UNKNOWN"));
+
+ given().header("Host", unknown).contentType(FORM)
+ .formParam("grant_type", "refresh_token")
+ .formParam("service", unknown)
+ .formParam("refresh_token", REFRESH_TOKEN)
+ .when().post("/oauth2/token")
+ .then().statusCode(404)
+ .body("errors[0].code", is("NAME_UNKNOWN"));
+ }
+
+ @Test
+ void deletingTheRegistryTakesItsDataPlaneWithIt() {
+ given().when().delete(ARM_REGISTRY + "?api-version=2025-11-01")
+ .then().statusCode(anyOf(is(200), is(202), is(204)));
+
+ given().header("Host", LOGIN_SERVER)
+ .when().get("/v2/")
+ .then().statusCode(404)
+ .body("errors[0].code", is("NAME_UNKNOWN"));
+ }
+
+ // ── Helpers ──────────────────────────────────────────────────────────────────
+
+ private static Response exchange(String grantType) {
+ return given().header("Host", LOGIN_SERVER)
+ .contentType(FORM)
+ .formParam("grant_type", grantType)
+ .formParam("service", LOGIN_SERVER)
+ .formParam("tenant", "00000000-0000-0000-0000-000000000002")
+ .formParam("access_token", "an-entra-token")
+ .when().post("/oauth2/exchange");
+ }
+
+ private static Response token(String grantType, Map form) {
+ var request = given().header("Host", LOGIN_SERVER).contentType(FORM)
+ .formParam("grant_type", grantType);
+ form.forEach(request::formParam);
+ return request.when().post("/oauth2/token");
+ }
+
+ private static String tokenForScope(String scope) {
+ return given().header("Host", LOGIN_SERVER)
+ .contentType(FORM)
+ .formParam("grant_type", "refresh_token")
+ .formParam("service", LOGIN_SERVER)
+ .formParam("scope", scope)
+ .formParam("refresh_token", REFRESH_TOKEN)
+ .when().post("/oauth2/token")
+ .then().statusCode(200)
+ .extract().path("access_token");
+ }
+
+ /** The token's claims: clients decode these without verifying the signature. */
+ private static JsonPath claims(String jwt) {
+ String[] parts = jwt.split("\\.");
+ assertTrue(parts.length == 3, "expected a compact JWT, got: " + jwt);
+ return new JsonPath(new String(Base64.getUrlDecoder().decode(parts[1]), StandardCharsets.UTF_8));
+ }
+}
diff --git a/src/test/java/io/floci/az/services/acr/AcrTokenServiceTest.java b/src/test/java/io/floci/az/services/acr/AcrTokenServiceTest.java
new file mode 100644
index 00000000..94b0d8a2
--- /dev/null
+++ b/src/test/java/io/floci/az/services/acr/AcrTokenServiceTest.java
@@ -0,0 +1,101 @@
+package io.floci.az.services.acr;
+
+import io.floci.az.services.acr.AcrTokenService.Access;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Scope parsing and the bearer challenge, the two pieces of the ACR token exchange that are pure
+ * functions of what the client sent. Scope forms are taken from Azure CLI 2.89.1
+ * ({@code azure/cli/command_modules/acr/_docker_utils.py}).
+ */
+@DisplayName("AcrTokenService: scope parsing and the bearer challenge")
+class AcrTokenServiceTest {
+
+ @Test
+ void parsesTheRepositoryScopeTheAzureCliSends() {
+ Access access = AcrTokenService.parseScope("repository:app:pull,push").orElseThrow();
+
+ assertEquals("repository", access.type());
+ assertEquals("app", access.name());
+ assertEquals(List.of("pull", "push"), access.actions());
+ }
+
+ @Test
+ void keepsSlashesInARepositoryName() {
+ Access access = AcrTokenService.parseScope("repository:team/app:metadata_read").orElseThrow();
+
+ assertEquals("team/app", access.name());
+ assertEquals(List.of("metadata_read"), access.actions());
+ }
+
+ @Test
+ void parsesTheArtifactRepositoryScope() {
+ Access access = AcrTokenService.parseScope("artifact-repository:charts:pull").orElseThrow();
+
+ assertEquals("artifact-repository", access.type());
+ assertEquals("charts", access.name());
+ }
+
+ @Test
+ void parsesTheRegistryScopeWhoseActionIsAlwaysAStar() {
+ Access access = AcrTokenService.parseScope("registry:catalog:*").orElseThrow();
+
+ assertEquals("registry", access.type());
+ assertEquals("catalog", access.name());
+ assertEquals(List.of("*"), access.actions());
+ }
+
+ @Test
+ void rejectsScopesThatAreNotTypeNameActions() {
+ assertEquals(Optional.empty(), AcrTokenService.parseScope(null));
+ assertEquals(Optional.empty(), AcrTokenService.parseScope(""));
+ assertEquals(Optional.empty(), AcrTokenService.parseScope("repository"));
+ assertEquals(Optional.empty(), AcrTokenService.parseScope("repository:app"));
+ assertEquals(Optional.empty(), AcrTokenService.parseScope("repository:app:"));
+ assertEquals(Optional.empty(), AcrTokenService.parseScope(":app:pull"));
+ }
+
+ @Test
+ void parsesEveryScopeWhenTheParameterRepeatsOrCarriesSeveral() {
+ List access = AcrTokenService.parseScopes(
+ List.of("repository:app:pull repository:other:push", "registry:catalog:*"));
+
+ assertEquals(3, access.size());
+ assertEquals("app", access.get(0).name());
+ assertEquals("other", access.get(1).name());
+ assertEquals("catalog", access.get(2).name());
+ }
+
+ @Test
+ void skipsUnparseableScopesRatherThanFailingTheRequest() {
+ assertEquals(List.of(), AcrTokenService.parseScopes(List.of("")));
+ assertEquals(List.of(), AcrTokenService.parseScopes(null));
+ assertEquals(1, AcrTokenService.parseScopes(List.of("nonsense repository:app:pull")).size());
+ }
+
+ @Test
+ void challengeNamesTheRealmAndServiceTheClientParses() {
+ String challenge = AcrTokenService.challenge("https", "myregistry.azurecr.io");
+
+ assertEquals("Bearer realm=\"https://myregistry.azurecr.io/oauth2/token\","
+ + "service=\"myregistry.azurecr.io\"", challenge);
+ // The CLI splits on ' ' then ',' and requires both parameters to be quoted.
+ assertTrue(challenge.startsWith("Bearer "));
+ }
+
+ @Test
+ void challengeRealmCarriesTheSchemeTheCallerUsed() {
+ // TLS is off by default, and a realm hardcoded to https:// would send the client to a port
+ // nothing is listening on. The service is the login server either way.
+ assertEquals("Bearer realm=\"http://myregistry.azurecr.io/oauth2/token\","
+ + "service=\"myregistry.azurecr.io\"",
+ AcrTokenService.challenge("http", "myregistry.azurecr.io"));
+ }
+}
diff --git a/src/test/java/io/floci/az/services/containerapps/ContainerAppIngressProxyTest.java b/src/test/java/io/floci/az/services/containerapps/ContainerAppIngressProxyTest.java
index eaeadad3..9a3e86de 100644
--- a/src/test/java/io/floci/az/services/containerapps/ContainerAppIngressProxyTest.java
+++ b/src/test/java/io/floci/az/services/containerapps/ContainerAppIngressProxyTest.java
@@ -38,7 +38,7 @@ void preservesEncodedIngressPath() throws Exception {
when(headers.getRequestHeaders()).thenReturn(new MultivaluedHashMap<>());
AzureRequest request = new AzureRequest("GET", "app", "containerapps", "items/a/b c",
headers, null, Map.of(), Map.of(), null, false, "app.azurecontainerapps.io", "127.0.0.1",
- "items/a%2Fb%20c%3Fvalue%23part").withAuthContext(null);
+ "items/a%2Fb%20c%3Fvalue%23part", null).withAuthContext(null);
assertEquals("app.azurecontainerapps.io", request.host());
assertEquals("127.0.0.1", request.remoteAddress());
var endpoint = new ContainerLifecycleManager.EndpointInfo(