build: gate local delete outputs

Signed-off-by: CrazyMax <1951866+crazy-max@users.noreply.github.com>
This commit is contained in:
CrazyMax
2026-06-10 16:41:10 +02:00
parent 473c6ef306
commit b6e1b7328f
14 changed files with 380 additions and 148 deletions
+9 -126
View File
@@ -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)
}
+4 -4
View File
@@ -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) {
+63
View File
@@ -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
+1 -1
View File
@@ -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 {
+9 -5
View File
@@ -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 {
+1 -1
View File
@@ -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`
+1 -1
View File
@@ -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
+13 -3
View File
@@ -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/).
+1 -1
View File
@@ -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 |
+1 -2
View File
@@ -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
<!---MARKER_GEN_END-->
+75
View File
@@ -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" {
+64
View File
@@ -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")
+16 -4
View File
@@ -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
}
+122
View File
@@ -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))
}