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..12c69849 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,11 +815,20 @@ 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 { - return fmt.Errorf("Error parsing chmod %s", chmod) + 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: %w", chmod, err) } 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..f4047055 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: %w", c.Chmod, err) } 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/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{ 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