From 54589d3714b65d2ba2ed87c3330721f66fbb3501 Mon Sep 17 00:00:00 2001 From: Jan Guth Date: Thu, 16 Apr 2026 11:11:29 +0200 Subject: [PATCH 1/7] feat(provider): add daemonless oci:// + :: for custom binary location MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new oci:// provider that pulls and extracts binaries from any OCI registry (Docker Hub, ghcr.io, quay.io, private) without requiring a container runtime. Uses go-containerregistry for native auth (reads ~/.docker/config.json) and multi-arch manifest resolution. Also adds :: syntax to both docker:// and oci:// refs to pin an explicit in-container binary path, so consumers no longer depend on the provider guessing the right search path: docker://docker@cli::/usr/local/bin/docker oci://ghcr.io/org/img@v1::/bin/tool Binary size impact (stripped): +1.03 MB (~10.7 MB → ~11.8 MB). - pkg/provider/oci.go — daemonless pull + layer tar extract - pkg/provider/docker.go — supports :: override - pkg/provider/provider.go — ParseImageRef, ParseRef/BinaryName aware of :: - pkg/binary/download.go — dispatches OCI case - pkg/cli/install.go — parseBinaryArg and parseSCPArg skip :: Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 4 + go.mod | 24 +++-- go.sum | 64 ++++++++---- pkg/binary/download.go | 7 ++ pkg/cli/cli_extra_test.go | 4 + pkg/cli/install.go | 29 +++++- pkg/cli/install_test.go | 9 ++ pkg/provider/docker.go | 27 +++-- pkg/provider/oci.go | 190 ++++++++++++++++++++++++++++++++++ pkg/provider/oci_test.go | 69 ++++++++++++ pkg/provider/provider.go | 52 +++++++++- pkg/provider/provider_test.go | 8 ++ 12 files changed, 448 insertions(+), 39 deletions(-) create mode 100644 pkg/provider/oci.go create mode 100644 pkg/provider/oci_test.go diff --git a/README.md b/README.md index 711fd90..af1bfae 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,10 @@ b install gitlab.com/org/tool b install codeberg.org/user/app@v1.0 b install go://golang.org/x/tools/cmd/goimports b install docker://alpine/helm +b install docker://docker@cli # tag via @, e.g. docker:cli image +b install docker://docker@cli::/usr/local/bin/docker # explicit in-container path +b install oci://ghcr.io/org/img@v1 # daemonless, any OCI registry +b install oci://docker@cli::/usr/local/bin/docker # daemonless docker CLI b install "git:///home/user/myrepo:.scripts/tool" b install "git://github.com/org/repo:bin/app@v1.0" diff --git a/go.mod b/go.mod index ccb2fc8..1b9ba95 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,12 @@ module github.com/fentas/b go 1.26 require ( - github.com/fatih/color v1.10.0 + github.com/fatih/color v1.15.0 github.com/fentas/goodies v0.0.0-20250628100539-67031d6c92c6 + github.com/google/go-containerregistry v0.21.5 github.com/jedib0t/go-pretty/v6 v6.5.6 github.com/jmespath-community/go-jmespath v1.1.1 - github.com/spf13/cobra v1.8.0 + github.com/spf13/cobra v1.10.2 github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 github.com/ulikunitz/xz v0.5.12 @@ -17,19 +18,30 @@ require ( require ( github.com/MakeNowJust/heredoc v1.0.0 // indirect + github.com/containerd/stargz-snapshotter/estargz v0.18.2 // indirect + github.com/docker/cli v29.4.0+incompatible // indirect + github.com/docker/docker-credential-helpers v0.9.3 // indirect github.com/fatih/structs v1.1.0 // indirect github.com/goccy/go-yaml v1.11.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/mattn/go-colorable v0.1.8 // indirect - github.com/mattn/go-isatty v0.0.12 // indirect + github.com/klauspost/compress v1.18.5 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/rivo/uniseg v0.2.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect - github.com/spf13/pflag v1.0.5 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/spf13/pflag v1.0.10 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect + github.com/vbatts/tar-split v0.12.2 // indirect golang.org/x/exp v0.0.0-20230314191032-db074128a8ec // indirect - golang.org/x/sys v0.16.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect + gotest.tools/v3 v3.5.2 // indirect k8s.io/utils v0.0.0-20240310230437-4693a0247e57 // indirect ) diff --git a/go.sum b/go.sum index 674652d..796cac7 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,16 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= -github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/containerd/stargz-snapshotter/estargz v0.18.2 h1:yXkZFYIzz3eoLwlTUZKz2iQ4MrckBxJjkmD16ynUTrw= +github.com/containerd/stargz-snapshotter/estargz v0.18.2/go.mod h1:XyVU5tcJ3PRpkA9XS2T5us6Eg35yM0214Y+wvrZTBrY= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/fatih/color v1.10.0 h1:s36xzo75JdqLaaWoiEHk767eHiwo0598uUxyfiPkDsg= -github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= +github.com/docker/cli v29.4.0+incompatible h1:+IjXULMetlvWJiuSI0Nbor36lcJ5BTcVpUmB21KBoVM= +github.com/docker/cli v29.4.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= +github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= +github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= +github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/fentas/goodies v0.0.0-20250628100539-67031d6c92c6 h1:fEKwzohcEvjnilKbOqvizP44S22kWNWSxhroaB4uT/4= @@ -17,34 +23,48 @@ github.com/go-playground/validator/v10 v10.4.1 h1:pH2c5ADXtd66mxoE0Zm9SUhxE20r7a github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= github.com/goccy/go-yaml v1.11.3 h1:B3W9IdWbvrUu2OYQGwvU1nZtvMQJPBKgBUuweJjLj6I= github.com/goccy/go-yaml v1.11.3/go.mod h1:wKnAMd44+9JAAnGQpWVEgBzGt3YuTaQ4uXoHvE4m7WU= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-containerregistry v0.21.5 h1:KTJG9Pn/jC0VdZR6ctV3/jcN+q6/Iqlx0sTVz3ywZlM= +github.com/google/go-containerregistry v0.21.5/go.mod h1:ySvMuiWg+dOsRW0Hw8GYwfMwBlNRTmpYBFJPlkco5zU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jedib0t/go-pretty/v6 v6.5.6 h1:nKXVLqPfAwY7sWcYXdNZZZ2fjqDpAtj9UeWupgfUxSg= github.com/jedib0t/go-pretty/v6 v6.5.6/go.mod h1:5LQIxa52oJ/DlDSLv0HEkWOFMDGoWkJb9ss5KqPpJBg= github.com/jmespath-community/go-jmespath v1.1.1 h1:bFikPhsi/FdmlZhVgSCd2jj1e7G/rw+zyQfyg5UF+L4= github.com/jmespath-community/go-jmespath v1.1.1/go.mod h1:4gOyFJsR/Gk+05RgTKYrifT7tBPWD8Lubtb5jRrfy9I= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= -github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8= -github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= -github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +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= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= -github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -56,14 +76,18 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc= github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +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= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/exp v0.0.0-20230314191032-db074128a8ec h1:pAv+d8BM2JNnNctsLJ6nnZ6NqXT8N4+eauvZSb3P0I0= golang.org/x/exp v0.0.0-20230314191032-db074128a8ec/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= -golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= -golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE= golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= @@ -72,5 +96,7 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= k8s.io/utils v0.0.0-20240310230437-4693a0247e57 h1:gbqbevonBh57eILzModw6mrkbwM0gQBEuevE/AaBsHY= k8s.io/utils v0.0.0-20240310230437-4693a0247e57/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= diff --git a/pkg/binary/download.go b/pkg/binary/download.go index 21e2c6e..f00d7cd 100644 --- a/pkg/binary/download.go +++ b/pkg/binary/download.go @@ -172,6 +172,13 @@ func (b *Binary) downloadViaProvider() error { } b.File = path return nil + case *provider.OCI: + path, err := pt.Install(b.ProviderRef, b.Version, destDir) + if err != nil { + return err + } + b.File = path + return nil case *provider.Git: path, err := pt.Install(b.ProviderRef, b.Version, destDir) if err != nil { diff --git a/pkg/cli/cli_extra_test.go b/pkg/cli/cli_extra_test.go index 3efaadc..b1dbf0f 100644 --- a/pkg/cli/cli_extra_test.go +++ b/pkg/cli/cli_extra_test.go @@ -575,6 +575,10 @@ func TestParseBinaryArg(t *testing.T) { {"jq@jq-1.7", "jq", "jq-1.7"}, {"kubectl@v1.28.0", "kubectl", "v1.28.0"}, {"terraform", "terraform", ""}, + // docker://oci:// "::" must not swallow @tag into version. + {"docker://docker@cli::/usr/local/bin/docker", "docker://docker::/usr/local/bin/docker", "cli"}, + {"oci://ghcr.io/org/img@v1::/bin/tool", "oci://ghcr.io/org/img::/bin/tool", "v1"}, + {"oci://alpine::/bin/busybox", "oci://alpine::/bin/busybox", ""}, } for _, tt := range tests { diff --git a/pkg/cli/install.go b/pkg/cli/install.go index 81527c8..a985c29 100644 --- a/pkg/cli/install.go +++ b/pkg/cli/install.go @@ -374,8 +374,22 @@ func (o *InstallOptions) updateLock(binaries []*binary.Binary) error { return lock.WriteLock(lockDir, lk, o.bVersion) } -// parseBinaryArg parses binary argument in format "name" or "name@version" +// parseBinaryArg parses binary argument in format "name" or "name@version". +// For docker://oci:// refs with "::" suffix, the path is preserved on +// name and not misinterpreted as part of @version. func parseBinaryArg(arg string) (name, version string) { + // docker://oci:// with "::" — isolate the image portion for @ scan. + if strings.HasPrefix(arg, "docker://") || strings.HasPrefix(arg, "oci://") { + imgPart, pathPart := arg, "" + if i := strings.Index(arg, "::"); i >= 0 { + imgPart = arg[:i] + pathPart = arg[i:] + } + if i := strings.LastIndex(imgPart, "@"); i > 0 { + return imgPart[:i] + pathPart, imgPart[i+1:] + } + return arg, "" + } parts := strings.SplitN(arg, "@", 2) name = parts[0] if len(parts) > 1 { @@ -391,14 +405,23 @@ func parseBinaryArg(arg string) (name, version string) { // Returns the parsed envInstall, how many additional args were consumed, and whether it matched. func parseSCPArg(arg string, remaining []string) (envInstall, int, bool) { // Look for colon that signals SCP syntax — must come after a ref (contains /) - // and before a glob. Skip protocol prefixes (go://, docker://). + // and before a glob. Skip protocol prefixes (go://, docker://, oci://) and + // the "::" separator used by docker://oci:// for explicit binary paths. colonIdx := -1 for i := range arg { if arg[i] == ':' { - // Skip protocol prefixes (e.g. go://, docker://) + // Skip protocol prefixes (e.g. go://, docker://, oci://) if i+2 < len(arg) && arg[i+1] == '/' && arg[i+2] == '/' { continue } + // Skip docker://oci:// "::" binary-path separator + if i+1 < len(arg) && arg[i+1] == ':' { + continue + } + // Also skip the colon that is the second ':' of '::' + if i > 0 && arg[i-1] == ':' { + continue + } // Must be preceded by something that looks like a ref (contains /) prefix := arg[:i] if strings.Contains(prefix, "/") || strings.Contains(prefix, ".") { diff --git a/pkg/cli/install_test.go b/pkg/cli/install_test.go index 5bc213f..6a71370 100644 --- a/pkg/cli/install_test.go +++ b/pkg/cli/install_test.go @@ -66,6 +66,15 @@ func TestParseSCPArg(t *testing.T) { arg: "kubectl", wantOk: false, }, + { + // docker://oci:// "::" — not SCP (binary-path separator) + arg: "docker://docker@cli::/usr/local/bin/docker", + wantOk: false, + }, + { + arg: "oci://ghcr.io/org/img@v1::/bin/tool", + wantOk: false, + }, } for _, tt := range tests { diff --git a/pkg/provider/docker.go b/pkg/provider/docker.go index 76f9f27..4b5a555 100644 --- a/pkg/provider/docker.go +++ b/pkg/provider/docker.go @@ -32,14 +32,20 @@ func (d *Docker) FetchRelease(ref, version string) (*Release, error) { // Install pulls the image, creates a container, copies the binary out, and cleans up. // searchPaths are the paths to search for the binary inside the container. +// If the ref includes "::", that path is used as the single search path. func (d *Docker) Install(ref, version, destDir string, searchPaths []string) (string, error) { runtime, err := detectContainerRuntime() if err != nil { return "", err } - image := dockerImage(ref) + rest := strings.TrimPrefix(ref, "docker://") + image, refTag, inContainerPath := ParseImageRef(rest) + tag := version + if tag == "" { + tag = refTag + } if tag == "" { tag = "latest" } @@ -61,8 +67,10 @@ func (d *Docker) Install(ref, version, destDir string, searchPaths []string) (st containerID := strings.TrimSpace(string(out)) defer exec.Command(runtime, "rm", containerID).Run() - // Try to copy binary from known paths - if searchPaths == nil { + // Determine search paths: explicit "::" overrides everything. + if inContainerPath != "" { + searchPaths = []string{inContainerPath} + } else if searchPaths == nil { searchPaths = []string{ "/usr/local/bin/" + name, "/usr/bin/" + name, @@ -90,15 +98,16 @@ func (d *Docker) Install(ref, version, destDir string, searchPaths []string) (st return "", fmt.Errorf("binary %q not found in image %s at paths: %v", name, imageRef, searchPaths) } +// dockerImage returns the image name (without tag/path) for legacy callers +// and tests. Prefer ParseImageRef for new code. func dockerImage(ref string) string { r := strings.TrimPrefix(ref, "docker://") - // Strip version (handled separately) - r, _ = ParseRef(r) - // Also strip docker-style tag after colon - if i := strings.LastIndex(r, ":"); i > 0 { - r = r[:i] + image, _, _ := ParseImageRef(r) + // Also strip docker-style "image:tag" when no explicit @ was given. + if i := strings.LastIndex(image, ":"); i > 0 { + image = image[:i] } - return r + return image } func detectContainerRuntime() (string, error) { diff --git a/pkg/provider/oci.go b/pkg/provider/oci.go new file mode 100644 index 0000000..33fe1a0 --- /dev/null +++ b/pkg/provider/oci.go @@ -0,0 +1,190 @@ +package provider + +import ( + "archive/tar" + "fmt" + "io" + "os" + "path" + "path/filepath" + "runtime" + "strings" + + "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/remote" + "github.com/google/go-containerregistry/pkg/v1/types" +) + +func init() { + Register(&OCI{}) +} + +// OCI extracts binaries from OCI images without a container runtime. +// Works with any OCI registry (Docker Hub, ghcr.io, quay.io, private). +// +// Syntax: +// +// oci://[@][::] +// +// Examples: +// +// oci://alpine +// oci://ghcr.io/helm/helm@v3.18.6 +// oci://docker@cli::/usr/local/bin/docker +type OCI struct{} + +func (o *OCI) Name() string { return "oci" } + +func (o *OCI) Match(ref string) bool { + return strings.HasPrefix(ref, "oci://") +} + +func (o *OCI) LatestVersion(ref string) (string, error) { + return "latest", nil +} + +// FetchRelease is not used for OCI — use Install instead. +func (o *OCI) FetchRelease(ref, version string) (*Release, error) { + return nil, fmt.Errorf("oci provider does not use FetchRelease; use Install()") +} + +// Install pulls the image manifest, selects a platform-matching layer, +// and extracts a single file without invoking any container runtime. +func (o *OCI) Install(ref, version, destDir string) (string, error) { + rest := strings.TrimPrefix(ref, "oci://") + image, refTag, inContainerPath := ParseImageRef(rest) + + tag := version + if tag == "" { + tag = refTag + } + if tag == "" { + tag = "latest" + } + binName := BinaryName(ref) + + nameRef, err := name.ParseReference(image + ":" + tag) + if err != nil { + return "", fmt.Errorf("parsing image ref %s:%s: %w", image, tag, err) + } + + opts := []remote.Option{ + remote.WithAuthFromKeychain(authn.DefaultKeychain), + remote.WithPlatform(v1.Platform{ + OS: runtime.GOOS, + Architecture: runtime.GOARCH, + }), + } + + desc, err := remote.Get(nameRef, opts...) + if err != nil { + return "", fmt.Errorf("fetching manifest for %s: %w", nameRef, err) + } + + img, err := resolveImage(desc, opts) + if err != nil { + return "", fmt.Errorf("resolving image %s: %w", nameRef, err) + } + + // Determine which paths to try inside the image. + searchPaths := []string{inContainerPath} + if inContainerPath == "" { + searchPaths = []string{ + "/usr/local/bin/" + binName, + "/usr/bin/" + binName, + "/bin/" + binName, + "/app/" + binName, + } + } + + if err := os.MkdirAll(destDir, 0755); err != nil { + return "", err + } + dest := filepath.Join(destDir, binName) + + layers, err := img.Layers() + if err != nil { + return "", fmt.Errorf("reading layers: %w", err) + } + // Walk layers newest-first so later overrides win. + for i := len(layers) - 1; i >= 0; i-- { + for _, sp := range searchPaths { + found, err := extractFromLayer(layers[i], sp, dest) + if err != nil { + return "", err + } + if found { + if err := os.Chmod(dest, 0755); err != nil { + return "", err + } + return dest, nil + } + } + } + + return "", fmt.Errorf("binary %q not found in image %s at paths: %v", binName, nameRef, searchPaths) +} + +// resolveImage returns an Image for a descriptor, selecting a platform-matching +// manifest when the descriptor is a manifest list/index. +func resolveImage(desc *remote.Descriptor, opts []remote.Option) (v1.Image, error) { + switch desc.MediaType { + case types.DockerManifestList, types.OCIImageIndex: + idx, err := desc.ImageIndex() + if err != nil { + return nil, err + } + manifest, err := idx.IndexManifest() + if err != nil { + return nil, err + } + want := v1.Platform{OS: runtime.GOOS, Architecture: runtime.GOARCH} + for _, m := range manifest.Manifests { + if m.Platform != nil && m.Platform.OS == want.OS && m.Platform.Architecture == want.Architecture { + return idx.Image(m.Digest) + } + } + return nil, fmt.Errorf("no manifest matches platform %s/%s", want.OS, want.Architecture) + default: + return desc.Image() + } +} + +// extractFromLayer scans a single layer's tar stream for filePath and writes it to dest. +// Returns true if the file was found and extracted. +func extractFromLayer(l v1.Layer, filePath, dest string) (bool, error) { + rc, err := l.Uncompressed() + if err != nil { + return false, err + } + defer rc.Close() + + target := strings.TrimPrefix(path.Clean(filePath), "/") + tr := tar.NewReader(rc) + for { + h, err := tr.Next() + if err == io.EOF { + return false, nil + } + if err != nil { + return false, err + } + if strings.TrimPrefix(path.Clean(h.Name), "/") != target { + continue + } + if h.Typeflag != tar.TypeReg { + return false, fmt.Errorf("%s is not a regular file (typeflag=%c)", filePath, h.Typeflag) + } + out, err := os.Create(dest) + if err != nil { + return false, err + } + if _, err := io.Copy(out, tr); err != nil { + out.Close() + return false, err + } + return true, out.Close() + } +} diff --git a/pkg/provider/oci_test.go b/pkg/provider/oci_test.go new file mode 100644 index 0000000..0d4c8a8 --- /dev/null +++ b/pkg/provider/oci_test.go @@ -0,0 +1,69 @@ +package provider + +import "testing" + +func TestOCIMatch(t *testing.T) { + o := &OCI{} + tests := []struct { + ref string + want bool + }{ + {"oci://alpine", true}, + {"oci://ghcr.io/org/img", true}, + {"oci://ghcr.io/org/img@v1::/bin/tool", true}, + {"docker://alpine", false}, + {"github.com/org/repo", false}, + } + for _, tt := range tests { + if got := o.Match(tt.ref); got != tt.want { + t.Errorf("OCI.Match(%q) = %v, want %v", tt.ref, got, tt.want) + } + } +} + +func TestOCIName(t *testing.T) { + o := &OCI{} + if o.Name() != "oci" { + t.Errorf("OCI.Name() = %q", o.Name()) + } +} + +func TestOCILatestVersion(t *testing.T) { + o := &OCI{} + v, err := o.LatestVersion("oci://alpine") + if err != nil { + t.Fatalf("LatestVersion() error = %v", err) + } + if v != "latest" { + t.Errorf("LatestVersion() = %q, want %q", v, "latest") + } +} + +func TestOCIFetchRelease(t *testing.T) { + o := &OCI{} + if _, err := o.FetchRelease("oci://alpine", "latest"); err == nil { + t.Error("expected error from OCI.FetchRelease") + } +} + +func TestParseImageRef(t *testing.T) { + tests := []struct { + in string + wantImage string + wantTag string + wantInContainer string + }{ + {"alpine", "alpine", "", ""}, + {"alpine@3.19", "alpine", "3.19", ""}, + {"docker@cli::/usr/local/bin/docker", "docker", "cli", "/usr/local/bin/docker"}, + {"ghcr.io/org/img@v1::/bin/tool", "ghcr.io/org/img", "v1", "/bin/tool"}, + {"alpine::/bin/busybox", "alpine", "", "/bin/busybox"}, + } + for _, tt := range tests { + img, tag, p := ParseImageRef(tt.in) + if img != tt.wantImage || tag != tt.wantTag || p != tt.wantInContainer { + t.Errorf("ParseImageRef(%q) = (%q, %q, %q), want (%q, %q, %q)", + tt.in, img, tag, p, tt.wantImage, tt.wantTag, tt.wantInContainer) + } + } +} diff --git a/pkg/provider/provider.go b/pkg/provider/provider.go index 8d8c873..2ff64ff 100644 --- a/pkg/provider/provider.go +++ b/pkg/provider/provider.go @@ -53,21 +53,59 @@ func Detect(ref string) (Provider, error) { // ParseRef splits a ref like "github.com/org/repo@v1.0" into // (github.com/org/repo, v1.0). Version may be empty. +// +// For docker:///oci:// refs, the optional "::" suffix +// is preserved on base (stripped only for the @ scan), and the tag ends +// up in version. E.g. "docker://docker@cli::/usr/local/bin/docker" → +// ("docker://docker::/usr/local/bin/docker", "cli"). func ParseRef(ref string) (base, version string) { + // For image refs, scan for @ on the image-only slice so paths are ignored. + if strings.HasPrefix(ref, "docker://") || strings.HasPrefix(ref, "oci://") { + imgPart, pathPart := ref, "" + if i := strings.Index(ref, "::"); i >= 0 { + imgPart = ref[:i] + pathPart = ref[i:] + } + if i := strings.LastIndex(imgPart, "@"); i > 0 { + return imgPart[:i] + pathPart, imgPart[i+1:] + } + return ref, "" + } if i := strings.LastIndex(ref, "@"); i > 0 { return ref[:i], ref[i+1:] } return ref, "" } +// ParseImageRef parses a docker://oci:// ref into (image, tag, path). +// +// docker://alpine → ("alpine", "", "") +// docker://alpine@3.19 → ("alpine", "3.19", "") +// docker://docker@cli::/usr/local/bin/docker → ("docker", "cli", "/usr/local/bin/docker") +// oci://ghcr.io/org/img@v1::/bin/tool → ("ghcr.io/org/img", "v1", "/bin/tool") +// +// The prefix (docker://oci://) must already be stripped. +func ParseImageRef(ref string) (image, tag, inContainerPath string) { + if i := strings.Index(ref, "::"); i >= 0 { + inContainerPath = ref[i+2:] + ref = ref[:i] + } + if i := strings.LastIndex(ref, "@"); i > 0 { + tag = ref[i+1:] + ref = ref[:i] + } + image = ref + return +} + // IsReleaseProvider returns true if the provider uses FetchRelease for downloads -// (i.e. GitHub, GitLab, Gitea). Returns false for go://, docker://, git://. +// (i.e. GitHub, GitLab, Gitea). Returns false for go://, docker://, oci://, git://. func IsReleaseProvider(p Provider) bool { if p == nil { return false } switch p.(type) { - case *GoInstall, *Docker, *Git: + case *GoInstall, *Docker, *OCI, *Git: return false default: return true @@ -91,6 +129,7 @@ func IsProviderRef(s string) bool { // // "go://github.com/jrhouston/tfk8s" → "tfk8s", // "docker://hashicorp/terraform" → "terraform" +// "docker://docker@cli::/usr/local/bin/docker" → "docker" func BinaryName(ref string) string { // git:// refs use the filepath part (after :) as the binary name if strings.HasPrefix(ref, "git://") { @@ -107,6 +146,15 @@ func BinaryName(ref string) string { } } + // docker:///oci:// with "::" — binary name is basename of path. + if strings.HasPrefix(ref, "docker://") || strings.HasPrefix(ref, "oci://") { + if i := strings.Index(ref, "::"); i >= 0 { + p := ref[i+2:] + parts := strings.Split(p, "/") + return parts[len(parts)-1] + } + } + // Strip protocol prefix r := ref if i := strings.Index(r, "://"); i >= 0 { diff --git a/pkg/provider/provider_test.go b/pkg/provider/provider_test.go index c0b9478..69f35cd 100644 --- a/pkg/provider/provider_test.go +++ b/pkg/provider/provider_test.go @@ -13,6 +13,10 @@ func TestParseRef(t *testing.T) { {"go://github.com/jrhouston/tfk8s@v0.1.8", "go://github.com/jrhouston/tfk8s", "v0.1.8"}, {"docker://hashicorp/terraform", "docker://hashicorp/terraform", ""}, {"gitlab.com/org/tool@v1.0", "gitlab.com/org/tool", "v1.0"}, + // docker://oci:// with "::" must preserve path on base. + {"docker://docker@cli::/usr/local/bin/docker", "docker://docker::/usr/local/bin/docker", "cli"}, + {"oci://ghcr.io/org/img@v1::/bin/tool", "oci://ghcr.io/org/img::/bin/tool", "v1"}, + {"oci://alpine::/bin/busybox", "oci://alpine::/bin/busybox", ""}, } for _, tt := range tests { @@ -57,6 +61,10 @@ func TestBinaryName(t *testing.T) { {"docker://hashicorp/terraform", "terraform"}, {"gitlab.com/org/my-tool", "my-tool"}, {"codeberg.org/user/app", "app"}, + // "::" takes the basename of the path as the binary name. + {"docker://docker@cli::/usr/local/bin/docker", "docker"}, + {"oci://ghcr.io/org/img@v1::/bin/my-tool", "my-tool"}, + {"oci://alpine", "alpine"}, } for _, tt := range tests { From 182a51b8c3927f45e05e1e63405dfd673cbea3f5 Mon Sep 17 00:00:00 2001 From: Jan Guth Date: Thu, 16 Apr 2026 11:18:36 +0200 Subject: [PATCH 2/7] docs(readme): document oci:// provider and :: syntax - List oci:// in the providers example in b.yaml config - Note that oci:// uses ~/.docker/config.json for registry auth Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index af1bfae..4404998 100644 --- a/README.md +++ b/README.md @@ -124,13 +124,18 @@ binaries: alias: renvsubst # alias to renvsubst kubectl: file: ../kc # custom path (relative to config) - # Install from any provider by ref (GitHub, GitLab, Gitea, go://, docker://, git://) + # Install from any provider by ref (GitHub, GitLab, Gitea, go://, docker://, oci://, git://) github.com/sharkdp/bat: version: v0.24.0 # Install from a git repo (local or remote) git:///home/user/myproject:.scripts/tool: git://github.com/org/repo:bin/app: version: v1.0 + # Install from an OCI registry (daemonless — works without docker) + oci://ghcr.io/org/img: + version: v1.0 + oci://docker::/usr/local/bin/docker: + version: cli envs: # Sync files from upstream git repos @@ -168,6 +173,8 @@ Set environment variables to authenticate with providers for higher rate limits | `GITLAB_TOKEN` | GitLab | | `GITEA_TOKEN` | Gitea / Forgejo (Codeberg) | +For `oci://` the daemonless client reuses your local registry auth from `~/.docker/config.json` (or the `DOCKER_CONFIG` override) — the same credentials `docker login` writes. +   ### 🐳 Using Docker From b633fc29e1dd82b423bd988679e6863a333b6977 Mon Sep 17 00:00:00 2001 From: Jan Guth Date: Thu, 16 Apr 2026 11:27:55 +0200 Subject: [PATCH 3/7] refactor(provider): switch path separator to single ':' + address review - Syntax changes from '::/path' to ':/path', consistent with b's other providers where '@' is the tag separator. The leading '/' on the path disambiguates it from docker's native 'image:tag' (which b never uses). - SplitImagePath skips the "://" scheme prefix and uses the last ":/" so registry ports like "localhost:5000/org/img" parse correctly. - Docker/OCI refs are no longer eligible for SCP-style env install; the parser short-circuits on their prefixes. Copilot review follow-ups: - BinaryName falls back to default derivation when path is empty / trailing slash so we never return "". - dockerImage only strips "image:tag" when ':' is after the last '/', preserving registry ports. - OCI.Install scans each layer once against a set of candidate paths (instead of O(layers x paths) re-decompressions), writing to a temp file and renaming once the highest-priority match is known. - resolveImage replaced with remote.Image + WithPlatform, which handles variant matching and fallback correctly via go-containerregistry. - New oci_extract_test.go covers priority, no-match, non-regular files, and empty search paths against in-memory tar layers. Docs: - README binary+config examples updated to the ':' path syntax. - docs/b/subcommands/install.mdx gets a "container images" section. - docs/authentication.mdx documents OCI auth via ~/.docker/config.json. - docs/glossary.mdx lists the new prefixes. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 10 +- docs/authentication.mdx | 15 +++ docs/b/subcommands/install.mdx | 24 +++++ docs/glossary.mdx | 2 +- pkg/cli/cli_extra_test.go | 8 +- pkg/cli/install.go | 32 +++---- pkg/cli/install_test.go | 6 +- pkg/provider/docker.go | 7 +- pkg/provider/oci.go | 154 ++++++++++++++++++------------- pkg/provider/oci_extract_test.go | 123 ++++++++++++++++++++++++ pkg/provider/oci_test.go | 11 ++- pkg/provider/provider.go | 69 +++++++++----- pkg/provider/provider_test.go | 18 ++-- 13 files changed, 345 insertions(+), 134 deletions(-) create mode 100644 pkg/provider/oci_extract_test.go diff --git a/README.md b/README.md index 4404998..5a5666b 100644 --- a/README.md +++ b/README.md @@ -50,10 +50,10 @@ b install gitlab.com/org/tool b install codeberg.org/user/app@v1.0 b install go://golang.org/x/tools/cmd/goimports b install docker://alpine/helm -b install docker://docker@cli # tag via @, e.g. docker:cli image -b install docker://docker@cli::/usr/local/bin/docker # explicit in-container path -b install oci://ghcr.io/org/img@v1 # daemonless, any OCI registry -b install oci://docker@cli::/usr/local/bin/docker # daemonless docker CLI +b install docker://docker@cli # tag via @ (e.g. docker:cli image) +b install docker://docker@cli:/usr/local/bin/docker # explicit in-container path +b install oci://ghcr.io/org/img@v1 # daemonless, any OCI registry +b install oci://docker@cli:/usr/local/bin/docker # daemonless docker CLI b install "git:///home/user/myrepo:.scripts/tool" b install "git://github.com/org/repo:bin/app@v1.0" @@ -134,7 +134,7 @@ binaries: # Install from an OCI registry (daemonless — works without docker) oci://ghcr.io/org/img: version: v1.0 - oci://docker::/usr/local/bin/docker: + oci://docker:/usr/local/bin/docker: version: cli envs: diff --git a/docs/authentication.mdx b/docs/authentication.mdx index 5758f91..e3af9c5 100644 --- a/docs/authentication.mdx +++ b/docs/authentication.mdx @@ -51,6 +51,21 @@ A Gitea token is required for private repositories on Gitea or Forgejo instances export GITEA_TOKEN="..." ``` +## OCI Registry Authentication + +For `oci://` refs, `b` pulls images daemonlessly but reuses your local docker +credentials. It reads `~/.docker/config.json` (or the location pointed to by +`DOCKER_CONFIG`) and uses whichever helper is configured there. In other +words, if `docker login ghcr.io` works, `b install oci://ghcr.io/...` works too. + +```bash +# Log in once; b picks it up automatically +docker login ghcr.io +b install oci://ghcr.io/org/private-img +``` + +No `b`-specific environment variable is needed for OCI auth. + ## SSH Authentication For private repos, you can also use SSH keys instead of tokens. SSH authentication uses your system's ssh-agent and works for both binaries and env sync: diff --git a/docs/b/subcommands/install.mdx b/docs/b/subcommands/install.mdx index 24d4ea5..ebea287 100644 --- a/docs/b/subcommands/install.mdx +++ b/docs/b/subcommands/install.mdx @@ -139,6 +139,30 @@ b install "git:///home/user/myrepo:bin/tool" b install "git://../../shared-repo:scripts/deploy.sh" ``` +### Install from container images + +Use `docker://` to pull from a local container runtime, or `oci://` to pull +**daemonless** from any OCI registry (Docker Hub, ghcr.io, quay.io, private). + +```bash +# docker:// — requires a running docker/podman/nerdctl +b install docker://alpine/helm +b install docker://docker@cli # tag via @ (docker:cli image) +b install docker://docker@cli:/usr/local/bin/docker # explicit in-container path + +# oci:// — daemonless; works in CI containers without docker +b install oci://ghcr.io/org/img@v1 +b install oci://docker@cli:/usr/local/bin/docker +``` + +The syntax is: `[@][:/]` + +- **image** — An image reference, including optional registry (e.g., `alpine`, `ghcr.io/org/img`) +- **tag** (optional, after `@`) — Image tag; defaults to `latest`. Use `@` consistently with every other `b` provider rather than docker's native `image:tag` syntax +- **path** (optional, after `:/`) — Absolute path to the binary inside the image. When omitted, `b` searches `/usr/local/bin`, `/usr/bin`, `/bin`, and `/app` for a file named after the image's last segment + +The leading `/` on the path disambiguates it from an `image:tag` pasted from docker documentation. For private registries, `oci://` reads credentials from `~/.docker/config.json` (same as `docker login`); see the [authentication](../authentication) page. + ## Flags | Flag | Description | diff --git a/docs/glossary.mdx b/docs/glossary.mdx index 6c7885c..db7266c 100644 --- a/docs/glossary.mdx +++ b/docs/glossary.mdx @@ -70,7 +70,7 @@ This glossary defines key terms and concepts used throughout the **b** documenta **Profile** - A named file set published in an upstream repo's `b.yaml` under the `profiles` section. Consumers discover profiles via `b env profiles` and install them via `b env add`, which copies the configuration into their local `envs`. -**Provider Reference** - A git-cloneable path used to install binaries or sync env files from GitHub (e.g., `github.com/org/repo`). +**Provider Reference** - A git-cloneable path used to install binaries or sync env files from GitHub (e.g., `github.com/org/repo`). Other supported prefixes: `go://` (Go install), `git://` (any git repo), `docker://` (via container runtime), and `oci://` (daemonless OCI pull). ## S diff --git a/pkg/cli/cli_extra_test.go b/pkg/cli/cli_extra_test.go index b1dbf0f..84110b3 100644 --- a/pkg/cli/cli_extra_test.go +++ b/pkg/cli/cli_extra_test.go @@ -575,10 +575,10 @@ func TestParseBinaryArg(t *testing.T) { {"jq@jq-1.7", "jq", "jq-1.7"}, {"kubectl@v1.28.0", "kubectl", "v1.28.0"}, {"terraform", "terraform", ""}, - // docker://oci:// "::" must not swallow @tag into version. - {"docker://docker@cli::/usr/local/bin/docker", "docker://docker::/usr/local/bin/docker", "cli"}, - {"oci://ghcr.io/org/img@v1::/bin/tool", "oci://ghcr.io/org/img::/bin/tool", "v1"}, - {"oci://alpine::/bin/busybox", "oci://alpine::/bin/busybox", ""}, + // docker:// / oci:// ":/" must not swallow @tag into version. + {"docker://docker@cli:/usr/local/bin/docker", "docker://docker:/usr/local/bin/docker", "cli"}, + {"oci://ghcr.io/org/img@v1:/bin/tool", "oci://ghcr.io/org/img:/bin/tool", "v1"}, + {"oci://alpine:/bin/busybox", "oci://alpine:/bin/busybox", ""}, } for _, tt := range tests { diff --git a/pkg/cli/install.go b/pkg/cli/install.go index a985c29..c15e52a 100644 --- a/pkg/cli/install.go +++ b/pkg/cli/install.go @@ -375,16 +375,13 @@ func (o *InstallOptions) updateLock(binaries []*binary.Binary) error { } // parseBinaryArg parses binary argument in format "name" or "name@version". -// For docker://oci:// refs with "::" suffix, the path is preserved on -// name and not misinterpreted as part of @version. +// For docker:// or oci:// refs with a ":/" suffix, the path is preserved +// on name and not misinterpreted as part of @version. func parseBinaryArg(arg string) (name, version string) { - // docker://oci:// with "::" — isolate the image portion for @ scan. + // docker:// or oci:// — isolate the image portion for @ scan so ":/path" + // (e.g. "docker://docker@cli:/usr/local/bin/docker") is preserved. if strings.HasPrefix(arg, "docker://") || strings.HasPrefix(arg, "oci://") { - imgPart, pathPart := arg, "" - if i := strings.Index(arg, "::"); i >= 0 { - imgPart = arg[:i] - pathPart = arg[i:] - } + imgPart, pathPart := provider.SplitImagePath(arg) if i := strings.LastIndex(imgPart, "@"); i > 0 { return imgPart[:i] + pathPart, imgPart[i+1:] } @@ -404,24 +401,21 @@ func parseBinaryArg(arg string) (name, version string) { // // Returns the parsed envInstall, how many additional args were consumed, and whether it matched. func parseSCPArg(arg string, remaining []string) (envInstall, int, bool) { + // docker:// and oci:// are always binary installs (never env SCP), even + // though they may contain ":/path" for in-container binary location. + if strings.HasPrefix(arg, "docker://") || strings.HasPrefix(arg, "oci://") { + return envInstall{}, 0, false + } + // Look for colon that signals SCP syntax — must come after a ref (contains /) - // and before a glob. Skip protocol prefixes (go://, docker://, oci://) and - // the "::" separator used by docker://oci:// for explicit binary paths. + // and before a glob. Skip the "://" of protocol prefixes (go://, git://). colonIdx := -1 for i := range arg { if arg[i] == ':' { - // Skip protocol prefixes (e.g. go://, docker://, oci://) + // Skip protocol prefixes (e.g. go://, git://) if i+2 < len(arg) && arg[i+1] == '/' && arg[i+2] == '/' { continue } - // Skip docker://oci:// "::" binary-path separator - if i+1 < len(arg) && arg[i+1] == ':' { - continue - } - // Also skip the colon that is the second ':' of '::' - if i > 0 && arg[i-1] == ':' { - continue - } // Must be preceded by something that looks like a ref (contains /) prefix := arg[:i] if strings.Contains(prefix, "/") || strings.Contains(prefix, ".") { diff --git a/pkg/cli/install_test.go b/pkg/cli/install_test.go index 6a71370..4304005 100644 --- a/pkg/cli/install_test.go +++ b/pkg/cli/install_test.go @@ -67,12 +67,12 @@ func TestParseSCPArg(t *testing.T) { wantOk: false, }, { - // docker://oci:// "::" — not SCP (binary-path separator) - arg: "docker://docker@cli::/usr/local/bin/docker", + // docker:// / oci:// — never SCP (binary-path separator ":/") + arg: "docker://docker@cli:/usr/local/bin/docker", wantOk: false, }, { - arg: "oci://ghcr.io/org/img@v1::/bin/tool", + arg: "oci://ghcr.io/org/img@v1:/bin/tool", wantOk: false, }, } diff --git a/pkg/provider/docker.go b/pkg/provider/docker.go index 4b5a555..8e1aa1d 100644 --- a/pkg/provider/docker.go +++ b/pkg/provider/docker.go @@ -103,8 +103,11 @@ func (d *Docker) Install(ref, version, destDir string, searchPaths []string) (st func dockerImage(ref string) string { r := strings.TrimPrefix(ref, "docker://") image, _, _ := ParseImageRef(r) - // Also strip docker-style "image:tag" when no explicit @ was given. - if i := strings.LastIndex(image, ":"); i > 0 { + // Also strip docker-style "image:tag" when no explicit @ was given, but + // only when the ':' is after the last '/' so registry ports like + // "localhost:5000/org/image" are preserved. + lastSlash := strings.LastIndex(image, "/") + if i := strings.LastIndex(image, ":"); i > lastSlash && i > 0 { image = image[:i] } return image diff --git a/pkg/provider/oci.go b/pkg/provider/oci.go index 33fe1a0..0536416 100644 --- a/pkg/provider/oci.go +++ b/pkg/provider/oci.go @@ -14,7 +14,6 @@ import ( "github.com/google/go-containerregistry/pkg/name" v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/remote" - "github.com/google/go-containerregistry/pkg/v1/types" ) func init() { @@ -26,13 +25,16 @@ func init() { // // Syntax: // -// oci://[@][::] +// oci://[@][:] // // Examples: // // oci://alpine // oci://ghcr.io/helm/helm@v3.18.6 -// oci://docker@cli::/usr/local/bin/docker +// oci://docker@cli:/usr/local/bin/docker +// +// The in-container path must begin with "/" so it is unambiguous with +// docker's own "image:tag" syntax (which we never use — tags go after "@"). type OCI struct{} func (o *OCI) Name() string { return "oci" } @@ -50,8 +52,8 @@ func (o *OCI) FetchRelease(ref, version string) (*Release, error) { return nil, fmt.Errorf("oci provider does not use FetchRelease; use Install()") } -// Install pulls the image manifest, selects a platform-matching layer, -// and extracts a single file without invoking any container runtime. +// Install pulls a platform-matching image manifest and extracts a single +// binary file without invoking any container runtime. func (o *OCI) Install(ref, version, destDir string) (string, error) { rest := strings.TrimPrefix(ref, "oci://") image, refTag, inContainerPath := ParseImageRef(rest) @@ -70,27 +72,25 @@ func (o *OCI) Install(ref, version, destDir string) (string, error) { return "", fmt.Errorf("parsing image ref %s:%s: %w", image, tag, err) } - opts := []remote.Option{ + // remote.Image handles manifest-list/index resolution internally using the + // provided platform (OS + arch + variant) so we don't need to reimplement + // platform matching here. + img, err := remote.Image(nameRef, remote.WithAuthFromKeychain(authn.DefaultKeychain), remote.WithPlatform(v1.Platform{ OS: runtime.GOOS, Architecture: runtime.GOARCH, }), - } - - desc, err := remote.Get(nameRef, opts...) + ) if err != nil { - return "", fmt.Errorf("fetching manifest for %s: %w", nameRef, err) - } - - img, err := resolveImage(desc, opts) - if err != nil { - return "", fmt.Errorf("resolving image %s: %w", nameRef, err) + return "", fmt.Errorf("fetching image %s: %w", nameRef, err) } // Determine which paths to try inside the image. - searchPaths := []string{inContainerPath} - if inContainerPath == "" { + var searchPaths []string + if inContainerPath != "" { + searchPaths = []string{inContainerPath} + } else { searchPaths = []string{ "/usr/local/bin/" + binName, "/usr/bin/" + binName, @@ -110,81 +110,103 @@ func (o *OCI) Install(ref, version, destDir string) (string, error) { } // Walk layers newest-first so later overrides win. for i := len(layers) - 1; i >= 0; i-- { - for _, sp := range searchPaths { - found, err := extractFromLayer(layers[i], sp, dest) - if err != nil { + found, err := extractBinaryFromLayer(layers[i], searchPaths, dest) + if err != nil { + return "", err + } + if found { + if err := os.Chmod(dest, 0755); err != nil { return "", err } - if found { - if err := os.Chmod(dest, 0755); err != nil { - return "", err - } - return dest, nil - } + return dest, nil } } return "", fmt.Errorf("binary %q not found in image %s at paths: %v", binName, nameRef, searchPaths) } -// resolveImage returns an Image for a descriptor, selecting a platform-matching -// manifest when the descriptor is a manifest list/index. -func resolveImage(desc *remote.Descriptor, opts []remote.Option) (v1.Image, error) { - switch desc.MediaType { - case types.DockerManifestList, types.OCIImageIndex: - idx, err := desc.ImageIndex() - if err != nil { - return nil, err - } - manifest, err := idx.IndexManifest() - if err != nil { - return nil, err - } - want := v1.Platform{OS: runtime.GOOS, Architecture: runtime.GOARCH} - for _, m := range manifest.Manifests { - if m.Platform != nil && m.Platform.OS == want.OS && m.Platform.Architecture == want.Architecture { - return idx.Image(m.Digest) - } - } - return nil, fmt.Errorf("no manifest matches platform %s/%s", want.OS, want.Architecture) - default: - return desc.Image() +// extractBinaryFromLayer scans a layer's tar stream once, looking for any of +// searchPaths. Returns true (and writes to dest) when a match is found. +// Earlier entries in searchPaths take priority; once a higher-priority match +// is found, the scan stops. +func extractBinaryFromLayer(l v1.Layer, searchPaths []string, dest string) (bool, error) { + if len(searchPaths) == 0 { + return false, nil + } + + // Normalise candidates to absolute, cleaned form and assign priorities + // (index in searchPaths; lower is better). + targets := make(map[string]int, len(searchPaths)) + for i, sp := range searchPaths { + targets[path.Clean("/"+strings.TrimPrefix(sp, "/"))] = i } -} -// extractFromLayer scans a single layer's tar stream for filePath and writes it to dest. -// Returns true if the file was found and extracted. -func extractFromLayer(l v1.Layer, filePath, dest string) (bool, error) { rc, err := l.Uncompressed() if err != nil { - return false, err + return false, fmt.Errorf("reading layer contents: %w", err) } defer rc.Close() - target := strings.TrimPrefix(path.Clean(filePath), "/") tr := tar.NewReader(rc) + bestPriority := len(searchPaths) // sentinel: nothing found yet + var tmpPath string + cleanup := func() { + if tmpPath != "" { + _ = os.Remove(tmpPath) + tmpPath = "" + } + } + for { - h, err := tr.Next() + hdr, err := tr.Next() if err == io.EOF { - return false, nil + break } if err != nil { - return false, err + cleanup() + return false, fmt.Errorf("reading tar: %w", err) } - if strings.TrimPrefix(path.Clean(h.Name), "/") != target { + if hdr.Typeflag != tar.TypeReg { continue } - if h.Typeflag != tar.TypeReg { - return false, fmt.Errorf("%s is not a regular file (typeflag=%c)", filePath, h.Typeflag) + normalized := path.Clean("/" + strings.TrimPrefix(hdr.Name, "/")) + priority, ok := targets[normalized] + if !ok || priority >= bestPriority { + continue } - out, err := os.Create(dest) + // Write to a temp file first; rename once we're confident this is + // the best match (since an even-higher-priority path may appear + // later in the same tar stream). + tmp, err := os.CreateTemp(filepath.Dir(dest), ".oci-extract-*") if err != nil { - return false, err + cleanup() + return false, fmt.Errorf("creating temp file: %w", err) + } + if _, err := io.Copy(tmp, tr); err != nil { + tmp.Close() + _ = os.Remove(tmp.Name()) + cleanup() + return false, fmt.Errorf("writing temp file: %w", err) + } + if err := tmp.Close(); err != nil { + _ = os.Remove(tmp.Name()) + cleanup() + return false, fmt.Errorf("closing temp file: %w", err) } - if _, err := io.Copy(out, tr); err != nil { - out.Close() - return false, err + cleanup() // remove any previous lower-priority candidate + tmpPath = tmp.Name() + bestPriority = priority + if bestPriority == 0 { + break // can't do better than the highest-priority path } - return true, out.Close() } + + if tmpPath == "" { + return false, nil + } + if err := os.Rename(tmpPath, dest); err != nil { + _ = os.Remove(tmpPath) + return false, fmt.Errorf("moving extracted file into place: %w", err) + } + return true, nil } diff --git a/pkg/provider/oci_extract_test.go b/pkg/provider/oci_extract_test.go new file mode 100644 index 0000000..d6cbf74 --- /dev/null +++ b/pkg/provider/oci_extract_test.go @@ -0,0 +1,123 @@ +package provider + +import ( + "archive/tar" + "bytes" + "io" + "os" + "path/filepath" + "testing" + + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/tarball" + "github.com/google/go-containerregistry/pkg/v1/types" +) + +// fakeLayer adapts an in-memory uncompressed tar stream into a v1.Layer for +// test purposes by delegating to tarball.LayerFromOpener. +func fakeLayer(t *testing.T, entries []tar.Header, contents map[string][]byte) v1.Layer { + t.Helper() + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + for _, h := range entries { + hdr := h + body := contents[hdr.Name] + hdr.Size = int64(len(body)) + if err := tw.WriteHeader(&hdr); err != nil { + t.Fatalf("tar write header: %v", err) + } + if _, err := tw.Write(body); err != nil { + t.Fatalf("tar write body: %v", err) + } + } + if err := tw.Close(); err != nil { + t.Fatalf("tar close: %v", err) + } + layer, err := tarball.LayerFromOpener( + func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(buf.Bytes())), nil }, + tarball.WithMediaType(types.DockerLayer), + tarball.WithCompressionLevel(0), + ) + if err != nil { + t.Fatalf("LayerFromOpener: %v", err) + } + return layer +} + +func TestExtractBinaryFromLayer_FindsHighestPriority(t *testing.T) { + // Two candidates in the same layer; earlier searchPaths entry wins. + entries := []tar.Header{ + {Name: "usr/bin/docker", Typeflag: tar.TypeReg, Mode: 0755}, + {Name: "usr/local/bin/docker", Typeflag: tar.TypeReg, Mode: 0755}, + } + contents := map[string][]byte{ + "usr/bin/docker": []byte("low"), + "usr/local/bin/docker": []byte("high"), + } + layer := fakeLayer(t, entries, contents) + + dest := filepath.Join(t.TempDir(), "out") + searchPaths := []string{"/usr/local/bin/docker", "/usr/bin/docker"} + found, err := extractBinaryFromLayer(layer, searchPaths, dest) + if err != nil { + t.Fatalf("extract: %v", err) + } + if !found { + t.Fatal("expected found=true") + } + body, err := os.ReadFile(dest) + if err != nil { + t.Fatalf("read dest: %v", err) + } + if string(body) != "high" { + t.Errorf("got %q, want %q (highest-priority match)", body, "high") + } +} + +func TestExtractBinaryFromLayer_NoMatch(t *testing.T) { + entries := []tar.Header{ + {Name: "bin/busybox", Typeflag: tar.TypeReg, Mode: 0755}, + } + contents := map[string][]byte{"bin/busybox": []byte("bb")} + layer := fakeLayer(t, entries, contents) + + dest := filepath.Join(t.TempDir(), "out") + found, err := extractBinaryFromLayer(layer, []string{"/nope"}, dest) + if err != nil { + t.Fatalf("extract: %v", err) + } + if found { + t.Error("expected found=false") + } + if _, err := os.Stat(dest); !os.IsNotExist(err) { + t.Error("dest should not be created when nothing matched") + } +} + +func TestExtractBinaryFromLayer_SkipsNonRegular(t *testing.T) { + // A symlink at the exact target path must not be copied as the binary. + entries := []tar.Header{ + {Name: "usr/bin/busybox", Typeflag: tar.TypeSymlink, Linkname: "busybox", Mode: 0777}, + } + layer := fakeLayer(t, entries, nil) + + dest := filepath.Join(t.TempDir(), "out") + found, err := extractBinaryFromLayer(layer, []string{"/usr/bin/busybox"}, dest) + if err != nil { + t.Fatalf("extract: %v", err) + } + if found { + t.Error("expected symlink to be ignored (non-regular)") + } +} + +func TestExtractBinaryFromLayer_EmptySearchPaths(t *testing.T) { + layer := fakeLayer(t, nil, nil) + found, err := extractBinaryFromLayer(layer, nil, filepath.Join(t.TempDir(), "out")) + if err != nil { + t.Fatalf("extract: %v", err) + } + if found { + t.Error("expected found=false for empty searchPaths") + } +} diff --git a/pkg/provider/oci_test.go b/pkg/provider/oci_test.go index 0d4c8a8..a37423b 100644 --- a/pkg/provider/oci_test.go +++ b/pkg/provider/oci_test.go @@ -10,7 +10,7 @@ func TestOCIMatch(t *testing.T) { }{ {"oci://alpine", true}, {"oci://ghcr.io/org/img", true}, - {"oci://ghcr.io/org/img@v1::/bin/tool", true}, + {"oci://ghcr.io/org/img@v1:/bin/tool", true}, {"docker://alpine", false}, {"github.com/org/repo", false}, } @@ -55,9 +55,12 @@ func TestParseImageRef(t *testing.T) { }{ {"alpine", "alpine", "", ""}, {"alpine@3.19", "alpine", "3.19", ""}, - {"docker@cli::/usr/local/bin/docker", "docker", "cli", "/usr/local/bin/docker"}, - {"ghcr.io/org/img@v1::/bin/tool", "ghcr.io/org/img", "v1", "/bin/tool"}, - {"alpine::/bin/busybox", "alpine", "", "/bin/busybox"}, + {"docker@cli:/usr/local/bin/docker", "docker", "cli", "/usr/local/bin/docker"}, + {"ghcr.io/org/img@v1:/bin/tool", "ghcr.io/org/img", "v1", "/bin/tool"}, + {"alpine:/bin/busybox", "alpine", "", "/bin/busybox"}, + // Registry port is preserved, not mistaken for path. + {"localhost:5000/org/img@v1:/bin/tool", "localhost:5000/org/img", "v1", "/bin/tool"}, + {"localhost:5000/org/img", "localhost:5000/org/img", "", ""}, } for _, tt := range tests { img, tag, p := ParseImageRef(tt.in) diff --git a/pkg/provider/provider.go b/pkg/provider/provider.go index 2ff64ff..060fad3 100644 --- a/pkg/provider/provider.go +++ b/pkg/provider/provider.go @@ -54,18 +54,15 @@ func Detect(ref string) (Provider, error) { // ParseRef splits a ref like "github.com/org/repo@v1.0" into // (github.com/org/repo, v1.0). Version may be empty. // -// For docker:///oci:// refs, the optional "::" suffix +// For docker:///oci:// refs, the optional ":" suffix // is preserved on base (stripped only for the @ scan), and the tag ends -// up in version. E.g. "docker://docker@cli::/usr/local/bin/docker" → -// ("docker://docker::/usr/local/bin/docker", "cli"). +// up in version. E.g. "docker://docker@cli:/usr/local/bin/docker" → +// ("docker://docker:/usr/local/bin/docker", "cli"). The path is recognised +// by its leading "/" which disambiguates it from docker's "image:tag". func ParseRef(ref string) (base, version string) { // For image refs, scan for @ on the image-only slice so paths are ignored. if strings.HasPrefix(ref, "docker://") || strings.HasPrefix(ref, "oci://") { - imgPart, pathPart := ref, "" - if i := strings.Index(ref, "::"); i >= 0 { - imgPart = ref[:i] - pathPart = ref[i:] - } + imgPart, pathPart := SplitImagePath(ref) if i := strings.LastIndex(imgPart, "@"); i > 0 { return imgPart[:i] + pathPart, imgPart[i+1:] } @@ -77,18 +74,36 @@ func ParseRef(ref string) (base, version string) { return ref, "" } +// SplitImagePath locates the ":/" suffix of a docker:// or oci:// ref +// and returns (imagePart, pathPart). pathPart is either empty or starts with +// ":/". Uses the last ":/" so registry ports (":443/") in the middle aren't +// mistaken for the path separator. Skips the protocol prefix's own ":/" so +// "oci://alpine" doesn't match on the scheme separator. +func SplitImagePath(ref string) (imagePart, pathPart string) { + start := 0 + if i := strings.Index(ref, "://"); i >= 0 { + start = i + 3 + } + if i := strings.LastIndex(ref[start:], ":/"); i >= 0 { + abs := start + i + return ref[:abs], ref[abs:] + } + return ref, "" +} + // ParseImageRef parses a docker://oci:// ref into (image, tag, path). // -// docker://alpine → ("alpine", "", "") -// docker://alpine@3.19 → ("alpine", "3.19", "") -// docker://docker@cli::/usr/local/bin/docker → ("docker", "cli", "/usr/local/bin/docker") -// oci://ghcr.io/org/img@v1::/bin/tool → ("ghcr.io/org/img", "v1", "/bin/tool") +// docker://alpine → ("alpine", "", "") +// docker://alpine@3.19 → ("alpine", "3.19", "") +// docker://docker@cli:/usr/local/bin/docker → ("docker", "cli", "/usr/local/bin/docker") +// oci://ghcr.io/org/img@v1:/bin/tool → ("ghcr.io/org/img", "v1", "/bin/tool") // // The prefix (docker://oci://) must already be stripped. func ParseImageRef(ref string) (image, tag, inContainerPath string) { - if i := strings.Index(ref, "::"); i >= 0 { - inContainerPath = ref[i+2:] - ref = ref[:i] + imagePart, pathPart := SplitImagePath(ref) + if pathPart != "" { + inContainerPath = pathPart[1:] // drop leading ":" + ref = imagePart } if i := strings.LastIndex(ref, "@"); i > 0 { tag = ref[i+1:] @@ -129,7 +144,7 @@ func IsProviderRef(s string) bool { // // "go://github.com/jrhouston/tfk8s" → "tfk8s", // "docker://hashicorp/terraform" → "terraform" -// "docker://docker@cli::/usr/local/bin/docker" → "docker" +// "docker://docker@cli:/usr/local/bin/docker" → "docker" func BinaryName(ref string) string { // git:// refs use the filepath part (after :) as the binary name if strings.HasPrefix(ref, "git://") { @@ -146,12 +161,18 @@ func BinaryName(ref string) string { } } - // docker:///oci:// with "::" — binary name is basename of path. + // docker:// or oci:// with ":/" — binary name is basename of path. + // Fall through to default derivation if the path is empty or a directory. if strings.HasPrefix(ref, "docker://") || strings.HasPrefix(ref, "oci://") { - if i := strings.Index(ref, "::"); i >= 0 { - p := ref[i+2:] - parts := strings.Split(p, "/") - return parts[len(parts)-1] + _, pathPart := SplitImagePath(ref) + if pathPart != "" { + p := pathPart[1:] // drop leading ":" + if p != "" && !strings.HasSuffix(p, "/") { + parts := strings.Split(p, "/") + if last := parts[len(parts)-1]; last != "" { + return last + } + } } } @@ -164,8 +185,10 @@ func BinaryName(ref string) string { if i := strings.LastIndex(r, "@"); i > 0 { r = r[:i] } - // Strip colon (docker image:tag handled by version) - if i := strings.LastIndex(r, ":"); i > 0 { + // Strip docker-style "image:tag" — only when ":" occurs after the last "/" + // so registry ports like "localhost:5000/org/image" are preserved. + lastSlash := strings.LastIndex(r, "/") + if i := strings.LastIndex(r, ":"); i > lastSlash && i > 0 { r = r[:i] } // Last path segment diff --git a/pkg/provider/provider_test.go b/pkg/provider/provider_test.go index 69f35cd..970c46f 100644 --- a/pkg/provider/provider_test.go +++ b/pkg/provider/provider_test.go @@ -13,10 +13,12 @@ func TestParseRef(t *testing.T) { {"go://github.com/jrhouston/tfk8s@v0.1.8", "go://github.com/jrhouston/tfk8s", "v0.1.8"}, {"docker://hashicorp/terraform", "docker://hashicorp/terraform", ""}, {"gitlab.com/org/tool@v1.0", "gitlab.com/org/tool", "v1.0"}, - // docker://oci:// with "::" must preserve path on base. - {"docker://docker@cli::/usr/local/bin/docker", "docker://docker::/usr/local/bin/docker", "cli"}, - {"oci://ghcr.io/org/img@v1::/bin/tool", "oci://ghcr.io/org/img::/bin/tool", "v1"}, - {"oci://alpine::/bin/busybox", "oci://alpine::/bin/busybox", ""}, + // docker:// / oci:// with ":/" must preserve path on base. + {"docker://docker@cli:/usr/local/bin/docker", "docker://docker:/usr/local/bin/docker", "cli"}, + {"oci://ghcr.io/org/img@v1:/bin/tool", "oci://ghcr.io/org/img:/bin/tool", "v1"}, + {"oci://alpine:/bin/busybox", "oci://alpine:/bin/busybox", ""}, + // Registry port must not be mistaken for the path separator. + {"oci://localhost:5000/org/img@v1:/bin/tool", "oci://localhost:5000/org/img:/bin/tool", "v1"}, } for _, tt := range tests { @@ -61,10 +63,12 @@ func TestBinaryName(t *testing.T) { {"docker://hashicorp/terraform", "terraform"}, {"gitlab.com/org/my-tool", "my-tool"}, {"codeberg.org/user/app", "app"}, - // "::" takes the basename of the path as the binary name. - {"docker://docker@cli::/usr/local/bin/docker", "docker"}, - {"oci://ghcr.io/org/img@v1::/bin/my-tool", "my-tool"}, + // ":/" takes the basename of the path as the binary name. + {"docker://docker@cli:/usr/local/bin/docker", "docker"}, + {"oci://ghcr.io/org/img@v1:/bin/my-tool", "my-tool"}, {"oci://alpine", "alpine"}, + // Registry port must not be mistaken for path. + {"oci://localhost:5000/org/img", "img"}, } for _, tt := range tests { From 16c1fc9cd46672d747196789c8027320a71f4c27 Mon Sep 17 00:00:00 2001 From: Jan Guth Date: Thu, 16 Apr 2026 11:38:13 +0200 Subject: [PATCH 4/7] fix(provider): OCI whiteouts + docstring sync with ':/path' syntax MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the second round of Copilot review: - OCI.Install now tracks whiteouts (.wh. and .wh..wh..opq) while walking layers newest-first, so a deleted-in-newer-layer path can't be resurrected from an older layer. - Dropped the stale '::'/'docker://oci://' wording from docstrings and examples — everything now uses ':/' and 'docker:// or oci://'. - docs/b/subcommands/install.mdx uses an absolute '/authentication' link so it resolves correctly in the Docusaurus build. - Provider Reference glossary entry is no longer git-centric. Added whiteout-specific tests (file whiteout, opaque-dir whiteout, whiteout map recording) against in-memory tar layers. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/b/subcommands/install.mdx | 2 +- docs/glossary.mdx | 2 +- pkg/provider/docker.go | 4 +- pkg/provider/oci.go | 69 ++++++++++++++++++++++++++++--- pkg/provider/oci_extract_test.go | 71 ++++++++++++++++++++++++++++++-- pkg/provider/provider.go | 14 +++---- 6 files changed, 141 insertions(+), 21 deletions(-) diff --git a/docs/b/subcommands/install.mdx b/docs/b/subcommands/install.mdx index ebea287..f4e2e52 100644 --- a/docs/b/subcommands/install.mdx +++ b/docs/b/subcommands/install.mdx @@ -161,7 +161,7 @@ The syntax is: `[@][:/]` - **tag** (optional, after `@`) — Image tag; defaults to `latest`. Use `@` consistently with every other `b` provider rather than docker's native `image:tag` syntax - **path** (optional, after `:/`) — Absolute path to the binary inside the image. When omitted, `b` searches `/usr/local/bin`, `/usr/bin`, `/bin`, and `/app` for a file named after the image's last segment -The leading `/` on the path disambiguates it from an `image:tag` pasted from docker documentation. For private registries, `oci://` reads credentials from `~/.docker/config.json` (same as `docker login`); see the [authentication](../authentication) page. +The leading `/` on the path disambiguates it from an `image:tag` pasted from docker documentation. For private registries, `oci://` reads credentials from `~/.docker/config.json` (same as `docker login`); see the [authentication](/authentication) page. ## Flags diff --git a/docs/glossary.mdx b/docs/glossary.mdx index db7266c..f7cea83 100644 --- a/docs/glossary.mdx +++ b/docs/glossary.mdx @@ -70,7 +70,7 @@ This glossary defines key terms and concepts used throughout the **b** documenta **Profile** - A named file set published in an upstream repo's `b.yaml` under the `profiles` section. Consumers discover profiles via `b env profiles` and install them via `b env add`, which copies the configuration into their local `envs`. -**Provider Reference** - A git-cloneable path used to install binaries or sync env files from GitHub (e.g., `github.com/org/repo`). Other supported prefixes: `go://` (Go install), `git://` (any git repo), `docker://` (via container runtime), and `oci://` (daemonless OCI pull). +**Provider Reference** - A provider-specific reference used to install binaries or sync env files (e.g., `github.com/org/repo`). Supported prefixes include `go://` (Go install), `git://` (any git repo), `docker://` (via container runtime), and `oci://` (daemonless OCI pull). ## S diff --git a/pkg/provider/docker.go b/pkg/provider/docker.go index 8e1aa1d..8510cfd 100644 --- a/pkg/provider/docker.go +++ b/pkg/provider/docker.go @@ -32,7 +32,7 @@ func (d *Docker) FetchRelease(ref, version string) (*Release, error) { // Install pulls the image, creates a container, copies the binary out, and cleans up. // searchPaths are the paths to search for the binary inside the container. -// If the ref includes "::", that path is used as the single search path. +// If the ref includes ":/", that path is used as the single search path. func (d *Docker) Install(ref, version, destDir string, searchPaths []string) (string, error) { runtime, err := detectContainerRuntime() if err != nil { @@ -67,7 +67,7 @@ func (d *Docker) Install(ref, version, destDir string, searchPaths []string) (st containerID := strings.TrimSpace(string(out)) defer exec.Command(runtime, "rm", containerID).Run() - // Determine search paths: explicit "::" overrides everything. + // Determine search paths: explicit ":/" overrides everything. if inContainerPath != "" { searchPaths = []string{inContainerPath} } else if searchPaths == nil { diff --git a/pkg/provider/oci.go b/pkg/provider/oci.go index 0536416..d8cd903 100644 --- a/pkg/provider/oci.go +++ b/pkg/provider/oci.go @@ -25,7 +25,7 @@ func init() { // // Syntax: // -// oci://[@][:] +// oci://[@][:/] // // Examples: // @@ -108,9 +108,11 @@ func (o *OCI) Install(ref, version, destDir string) (string, error) { if err != nil { return "", fmt.Errorf("reading layers: %w", err) } - // Walk layers newest-first so later overrides win. + // Walk layers newest-first so later overrides win. Track OCI whiteouts + // from newer layers so we don't resurrect a file deleted in the final image. + whiteouts := make(map[string]bool) for i := len(layers) - 1; i >= 0; i-- { - found, err := extractBinaryFromLayer(layers[i], searchPaths, dest) + found, err := extractBinaryFromLayer(layers[i], searchPaths, dest, whiteouts) if err != nil { return "", err } @@ -129,7 +131,11 @@ func (o *OCI) Install(ref, version, destDir string) (string, error) { // searchPaths. Returns true (and writes to dest) when a match is found. // Earlier entries in searchPaths take priority; once a higher-priority match // is found, the scan stops. -func extractBinaryFromLayer(l v1.Layer, searchPaths []string, dest string) (bool, error) { +// +// whiteouts tracks OCI whiteout markers (".wh.", ".wh..wh..opq") seen +// in newer layers so deleted files aren't resurrected from older ones. The +// map is updated in-place with whiteouts discovered in this layer. +func extractBinaryFromLayer(l v1.Layer, searchPaths []string, dest string, whiteouts map[string]bool) (bool, error) { if len(searchPaths) == 0 { return false, nil } @@ -166,11 +172,26 @@ func extractBinaryFromLayer(l v1.Layer, searchPaths []string, dest string) (bool cleanup() return false, fmt.Errorf("reading tar: %w", err) } + + name := path.Clean("/" + strings.TrimPrefix(hdr.Name, "/")) + base := path.Base(name) + + // Record whiteouts from this (newer-than-caller) layer so older + // layers are prevented from resurrecting deleted paths. + if base == ".wh..wh..opq" { + // Opaque dir: everything in its parent is hidden from older layers. + whiteouts[path.Dir(name)+"/"] = true + continue + } + if strings.HasPrefix(base, ".wh.") { + whiteouts[path.Join(path.Dir(name), strings.TrimPrefix(base, ".wh."))] = true + continue + } + if hdr.Typeflag != tar.TypeReg { continue } - normalized := path.Clean("/" + strings.TrimPrefix(hdr.Name, "/")) - priority, ok := targets[normalized] + priority, ok := targets[name] if !ok || priority >= bestPriority { continue } @@ -204,9 +225,45 @@ func extractBinaryFromLayer(l v1.Layer, searchPaths []string, dest string) (bool if tmpPath == "" { return false, nil } + // If a newer layer whited out this path (or an ancestor directory + // opaque-marker), don't use the file we found here. + if isWhiteoutBlocked(findMatchedPath(searchPaths, bestPriority), whiteouts) { + cleanup() + return false, nil + } if err := os.Rename(tmpPath, dest); err != nil { _ = os.Remove(tmpPath) return false, fmt.Errorf("moving extracted file into place: %w", err) } return true, nil } + +// findMatchedPath returns the searchPaths entry at the given priority index, +// normalised to absolute form. +func findMatchedPath(searchPaths []string, priority int) string { + if priority < 0 || priority >= len(searchPaths) { + return "" + } + return path.Clean("/" + strings.TrimPrefix(searchPaths[priority], "/")) +} + +// isWhiteoutBlocked reports whether target (or any ancestor dir marked opaque) +// has been whited out by a newer layer. +func isWhiteoutBlocked(target string, whiteouts map[string]bool) bool { + if target == "" || len(whiteouts) == 0 { + return false + } + if whiteouts[target] { + return true + } + // Walk ancestor directories to honour opaque whiteouts stored as "dir/". + for dir := path.Dir(target); dir != "/" && dir != "."; dir = path.Dir(dir) { + if whiteouts[dir+"/"] { + return true + } + } + if whiteouts["/"] { + return true + } + return false +} diff --git a/pkg/provider/oci_extract_test.go b/pkg/provider/oci_extract_test.go index d6cbf74..87a4b3a 100644 --- a/pkg/provider/oci_extract_test.go +++ b/pkg/provider/oci_extract_test.go @@ -58,7 +58,7 @@ func TestExtractBinaryFromLayer_FindsHighestPriority(t *testing.T) { dest := filepath.Join(t.TempDir(), "out") searchPaths := []string{"/usr/local/bin/docker", "/usr/bin/docker"} - found, err := extractBinaryFromLayer(layer, searchPaths, dest) + found, err := extractBinaryFromLayer(layer, searchPaths, dest, map[string]bool{}) if err != nil { t.Fatalf("extract: %v", err) } @@ -82,7 +82,7 @@ func TestExtractBinaryFromLayer_NoMatch(t *testing.T) { layer := fakeLayer(t, entries, contents) dest := filepath.Join(t.TempDir(), "out") - found, err := extractBinaryFromLayer(layer, []string{"/nope"}, dest) + found, err := extractBinaryFromLayer(layer, []string{"/nope"}, dest, map[string]bool{}) if err != nil { t.Fatalf("extract: %v", err) } @@ -102,7 +102,7 @@ func TestExtractBinaryFromLayer_SkipsNonRegular(t *testing.T) { layer := fakeLayer(t, entries, nil) dest := filepath.Join(t.TempDir(), "out") - found, err := extractBinaryFromLayer(layer, []string{"/usr/bin/busybox"}, dest) + found, err := extractBinaryFromLayer(layer, []string{"/usr/bin/busybox"}, dest, map[string]bool{}) if err != nil { t.Fatalf("extract: %v", err) } @@ -113,7 +113,7 @@ func TestExtractBinaryFromLayer_SkipsNonRegular(t *testing.T) { func TestExtractBinaryFromLayer_EmptySearchPaths(t *testing.T) { layer := fakeLayer(t, nil, nil) - found, err := extractBinaryFromLayer(layer, nil, filepath.Join(t.TempDir(), "out")) + found, err := extractBinaryFromLayer(layer, nil, filepath.Join(t.TempDir(), "out"), map[string]bool{}) if err != nil { t.Fatalf("extract: %v", err) } @@ -121,3 +121,66 @@ func TestExtractBinaryFromLayer_EmptySearchPaths(t *testing.T) { t.Error("expected found=false for empty searchPaths") } } + +func TestExtractBinaryFromLayer_RespectsWhiteout(t *testing.T) { + // A whiteout from a newer layer blocks extraction of /usr/bin/tool + // from this (older) layer. + entries := []tar.Header{ + {Name: "usr/bin/tool", Typeflag: tar.TypeReg, Mode: 0755}, + } + contents := map[string][]byte{"usr/bin/tool": []byte("stale")} + layer := fakeLayer(t, entries, contents) + + dest := filepath.Join(t.TempDir(), "out") + whiteouts := map[string]bool{"/usr/bin/tool": true} + found, err := extractBinaryFromLayer(layer, []string{"/usr/bin/tool"}, dest, whiteouts) + if err != nil { + t.Fatalf("extract: %v", err) + } + if found { + t.Error("whiteout from newer layer should block extraction") + } + if _, err := os.Stat(dest); !os.IsNotExist(err) { + t.Error("dest should not be created when path is whited out") + } +} + +func TestExtractBinaryFromLayer_RecordsWhiteouts(t *testing.T) { + // A whiteout marker in this layer populates the shared map so older + // layers skip that path. + entries := []tar.Header{ + {Name: "usr/bin/.wh.deleted", Typeflag: tar.TypeReg, Mode: 0644}, + {Name: "opt/.wh..wh..opq", Typeflag: tar.TypeReg, Mode: 0644}, + } + layer := fakeLayer(t, entries, nil) + + whiteouts := map[string]bool{} + _, err := extractBinaryFromLayer(layer, []string{"/nope"}, filepath.Join(t.TempDir(), "out"), whiteouts) + if err != nil { + t.Fatalf("extract: %v", err) + } + if !whiteouts["/usr/bin/deleted"] { + t.Errorf("expected /usr/bin/deleted to be whited out, got %v", whiteouts) + } + if !whiteouts["/opt/"] { + t.Errorf("expected /opt/ opaque marker, got %v", whiteouts) + } +} + +func TestExtractBinaryFromLayer_OpaqueDirBlocksDescendant(t *testing.T) { + // An opaque-dir whiteout on "/usr/local/bin/" should block extraction of + // /usr/local/bin/tool from an older layer. + entries := []tar.Header{ + {Name: "usr/local/bin/tool", Typeflag: tar.TypeReg, Mode: 0755}, + } + layer := fakeLayer(t, entries, map[string][]byte{"usr/local/bin/tool": []byte("x")}) + + whiteouts := map[string]bool{"/usr/local/bin/": true} + found, err := extractBinaryFromLayer(layer, []string{"/usr/local/bin/tool"}, filepath.Join(t.TempDir(), "out"), whiteouts) + if err != nil { + t.Fatalf("extract: %v", err) + } + if found { + t.Error("opaque-dir whiteout should block extraction of descendants") + } +} diff --git a/pkg/provider/provider.go b/pkg/provider/provider.go index 060fad3..fd08bbb 100644 --- a/pkg/provider/provider.go +++ b/pkg/provider/provider.go @@ -54,7 +54,7 @@ func Detect(ref string) (Provider, error) { // ParseRef splits a ref like "github.com/org/repo@v1.0" into // (github.com/org/repo, v1.0). Version may be empty. // -// For docker:///oci:// refs, the optional ":" suffix +// For docker:// or oci:// refs, the optional ":/" suffix // is preserved on base (stripped only for the @ scan), and the tag ends // up in version. E.g. "docker://docker@cli:/usr/local/bin/docker" → // ("docker://docker:/usr/local/bin/docker", "cli"). The path is recognised @@ -91,14 +91,14 @@ func SplitImagePath(ref string) (imagePart, pathPart string) { return ref, "" } -// ParseImageRef parses a docker://oci:// ref into (image, tag, path). +// ParseImageRef parses a docker:// or oci:// ref into (image, tag, path). // -// docker://alpine → ("alpine", "", "") -// docker://alpine@3.19 → ("alpine", "3.19", "") -// docker://docker@cli:/usr/local/bin/docker → ("docker", "cli", "/usr/local/bin/docker") -// oci://ghcr.io/org/img@v1:/bin/tool → ("ghcr.io/org/img", "v1", "/bin/tool") +// alpine → ("alpine", "", "") +// alpine@3.19 → ("alpine", "3.19", "") +// docker@cli:/usr/local/bin/docker → ("docker", "cli", "/usr/local/bin/docker") +// ghcr.io/org/img@v1:/bin/tool → ("ghcr.io/org/img", "v1", "/bin/tool") // -// The prefix (docker://oci://) must already be stripped. +// The prefix (docker:// or oci://) must already be stripped. func ParseImageRef(ref string) (image, tag, inContainerPath string) { imagePart, pathPart := SplitImagePath(ref) if pathPart != "" { From 8dd53433fa00ce4a71e3071312dd3b592d840371 Mon Sep 17 00:00:00 2001 From: Jan Guth Date: Thu, 16 Apr 2026 11:40:52 +0200 Subject: [PATCH 5/7] fix(oci): satisfy staticcheck S1008 in isWhiteoutBlocked Replace 'if whiteouts["/"] { return true }; return false' with direct 'return whiteouts["/"]'. Functionally identical. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/provider/oci.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/pkg/provider/oci.go b/pkg/provider/oci.go index d8cd903..aa2a9a4 100644 --- a/pkg/provider/oci.go +++ b/pkg/provider/oci.go @@ -262,8 +262,5 @@ func isWhiteoutBlocked(target string, whiteouts map[string]bool) bool { return true } } - if whiteouts["/"] { - return true - } - return false + return whiteouts["/"] } From 3f664c9a16f0f0459a0993ceb85aa71d8162f001 Mon Sep 17 00:00:00 2001 From: Jan Guth Date: Thu, 16 Apr 2026 11:49:50 +0200 Subject: [PATCH 6/7] refactor(provider): round-3 review follow-ups - ParseImageRef also accepts docker-style 'image:tag' as a copy-paste convenience. @tag remains the preferred syntax, but users can paste 'oci://alpine:3.19' from docker docs without a confusing error. Registry ports are still preserved (the scan only treats a ':' as a tag when it's after the last '/'). - parseBinaryArg now delegates to provider.ParseRef, eliminating the duplicated docker://oci://-aware '@' split logic. - extractBinaryFromLayer skips whiteout-blocked candidates during the scan rather than only after, so a lower-priority fallback in the same layer is correctly used when the preferred path is whited out. - Regular-file check uses FileInfo().Mode().IsRegular() so both TypeReg and the deprecated NUL-byte TypeRegA (still seen in some tar encodings) are accepted, without referencing the deprecated constant. New tests cover: fallback after whiteout in same layer, legacy NUL typeflag, and docker-style 'image:tag' parsing. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/cli/install.go | 20 ++----------- pkg/provider/oci.go | 25 ++++++----------- pkg/provider/oci_extract_test.go | 48 ++++++++++++++++++++++++++++++++ pkg/provider/oci_test.go | 3 ++ pkg/provider/provider.go | 14 +++++++++- 5 files changed, 76 insertions(+), 34 deletions(-) diff --git a/pkg/cli/install.go b/pkg/cli/install.go index c15e52a..e5fcaff 100644 --- a/pkg/cli/install.go +++ b/pkg/cli/install.go @@ -375,24 +375,10 @@ func (o *InstallOptions) updateLock(binaries []*binary.Binary) error { } // parseBinaryArg parses binary argument in format "name" or "name@version". -// For docker:// or oci:// refs with a ":/" suffix, the path is preserved -// on name and not misinterpreted as part of @version. +// Delegates to provider.ParseRef which already handles the docker://oci:// +// quirk of preserving a ":/" suffix on name. func parseBinaryArg(arg string) (name, version string) { - // docker:// or oci:// — isolate the image portion for @ scan so ":/path" - // (e.g. "docker://docker@cli:/usr/local/bin/docker") is preserved. - if strings.HasPrefix(arg, "docker://") || strings.HasPrefix(arg, "oci://") { - imgPart, pathPart := provider.SplitImagePath(arg) - if i := strings.LastIndex(imgPart, "@"); i > 0 { - return imgPart[:i] + pathPart, imgPart[i+1:] - } - return arg, "" - } - parts := strings.SplitN(arg, "@", 2) - name = parts[0] - if len(parts) > 1 { - version = parts[1] - } - return + return provider.ParseRef(arg) } // parseSCPArg tries to parse an SCP-style env install: diff --git a/pkg/provider/oci.go b/pkg/provider/oci.go index aa2a9a4..0bf8f63 100644 --- a/pkg/provider/oci.go +++ b/pkg/provider/oci.go @@ -188,13 +188,21 @@ func extractBinaryFromLayer(l v1.Layer, searchPaths []string, dest string, white continue } - if hdr.Typeflag != tar.TypeReg { + // Accept any regular file; some tar encodings use the legacy NUL + // typeflag (TypeRegA) which FileInfo.Mode().IsRegular() handles + // along with the modern '0' TypeReg. + if !hdr.FileInfo().Mode().IsRegular() { continue } priority, ok := targets[name] if !ok || priority >= bestPriority { continue } + // Skip candidates that a newer layer has whited out; keep looking + // for the next-best unblocked match in this same tar stream. + if isWhiteoutBlocked(name, whiteouts) { + continue + } // Write to a temp file first; rename once we're confident this is // the best match (since an even-higher-priority path may appear // later in the same tar stream). @@ -225,12 +233,6 @@ func extractBinaryFromLayer(l v1.Layer, searchPaths []string, dest string, white if tmpPath == "" { return false, nil } - // If a newer layer whited out this path (or an ancestor directory - // opaque-marker), don't use the file we found here. - if isWhiteoutBlocked(findMatchedPath(searchPaths, bestPriority), whiteouts) { - cleanup() - return false, nil - } if err := os.Rename(tmpPath, dest); err != nil { _ = os.Remove(tmpPath) return false, fmt.Errorf("moving extracted file into place: %w", err) @@ -238,15 +240,6 @@ func extractBinaryFromLayer(l v1.Layer, searchPaths []string, dest string, white return true, nil } -// findMatchedPath returns the searchPaths entry at the given priority index, -// normalised to absolute form. -func findMatchedPath(searchPaths []string, priority int) string { - if priority < 0 || priority >= len(searchPaths) { - return "" - } - return path.Clean("/" + strings.TrimPrefix(searchPaths[priority], "/")) -} - // isWhiteoutBlocked reports whether target (or any ancestor dir marked opaque) // has been whited out by a newer layer. func isWhiteoutBlocked(target string, whiteouts map[string]bool) bool { diff --git a/pkg/provider/oci_extract_test.go b/pkg/provider/oci_extract_test.go index 87a4b3a..1769c5d 100644 --- a/pkg/provider/oci_extract_test.go +++ b/pkg/provider/oci_extract_test.go @@ -167,6 +167,54 @@ func TestExtractBinaryFromLayer_RecordsWhiteouts(t *testing.T) { } } +func TestExtractBinaryFromLayer_FallsBackWhenFirstWhitedOut(t *testing.T) { + // Layer contains both candidates; the higher-priority one is whited out + // by a newer layer, so the lower-priority candidate must be extracted. + entries := []tar.Header{ + {Name: "usr/local/bin/tool", Typeflag: tar.TypeReg, Mode: 0755}, + {Name: "usr/bin/tool", Typeflag: tar.TypeReg, Mode: 0755}, + } + contents := map[string][]byte{ + "usr/local/bin/tool": []byte("blocked"), + "usr/bin/tool": []byte("fallback"), + } + layer := fakeLayer(t, entries, contents) + + dest := filepath.Join(t.TempDir(), "out") + whiteouts := map[string]bool{"/usr/local/bin/tool": true} + searchPaths := []string{"/usr/local/bin/tool", "/usr/bin/tool"} + found, err := extractBinaryFromLayer(layer, searchPaths, dest, whiteouts) + if err != nil { + t.Fatalf("extract: %v", err) + } + if !found { + t.Fatal("expected fallback candidate to be extracted") + } + body, err := os.ReadFile(dest) + if err != nil { + t.Fatalf("read dest: %v", err) + } + if string(body) != "fallback" { + t.Errorf("got %q, want %q", body, "fallback") + } +} + +func TestExtractBinaryFromLayer_AcceptsLegacyRegular(t *testing.T) { + // Legacy NUL-typeflag regular file must be accepted via FileInfo.IsRegular(). + entries := []tar.Header{ + {Name: "usr/bin/tool", Typeflag: 0x00, Mode: 0755}, + } + layer := fakeLayer(t, entries, map[string][]byte{"usr/bin/tool": []byte("x")}) + dest := filepath.Join(t.TempDir(), "out") + found, err := extractBinaryFromLayer(layer, []string{"/usr/bin/tool"}, dest, map[string]bool{}) + if err != nil { + t.Fatalf("extract: %v", err) + } + if !found { + t.Error("legacy NUL typeflag regular file should be accepted") + } +} + func TestExtractBinaryFromLayer_OpaqueDirBlocksDescendant(t *testing.T) { // An opaque-dir whiteout on "/usr/local/bin/" should block extraction of // /usr/local/bin/tool from an older layer. diff --git a/pkg/provider/oci_test.go b/pkg/provider/oci_test.go index a37423b..35f6949 100644 --- a/pkg/provider/oci_test.go +++ b/pkg/provider/oci_test.go @@ -61,6 +61,9 @@ func TestParseImageRef(t *testing.T) { // Registry port is preserved, not mistaken for path. {"localhost:5000/org/img@v1:/bin/tool", "localhost:5000/org/img", "v1", "/bin/tool"}, {"localhost:5000/org/img", "localhost:5000/org/img", "", ""}, + // Docker-style "image:tag" is tolerated as a copy-paste convenience. + {"alpine:3.19", "alpine", "3.19", ""}, + {"ghcr.io/org/img:v1", "ghcr.io/org/img", "v1", ""}, } for _, tt := range tests { img, tag, p := ParseImageRef(tt.in) diff --git a/pkg/provider/provider.go b/pkg/provider/provider.go index fd08bbb..6a7b1c6 100644 --- a/pkg/provider/provider.go +++ b/pkg/provider/provider.go @@ -95,10 +95,14 @@ func SplitImagePath(ref string) (imagePart, pathPart string) { // // alpine → ("alpine", "", "") // alpine@3.19 → ("alpine", "3.19", "") +// alpine:3.19 → ("alpine", "3.19", "") // docker-style, tolerated // docker@cli:/usr/local/bin/docker → ("docker", "cli", "/usr/local/bin/docker") // ghcr.io/org/img@v1:/bin/tool → ("ghcr.io/org/img", "v1", "/bin/tool") +// localhost:5000/org/img → ("localhost:5000/org/img", "", "") // -// The prefix (docker:// or oci://) must already be stripped. +// The prefix (docker:// or oci://) must already be stripped. Docker-style +// "image:tag" is accepted for convenience (a copy-paste from docker docs) +// but the preferred syntax remains "@tag" to stay consistent across providers. func ParseImageRef(ref string) (image, tag, inContainerPath string) { imagePart, pathPart := SplitImagePath(ref) if pathPart != "" { @@ -108,6 +112,14 @@ func ParseImageRef(ref string) (image, tag, inContainerPath string) { if i := strings.LastIndex(ref, "@"); i > 0 { tag = ref[i+1:] ref = ref[:i] + } else { + // Also accept docker-style "image:tag" — only when ':' is after the + // last '/' so registry ports ("localhost:5000/org/img") are preserved. + lastSlash := strings.LastIndex(ref, "/") + if i := strings.LastIndex(ref, ":"); i > lastSlash && i > 0 { + tag = ref[i+1:] + ref = ref[:i] + } } image = ref return From 96b6cf7c1927f3c030f2422ec2a28feb218355d3 Mon Sep 17 00:00:00 2001 From: Jan Guth Date: Thu, 16 Apr 2026 12:02:51 +0200 Subject: [PATCH 7/7] =?UTF-8?q?fix(provider):=20round-4=20review=20?= =?UTF-8?q?=E2=80=94=20ParseRef=20+=20root=20opaque=20whiteout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ParseRef now also tolerates docker-style 'image:tag' (with ':' only after the last '/' so registry ports survive). Previously the tag was silently kept on base, producing inconsistent lock/config representations vs. the preferred '@tag' form. - Root opaque whiteout ('/.wh..wh..opq') is now stored under the '/' sentinel so isWhiteoutBlocked actually hides older layers. Previously path.Dir("/")+"/" produced '//' and the root sentinel never matched. - Minor wording fix in parseBinaryArg comment. New test: TestExtractBinaryFromLayer_RootOpaqueBlocksEverything. New ParseRef cases cover 'image:tag', 'image:tag:/path', registry ports. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/cli/install.go | 2 +- pkg/provider/oci.go | 8 +++++++- pkg/provider/oci_extract_test.go | 17 +++++++++++++++++ pkg/provider/provider.go | 22 +++++++++++++++++----- pkg/provider/provider_test.go | 6 ++++++ 5 files changed, 48 insertions(+), 7 deletions(-) diff --git a/pkg/cli/install.go b/pkg/cli/install.go index e5fcaff..6efb711 100644 --- a/pkg/cli/install.go +++ b/pkg/cli/install.go @@ -375,7 +375,7 @@ func (o *InstallOptions) updateLock(binaries []*binary.Binary) error { } // parseBinaryArg parses binary argument in format "name" or "name@version". -// Delegates to provider.ParseRef which already handles the docker://oci:// +// Delegates to provider.ParseRef which already handles the docker:// or oci:// // quirk of preserving a ":/" suffix on name. func parseBinaryArg(arg string) (name, version string) { return provider.ParseRef(arg) diff --git a/pkg/provider/oci.go b/pkg/provider/oci.go index 0bf8f63..741a6e9 100644 --- a/pkg/provider/oci.go +++ b/pkg/provider/oci.go @@ -180,7 +180,13 @@ func extractBinaryFromLayer(l v1.Layer, searchPaths []string, dest string, white // layers are prevented from resurrecting deleted paths. if base == ".wh..wh..opq" { // Opaque dir: everything in its parent is hidden from older layers. - whiteouts[path.Dir(name)+"/"] = true + // Use "/" as the sentinel for the root so isWhiteoutBlocked finds it. + dir := path.Dir(name) + if dir == "/" { + whiteouts["/"] = true + } else { + whiteouts[dir+"/"] = true + } continue } if strings.HasPrefix(base, ".wh.") { diff --git a/pkg/provider/oci_extract_test.go b/pkg/provider/oci_extract_test.go index 1769c5d..4ee3a0b 100644 --- a/pkg/provider/oci_extract_test.go +++ b/pkg/provider/oci_extract_test.go @@ -215,6 +215,23 @@ func TestExtractBinaryFromLayer_AcceptsLegacyRegular(t *testing.T) { } } +func TestExtractBinaryFromLayer_RootOpaqueBlocksEverything(t *testing.T) { + // An opaque whiteout at the image root ("/.wh..wh..opq") must be stored + // under the "/" sentinel so isWhiteoutBlocked hides older layers. + entries := []tar.Header{ + {Name: ".wh..wh..opq", Typeflag: tar.TypeReg, Mode: 0644}, + } + layer := fakeLayer(t, entries, nil) + whiteouts := map[string]bool{} + _, err := extractBinaryFromLayer(layer, []string{"/nope"}, filepath.Join(t.TempDir(), "out"), whiteouts) + if err != nil { + t.Fatalf("extract: %v", err) + } + if !whiteouts["/"] { + t.Errorf("expected root opaque to set whiteouts[\"/\"], got %v", whiteouts) + } +} + func TestExtractBinaryFromLayer_OpaqueDirBlocksDescendant(t *testing.T) { // An opaque-dir whiteout on "/usr/local/bin/" should block extraction of // /usr/local/bin/tool from an older layer. diff --git a/pkg/provider/provider.go b/pkg/provider/provider.go index 6a7b1c6..7f2e79c 100644 --- a/pkg/provider/provider.go +++ b/pkg/provider/provider.go @@ -55,17 +55,29 @@ func Detect(ref string) (Provider, error) { // (github.com/org/repo, v1.0). Version may be empty. // // For docker:// or oci:// refs, the optional ":/" suffix -// is preserved on base (stripped only for the @ scan), and the tag ends -// up in version. E.g. "docker://docker@cli:/usr/local/bin/docker" → -// ("docker://docker:/usr/local/bin/docker", "cli"). The path is recognised -// by its leading "/" which disambiguates it from docker's "image:tag". +// is preserved on base (stripped only for the tag scan), and the tag ends +// up in version. Docker-style "image:tag" is also accepted as a copy-paste +// convenience — a ':' is treated as a tag separator when it occurs after +// the last '/' (so registry ports like "localhost:5000/org/img" still parse +// correctly). Examples: +// +// "docker://docker@cli:/usr/local/bin/docker" → +// ("docker://docker:/usr/local/bin/docker", "cli") +// "oci://ghcr.io/org/img:v1:/bin/tool" → +// ("oci://ghcr.io/org/img:/bin/tool", "v1") func ParseRef(ref string) (base, version string) { - // For image refs, scan for @ on the image-only slice so paths are ignored. if strings.HasPrefix(ref, "docker://") || strings.HasPrefix(ref, "oci://") { imgPart, pathPart := SplitImagePath(ref) + // Prefer explicit "@tag". if i := strings.LastIndex(imgPart, "@"); i > 0 { return imgPart[:i] + pathPart, imgPart[i+1:] } + // Tolerate docker-style "image:tag" — but only when the ':' is after + // the last '/' so registry ports are preserved. + lastSlash := strings.LastIndex(imgPart, "/") + if i := strings.LastIndex(imgPart, ":"); i > lastSlash && i > 0 { + return imgPart[:i] + pathPart, imgPart[i+1:] + } return ref, "" } if i := strings.LastIndex(ref, "@"); i > 0 { diff --git a/pkg/provider/provider_test.go b/pkg/provider/provider_test.go index 970c46f..489d408 100644 --- a/pkg/provider/provider_test.go +++ b/pkg/provider/provider_test.go @@ -19,6 +19,12 @@ func TestParseRef(t *testing.T) { {"oci://alpine:/bin/busybox", "oci://alpine:/bin/busybox", ""}, // Registry port must not be mistaken for the path separator. {"oci://localhost:5000/org/img@v1:/bin/tool", "oci://localhost:5000/org/img:/bin/tool", "v1"}, + // Docker-style "image:tag" is tolerated and normalised same as @tag. + {"oci://alpine:3.19", "oci://alpine", "3.19"}, + {"oci://ghcr.io/org/img:v1", "oci://ghcr.io/org/img", "v1"}, + {"oci://ghcr.io/org/img:v1:/bin/tool", "oci://ghcr.io/org/img:/bin/tool", "v1"}, + // Registry port without tag stays as-is. + {"oci://localhost:5000/org/img", "oci://localhost:5000/org/img", ""}, } for _, tt := range tests {