From b33ef2c9d4f280c6c78ddd61adb8206f74c95a18 Mon Sep 17 00:00:00 2001 From: "Jonathan A. Sternberg" Date: Tue, 17 Mar 2026 10:31:11 -0500 Subject: [PATCH] dap: fix the check to determine whether exec will succeed This refines the check for determining whether exec will succeed to work when an error occurs. This check previously relied on the `Ref` being populated in the result context but this would only happen if we were paused from a breakpoint or by stepping. An error would not fill in this field. The check is now refined to use the new gateway filesystem exec API so we can create the container and then check even if we don't have a returned gateway reference. The logic to determine which mount to check has also been moved. Signed-off-by: Jonathan A. Sternberg --- build/invoke.go | 54 ++++++++++++++++++++++++++++++++++++++++++++++ build/result.go | 38 ++++++++++++++++---------------- dap/debug_shell.go | 44 +++++++------------------------------ 3 files changed, 80 insertions(+), 56 deletions(-) diff --git a/build/invoke.go b/build/invoke.go index 0b65fb08ca5e..72a0f095bb71 100644 --- a/build/invoke.go +++ b/build/invoke.go @@ -4,6 +4,9 @@ import ( "context" _ "crypto/sha256" // ensure digests can be computed "io" + "io/fs" + "os" + "path" "sync" "sync/atomic" "syscall" @@ -146,6 +149,57 @@ func (c *Container) Exec(ctx context.Context, cfg *InvokeConfig, stdin io.ReadCl return err } +func (c *Container) CanInvoke(ctx context.Context, cfg *InvokeConfig) error { + var cmd string + if len(cfg.Entrypoint) > 0 { + cmd = cfg.Entrypoint[0] + } else if len(cfg.Cmd) > 0 { + cmd = cfg.Cmd[0] + } + + if cmd == "" { + return errors.New("no command specified") + } + + const symlinkResolutionLimit = 40 + for range symlinkResolutionLimit { + fpath, index, err := c.resultCtx.inferMountIndex(cmd, cfg) + if err != nil { + return err + } + + st, err := c.container.StatFile(ctx, gateway.StatContainerRequest{ + StatRequest: gateway.StatRequest{ + Path: fpath, + }, + MountIndex: index, + }) + if err != nil { + return errors.Wrapf(err, "stat error: %s", cmd) + } + + mode := fs.FileMode(st.Mode) + if mode&os.ModeSymlink != 0 { + // Follow the link. + if path.IsAbs(st.Linkname) { + cmd = st.Linkname + } else { + cmd = path.Join(path.Dir(fpath), st.Linkname) + } + continue + } + + if !mode.IsRegular() { + return errors.Errorf("%s: not a file", cmd) + } + if mode&0o111 == 0 { + return errors.Errorf("%s: not an executable", cmd) + } + return nil + } + return errors.Errorf("%s: reached symlink resolution limit", cmd) +} + func (c *Container) ReadFile(ctx context.Context, req gateway.ReadContainerRequest) ([]byte, error) { return c.container.ReadFile(ctx, req) } diff --git a/build/result.go b/build/result.go index b940fce0d342..ce83eaffc9f7 100644 --- a/build/result.go +++ b/build/result.go @@ -7,7 +7,7 @@ import ( "encoding/json" "io" iofs "io/fs" - "path/filepath" + "path" "slices" "strings" "sync" @@ -19,7 +19,6 @@ import ( ocispecs "github.com/opencontainers/image-spec/specs-go/v1" "github.com/pkg/errors" "github.com/sirupsen/logrus" - "github.com/tonistiigi/fsutil/types" ) // NewResultHandle stores a gateway client, gateway reference, and the error from @@ -81,38 +80,37 @@ func (r *ResultHandle) NewContainer(ctx context.Context, cfg *InvokeConfig) (gat return r.gwClient.NewContainer(ctx, req) } -func (r *ResultHandle) StatFile(ctx context.Context, fpath string, cfg *InvokeConfig) (*types.Stat, error) { +func (r *ResultHandle) inferMountIndex(fpath string, cfg *InvokeConfig) (string, int, error) { containerCfg, err := r.getContainerConfig(cfg) if err != nil { - return nil, err + return "", 0, err + } + + type mountCandidate struct { + gateway.Mount + Index int } - candidateMounts := make([]gateway.Mount, 0, len(containerCfg.Mounts)) - for _, m := range containerCfg.Mounts { + candidateMounts := make([]mountCandidate, 0, len(containerCfg.Mounts)) + for i, m := range containerCfg.Mounts { if strings.HasPrefix(fpath, m.Dest) { - candidateMounts = append(candidateMounts, m) + candidateMounts = append(candidateMounts, mountCandidate{ + Mount: m, + Index: i, + }) } } if len(candidateMounts) == 0 { - return nil, iofs.ErrNotExist + return "", 0, iofs.ErrNotExist } - slices.SortFunc(candidateMounts, func(a, b gateway.Mount) int { + slices.SortFunc(candidateMounts, func(a, b mountCandidate) int { return cmp.Compare(len(a.Dest), len(b.Dest)) }) m := candidateMounts[len(candidateMounts)-1] - relpath, err := filepath.Rel(m.Dest, fpath) - if err != nil { - return nil, err - } - - if m.Ref == nil { - return nil, iofs.ErrNotExist - } - - req := gateway.StatRequest{Path: filepath.ToSlash(relpath)} - return m.Ref.StatFile(ctx, req) + relpath := strings.TrimPrefix(fpath, m.Dest) + return path.Join("/", relpath), m.Index, nil } func (r *ResultHandle) getContainerConfig(cfg *InvokeConfig) (containerCfg gateway.NewContainerRequest, _ error) { diff --git a/dap/debug_shell.go b/dap/debug_shell.go index 5ebb1c930bc5..2bfe84635da7 100644 --- a/dap/debug_shell.go +++ b/dap/debug_shell.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "io" - "io/fs" "net" "os" "path/filepath" @@ -192,14 +191,6 @@ func (s *shell) attach(ctx context.Context, f dap.StackFrame, rCtx *build.Result } }() - // Check if the entrypoint is executable. If it isn't, don't bother - // trying to invoke. - if reason, ok := s.canInvoke(ctx, rCtx, cfg); !ok { - writeLineF(in.Stdout, "Build container is not executable. (reason: %s)", reason) - <-ctx.Done() - return context.Cause(ctx) - } - if err := s.sem.Acquire(ctx, 1); err != nil { return err } @@ -211,6 +202,14 @@ func (s *shell) attach(ctx context.Context, f dap.StackFrame, rCtx *build.Result } defer ctr.Cancel() + // Check if the entrypoint is executable. If it isn't, don't bother + // trying to invoke. + if err := ctr.CanInvoke(ctx, cfg); err != nil { + writeLineF(in.Stdout, "Build container is not executable. (reason: %s)", err) + <-ctx.Done() + return context.Cause(ctx) + } + writeLineF(in.Stdout, "Running %s in build container from line %d.", strings.Join(append(cfg.Entrypoint, cfg.Cmd...), " "), f.Line, @@ -231,33 +230,6 @@ func (s *shell) attach(ctx context.Context, f dap.StackFrame, rCtx *build.Result return nil } -func (s *shell) canInvoke(ctx context.Context, rCtx *build.ResultHandle, cfg *build.InvokeConfig) (reason string, ok bool) { - var cmd string - if len(cfg.Entrypoint) > 0 { - cmd = cfg.Entrypoint[0] - } else if len(cfg.Cmd) > 0 { - cmd = cfg.Cmd[0] - } - - if cmd == "" { - return "no command specified", false - } - - st, err := rCtx.StatFile(ctx, cmd, cfg) - if err != nil { - return fmt.Sprintf("stat error: %s", err), false - } - - mode := fs.FileMode(st.Mode) - if !mode.IsRegular() { - return fmt.Sprintf("%s: not a file", cmd), false - } - if mode&0111 == 0 { - return fmt.Sprintf("%s: not an executable", cmd), false - } - return "", true -} - // SendRunInTerminalRequest will send the request to the client to attach to // the socket path that was created by Init. This is intended to be run // from the adapter and interact directly with the client.