diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26ca947..b652f34 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,6 +52,25 @@ jobs: - run: make docs-content - run: git diff --exit-code README.md + dockerflags: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + # cmd/dockerflagsgen reads the "docker run" flags out of docker/cli itself. A diff here means + # the pinned docker/cli changed one decolint parses "runArgs" with, which decides where in the + # array a rule reads a value from; it should arrive as a reviewable diff rather than as + # findings that quietly stop matching what Docker does. + - run: make dockerflags + - run: git diff --exit-code dockerargs/runflags.go + - run: make dockerflags-test + dogfooding: runs-on: ubuntu-latest permissions: diff --git a/.gitignore b/.gitignore index 67e9d10..dcb34b5 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,9 @@ profile.cov # A stray "go build ./cmd/docgen/..." from the repo root lands here; the Makefile always uses # "go run" for it instead, so this is never an intentional artifact. /docgen +# The same for cmd/dockerflagsgen, except that it is a module of its own, so a stray "go build" +# runs from inside it and the binary lands beside the source; "make dockerflags" uses "go run". +/cmd/dockerflagsgen/dockerflagsgen /docs/public/ /docs/resources/ # Rule pages, the README-derived pages, and the generated syntax stylesheet; see cmd/docgen and diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f660b98..7117d80 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -118,6 +118,53 @@ context the two snippets alone don't convey. The existing rules in [`rules/`](rules/) are good references, including for the table-driven tests each rule ships with. +## The `docker run` flag table + +A devcontainer.json's `runArgs` is spliced into a `docker run` command +line, so where a rule finds a value depends on which flags take one: +`["--label", "--cap-drop=ALL"]` drops no capability, because `--label` +consumes the entry after it. [`dockerargs`](dockerargs/) reads a +`runArgs` array the way pflag — the parser docker/cli uses — reads an +argv, and rules ask it for a flag's values instead of matching entries +themselves. + +That needs to know every flag `docker run` registers and whether it +takes a value. A table written by hand would go stale the first time +Docker adds a flag, and stale silently: decolint would keep parsing, +just no longer the way Docker does. So +[`cmd/dockerflagsgen`](cmd/dockerflagsgen/) builds the command +docker/cli builds and writes the flags back out as +[`dockerargs/runflags.go`](dockerargs/runflags.go): + +```console +make dockerflags # regenerate the table +make dockerflags-test # the generator's tests, incl. the differential test against pflag +``` + +The generator is a module of its own, with docker/cli pinned in its own +[`go.mod`](cmd/dockerflagsgen/go.mod). decolint itself depends on +neither docker/cli nor pflag, and `go build`, `go test` and `make lint`, +which all work on the module rooted here, never reach it. Renovate bumps +the pin like any other dependency; CI's `dockerflags` job regenerates +the table and fails on a diff, so a docker/cli release that changes a +flag arrives as a diff to review rather than as findings that quietly +stop matching what Docker does. + +Two of the generator module's tests are the ones that keep the table +honest, and both are meant to fail loudly: + +- `TestParse` runs random argvs through both `dockerargs.Parse` and a + real `pflag.FlagSet` built from the table, and compares the values + each assigns. +- `TestRunFlags`, in `dockerargs` itself, spells the whole table out a + second time, so regenerating it against a newer docker/cli fails a + test instead of changing which entry a rule reads a value from. + +`dockerargs` deliberately parts from Docker in two places, both +documented at `Parse`: it reads on past a `--` terminator and past the +image name, where Docker stops. Anything else is a bug in `dockerargs`, +not a rule's problem. + ## Where documentation lives The README and the site divide by what the reader is trying to decide: diff --git a/Makefile b/Makefile index e599d1e..1611e28 100644 --- a/Makefile +++ b/Makefile @@ -44,6 +44,14 @@ coverage: ## Run tests and open an HTML coverage report lint: ## Run all lint rules go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) run +.PHONY: dockerflags +dockerflags: ## Regenerate the "docker run" flag table in dockerargs + go -C cmd/dockerflagsgen run . -o ../../dockerargs/runflags.go + +.PHONY: dockerflags-test +dockerflags-test: ## Run cmd/dockerflagsgen's tests, including the differential test against pflag + go -C cmd/dockerflagsgen test ./... + .PHONY: docs docs: docs-content docs-syntax ## Build the documentation site into docs/public $(HUGO) --source docs --minify diff --git a/cmd/dockerflagsgen/go.mod b/cmd/dockerflagsgen/go.mod new file mode 100644 index 0000000..e48d2b3 --- /dev/null +++ b/cmd/dockerflagsgen/go.mod @@ -0,0 +1,104 @@ +// dockerflagsgen is a module of its own so that decolint does not depend on docker/cli. See +// main.go. +module github.com/bare-devcontainer/decolint/cmd/dockerflagsgen + +go 1.26.5 + +require ( + github.com/bare-devcontainer/decolint v0.0.0-00010101000000-000000000000 + github.com/docker/cli v29.7.1+incompatible + github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.10 +) + +require ( + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/clipperhouse/uax29/v2 v2.6.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v1.0.0-rc.4 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/cli-docs-tool v0.11.0 // indirect + github.com/docker/distribution v2.8.3+incompatible // indirect + github.com/docker/docker-credential-helpers v0.9.8 // indirect + github.com/docker/go-connections v0.7.0 // indirect + github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c // indirect + github.com/docker/go-metrics v0.0.1 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fvbommel/sortorder v1.1.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/mux v1.8.1 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/klauspost/compress v1.18.6 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.2.0 // indirect + github.com/moby/moby/api v1.55.0 // indirect + github.com/moby/moby/client v0.5.1 // indirect + github.com/moby/patternmatcher v0.6.1 // indirect + github.com/moby/swarmkit/v2 v2.1.2 // indirect + github.com/moby/sys/atomicwriter v0.1.0 // indirect + github.com/moby/sys/capability v0.4.0 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/signal v0.7.1 // indirect + github.com/moby/sys/symlink v0.3.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/morikuni/aec v1.1.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/prometheus/client_golang v1.20.5 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.62.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/tonistiigi/go-rosetta v0.0.0-20220804170347-3f4430f2d346 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/xeipuuv/gojsonschema v1.2.0 // indirect + go.etcd.io/raft/v3 v3.6.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/sdk v1.44.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/term v0.45.0 // indirect + golang.org/x/text v0.38.0 // indirect + golang.org/x/time v0.15.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/grpc v1.82.1 // indirect + google.golang.org/protobuf v1.36.11 // indirect + tags.cncf.io/container-device-interface v1.1.0 // indirect +) + +replace github.com/bare-devcontainer/decolint => ../.. diff --git a/cmd/dockerflagsgen/go.sum b/cmd/dockerflagsgen/go.sum new file mode 100644 index 0000000..4cffb2d --- /dev/null +++ b/cmd/dockerflagsgen/go.sum @@ -0,0 +1,302 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/clipperhouse/uax29/v2 v2.6.0 h1:z0cDbUV+aPASdFb2/ndFnS9ts/WNXgTNNGFoKXuhpos= +github.com/clipperhouse/uax29/v2 v2.6.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v1.0.0-rc.4 h1:M42JrUT4zfZTqtkUwkr0GzmUWbfyO5VO0Q5b3op97T4= +github.com/containerd/platforms v1.0.0-rc.4/go.mod h1:lKlMXyLybmBedS/JJm11uDofzI8L2v0J2ZbYvNsbq1A= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/cli v29.7.1+incompatible h1:ILZpP6B7fedIr6ANy824QkDp1WMJuouIq0O2SrBkB2w= +github.com/docker/cli v29.7.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/cli-docs-tool v0.11.0 h1:7d8QARFb7QEobizqxmEM7fOteZEHwH/zWgHQtHZEcfE= +github.com/docker/cli-docs-tool v0.11.0/go.mod h1:ma8BKiisUo8D6W05XEYIh3oa1UbgrZhi1nowyKFJa8Q= +github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= +github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docker/docker-credential-helpers v0.9.8 h1:bIREROb7So6PRlq6KTtdS9MPEjC29OQRkFNlvK2OX8Q= +github.com/docker/docker-credential-helpers v0.9.8/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c= +github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= +github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c h1:+pKlWGMw7gf6bQ+oDZB4KHQFypsfjYlq/C4rfL7D3g8= +github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA= +github.com/docker/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQV8= +github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 h1:UhxFibDNY/bfvqU5CAUmr9zpesgbU6SWc8/B4mflAE4= +github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fvbommel/sortorder v1.1.0 h1:fUmoe+HLsBTctBDoaBwpQo5N+nrCp8g/BjKb/6ZQmYw= +github.com/fvbommel/sortorder v1.1.0/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc= +github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.5.1 h1:tYNaJno4c0HXz12y5BiqEDy0rVTYkWzI26lGvnTMiJw= +github.com/moby/moby/client v0.5.1/go.mod h1:odLstlZ6uSnfvAgVxMpvgmb8SUdd+siH2T0GBuxVAlM= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/swarmkit/v2 v2.1.2 h1:1WDZAI6HVYNKdCG4zlXnTAPyLsLwuhRGWlHoOUf5Z6I= +github.com/moby/swarmkit/v2 v2.1.2/go.mod h1:GQ6T0ij2oBbWX10OHwpvK449xfXkQMZ8J+B+eQ4mgp4= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/capability v0.4.0 h1:4D4mI6KlNtWMCM1Z/K0i7RV1FkX+DBDHKVJpCndZoHk= +github.com/moby/sys/capability v0.4.0/go.mod h1:4g9IK291rVkms3LKCDOoYlnV8xKwoDTpIrNEE35Wq0I= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/signal v0.7.1 h1:PrQxdvxcGijdo6UXXo/lU/TvHUWyPhj7UOpSo8tuvk0= +github.com/moby/sys/signal v0.7.1/go.mod h1:Se1VGehYokAkrSQwL4tDzHvETwUZlnY7S5XtQ50mQp8= +github.com/moby/sys/symlink v0.3.0 h1:GZX89mEZ9u53f97npBy4Rc3vJKj7JBDj/PN2I22GrNU= +github.com/moby/sys/symlink v0.3.0/go.mod h1:3eNdhduHmYPcgsJtZXW1W4XUJdZGBIkttZ8xKqPUJq0= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ= +github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +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/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.1.0/go.mod h1:I1FGZT9+L76gKKOs5djB6ezCbFQP1xR9D75/vuwEF3g= +github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= +github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.6.0/go.mod h1:eBmuwkDJBwy6iBfxCBob6t6dR6ENT/y+J+Zk0j9GMYc= +github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= +github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= +github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +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/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tonistiigi/go-rosetta v0.0.0-20220804170347-3f4430f2d346 h1:TvtdmeYsYEij78hS4oxnwikoiLdIrgav3BA+CbhaDAI= +github.com/tonistiigi/go-rosetta v0.0.0-20220804170347-3f4430f2d346/go.mod h1:xKQhd7snlzKFuUi1taTGWjpRE8iFTA06DeacYi3CVFQ= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.etcd.io/raft/v3 v3.6.0 h1:5NtvbDVYpnfZWcIHgGRk9DyzkBIXOi8j+DDp1IcnUWQ= +go.etcd.io/raft/v3 v3.6.0/go.mod h1:nLvLevg6+xrVtHUmVaTcTz603gQPHfh7kUAwV6YpfGo= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 h1:SUplec5dp06reu1zaXmOXdvqH398taqrDXqUl99jxSc= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0/go.mod h1:ho2g4N+ane+swq5I/VBkKWnRDY4kUINH3FuqyZqX/Ug= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA= +go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +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= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= +tags.cncf.io/container-device-interface v1.1.0 h1:RnxNhxF1JOu6CJUVpetTYvrXHdxw9j9jFYgZpI+anSY= +tags.cncf.io/container-device-interface v1.1.0/go.mod h1:76Oj0Yqp9FwTx/pySDc8Bxjpg+VqXfDb50cKAXVJ34Q= diff --git a/cmd/dockerflagsgen/main.go b/cmd/dockerflagsgen/main.go new file mode 100644 index 0000000..624bff4 --- /dev/null +++ b/cmd/dockerflagsgen/main.go @@ -0,0 +1,142 @@ +// Command dockerflagsgen writes the table of "docker run" flags that package dockerargs parses +// with, by building the command docker/cli builds and reading back the flags it registered. A table +// written by hand goes stale the moment Docker adds a flag, and it goes stale silently: decolint +// keeps parsing, just no longer the way Docker does. +// +// It is a module of its own so that decolint neither imports nor vendors docker/cli, and so that +// "go build", "go test" and the linter, which all work on the module rooted at the repository root, +// never reach it. Run it with "make dockerflags". +package main + +import ( + "flag" + "fmt" + "go/format" + "io" + "os" + "runtime/debug" + "sort" + "strings" + + "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/command/commands" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +const dockerCLIModule = "github.com/docker/cli" + +func main() { + out := flag.String("o", "", "write the table to this file instead of standard output") + flag.Parse() + + if err := run(*out); err != nil { + fmt.Fprintln(os.Stderr, "dockerflagsgen:", err) + os.Exit(1) + } +} + +// run writes the table to the file named out, creating it, or to standard output when out is empty. +func run(out string) (err error) { + if out == "" { + return generate(os.Stdout) + } + + f, err := os.Create(out) + if err != nil { + return fmt.Errorf("creating %s: %w", out, err) + } + defer func() { + if cerr := f.Close(); cerr != nil && err == nil { + err = fmt.Errorf("closing %s: %w", out, cerr) + } + }() + return generate(f) +} + +// generate writes the table to w. +func generate(w io.Writer) error { + flags, err := RunFlagSet() + if err != nil { + return err + } + src, err := format.Source([]byte(render(flags, dockerCLIVersion()))) + if err != nil { + return fmt.Errorf("formatting the generated table: %w", err) + } + if _, err := w.Write(src); err != nil { + return fmt.Errorf("writing the table: %w", err) + } + return nil +} + +// RunFlagSet returns the flags "docker run" registers, as docker/cli registers them: on the command +// its own command tree hands to cobra, rather than on a set assembled here that could drift from it. +func RunFlagSet() (*pflag.FlagSet, error) { + dockerCLI, err := command.NewDockerCli() + if err != nil { + return nil, fmt.Errorf("creating a docker CLI: %w", err) + } + root := &cobra.Command{Use: "docker"} + commands.AddCommands(root, dockerCLI) + for _, cmd := range root.Commands() { + if cmd.Name() == "run" { + return cmd.Flags(), nil + } + } + return nil, fmt.Errorf("docker/cli registered no %q command", "run") +} + +// dockerCLIVersion returns the version of docker/cli this binary was built against, or "" if the +// build carries no information about it. +func dockerCLIVersion() string { + info, ok := debug.ReadBuildInfo() + if !ok { + return "" + } + for _, dep := range info.Deps { + if dep.Path == dockerCLIModule { + return dep.Version + } + } + return "" +} + +func render(flags *pflag.FlagSet, version string) string { + var names []string + byName := map[string]*pflag.Flag{} + flags.VisitAll(func(f *pflag.Flag) { + names = append(names, f.Name) + byName[f.Name] = f + }) + sort.Strings(names) + + var b strings.Builder + b.WriteString("// Code generated by cmd/dockerflagsgen; DO NOT EDIT.\n\n") + b.WriteString("package dockerargs\n\n") + b.WriteString("// RunFlags is every flag \"docker run\" registers") + if version != "" { + fmt.Fprintf(&b, ", as of %s %s", dockerCLIModule, version) + } + b.WriteString(".\n") + b.WriteString("// It includes the hidden and deprecated ones, which Docker still parses.\n") + b.WriteString("//\n") + b.WriteString("// A flag is identified by its name, never by the spelling an argv uses: \"--net\" is not the\n") + b.WriteString("// short form of \"--network\" but a flag docker/cli registers separately, while \"-v\" is the\n") + b.WriteString("// shorthand of \"--volume\" and reaches the same entry here.\n") + b.WriteString("var RunFlags = []Flag{\n") + for _, name := range names { + f := byName[name] + fmt.Fprintf(&b, "\t{Name: %q", f.Name) + if f.Shorthand != "" { + fmt.Fprintf(&b, ", Shorthand: %q", f.Shorthand) + } + fmt.Fprintf(&b, ", Type: %q", f.Value.Type()) + if f.NoOptDefVal != "" { + fmt.Fprintf(&b, ", NoOptDefVal: %q", f.NoOptDefVal) + } + b.WriteString("},\n") + } + b.WriteString("}\n") + return b.String() +} diff --git a/cmd/dockerflagsgen/parse_test.go b/cmd/dockerflagsgen/parse_test.go new file mode 100644 index 0000000..9926f8e --- /dev/null +++ b/cmd/dockerflagsgen/parse_test.go @@ -0,0 +1,135 @@ +package main + +import ( + "fmt" + "io" + "math/rand/v2" + "strings" + "testing" + + "github.com/bare-devcontainer/decolint/dockerargs" + "github.com/spf13/pflag" +) + +// TestParse checks dockerargs.Parse against the parser it models, on argvs built to hit the entry +// forms and the adjacencies that tell the two apart. It lives in this module because decolint does +// not depend on pflag: it is the argument parser docker/cli happens to use, not a contract the +// linter should carry a dependency for. +// +// The argvs use only flags dockerargs.RunFlags names and never the "--" terminator, the two places +// Parse is documented to part from pflag. Both readings of an unrecognized flag are a deliberate +// deviation as well — pflag rejects the argv outright — and are covered by the package's own tests. +func TestParse(t *testing.T) { + const iterations = 20000 + rng := rand.New(rand.NewPCG(1, 2)) + + for i := range iterations { + argv := randomArgv(rng) + want, err := pflagSets(argv) + // Any other error stops pflag's parse partway, which would compare Parse against a + // truncated reading instead of a whole one. + if err != nil && !strings.Contains(err.Error(), "flag needs an argument") { + t.Fatalf("argv %d %q: pflag stopped early: %v", i, argv, err) + } + + var got []setCall + for _, arg := range dockerargs.Parse(argv) { + got = append(got, setCall{flag: arg.Flag, value: arg.Value}) + } + + if fmt.Sprint(got) != fmt.Sprint(want) { + t.Fatalf("argv %d %q:\n got %v\nwant %v", i, argv, got, want) + } + } +} + +// setCall is one call pflag makes on a flag's value while parsing an argv. +type setCall struct{ flag, value string } + +func (c setCall) String() string { return c.flag + "=" + c.value } + +// recorder is a pflag.Value that accepts every value and records the call. Recording is what the +// comparison needs, and accepting keeps the real value types' own validation — which decolint does +// not model — out of the picture. +type recorder struct { + flag string + typ string + calls *[]setCall +} + +func (r *recorder) String() string { return "" } +func (r *recorder) Type() string { return r.typ } + +func (r *recorder) Set(value string) error { + *r.calls = append(*r.calls, setCall{flag: r.flag, value: value}) + return nil +} + +// pflagSets parses argv with a pflag.FlagSet built from dockerargs.RunFlags and returns the values +// it assigned, in order. +// +// The set stays interspersed, unlike "docker run"'s: stopping at the first operand is the behavior +// Parse deliberately drops, so leaving it on here would report that deviation as a difference on +// every argv that has one. +func pflagSets(argv []string) ([]setCall, error) { + var calls []setCall + fs := pflag.NewFlagSet("run", pflag.ContinueOnError) + fs.SetOutput(io.Discard) + for _, f := range dockerargs.RunFlags { + fs.VarP(&recorder{flag: f.Name, typ: f.Type, calls: &calls}, f.Name, f.Shorthand, "") + fs.Lookup(f.Name).NoOptDefVal = f.NoOptDefVal + } + return calls, fs.Parse(argv) //nolint:wrapcheck // the caller only classifies the error. +} + +// values are the entries an argv can hold besides a flag: operands, and the strings that pose as a +// flag to a reader that does not know an earlier flag consumed them. +var values = []string{"", "x", "ALL", "=", "a=b", "-", "-v", "-itv", "--privileged", "--cap-drop=ALL"} + +func randomArgv(rng *rand.Rand) []string { + argv := make([]string, 0, 6) + for range rng.IntN(6) + 1 { + argv = append(argv, randomEntry(rng)) + } + return argv +} + +func randomEntry(rng *rand.Rand) string { + f := dockerargs.RunFlags[rng.IntN(len(dockerargs.RunFlags))] + value := values[rng.IntN(len(values))] + + forms := []string{ + "--" + f.Name, + "--" + f.Name + "=" + value, + value, + randomShorthands(rng), + } + if f.Shorthand != "" { + forms = append(forms, "-"+f.Shorthand) + if f.TakesValue() { + forms = append(forms, "-"+f.Shorthand+value, "-"+f.Shorthand+"="+value) + } else { + // Attaching anything else to a flag that takes no value leaves it to be read as more + // shorthands, so "-i--privileged" reaches pflag's unknown-shorthand error and ends its + // parse — one more place Parse deliberately reads on. + forms = append(forms, "-"+f.Shorthand+"=true", "-"+f.Shorthand+"=false") + } + } + return forms[rng.IntN(len(forms))] +} + +// randomShorthands returns a run of shorthands in a single entry, "-itv" and the like. +func randomShorthands(rng *rand.Rand) string { + var shorthands []string + for _, f := range dockerargs.RunFlags { + if f.Shorthand != "" { + shorthands = append(shorthands, f.Shorthand) + } + } + var b strings.Builder + b.WriteString("-") + for range rng.IntN(4) + 1 { + b.WriteString(shorthands[rng.IntN(len(shorthands))]) + } + return b.String() +} diff --git a/dockerargs/dockerargs.go b/dockerargs/dockerargs.go new file mode 100644 index 0000000..1ab5f07 --- /dev/null +++ b/dockerargs/dockerargs.go @@ -0,0 +1,217 @@ +// Package dockerargs reads a devcontainer.json "runArgs" array as what it becomes: the argv of the +// "docker run" command the devcontainer tooling builds. It is the single place that knows where a +// flag's value can be written, so a rule only has to know the values it cares about — a capability +// name, a mount, a "securityOpt" entry — and never which entry of the array holds one. +package dockerargs + +import ( + "strconv" + "strings" +) + +// Flag describes one flag "docker run" registers. The fields mirror pflag, whose parser docker/cli +// uses, closely enough that [Parse] can reproduce its reading of an argv. +type Flag struct { + // Name is the flag's canonical long name, without the leading "--". + Name string + // Shorthand is the flag's one-character short name, without the leading "-", or "" for a flag + // that has none. + Shorthand string + // Type names the kind of value the flag stores, e.g. "bool", "string" or "list". + Type string + // NoOptDefVal is the value the flag takes when written without one. A flag that requires a + // value has none, so an empty NoOptDefVal is how pflag tells the two kinds apart. + NoOptDefVal string +} + +// TakesValue reports whether the flag has to be given a value, either in its own argv entry or by +// consuming the entry that follows. +func (f Flag) TakesValue() bool { return f.NoOptDefVal == "" } + +// unknownNoOptDefVal is the value [Parse] gives a flag missing from [RunFlags] when it reads it as +// taking none. Every "docker run" flag that takes no value is a boolean that defaults to this, and +// a flag Docker has added since the table was generated is overwhelmingly likely to be one too. +const unknownNoOptDefVal = "true" + +// Arg is one flag occurrence in an argv. +type Arg struct { + // Flag is the flag's canonical long name, without the leading "--". Every spelling of a flag + // reduces to it, so "-v", "--volume=x" and "--volume x" all yield "volume". + Flag string + // Value is what the argv gives the flag, which for a flag that takes no value is the value it + // stands for on its own — see [Flag.NoOptDefVal]. + Value string + // Index is the argv position of the entry Value was read from. That is the flag's own entry + // whenever it carries the value ("--volume=x", "-vx", a bare "--privileged"), and the following + // entry otherwise. + Index int +} + +var ( + // flagsByName indexes [RunFlags] by long name. + flagsByName = func() map[string]Flag { + m := make(map[string]Flag, len(RunFlags)) + for _, f := range RunFlags { + m[f.Name] = f + } + return m + }() + + // flagsByShorthand indexes the entries of [RunFlags] that have a shorthand by that shorthand. + flagsByShorthand = func() map[byte]Flag { + m := make(map[byte]Flag) + for _, f := range RunFlags { + if f.Shorthand != "" { + m[f.Shorthand[0]] = f + } + } + return m + }() +) + +// Parse returns every flag occurrence in argv, a "docker run" command line, ordered by the argv +// position each value was read from. It recognizes the entry forms pflag does: +// +// - a value written in the flag's own entry, as "--flag=value", "-fvalue" or "-f=value"; +// - a flag that takes no value written bare, as "--flag" or "-f"; +// - a flag that takes one written bare, consuming the entry that follows; +// - a run of shorthands in one entry, "-itv", ending at the first one that takes a value. +// +// An entry consumed as a value never names a flag itself. +// +// A flag missing from [RunFlags] is read both ways, as taking no value and as consuming the entry +// that follows: the table can only be older than Docker, never newer. Reading both costs at worst a +// finding Docker would not have seen, where trusting one reading would drop findings silently, on +// exactly the configurations a newly added flag appears in. An unrecognized shorthand names no flag +// to report, so it yields no Arg at all — only the two readings of the entries around it. +// +// Parse deliberately parts from Docker in two places, both of which stop Docker's parse where they +// appear: the "--" terminator, and the image name that ends the flags. It reads on instead. A +// "runArgs" holding either is already broken — it is spliced into an argv that goes on to name the +// image and the flags the devcontainer tooling adds itself, which the entry would displace — so +// reporting what the array says is more use to its author than falling silent on all of it. +func Parse(argv []string) []Arg { + p := parser{argv: argv, starts: make([]bool, len(argv)+2)} + p.starts[0] = true + for i, s := range argv { + if !p.starts[i] { + continue + } + switch { + case strings.HasPrefix(s, "--"): + p.parseLong(i) + case len(s) > 1 && s[0] == '-': + p.parseShorthands(i) + default: + p.starts[i+1] = true + } + } + return p.args +} + +type parser struct { + argv []string + // starts[i] reports whether any reading of the argv reaches argv[i] as an entry of its own, + // rather than as a value some earlier flag consumed. It has two entries of slack so that + // consuming the last entry needs no bounds check. + starts []bool + args []Arg +} + +// parseLong reads argv[i], which starts with "--", as a long flag. +func (p *parser) parseLong(i int) { + name, value, hasValue := strings.Cut(p.argv[i][2:], "=") + if name == "" || name[0] == '-' { + // "--", "--=x" and "---x" name no flag: pflag rejects the first as the end of the flags and + // the others as malformed. Reading them as operands leaves the rest of the argv readable. + p.starts[i+1] = true + return + } + + f, known := flagsByName[name] + switch { + case hasValue: + p.emit(name, value, i) + p.starts[i+1] = true + case known && !f.TakesValue(): + p.emit(name, f.NoOptDefVal, i) + p.starts[i+1] = true + case known: + p.consumeNext(name, i) + default: + p.emit(name, unknownNoOptDefVal, i) + p.starts[i+1] = true + p.consumeNext(name, i) + } +} + +// parseShorthands reads argv[i], which starts with a single "-", as a run of shorthands. +func (p *parser) parseShorthands(i int) { + s := p.argv[i][1:] + // reached[j] is starts for the run: whether any reading gets as far as s[j] still looking for a + // shorthand. Its extra entry marks the run ending without consuming anything else. + reached := make([]bool, len(s)+1) + reached[0] = true + + for j := range len(s) { + if !reached[j] { + continue + } + f, known := flagsByShorthand[s[j]] + rest := s[j+1:] + switch { + case len(rest) > 1 && rest[0] == '=': + // "-f=value". A lone "=" is not this form but an ordinary one-character value. + p.emit(f.Name, rest[1:], i) + p.starts[i+1] = true + case known && !f.TakesValue(): + p.emit(f.Name, f.NoOptDefVal, i) + reached[j+1] = true + case known: + p.consumeRest(f.Name, rest, i) + default: + reached[j+1] = true + p.consumeRest("", rest, i) + } + } + + if reached[len(s)] { + p.starts[i+1] = true + } +} + +// consumeNext gives flag the entry after argv[i] as its value. +func (p *parser) consumeNext(flag string, i int) { + if i+1 >= len(p.argv) { + return // Docker rejects the argv outright; there is no value to report. + } + p.emit(flag, p.argv[i+1], i+1) + p.starts[i+2] = true +} + +// consumeRest gives flag the value a shorthand ending the run in argv[i] takes: rest, what is left +// of the run, or the entry after it when the run ends there. +func (p *parser) consumeRest(flag, rest string, i int) { + if rest == "" { + p.consumeNext(flag, i) + return + } + p.emit(flag, rest, i) + p.starts[i+1] = true +} + +// emit records an occurrence of flag, or nothing at all for the "" of an unrecognized shorthand. +func (p *parser) emit(flag, value string, i int) { + if flag == "" { + return + } + p.args = append(p.args, Arg{Flag: flag, Value: value, Index: i}) +} + +// IsTrue reports whether value turns on the boolean flag it was written for. Docker reads it with +// [strconv.ParseBool] and refuses to start the container on anything else; decolint reads anything +// else as turning the flag on, since the argv is already broken and the flag was plainly asked for. +func IsTrue(value string) bool { + on, err := strconv.ParseBool(value) + return err != nil || on +} diff --git a/dockerargs/dockerargs_test.go b/dockerargs/dockerargs_test.go new file mode 100644 index 0000000..542b607 --- /dev/null +++ b/dockerargs/dockerargs_test.go @@ -0,0 +1,167 @@ +package dockerargs + +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestParse(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + argv []string + want []Arg + }{ + {"empty", nil, nil}, + {"operand only", []string{"ubuntu"}, nil}, + + {"long flag with a joined value", []string{"--cap-drop=ALL"}, []Arg{ + {Flag: "cap-drop", Value: "ALL", Index: 0}, + }}, + {"long flag consuming the next entry", []string{"--cap-drop", "ALL"}, []Arg{ + {Flag: "cap-drop", Value: "ALL", Index: 1}, + }}, + {"long flag with an empty joined value", []string{"--cap-drop="}, []Arg{ + {Flag: "cap-drop", Value: "", Index: 0}, + }}, + {"long flag whose value holds an equals sign", []string{"--security-opt=seccomp=unconfined"}, []Arg{ + {Flag: "security-opt", Value: "seccomp=unconfined", Index: 0}, + }}, + {"long flag taking a value with nothing left to consume", []string{"--cap-drop"}, nil}, + + {"boolean flag written bare", []string{"--privileged"}, []Arg{ + {Flag: "privileged", Value: "true", Index: 0}, + }}, + {"boolean flag written with a value", []string{"--privileged=true"}, []Arg{ + {Flag: "privileged", Value: "true", Index: 0}, + }}, + {"boolean flag turned off", []string{"--privileged=false"}, []Arg{ + {Flag: "privileged", Value: "false", Index: 0}, + }}, + {"boolean flag does not consume the next entry", []string{"--privileged", "--cap-drop=ALL"}, []Arg{ + {Flag: "privileged", Value: "true", Index: 0}, + {Flag: "cap-drop", Value: "ALL", Index: 1}, + }}, + + {"consumed entry names no flag", []string{"--label", "--cap-drop=ALL"}, []Arg{ + {Flag: "label", Value: "--cap-drop=ALL", Index: 1}, + }}, + {"entry after a consumed one names a flag again", []string{"--label", "x", "--privileged"}, []Arg{ + {Flag: "label", Value: "x", Index: 1}, + {Flag: "privileged", Value: "true", Index: 2}, + }}, + + {"shorthand with a joined value", []string{"-v/var/run/docker.sock:/x"}, []Arg{ + {Flag: "volume", Value: "/var/run/docker.sock:/x", Index: 0}, + }}, + {"shorthand consuming the next entry", []string{"-v", "/a:/b"}, []Arg{ + {Flag: "volume", Value: "/a:/b", Index: 1}, + }}, + {"shorthand with an equals-separated value", []string{"-v=/a:/b"}, []Arg{ + {Flag: "volume", Value: "/a:/b", Index: 0}, + }}, + {"lone equals is an ordinary shorthand value", []string{"-v="}, []Arg{ + {Flag: "volume", Value: "=", Index: 0}, + }}, + {"run of shorthands ending in one that takes a value", []string{"-itv", "/var/run/docker.sock:/x"}, []Arg{ + {Flag: "interactive", Value: "true", Index: 0}, + {Flag: "tty", Value: "true", Index: 0}, + {Flag: "volume", Value: "/var/run/docker.sock:/x", Index: 1}, + }}, + {"run of shorthands ending in a joined value", []string{"-itv/a:/b"}, []Arg{ + {Flag: "interactive", Value: "true", Index: 0}, + {Flag: "tty", Value: "true", Index: 0}, + {Flag: "volume", Value: "/a:/b", Index: 0}, + }}, + {"run of boolean shorthands", []string{"-it"}, []Arg{ + {Flag: "interactive", Value: "true", Index: 0}, + {Flag: "tty", Value: "true", Index: 0}, + }}, + {"shorthand turned off", []string{"-t=false"}, []Arg{ + {Flag: "tty", Value: "false", Index: 0}, + }}, + + {"a flag is its name, not its spelling", []string{"-v", "/a:/b", "--volume=/c:/d"}, []Arg{ + {Flag: "volume", Value: "/a:/b", Index: 1}, + {Flag: "volume", Value: "/c:/d", Index: 2}, + }}, + {"net is its own flag, not a short form of network", []string{"--net=host", "--network=none"}, []Arg{ + {Flag: "net", Value: "host", Index: 0}, + {Flag: "network", Value: "none", Index: 1}, + }}, + + {"unrecognized long flag is read both ways", []string{"--not-a-flag", "--privileged"}, []Arg{ + {Flag: "not-a-flag", Value: "true", Index: 0}, + {Flag: "not-a-flag", Value: "--privileged", Index: 1}, + {Flag: "privileged", Value: "true", Index: 1}, + }}, + {"unrecognized long flag with a joined value is unambiguous", []string{"--not-a-flag=x", "--privileged"}, []Arg{ + {Flag: "not-a-flag", Value: "x", Index: 0}, + {Flag: "privileged", Value: "true", Index: 1}, + }}, + {"unrecognized shorthand names no flag but is read both ways", []string{"-Z", "--privileged"}, []Arg{ + {Flag: "privileged", Value: "true", Index: 1}, + }}, + {"unrecognized shorthand does not hide the rest of its run", []string{"-Zv", "/a:/b"}, []Arg{ + {Flag: "volume", Value: "/a:/b", Index: 1}, + }}, + {"unrecognized shorthand joined to a value", []string{"-Zv/a:/b"}, []Arg{ + {Flag: "volume", Value: "/a:/b", Index: 0}, + }}, + + {"parsing continues past the image name", []string{"ubuntu", "--privileged"}, []Arg{ + {Flag: "privileged", Value: "true", Index: 1}, + }}, + {"parsing continues past a terminator", []string{"--", "--privileged"}, []Arg{ + {Flag: "privileged", Value: "true", Index: 1}, + }}, + + {"a lone dash is an operand", []string{"-", "--privileged"}, []Arg{ + {Flag: "privileged", Value: "true", Index: 1}, + }}, + {"an empty entry is an operand", []string{"", "--privileged"}, []Arg{ + {Flag: "privileged", Value: "true", Index: 1}, + }}, + {"a malformed long flag names none", []string{"---privileged", "--=x", "--privileged"}, []Arg{ + {Flag: "privileged", Value: "true", Index: 2}, + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if diff := cmp.Diff(tt.want, Parse(tt.argv)); diff != "" { + t.Errorf("Parse(%q) mismatch (-want +got):\n%s", tt.argv, diff) + } + }) + } +} + +func TestIsTrue(t *testing.T) { + t.Parallel() + + tests := []struct { + value string + want bool + }{ + {"true", true}, + {"TRUE", true}, + {"1", true}, + {"t", true}, + {"false", false}, + {"FALSE", false}, + {"0", false}, + {"f", false}, + {"", true}, + {"yes", true}, + } + for _, tt := range tests { + t.Run(tt.value, func(t *testing.T) { + t.Parallel() + if got := IsTrue(tt.value); got != tt.want { + t.Errorf("IsTrue(%q) = %v, want %v", tt.value, got, tt.want) + } + }) + } +} diff --git a/dockerargs/runflags.go b/dockerargs/runflags.go new file mode 100644 index 0000000..3b8cc47 --- /dev/null +++ b/dockerargs/runflags.go @@ -0,0 +1,119 @@ +// Code generated by cmd/dockerflagsgen; DO NOT EDIT. + +package dockerargs + +// RunFlags is every flag "docker run" registers, as of github.com/docker/cli v29.7.1+incompatible. +// It includes the hidden and deprecated ones, which Docker still parses. +// +// A flag is identified by its name, never by the spelling an argv uses: "--net" is not the +// short form of "--network" but a flag docker/cli registers separately, while "-v" is the +// shorthand of "--volume" and reaches the same entry here. +var RunFlags = []Flag{ + {Name: "add-host", Type: "list"}, + {Name: "annotation", Type: "map"}, + {Name: "attach", Shorthand: "a", Type: "list"}, + {Name: "blkio-weight", Type: "uint16"}, + {Name: "blkio-weight-device", Type: "list"}, + {Name: "cap-add", Type: "list"}, + {Name: "cap-drop", Type: "list"}, + {Name: "cgroup-parent", Type: "string"}, + {Name: "cgroupns", Type: "string"}, + {Name: "cidfile", Type: "string"}, + {Name: "cpu-count", Type: "int64"}, + {Name: "cpu-percent", Type: "int64"}, + {Name: "cpu-period", Type: "int64"}, + {Name: "cpu-quota", Type: "int64"}, + {Name: "cpu-rt-period", Type: "int64"}, + {Name: "cpu-rt-runtime", Type: "int64"}, + {Name: "cpu-shares", Shorthand: "c", Type: "int64"}, + {Name: "cpus", Type: "decimal"}, + {Name: "cpuset-cpus", Type: "string"}, + {Name: "cpuset-mems", Type: "string"}, + {Name: "detach", Shorthand: "d", Type: "bool", NoOptDefVal: "true"}, + {Name: "detach-keys", Type: "string"}, + {Name: "device", Type: "list"}, + {Name: "device-cgroup-rule", Type: "list"}, + {Name: "device-read-bps", Type: "list"}, + {Name: "device-read-iops", Type: "list"}, + {Name: "device-write-bps", Type: "list"}, + {Name: "device-write-iops", Type: "list"}, + {Name: "disable-content-trust", Type: "bool", NoOptDefVal: "true"}, + {Name: "dns", Type: "list"}, + {Name: "dns-opt", Type: "list"}, + {Name: "dns-option", Type: "list"}, + {Name: "dns-search", Type: "list"}, + {Name: "domainname", Type: "string"}, + {Name: "entrypoint", Type: "string"}, + {Name: "env", Shorthand: "e", Type: "list"}, + {Name: "env-file", Type: "list"}, + {Name: "expose", Type: "list"}, + {Name: "gpus", Type: "gpu-request"}, + {Name: "group-add", Type: "list"}, + {Name: "health-cmd", Type: "string"}, + {Name: "health-interval", Type: "duration"}, + {Name: "health-retries", Type: "int"}, + {Name: "health-start-interval", Type: "duration"}, + {Name: "health-start-period", Type: "duration"}, + {Name: "health-timeout", Type: "duration"}, + {Name: "help", Type: "bool", NoOptDefVal: "true"}, + {Name: "hostname", Shorthand: "h", Type: "string"}, + {Name: "init", Type: "bool", NoOptDefVal: "true"}, + {Name: "interactive", Shorthand: "i", Type: "bool", NoOptDefVal: "true"}, + {Name: "io-maxbandwidth", Type: "bytes"}, + {Name: "io-maxiops", Type: "uint64"}, + {Name: "ip", Type: "ip"}, + {Name: "ip6", Type: "ip"}, + {Name: "ipc", Type: "string"}, + {Name: "isolation", Type: "string"}, + {Name: "kernel-memory", Type: "bytes"}, + {Name: "label", Shorthand: "l", Type: "list"}, + {Name: "label-file", Type: "list"}, + {Name: "link", Type: "list"}, + {Name: "link-local-ip", Type: "list"}, + {Name: "log-driver", Type: "string"}, + {Name: "log-opt", Type: "list"}, + {Name: "mac-address", Type: "string"}, + {Name: "memory", Shorthand: "m", Type: "bytes"}, + {Name: "memory-reservation", Type: "bytes"}, + {Name: "memory-swap", Type: "bytes"}, + {Name: "memory-swappiness", Type: "int64"}, + {Name: "mount", Type: "mount"}, + {Name: "name", Type: "string"}, + {Name: "net", Type: "network"}, + {Name: "net-alias", Type: "list"}, + {Name: "network", Type: "network"}, + {Name: "network-alias", Type: "list"}, + {Name: "no-healthcheck", Type: "bool", NoOptDefVal: "true"}, + {Name: "oom-kill-disable", Type: "bool", NoOptDefVal: "true"}, + {Name: "oom-score-adj", Type: "int"}, + {Name: "pid", Type: "string"}, + {Name: "pids-limit", Type: "int64"}, + {Name: "platform", Type: "string"}, + {Name: "privileged", Type: "bool", NoOptDefVal: "true"}, + {Name: "publish", Shorthand: "p", Type: "list"}, + {Name: "publish-all", Shorthand: "P", Type: "bool", NoOptDefVal: "true"}, + {Name: "pull", Type: "string"}, + {Name: "quiet", Shorthand: "q", Type: "bool", NoOptDefVal: "true"}, + {Name: "read-only", Type: "bool", NoOptDefVal: "true"}, + {Name: "restart", Type: "string"}, + {Name: "rm", Type: "bool", NoOptDefVal: "true"}, + {Name: "runtime", Type: "string"}, + {Name: "security-opt", Type: "list"}, + {Name: "shm-size", Type: "bytes"}, + {Name: "sig-proxy", Type: "bool", NoOptDefVal: "true"}, + {Name: "stop-signal", Type: "string"}, + {Name: "stop-timeout", Type: "int"}, + {Name: "storage-opt", Type: "list"}, + {Name: "sysctl", Type: "map"}, + {Name: "tmpfs", Type: "list"}, + {Name: "tty", Shorthand: "t", Type: "bool", NoOptDefVal: "true"}, + {Name: "ulimit", Type: "ulimit"}, + {Name: "use-api-socket", Type: "bool", NoOptDefVal: "true"}, + {Name: "user", Shorthand: "u", Type: "string"}, + {Name: "userns", Type: "string"}, + {Name: "uts", Type: "string"}, + {Name: "volume", Shorthand: "v", Type: "list"}, + {Name: "volume-driver", Type: "string"}, + {Name: "volumes-from", Type: "list"}, + {Name: "workdir", Shorthand: "w", Type: "string"}, +} diff --git a/dockerargs/runflags_test.go b/dockerargs/runflags_test.go new file mode 100644 index 0000000..4eb6081 --- /dev/null +++ b/dockerargs/runflags_test.go @@ -0,0 +1,176 @@ +package dockerargs + +import ( + "slices" + "testing" + + "github.com/google/go-cmp/cmp" +) + +// wantRunFlags is every flag "docker run" registers, written out here so that regenerating +// [RunFlags] against a newer docker/cli fails this test rather than quietly changing which entry of +// a "runArgs" array a rule reads a value from. Each line is +// "name[/shorthand] type[=value-when-written-bare]"; a line without a value is a flag that takes +// one, from the entry it is written in or from the entry that follows. +var wantRunFlags = []string{ + "add-host list", + "annotation map", + "attach/a list", + "blkio-weight uint16", + "blkio-weight-device list", + "cap-add list", + "cap-drop list", + "cgroup-parent string", + "cgroupns string", + "cidfile string", + "cpu-count int64", + "cpu-percent int64", + "cpu-period int64", + "cpu-quota int64", + "cpu-rt-period int64", + "cpu-rt-runtime int64", + "cpu-shares/c int64", + "cpus decimal", + "cpuset-cpus string", + "cpuset-mems string", + "detach/d bool=true", + "detach-keys string", + "device list", + "device-cgroup-rule list", + "device-read-bps list", + "device-read-iops list", + "device-write-bps list", + "device-write-iops list", + "disable-content-trust bool=true", + "dns list", + "dns-opt list", + "dns-option list", + "dns-search list", + "domainname string", + "entrypoint string", + "env/e list", + "env-file list", + "expose list", + "gpus gpu-request", + "group-add list", + "health-cmd string", + "health-interval duration", + "health-retries int", + "health-start-interval duration", + "health-start-period duration", + "health-timeout duration", + "help bool=true", + "hostname/h string", + "init bool=true", + "interactive/i bool=true", + "io-maxbandwidth bytes", + "io-maxiops uint64", + "ip ip", + "ip6 ip", + "ipc string", + "isolation string", + "kernel-memory bytes", + "label/l list", + "label-file list", + "link list", + "link-local-ip list", + "log-driver string", + "log-opt list", + "mac-address string", + "memory/m bytes", + "memory-reservation bytes", + "memory-swap bytes", + "memory-swappiness int64", + "mount mount", + "name string", + "net network", + "net-alias list", + "network network", + "network-alias list", + "no-healthcheck bool=true", + "oom-kill-disable bool=true", + "oom-score-adj int", + "pid string", + "pids-limit int64", + "platform string", + "privileged bool=true", + "publish/p list", + "publish-all/P bool=true", + "pull string", + "quiet/q bool=true", + "read-only bool=true", + "restart string", + "rm bool=true", + "runtime string", + "security-opt list", + "shm-size bytes", + "sig-proxy bool=true", + "stop-signal string", + "stop-timeout int", + "storage-opt list", + "sysctl map", + "tmpfs list", + "tty/t bool=true", + "ulimit ulimit", + "use-api-socket bool=true", + "user/u string", + "userns string", + "uts string", + "volume/v list", + "volume-driver string", + "volumes-from list", + "workdir/w string", +} + +func TestRunFlags(t *testing.T) { + var got []string + for _, f := range RunFlags { + s := f.Name + if f.Shorthand != "" { + s += "/" + f.Shorthand + } + s += " " + f.Type + if f.NoOptDefVal != "" { + s += "=" + f.NoOptDefVal + } + got = append(got, s) + } + if diff := cmp.Diff(wantRunFlags, got); diff != "" { + t.Errorf("RunFlags mismatch (-want +got):\n%s", diff) + } +} + +// TestRunFlags_Unique checks the assumption [Parse] indexes the table on: that a name and a +// shorthand each reach one flag. +func TestRunFlags_Unique(t *testing.T) { + names := map[string]bool{} + shorthands := map[string]bool{} + for _, f := range RunFlags { + if names[f.Name] { + t.Errorf("two flags named %q", f.Name) + } + names[f.Name] = true + if f.Shorthand == "" { + continue + } + if len(f.Shorthand) != 1 { + t.Errorf("flag %q has a %d-character shorthand %q", f.Name, len(f.Shorthand), f.Shorthand) + } + if shorthands[f.Shorthand] { + t.Errorf("two flags share the shorthand %q", f.Shorthand) + } + shorthands[f.Shorthand] = true + } +} + +// TestRunFlags_Sorted checks that the generated table is sorted by name, so that a docker/cli +// release shows up as the flags it changed rather than as a reordering of all of them. +func TestRunFlags_Sorted(t *testing.T) { + names := make([]string, len(RunFlags)) + for i, f := range RunFlags { + names[i] = f.Name + } + if !slices.IsSorted(names) { + t.Errorf("RunFlags is not sorted by name: %v", names) + } +} diff --git a/rules/no_cap_add_all.go b/rules/no_cap_add_all.go index 95bf9f5..823d4b9 100644 --- a/rules/no_cap_add_all.go +++ b/rules/no_cap_add_all.go @@ -52,7 +52,7 @@ func checkNoCapAddAll(ctx *linter.Context, node *linter.Node) []linter.Finding { if !ok || !runArgsApplicable(ctx) { return nil } - v := runArgsFindFlagValue(arr, "--cap-add", func(s string) bool { return s == "ALL" }) + v := runArgsFindFlagValue(arr, "cap-add", isAllCapability) if v == nil { return nil } diff --git a/rules/no_cap_add_all_test.go b/rules/no_cap_add_all_test.go index 6b1508b..9b15975 100644 --- a/rules/no_cap_add_all_test.go +++ b/rules/no_cap_add_all_test.go @@ -32,6 +32,11 @@ func TestNoCapAddAll(t *testing.T) { Message: `"runArgs" contains "--cap-add=ALL", granting every Linux capability to the container`}, }}, {"runArgs with cap-drop ALL is not cap-add", `{"runArgs": ["--cap-drop", "ALL"]}`, nil}, + {"runArgs with lower-case all", `{"runArgs": ["--cap-add=all"]}`, []linter.Issue{ + {Path: "devcontainer.json", Line: 1, Col: 14, RuleID: "no-cap-add-all", + Message: `"runArgs" contains "--cap-add=ALL", granting every Linux capability to the container`}, + }}, + {"runArgs with cap-add consumed as another flag's value", `{"runArgs": ["--label", "--cap-add=ALL"]}`, nil}, {"runArgs with non-string entry before cap-add=ALL", `{"runArgs": [123, "--cap-add=ALL"]}`, []linter.Issue{ {Path: "devcontainer.json", Line: 1, Col: 19, RuleID: "no-cap-add-all", Message: `"runArgs" contains "--cap-add=ALL", granting every Linux capability to the container`}, diff --git a/rules/no_docker_socket_mount.go b/rules/no_docker_socket_mount.go index cdfba49..7771f30 100644 --- a/rules/no_docker_socket_mount.go +++ b/rules/no_docker_socket_mount.go @@ -75,15 +75,13 @@ func checkDockerSocketMount(node *linter.Node) []linter.Finding { // dockerSocketRunArgFlags are the "runArgs" flags that can mount a host path, each paired with the // reader for its own value syntax. The two syntaxes are unrelated, so a value must be read only as -// the flag introducing it, which is why this rule inspects the whole "runArgs" array rather than its -// entries one by one. +// the flag introducing it. var dockerSocketRunArgFlags = []struct { flag string source func(string) string }{ - {"--mount", func(s string) string { _, source := parseMountString(s); return source }}, - {"--volume", volumeSpecSource}, - {"-v", volumeSpecSource}, + {"mount", func(s string) string { _, source := parseMountString(s); return source }}, + {"volume", volumeSpecSource}, } func checkDockerSocketRunArgs(node *linter.Node) []linter.Finding { diff --git a/rules/no_docker_socket_mount_test.go b/rules/no_docker_socket_mount_test.go index 0ab54aa..60c1ae7 100644 --- a/rules/no_docker_socket_mount_test.go +++ b/rules/no_docker_socket_mount_test.go @@ -100,6 +100,15 @@ func TestNoDockerSocketMount(t *testing.T) { {Path: "devcontainer.json", Line: 1, Col: 53, RuleID: "no-docker-socket-mount", Message: `"runArgs" bind-mounts the Docker socket, which grants the container root-equivalent control over the host`}, }}, + {"runArgs -v with a joined value", `{"runArgs": ["-v/var/run/docker.sock:/x"]}`, []linter.Issue{ + {Path: "devcontainer.json", Line: 1, Col: 14, RuleID: "no-docker-socket-mount", + Message: `"runArgs" bind-mounts the Docker socket, which grants the container root-equivalent control over the host`}, + }}, + {"runArgs -v ending a run of shorthands", `{"runArgs": ["-itv", "/var/run/docker.sock:/x"]}`, []linter.Issue{ + {Path: "devcontainer.json", Line: 1, Col: 22, RuleID: "no-docker-socket-mount", + Message: `"runArgs" bind-mounts the Docker socket, which grants the container root-equivalent control over the host`}, + }}, + {"runArgs -v consumed as another flag's value", `{"runArgs": ["--label", "-v", "/var/run/docker.sock:/x"]}`, nil}, {"runArgs unrelated volume", `{"runArgs": ["-v", "/host/docker.sock:/var/run/docker.sock"]}`, nil}, // A -v value of a single field is an anonymous volume, and that field is the container path: // nothing from the host is bound. diff --git a/rules/no_privileged_container.go b/rules/no_privileged_container.go index f67cd91..89fb647 100644 --- a/rules/no_privileged_container.go +++ b/rules/no_privileged_container.go @@ -1,6 +1,7 @@ package rules import ( + "github.com/bare-devcontainer/decolint/dockerargs" "github.com/bare-devcontainer/decolint/linter" "github.com/tailscale/hujson" ) @@ -23,7 +24,7 @@ capabilities and devices the workload needs, is a far narrower grant.`, }, Category: linter.CategorySecurity, FileTypes: []linter.FileType{linter.Devcontainer, linter.Feature}, - Paths: []string{"/privileged", "/runArgs/*"}, + Paths: []string{"/privileged", "/runArgs"}, Example: linter.Example{ Bad: linter.Snippet{ Files: []linter.ExampleFile{ @@ -52,27 +53,27 @@ nested containers.`, } func checkNoPrivilegedContainer(ctx *linter.Context, node *linter.Node) []linter.Finding { - lit, ok := node.Value.Value.(hujson.Literal) - if !ok { - return nil - } - - switch node.Pointer { - case "/privileged": - if lit.Kind() != 't' { + if node.Pointer == "/runArgs" { + arr, ok := node.Value.Value.(*hujson.Array) + if !ok || !runArgsApplicable(ctx) { return nil } - return []linter.Finding{{ - Message: `"privileged" is set to true, disabling the container's isolation from the host`, - Offset: node.Value.StartOffset, - }} - default: - if !runArgsApplicable(ctx) || lit.Kind() != '"' || lit.String() != "--privileged" { + v := runArgsFindFlagValue(arr, "privileged", dockerargs.IsTrue) + if v == nil { return nil } return []linter.Finding{{ Message: `"runArgs" contains "--privileged", disabling the container's isolation from the host`, - Offset: node.Value.StartOffset, + Offset: v.StartOffset, }} } + + lit, ok := node.Value.Value.(hujson.Literal) + if !ok || lit.Kind() != 't' { + return nil + } + return []linter.Finding{{ + Message: `"privileged" is set to true, disabling the container's isolation from the host`, + Offset: node.Value.StartOffset, + }} } diff --git a/rules/no_privileged_container_test.go b/rules/no_privileged_container_test.go index face7ea..8bdf519 100644 --- a/rules/no_privileged_container_test.go +++ b/rules/no_privileged_container_test.go @@ -29,6 +29,12 @@ func TestNoPrivilegedContainer(t *testing.T) { {Path: "devcontainer.json", Line: 1, Col: 24, RuleID: "no-privileged-container", Message: `"runArgs" contains "--privileged", disabling the container's isolation from the host`}, }}, + {"runArgs with privileged set to true", `{"runArgs": ["--privileged=true"]}`, []linter.Issue{ + {Path: "devcontainer.json", Line: 1, Col: 14, RuleID: "no-privileged-container", + Message: `"runArgs" contains "--privileged", disabling the container's isolation from the host`}, + }}, + {"runArgs with privileged set to false", `{"runArgs": ["--privileged=false"]}`, nil}, + {"runArgs with privileged consumed as another flag's value", `{"runArgs": ["--label", "--privileged"]}`, nil}, {"both privileged and runArgs flag", `{"privileged": true, "runArgs": ["--privileged"]}`, []linter.Issue{ {Path: "devcontainer.json", Line: 1, Col: 16, RuleID: "no-privileged-container", Message: `"privileged" is set to true, disabling the container's isolation from the host`}, diff --git a/rules/no_seccomp_override.go b/rules/no_seccomp_override.go index 40e23bc..400ba29 100644 --- a/rules/no_seccomp_override.go +++ b/rules/no_seccomp_override.go @@ -27,7 +27,7 @@ does.`, }, Category: linter.CategorySecurity, FileTypes: []linter.FileType{linter.Devcontainer, linter.Feature}, - Paths: []string{"/securityOpt/*", "/runArgs/*"}, + Paths: []string{"/securityOpt/*", "/runArgs"}, Example: linter.Example{ Bad: linter.Snippet{ Files: []linter.ExampleFile{ @@ -54,33 +54,33 @@ which already allows what a development container normally does.`, } func checkNoSeccompOverride(ctx *linter.Context, node *linter.Node) []linter.Finding { - lit, ok := node.Value.Value.(hujson.Literal) - if !ok || lit.Kind() != '"' { - return nil - } - - if strings.HasPrefix(node.Pointer, "/securityOpt/") { - if !strings.HasPrefix(lit.String(), "seccomp=") { + if node.Pointer == "/runArgs" { + arr, ok := node.Value.Value.(*hujson.Array) + if !ok || !runArgsApplicable(ctx) { + return nil + } + v := runArgsFindFlagValue(arr, "security-opt", securityOptOverridesSeccomp) + if v == nil { return nil } return []linter.Finding{{ - Message: `"securityOpt" overrides the default seccomp profile`, - Offset: node.Value.StartOffset, + Message: `"runArgs" overrides the default seccomp profile via "--security-opt"`, + Offset: v.StartOffset, }} } - if !runArgsApplicable(ctx) || !runArgOverridesSeccomp(lit.String()) { + lit, ok := node.Value.Value.(hujson.Literal) + if !ok || lit.Kind() != '"' || !securityOptOverridesSeccomp(lit.String()) { return nil } return []linter.Finding{{ - Message: `"runArgs" overrides the default seccomp profile via "--security-opt"`, + Message: `"securityOpt" overrides the default seccomp profile`, Offset: node.Value.StartOffset, }} } -// runArgOverridesSeccomp reports whether s, a single "runArgs" entry, overrides the default seccomp -// profile. It recognizes a combined "--security-opt=seccomp=..." entry as well as the bare -// "seccomp=..." value that follows a separate "--security-opt" entry. -func runArgOverridesSeccomp(s string) bool { - return strings.HasPrefix(strings.TrimPrefix(s, "--security-opt="), "seccomp=") +// securityOptOverridesSeccomp reports whether s, a single "securityOpt" entry, points seccomp at a +// profile of its own. +func securityOptOverridesSeccomp(s string) bool { + return strings.HasPrefix(s, "seccomp=") } diff --git a/rules/no_seccomp_override_test.go b/rules/no_seccomp_override_test.go index 2550594..b96347e 100644 --- a/rules/no_seccomp_override_test.go +++ b/rules/no_seccomp_override_test.go @@ -40,6 +40,8 @@ func TestNoSeccompOverride(t *testing.T) { {Path: "devcontainer.json", Line: 1, Col: 14, RuleID: "no-seccomp-override", Message: `"runArgs" overrides the default seccomp profile via "--security-opt"`}, }}, + {"runArgs security-opt consumed as another flag's value", `{"runArgs": ["--label", "--security-opt=seccomp=unconfined"]}`, nil}, + {"runArgs bare seccomp entry names no flag", `{"runArgs": ["seccomp=unconfined"]}`, nil}, {"runArgs with custom seccomp profile", `{"runArgs": ["--security-opt", "seccomp=/path/to/profile.json"]}`, []linter.Issue{ {Path: "devcontainer.json", Line: 1, Col: 32, RuleID: "no-seccomp-override", Message: `"runArgs" overrides the default seccomp profile via "--security-opt"`}, diff --git a/rules/no_seccomp_unconfined.go b/rules/no_seccomp_unconfined.go index 62596e8..a9a2a8e 100644 --- a/rules/no_seccomp_unconfined.go +++ b/rules/no_seccomp_unconfined.go @@ -52,7 +52,7 @@ func checkNoSeccompUnconfined(ctx *linter.Context, node *linter.Node) []linter.F if !ok || !runArgsApplicable(ctx) { return nil } - v := runArgsFindFlagValue(arr, "--security-opt", func(s string) bool { return s == "seccomp=unconfined" }) + v := runArgsFindFlagValue(arr, "security-opt", func(s string) bool { return s == "seccomp=unconfined" }) if v == nil { return nil } diff --git a/rules/no_seccomp_unconfined_test.go b/rules/no_seccomp_unconfined_test.go index 9bbf28c..9d06623 100644 --- a/rules/no_seccomp_unconfined_test.go +++ b/rules/no_seccomp_unconfined_test.go @@ -33,6 +33,8 @@ func TestNoSeccompUnconfined(t *testing.T) { {Path: "devcontainer.json", Line: 1, Col: 32, RuleID: "no-seccomp-unconfined", Message: `"runArgs" contains "--security-opt seccomp=unconfined", disabling the container's syscall filtering`}, }}, + {"runArgs security-opt consumed as another flag's value", `{"runArgs": ["--label", "--security-opt=seccomp=unconfined"]}`, nil}, + {"runArgs bare seccomp entry names no flag", `{"runArgs": ["seccomp=unconfined"]}`, nil}, {"runArgs seccomp unconfined combined", `{"runArgs": ["--security-opt=seccomp=unconfined"]}`, []linter.Issue{ {Path: "devcontainer.json", Line: 1, Col: 14, RuleID: "no-seccomp-unconfined", Message: `"runArgs" contains "--security-opt seccomp=unconfined", disabling the container's syscall filtering`}, diff --git a/rules/require_cap_drop_all.go b/rules/require_cap_drop_all.go index ec54e53..9fa698e 100644 --- a/rules/require_cap_drop_all.go +++ b/rules/require_cap_drop_all.go @@ -57,7 +57,7 @@ func checkRequireCapDropAll(_ *linter.Context, node *linter.Node) []linter.Findi } for arr := range arrayMembers(obj, "runArgs") { - if runArgsFindFlagValue(arr, "--cap-drop", func(s string) bool { return s == "ALL" }) != nil { + if runArgsFindFlagValue(arr, "cap-drop", isAllCapability) != nil { return nil } } diff --git a/rules/require_cap_drop_all_test.go b/rules/require_cap_drop_all_test.go index 91a1b3c..3268a09 100644 --- a/rules/require_cap_drop_all_test.go +++ b/rules/require_cap_drop_all_test.go @@ -21,6 +21,11 @@ func TestRequireCapDropAll(t *testing.T) { }}, {"runArgs with cap-drop=ALL", `{"runArgs": ["--cap-drop=ALL"]}`, nil}, {"runArgs with cap-drop ALL two tokens", `{"runArgs": ["--cap-drop", "ALL"]}`, nil}, + {"runArgs with lower-case all", `{"runArgs": ["--cap-drop=all"]}`, nil}, + {"runArgs with cap-drop consumed as another flag's value", `{"runArgs": ["--label", "--cap-drop=ALL"]}`, []linter.Issue{ + {Path: "devcontainer.json", Line: 1, Col: 1, RuleID: "require-cap-drop-all", + Message: `"ALL" is not set via "runArgs", leaving the container with its default Linux capabilities`}, + }}, {"runArgs without cap-drop=ALL", `{"runArgs": ["--init", "--cap-add=SYS_PTRACE"]}`, []linter.Issue{ {Path: "devcontainer.json", Line: 1, Col: 1, RuleID: "require-cap-drop-all", Message: `"ALL" is not set via "runArgs", leaving the container with its default Linux capabilities`}, diff --git a/rules/require_no_new_privileges.go b/rules/require_no_new_privileges.go index 0068974..39b70ec 100644 --- a/rules/require_no_new_privileges.go +++ b/rules/require_no_new_privileges.go @@ -56,7 +56,7 @@ func checkRequireNoNewPrivileges(_ *linter.Context, node *linter.Node) []linter. return nil } for arr := range arrayMembers(obj, "runArgs") { - if runArgsFindFlagValue(arr, "--security-opt", securityOptIsNoNewPrivileges) != nil { + if runArgsFindFlagValue(arr, "security-opt", securityOptIsNoNewPrivileges) != nil { return nil } } diff --git a/rules/require_no_new_privileges_test.go b/rules/require_no_new_privileges_test.go index 39061c3..a6776c5 100644 --- a/rules/require_no_new_privileges_test.go +++ b/rules/require_no_new_privileges_test.go @@ -44,6 +44,10 @@ func TestRequireNoNewPrivileges(t *testing.T) { }}, {"runArgs two tokens", `{"runArgs": ["--security-opt", "no-new-privileges"]}`, nil}, {"runArgs combined", `{"runArgs": ["--security-opt=no-new-privileges=true"]}`, nil}, + {"runArgs security-opt consumed as another flag's value", `{"runArgs": ["--label", "--security-opt=no-new-privileges"]}`, []linter.Issue{ + {Path: "devcontainer.json", Line: 1, Col: 1, RuleID: "require-no-new-privileges", + Message: `"no-new-privileges" is not set via "securityOpt" or "runArgs", allowing container processes to gain additional privileges`}, + }}, {"runArgs combined false", `{"runArgs": ["--security-opt=no-new-privileges=false"]}`, []linter.Issue{ {Path: "devcontainer.json", Line: 1, Col: 1, RuleID: "require-no-new-privileges", Message: `"no-new-privileges" is not set via "securityOpt" or "runArgs", allowing container processes to gain additional privileges`}, diff --git a/rules/util.go b/rules/util.go index 399c721..6041fce 100644 --- a/rules/util.go +++ b/rules/util.go @@ -6,6 +6,7 @@ import ( "path" "strings" + "github.com/bare-devcontainer/decolint/dockerargs" "github.com/bare-devcontainer/decolint/linter" "github.com/tailscale/hujson" ) @@ -25,6 +26,12 @@ func isDockerSocketSource(source string) bool { return path.Clean(source) == dockerSocketPath } +// isAllCapability reports whether s names the "ALL" pseudo-capability, which stands for every Linux +// capability. Docker upper-cases a capability name before matching it, so "all" names it too. +func isAllCapability(s string) bool { + return strings.EqualFold(s, "ALL") +} + // hasMember reports whether obj has a member named name. func hasMember(obj *hujson.Object, name string) bool { return memberNamed(obj, name) != nil @@ -76,36 +83,33 @@ func arrayMembers(obj *hujson.Object, name string) iter.Seq[*hujson.Array] { } } -// runArgsFlagValues yields every value that arr, a "runArgs" array, gives to flag, in order. Docker -// accepts such a value either as a single combined "flag=value" entry or as two adjacent entries, -// "flag" followed by "value". Each yielded pair is the hujson.Value holding the value and the value -// itself. +// runArgsFlagValues yields every value that arr, a "runArgs" array, gives to the "docker run" flag +// named flag, in order. flag is the flag's name rather than a spelling of it, so "volume" covers +// both "-v" and "--volume"; see [dockerargs.Parse] for the entry forms a value can be written in. +// Each yielded pair is the array element holding the value and the value itself. func runArgsFlagValues(arr *hujson.Array, flag string) iter.Seq2[*hujson.Value, string] { return func(yield func(*hujson.Value, string) bool) { - for i := range arr.Elements { - lit, ok := arr.Elements[i].Value.(hujson.Literal) - if !ok || lit.Kind() != '"' { - continue - } - - if v, ok := strings.CutPrefix(lit.String(), flag+"="); ok { - if !yield(&arr.Elements[i], v) { - return - } - continue - } - - if lit.String() != flag || i+1 >= len(arr.Elements) { - continue - } - next, ok := arr.Elements[i+1].Value.(hujson.Literal) - if ok && next.Kind() == '"' && !yield(&arr.Elements[i+1], next.String()) { + for _, arg := range dockerargs.Parse(runArgsArgv(arr)) { + if arg.Flag == flag && !yield(&arr.Elements[arg.Index], arg.Value) { return } } } } +// runArgsArgv returns arr, a "runArgs" array, as the argv it becomes. An element that is not a +// string, which the devcontainer tooling could not hand to docker at all, stands in as an empty +// entry so that the elements around it keep the positions docker would read them at. +func runArgsArgv(arr *hujson.Array) []string { + argv := make([]string, len(arr.Elements)) + for i, elem := range arr.Elements { + if lit, ok := elem.Value.(hujson.Literal); ok && lit.Kind() == '"' { + argv[i] = lit.String() + } + } + return argv +} + // runArgsFindFlagValue returns the hujson.Value holding the first value arr gives to flag that match // accepts, or nil if it gives flag no such value. See [runArgsFlagValues] for the entry forms it // recognizes.