diff --git a/README.md b/README.md
index 711fd90..5a5666b 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"
@@ -120,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
@@ -164,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
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..f4e2e52 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..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`).
+**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/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..84110b3 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..6efb711 100644
--- a/pkg/cli/install.go
+++ b/pkg/cli/install.go
@@ -374,14 +374,11 @@ 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".
+// 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) {
- 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:
@@ -390,12 +387,18 @@ 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://).
+ // 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://)
+ // Skip protocol prefixes (e.g. go://, git://)
if i+2 < len(arg) && arg[i+1] == '/' && arg[i+2] == '/' {
continue
}
diff --git a/pkg/cli/install_test.go b/pkg/cli/install_test.go
index 5bc213f..4304005 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:// — never 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..8510cfd 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,19 @@ 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, 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 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..741a6e9
--- /dev/null
+++ b/pkg/provider/oci.go
@@ -0,0 +1,265 @@
+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"
+)
+
+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
+//
+// 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" }
+
+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 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)
+
+ 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)
+ }
+
+ // 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,
+ }),
+ )
+ if err != nil {
+ return "", fmt.Errorf("fetching image %s: %w", nameRef, err)
+ }
+
+ // Determine which paths to try inside the image.
+ var searchPaths []string
+ if inContainerPath != "" {
+ searchPaths = []string{inContainerPath}
+ } else {
+ 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. 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, whiteouts)
+ 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)
+}
+
+// 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.
+//
+// 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
+ }
+
+ // 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
+ }
+
+ rc, err := l.Uncompressed()
+ if err != nil {
+ return false, fmt.Errorf("reading layer contents: %w", err)
+ }
+ defer rc.Close()
+
+ tr := tar.NewReader(rc)
+ bestPriority := len(searchPaths) // sentinel: nothing found yet
+ var tmpPath string
+ cleanup := func() {
+ if tmpPath != "" {
+ _ = os.Remove(tmpPath)
+ tmpPath = ""
+ }
+ }
+
+ for {
+ hdr, err := tr.Next()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ 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.
+ // 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.") {
+ whiteouts[path.Join(path.Dir(name), strings.TrimPrefix(base, ".wh."))] = true
+ continue
+ }
+
+ // 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).
+ tmp, err := os.CreateTemp(filepath.Dir(dest), ".oci-extract-*")
+ if err != nil {
+ 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)
+ }
+ 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
+ }
+ }
+
+ 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
+}
+
+// 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
+ }
+ }
+ return whiteouts["/"]
+}
diff --git a/pkg/provider/oci_extract_test.go b/pkg/provider/oci_extract_test.go
new file mode 100644
index 0000000..4ee3a0b
--- /dev/null
+++ b/pkg/provider/oci_extract_test.go
@@ -0,0 +1,251 @@
+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, map[string]bool{})
+ 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, map[string]bool{})
+ 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, map[string]bool{})
+ 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"), map[string]bool{})
+ if err != nil {
+ t.Fatalf("extract: %v", err)
+ }
+ if found {
+ 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_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_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.
+ 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/oci_test.go b/pkg/provider/oci_test.go
new file mode 100644
index 0000000..35f6949
--- /dev/null
+++ b/pkg/provider/oci_test.go
@@ -0,0 +1,75 @@
+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"},
+ // 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)
+ 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..7f2e79c 100644
--- a/pkg/provider/provider.go
+++ b/pkg/provider/provider.go
@@ -53,21 +53,98 @@ 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:// or oci:// refs, the optional ":/" suffix
+// 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) {
+ 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 {
return ref[:i], ref[i+1:]
}
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:// or oci:// ref into (image, tag, path).
+//
+// 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. 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 != "" {
+ inContainerPath = pathPart[1:] // drop leading ":"
+ ref = imagePart
+ }
+ 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
+}
+
// 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 +168,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 +185,21 @@ func BinaryName(ref string) string {
}
}
+ // 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://") {
+ _, 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
+ }
+ }
+ }
+ }
+
// Strip protocol prefix
r := ref
if i := strings.Index(r, "://"); i >= 0 {
@@ -116,8 +209,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 c0b9478..489d408 100644
--- a/pkg/provider/provider_test.go
+++ b/pkg/provider/provider_test.go
@@ -13,6 +13,18 @@ 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", ""},
+ // 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 {
@@ -57,6 +69,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"},
+ {"oci://alpine", "alpine"},
+ // Registry port must not be mistaken for path.
+ {"oci://localhost:5000/org/img", "img"},
}
for _, tt := range tests {