diff --git a/internal/commands/connect.go b/internal/commands/connect.go index 8926e9a19..d668f5a79 100644 --- a/internal/commands/connect.go +++ b/internal/commands/connect.go @@ -620,6 +620,7 @@ func runConnectSetup(cmd *cobra.Command, app *appctx.App, f *connectSetupFlags) report.Add(checks...) report.Add(setup.TicketCheck(ctx, reader, kind)) report.Add(setup.ProjectChecks(ctx, reader, next, !exists)...) + report.Add(workFolderCheck()) // A command the person stopped did not find the connector unready: it // found nothing, and says so as an interruption. if err := ctx.Err(); err != nil { diff --git a/internal/commands/connect_doctor.go b/internal/commands/connect_doctor.go index 9953e8408..3778cbb83 100644 --- a/internal/commands/connect_doctor.go +++ b/internal/commands/connect_doctor.go @@ -81,6 +81,7 @@ func runConnectDoctor(cmd *cobra.Command, _ []string) error { } checks = append(checks, ledgerChecks(ctx, p)...) checks = append(checks, workerChecks(ctx, p.file)...) + checks = append(checks, workFolderCheck()) checks = append(checks, mcpHandshakeCheck(ctx, p.name)) result := summarizeChecks(asDoctorChecks(checks)) diff --git a/internal/commands/connect_workfolder.go b/internal/commands/connect_workfolder.go new file mode 100644 index 000000000..a7743d156 --- /dev/null +++ b/internal/commands/connect_workfolder.go @@ -0,0 +1,119 @@ +package commands + +import ( + "fmt" + "os" + "os/user" + "path/filepath" + + "github.com/basecamp/basecamp-cli/internal/connector/setup" + "github.com/basecamp/basecamp-cli/internal/richtext" +) + +// workFolderCheck says which folder workers run in: the one the connector is +// started from (connector.DispatcherOptions.WorkDir), which setup and doctor +// take to be the one they run in. Workers may change files there without +// asking, so the home directory, or a filesystem root, is a warning. +func workFolderCheck() setup.Check { + c := setup.Check{Name: "Work folder"} + dir, err := os.Getwd() + if err == nil { + // Workers enter the folder by its path (cmd.Dir), and getcwd can + // return a path that can no longer be entered: a parent, or the + // folder itself, lost its search permission after the shell went in. + // The path resolving says the parents can be searched; "." resolving + // says the folder itself can, without lengthening the path. + if _, err = os.Stat(dir); err == nil { + _, err = os.Stat(".") + } + } + if err != nil { + // The connector can't start without it (connector.NewDispatcher). + c.Status = setup.StatusFail + c.Message = "Couldn't read which folder this is: " + richtext.SanitizeSingleLine(err.Error()) + c.Hint = "Change to a folder that exists, then run this again." + return c + } + // The path is shown on one line: a folder name can carry newlines or + // terminal escapes, and check messages are printed as they are. + shown := richtext.SanitizeSingleLine(dir) + home := homeFolder() + switch { + case home != "" && samePath(dir, home): + c.Status = setup.StatusWarn + c.Message = fmt.Sprintf("Started from here, the agent works in %s, your home folder, and may change files anywhere in it without asking", shown) + case isFilesystemRoot(dir): + c.Status = setup.StatusWarn + c.Message = fmt.Sprintf("Started from here, the agent works in %s, the root of the filesystem, and may change any file it can reach without asking", shown) + case home == "": + c.Status = setup.StatusWarn + c.Message = fmt.Sprintf("Started from here, the agent works in %s and may change files in it without asking. Couldn't tell where your home folder is, so couldn't check this isn't it", shown) + default: + c.Status = setup.StatusPass + c.Message = fmt.Sprintf("Started from here, the agent works in %s and may change files in it without asking", shown) + return c + } + c.Hint = "Start the connector from a folder made for the agent, or from the project it should work on." + return c +} + +// homeFolder is where the person's home folder is: $HOME (USERPROFILE on +// Windows), or else the account's own record, or "" when neither names a +// folder that can be looked at. A home that can't be looked at can't be +// compared with, so it counts as unknown rather than as somewhere else. +func homeFolder() string { + if home, err := os.UserHomeDir(); err == nil && reachable(home) { + return home + } + if home, err := accountHomeFolder(); err == nil && home != "" && reachable(home) { + return home + } + return "" +} + +// reachable reports whether path is a folder that can be looked at: a home +// that is a file, or can't be read, can't be compared with. +func reachable(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() +} + +// accountHomeFolder reads the home folder from the account's record. A +// variable so a test can stand in for the system's user database. +var accountHomeFolder = func() (string, error) { + u, err := user.Current() + if err != nil { + return "", err + } + return u.HomeDir, nil +} + +// isFilesystemRoot reports whether dir is a root: / on Unix, a volume root +// such as C:\ or a UNC share on Windows. A root is its own parent, once +// symlinks are resolved: /proc/self/root is / by another name. +func isFilesystemRoot(dir string) bool { + // Identity first: a path can reach the root through more symlinks than + // EvalSymlinks can expand within the path-length limit. + if samePath(dir, string(filepath.Separator)) { + return true + } + // Resolve the path as given: cleaning first would turn a path like + // /proc/self/root/.. into /proc/self, which is not where it leads. + clean := filepath.Clean(dir) + if resolved, err := filepath.EvalSymlinks(dir); err == nil { + clean = resolved + } + return filepath.Dir(clean) == clean +} + +// samePath reports whether two paths name the same folder: the same file on +// disk, whatever the path's spelling, symlinks or letter case (a Mac's +// filesystem ignores case). Paths that can't be read compare as cleaned text. +func samePath(a, b string) bool { + ia, errA := os.Stat(a) + ib, errB := os.Stat(b) + if errA != nil || errB != nil { + return filepath.Clean(a) == filepath.Clean(b) + } + return os.SameFile(ia, ib) +} diff --git a/internal/commands/connect_workfolder_test.go b/internal/commands/connect_workfolder_test.go new file mode 100644 index 000000000..371bd6865 --- /dev/null +++ b/internal/commands/connect_workfolder_test.go @@ -0,0 +1,266 @@ +package commands + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/basecamp/basecamp-cli/internal/connector/setup" + "github.com/basecamp/basecamp-cli/internal/richtext" +) + +// Workers run in the folder the connector starts in and may change files +// there without asking, so starting from the home folder is a warning. +func TestWorkFolderCheckWarnsInTheHomeFolder(t *testing.T) { + home := t.TempDir() + setHome(t, home) + t.Chdir(home) + + c := workFolderCheck() + assert.Equal(t, setup.StatusWarn, c.Status) + assert.Contains(t, c.Message, currentFolder(t)+", your home folder") + assert.NotEmpty(t, c.Hint) +} + +func TestWorkFolderCheckPassesInAFolderOfItsOwn(t *testing.T) { + home := t.TempDir() + setHome(t, home) + work := filepath.Join(home, "agent") + require.NoError(t, os.Mkdir(work, 0o700)) + t.Chdir(work) + + c := workFolderCheck() + assert.Equal(t, setup.StatusPass, c.Status) + assert.Contains(t, c.Message, currentFolder(t)) + assert.NotContains(t, c.Message, "home folder") +} + +// A symlink to the home folder is still the home folder. +func TestWorkFolderCheckSeesThroughASymlinkToHome(t *testing.T) { + home := t.TempDir() + setHome(t, home) + link := filepath.Join(t.TempDir(), "home-link") + require.NoError(t, os.Symlink(home, link)) + t.Chdir(link) + + assert.Equal(t, setup.StatusWarn, workFolderCheck().Status) +} + +// The filesystem root is a warning too, and says so rather than calling it +// the home folder. +func TestWorkFolderCheckWarnsAtTheFilesystemRoot(t *testing.T) { + setHome(t, t.TempDir()) + t.Chdir(string(filepath.Separator)) + + c := workFolderCheck() + assert.Equal(t, setup.StatusWarn, c.Status) + assert.Contains(t, c.Message, "the root of the filesystem") + assert.NotContains(t, c.Message, "home folder") +} + +func TestIsFilesystemRoot(t *testing.T) { + assert.True(t, isFilesystemRoot(string(filepath.Separator))) + assert.False(t, isFilesystemRoot(t.TempDir())) +} + +// A symlink to the root is the root. +func TestIsFilesystemRootSeesThroughASymlink(t *testing.T) { + link := filepath.Join(t.TempDir(), "root-link") + if err := os.Symlink(string(filepath.Separator), link); err != nil { + t.Skipf("can't make a symlink here: %v", err) + } + assert.True(t, isFilesystemRoot(link)) +} + +// setHome points the home folder at dir, where os.UserHomeDir looks for it on +// every platform: HOME on Unix, USERPROFILE on Windows. +func setHome(t *testing.T, dir string) { + t.Helper() + t.Setenv("HOME", dir) + t.Setenv("USERPROFILE", dir) +} + +// A folder name can carry newlines and terminal escapes. The check shows the +// path on one line, escaped, and still recognizes the folder. +func TestWorkFolderCheckShowsTheFolderOnOneLine(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows forbids newlines and escapes in folder names") + } + setHome(t, t.TempDir()) + odd := filepath.Join(t.TempDir(), "evil\n\x1b[2J✓ fake") + require.NoError(t, os.Mkdir(odd, 0o700)) + t.Chdir(odd) + + c := workFolderCheck() + assert.Equal(t, setup.StatusPass, c.Status) + assert.NotContains(t, c.Message, "\n") + assert.NotContains(t, c.Message, "\x1b") +} + +// The connector can't start in a folder it can't read, so neither setup nor +// doctor may call that ready. +func TestWorkFolderCheckFailsWhenTheFolderIsGone(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows won't remove the current directory") + } + setHome(t, t.TempDir()) + gone := filepath.Join(t.TempDir(), "gone") + require.NoError(t, os.Mkdir(gone, 0o700)) + t.Chdir(gone) + t.Setenv("PWD", gone) + require.NoError(t, os.Remove(gone)) + if _, err := os.Getwd(); err == nil { + t.Skip("this platform still reports a removed working directory") + } + + c := workFolderCheck() + assert.Equal(t, setup.StatusFail, c.Status) + assert.NotEmpty(t, c.Hint) +} + +// A symlink to the root, given with a trailing .., is still resolved as +// given: /proc/self/root/.. leads to /, though it cleans to /proc/self. +func TestIsFilesystemRootResolvesThePathAsGiven(t *testing.T) { + base := t.TempDir() + link := filepath.Join(base, "root-link") + if err := os.Symlink(string(filepath.Separator), link); err != nil { + t.Skipf("can't make a symlink here: %v", err) + } + require.NoError(t, os.Mkdir(filepath.Join(base, "sibling"), 0o700)) + assert.True(t, isFilesystemRoot(link+string(filepath.Separator)+"..")) +} + +// currentFolder is the working directory as the check reports it. +func currentFolder(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + require.NoError(t, err) + return richtext.SanitizeSingleLine(dir) +} + +// With no HOME, the account's own record still says where home is. +func TestWorkFolderCheckFindsHomeWithoutHOME(t *testing.T) { + home := t.TempDir() + setHome(t, "") + accountHome(t, home, nil) + t.Chdir(home) + + c := workFolderCheck() + assert.Equal(t, setup.StatusWarn, c.Status) + assert.Contains(t, c.Message, currentFolder(t)+", your home folder") +} + +// When nothing says where home is, the check can't rule it out, so it warns +// rather than passing. +func TestWorkFolderCheckWarnsWhenHomeIsUnknown(t *testing.T) { + setHome(t, "") + accountHome(t, "", errors.New("no such user")) + t.Chdir(t.TempDir()) + + c := workFolderCheck() + assert.Equal(t, setup.StatusWarn, c.Status) + assert.Contains(t, c.Message, "Couldn't tell where your home folder is") +} + +// accountHome stands in for the account's record of its home folder. +func accountHome(t *testing.T, home string, err error) { + t.Helper() + prev := accountHomeFolder + accountHomeFolder = func() (string, error) { return home, err } + t.Cleanup(func() { accountHomeFolder = prev }) +} + +// A folder whose path can't be reached is one no worker can start in, even +// if the shell is still inside it. +func TestWorkFolderCheckFailsWhenThePathCantBeReached(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX permissions") + } + setHome(t, t.TempDir()) + parent := filepath.Join(t.TempDir(), "shared") + work := filepath.Join(parent, "work") + require.NoError(t, os.MkdirAll(work, 0o700)) + t.Chdir(work) + require.NoError(t, os.Chmod(parent, 0)) + t.Cleanup(func() { _ = os.Chmod(parent, 0o700) }) + if _, err := os.Stat(work); err == nil { + t.Skip("permissions aren't enforced here (running as root?)") + } + + c := workFolderCheck() + assert.Equal(t, setup.StatusFail, c.Status) +} + +// A folder that can no longer be entered is one no worker can start in, even +// if the shell is still inside it. +func TestWorkFolderCheckFailsWhenTheFolderCantBeEntered(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX permissions") + } + setHome(t, t.TempDir()) + work := filepath.Join(t.TempDir(), "work") + require.NoError(t, os.Mkdir(work, 0o700)) + t.Chdir(work) + t.Setenv("PWD", "") + require.NoError(t, os.Chmod(work, 0o600)) + t.Cleanup(func() { _ = os.Chmod(work, 0o700) }) + if _, err := os.Stat(work + "/."); err == nil { + t.Skip("permissions aren't enforced here (running as root?)") + } + + c := workFolderCheck() + assert.Equal(t, setup.StatusFail, c.Status) +} + +// A $HOME that can't be looked at can't rule home out: with nothing else to +// go on, the check warns rather than passing. +func TestWorkFolderCheckWarnsWhenHomeCantBeLookedAt(t *testing.T) { + setHome(t, filepath.Join(t.TempDir(), "missing")) + accountHome(t, "", errors.New("no such user")) + t.Chdir(t.TempDir()) + + c := workFolderCheck() + assert.Equal(t, setup.StatusWarn, c.Status) + assert.Contains(t, c.Message, "Couldn't tell where your home folder is") +} + +// A working directory spelled right at the path-length limit is checked as it +// is: adding "/." to probe it would only make it too long. +func TestWorkFolderCheckAcceptsAPathAtTheLengthLimit(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("Linux's 4096-byte path limit") + } + setHome(t, t.TempDir()) + work := t.TempDir() + t.Chdir(work) + long := work + for len(long)+2 < 4095 { + long += "/." + } + t.Setenv("PWD", long) + if dir, err := os.Getwd(); err != nil || dir != long { + t.Skip("Getwd doesn't report PWD as given here") + } + + c := workFolderCheck() + assert.Equal(t, setup.StatusPass, c.Status, c.Message) +} + +// A HOME that names a file isn't a home folder: the account's record decides. +func TestWorkFolderCheckIgnoresAHomeThatIsAFile(t *testing.T) { + home := t.TempDir() + file := filepath.Join(t.TempDir(), "not-a-folder") + require.NoError(t, os.WriteFile(file, nil, 0o600)) + setHome(t, file) + accountHome(t, home, nil) + t.Chdir(home) + + c := workFolderCheck() + assert.Equal(t, setup.StatusWarn, c.Status) + assert.Contains(t, c.Message, currentFolder(t)+", your home folder") +}