From 1af4f05ba4786c9ac813b9b11d9ff80d161774ab Mon Sep 17 00:00:00 2001 From: Tonis Tiigi Date: Fri, 23 Aug 2024 16:36:23 +0300 Subject: [PATCH] bake: add filesystem entitlements support Signed-off-by: Tonis Tiigi --- bake/bake.go | 4 + bake/entitlements.go | 409 +++++++++++++++++++++++++++++++++++++- bake/entitlements_test.go | 393 ++++++++++++++++++++++++++++++++++++ build/build.go | 3 + commands/bake.go | 7 + 5 files changed, 806 insertions(+), 10 deletions(-) create mode 100644 bake/entitlements_test.go diff --git a/bake/bake.go b/bake/bake.go index 8e4077c0..fbea1dc3 100644 --- a/bake/bake.go +++ b/bake/bake.go @@ -1315,6 +1315,8 @@ func toBuildOpt(t *Target, inp *Input) (*build.Options, error) { if err != nil { return nil, err } + bo.SecretSpecs = secrets + secretAttachment, err := controllerapi.CreateSecrets(secrets) if err != nil { return nil, err @@ -1328,6 +1330,8 @@ func toBuildOpt(t *Target, inp *Input) (*build.Options, error) { if len(sshSpecs) == 0 && (buildflags.IsGitSSH(bi.ContextPath) || (inp != nil && buildflags.IsGitSSH(inp.URL))) { sshSpecs = append(sshSpecs, &controllerapi.SSH{ID: "default"}) } + bo.SSHSpecs = sshSpecs + sshAttachment, err := controllerapi.CreateSSH(sshSpecs) if err != nil { return nil, err diff --git a/bake/entitlements.go b/bake/entitlements.go index 030e812b..f1eddcfb 100644 --- a/bake/entitlements.go +++ b/bake/entitlements.go @@ -2,12 +2,17 @@ package bake import ( "bufio" + "cmp" "context" "fmt" "io" + "io/fs" "os" + "path/filepath" "slices" + "strconv" "strings" + "syscall" "github.com/containerd/console" "github.com/docker/buildx/build" @@ -67,10 +72,8 @@ func ParseEntitlements(in []string) (EntitlementConf, error) { conf.ImagePush = append(conf.ImagePush, v) conf.ImageLoad = append(conf.ImageLoad, v) default: - return conf, errors.Errorf("uknown entitlement key %q", k) + return conf, errors.Errorf("unknown entitlement key %q", k) } - - // TODO: dedupe slices and parent paths } } return conf, nil @@ -101,6 +104,66 @@ func (c EntitlementConf) check(bo build.Options, expected *EntitlementConf) erro } } } + + rwPaths := map[string]struct{}{} + roPaths := map[string]struct{}{} + + for _, out := range bo.Exports { + if out.Type == "local" { + if dest, ok := out.Attrs["dest"]; ok { + rwPaths[dest] = struct{}{} + } + } + if out.Type == "tar" { + if dest, ok := out.Attrs["dest"]; ok && dest != "-" { + rwPaths[dest] = struct{}{} + } + } + } + + for _, ce := range bo.CacheTo { + if ce.Type == "local" { + if dest, ok := ce.Attrs["dest"]; ok { + rwPaths[dest] = struct{}{} + } + } + } + + for _, ci := range bo.CacheFrom { + if ci.Type == "local" { + if src, ok := ci.Attrs["src"]; ok { + roPaths[src] = struct{}{} + } + + } + } + + for _, secret := range bo.SecretSpecs { + if secret.FilePath != "" { + roPaths[secret.FilePath] = struct{}{} + } + } + + for _, ssh := range bo.SSHSpecs { + for _, p := range ssh.Paths { + roPaths[p] = struct{}{} + } + if len(ssh.Paths) == 0 { + expected.SSH = true + } + } + + var err error + expected.FSRead, err = findMissingPaths(c.FSRead, roPaths) + if err != nil { + return err + } + + expected.FSWrite, err = findMissingPaths(c.FSWrite, rwPaths) + if err != nil { + return err + } + return nil } @@ -113,32 +176,67 @@ func (c EntitlementConf) Prompt(ctx context.Context, out io.Writer) error { var msgs []string var flags []string + // these warnings are currently disabled to give users time to update + var msgsFS []string + var flagsFS []string + if c.NetworkHost { msgs = append(msgs, " - Running build containers that can access host network") - flags = append(flags, "network.host") + flags = append(flags, string(EntitlementKeyNetworkHost)) } if c.SecurityInsecure { msgs = append(msgs, " - Running privileged containers that can make system changes") - flags = append(flags, "security.insecure") + flags = append(flags, string(EntitlementKeySecurityInsecure)) } - if len(msgs) == 0 { + if c.SSH { + msgsFS = append(msgsFS, " - Forwarding default SSH agent socket") + flagsFS = append(flagsFS, string(EntitlementKeySSH)) + } + + roPaths, rwPaths, commonPaths := groupSamePaths(c.FSRead, c.FSWrite) + + if len(commonPaths) > 0 { + for _, p := range commonPaths { + msgsFS = append(msgsFS, fmt.Sprintf(" - Read and write access to path %s", p)) + flagsFS = append(flagsFS, string(EntitlementKeyFS)+"="+p) + } + } + + if len(roPaths) > 0 { + for _, p := range roPaths { + msgsFS = append(msgsFS, fmt.Sprintf(" - Read access to path %s", p)) + flagsFS = append(flagsFS, string(EntitlementKeyFSRead)+"="+p) + } + } + + if len(rwPaths) > 0 { + for _, p := range rwPaths { + msgsFS = append(msgsFS, fmt.Sprintf(" - Write access to path %s", p)) + flagsFS = append(flagsFS, string(EntitlementKeyFSWrite)+"="+p) + } + } + + if len(msgs) == 0 && len(msgsFS) == 0 { return nil } fmt.Fprintf(out, "Your build is requesting privileges for following possibly insecure capabilities:\n\n") - for _, m := range msgs { + for _, m := range slices.Concat(msgs, msgsFS) { fmt.Fprintf(out, "%s\n", m) } for i, f := range flags { flags[i] = "--allow=" + f } + for i, f := range flagsFS { + flagsFS[i] = "--allow=" + f + } if term { - fmt.Fprintf(out, "\nIn order to not see this message in the future pass %q to grant requested privileges.\n", strings.Join(flags, " ")) + fmt.Fprintf(out, "\nIn order to not see this message in the future pass %q to grant requested privileges.\n", strings.Join(slices.Concat(flags, flagsFS), " ")) } else { - fmt.Fprintf(out, "\nPass %q to grant requested privileges.\n", strings.Join(flags, " ")) + fmt.Fprintf(out, "\nPass %q to grant requested privileges.\n", strings.Join(slices.Concat(flags, flagsFS), " ")) } args := append([]string(nil), os.Args...) @@ -149,7 +247,24 @@ func (c EntitlementConf) Prompt(ctx context.Context, out io.Writer) error { if idx != -1 { fmt.Fprintf(out, "\nYour full command with requested privileges:\n\n") - fmt.Fprintf(out, "%s %s %s\n\n", strings.Join(args[:idx+1], " "), strings.Join(flags, " "), strings.Join(args[idx+1:], " ")) + fmt.Fprintf(out, "%s %s %s\n\n", strings.Join(args[:idx+1], " "), strings.Join(slices.Concat(flags, flagsFS), " "), strings.Join(args[idx+1:], " ")) + } + + fsEntitlementsEnabled := false + v, fsEntitlementsSet := os.LookupEnv("BUILDX_BAKE_ENTITLEMENTS_FS") + if fsEntitlementsSet { + vv, err := strconv.ParseBool(v) + if err != nil { + return errors.Wrapf(err, "failed to parse BUILDX_BAKE_ENTITLEMENTS_FS value %q", v) + } + fsEntitlementsEnabled = vv + } + + if !fsEntitlementsEnabled && len(msgs) == 0 { + if !fsEntitlementsSet { + fmt.Fprintf(out, "This warning will become an error in a future release. To enable filesystem entitlements checks at the moment, set BUILDX_BAKE_ENTITLEMENTS_FS=1 .\n\n") + } + return nil } if term { @@ -173,3 +288,277 @@ func (c EntitlementConf) Prompt(ctx context.Context, out io.Writer) error { return errors.Errorf("additional privileges requested") } + +func isParentOrEqualPath(p, parent string) bool { + if p == parent || parent == "/" { + return true + } + if strings.HasPrefix(p, parent+string(filepath.Separator)) { + return true + } + return false +} + +func findMissingPaths(set []string, paths map[string]struct{}) ([]string, error) { + paths, err := evaluateToExistingPaths(paths) + if err != nil { + return nil, err + } + paths, err = dedupPaths(paths) + if err != nil { + return nil, err + } + + set, err = evaluatePaths(set) + if err != nil { + return nil, err + } + + out := make([]string, 0, len(paths)) +loop0: + for p := range paths { + for _, c := range set { + if isParentOrEqualPath(p, c) { + continue loop0 + } + } + out = append(out, p) + } + if len(out) == 0 { + return nil, nil + } + + slices.Sort(out) + + return out, nil +} + +func dedupPaths(in map[string]struct{}) (map[string]struct{}, error) { + wd, err := os.Getwd() + if err != nil { + return nil, err + } + + arr := make([]string, 0, len(in)) + for p := range in { + arr = append(arr, filepath.Clean(p)) + } + + slices.SortFunc(arr, func(a, b string) int { + return cmp.Compare(len(a), len(b)) + }) + + m := make(map[string]struct{}, len(arr)) +loop0: + for _, p := range arr { + for parent := range m { + if strings.HasPrefix(p, parent+string(filepath.Separator)) { + continue loop0 + } + } + m[p] = struct{}{} + } + + for p := range m { + rel, err := filepath.Rel(wd, p) + if err == nil { + if !strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + delete(m, p) + m[rel] = struct{}{} + } + } + } + + return m, nil +} + +func groupSamePaths(in1, in2 []string) ([]string, []string, []string) { + if in1 == nil || in2 == nil { + return in1, in2, nil + } + + slices.Sort(in1) + slices.Sort(in2) + + common := []string{} + i, j := 0, 0 + + for i < len(in1) && j < len(in2) { + switch { + case in1[i] == in2[j]: + common = append(common, in1[i]) + i++ + j++ + case in1[i] < in2[j]: + i++ + default: + j++ + } + } + + in1 = removeCommonPaths(in1, common) + in2 = removeCommonPaths(in2, common) + + return in1, in2, common +} + +func removeCommonPaths(in, common []string) []string { + filtered := make([]string, 0, len(in)) + commonIndex := 0 + for _, path := range in { + if commonIndex < len(common) && path == common[commonIndex] { + commonIndex++ + continue + } + filtered = append(filtered, path) + } + return filtered +} + +func evaluatePaths(in []string) ([]string, error) { + out := make([]string, 0, len(in)) + for _, p := range in { + v, err := filepath.Abs(p) + if err != nil { + return nil, err + } + v, err = filepath.EvalSymlinks(v) + if err != nil { + return nil, errors.Wrapf(err, "failed to evaluate path %q", p) + } + out = append(out, v) + } + return out, nil +} + +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) + if err != nil { + return nil, errors.Wrapf(err, "failed to evaluate path %q", p) + } + m[v] = struct{}{} + } + return m, nil +} + +func evaluateToExistingPath(in 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], nil + } + } + return vol, 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)) +} diff --git a/bake/entitlements_test.go b/bake/entitlements_test.go new file mode 100644 index 00000000..95f6f434 --- /dev/null +++ b/bake/entitlements_test.go @@ -0,0 +1,393 @@ +package bake + +import ( + "fmt" + "os" + "path/filepath" + "slices" + "testing" + + "github.com/docker/buildx/build" + "github.com/docker/buildx/controller/pb" + "github.com/moby/buildkit/client" + "github.com/moby/buildkit/util/entitlements" + "github.com/stretchr/testify/require" +) + +func TestEvaluateToExistingPath(t *testing.T) { + tempDir := t.TempDir() + + // Setup temporary directory structure for testing + existingFile := filepath.Join(tempDir, "existing_file") + err := os.WriteFile(existingFile, []byte("test"), 0644) + require.NoError(t, err) + + existingDir := filepath.Join(tempDir, "existing_dir") + err = os.Mkdir(existingDir, 0755) + require.NoError(t, err) + + symlinkToFile := filepath.Join(tempDir, "symlink_to_file") + err = os.Symlink(existingFile, symlinkToFile) + require.NoError(t, err) + + symlinkToDir := filepath.Join(tempDir, "symlink_to_dir") + err = os.Symlink(existingDir, symlinkToDir) + require.NoError(t, err) + + nonexistentPath := filepath.Join(tempDir, "nonexistent", "path", "file.txt") + + tests := []struct { + name string + input string + expected string + expectErr bool + }{ + { + name: "Existing file", + input: existingFile, + expected: existingFile, + expectErr: false, + }, + { + name: "Existing directory", + input: existingDir, + expected: existingDir, + expectErr: false, + }, + { + name: "Symlink to file", + input: symlinkToFile, + expected: existingFile, + expectErr: false, + }, + { + name: "Symlink to directory", + input: symlinkToDir, + expected: existingDir, + expectErr: false, + }, + { + name: "Non-existent path", + input: nonexistentPath, + expected: tempDir, + expectErr: false, + }, + { + name: "Non-existent intermediate path", + input: filepath.Join(tempDir, "nonexistent", "file.txt"), + expected: tempDir, + expectErr: false, + }, + { + name: "Root path", + input: "/", + expected: "/", + expectErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := evaluateToExistingPath(tt.input) + + if tt.expectErr { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, tt.expected, result) + } + }) + } +} + +func TestDedupePaths(t *testing.T) { + wd, err := os.Getwd() + require.NoError(t, err) + tcases := []struct { + in map[string]struct{} + out map[string]struct{} + }{ + { + in: map[string]struct{}{ + "/a/b/c": {}, + "/a/b/d": {}, + "/a/b/e": {}, + }, + out: map[string]struct{}{ + "/a/b/c": {}, + "/a/b/d": {}, + "/a/b/e": {}, + }, + }, + { + in: map[string]struct{}{ + "/a/b/c": {}, + "/a/b/c/d": {}, + "/a/b/c/d/e": {}, + "/a/b/../b/c": {}, + }, + out: map[string]struct{}{ + "/a/b/c": {}, + }, + }, + { + in: map[string]struct{}{ + filepath.Join(wd, "a/b/c"): {}, + filepath.Join(wd, "../aa"): {}, + filepath.Join(wd, "a/b"): {}, + filepath.Join(wd, "a/b/d"): {}, + filepath.Join(wd, "../aa/b"): {}, + }, + out: map[string]struct{}{ + "a/b": {}, + filepath.Join(wd, "../aa"): {}, + }, + }, + } + + for i, tc := range tcases { + t.Run(fmt.Sprintf("case%d", i), func(t *testing.T) { + out, err := dedupPaths(tc.in) + if err != nil { + require.NoError(t, err) + } + require.Equal(t, tc.out, out) + }) + } +} + +func TestValidateEntitlements(t *testing.T) { + dir1 := t.TempDir() + dir2 := t.TempDir() + + escapeLink := filepath.Join(dir1, "escape_link") + err := os.Symlink("../../aa", escapeLink) + require.NoError(t, err) + + tcases := []struct { + name string + conf EntitlementConf + opt build.Options + expected EntitlementConf + }{ + { + name: "No entitlements", + }, + { + name: "NetworkHostMissing", + opt: build.Options{ + Allow: []entitlements.Entitlement{ + entitlements.EntitlementNetworkHost, + }, + }, + expected: EntitlementConf{ + NetworkHost: true, + }, + }, + { + name: "NetworkHostSet", + conf: EntitlementConf{ + NetworkHost: true, + }, + opt: build.Options{ + Allow: []entitlements.Entitlement{ + entitlements.EntitlementNetworkHost, + }, + }, + expected: EntitlementConf{}, + }, + { + name: "SecurityAndNetworkHostMissing", + opt: build.Options{ + Allow: []entitlements.Entitlement{ + entitlements.EntitlementNetworkHost, + entitlements.EntitlementSecurityInsecure, + }, + }, + expected: EntitlementConf{ + NetworkHost: true, + SecurityInsecure: true, + }, + }, + { + name: "SecurityMissingAndNetworkHostSet", + conf: EntitlementConf{ + NetworkHost: true, + }, + opt: build.Options{ + Allow: []entitlements.Entitlement{ + entitlements.EntitlementNetworkHost, + entitlements.EntitlementSecurityInsecure, + }, + }, + expected: EntitlementConf{ + SecurityInsecure: true, + }, + }, + { + name: "SSHMissing", + opt: build.Options{ + SSHSpecs: []*pb.SSH{ + { + ID: "test", + }, + }, + }, + expected: EntitlementConf{ + SSH: true, + }, + }, + { + name: "ExportLocal", + opt: build.Options{ + Exports: []client.ExportEntry{ + { + Type: "local", + Attrs: map[string]string{ + "dest": dir1, + }, + }, + { + Type: "local", + Attrs: map[string]string{ + "dest": filepath.Join(dir1, "subdir"), + }, + }, + { + Type: "local", + Attrs: map[string]string{ + "dest": dir2, + }, + }, + }, + }, + expected: EntitlementConf{ + FSWrite: func() []string { + exp := []string{dir1, dir2} + slices.Sort(exp) + return exp + }(), + }, + }, + { + name: "SecretFromSubFile", + opt: build.Options{ + SecretSpecs: []*pb.Secret{ + { + FilePath: filepath.Join(dir1, "subfile"), + }, + }, + }, + conf: EntitlementConf{ + FSRead: []string{dir1}, + }, + }, + { + name: "SecretFromEscapeLink", + opt: build.Options{ + SecretSpecs: []*pb.Secret{ + { + FilePath: escapeLink, + }, + }, + }, + conf: EntitlementConf{ + FSRead: []string{dir1}, + }, + expected: EntitlementConf{ + FSRead: []string{filepath.Join(dir1, "../..")}, + }, + }, + { + name: "SecretFromEscapeLinkAllowRoot", + opt: build.Options{ + SecretSpecs: []*pb.Secret{ + { + FilePath: escapeLink, + }, + }, + }, + conf: EntitlementConf{ + FSRead: []string{"/"}, + }, + expected: EntitlementConf{}, + }, + } + + for _, tc := range tcases { + t.Run(tc.name, func(t *testing.T) { + expected, err := tc.conf.Validate(map[string]build.Options{"test": tc.opt}) + require.NoError(t, err) + require.Equal(t, tc.expected, expected) + }) + } +} + +func TestGroupSamePaths(t *testing.T) { + tests := []struct { + name string + in1 []string + in2 []string + expected1 []string + expected2 []string + expectedC []string + }{ + { + name: "All common paths", + in1: []string{"/path/a", "/path/b", "/path/c"}, + in2: []string{"/path/a", "/path/b", "/path/c"}, + expected1: []string{}, + expected2: []string{}, + expectedC: []string{"/path/a", "/path/b", "/path/c"}, + }, + { + name: "No common paths", + in1: []string{"/path/a", "/path/b"}, + in2: []string{"/path/c", "/path/d"}, + expected1: []string{"/path/a", "/path/b"}, + expected2: []string{"/path/c", "/path/d"}, + expectedC: []string{}, + }, + { + name: "Some common paths", + in1: []string{"/path/a", "/path/b", "/path/c"}, + in2: []string{"/path/b", "/path/c", "/path/d"}, + expected1: []string{"/path/a"}, + expected2: []string{"/path/d"}, + expectedC: []string{"/path/b", "/path/c"}, + }, + { + name: "Empty inputs", + in1: []string{}, + in2: []string{}, + expected1: []string{}, + expected2: []string{}, + expectedC: []string{}, + }, + { + name: "One empty input", + in1: []string{"/path/a", "/path/b"}, + in2: []string{}, + expected1: []string{"/path/a", "/path/b"}, + expected2: []string{}, + expectedC: []string{}, + }, + { + name: "Unsorted inputs with common paths", + in1: []string{"/path/c", "/path/a", "/path/b"}, + in2: []string{"/path/b", "/path/c", "/path/a"}, + expected1: []string{}, + expected2: []string{}, + expectedC: []string{"/path/a", "/path/b", "/path/c"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + out1, out2, common := groupSamePaths(tt.in1, tt.in2) + require.Equal(t, tt.expected1, out1, "in1 should match expected1") + require.Equal(t, tt.expected2, out2, "in2 should match expected2") + require.Equal(t, tt.expectedC, common, "common should match expectedC") + }) + } +} diff --git a/build/build.go b/build/build.go index 17cd7028..3a2e7663 100644 --- a/build/build.go +++ b/build/build.go @@ -18,6 +18,7 @@ import ( "github.com/containerd/containerd/images" "github.com/distribution/reference" "github.com/docker/buildx/builder" + controllerapi "github.com/docker/buildx/controller/pb" "github.com/docker/buildx/driver" "github.com/docker/buildx/util/confutil" "github.com/docker/buildx/util/desktop" @@ -76,6 +77,8 @@ type Options struct { NoCacheFilter []string Platforms []specs.Platform Pull bool + SecretSpecs []*controllerapi.Secret + SSHSpecs []*controllerapi.SSH ShmSize opts.MemBytes Tags []string Target string diff --git a/commands/bake.go b/commands/bake.go index 4737c138..4f2bc6f3 100644 --- a/commands/bake.go +++ b/commands/bake.go @@ -107,6 +107,13 @@ func runBake(ctx context.Context, dockerCli command.Cli, targets []string, in ba if err != nil { return err } + wd, err := os.Getwd() + if err != nil { + return errors.Wrapf(err, "failed to get current working directory") + } + // filesystem access under the current working directory is allowed by default + ent.FSRead = append(ent.FSRead, wd) + ent.FSWrite = append(ent.FSWrite, wd) ctx2, cancel := context.WithCancelCause(context.TODO()) defer cancel(errors.WithStack(context.Canceled))