From b6e1b7328f0b70c83fe2dc5c2d0de04bf32022c3 Mon Sep 17 00:00:00 2001 From: CrazyMax <1951866+crazy-max@users.noreply.github.com> Date: Wed, 3 Jun 2026 15:44:07 +0200 Subject: [PATCH] build: gate local delete outputs Signed-off-by: CrazyMax <1951866+crazy-max@users.noreply.github.com> --- bake/entitlements.go | 135 ++------------------------- bake/entitlements_test.go | 8 +- build/opt.go | 63 +++++++++++++ commands/bake.go | 2 +- commands/build.go | 14 ++- docs/bake-reference.md | 2 +- docs/reference/buildx_bake.md | 2 +- docs/reference/buildx_build.md | 16 +++- docs/reference/buildx_dap_build.md | 2 +- docs/reference/buildx_debug_build.md | 3 +- tests/bake.go | 75 +++++++++++++++ tests/build.go | 64 +++++++++++++ util/buildflags/entitlements.go | 20 +++- util/osutil/path.go | 122 ++++++++++++++++++++++++ 14 files changed, 380 insertions(+), 148 deletions(-) diff --git a/bake/entitlements.go b/bake/entitlements.go index 386b973c8..3ce16a362 100644 --- a/bake/entitlements.go +++ b/bake/entitlements.go @@ -6,16 +6,15 @@ import ( "context" "fmt" "io" - "io/fs" "os" "path/filepath" "slices" "strconv" "strings" - "syscall" "github.com/containerd/console" "github.com/docker/buildx/build" + "github.com/docker/buildx/util/buildflags" "github.com/docker/buildx/util/osutil" "github.com/moby/buildkit/client" "github.com/moby/buildkit/util/entitlements" @@ -37,7 +36,7 @@ const ( EntitlementKeyImageLoad EntitlementKey = "image.load" EntitlementKeyImage EntitlementKey = "image" EntitlementKeySSH EntitlementKey = "ssh" - EntitlementKeyLocalOutputDelete EntitlementKey = "local-output-delete" + EntitlementKeyBuildxLocalDelete EntitlementKey = EntitlementKey(buildflags.EntitlementBuildxLocalDelete) ) type EntitlementConf struct { @@ -67,7 +66,7 @@ func ParseEntitlements(in []string) (EntitlementConf, error) { conf.SecurityInsecure = true case string(EntitlementKeySSH): conf.SSH = true - case string(EntitlementKeyLocalOutputDelete): + case string(EntitlementKeyBuildxLocalDelete): conf.LocalOutputDelete = true default: k, v, _ := strings.Cut(e, "=") @@ -102,8 +101,8 @@ func ParseEntitlements(in []string) (EntitlementConf, error) { case string(EntitlementKeyImage): conf.ImagePush = append(conf.ImagePush, v) conf.ImageLoad = append(conf.ImageLoad, v) - case string(EntitlementKeyLocalOutputDelete): - return conf, errors.Errorf("%s does not accept a value", EntitlementKeyLocalOutputDelete) + case string(EntitlementKeyBuildxLocalDelete): + return conf, errors.Errorf("%s does not accept a value", EntitlementKeyBuildxLocalDelete) default: return conf, errors.Errorf("unknown entitlement key %q", k) } @@ -271,7 +270,7 @@ func (c EntitlementConf) Prompt(ctx context.Context, isRemote bool, out io.Write } if c.LocalOutputDelete { msgs = append(msgs, " - Deleting stale files from local output destinations") - flags = append(flags, string(EntitlementKeyLocalOutputDelete)) + flags = append(flags, string(EntitlementKeyBuildxLocalDelete)) } roPaths, rwPaths, commonPaths := groupSamePaths(c.FSRead, c.FSWrite) @@ -530,7 +529,7 @@ func evaluatePaths(in []string) ([]string, bool, error) { logrus.Warnf("failed to evaluate entitlement path %q: %v", p, err) continue } - v, rest, err := evaluateToExistingPath(v) + v, rest, err := osutil.EvaluateToExistingPath(v) if err != nil { return nil, false, errors.Wrapf(err, "failed to evaluate path %q", p) } @@ -549,7 +548,7 @@ func evaluatePaths(in []string) ([]string, bool, error) { func evaluateToExistingPaths(in map[string]struct{}) (map[string]struct{}, error) { m := make(map[string]struct{}, len(in)) for p := range in { - v, _, err := evaluateToExistingPath(p) + v, _, err := osutil.EvaluateToExistingPath(p) if err != nil { return nil, errors.Wrapf(err, "failed to evaluate path %q", p) } @@ -563,121 +562,5 @@ func evaluateToExistingPaths(in map[string]struct{}) (map[string]struct{}, error } func evaluateToExistingPath(in string) (string, string, error) { - in, err := filepath.Abs(in) - if err != nil { - return "", "", err - } - - volLen := volumeNameLen(in) - pathSeparator := string(os.PathSeparator) - - if volLen < len(in) && os.IsPathSeparator(in[volLen]) { - volLen++ - } - vol := in[:volLen] - dest := vol - linksWalked := 0 - var end int - for start := volLen; start < len(in); start = end { - for start < len(in) && os.IsPathSeparator(in[start]) { - start++ - } - end = start - for end < len(in) && !os.IsPathSeparator(in[end]) { - end++ - } - - if end == start { - break - } else if in[start:end] == "." { - continue - } else if in[start:end] == ".." { - var r int - for r = len(dest) - 1; r >= volLen; r-- { - if os.IsPathSeparator(dest[r]) { - break - } - } - if r < volLen || dest[r+1:] == ".." { - if len(dest) > volLen { - dest += pathSeparator - } - dest += ".." - } else { - dest = dest[:r] - } - continue - } - - if len(dest) > volumeNameLen(dest) && !os.IsPathSeparator(dest[len(dest)-1]) { - dest += pathSeparator - } - dest += in[start:end] - - fi, err := os.Lstat(dest) - if err != nil { - // If the component doesn't exist, return the last valid path - if os.IsNotExist(err) { - for r := len(dest) - 1; r >= volLen; r-- { - if os.IsPathSeparator(dest[r]) { - return dest[:r], in[start:], nil - } - } - return vol, in[start:], nil - } - return "", "", err - } - - if fi.Mode()&fs.ModeSymlink == 0 { - if !fi.Mode().IsDir() && end < len(in) { - return "", "", syscall.ENOTDIR - } - continue - } - - linksWalked++ - if linksWalked > 255 { - return "", "", errors.New("too many symlinks") - } - - link, err := os.Readlink(dest) - if err != nil { - return "", "", err - } - - in = link + in[end:] - - v := volumeNameLen(link) - if v > 0 { - if v < len(link) && os.IsPathSeparator(link[v]) { - v++ - } - vol = link[:v] - dest = vol - end = len(vol) - } else if len(link) > 0 && os.IsPathSeparator(link[0]) { - dest = link[:1] - end = 1 - vol = link[:1] - volLen = 1 - } else { - var r int - for r = len(dest) - 1; r >= volLen; r-- { - if os.IsPathSeparator(dest[r]) { - break - } - } - if r < volLen { - dest = vol - } else { - dest = dest[:r] - } - end = 0 - } - } - return filepath.Clean(dest), "", nil -} - -func volumeNameLen(s string) int { - return len(filepath.VolumeName(s)) + return osutil.EvaluateToExistingPath(in) } diff --git a/bake/entitlements_test.go b/bake/entitlements_test.go index 445609941..8c68d82dd 100644 --- a/bake/entitlements_test.go +++ b/bake/entitlements_test.go @@ -506,12 +506,12 @@ func TestValidateEntitlementsInvalidLocalOutputMode(t *testing.T) { } func TestParseEntitlementsLocalOutputDelete(t *testing.T) { - conf, err := ParseEntitlements([]string{string(EntitlementKeyLocalOutputDelete)}) + conf, err := ParseEntitlements([]string{string(EntitlementKeyBuildxLocalDelete)}) require.NoError(t, err) require.True(t, conf.LocalOutputDelete) - _, err = ParseEntitlements([]string{string(EntitlementKeyLocalOutputDelete) + "=true"}) - require.ErrorContains(t, err, "local-output-delete does not accept a value") + _, err = ParseEntitlements([]string{string(EntitlementKeyBuildxLocalDelete) + "=true"}) + require.ErrorContains(t, err, "buildx.local.delete does not accept a value") } func TestPromptLocalOutputDeleteCannotBeDisabledWithFSEntitlements(t *testing.T) { @@ -524,7 +524,7 @@ func TestPromptLocalOutputDeleteCannotBeDisabledWithFSEntitlements(t *testing.T) err := EntitlementConf{LocalOutputDelete: true}.Prompt(ctx, true, &out) require.ErrorContains(t, err, "additional privileges requested") require.Contains(t, out.String(), "Deleting stale files from local output destinations") - require.Contains(t, out.String(), "--allow=local-output-delete") + require.Contains(t, out.String(), "--allow=buildx.local.delete") } func TestGroupSamePaths(t *testing.T) { diff --git a/build/opt.go b/build/opt.go index 8f0410aee..fc030ee96 100644 --- a/build/opt.go +++ b/build/opt.go @@ -1329,6 +1329,69 @@ func CreateExports(entries []*buildflags.ExportEntry) ([]client.ExportEntry, []s return outs, localPaths, nil } +func ValidateLocalExportDelete(outputs []client.ExportEntry, allowDelete bool) error { + for _, ex := range outputs { + if ex.Type != client.ExporterLocal { + continue + } + mode, err := client.ParseLocalExporterMode(ex.Attrs["mode"]) + if err != nil { + return err + } + if mode != client.LocalExporterModeDelete || allowDelete { + continue + } + ok, err := isSafeLocalDeleteDest(ex.OutputDir) + if err != nil { + return err + } + if !ok { + return errors.Errorf("local output mode=delete for destination %q requires --allow=%s", ex.OutputDir, buildflags.EntitlementBuildxLocalDelete) + } + } + return nil +} + +func isSafeLocalDeleteDest(dest string) (bool, error) { + wd, err := os.Getwd() + if err != nil { + return false, errors.Wrap(err, "failed to get current working directory") + } + wd, err = resolveOutputPath(wd) + if err != nil { + return false, errors.Wrap(err, "failed to evaluate current working directory") + } + + dest, err = resolveOutputPath(dest) + if err != nil { + return false, errors.Wrapf(err, "failed to evaluate local output destination %q", dest) + } + + rel, err := filepath.Rel(wd, dest) + if err != nil { + return false, nil + } + if rel == "." || rel == ".." || filepath.IsAbs(rel) || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return false, nil + } + return true, nil +} + +func resolveOutputPath(p string) (string, error) { + p, rest, err := osutil.EvaluateToExistingPath(p) + if err != nil { + return "", err + } + p, err = osutil.GetLongPathName(p) + if err != nil { + return "", err + } + if rest != "" { + p = filepath.Join(p, rest) + } + return filepath.Clean(p), nil +} + func wrapWriteCloser(wc io.WriteCloser) func(map[string]string) (io.WriteCloser, error) { return func(map[string]string) (io.WriteCloser, error) { return wc, nil diff --git a/commands/bake.go b/commands/bake.go index 119ea8af7..4bd70f896 100644 --- a/commands/bake.go +++ b/commands/bake.go @@ -331,7 +331,7 @@ func runBake(ctx context.Context, dockerCli command.Cli, targets []string, in ba } if progressMode == progressui.RawJSONMode { if exp.LocalOutputDelete { - return errors.Errorf("additional privileges requested: pass %q to grant requested privileges", "--allow="+string(bake.EntitlementKeyLocalOutputDelete)) + return errors.Errorf("additional privileges requested: pass %q to grant requested privileges", "--allow="+string(bake.EntitlementKeyBuildxLocalDelete)) } } else { if err := exp.Prompt(ctx, url != "", &syncWriter{w: dockerCli.Err(), wait: printer.Wait}); err != nil { diff --git a/commands/build.go b/commands/build.go index b9a3750a3..6c310cd6f 100644 --- a/commands/build.go +++ b/commands/build.go @@ -554,7 +554,7 @@ func buildCmd(dockerCli command.Cli, rootOpts *rootOptions, debugger debuggerOpt flags.StringSliceVar(&options.extraHosts, "add-host", []string{}, `Add a custom host-to-IP mapping (format: "host:ip")`) - flags.StringArrayVar(&options.allow, "allow", []string{}, `Allow extra privileged entitlement (e.g., "network.host", "security.insecure", "device")`) + flags.StringArrayVar(&options.allow, "allow", []string{}, `Allow extra privileged entitlement (e.g., "network.host", "security.insecure", "device", "buildx.local.delete")`) flags.StringArrayVarP(&options.annotations, "annotation", "", []string{}, "Add annotation to the image") @@ -1138,6 +1138,14 @@ func RunBuild(ctx context.Context, dockerCli command.Cli, in *BuildOptions, inSt } } + allow, allowLocalOutputDelete, err := buildflags.ParseEntitlements(in.Allow) + if err != nil { + return nil, nil, err + } + if err := build.ValidateLocalExportDelete(outputs, allowLocalOutputDelete); err != nil { + return nil, nil, err + } + opts.Annotations, err = buildflags.ParseAnnotations(in.Annotations) if err != nil { return nil, nil, errors.Wrap(err, "parse annotations") @@ -1159,10 +1167,6 @@ func RunBuild(ctx context.Context, dockerCli command.Cli, in *BuildOptions, inSt opts.SourcePolicy = in.SourcePolicy opts.Policy = in.Policy - allow, err := buildflags.ParseEntitlements(in.Allow) - if err != nil { - return nil, nil, err - } opts.Allow = allow if in.CallFunc != nil { diff --git a/docs/bake-reference.md b/docs/bake-reference.md index f347fbbdc..e473b2d1b 100644 --- a/docs/bake-reference.md +++ b/docs/bake-reference.md @@ -902,7 +902,7 @@ target "default" { ``` > [!NOTE] -> Local outputs with `mode=delete` require granting `--allow=local-output-delete` +> Local outputs with `mode=delete` require granting `--allow=buildx.local.delete` > when invoking `docker buildx bake`. ### `target.policy` diff --git a/docs/reference/buildx_bake.md b/docs/reference/buildx_bake.md index dd12d7358..c72b8fc11 100644 --- a/docs/reference/buildx_bake.md +++ b/docs/reference/buildx_bake.md @@ -85,7 +85,7 @@ The `fs` entitlements take a path value (relative or absolute) to a directory on the filesystem. Alternatively, you can pass a wildcard (`*`) to allow Bake to access the entire filesystem. -Bake also supports `--allow=local-output-delete` to grant local outputs +Bake also supports `--allow=buildx.local.delete` to grant local outputs permission to delete stale files when `mode=delete` is set. ### Example: fs.read diff --git a/docs/reference/buildx_build.md b/docs/reference/buildx_build.md index 41216ecc7..141b17d8b 100644 --- a/docs/reference/buildx_build.md +++ b/docs/reference/buildx_build.md @@ -16,7 +16,7 @@ Start a build | Name | Type | Default | Description | |:----------------------------------------|:--------------|:----------|:-------------------------------------------------------------------------------------------------------------------------------------------------| | [`--add-host`](#add-host) | `stringSlice` | | Add a custom host-to-IP mapping (format: `host:ip`) | -| [`--allow`](#allow) | `stringArray` | | Allow extra privileged entitlement (e.g., `network.host`, `security.insecure`, `device`) | +| [`--allow`](#allow) | `stringArray` | | Allow extra privileged entitlement (e.g., `network.host`, `security.insecure`, `device`, `buildx.local.delete`) | | [`--annotation`](#annotation) | `stringArray` | | Add annotation to the image | | [`--attest`](#attest) | `stringArray` | | Attestation parameters (format: `type=sbom,generator=image`) | | [`--build-arg`](#build-arg) | `stringArray` | | Set build-time variables | @@ -179,9 +179,14 @@ Allow extra privileged entitlement. List of entitlements: - `--allow device` - Grants access to all devices. - `--allow device=kind|name` - Grants access to a specific device. - `--allow device=kind|name,alias=kind|name` - Grants access to a specific device, with optional aliasing. +- `buildx.local.delete` - Allows local outputs using `mode=delete` to delete + stale destination files when the destination is the current working directory + or outside it. -For entitlements to be enabled, the BuildKit daemon also needs to allow them -with `--allow-insecure-entitlement` (see [`create --buildkitd-flags`](buildx_create.md#buildkitd-flags)). +For BuildKit entitlements to be enabled, the BuildKit daemon also needs to allow +them with `--allow-insecure-entitlement` (see [`create --buildkitd-flags`](buildx_create.md#buildkitd-flags)). +The `buildx.local.delete` entitlement is checked by Buildx and isn't sent to the +BuildKit daemon. ```console $ docker buildx create --use --name insecure-builder --buildkitd-flags '--allow-insecure-entitlement security.insecure' @@ -753,6 +758,11 @@ will be put in subdirectories by their platform. Attribute key: - `dest` - destination directory where files will be written +- `mode` - write mode, either `copy` or `delete`. The default is `copy`. + `delete` removes stale files from the destination after exporting the build + result. It can be used without `--allow` when `dest` resolves to a + subdirectory of the current working directory. If `dest` is the current working + directory or resolves outside it, pass `--allow=buildx.local.delete`. For more information, see [Local and tar exporters](https://docs.docker.com/build/exporters/local-tar/). diff --git a/docs/reference/buildx_dap_build.md b/docs/reference/buildx_dap_build.md index b5d030719..581597e6c 100644 --- a/docs/reference/buildx_dap_build.md +++ b/docs/reference/buildx_dap_build.md @@ -8,7 +8,7 @@ Start a build | Name | Type | Default | Description | |:--------------------|:--------------|:----------|:-------------------------------------------------------------------------------------------------------------------------------------------------| | `--add-host` | `stringSlice` | | Add a custom host-to-IP mapping (format: `host:ip`) | -| `--allow` | `stringArray` | | Allow extra privileged entitlement (e.g., `network.host`, `security.insecure`, `device`) | +| `--allow` | `stringArray` | | Allow extra privileged entitlement (e.g., `network.host`, `security.insecure`, `device`, `buildx.local.delete`) | | `--annotation` | `stringArray` | | Add annotation to the image | | `--attest` | `stringArray` | | Attestation parameters (format: `type=sbom,generator=image`) | | `--build-arg` | `stringArray` | | Set build-time variables | diff --git a/docs/reference/buildx_debug_build.md b/docs/reference/buildx_debug_build.md index 4c64ccd67..3ffc22b81 100644 --- a/docs/reference/buildx_debug_build.md +++ b/docs/reference/buildx_debug_build.md @@ -12,7 +12,7 @@ Start a build | Name | Type | Default | Description | |:--------------------|:--------------|:----------|:-------------------------------------------------------------------------------------------------------------------------------------------------| | `--add-host` | `stringSlice` | | Add a custom host-to-IP mapping (format: `host:ip`) | -| `--allow` | `stringArray` | | Allow extra privileged entitlement (e.g., `network.host`, `security.insecure`, `device`) | +| `--allow` | `stringArray` | | Allow extra privileged entitlement (e.g., `network.host`, `security.insecure`, `device`, `buildx.local.delete`) | | `--annotation` | `stringArray` | | Add annotation to the image | | `--attest` | `stringArray` | | Attestation parameters (format: `type=sbom,generator=image`) | | `--build-arg` | `stringArray` | | Set build-time variables | @@ -51,4 +51,3 @@ Start a build - diff --git a/tests/bake.go b/tests/bake.go index 791fc4a8a..c8a1d1ee4 100644 --- a/tests/bake.go +++ b/tests/bake.go @@ -45,6 +45,7 @@ var bakeTests = []func(t *testing.T, sb integration.Sandbox){ testBakePrintRemoteContextSubdir, testBakeLocal, testBakeLocalMulti, + testBakeLocalExportDeleteMode, testBakeRemote, testBakeRemoteAuth, testBakeRemoteCmdContext, @@ -673,6 +674,80 @@ services: require.FileExists(t, filepath.Join(dirDest2, "foo")) } +func testBakeLocalExportDeleteMode(t *testing.T, sb integration.Sandbox) { + dockerfile := []byte(` +FROM scratch +COPY foo /foo +`) + bakefile := []byte(` +target "default" { + output = ["type=local,dest=out,mode=delete"] +} +`) + + t.Run("definition requires allow", func(t *testing.T) { + dir := tmpdir( + t, + fstest.CreateFile("docker-bake.hcl", bakefile, 0600), + fstest.CreateFile("Dockerfile", dockerfile, 0600), + fstest.CreateFile("foo", []byte("foo"), 0600), + ) + + cmd := buildxCmd(sb, withDir(dir), withArgs("bake", "--progress=rawjson")) + out, err := cmd.CombinedOutput() + require.Error(t, err, string(out)) + require.Contains(t, string(out), "--allow=buildx.local.delete") + }) + + t.Run("set requires allow", func(t *testing.T) { + dir := tmpdir( + t, + fstest.CreateFile("docker-bake.hcl", []byte(`target "default" {}`), 0600), + fstest.CreateFile("Dockerfile", dockerfile, 0600), + fstest.CreateFile("foo", []byte("foo"), 0600), + ) + + cmd := buildxCmd(sb, withDir(dir), withArgs("bake", "--progress=rawjson", "--set", "*.output=type=local,dest=out,mode=delete")) + out, err := cmd.CombinedOutput() + require.Error(t, err, string(out)) + require.Contains(t, string(out), "--allow=buildx.local.delete") + }) + + t.Run("allow does not accept value", func(t *testing.T) { + dir := tmpdir( + t, + fstest.CreateFile("docker-bake.hcl", bakefile, 0600), + fstest.CreateFile("Dockerfile", dockerfile, 0600), + fstest.CreateFile("foo", []byte("foo"), 0600), + ) + + out, err := bakeCmd(sb, withDir(dir), withArgs("--allow=buildx.local.delete=true")) + require.Error(t, err, out) + require.Contains(t, out, "buildx.local.delete does not accept a value") + }) + + t.Run("allow deletes stale files", func(t *testing.T) { + skipNoCompatBuildKit(t, sb, ">= 0.31.0-0", "local exporter mode=delete") + + dir := tmpdir( + t, + fstest.CreateFile("docker-bake.hcl", bakefile, 0600), + fstest.CreateFile("Dockerfile", dockerfile, 0600), + fstest.CreateFile("foo", []byte("foo"), 0600), + ) + dest := filepath.Join(dir, "out") + stale := filepath.Join(dest, "stale") + require.NoError(t, os.MkdirAll(dest, 0o755)) + require.NoError(t, os.WriteFile(stale, []byte("stale"), 0o600)) + + out, err := bakeCmd(sb, withDir(dir), withArgs("--allow=buildx.local.delete")) + require.NoError(t, err, out) + require.FileExists(t, filepath.Join(dest, "foo")) + _, err = os.Stat(stale) + require.ErrorIs(t, err, os.ErrNotExist) + }) +} + func testBakeRemote(t *testing.T, sb integration.Sandbox) { bakefile := []byte(` target "default" { diff --git a/tests/build.go b/tests/build.go index 95c4c8579..d3a69500e 100644 --- a/tests/build.go +++ b/tests/build.go @@ -63,6 +63,7 @@ var buildTests = []func(t *testing.T, sb integration.Sandbox){ testBuildLocalStateRemote, testImageIDOutput, testBuildLocalExport, + testBuildLocalExportDeleteMode, testBuildRegistryExport, testBuildRegistryExportAttestations, testBuildTarExport, @@ -491,6 +492,69 @@ func testBuildLocalExport(t *testing.T, sb integration.Sandbox) { require.Equal(t, "foo", string(dt)) } +func testBuildLocalExportDeleteMode(t *testing.T, sb integration.Sandbox) { + t.Run("requires allow for current working directory", func(t *testing.T) { + dir := createTestProject(t) + out, err := buildCmd(sb, withDir(dir), withArgs("--output=type=local,dest=.,mode=delete", ".")) + require.Error(t, err, out) + require.Contains(t, out, "--allow=buildx.local.delete") + }) + + t.Run("requires allow for outside destination", func(t *testing.T) { + dir := createTestProject(t) + dest := filepath.Join(t.TempDir(), "out") + out, err := buildCmd(sb, withDir(dir), withArgs("--output=type=local,dest="+dest+",mode=delete", ".")) + require.Error(t, err, out) + require.Contains(t, out, "--allow=buildx.local.delete") + }) + + t.Run("requires allow for symlink outside destination", func(t *testing.T) { + dir := createTestProject(t) + require.NoError(t, os.Symlink(t.TempDir(), filepath.Join(dir, "out"))) + + out, err := buildCmd(sb, withDir(dir), withArgs("--output=type=local,dest=out,mode=delete", ".")) + require.Error(t, err, out) + require.Contains(t, out, "--allow=buildx.local.delete") + }) + + t.Run("allow does not accept value", func(t *testing.T) { + dir := createTestProject(t) + out, err := buildCmd(sb, withDir(dir), withArgs("--allow=buildx.local.delete=true", "--output=type=local,dest=out", ".")) + require.Error(t, err, out) + require.Contains(t, out, "buildx.local.delete does not accept a value") + }) + + t.Run("deletes stale files in subdirectory without allow", func(t *testing.T) { + skipNoCompatBuildKit(t, sb, ">= 0.31.0-0", "local exporter mode=delete") + + dir := createTestProject(t) + dest := filepath.Join(dir, "out") + stale := filepath.Join(dest, "stale") + require.NoError(t, os.MkdirAll(dest, 0o755)) + require.NoError(t, os.WriteFile(stale, []byte("stale"), 0o600)) + + out, err := buildCmd(sb, withDir(dir), withArgs("--output=type=local,dest=out,mode=delete", ".")) + require.NoError(t, err, out) + require.FileExists(t, filepath.Join(dest, "bar")) + _, err = os.Stat(stale) + require.ErrorIs(t, err, os.ErrNotExist) + }) + + t.Run("allow permits current working directory", func(t *testing.T) { + skipNoCompatBuildKit(t, sb, ">= 0.31.0-0", "local exporter mode=delete") + + dir := createTestProject(t) + stale := filepath.Join(dir, "stale") + require.NoError(t, os.WriteFile(stale, []byte("stale"), 0o600)) + + out, err := buildCmd(sb, withDir(dir), withArgs("--allow=buildx.local.delete", "--output=type=local,dest=.,mode=delete", ".")) + require.NoError(t, err, out) + require.FileExists(t, filepath.Join(dir, "bar")) + _, err = os.Stat(stale) + require.ErrorIs(t, err, os.ErrNotExist) + }) +} + func testBuildTarExport(t *testing.T, sb integration.Sandbox) { dir := createTestProject(t) outdir := path.Join(dir, "out") diff --git a/util/buildflags/entitlements.go b/util/buildflags/entitlements.go index 116b91d38..e484f5936 100644 --- a/util/buildflags/entitlements.go +++ b/util/buildflags/entitlements.go @@ -1,20 +1,32 @@ package buildflags import ( + "strings" + "github.com/moby/buildkit/util/entitlements" + "github.com/pkg/errors" ) -func ParseEntitlements(in []string) ([]string, error) { +const EntitlementBuildxLocalDelete = "buildx.local.delete" + +func ParseEntitlements(in []string) (_ []string, allowLocalOutputDelete bool, _ error) { out := make([]string, 0, len(in)) for _, v := range in { if v == "" { continue } - + k, _, hasValue := strings.Cut(v, "=") + if k == EntitlementBuildxLocalDelete { + if hasValue { + return nil, false, errors.Errorf("%s does not accept a value", EntitlementBuildxLocalDelete) + } + allowLocalOutputDelete = true + continue + } if _, _, err := entitlements.Parse(v); err != nil { - return nil, err + return nil, false, err } out = append(out, v) } - return out, nil + return out, allowLocalOutputDelete, nil } diff --git a/util/osutil/path.go b/util/osutil/path.go index d72fb0f67..26ed17ff3 100644 --- a/util/osutil/path.go +++ b/util/osutil/path.go @@ -1,8 +1,11 @@ package osutil import ( + "errors" + "io/fs" "os" "path/filepath" + "syscall" ) // GetWd retrieves the current working directory. @@ -28,3 +31,122 @@ func ToAbs(path string) string { } return SanitizePath(path) } + +func EvaluateToExistingPath(in string) (string, string, error) { + in, err := filepath.Abs(in) + if err != nil { + return "", "", err + } + + volLen := volumeNameLen(in) + pathSeparator := string(os.PathSeparator) + + if volLen < len(in) && os.IsPathSeparator(in[volLen]) { + volLen++ + } + vol := in[:volLen] + dest := vol + linksWalked := 0 + var end int + for start := volLen; start < len(in); start = end { + for start < len(in) && os.IsPathSeparator(in[start]) { + start++ + } + end = start + for end < len(in) && !os.IsPathSeparator(in[end]) { + end++ + } + + if end == start { + break + } else if in[start:end] == "." { + continue + } else if in[start:end] == ".." { + var r int + for r = len(dest) - 1; r >= volLen; r-- { + if os.IsPathSeparator(dest[r]) { + break + } + } + if r < volLen || dest[r+1:] == ".." { + if len(dest) > volLen { + dest += pathSeparator + } + dest += ".." + } else { + dest = dest[:r] + } + continue + } + + if len(dest) > volumeNameLen(dest) && !os.IsPathSeparator(dest[len(dest)-1]) { + dest += pathSeparator + } + dest += in[start:end] + + fi, err := os.Lstat(dest) + if err != nil { + if os.IsNotExist(err) { + for r := len(dest) - 1; r >= volLen; r-- { + if os.IsPathSeparator(dest[r]) { + return dest[:r], in[start:], nil + } + } + return vol, in[start:], nil + } + return "", "", err + } + + if fi.Mode()&fs.ModeSymlink == 0 { + if !fi.Mode().IsDir() && end < len(in) { + return "", "", syscall.ENOTDIR + } + continue + } + + linksWalked++ + if linksWalked > 255 { + return "", "", errors.New("too many symlinks") + } + + link, err := os.Readlink(dest) + if err != nil { + return "", "", err + } + + in = link + in[end:] + + v := volumeNameLen(link) + if v > 0 { + if v < len(link) && os.IsPathSeparator(link[v]) { + v++ + } + vol = link[:v] + dest = vol + end = len(vol) + } else if len(link) > 0 && os.IsPathSeparator(link[0]) { + dest = link[:1] + end = 1 + vol = link[:1] + volLen = 1 + } else { + var r int + for r = len(dest) - 1; r >= volLen; r-- { + if os.IsPathSeparator(dest[r]) { + break + } + } + if r < volLen { + dest = vol + } else { + dest = dest[:r] + } + end = 0 + } + } + return filepath.Clean(dest), "", nil +} + +func volumeNameLen(s string) int { + return len(filepath.VolumeName(s)) +}