Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 14 additions & 4 deletions dispatchers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
}
Expand Down
54 changes: 54 additions & 0 deletions dispatchers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
57 changes: 57 additions & 0 deletions dockerclient/chmod_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
30 changes: 25 additions & 5 deletions dockerclient/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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
}
}
Expand Down
2 changes: 1 addition & 1 deletion dockerclient/conformance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
5 changes: 5 additions & 0 deletions vendor/github.com/tonistiigi/dchapes-mode/.hgignore

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 29 additions & 0 deletions vendor/github.com/tonistiigi/dchapes-mode/Dockerfile

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 22 additions & 0 deletions vendor/github.com/tonistiigi/dchapes-mode/LICENSE

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 26 additions & 0 deletions vendor/github.com/tonistiigi/dchapes-mode/README.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading