From d3130d5d8c8740420687d328f7934b5a783cb36c Mon Sep 17 00:00:00 2001 From: Paolo Antinori Date: Sat, 15 Aug 2026 22:25:54 +0200 Subject: [PATCH 1/3] Allow symbolic modes in COPY/ADD --chmod checkChmodConversion rejected anything that was not an octal number, so Containerfiles using symbolic --chmod clauses (+x, u+x, a+rX,go-w, u+rX-w, ...) failed at parse time with "Error parsing chmod". Numeric and symbolic --chmod are both part of the dockerfile frontend spec since Dockerfile syntax 1.14, so these forms are mainstream now. Accept symbolic modes by validating them with mode.Parse from github.com/tonistiigi/dchapes-mode, which owns this grammar (it is what buildah uses to resolve these values and what BuildKit's frontend uses), rather than restating the grammar here: chmod(1) allows repeated op-perms groups within a clause (u+r-w), which a hand-rolled subset validator would keep getting wrong. Parsing only checks the syntax; the clauses are resolved against each copied file's current mode by the executor, where conditional bits such as the capital X in a+rX are meaningful. The dependency is already in the module graph via moby/buildkit and is stdlib-only. The dockerclient executor resolved Chmod with ParseInt octal-only, which would have turned these newly-accepted values into a later, fuzzier error; resolve them with mode.Parse there too. The tar-header rewrite (applyChmod) goes through h.FileInfo().Mode() so symbolic clauses see a correct os.FileMode (directory type, special bits) and maps setuid, setgid, and sticky back onto their unix bit positions; a raw cast of the tar mode to os.FileMode would write those bits in the wrong places and silently drop them. applyChmod has unit coverage for numeric, symbolic, special-bit, and directory-X cases, which the conformance suite only gates behind a build tag and a live daemon. Two deliberate behavior alignments, both matching chmod(1) and BuildKit, are worth calling out: numeric (absolute) modes now clear pre-existing setuid/setgid/sticky bits instead of preserving them, and octal values above 07777 (e.g. 17777) are rejected at parse time instead of being masked down at execution. Octal behavior is otherwise unchanged, including rejecting non-octal digits, 0o-prefixed values, and ls-style mode strings like rwxrwxrwx. --- builder.go | 4 + dispatchers.go | 16 +- dispatchers_test.go | 54 ++ dockerclient/chmod_test.go | 57 ++ dockerclient/client.go | 30 +- go.mod | 1 + go.sum | 2 + .../tonistiigi/dchapes-mode/.hgignore | 5 + .../tonistiigi/dchapes-mode/Dockerfile | 29 + .../tonistiigi/dchapes-mode/LICENSE | 22 + .../tonistiigi/dchapes-mode/README.md | 26 + .../tonistiigi/dchapes-mode/bits.go | 76 +++ .../tonistiigi/dchapes-mode/docker-bake.hcl | 24 + .../tonistiigi/dchapes-mode/mode.go | 548 ++++++++++++++++++ vendor/modules.txt | 3 + 15 files changed, 889 insertions(+), 8 deletions(-) create mode 100644 dockerclient/chmod_test.go create mode 100644 vendor/github.com/tonistiigi/dchapes-mode/.hgignore create mode 100644 vendor/github.com/tonistiigi/dchapes-mode/Dockerfile create mode 100644 vendor/github.com/tonistiigi/dchapes-mode/LICENSE create mode 100644 vendor/github.com/tonistiigi/dchapes-mode/README.md create mode 100644 vendor/github.com/tonistiigi/dchapes-mode/bits.go create mode 100644 vendor/github.com/tonistiigi/dchapes-mode/docker-bake.hcl create mode 100644 vendor/github.com/tonistiigi/dchapes-mode/mode.go diff --git a/builder.go b/builder.go index 3ca78b05..2959bfbf 100644 --- a/builder.go +++ b/builder.go @@ -33,6 +33,10 @@ type Copy struct { // If set, the owner:group for the destination. This value is passed // to the executor for handling. Chown string + // If set, an octal mode (0 through 07777) or chmod(1) symbolic mode + // clauses for the destination. This value is passed to the executor for + // handling: symbolic clauses (including the conditional X) are resolved + // against each copied file's current mode there. Chmod string // If set, a checksum which the source must match, or be rejected. Checksum string diff --git a/dispatchers.go b/dispatchers.go index 3672f69e..620ba24c 100644 --- a/dispatchers.go +++ b/dispatchers.go @@ -28,6 +28,7 @@ import ( buildkitcommand "github.com/moby/buildkit/frontend/dockerfile/command" buildkitparser "github.com/moby/buildkit/frontend/dockerfile/parser" buildkitshell "github.com/moby/buildkit/frontend/dockerfile/shell" + "github.com/tonistiigi/dchapes-mode" ) var ( @@ -814,10 +815,19 @@ func shell(b *Builder, args []string, attributes map[string]bool, flagArgs []str } // checkChmodConversion makes sure that the argument to a --chmod= flag for -// COPY or ADD is an octal number +// COPY or ADD is an octal number between 0 and 07777 or a symbolic mode, +// both of which the dockerfile frontend spec accepts since Dockerfile +// syntax 1.14. Parsing here only validates the syntax: the clauses are +// resolved against each copied file's current mode by the executor, which +// is where conditional bits such as the capital X in a+rX are meaningful. func checkChmodConversion(chmod string) error { - _, err := strconv.ParseUint(chmod, 8, 32) - if err != nil { + if v, err := strconv.ParseUint(chmod, 8, 32); err == nil { + if v > 0o7777 { + return fmt.Errorf("Error parsing chmod %s: it should be octal and between 0 and 07777", chmod) + } + return nil + } + if _, err := mode.Parse(chmod); err != nil { return fmt.Errorf("Error parsing chmod %s", chmod) } return nil diff --git a/dispatchers_test.go b/dispatchers_test.go index e167b221..1e453e96 100644 --- a/dispatchers_test.go +++ b/dispatchers_test.go @@ -273,6 +273,36 @@ func TestDispatchCopyChown(t *testing.T) { } } +func TestCheckChmodConversion(t *testing.T) { + good := []string{ + // Numeric modes, as before. + "644", "755", "0755", "0777", "7755", + // Symbolic modes: who+op+perms in the common shapes. + "+x", "u+x", "g-w", "o-r", "a+rX", "a+rwx", "u+rX,go-w", "a+rX,go-w", + "u=rx,g=,o=", "g=u", "u+s", "o+t", "=rw,+X", + // Multiple op-perms groups within one clause, as chmod(1) allows. + "u+r-w", "g+r=rx", "o-r+x", "u+rX-w", + } + for _, mode := range good { + if err := checkChmodConversion(mode); err != nil { + t.Errorf("expected %q to be accepted, got %v", mode, err) + } + } + bad := []string{ + "", "888", "0778", "0o755", "17777", "rwxrwxrwx", "x+", "a&+r", + "+x,", ",+x", "+x,,u+w", "a+rX,", "chmod", "r", + // Who letters or permission letters alone, without an operator. + "a", "ugo", "X", + // Whitespace is not part of the grammar. + "u +x", + } + for _, mode := range bad { + if err := checkChmodConversion(mode); err == nil { + t.Errorf("expected %q to be rejected", mode) + } + } +} + func TestDispatchCopyChmod(t *testing.T) { mybuilder := Builder{ RunConfig: docker.Config{ @@ -323,6 +353,18 @@ func TestDispatchCopyChmod(t *testing.T) { if !reflect.DeepEqual(mybuilder2.PendingCopies, expectedPendingCopies) { t.Errorf("Expected %v, to match %v\n", expectedPendingCopies, mybuilder2.PendingCopies) } + + // Test symbolic chmod values: accepted and passed through verbatim for the + // executor to resolve against each copied file's mode. + flagArgs = []string{"--chmod=a+rX,go-w"} + original = "COPY --chmod=a+rX,go-w /go/src/github.com/kubernetes-incubator/service-catalog/controller-manager ." + if err := dispatchCopy(&mybuilder2, args, nil, flagArgs, original, nil); err != nil { + t.Errorf("copy error: %v", err) + } + last := mybuilder2.PendingCopies[len(mybuilder2.PendingCopies)-1] + if last.Chmod != "a+rX,go-w" { + t.Errorf("expected symbolic chmod to pass through verbatim, got %q", last.Chmod) + } } func TestDispatchAddChownWithEnvironment(t *testing.T) { @@ -767,6 +809,18 @@ func TestDispatchAddChmod(t *testing.T) { if !reflect.DeepEqual(mybuilder2.PendingCopies, expectedPendingCopies) { t.Errorf("Expected %v, to match %v\n", expectedPendingCopies, mybuilder2.PendingCopies) } + + // Test symbolic chmod values: accepted and passed through verbatim for the + // executor to resolve against each copied file's mode. + flagArgs = []string{"--chmod=u+x"} + original = "ADD --chmod=u+x /go/src/github.com/kubernetes-incubator/service-catalog/controller-manager" + if err := add(&mybuilder2, args, nil, flagArgs, original, nil); err != nil { + t.Errorf("add error: %v", err) + } + last := mybuilder2.PendingCopies[len(mybuilder2.PendingCopies)-1] + if last.Chmod != "u+x" { + t.Errorf("expected symbolic chmod to pass through verbatim, got %q", last.Chmod) + } } func TestDispatchAddChecksum(t *testing.T) { diff --git a/dockerclient/chmod_test.go b/dockerclient/chmod_test.go new file mode 100644 index 00000000..d0ae9f6e --- /dev/null +++ b/dockerclient/chmod_test.go @@ -0,0 +1,57 @@ +package dockerclient + +import ( + "archive/tar" + "testing" + + "github.com/tonistiigi/dchapes-mode" +) + +// TestApplyChmod covers the tar-header rewrite done for --chmod in +// CopyContainer, which the conformance suite only exercises behind a build +// tag and a live daemon. +func TestApplyChmod(t *testing.T) { + tests := []struct { + name string + chmod string + typeflag byte + inMode int64 + wantMode int64 + }{ + // Numeric modes replace the permission bits (and any special bits) + // of a regular file, keeping the file-type bits. + {name: "numeric basic", chmod: "755", typeflag: tar.TypeReg, inMode: 0o100644, wantMode: 0o100755}, + {name: "numeric clears exec", chmod: "644", typeflag: tar.TypeReg, inMode: 0o100755, wantMode: 0o100644}, + {name: "numeric setuid", chmod: "4755", typeflag: tar.TypeReg, inMode: 0o100644, wantMode: 0o104755}, + {name: "numeric setgid", chmod: "2755", typeflag: tar.TypeReg, inMode: 0o100644, wantMode: 0o102755}, + {name: "numeric sticky", chmod: "1755", typeflag: tar.TypeReg, inMode: 0o100644, wantMode: 0o101755}, + // Numeric modes are absolute: a pre-existing setuid bit is cleared, + // matching chmod(1). + {name: "numeric clears setuid", chmod: "755", typeflag: tar.TypeReg, inMode: 0o104755, wantMode: 0o100755}, + // Symbolic modes. + {name: "symbolic add exec", chmod: "+x", typeflag: tar.TypeReg, inMode: 0o100644, wantMode: 0o100755}, + {name: "symbolic clause list", chmod: "u+x,go-w", typeflag: tar.TypeReg, inMode: 0o100644, wantMode: 0o100744}, + {name: "symbolic multi group", chmod: "u+rX-w", typeflag: tar.TypeReg, inMode: 0o100700, wantMode: 0o100500}, + {name: "symbolic setuid", chmod: "u+s", typeflag: tar.TypeReg, inMode: 0o100644, wantMode: 0o104644}, + {name: "symbolic sticky", chmod: "+t", typeflag: tar.TypeReg, inMode: 0o100755, wantMode: 0o101755}, + // The conditional X: granted on directories, and on regular files + // only if an execute bit is already set. + {name: "X on directory", chmod: "u=rwX,go=rX", typeflag: tar.TypeDir, inMode: 0o040644, wantMode: 0o040755}, + {name: "X on non-exec regular", chmod: "u=rwX", typeflag: tar.TypeReg, inMode: 0o100644, wantMode: 0o100644}, + {name: "X on exec regular", chmod: "a+rX", typeflag: tar.TypeReg, inMode: 0o100700, wantMode: 0o100755}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + set, err := mode.Parse(tc.chmod) + if err != nil { + t.Fatalf("parsing %q: %v", tc.chmod, err) + } + h := &tar.Header{Typeflag: tc.typeflag, Mode: tc.inMode} + applyChmod(h, set) + if h.Mode != tc.wantMode { + t.Errorf("chmod %q on header %#o (%c): got %#o, want %#o", + tc.chmod, tc.inMode, tc.typeflag, h.Mode, tc.wantMode) + } + }) + } +} diff --git a/dockerclient/client.go b/dockerclient/client.go index ea892e02..a2c5bf18 100644 --- a/dockerclient/client.go +++ b/dockerclient/client.go @@ -23,6 +23,7 @@ import ( "github.com/openshift/imagebuilder" "github.com/openshift/imagebuilder/dockerfile/parser" "github.com/openshift/imagebuilder/imageprogress" + "github.com/tonistiigi/dchapes-mode" ) // NewClientFromEnv is exposed to simplify getting a client when vendoring this library. @@ -1051,6 +1052,27 @@ func (e *ClientExecutor) getUser(userspec string) (int, int, error) { return int(parsedUid), int(parsedGid), nil } +// applyChmod rewrites h's permission bits per set, preserving the file-type +// bits. It goes through h.FileInfo().Mode() so symbolic clauses see a correct +// os.FileMode (ModeDir, and setuid/setgid/sticky in their flag positions), +// then maps the result back onto the tar header's unix convention +// (setuid/setgid/sticky at 0o4000/0o2000/0o1000). An absolute (numeric or +// "=") mode replaces the permission and special bits entirely, matching +// chmod(1). +func applyChmod(h *tar.Header, set mode.Set) { + fm := set.Apply(h.FileInfo().Mode()) + h.Mode = (h.Mode &^ 0o7777) | int64(fm.Perm()) + if fm&os.ModeSetuid != 0 { + h.Mode |= 0o4000 + } + if fm&os.ModeSetgid != 0 { + h.Mode |= 0o2000 + } + if fm&os.ModeSticky != 0 { + h.Mode |= 0o1000 + } +} + // CopyContainer copies the provided content into a destination container. func (e *ClientExecutor) CopyContainer(container *docker.Container, excludes []string, copies ...imagebuilder.Copy) error { chownUid, chownGid := -1, -1 @@ -1069,14 +1091,12 @@ func (e *ClientExecutor) CopyContainer(container *docker.Container, excludes []s for _, c := range copies { var chmod func(h *tar.Header, r io.Reader) (data []byte, update bool, skip bool, err error) if c.Chmod != "" { - parsed, err := strconv.ParseInt(c.Chmod, 8, 16) + parsed, err := mode.Parse(c.Chmod) if err != nil { - return err + return fmt.Errorf("invalid chmod %q", c.Chmod) } chmod = func(h *tar.Header, r io.Reader) (data []byte, update bool, skip bool, err error) { - mode := h.Mode &^ 0o777 - mode |= parsed & 0o7777 - h.Mode = mode + applyChmod(h, parsed) return nil, false, false, nil } } diff --git a/go.mod b/go.mod index f88965c0..690c6b8d 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/moby/moby/api v1.54.2 github.com/moby/patternmatcher v0.6.1 github.com/stretchr/testify v1.11.1 + github.com/tonistiigi/dchapes-mode v0.0.0-20250318174251-73d941a28323 go.podman.io/storage v1.62.0 k8s.io/klog v1.0.0 ) diff --git a/go.sum b/go.sum index d0d2491e..3b207d3e 100644 --- a/go.sum +++ b/go.sum @@ -78,6 +78,8 @@ github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= 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/dchapes-mode v0.0.0-20250318174251-73d941a28323 h1:r0p7fK56l8WPequOaR3i9LBqfPtEdXIQbUTzT55iqT4= +github.com/tonistiigi/dchapes-mode v0.0.0-20250318174251-73d941a28323/go.mod h1:3Iuxbr0P7D3zUzBMAZB+ois3h/et0shEz0qApgHYGpY= github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= diff --git a/vendor/github.com/tonistiigi/dchapes-mode/.hgignore b/vendor/github.com/tonistiigi/dchapes-mode/.hgignore new file mode 100644 index 00000000..be8f61dd --- /dev/null +++ b/vendor/github.com/tonistiigi/dchapes-mode/.hgignore @@ -0,0 +1,5 @@ +syntax: glob +bench*.out* +cmode +coverage.out +coverage.txt diff --git a/vendor/github.com/tonistiigi/dchapes-mode/Dockerfile b/vendor/github.com/tonistiigi/dchapes-mode/Dockerfile new file mode 100644 index 00000000..b295c4fb --- /dev/null +++ b/vendor/github.com/tonistiigi/dchapes-mode/Dockerfile @@ -0,0 +1,29 @@ + +# syntax=docker/dockerfile:1 + +ARG GO_VERSION=1.23 +ARG XX_VERSION=1.5.0 + +FROM --platform=$BUILDPLATFORM tonistiigi/xx:${XX_VERSION} AS xx + +FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-alpine AS base +RUN apk add --no-cache git +COPY --from=xx / / +WORKDIR /src + +FROM base AS build +ARG TARGETPLATFORM +RUN --mount=target=. --mount=target=/go/pkg/mod,type=cache \ + --mount=target=/root/.cache,type=cache \ + xx-go build ./... + +FROM base AS test +ARG TESTFLAGS +RUN --mount=target=. --mount=target=/go/pkg/mod,type=cache \ + --mount=target=/root/.cache,type=cache \ + xx-go test -v -coverprofile=/tmp/coverage.txt -covermode=atomic ${TESTFLAGS} ./... + +FROM scratch AS test-coverage +COPY --from=test /tmp/coverage.txt /coverage-root.txt + +FROM build \ No newline at end of file diff --git a/vendor/github.com/tonistiigi/dchapes-mode/LICENSE b/vendor/github.com/tonistiigi/dchapes-mode/LICENSE new file mode 100644 index 00000000..a8743fb2 --- /dev/null +++ b/vendor/github.com/tonistiigi/dchapes-mode/LICENSE @@ -0,0 +1,22 @@ +Copyright © 2016-2018, Dave Chapeskie +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/vendor/github.com/tonistiigi/dchapes-mode/README.md b/vendor/github.com/tonistiigi/dchapes-mode/README.md new file mode 100644 index 00000000..dca34f9f --- /dev/null +++ b/vendor/github.com/tonistiigi/dchapes-mode/README.md @@ -0,0 +1,26 @@ +Mode +======== + +This is a fork of [hg.sr.ht/~dchapes/mode](https://hg.sr.ht/~dchapes/mode) with minimal patches and basic CI. + +[Mode](https://hg.sr.ht/~dchapes/mode) +is a [Go](http://golang.org/) package that provides +a native Go implementation of BSD's +[`setmode`](https://www.freebsd.org/cgi/man.cgi?query=setmode&sektion=3) +and `getmode` which can be used to modify the mode bits of +an [`os.FileMode`](https://golang.org/pkg/os#FileMode) value +based on a symbolic value as described by the +Unix [`chmod`](https://www.freebsd.org/cgi/man.cgi?query=chmod&sektion=1) command. + +[![Go Reference](https://pkg.go.dev/badge/hg.sr.ht/~dchapes/mode.svg)](https://pkg.go.dev/hg.sr.ht/~dchapes/mode) + +Online package documentation is available via +[pkg.go.dev](https://pkg.go.dev/hg.sr.ht/~dchapes/mode). + +To install: + + go get hg.sr.ht/~dchapes/mode + +or `go build` any Go code that imports it: + + import "hg.sr.ht/~dchapes/mode" diff --git a/vendor/github.com/tonistiigi/dchapes-mode/bits.go b/vendor/github.com/tonistiigi/dchapes-mode/bits.go new file mode 100644 index 00000000..4dbb08ad --- /dev/null +++ b/vendor/github.com/tonistiigi/dchapes-mode/bits.go @@ -0,0 +1,76 @@ +package mode + +import "os" + +type modet uint16 + +// Although many of these can be found in the syscall package +// we don't use those to avoid the dependency, add some more +// values, use non-exported Go names, and use octal for better clarity. +// +// Note that Go only uses the the nine least significant bits as "Unix +// permission bits" (os.ModePerm == 0777). We use chmod(1)'s octal +// definitions that include three further bits: isUID, isGID, and +// isTXT (07000). Go has os.ModeSetuid=1<<23, os.ModeSetgid=1<<22, +// and os.ModeSticy=1<<20 for these. We do this so that absolute +// octal values can include those bits as defined by chmod(1). +const ( + //ifDir = 040000 // directory + isUID = 04000 // set user id on execution + isGID = 02000 // set group id on execution + isTXT = 01000 // sticky bit + iRWXU = 00700 // RWX mask for owner + iRUser = 00400 // R for owner + iWUser = 00200 // W for owner + iXUser = 00100 // X for owner + iRWXG = 00070 // RWX mask for group + iRGroup = 00040 // R for group + iWGroup = 00020 // W for group + iXGroup = 00010 // X for group + iRWXO = 00007 // RWX mask for other + iROther = 00004 // R for other + iWOther = 00002 // W for other + iXOther = 00001 // X for other + + standardBits = isUID | isGID | iRWXU | iRWXG | iRWXO + + // os.FileMode bits we touch + fmBits = os.ModeSetuid | os.ModeSetgid | os.ModeSticky | os.ModePerm +) + +func fileModeToBits(fm os.FileMode) modet { + m := modet(fm.Perm()) + /* + if fm&os.ModeSetuid != 0 { + m |= isUID + } + if fm&os.ModeSetgid != 0 { + m |= isGID + } + if fm&os.ModeSticky != 0 { + m |= isTXT + } + */ + m |= modet(fm & (os.ModeSetuid | os.ModeSetgid) >> 12) + m |= modet(fm & os.ModeSticky >> 11) + return m +} + +func bitsToFileMode(old os.FileMode, m modet) os.FileMode { + fm := old &^ fmBits + fm |= os.FileMode(m) & os.ModePerm + /* + if m&isUID != 0 { + fm |= os.ModeSetuid + } + if m&isGID != 0 { + fm |= os.ModeSetgid + } + if m&isTXT != 0 { + fm |= os.ModeSticky + } + */ + fm |= os.FileMode(m&(isUID|isGID)) << 12 + fm |= os.FileMode(m&isTXT) << 11 + return fm +} diff --git a/vendor/github.com/tonistiigi/dchapes-mode/docker-bake.hcl b/vendor/github.com/tonistiigi/dchapes-mode/docker-bake.hcl new file mode 100644 index 00000000..1220e909 --- /dev/null +++ b/vendor/github.com/tonistiigi/dchapes-mode/docker-bake.hcl @@ -0,0 +1,24 @@ +variable "GO_VERSION" { + default = null +} + +group "default" { + targets = ["build"] +} + +target "build" { + args = { + GO_VERSION = GO_VERSION + } + output = ["type=cacheonly"] +} + +target "test" { + inherits = ["build"] + target = "test" +} + +target "cross" { + inherits = ["build"] + platforms = ["linux/amd64", "linux/386", "linux/arm64", "linux/arm", "linux/ppc64le", "linux/s390x", "darwin/amd64", "darwin/arm64", "windows/amd64", "windows/arm64", "freebsd/amd64", "freebsd/arm64"] +} \ No newline at end of file diff --git a/vendor/github.com/tonistiigi/dchapes-mode/mode.go b/vendor/github.com/tonistiigi/dchapes-mode/mode.go new file mode 100644 index 00000000..a53aa7d0 --- /dev/null +++ b/vendor/github.com/tonistiigi/dchapes-mode/mode.go @@ -0,0 +1,548 @@ +/* + +Parts of this file are a heavily modified C to Go +translation of BSD's /usr/src/lib/libc/gen/setmode.c +that contains the following copyright notice: + + * Copyright (c) 1989, 1993, 1994 + * The Regents of the University of California. All rights reserved. + * + * This code is derived from software contributed to Berkeley by + * Dave Borman at Cray Research, Inc. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * 4. Neither the name of the University nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY + * OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF + * SUCH DAMAGE. + +*/ + +// Package mode provides a native Go implementation of BSD's setmode and getmode +// which can be used to modify the mode bits of an os.FileMode value based on +// a symbolic value as described by the Unix chmod command. +// +// For a full description of the mode string see chmod(1). +// Some examples include: +// +// 644 make a file readable by anyone and writable by the owner +// only. +// +// go-w deny write permission to group and others. +// +// =rw,+X set the read and write permissions to the usual defaults, +// but retain any execute permissions that are currently set. +// +// +X make a directory or file searchable/executable by everyone +// if it is already searchable/executable by anyone. +// +// 755 +// u=rwx,go=rx +// u=rwx,go=u-w make a file readable/executable by everyone and writable by +// the owner only. +// +// go= clear all mode bits for group and others. +// +// go=u-w set the group bits equal to the user bits, but clear the +// group write bit. +// +// See Also: +// +// setmode(3): https://www.freebsd.org/cgi/man.cgi?query=setmode&sektion=3 +// chmod(1): https://www.freebsd.org/cgi/man.cgi?query=chmod&sektion=1 +package mode + +import ( + "errors" + "fmt" + "os" + "strconv" + "strings" +) + +// Set is a set of changes to apply to an os.FileMode. +// Changes include setting or clearing specific bits, copying bits from one +// user class to another (e.g. "u=go" sets the user permissions to a copy of +// the group and other permsissions), etc. +type Set struct { + cmds []bitcmd +} + +type bitcmd struct { + cmd byte + cmd2 byte + bits modet +} + +const ( + cmd2Clear byte = 1 << iota + cmd2Set + cmd2GBits + cmd2OBits + cmd2UBits +) + +func (c bitcmd) String() string { + c2 := "" + if c.cmd2 != 0 { + c2 = " cmd2:" + if c.cmd2&cmd2Clear != 0 { + c2 += " CLR" + } + if c.cmd2&cmd2Set != 0 { + c2 += " SET" + } + if c.cmd2&cmd2UBits != 0 { + c2 += " UBITS" + } + if c.cmd2&cmd2GBits != 0 { + c2 += " GBITS" + } + if c.cmd2&cmd2OBits != 0 { + c2 += " OBITS" + } + } + return fmt.Sprintf("cmd: %q bits %#05o%s", c.cmd, c.bits, c2) +} + +// The String method will likely only be useful when testing. +func (s Set) String() string { + var buf strings.Builder + buf.Grow(21*len(s.cmds) + 10) + _, _ = buf.WriteString("set: {\n") + for _, c := range s.cmds { + _, _ = buf.WriteString(c.String()) + _ = buf.WriteByte('\n') + } + _, _ = buf.WriteString("}") + return buf.String() +} + +// ErrSyntax indicates an argument does not represent a valid mode. +var ErrSyntax = errors.New("invalid syntax") + +// Apply changes the provided os.FileMode based on the given umask and +// absolute or symbolic mode value. +// +// Apply is a convience to calling ParseWithUmask followed by Apply. +// Since it needs to parse the mode value string on each call it +// should only be used when mode value string will not be reapplied. +func Apply(s string, perm os.FileMode, umask uint) (os.FileMode, error) { + set, err := ParseWithUmask(s, umask) + if err != nil { + return 0, err + } + return set.Apply(perm), nil +} + +// Parse takes an absolute (octal) or symbolic mode value, +// as described in chmod(1), as an argument and returns +// the set of bit operations representing the mode value +// that can be applied to specific os.FileMode values. +// +// Same as ParseWithUmask(s, 0). +func Parse(s string) (Set, error) { + return ParseWithUmask(s, 0) +} + +// TODO(dchapes): A Set.Parse method that reuses existing memory. + +// TODO(dchapes): Only call syscall.Umask when abosolutely necessary and +// provide a Set method to query if set is umask dependant (and perhaps +// the umask that was in effect when parsed). + +// ParseWithUmask is like Parse but uses the provided +// file creation mask instead of calling syscall.Umask. +func ParseWithUmask(s string, umask uint) (Set, error) { + var m Set + if s == "" { + return m, ErrSyntax + } + + // If an absolute number, get it and return; + // disallow non-octal digits or illegal bits. + if d := s[0]; '0' <= d && d <= '9' { + v, err := strconv.ParseInt(s, 8, 16) + if err != nil { + return m, err + } + if v&^(standardBits|isTXT) != 0 { + return m, ErrSyntax + } + // We know this takes exactly two bitcmds. + m.cmds = make([]bitcmd, 0, 2) + m.addcmd('=', standardBits|isTXT, modet(v), 0) + return m, nil + } + + // Get a copy of the mask for the permissions that are mask relative. + // Flip the bits, we want what's not set. + var mask modet = ^modet(umask) + + // Pre-allocate room for several commands. + //m.cmds = make([]bitcmd, 0, 8) + + // Build list of bitcmd structs to set/clear/copy bits as described by + // each clause of the symbolic mode. + equalOpDone := false + for { + // First, find out which bits might be modified. + var who modet + whoLoop: + for { + if len(s) == 0 { + return Set{}, ErrSyntax + } + switch s[0] { + case 'a': + who |= standardBits + case 'u': + who |= isUID | iRWXU + case 'g': + who |= isGID | iRWXG + case 'o': + who |= iRWXO + default: + break whoLoop + } + s = s[1:] + } + + var op byte + getop: + op, s = s[0], s[1:] + switch op { + case '+', '-': + // Nothing. + case '=': + equalOpDone = false + default: + return Set{}, ErrSyntax + } + + who &^= isTXT + permLoop: + for perm, permX := modet(0), modet(0); ; s = s[1:] { + var b byte + if len(s) > 0 { + b = s[0] + } + switch b { + case 'r': + perm |= iRUser | iRGroup | iROther + case 's': + // If only "other" bits ignore set-id. + if who == 0 || who&^iRWXO != 0 { + perm |= isUID | isGID + } + case 't': + // If only "other bits ignore sticky. + if who == 0 || who&^iRWXO != 0 { + who |= isTXT + perm |= isTXT + } + case 'w': + perm |= iWUser | iWGroup | iWOther + case 'X': + if op != '-' { + permX = iXUser | iXGroup | iXOther + } else { + perm |= iXUser | iXGroup | iXOther + } + case 'x': + perm |= iXUser | iXGroup | iXOther + case 'u', 'g', 'o': + // Whenever we hit 'u', 'g', or 'o', we have + // to flush out any partial mode that we have, + // and then do the copying of the mode bits. + if perm != 0 { + m.addcmd(op, who, perm, mask) + perm = 0 + } + if op == '=' { + equalOpDone = true + } + if permX != 0 { + m.addcmd('X', who, permX, mask) + permX = 0 + } + m.addcmd(b, who, modet(op), mask) + default: + // Add any permissions that we haven't alread done. + if perm != 0 || op == '=' && !equalOpDone { + if op == '=' { + equalOpDone = true + } + m.addcmd(op, who, perm, mask) + //perm = 0 + } + if permX != 0 { + m.addcmd('X', who, permX, mask) + //permX = 0 + } + break permLoop + } + } + + if s == "" { + break + } + if s[0] != ',' { + goto getop + } + s = s[1:] + } + + m.compress() + return m, nil +} + +// Apply returns the os.FileMode after applying the set of changes. +func (s Set) Apply(perm os.FileMode) os.FileMode { + omode := fileModeToBits(perm) + newmode := omode + + // When copying the user, group or other bits around, we "know" + // where the bits are in the mode so that we can do shifts to + // copy them around. If we don't use shifts, it gets real + // grundgy with lots of single bit checks and bit sets. + common := func(c bitcmd, value modet) { + if c.cmd2&cmd2Clear != 0 { + var clrval modet + if c.cmd2&cmd2Set != 0 { + clrval = iRWXO + } else { + clrval = value + } + if c.cmd2&cmd2UBits != 0 { + newmode &^= clrval << 6 & c.bits + } + if c.cmd2&cmd2GBits != 0 { + newmode &^= clrval << 3 & c.bits + } + if c.cmd2&cmd2OBits != 0 { + newmode &^= clrval & c.bits + } + } + if c.cmd2&cmd2Set != 0 { + if c.cmd2&cmd2UBits != 0 { + newmode |= value << 6 & c.bits + } + if c.cmd2&cmd2GBits != 0 { + newmode |= value << 3 & c.bits + } + if c.cmd2&cmd2OBits != 0 { + newmode |= value & c.bits + } + } + } + + for _, c := range s.cmds { + switch c.cmd { + case 'u': + common(c, newmode&iRWXU>>6) + case 'g': + common(c, newmode&iRWXG>>3) + case 'o': + common(c, newmode&iRWXO) + + case '+': + newmode |= c.bits + case '-': + newmode &^= c.bits + + case 'X': + if omode&(iXUser|iXGroup|iXOther) != 0 || perm.IsDir() { + newmode |= c.bits + } + } + } + + return bitsToFileMode(perm, newmode) +} + +// Chmod is a convience routine that applies the changes in +// Set to the named file. To avoid some race conditions, +// it opens the file and uses os.File.Stat and +// os.File.Chmod rather than os.Stat and os.Chmod if possible. +func (s *Set) Chmod(name string) (old, new os.FileMode, err error) { + if f, err := os.Open(name); err == nil { // nolint: vetshadow + defer f.Close() // nolint: errcheck + return s.ChmodFile(f) + } + // Fallback to os.Stat and os.Chmod if we + // don't have permission to open the file. + fi, err := os.Stat(name) + if err != nil { + return 0, 0, err + } + old = fi.Mode() + new = s.Apply(old) + if new != old { + err = os.Chmod(name, new) + } + return old, new, err + +} + +// ChmodFile is a convience routine that applies +// the changes in Set to the open file f. +func (s *Set) ChmodFile(f *os.File) (old, new os.FileMode, err error) { + fi, err := f.Stat() + if err != nil { + return 0, 0, err + } + old = fi.Mode() + new = s.Apply(old) + if new != old { + err = f.Chmod(new) + } + return old, new, err +} + +func (s *Set) addcmd(op byte, who, oparg, mask modet) { + c := bitcmd{} + switch op { + case '=': + c.cmd = '-' + if who != 0 { + c.bits = who + } else { + c.bits = standardBits + } + + s.cmds = append(s.cmds, c) + //c = bitcmd{} // reset, not actually needed + op = '+' + fallthrough + case '+', '-', 'X': + c.cmd = op + if who != 0 { + c.bits = who & oparg + } else { + c.bits = mask & oparg + } + + case 'u', 'g', 'o': + c.cmd = op + if who != 0 { + if who&iRUser != 0 { + c.cmd2 |= cmd2UBits + } + if who&iRGroup != 0 { + c.cmd2 |= cmd2GBits + } + if who&iROther != 0 { + c.cmd2 |= cmd2OBits + } + c.bits = ^modet(0) + } else { + c.cmd2 = cmd2UBits | cmd2GBits | cmd2OBits + c.bits = mask + } + + switch oparg { + case '+': + c.cmd2 |= cmd2Set + case '-': + c.cmd2 |= cmd2Clear + case '=': + c.cmd2 |= cmd2Set | cmd2Clear + } + default: + panic("unreachable") + } + s.cmds = append(s.cmds, c) +} + +// compress by compacting consecutive '+', '-' and 'X' +// commands into at most 3 commands, one of each. The 'u', +// 'g' and 'o' commands continue to be separate. They could +// probably be compacted, but it's not worth the effort. +func (s *Set) compress() { + //log.Println("before:", *m) + //log.Println("Start compress:") + j := 0 + for i := 0; i < len(s.cmds); i++ { + c := s.cmds[i] + //log.Println(" read", i, c) + if strings.IndexByte("+-X", c.cmd) < 0 { + // Copy over any 'u', 'g', and 'o' commands. + if i != j { + s.cmds[j] = c + } + //log.Println(" wrote", j, "from", i) + j++ + continue + } + var setbits, clrbits, Xbits modet + for ; i < len(s.cmds); i++ { + c = s.cmds[i] + //log.Println(" scan", i, c) + switch c.cmd { + case '-': + clrbits |= c.bits + setbits &^= c.bits + Xbits &^= c.bits + continue + case '+': + setbits |= c.bits + clrbits &^= c.bits + Xbits &^= c.bits + continue + case 'X': + Xbits |= c.bits &^ setbits + continue + default: + i-- + } + break + } + if clrbits != 0 { + s.cmds[j].cmd = '-' + s.cmds[j].cmd2 = 0 + s.cmds[j].bits = clrbits + //log.Println(" wrote", j, "clrbits") + j++ + } + if setbits != 0 { + s.cmds[j].cmd = '+' + s.cmds[j].cmd2 = 0 + s.cmds[j].bits = setbits + //log.Println(" wrote", j, "setbits") + j++ + } + if Xbits != 0 { + s.cmds[j].cmd = 'X' + s.cmds[j].cmd2 = 0 + s.cmds[j].bits = Xbits + //log.Println(" wrote", j, "Xbits") + j++ + } + } + /* + if len(m.cmds) != j { + log.Println("compressed", len(m.cmds), "down to", j) + } + */ + s.cmds = s.cmds[:j] + //log.Println("after:", *m) +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 68c38513..35c44f78 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -122,6 +122,9 @@ github.com/sirupsen/logrus github.com/stretchr/testify/assert github.com/stretchr/testify/assert/yaml github.com/stretchr/testify/require +# github.com/tonistiigi/dchapes-mode v0.0.0-20250318174251-73d941a28323 +## explicit; go 1.21 +github.com/tonistiigi/dchapes-mode # github.com/ulikunitz/xz v0.5.15 ## explicit; go 1.12 github.com/ulikunitz/xz From 2ca5e05f8f415ae4097759347cb412f988b6f0a3 Mon Sep 17 00:00:00 2001 From: Paolo Antinori Date: Mon, 17 Aug 2026 19:47:06 +0200 Subject: [PATCH 2/3] review: wrap underlying parse error in chmod failure messages (nits from review) --- dispatchers.go | 2 +- dockerclient/client.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dispatchers.go b/dispatchers.go index 620ba24c..12c69849 100644 --- a/dispatchers.go +++ b/dispatchers.go @@ -828,7 +828,7 @@ func checkChmodConversion(chmod string) error { return nil } if _, err := mode.Parse(chmod); err != nil { - return fmt.Errorf("Error parsing chmod %s", chmod) + return fmt.Errorf("Error parsing chmod %s: %w", chmod, err) } return nil } diff --git a/dockerclient/client.go b/dockerclient/client.go index a2c5bf18..f4047055 100644 --- a/dockerclient/client.go +++ b/dockerclient/client.go @@ -1093,7 +1093,7 @@ func (e *ClientExecutor) CopyContainer(container *docker.Container, excludes []s if c.Chmod != "" { parsed, err := mode.Parse(c.Chmod) if err != nil { - return fmt.Errorf("invalid chmod %q", c.Chmod) + return fmt.Errorf("invalid chmod %q: %w", c.Chmod, err) } chmod = func(h *tar.Header, r io.Reader) (data []byte, update bool, skip bool, err error) { applyChmod(h, parsed) From e5a2d5f2dd9ee4f83cd3fd02f5c3a91494bef272 Mon Sep 17 00:00:00 2001 From: Nalin Dahyabhai Date: Tue, 18 Aug 2026 11:23:21 -0400 Subject: [PATCH 3/3] TestConformanceExternal/copy and env interaction: update test context Update the context directory used for a conformance test that references an external git repository to keep up with changes on the main branch. Signed-off-by: Nalin Dahyabhai --- dockerclient/conformance_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dockerclient/conformance_test.go b/dockerclient/conformance_test.go index 333685af..2106f641 100644 --- a/dockerclient/conformance_test.go +++ b/dockerclient/conformance_test.go @@ -561,7 +561,7 @@ func TestConformanceExternal(t *testing.T) { { Name: "copy and env interaction", // Tests COPY and other complex interactions of ENV - ContextDir: "18/alpine3.22", + ContextDir: "19/alpine3.24", Dockerfile: "Dockerfile", Git: "https://github.com/docker-library/postgres.git", Ignore: []ignoreFunc{