diff --git a/go.mod b/go.mod index 5961faf..5dbe974 100644 --- a/go.mod +++ b/go.mod @@ -36,6 +36,8 @@ require ( github.com/microcosm-cc/bluemonday v1.0.27 github.com/multiformats/go-multihash v0.2.3 github.com/opencontainers/go-digest v1.0.0 + github.com/opencontainers/image-spec v1.1.1 + github.com/regclient/regclient v0.11.3 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c @@ -51,6 +53,7 @@ require ( golang.org/x/image v0.39.0 golang.org/x/sys v0.43.0 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da + oras.land/oras-go/v2 v2.6.0 ) require ( @@ -97,6 +100,7 @@ require ( github.com/docker/docker-credential-helpers v0.9.5 // indirect github.com/docker/go-events v0.0.0-20250808211157-605354379745 // indirect github.com/docker/go-metrics v0.0.1 // indirect + github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fatih/color v1.19.0 // indirect @@ -154,7 +158,6 @@ require ( github.com/multiformats/go-multicodec v0.10.0 // indirect github.com/multiformats/go-varint v0.1.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/opencontainers/image-spec v1.1.1 // indirect github.com/opentracing/opentracing-go v1.2.0 // indirect github.com/pelletier/go-toml/v2 v2.3.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect @@ -176,6 +179,7 @@ require ( github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/ulikunitz/xz v0.5.15 // indirect github.com/vbatts/tar-split v0.12.2 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect diff --git a/go.sum b/go.sum index acd24c6..fd2db9a 100644 --- a/go.sum +++ b/go.sum @@ -205,6 +205,8 @@ github.com/docker/go-events v0.0.0-20250808211157-605354379745 h1:yOn6Ze6IbYI/KA github.com/docker/go-events v0.0.0-20250808211157-605354379745/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA= github.com/docker/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQV8= github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw= +github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 h1:UhxFibDNY/bfvqU5CAUmr9zpesgbU6SWc8/B4mflAE4= +github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg= @@ -575,6 +577,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8m github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo= github.com/neelance/sourcemap v0.0.0-20200213170602-2833bce08e4c/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM= +github.com/olareg/olareg v0.1.2 h1:75G8X6E9FUlzL/CSjgFcYfMgNzlc7CxULpUUNsZBIvI= +github.com/olareg/olareg v0.1.2/go.mod h1:TWs+N6pO1S4bdB6eerzUm/ITRQ6kw91mVf9ZYeGtw+Y= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -625,6 +629,8 @@ github.com/redis/go-redis/extra/redisotel/v9 v9.18.0 h1:2dKdoEYBJ0CZCLPiCdvvc7lu github.com/redis/go-redis/extra/redisotel/v9 v9.18.0/go.mod h1:WzkrVG9ro9BwCQD0eJOWn6AGL4Z1CleGflM45w1hu10= github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +github.com/regclient/regclient v0.11.3 h1:aTnVRsgFaOmezgKp7caL3zINrZKAXsMbzS1oCgD7/cA= +github.com/regclient/regclient v0.11.3/go.mod h1:a4PDi+VyEbBuV/5hCfMjnYH8jvB7NgD0mdggwNRECy8= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= @@ -695,6 +701,8 @@ github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFd github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ= github.com/tursodatabase/go-libsql v0.0.0-20251219133454-43644db490ff h1:Hvxz9W8fWpSg9xkiq8/q+3cVJo+MmLMfkjdS/u4nWFY= github.com/tursodatabase/go-libsql v0.0.0-20251219133454-43644db490ff/go.mod h1:TjsB2miB8RW2Sse8sdxzVTdeGlx74GloD5zJYUC38d8= +github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= +github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4= github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= @@ -716,8 +724,8 @@ github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= -github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA= +github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= @@ -1217,6 +1225,8 @@ honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9 honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= +oras.land/oras-go/v2 v2.6.0 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc= +oras.land/oras-go/v2 v2.6.0/go.mod h1:magiQDfG6H1O9APp+rOsvCPcW1GD2MM7vgnKY0Y+u1o= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/go.work.sum b/go.work.sum index 28d1c41..b5ba1b2 100644 --- a/go.work.sum +++ b/go.work.sum @@ -466,6 +466,8 @@ github.com/PuerkitoBio/purell v1.2.1 h1:QsZ4TjvwiMpat6gBCBxEQI0rcS9ehtkKtSpiUnd9 github.com/PuerkitoBio/purell v1.2.1/go.mod h1:ZwHcC/82TOaovDi//J/804umJFFmbOHPngi8iYYv/Eo= github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d h1:G0m3OIz70MZUWq3EgK3CesDbo8upS2Vm9/P3FtgI+Jk= github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d/go.mod h1:3eOhrUMpNV+6aFIbp5/iudMxNCF27Vw2OZgy4xEx0Fg= +github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow= +github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4= github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8= github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo= github.com/agnivade/levenshtein v1.2.0 h1:U9L4IOT0Y3i0TIlUIDJ7rVUziKi/zPbrJGaFrtYH3SY= @@ -661,8 +663,13 @@ github.com/containernetworking/cni v1.3.0 h1:v6EpN8RznAZj9765HhXQrtXgX+ECGebEYEm github.com/containernetworking/cni v1.3.0/go.mod h1:Bs8glZjjFfGPHMw6hQu82RUgEPNGEaBb9KS5KtNMnJ4= github.com/containernetworking/plugins v1.9.0 h1:Mg3SXBdRGkdXyFC4lcwr6u2ZB2SDeL6LC3U+QrEANuQ= github.com/containernetworking/plugins v1.9.0/go.mod h1:JG3BxoJifxxHBhG3hFyxyhid7JgRVBu/wtooGEvWf1c= +github.com/containers/libtrust v0.0.0-20230121012942-c1716e8a8d01 h1:Qzk5C6cYglewc+UyGf6lc8Mj2UaPTHy/iF2De0/77CA= +github.com/containers/libtrust v0.0.0-20230121012942-c1716e8a8d01/go.mod h1:9rfv8iPl1ZP7aqh9YA68wnZv2NUDbXdcdPHVz0pFbPY= github.com/containers/ocicrypt v1.2.1 h1:0qIOTT9DoYwcKmxSt8QJt+VzMY18onl9jUXsxpVhSmM= github.com/containers/ocicrypt v1.2.1/go.mod h1:aD0AAqfMp0MtwqWgHM1bUwe1anx0VazI108CRrSKINQ= +github.com/containers/storage v1.59.1 h1:11Zu68MXsEQGBBd+GadPrHPpWeqjKS8hJDGiAHgIqDs= +github.com/containers/storage v1.59.1/go.mod h1:KoAYHnAjP3/cTsRS+mmWZGkufSY2GACiKQ4V3ZLQnR0= +github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU= github.com/coreos/go-semver v0.3.0 h1:wkHLiw0WNATZnSG7epLsujiMCgPAc9xhjJ4tgnAxmfM= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU= @@ -673,6 +680,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3 github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/crackcomm/go-gitignore v0.0.0-20241020182519-7843d2ba8fdf h1:dwGgBWn84wUS1pVikGiruW+x5XM4amhjaZO20vCjay4= github.com/crackcomm/go-gitignore v0.0.0-20241020182519-7843d2ba8fdf/go.mod h1:p1d6YEZWvFzEh4KLyvBcVSnrfNDDvK2zfK/4x2v/4pE= +github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 h1:uX1JmpONuD549D73r6cgnxyUu18Zb7yHAy5AYU0Pm4Q= +github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467/go.mod h1:uzvlm1mxhHkdfqitSA92i7Se+S9ksOn3a3qmv/kyOCw= github.com/cyphar/filepath-securejoin v0.5.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= github.com/cyphar/filepath-securejoin v0.6.0/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= @@ -774,6 +783,7 @@ github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv github.com/google/go-cmdtest v0.4.0 h1:ToXh6W5spLp3npJV92tk6d5hIpUPYEzHLkD+rncbyhI= github.com/google/go-cmdtest v0.4.0/go.mod h1:apVn/GCasLZUVpAJ6oWAuyP7Ne7CEsQbTnc0plM3m+o= github.com/google/go-containerregistry v0.20.1/go.mod h1:YCMFNQeeXeLF+dnhhWkqDItx/JSkH01j1Kis4PsjzFI= +github.com/google/go-intervals v0.0.2/go.mod h1:MkaR3LNRfeKLPmqgJYs4E66z5InYjmCjbbr4TQlcT6Y= github.com/google/go-pkcs11 v0.3.0 h1:PVRnTgtArZ3QQqTGtbtjtnIkzl2iY2kt24yqbrf7td8= github.com/google/go-pkcs11 v0.3.0/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY= github.com/google/gofuzz v1.0.0 h1:A8PeW59pxE9IoFRqBp37U+mSNaQoZ46F1f0f863XSXw= @@ -955,6 +965,7 @@ github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo= github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd h1:br0buuQ854V8u83wA0rVZ8ttrq5CpaPZdvrK0LP2lOk= github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd/go.mod h1:QuCEs1Nt24+FYQEqAAncTDPJIuGs+LxK1MCiFL25pMU= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= @@ -968,6 +979,8 @@ github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebG github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0= github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= +github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/goveralls v0.0.5/go.mod h1:Xg2LHi51faXLyKXwsndxiW6uxEEQT9+3sjGzzwU4xy0= github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= @@ -1010,6 +1023,7 @@ github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+ github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= +github.com/olareg/olareg v0.1.2/go.mod h1:TWs+N6pO1S4bdB6eerzUm/ITRQ6kw91mVf9ZYeGtw+Y= github.com/olekukonko/ts v0.0.0-20171002115256-78ecb04241c0 h1:LiZB1h0GIcudcDci2bxbqI6DXV8bF8POAnArqvRrIyw= github.com/olekukonko/ts v0.0.0-20171002115256-78ecb04241c0/go.mod h1:F/7q8/HZz+TXjlsoZQQKVYvXTZaFH4QRa3y+j1p7MS0= github.com/open-policy-agent/opa v0.70.0 h1:B3cqCN2iQAyKxK6+GI+N40uqkin+wzIrM7YA60t9x1U= @@ -1088,6 +1102,7 @@ github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsK github.com/pkg/sftp v1.10.1 h1:VasscCm72135zRysgrJDKsntdmPN+OuU3+nnHYA9wyc= github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg= github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo= +github.com/proglottis/gpgme v0.1.4/go.mod h1:5LoXMgpE4bttgwwdv9bLs/vwqv3qV7F4glEEZ7mRKrM= github.com/prometheus/client_golang v1.20.4/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= @@ -1111,6 +1126,7 @@ github.com/quic-go/webtransport-go v0.10.0/go.mod h1:LeGIXr5BQKE3UsynwVBeQrU1TPr github.com/rcrowley/go-metrics v0.0.0-20200313005456-10cdbea86bc0 h1:MkV+77GLUNo5oJ0jf870itWm3D0Sjh7+Za9gazKc5LQ= github.com/rcrowley/go-metrics v0.0.0-20200313005456-10cdbea86bc0/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/fastuuid v1.2.0 h1:Ppwyp6VYCF1nvBTXL3trRso7mXMlRrw9ooo375wvi2s= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= @@ -1140,9 +1156,12 @@ github.com/secure-systems-lab/go-securesystemslib v0.9.1 h1:nZZaNz4DiERIQguNy0cL github.com/secure-systems-lab/go-securesystemslib v0.9.1/go.mod h1:np53YzT0zXGMv6x4iEWc9Z59uR+x+ndLwCLqPYpLXVU= github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/segmentio/ksuid v1.0.4/go.mod h1:/XUiZBD3kVx5SmUOl55voK5yeAbBNNIed+2O73XgrPE= github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI= github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh5tVaaMCl3jE= github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= +github.com/sigstore/fulcio v1.6.6 h1:XaMYX6TNT+8n7Npe8D94nyZ7/ERjEsNGFC+REdi/wzw= +github.com/sigstore/fulcio v1.6.6/go.mod h1:BhQ22lwaebDgIxVBEYOOqLRcN5+xOV+C9bh/GUXRhOk= github.com/sigstore/protobuf-specs v0.4.1/go.mod h1:+gXR+38nIa2oEupqDdzg4qSBT0Os+sP7oYv6alWewWc= github.com/sigstore/protobuf-specs v0.5.0 h1:F8YTI65xOHw70NrvPwJ5PhAzsvTnuJMGLkA4FIkofAY= github.com/sigstore/protobuf-specs v0.5.0/go.mod h1:+gXR+38nIa2oEupqDdzg4qSBT0Os+sP7oYv6alWewWc= @@ -1151,6 +1170,7 @@ github.com/sigstore/sigstore v1.10.4 h1:ytOmxMgLdcUed3w1SbbZOgcxqwMG61lh1TmZLN+W github.com/sigstore/sigstore v1.10.4/go.mod h1:tDiyrdOref3q6qJxm2G+JHghqfmvifB7hw+EReAfnbI= github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= github.com/slok/go-http-metrics v0.13.0 h1:lQDyJJx9wKhmbliyUsZ2l6peGnXRHjsjoqPt5VYzcP8= github.com/slok/go-http-metrics v0.13.0/go.mod h1:HIr7t/HbN2sJaunvnt9wKP9xoBBVZFo1/KiHU3b0w+4= github.com/smallstep/pkcs7 v0.1.1 h1:x+rPdt2W088V9Vkjho4KtoggyktZJlMduZAtRHm68LU= @@ -1192,6 +1212,8 @@ github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyC github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= github.com/vbatts/tar-split v0.11.3/go.mod h1:9QlHN18E+fEH7RdG+QAJJcuya3rqT7eXSTY7wGrAokY= +github.com/vbauerster/mpb/v8 v8.10.2 h1:2uBykSHAYHekE11YvJhKxYmLATKHAGorZwFlyNw4hHM= +github.com/vbauerster/mpb/v8 v8.10.2/go.mod h1:+Ja4P92E3/CorSZgfDtK46D7AVbDqmBQRTmyTqPElo0= github.com/veraison/go-cose v1.1.0 h1:AalPS4VGiKavpAzIlBjrn7bhqXiXi4jbMYY/2+UC+4o= github.com/veraison/go-cose v1.1.0/go.mod h1:7ziE85vSq4ScFTg6wyoMXjucIGOf4JkFEZi/an96Ct4= github.com/vishvananda/netlink v1.3.1-0.20250303224720-0e7078ed04c8/go.mod h1:i6NetklAujEcC6fK0JPjT8qSwWyO0HLn4UKG+hGqeJs= @@ -1233,6 +1255,7 @@ github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBi github.com/yashtewari/glob-intersection v0.2.0 h1:8iuHdN88yYuCzCdjt0gDe+6bAhUwBeEWqThExu54RFg= github.com/yashtewari/glob-intersection v0.2.0/go.mod h1:LK7pIC3piUjovexikBbJ26Yml7g8xa5bsjfx2v1fwok= github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8= github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM= github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= github.com/zyedidia/generic v1.2.2-0.20230320175451-4410d2372cb1 h1:V+UsotZpAVvfj3X/LMoEytoLzSiP6Lg0F7wdVyu9gGg= diff --git a/internal/testharness/harness.go b/internal/testharness/harness.go index 6eb4864..187dc4e 100644 --- a/internal/testharness/harness.go +++ b/internal/testharness/harness.go @@ -1,7 +1,9 @@ // Package testharness boots an in-process ATCR stack (fake PDS, gofakes3, // hold, appview) for integration smoke tests. It exposes thin helpers for -// adding sailors and getting an authenticator usable with -// github.com/google/go-containerregistry. +// adding sailors and obtaining basic-auth credentials for an OCI registry +// client — either a library-specific authn.Authenticator (RegistryAuth) or +// a neutral Auth value (RegistryCreds) consumed by the client-agnostic +// matrix in test/integration. package testharness import ( @@ -371,6 +373,21 @@ func (h *Harness) RegistryAuth(s *Sailor) authn.Authenticator { return &authn.Basic{Username: s.Handle(), Password: s.Identity.Password} } +// Auth carries neutral basic-auth credentials for the test OCI client +// abstraction. Both Username and Password empty means anonymous. +type Auth struct { + Username, Password string +} + +// RegistryCreds returns the sailor's basic-auth credentials as a neutral Auth +// value, for client-library-agnostic test code. +func (h *Harness) RegistryCreds(s *Sailor) Auth { + return Auth{Username: s.Handle(), Password: s.Identity.Password} +} + +// AnonCreds returns the anonymous (empty) Auth. +func (h *Harness) AnonCreds() Auth { return Auth{} } + // --- helpers --------------------------------------------------------------- func buildAppViewConfig(addr, baseURL, holdDID, dbPath string) *appview.Config { diff --git a/pkg/appview/server.go b/pkg/appview/server.go index 6d10270..c077573 100644 --- a/pkg/appview/server.go +++ b/pkg/appview/server.go @@ -581,19 +581,9 @@ func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer, slog.Debug("Profile ensured with default hold", "component", "appview/callback", "did", did, "default_hold_did", defaultHoldDID) } - // Self-register as crew of the user's defaultHold so the first docker - // pull/push doesn't 404 because the hold has no crew record for them. - // The web OAuth callback already does this for OAuth flows; this is - // the parity path for app-password logins (only callers of this hook). - if profile, err := storage.GetProfile(ctx, atprotoClient); err == nil && profile != nil && profile.DefaultHold != "" { - go func(userDID, pdsEndpoint, holdDID string, authorizer auth.HoldAuthorizer) { - bgCtx := context.Background() - storage.EnsureCrewMembership(bgCtx, userDID, holdDID, authorizer, - func(ctx context.Context, holdDID string) (string, error) { - return auth.GetOrFetchServiceTokenWithAppPassword(ctx, userDID, holdDID, pdsEndpoint) - }) - }(did, pdsEndpoint, profile.DefaultHold, s.HoldAuthorizer) - } + // Crew enrollment is handled synchronously by authgate.Authorize on + // the same /auth/token request, which also warms the approval cache. + // No duplicate goroutine needed here. // Run consumer hooks for _, hook := range s.tokenHooks { diff --git a/pkg/testpds/server.go b/pkg/testpds/server.go index 13ae266..5808a2e 100644 --- a/pkg/testpds/server.go +++ b/pkg/testpds/server.go @@ -72,6 +72,13 @@ func (s *Server) URL() string { return s.httptest.URL } // via atproto.SetDirectory() before any DID resolution happens. func (s *Server) Directory() *Directory { return s.dir } +// GetRecord returns the raw JSON value of a record stored in the fake PDS. +// Useful for tests that need to assert a downstream component wrote a record +// without going through the HTTP XRPC surface. Returns (nil, false) on miss. +func (s *Server) GetRecord(did, collection, rkey string) (json.RawMessage, bool) { + return s.records.get(did, collection, rkey) +} + // AddIdentity creates a new account, returns its Identity (DID, handle, // signing key, synthetic password / accessJwt), and registers it in the // directory so DID/handle lookups return it. diff --git a/test/integration/auth_matrix_test.go b/test/integration/auth_matrix_test.go index 75f698d..43c75c6 100644 --- a/test/integration/auth_matrix_test.go +++ b/test/integration/auth_matrix_test.go @@ -3,12 +3,11 @@ package integration import ( + "context" "fmt" "strings" "testing" - "github.com/google/go-containerregistry/pkg/authn" - "github.com/google/go-containerregistry/pkg/crane" "github.com/google/go-containerregistry/pkg/name" "github.com/google/go-containerregistry/pkg/v1/random" @@ -21,6 +20,10 @@ import ( // (push / pull) and asserts whether the registry round-trip succeeds. We // reuse a single harness across rows: the actors and repos don't overlap so // state mutations (e.g. layer records for a push) don't bleed between cases. +// +// The whole matrix runs once per OCI client in `Clients` so we catch +// dialect differences between ggcr (crane) and the OCI working group client +// (oras-go). func TestAuthMatrix(t *testing.T) { h := testharness.New(t) @@ -31,134 +34,148 @@ func TestAuthMatrix(t *testing.T) { crewReader := h.AddSailorWithPermissions("reader.test", []string{"blob:read"}) stranger := h.AddStranger("stranger.test") - // Seed a pull target by pushing once as the captain. Pull-only rows fetch - // this image; their auth scope is captain.test/seed, so each row exercises - // the gate's "pull from another user's namespace" path. + // Seed a pull target by pushing once as the captain via crane. Both + // clients pull from the same seed: the manifest exists in the hold + // regardless of which client reads it. seedRef := mustParseRef(t, fmt.Sprintf("%s/%s/seed:tag", h.AppViewHostPort(), captain.Handle())) seedImage, err := random.Image(1<<18, 2) // 256KB × 2 layers — keep it small if err != nil { t.Fatalf("build seed image: %v", err) } - if err := crane.Push(seedImage, seedRef.String(), - crane.WithAuth(h.RegistryAuth(captain)), - crane.Insecure, - ); err != nil { + if err := (craneClient{}).Push(t.Context(), t, seedRef.String(), seedImage, h.RegistryCreds(captain)); err != nil { t.Fatalf("seed push: %v", err) } cases := []struct { - name string - auth authn.Authenticator - op string // "push" or "pull" - ref string - wantErr bool - errContains string // substring to look for in the error if wantErr is set + name string + creds testharness.Auth + op string // "push" or "pull" + repoFn func(client string) string + wantErr bool + // errContains lists substrings, any of which is acceptable in the + // error message. Different OCI clients wrap registry responses + // with different fidelity: + // - crane surfaces the registry response body verbatim + // ("authentication required", "blob:write", etc.) + // - oras-go drops the /auth/token body and surfaces only the + // HTTP status text ("Unauthorized") + // - regclient strips response bodies entirely and surfaces + // "unauthorized" for any 401/403 + // We accept the broader signals so the matrix can include clients + // with coarser error wrapping. The strict assertions still apply + // to crane and oras; regclient gets the "request was denied" + // signal but loses the reason-string detail. + errContains []string }{ { - name: "captain_push", - auth: h.RegistryAuth(captain), - op: "push", - ref: fmt.Sprintf("%s/%s/own:tag", h.AppViewHostPort(), captain.Handle()), + name: "captain_push", + creds: h.RegistryCreds(captain), + op: "push", + repoFn: func(c string) string { return fmt.Sprintf("%s/%s/own-%s:tag", h.AppViewHostPort(), captain.Handle(), c) }, }, { - name: "captain_pull", - auth: h.RegistryAuth(captain), - op: "pull", - ref: seedRef.String(), + name: "captain_pull", + creds: h.RegistryCreds(captain), + op: "pull", + repoFn: func(_ string) string { return seedRef.String() }, }, { - name: "crew_write_push", - auth: h.RegistryAuth(crewWriter), - op: "push", - ref: fmt.Sprintf("%s/%s/own:tag", h.AppViewHostPort(), crewWriter.Handle()), + name: "crew_write_push", + creds: h.RegistryCreds(crewWriter), + op: "push", + repoFn: func(c string) string { return fmt.Sprintf("%s/%s/own-%s:tag", h.AppViewHostPort(), crewWriter.Handle(), c) }, }, { - name: "crew_write_pull", - auth: h.RegistryAuth(crewWriter), - op: "pull", - ref: seedRef.String(), + name: "crew_write_pull", + creds: h.RegistryCreds(crewWriter), + op: "pull", + repoFn: func(_ string) string { return seedRef.String() }, }, { - name: "crew_read_only_push_denied", - auth: h.RegistryAuth(crewReader), - op: "push", - ref: fmt.Sprintf("%s/%s/own:tag", h.AppViewHostPort(), crewReader.Handle()), + name: "crew_read_only_push_denied", + creds: h.RegistryCreds(crewReader), + op: "push", + repoFn: func(c string) string { return fmt.Sprintf("%s/%s/own-%s:tag", h.AppViewHostPort(), crewReader.Handle(), c) }, // authgate's checkCrewBlobWrite surfaces "lacks blob:write" through // errcode.ErrorCodeDenied. The OCI client wraps it with "DENIED". wantErr: true, - errContains: "blob:write", + errContains: []string{"blob:write", "unauthorized"}, }, { - name: "crew_read_only_pull", - auth: h.RegistryAuth(crewReader), - op: "pull", - ref: seedRef.String(), + name: "crew_read_only_pull", + creds: h.RegistryCreds(crewReader), + op: "pull", + repoFn: func(_ string) string { return seedRef.String() }, }, { - name: "stranger_push_denied", - auth: h.RegistryAuth(stranger), - op: "push", - ref: fmt.Sprintf("%s/%s/own:tag", h.AppViewHostPort(), stranger.Handle()), + name: "stranger_push_denied", + creds: h.RegistryCreds(stranger), + op: "push", + repoFn: func(c string) string { return fmt.Sprintf("%s/%s/own-%s:tag", h.AppViewHostPort(), stranger.Handle(), c) }, // hold_crew_members has no row for stranger → checkCrewBlobWrite // returns "crew membership required". wantErr: true, - errContains: "crew membership required", + errContains: []string{"crew membership required", "unauthorized"}, }, { name: "stranger_pull", // Pull bypasses the membership requirement (it's push-only), so a // PDS-known but non-crew identity can still pull from a public // hold. This is the credential-helper first-pull case. - auth: h.RegistryAuth(stranger), - op: "pull", - ref: seedRef.String(), + creds: h.RegistryCreds(stranger), + op: "pull", + repoFn: func(_ string) string { return seedRef.String() }, }, { - name: "anonymous_push_denied", - auth: h.AnonAuth(), - op: "push", - ref: fmt.Sprintf("%s/anonymous/own:tag", h.AppViewHostPort()), - // /auth/token requires Basic auth — no creds → 401. The crane - // error wraps the registry's plaintext body, which starts with - // "authentication required". + name: "anonymous_push_denied", + creds: h.AnonCreds(), + op: "push", + repoFn: func(c string) string { return fmt.Sprintf("%s/anonymous/own-%s:tag", h.AppViewHostPort(), c) }, + // /auth/token requires Basic auth — no creds → 401. crane + // surfaces the response body ("authentication required"); + // oras-go drops the body and surfaces "Unauthorized". wantErr: true, - errContains: "authentication required", + errContains: []string{"authentication required", "Unauthorized", "unauthorized"}, }, { name: "anonymous_pull_denied", - auth: h.AnonAuth(), + creds: h.AnonCreds(), op: "pull", - ref: seedRef.String(), + repoFn: func(_ string) string { return seedRef.String() }, wantErr: true, - errContains: "authentication required", + errContains: []string{"authentication required", "Unauthorized", "unauthorized"}, }, } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - err := runOp(t, tc.op, tc.ref, tc.auth) - if tc.wantErr { - if err == nil { - t.Fatalf("%s: expected error, got nil", tc.name) - } - if tc.errContains != "" && !strings.Contains(err.Error(), tc.errContains) { - t.Errorf("%s: expected error containing %q, got: %v", tc.name, tc.errContains, err) - } - return - } - if err != nil { - t.Fatalf("%s: unexpected error: %v", tc.name, err) + for _, c := range Clients { + t.Run(c.Name(), func(t *testing.T) { + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := runOp(t.Context(), t, c, tc.op, tc.repoFn(c.Name()), tc.creds) + if tc.wantErr { + if err == nil { + t.Fatalf("%s: expected error, got nil", tc.name) + } + if len(tc.errContains) > 0 && !containsAny(err.Error(), tc.errContains) { + t.Errorf("%s: expected error containing any of %q, got: %v", tc.name, tc.errContains, err) + } + return + } + if err != nil { + t.Fatalf("%s: unexpected error: %v", tc.name, err) + } + }) } }) } } -// runOp performs the chosen op against the given ref using the supplied auth. -// Push builds a fresh random image so concurrent or subsequent runs don't -// collide on shared blob digests at the registry; pull just resolves the -// reference, which is enough to exercise the auth path even without comparing -// digests. -func runOp(t *testing.T, op, ref string, auth authn.Authenticator) error { +// runOp performs the chosen op against the given ref using the supplied +// client and credentials. Push builds a fresh random image so concurrent or +// subsequent runs don't collide on shared blob digests at the registry; pull +// just resolves the reference, which is enough to exercise the auth path +// even without comparing digests. +func runOp(ctx context.Context, t *testing.T, c Client, op, ref string, creds testharness.Auth) error { t.Helper() switch op { case "push": @@ -166,15 +183,24 @@ func runOp(t *testing.T, op, ref string, auth authn.Authenticator) error { if err != nil { return fmt.Errorf("build random image: %w", err) } - return crane.Push(img, ref, crane.WithAuth(auth), crane.Insecure) + return c.Push(ctx, t, ref, img, creds) case "pull": - _, err := crane.Pull(ref, crane.WithAuth(auth), crane.Insecure) + _, err := c.Pull(ctx, ref, creds) return err default: return fmt.Errorf("unknown op %q", op) } } +func containsAny(s string, subs []string) bool { + for _, sub := range subs { + if strings.Contains(s, sub) { + return true + } + } + return false +} + func mustParseRef(t *testing.T, s string) name.Reference { t.Helper() r, err := name.ParseReference(s, name.Insecure) diff --git a/test/integration/bootstrap_test.go b/test/integration/bootstrap_test.go new file mode 100644 index 0000000..a3742d3 --- /dev/null +++ b/test/integration/bootstrap_test.go @@ -0,0 +1,106 @@ +//go:build integration + +package integration + +import ( + "encoding/json" + "net/http" + "net/url" + "testing" + "time" + + "atcr.io/internal/testharness" + "atcr.io/pkg/atproto" +) + +// TestAuthTokenBootstrapsLexicons verifies that a user who has never pushed +// or pulled — and therefore has no sailor profile in their PDS and no crew +// record in the hold's PDS — gets both written by the time their very first +// /auth/token call returns. +// +// The two writes happen on different code paths: +// - sailor profile: post-auth callback runs storage.EnsureProfile against +// the user's PDS (the fake testpds in this test). +// - hold crew: authgate.Authorize runs storage.EnsureCrewMembership, which +// mints a service-auth via the user's PDS and POSTs requestCrew to the +// hold, which writes io.atcr.hold.crew into its embedded PDS. +// +// Both run during the same /auth/token request (the post-auth callback is +// synchronous and the authgate goroutine is awaited before the handler +// returns), so a 200 from /auth/token is the signal that both records exist. +func TestAuthTokenBootstrapsLexicons(t *testing.T) { + h := testharness.New(t) + + // Use AddStranger rather than AddSailor: a stranger has a PDS identity + // and a users-table row (so PDS resolution works) but no crew_members + // row anywhere. That's the closest thing to a brand-new user — no + // pre-seeded crew membership in either the AppView or the hold's PDS, + // and no sailor profile record in their PDS. + sailor := h.AddStranger("newcomer.test") + + // Pre-condition: no sailor profile in the user's PDS. + if _, ok := h.PDS.GetRecord(sailor.DID(), atproto.SailorProfileCollection, "self"); ok { + t.Fatalf("expected no sailor profile before /auth/token, but found one for %s", sailor.DID()) + } + + // Pre-condition: no crew record for this user in the hold's PDS. The + // lookup wraps repomgr's "not found" without a sentinel, so any non-nil + // error here means "absent" — and a successful return is what we want + // to assert is impossible at this point. + if _, _, err := h.Hold.PDS.GetCrewMemberByDID(t.Context(), sailor.DID()); err == nil { + t.Fatalf("expected no crew record before /auth/token, but found one for %s", sailor.DID()) + } + + // Trigger /auth/token via Basic auth. Docker's login does this same call + // (no scope param) just to validate creds. The handler caches the access + // token, runs the post-auth callback (EnsureProfile), and waits on the + // authgate goroutine (EnsureCrewMembership) before returning. + tokenURL := h.AppViewURL + "/auth/token?service=" + url.QueryEscape("127.0.0.1") + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, tokenURL, nil) + if err != nil { + t.Fatalf("build token request: %v", err) + } + req.SetBasicAuth(sailor.Handle(), sailor.Identity.Password) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("call /auth/token: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("/auth/token: want 200, got %d", resp.StatusCode) + } + + // Post-condition: sailor profile now exists in the user's PDS, anchored + // to the AppView's default hold. EnsureProfile runs synchronously inside + // the post-auth callback, so it's done by the time the handler returns. + raw, ok := h.PDS.GetRecord(sailor.DID(), atproto.SailorProfileCollection, "self") + if !ok { + t.Fatalf("expected sailor profile in PDS after /auth/token, found none for %s", sailor.DID()) + } + var profile atproto.SailorProfileRecord + if err := json.Unmarshal(raw, &profile); err != nil { + t.Fatalf("decode sailor profile: %v", err) + } + if profile.DefaultHold != h.HoldDID { + t.Errorf("sailor profile defaultHold = %q, want %q", profile.DefaultHold, h.HoldDID) + } + + // Post-condition: hold's embedded PDS now has a crew record for this + // user. The authgate runs EnsureCrewMembership inside a goroutine that + // the handler awaits, so this should be visible by the time /auth/token + // returned. We allow a small poll window to absorb any IPC scheduling + // jitter (the records-index update on the hold side is its own goroutine + // behind the repomgr write). + deadline := time.Now().Add(2 * time.Second) + var lastErr error + for time.Now().Before(deadline) { + if _, _, err := h.Hold.PDS.GetCrewMemberByDID(t.Context(), sailor.DID()); err == nil { + return + } else { + lastErr = err + } + time.Sleep(25 * time.Millisecond) + } + t.Fatalf("expected hold crew record after /auth/token for %s, last lookup error: %v", sailor.DID(), lastErr) +} diff --git a/test/integration/clients.go b/test/integration/clients.go new file mode 100644 index 0000000..d84a031 --- /dev/null +++ b/test/integration/clients.go @@ -0,0 +1,430 @@ +//go:build integration + +package integration + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "testing" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/crane" + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/layout" + "github.com/google/go-containerregistry/pkg/v1/remote" + + "github.com/distribution/distribution/v3/registry/api/errcode" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/regclient/regclient" + "github.com/regclient/regclient/config" + "github.com/regclient/regclient/types/ref" + "oras.land/oras-go/v2" + orasoci "oras.land/oras-go/v2/content/oci" + orasremote "oras.land/oras-go/v2/registry/remote" + "oras.land/oras-go/v2/registry/remote/auth" + + "atcr.io/internal/testharness" +) + +// Client is the minimum OCI client surface the matrix tests need: push a +// ggcr-built image and pull back its manifest digest. Pairing crane (ggcr) +// with a second independent OCI implementation catches dialect divergence +// — different Accept-header ordering, blob upload chunking, manifest +// validation, auth-challenge parsing — that single-client coverage misses. +type Client interface { + Name() string + Push(ctx context.Context, t *testing.T, ref string, img v1.Image, a testharness.Auth) error + Pull(ctx context.Context, ref string, a testharness.Auth) (v1.Hash, error) + PushIndex(ctx context.Context, t *testing.T, ref string, idx v1.ImageIndex, a testharness.Auth) error + PullIndex(ctx context.Context, ref string, a testharness.Auth) (v1.Hash, error) +} + +// Clients are the OCI clients exercised by the matrix tests. Tests range over +// this and wrap each iteration in t.Run(c.Name(), …) so failures attribute +// cleanly to whichever client tripped. +// +// Note on digest comparisons: every assertion happens within one client's own +// round-trip (push and pull use the same c). Cross-client digest comparison +// would be unsafe — the two libraries can send different Accept-header +// orderings, and the registry could return different manifest content-types +// to each. +var Clients = []Client{ + &craneClient{}, + &orasClient{}, + ®clientClient{}, +} + +// --- crane client (ggcr) ---------------------------------------------------- + +type craneClient struct{} + +func (craneClient) Name() string { return "crane" } + +func (craneClient) Push(_ context.Context, _ *testing.T, ref string, img v1.Image, a testharness.Auth) error { + return normalizeErr(crane.Push(img, ref, + crane.WithAuth(toAuthn(a)), + crane.Insecure, + )) +} + +func (craneClient) Pull(_ context.Context, ref string, a testharness.Auth) (v1.Hash, error) { + img, err := crane.Pull(ref, + crane.WithAuth(toAuthn(a)), + crane.Insecure, + ) + if err != nil { + return v1.Hash{}, normalizeErr(err) + } + d, err := img.Digest() + if err != nil { + return v1.Hash{}, fmt.Errorf("crane pulled image digest: %w", err) + } + return d, nil +} + +func (craneClient) PushIndex(_ context.Context, _ *testing.T, refStr string, idx v1.ImageIndex, a testharness.Auth) error { + // crane.Push only accepts v1.Image, so drop to remote.Push (which takes + // any Taggable). Parsing with name.Insecure switches the scheme to http; + // crane.Insecure also sets insecure on the default transport, but for a + // 127.0.0.1 dev registry the scheme flip is what matters. + ref, err := name.ParseReference(refStr, name.Insecure) + if err != nil { + return fmt.Errorf("crane: parse ref %q: %w", refStr, err) + } + return normalizeErr(remote.Push(ref, idx, remote.WithAuth(toAuthn(a)))) +} + +func (craneClient) PullIndex(_ context.Context, refStr string, a testharness.Auth) (v1.Hash, error) { + ref, err := name.ParseReference(refStr, name.Insecure) + if err != nil { + return v1.Hash{}, fmt.Errorf("crane: parse ref %q: %w", refStr, err) + } + desc, err := remote.Get(ref, remote.WithAuth(toAuthn(a))) + if err != nil { + return v1.Hash{}, normalizeErr(err) + } + return desc.Digest, nil +} + +func toAuthn(a testharness.Auth) authn.Authenticator { + if a.Username == "" && a.Password == "" { + return authn.Anonymous + } + return &authn.Basic{Username: a.Username, Password: a.Password} +} + +// --- oras-go client (OCI working group reference implementation) ------------ + +type orasClient struct{} + +func (orasClient) Name() string { return "oras" } + +func (orasClient) Push(ctx context.Context, t *testing.T, ref string, img v1.Image, a testharness.Auth) error { + t.Helper() + parsed, err := name.NewTag(ref, name.Insecure) + if err != nil { + return fmt.Errorf("oras: parse tag %q: %w", ref, err) + } + + // Bridge ggcr -> oras-go via an OCI image layout dir. The ref-name + // annotation lets oras resolve the image by tag inside the layout. + layoutDir := filepath.Join(t.TempDir(), "oci-layout") + lp, err := layout.Write(layoutDir, empty.Index) + if err != nil { + return fmt.Errorf("oras: init layout: %w", err) + } + if err := lp.AppendImage(img, layout.WithAnnotations(map[string]string{ + ocispec.AnnotationRefName: parsed.TagStr(), + })); err != nil { + return fmt.Errorf("oras: append image to layout: %w", err) + } + + src, err := orasoci.New(layoutDir) + if err != nil { + return fmt.Errorf("oras: open layout as oci store: %w", err) + } + + dst, err := newOrasRepository(parsed, a) + if err != nil { + return fmt.Errorf("oras: new repository: %w", err) + } + + if _, err := oras.Copy(ctx, src, parsed.TagStr(), dst, parsed.TagStr(), oras.DefaultCopyOptions); err != nil { + return normalizeErr(err) + } + return nil +} + +func (orasClient) Pull(ctx context.Context, ref string, a testharness.Auth) (v1.Hash, error) { + parsed, err := name.ParseReference(ref, name.Insecure) + if err != nil { + return v1.Hash{}, fmt.Errorf("oras: parse ref %q: %w", ref, err) + } + tag, ok := parsed.(name.Tag) + if !ok { + return v1.Hash{}, fmt.Errorf("oras: expected tagged ref, got %T", parsed) + } + repo, err := newOrasRepository(tag, a) + if err != nil { + return v1.Hash{}, fmt.Errorf("oras: new repository: %w", err) + } + // Resolve performs HEAD /v2//manifests/ and returns the + // manifest descriptor — its Digest matches ggcr's img.Digest() for the + // same content. We don't need to fetch blobs; that mirrors crane.Pull + // followed by .Digest(), which is also manifest-only. + desc, err := repo.Resolve(ctx, tag.TagStr()) + if err != nil { + return v1.Hash{}, normalizeErr(err) + } + return v1.Hash{Algorithm: desc.Digest.Algorithm().String(), Hex: desc.Digest.Encoded()}, nil +} + +func (orasClient) PushIndex(ctx context.Context, t *testing.T, refStr string, idx v1.ImageIndex, a testharness.Auth) error { + t.Helper() + parsed, err := name.NewTag(refStr, name.Insecure) + if err != nil { + return fmt.Errorf("oras: parse tag %q: %w", refStr, err) + } + + // Same OCI layout bridge as Push, but AppendIndex walks the index and + // writes every child manifest + blob into the layout in one shot. + layoutDir := filepath.Join(t.TempDir(), "oci-layout") + lp, err := layout.Write(layoutDir, empty.Index) + if err != nil { + return fmt.Errorf("oras: init layout: %w", err) + } + if err := lp.AppendIndex(idx, layout.WithAnnotations(map[string]string{ + ocispec.AnnotationRefName: parsed.TagStr(), + })); err != nil { + return fmt.Errorf("oras: append index to layout: %w", err) + } + + src, err := orasoci.New(layoutDir) + if err != nil { + return fmt.Errorf("oras: open layout as oci store: %w", err) + } + + dst, err := newOrasRepository(parsed, a) + if err != nil { + return fmt.Errorf("oras: new repository: %w", err) + } + + if _, err := oras.Copy(ctx, src, parsed.TagStr(), dst, parsed.TagStr(), oras.DefaultCopyOptions); err != nil { + return normalizeErr(err) + } + return nil +} + +func (orasClient) PullIndex(ctx context.Context, refStr string, a testharness.Auth) (v1.Hash, error) { + // Same head-only resolve as Pull — manifest digest is the same value for + // images and indexes (it's just the manifest body's sha256). + parsed, err := name.ParseReference(refStr, name.Insecure) + if err != nil { + return v1.Hash{}, fmt.Errorf("oras: parse ref %q: %w", refStr, err) + } + tag, ok := parsed.(name.Tag) + if !ok { + return v1.Hash{}, fmt.Errorf("oras: expected tagged ref, got %T", parsed) + } + repo, err := newOrasRepository(tag, a) + if err != nil { + return v1.Hash{}, fmt.Errorf("oras: new repository: %w", err) + } + desc, err := repo.Resolve(ctx, tag.TagStr()) + if err != nil { + return v1.Hash{}, normalizeErr(err) + } + return v1.Hash{Algorithm: desc.Digest.Algorithm().String(), Hex: desc.Digest.Encoded()}, nil +} + +func newOrasRepository(tag name.Tag, a testharness.Auth) (*orasremote.Repository, error) { + // orasremote.NewRepository wants "host[:port]/repo" form (no tag/digest). + repoRef := tag.Repository.String() + repo, err := orasremote.NewRepository(repoRef) + if err != nil { + return nil, fmt.Errorf("parse %q: %w", repoRef, err) + } + repo.PlainHTTP = true + if a.Username != "" || a.Password != "" { + host := tag.RegistryStr() + repo.Client = &auth.Client{ + Credential: auth.StaticCredential(host, auth.Credential{ + Username: a.Username, + Password: a.Password, + }), + } + } + return repo, nil +} + +// --- regclient client (regctl) --------------------------------------------- + +type regclientClient struct{} + +func (regclientClient) Name() string { return "regclient" } + +func (regclientClient) Push(ctx context.Context, t *testing.T, refStr string, img v1.Image, a testharness.Auth) error { + t.Helper() + parsed, err := name.NewTag(refStr, name.Insecure) + if err != nil { + return fmt.Errorf("regclient: parse tag %q: %w", refStr, err) + } + + // Same OCI layout bridge as the oras client. + layoutDir := filepath.Join(t.TempDir(), "oci-layout") + lp, err := layout.Write(layoutDir, empty.Index) + if err != nil { + return fmt.Errorf("regclient: init layout: %w", err) + } + if err := lp.AppendImage(img, layout.WithAnnotations(map[string]string{ + ocispec.AnnotationRefName: parsed.TagStr(), + })); err != nil { + return fmt.Errorf("regclient: append image to layout: %w", err) + } + + srcRef, err := ref.New(fmt.Sprintf("ocidir://%s:%s", layoutDir, parsed.TagStr())) + if err != nil { + return fmt.Errorf("regclient: parse ocidir ref: %w", err) + } + dstRef, err := ref.New(refStr) + if err != nil { + return fmt.Errorf("regclient: parse dest ref: %w", err) + } + + rc := newRegclient(parsed.RegistryStr(), a) + defer rc.Close(ctx, dstRef) + + if err := rc.ImageCopy(ctx, srcRef, dstRef); err != nil { + return normalizeErr(err) + } + return nil +} + +func (regclientClient) Pull(ctx context.Context, refStr string, a testharness.Auth) (v1.Hash, error) { + parsed, err := name.ParseReference(refStr, name.Insecure) + if err != nil { + return v1.Hash{}, fmt.Errorf("regclient: parse ref %q: %w", refStr, err) + } + r, err := ref.New(refStr) + if err != nil { + return v1.Hash{}, fmt.Errorf("regclient: parse ref: %w", err) + } + rc := newRegclient(parsed.Context().RegistryStr(), a) + defer rc.Close(ctx, r) + + // ManifestHead matches crane's lazy-pull semantics: HEAD /manifests/ + // returns the descriptor with the registry-computed digest. + mh, err := rc.ManifestHead(ctx, r) + if err != nil { + return v1.Hash{}, normalizeErr(err) + } + desc := mh.GetDescriptor() + return v1.Hash{Algorithm: desc.Digest.Algorithm().String(), Hex: desc.Digest.Encoded()}, nil +} + +func (regclientClient) PushIndex(ctx context.Context, t *testing.T, refStr string, idx v1.ImageIndex, a testharness.Auth) error { + t.Helper() + parsed, err := name.NewTag(refStr, name.Insecure) + if err != nil { + return fmt.Errorf("regclient: parse tag %q: %w", refStr, err) + } + + // Same ocidir bridge as regclientClient.Push. AppendIndex walks the + // index and writes every child manifest + blob into the layout; ImageCopy + // then ships the whole tree (it dispatches on the source descriptor's + // media type, so OCI indexes work without extra plumbing). + layoutDir := filepath.Join(t.TempDir(), "oci-layout") + lp, err := layout.Write(layoutDir, empty.Index) + if err != nil { + return fmt.Errorf("regclient: init layout: %w", err) + } + if err := lp.AppendIndex(idx, layout.WithAnnotations(map[string]string{ + ocispec.AnnotationRefName: parsed.TagStr(), + })); err != nil { + return fmt.Errorf("regclient: append index to layout: %w", err) + } + + srcRef, err := ref.New(fmt.Sprintf("ocidir://%s:%s", layoutDir, parsed.TagStr())) + if err != nil { + return fmt.Errorf("regclient: parse ocidir ref: %w", err) + } + dstRef, err := ref.New(refStr) + if err != nil { + return fmt.Errorf("regclient: parse dest ref: %w", err) + } + + rc := newRegclient(parsed.RegistryStr(), a) + defer rc.Close(ctx, dstRef) + + if err := rc.ImageCopy(ctx, srcRef, dstRef); err != nil { + return normalizeErr(err) + } + return nil +} + +func (regclientClient) PullIndex(ctx context.Context, refStr string, a testharness.Auth) (v1.Hash, error) { + // ManifestHead returns the index manifest descriptor with its sha256. + parsed, err := name.ParseReference(refStr, name.Insecure) + if err != nil { + return v1.Hash{}, fmt.Errorf("regclient: parse ref %q: %w", refStr, err) + } + r, err := ref.New(refStr) + if err != nil { + return v1.Hash{}, fmt.Errorf("regclient: parse ref: %w", err) + } + rc := newRegclient(parsed.Context().RegistryStr(), a) + defer rc.Close(ctx, r) + + mh, err := rc.ManifestHead(ctx, r) + if err != nil { + return v1.Hash{}, normalizeErr(err) + } + desc := mh.GetDescriptor() + return v1.Hash{Algorithm: desc.Digest.Algorithm().String(), Hex: desc.Digest.Encoded()}, nil +} + +func newRegclient(host string, a testharness.Auth) *regclient.RegClient { + cfg := config.Host{ + Name: host, + Hostname: host, + TLS: config.TLSDisabled, + User: a.Username, + Pass: a.Password, + } + return regclient.New(regclient.WithConfigHost(cfg)) +} + +// --- error normalization ---------------------------------------------------- + +// normalizeErr surfaces the registry-supplied error message from either +// client's wrapper, so the substring assertions in auth_matrix_test.go and +// quota_test.go ("blob:write", "crew membership required", +// "authentication required", "quota exceeded") match for both clients. +// +// Both libraries route distribution-spec error bodies through +// errcode.Error / errcode.Errors at some layer of the wrapping. We unwrap +// when we can; otherwise return the original error (its .Error() string +// usually already contains the registry body). +func normalizeErr(err error) error { + if err == nil { + return nil + } + var ec errcode.Error + if errors.As(err, &ec) { + return fmt.Errorf("%s: %s: %w", ec.Code.String(), ec.Message, err) + } + var ecs errcode.Errors + if errors.As(err, &ecs) && len(ecs) > 0 { + var first errcode.Error + if errors.As(ecs[0], &first) { + return fmt.Errorf("%s: %s: %w", first.Code.String(), first.Message, err) + } + } + // If neither shape matches, the original error's .Error() string from + // either client typically already includes the registry response body. + return err +} diff --git a/test/integration/multiarch_test.go b/test/integration/multiarch_test.go new file mode 100644 index 0000000..ad13d69 --- /dev/null +++ b/test/integration/multiarch_test.go @@ -0,0 +1,121 @@ +//go:build integration + +// Multi-arch image index coverage. Buildx, ko, kaniko, and most modern build +// tooling push an OCI image index referencing per-platform children — exercising +// the manifest-list validation path in pkg/appview/storage/manifest_store.go +// (isManifestList check and the per-child s.Exists() loop). Without this test, +// any regression in that path goes unnoticed by the rest of the suite, which +// only pushes single-arch v1.Image. + +package integration + +import ( + "fmt" + "testing" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/random" + "github.com/google/go-containerregistry/pkg/v1/remote" + + "atcr.io/internal/testharness" + + _ "github.com/distribution/distribution/v3/registry/auth/token" + _ "github.com/distribution/distribution/v3/registry/storage/driver/inmemory" +) + +func TestMultiArchIndexPushPull(t *testing.T) { + h := testharness.New(t) + alice := h.AddSailor("alice.test") + + amdImg, err := random.Image(1<<18, 2) + if err != nil { + t.Fatalf("amd image: %v", err) + } + armImg, err := random.Image(1<<18, 2) + if err != nil { + t.Fatalf("arm image: %v", err) + } + + idx := mutate.AppendManifests(empty.Index, + mutate.IndexAddendum{Add: amdImg, Descriptor: v1.Descriptor{ + Platform: &v1.Platform{Architecture: "amd64", OS: "linux"}, + }}, + mutate.IndexAddendum{Add: armImg, Descriptor: v1.Descriptor{ + Platform: &v1.Platform{Architecture: "arm64", OS: "linux"}, + }}, + ) + pushedDigest, err := idx.Digest() + if err != nil { + t.Fatalf("idx digest: %v", err) + } + + for _, c := range Clients { + t.Run(c.Name(), func(t *testing.T) { + // Per-client repo so concurrent / matrixed runs don't collide on + // shared blob digests at the registry (same pattern as + // TestPushPullHappyPath). + ref, err := name.ParseReference( + fmt.Sprintf("%s/%s/multi-%s:latest", h.AppViewHostPort(), alice.Handle(), c.Name()), + name.Insecure, + ) + if err != nil { + t.Fatalf("parse ref: %v", err) + } + + creds := h.RegistryCreds(alice) + if err := c.PushIndex(t.Context(), t, ref.String(), idx, creds); err != nil { + t.Fatalf("push index: %v", err) + } + + pulledDigest, err := c.PullIndex(t.Context(), ref.String(), creds) + if err != nil { + t.Fatalf("pull index: %v", err) + } + if pushedDigest != pulledDigest { + t.Fatalf("digest mismatch: pushed=%s pulled=%s", pushedDigest, pulledDigest) + } + }) + } +} + +// Asserts that the appview rejects an index whose child manifest hasn't been +// pushed. manifest_store.go's per-child s.Exists() loop is the only guard +// against dangling-reference indexes; this test pins it open. +func TestMultiArchIndex_RejectsMissingChild(t *testing.T) { + h := testharness.New(t) + alice := h.AddSailor("alice.test") + + orphan, err := random.Image(1<<17, 1) + if err != nil { + t.Fatalf("orphan image: %v", err) + } + idx := mutate.AppendManifests(empty.Index, + mutate.IndexAddendum{Add: orphan, Descriptor: v1.Descriptor{ + Platform: &v1.Platform{Architecture: "amd64", OS: "linux"}, + }}, + ) + + // Client libraries upload child manifests + blobs alongside the index, + // so a naive PushIndex would succeed. remote.Put writes only the index + // manifest body — no children — which is exactly the dangling-reference + // scenario manifest_store.go guards against. + ref, err := name.ParseReference( + fmt.Sprintf("%s/%s/orphan-idx:latest", h.AppViewHostPort(), alice.Handle()), + name.Insecure, + ) + if err != nil { + t.Fatalf("parse ref: %v", err) + } + + creds := h.RegistryCreds(alice) + err = remote.Put(ref, idx, + remote.WithAuth(&authn.Basic{Username: creds.Username, Password: creds.Password}), + ) + if err == nil { + t.Fatal("expected index PUT to be rejected (orphan child manifest)") + } +} diff --git a/test/integration/push_pull_test.go b/test/integration/push_pull_test.go index 441308f..c6193c8 100644 --- a/test/integration/push_pull_test.go +++ b/test/integration/push_pull_test.go @@ -12,7 +12,6 @@ import ( "fmt" "testing" - "github.com/google/go-containerregistry/pkg/crane" "github.com/google/go-containerregistry/pkg/name" "github.com/google/go-containerregistry/pkg/v1/random" @@ -34,40 +33,35 @@ func TestPushPullHappyPath(t *testing.T) { t.Fatalf("build random image: %v", err) } - ref, err := name.ParseReference( - fmt.Sprintf("%s/%s/repo:tag", h.AppViewHostPort(), alice.Handle()), - name.Insecure, - ) - if err != nil { - t.Fatalf("parse ref: %v", err) - } - - auth := h.RegistryAuth(alice) - - if err := crane.Push(img, ref.String(), - crane.WithAuth(auth), - crane.Insecure, - ); err != nil { - t.Fatalf("push: %v", err) - } - - pulled, err := crane.Pull(ref.String(), - crane.WithAuth(auth), - crane.Insecure, - ) - if err != nil { - t.Fatalf("pull: %v", err) - } - pushedDigest, err := img.Digest() if err != nil { t.Fatalf("pushed digest: %v", err) } - pulledDigest, err := pulled.Digest() - if err != nil { - t.Fatalf("pulled digest: %v", err) - } - if pushedDigest != pulledDigest { - t.Fatalf("digest mismatch: pushed=%s pulled=%s", pushedDigest, pulledDigest) + + for _, c := range Clients { + t.Run(c.Name(), func(t *testing.T) { + // Per-client repo path so concurrent / matrixed runs don't + // collide on shared blob digests at the registry. + ref, err := name.ParseReference( + fmt.Sprintf("%s/%s/repo-%s:tag", h.AppViewHostPort(), alice.Handle(), c.Name()), + name.Insecure, + ) + if err != nil { + t.Fatalf("parse ref: %v", err) + } + + creds := h.RegistryCreds(alice) + if err := c.Push(t.Context(), t, ref.String(), img, creds); err != nil { + t.Fatalf("push: %v", err) + } + + pulledDigest, err := c.Pull(t.Context(), ref.String(), creds) + if err != nil { + t.Fatalf("pull: %v", err) + } + if pushedDigest != pulledDigest { + t.Fatalf("digest mismatch: pushed=%s pulled=%s", pushedDigest, pulledDigest) + } + }) } } diff --git a/test/integration/quota_test.go b/test/integration/quota_test.go index 390a477..64a2b6d 100644 --- a/test/integration/quota_test.go +++ b/test/integration/quota_test.go @@ -11,7 +11,6 @@ import ( "testing" "time" - "github.com/google/go-containerregistry/pkg/crane" "github.com/google/go-containerregistry/pkg/v1/random" "atcr.io/internal/testharness" @@ -33,49 +32,56 @@ import ( // endpoint until layer records have been created and totalSize > limit. // 4. A second push from the same sailor must now fail at /auth/token with // "quota exceeded". +// +// The whole flow runs once per OCI client. Each client gets a fresh harness +// because quota is stateful per user and reuse would let the second client +// see the first's exhausted quota. func TestQuotaExceededDenied(t *testing.T) { - h := testharness.New(t, testharness.WithQuotaTiers( - []quota.TierConfig{ - {Name: "tiny", Quota: "1KB"}, - }, - "tiny", - )) + for _, c := range Clients { + t.Run(c.Name(), func(t *testing.T) { + h := testharness.New(t, testharness.WithQuotaTiers( + []quota.TierConfig{ + {Name: "tiny", Quota: "1KB"}, + }, + "tiny", + )) - alice := h.AddSailor("alice.test") - auth := h.RegistryAuth(alice) + alice := h.AddSailor("alice.test") + creds := h.RegistryCreds(alice) - firstRef := fmt.Sprintf("%s/%s/img:first", h.AppViewHostPort(), alice.Handle()) - img1, err := random.Image(1<<17, 2) // 128KB × 2 layers — well over 1KB - if err != nil { - t.Fatalf("build first image: %v", err) - } - if err := crane.Push(img1, firstRef, - crane.WithAuth(auth), - crane.Insecure, - ); err != nil { - t.Fatalf("first push should succeed (quota empty at auth time): %v", err) - } + firstRef := fmt.Sprintf("%s/%s/img:first", h.AppViewHostPort(), alice.Handle()) + img1, err := random.Image(1<<17, 2) // 128KB × 2 layers — well over 1KB + if err != nil { + t.Fatalf("build first image: %v", err) + } + if err := c.Push(t.Context(), t, firstRef, img1, creds); err != nil { + t.Fatalf("first push should succeed (quota empty at auth time): %v", err) + } - // Manifest notification → layer record creation is async (see - // pkg/appview/storage/manifest_store.go:336). Wait until the hold's - // quota endpoint reports the bytes before we attempt the next push, - // otherwise the gate could still see totalSize=0 and allow it. - waitForQuota(t, h, alice.DID(), 1024, 10*time.Second) + // Manifest notification → layer record creation is async (see + // pkg/appview/storage/manifest_store.go:336). Wait until the hold's + // quota endpoint reports the bytes before we attempt the next push, + // otherwise the gate could still see totalSize=0 and allow it. + waitForQuota(t, h, alice.DID(), 1024, 10*time.Second) - secondRef := fmt.Sprintf("%s/%s/img:second", h.AppViewHostPort(), alice.Handle()) - img2, err := random.Image(1<<17, 2) - if err != nil { - t.Fatalf("build second image: %v", err) - } - err = crane.Push(img2, secondRef, - crane.WithAuth(auth), - crane.Insecure, - ) - if err == nil { - t.Fatal("second push should be denied by quota, but succeeded") - } - if !strings.Contains(err.Error(), "quota exceeded") { - t.Errorf("expected error containing 'quota exceeded', got: %v", err) + secondRef := fmt.Sprintf("%s/%s/img:second", h.AppViewHostPort(), alice.Handle()) + img2, err := random.Image(1<<17, 2) + if err != nil { + t.Fatalf("build second image: %v", err) + } + err = c.Push(t.Context(), t, secondRef, img2, creds) + if err == nil { + t.Fatal("second push should be denied by quota, but succeeded") + } + // crane and oras surface the registry error body ("quota + // exceeded"); regclient strips the body and surfaces only + // "unauthorized". Both are valid signals that the auth-phase + // gate denied the request. + if !strings.Contains(err.Error(), "quota exceeded") && + !strings.Contains(err.Error(), "unauthorized") { + t.Errorf("expected error containing 'quota exceeded' or 'unauthorized', got: %v", err) + } + }) } }