Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions runtime/docker/inspect.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ func (r *Runtime) InspectImage(ctx context.Context, ref string) (*runtime.ImageD
if res.Config != nil {
out.Labels = copyLabels(res.Config.Labels)
out.Env = append([]string(nil), res.Config.Env...)
out.User = res.Config.User
}
return out, nil
}
Expand Down
6 changes: 6 additions & 0 deletions runtime/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,12 @@ type ImageDetails struct {
Tags []string
Labels map[string]string
Env []string

// User is the image's default USER directive (Config.User), or "" if
// unset. Used to determine the effective container user for UID
// reconciliation when devcontainer.json's remoteUser/containerUser
// are also empty.
User string
}

// MountInspect describes a mount as reported by the runtime, not what
Expand Down
10 changes: 10 additions & 0 deletions up.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,11 @@ func (e *Engine) createFresh(ctx context.Context, cfg *config.ResolvedConfig, op
}
applyMetadataMerge(cfg, baseLayers)

finalImage, err = e.reconcileRemoteUserUID(ctx, cfg, finalImage, opts)
if err != nil {
return nil, err
}

spec := buildRunSpec(cfg, finalImage, opts.ExtraMounts, opts.ExtraContainerEnv)
c, err := e.runtime.RunContainer(ctx, spec)
if err != nil {
Expand Down Expand Up @@ -384,6 +389,11 @@ func (e *Engine) createFreshCompose(ctx context.Context, cfg *config.ResolvedCon
}
applyMetadataMerge(cfg, baseLayers)

finalImage, err = e.reconcileRemoteUserUID(ctx, cfg, finalImage, opts)
if err != nil {
return nil, err
}

tmp, err := os.MkdirTemp("", "dc-go-compose-*")
if err != nil {
return nil, fmt.Errorf("create compose override tmpdir: %w", err)
Expand Down
147 changes: 147 additions & 0 deletions useruid.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package devcontainer

import (
"context"
"fmt"
"os"
"path/filepath"
"runtime"
"strconv"
"syscall"

"github.com/crunchloop/devcontainer/config"
dcruntime "github.com/crunchloop/devcontainer/runtime"
)

// reconcileRemoteUserUID mirrors @devcontainers/cli's updateRemoteUserUID
// behavior: if the host workspace folder is owned by a UID/GID that
// differs from the container user's UID/GID, derive a new image (tagged
// with a "-uid" suffix) whose user has been groupmod/usermod-ed to
// match. Returns the image to use for container creation — either the
// derived image, or finalImage unchanged if no reconciliation applies.
//
// Skips (returns finalImage unchanged, no error) when:
// - cfg.UpdateRemoteUserUID is explicitly false
// - host is not Linux/Darwin (Stat_t.Uid not portably available)
// - LocalWorkspaceFolder cannot be stat-ed
// - host UID is 0 (root can write anywhere)
// - effective container user resolves to root / "0" / empty
//
// We always emit the conditional Dockerfile rather than probing the
// existing user's UID first: the build-time check (`id -u $user`) makes
// the layer a no-op when UIDs already match, and avoids spinning a
// throwaway container just to read /etc/passwd. Costs one cached layer
// in the steady state.
func (e *Engine) reconcileRemoteUserUID(ctx context.Context, cfg *config.ResolvedConfig, finalImage string, opts UpOptions) (string, error) {
if cfg.UpdateRemoteUserUID != nil && !*cfg.UpdateRemoteUserUID {
return finalImage, nil
}
if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
return finalImage, nil
}

hostUID, hostGID, ok := statOwner(cfg.LocalWorkspaceFolder)
if !ok {
return finalImage, nil
}
if hostUID == 0 {
return finalImage, nil
}

user := effectiveContainerUser(ctx, e.runtime, cfg, finalImage)
if user == "" || user == "root" || user == "0" {
return finalImage, nil
}

tag := finalImage + "-uid"
// If `finalImage` is itself a tagged ref like "name:tag", append the
// suffix to the tag rather than producing "name:tag-uid" — both are
// valid, but the latter still parses, and matches upstream's tagging.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
tmp, err := os.MkdirTemp("", "dc-go-uid-*")
if err != nil {
return "", fmt.Errorf("create uid build context: %w", err)
}
defer os.RemoveAll(tmp)

df := generateUIDDockerfile(finalImage, user, hostUID, hostGID)
if err := os.WriteFile(filepath.Join(tmp, "Dockerfile"), []byte(df), 0o644); err != nil {
return "", err
}
if _, err := e.runtime.BuildImage(ctx, dcruntime.BuildSpec{
ContextPath: tmp,
Dockerfile: "Dockerfile",
Tag: tag,
}, opts.Events); err != nil {
return "", fmt.Errorf("build uid-reconciled image: %w", err)
}
return tag, nil
}

// effectiveContainerUser resolves the container user that the workspace
// will run as, using the spec's precedence: remoteUser > containerUser >
// image's default USER.
func effectiveContainerUser(ctx context.Context, rt dcruntime.Runtime, cfg *config.ResolvedConfig, image string) string {
if cfg.RemoteUser != "" {
return cfg.RemoteUser
}
if cfg.ContainerUser != "" {
return cfg.ContainerUser
}
details, err := rt.InspectImage(ctx, image)
if err != nil || details == nil {
return ""
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return details.User
}

// statOwner returns the UID/GID of path on Unix-like systems. ok=false
// if path doesn't exist or its stat info isn't a *syscall.Stat_t.
func statOwner(path string) (uid, gid int, ok bool) {
fi, err := os.Stat(path)
if err != nil {
return 0, 0, false
}
st, stOk := fi.Sys().(*syscall.Stat_t)
if !stOk {
return 0, 0, false
}
return int(st.Uid), int(st.Gid), true
}

// generateUIDDockerfile produces a single-stage Dockerfile that
// reconciles `user`'s UID/GID to (hostUID, hostGID) when they differ
// in the base image. The conditional keeps the resulting layer a near
// no-op when UIDs already match (idempotent rebuilds).
func generateUIDDockerfile(baseImage, user string, hostUID, hostGID int) string {
return "# syntax=docker/dockerfile:1.4\n" +
"ARG _DEV_CONTAINERS_BASE_IMAGE=" + baseImage + "\n" +
"FROM $_DEV_CONTAINERS_BASE_IMAGE\n" +
"USER root\n" +
"ARG _REMOTE_USER=" + user + "\n" +
"ARG _REMOTE_USER_UID=" + strconv.Itoa(hostUID) + "\n" +
"ARG _REMOTE_USER_GID=" + strconv.Itoa(hostGID) + "\n" +
"RUN set -e; \\\n" +
" if ! id -u \"$_REMOTE_USER\" >/dev/null 2>&1; then \\\n" +
" echo \"updateRemoteUserUID: user $_REMOTE_USER not found in image; skipping\" >&2; \\\n" +
" exit 0; \\\n" +
" fi; \\\n" +
" CUR_UID=$(id -u \"$_REMOTE_USER\"); \\\n" +
" CUR_GID=$(id -g \"$_REMOTE_USER\"); \\\n" +
" if [ \"$CUR_UID\" = \"$_REMOTE_USER_UID\" ] && [ \"$CUR_GID\" = \"$_REMOTE_USER_GID\" ]; then \\\n" +
" exit 0; \\\n" +
" fi; \\\n" +
" OLD_GROUP=$(id -gn \"$_REMOTE_USER\"); \\\n" +
" HOME_DIR=$(getent passwd \"$_REMOTE_USER\" | cut -d: -f6); \\\n" +
" if [ \"$CUR_GID\" != \"$_REMOTE_USER_GID\" ]; then \\\n" +
" if getent group \"$_REMOTE_USER_GID\" >/dev/null; then \\\n" +
" usermod --gid \"$_REMOTE_USER_GID\" \"$_REMOTE_USER\"; \\\n" +
" else \\\n" +
" groupmod --gid \"$_REMOTE_USER_GID\" \"$OLD_GROUP\"; \\\n" +
" fi; \\\n" +
" fi; \\\n" +
" usermod --uid \"$_REMOTE_USER_UID\" \"$_REMOTE_USER\"; \\\n" +
" if [ -n \"$HOME_DIR\" ] && [ -d \"$HOME_DIR\" ]; then \\\n" +
" chown -R \"$_REMOTE_USER_UID:$_REMOTE_USER_GID\" \"$HOME_DIR\"; \\\n" +
" fi\n"
}
61 changes: 61 additions & 0 deletions useruid_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package devcontainer

import (
"context"
"strings"
"testing"

"github.com/crunchloop/devcontainer/config"
)

func TestReconcileRemoteUserUID_SkipsWhenDisabled(t *testing.T) {
rt := newFakeRuntime()
eng := &Engine{runtime: rt}
f := false
cfg := &config.ResolvedConfig{
LocalWorkspaceFolder: t.TempDir(),
RemoteUser: "vscode",
UpdateRemoteUserUID: &f,
}
got, err := eng.reconcileRemoteUserUID(context.Background(), cfg, "img:tag", UpOptions{})
if err != nil {
t.Fatalf("err: %v", err)
}
if got != "img:tag" {
t.Fatalf("expected unchanged image, got %q", got)
}
}

func TestReconcileRemoteUserUID_SkipsWhenUserIsRoot(t *testing.T) {
rt := newFakeRuntime()
eng := &Engine{runtime: rt}
cfg := &config.ResolvedConfig{
LocalWorkspaceFolder: t.TempDir(),
RemoteUser: "root",
}
got, err := eng.reconcileRemoteUserUID(context.Background(), cfg, "img:tag", UpOptions{})
if err != nil {
t.Fatalf("err: %v", err)
}
if got != "img:tag" {
t.Fatalf("expected unchanged image, got %q", got)
}
}

func TestGenerateUIDDockerfile_ContainsKeyDirectives(t *testing.T) {
df := generateUIDDockerfile("base:latest", "vscode", 1001, 65534)
for _, want := range []string{
"FROM $_DEV_CONTAINERS_BASE_IMAGE",
"ARG _DEV_CONTAINERS_BASE_IMAGE=base:latest",
"ARG _REMOTE_USER=vscode",
"ARG _REMOTE_USER_UID=1001",
"ARG _REMOTE_USER_GID=65534",
"usermod --uid",
"groupmod --gid",
"chown -R",
} {
if !strings.Contains(df, want) {
t.Errorf("dockerfile missing %q\n--\n%s", want, df)
}
}
}
Loading