diff --git a/e2e/tests/ide/browser_returns.go b/e2e/tests/ide/browser_returns.go index 2e0cecd08..c704c5afa 100644 --- a/e2e/tests/ide/browser_returns.go +++ b/e2e/tests/ide/browser_returns.go @@ -271,6 +271,44 @@ var _ = ginkgo.Describe( }, ) + ginkgo.It( + "leaves ~/.local writable by a non-root remoteUser after code-server settings install", + ginkgo.SpecTimeout(framework.TimeoutLong()), + func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + "/bin") + tempDir, err := framework.CopyToTempDir("tests/ide/testdata-codeserver-nonroot") + framework.ExpectNoError(err) + ginkgo.DeferCleanup(framework.CleanupTempDir, initialDir, tempDir) + + err = f.DevsyProviderAdd(ctx, "docker") + framework.ExpectNoError(err) + err = f.DevsyProviderUse(ctx, "docker") + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func(cleanupCtx context.Context) { + _ = f.DevsyWorkspaceDelete(cleanupCtx, tempDir) + }) + + err = f.DevsyUpWithIDE(ctx, + "--ide=code-server", "--ide-launch=headless", tempDir) + framework.ExpectNoError(err) + + for _, dir := range []string{"$HOME/.local", "$HOME/.local/share"} { + owner, err := f.DevsySSH(ctx, tempDir, "stat -c %U "+dir) + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(owner)).To(gomega.Equal("devsyuser"), + "%s should be owned by the remote user, not root", dir) + } + + out, err := f.DevsySSH(ctx, tempDir, + "mkdir -p $HOME/.local/lib && echo ok") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(out)).To(gomega.Equal("ok"), + "remote user should be able to create ~/.local/lib") + + framework.ExpectNoError(f.DevsyStop(ctx, tempDir)) + }, + ) + ginkgo.It( "does not log 'setup KubeConfig' on a workspace without kubeconfig forwarding", ginkgo.SpecTimeout(framework.TimeoutLong()), diff --git a/e2e/tests/ide/testdata-codeserver-nonroot/.devcontainer.json b/e2e/tests/ide/testdata-codeserver-nonroot/.devcontainer.json new file mode 100644 index 000000000..119335414 --- /dev/null +++ b/e2e/tests/ide/testdata-codeserver-nonroot/.devcontainer.json @@ -0,0 +1,14 @@ +{ + "name": "code-server non-root", + "build": { + "dockerfile": "Dockerfile" + }, + "remoteUser": "devsyuser", + "customizations": { + "vscode": { + "settings": { + "editor.tabSize": 2 + } + } + } +} diff --git a/e2e/tests/ide/testdata-codeserver-nonroot/Dockerfile b/e2e/tests/ide/testdata-codeserver-nonroot/Dockerfile new file mode 100644 index 000000000..cd189e76a --- /dev/null +++ b/e2e/tests/ide/testdata-codeserver-nonroot/Dockerfile @@ -0,0 +1,3 @@ +FROM ghcr.io/devsy-org/test-images/base:ubuntu + +RUN useradd --create-home --shell /bin/bash devsyuser diff --git a/pkg/copy/copy.go b/pkg/copy/copy.go index 183115c9e..628f715d9 100644 --- a/pkg/copy/copy.go +++ b/pkg/copy/copy.go @@ -58,6 +58,29 @@ func ChownR(path string, userName string) error { }) } +func MkdirAllChown(path string, perm os.FileMode, userName string) error { + var created []string + for cur := filepath.Clean(path); !Exists(cur); { + created = append(created, cur) + parent := filepath.Dir(cur) + if parent == cur { + break + } + cur = parent + } + + if err := os.MkdirAll(path, perm); err != nil { + return err + } + + for _, dir := range created { + if err := Chown(dir, userName); err != nil { + return err + } + } + return nil +} + func RenameDirectory(srcDir, dest string) error { err := Directory(srcDir, dest) if err != nil { diff --git a/pkg/copy/copy_test.go b/pkg/copy/copy_test.go new file mode 100644 index 000000000..215a5b022 --- /dev/null +++ b/pkg/copy/copy_test.go @@ -0,0 +1,77 @@ +//go:build linux || darwin || unix + +package copy + +import ( + "os" + "os/user" + "path/filepath" + "syscall" + "testing" +) + +func currentUserName(t *testing.T) string { + t.Helper() + u, err := user.Current() + if err != nil { + t.Fatalf("current user: %v", err) + } + return u.Username +} + +func ownerUID(t *testing.T, path string) uint32 { + t.Helper() + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat %s: %v", path, err) + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + t.Fatalf("no syscall stat for %s", path) + } + return stat.Uid +} + +func TestMkdirAllChownCreatesAndOwnsNewDirs(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "a", "b", "c") + + if err := MkdirAllChown(target, 0o755, currentUserName(t)); err != nil { + t.Fatalf("MkdirAllChown: %v", err) + } + + self := os.Getuid() + for _, dir := range []string{ + filepath.Join(root, "a"), + filepath.Join(root, "a", "b"), + target, + } { + if got := int(ownerUID(t, dir)); got != self { + t.Errorf("dir %s owned by uid %d, want %d", dir, got, self) + } + } +} + +func TestMkdirAllChownIdempotentOnExisting(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "x", "y") + + if err := MkdirAllChown(target, 0o755, currentUserName(t)); err != nil { + t.Fatalf("first call: %v", err) + } + if err := MkdirAllChown(target, 0o755, currentUserName(t)); err != nil { + t.Fatalf("second call on existing path: %v", err) + } +} + +func TestMkdirAllChownEmptyUserSkipsChown(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "p", "q") + + if err := MkdirAllChown(target, 0o755, ""); err != nil { + t.Fatalf("MkdirAllChown empty user: %v", err) + } + if !Exists(target) { + t.Fatalf("expected %s to be created", target) + } +} diff --git a/pkg/ide/codeserver/codeserver.go b/pkg/ide/codeserver/codeserver.go index e90955b39..bcc8139fb 100644 --- a/pkg/ide/codeserver/codeserver.go +++ b/pkg/ide/codeserver/codeserver.go @@ -253,8 +253,7 @@ func (c *CodeServer) installSettings() error { } codeServerDataDir := filepath.Join(homeFolder, ".local", "share", "code-server") settingsDir := filepath.Join(codeServerDataDir, "User") - // #nosec G301 -- match openvscode-server convention for parity. - if err := os.MkdirAll(settingsDir, 0o755); err != nil { + if err := copy2.MkdirAllChown(settingsDir, 0o755, c.userName); err != nil { return err } if err := os.WriteFile( diff --git a/pkg/ide/rstudio/rstudio.go b/pkg/ide/rstudio/rstudio.go index 4e4dee80c..bfffc3fda 100644 --- a/pkg/ide/rstudio/rstudio.go +++ b/pkg/ide/rstudio/rstudio.go @@ -319,11 +319,7 @@ func setupPreferences(workspaceFolder, userName string) error { return fmt.Errorf("get home dir") } prefsDir := filepath.Join(homeDir, ".config", "rstudio") - err = os.MkdirAll(prefsDir, os.ModePerm) - if err != nil { - return err - } - err = copypkg.ChownR(prefsDir, userName) + err = copypkg.MkdirAllChown(prefsDir, os.ModePerm, userName) if err != nil { return err }