From 0dd9f78c14fb57c43790c11830369b41b806a8fe Mon Sep 17 00:00:00 2001 From: nabil1440 <52530910+nabil1440@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:52:36 +0600 Subject: [PATCH] fix(site): stop the site search at / and validate --domain - FindComposeFile stops at the home directory or at the filesystem root, so a command outside $HOME no longer loops forever. - FindComposeFile returns errors from UserHomeDir and Getwd, and ErrComposeNotFound when a site has no compose file. - --domain must be a valid hostname (labels of letters, digits and inner hyphens). This rejects path separators and '..', so the value cannot point outside the sites directory. Closes #3 --- cmd/site.go | 12 +-- internal/utils/finder.go | 77 +++++++++++++++--- internal/utils/finder_test.go | 142 ++++++++++++++++++++++++++++++++++ main_test.go | 44 ++++++++++- 4 files changed, 253 insertions(+), 22 deletions(-) create mode 100644 internal/utils/finder_test.go diff --git a/cmd/site.go b/cmd/site.go index 0b9c1ad..90716fd 100644 --- a/cmd/site.go +++ b/cmd/site.go @@ -25,16 +25,12 @@ Or specify the domain name: // siteComposePath returns the compose file of the site selected by --domain // or by the current directory. func siteComposePath() (string, error) { - composePath := utils.FindComposeFile(domain) - if composePath != "" { - return composePath, nil + composePath, err := utils.FindComposeFile(domain) + if errors.Is(err, utils.ErrComposeNotFound) && domain == "" { + return "", errNoSite } - if domain != "" { - return "", fmt.Errorf("no docker-compose.yml file found for domain %q", domain) - } - - return "", errNoSite + return composePath, err } var wpCmd = &cobra.Command{ diff --git a/internal/utils/finder.go b/internal/utils/finder.go index 954bd37..74e6026 100644 --- a/internal/utils/finder.go +++ b/internal/utils/finder.go @@ -1,36 +1,89 @@ package utils import ( + "errors" + "fmt" + "io/fs" "os" "path/filepath" + "regexp" + "strings" ) -func FindComposeFile(domain string) string { - homeDir, _ := os.UserHomeDir() +// ErrComposeNotFound means that the site has no docker-compose.yml file. +var ErrComposeNotFound = errors.New("no docker-compose.yml file found") + +// hostnameLabel matches one DNS label: letters, digits and inner hyphens. +var hostnameLabel = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$`) + +// ValidateDomain returns an error if domain is not a valid hostname. A valid +// hostname has no path separators and no "..", so it can only name a +// directory directly inside the sites directory. +func ValidateDomain(domain string) error { + if len(domain) == 0 || len(domain) > 253 { + return fmt.Errorf("invalid domain %q", domain) + } + + for _, label := range strings.Split(domain, ".") { + if !hostnameLabel.MatchString(label) { + return fmt.Errorf("invalid domain %q", domain) + } + } + + return nil +} + +// FindComposeFile returns the docker-compose.yml file of a site. With a +// domain, it looks in ~/. Without one, it searches from the current +// directory up to the home directory. It returns ErrComposeNotFound if the +// site has no compose file. +func FindComposeFile(domain string) (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", err + } + homeDir = filepath.Clean(homeDir) // If domain is provided, check in ~/domain/docker-compose.yml if domain != "" { - composePath := filepath.Join(homeDir, domain, "docker-compose.yml") - if _, err := os.Stat(composePath); err == nil { - return composePath + if err := ValidateDomain(domain); err != nil { + return "", err } - return "" + + siteDir := filepath.Join(homeDir, domain) + if filepath.Dir(siteDir) != homeDir { + return "", fmt.Errorf("invalid domain %q", domain) + } + + composePath := filepath.Join(siteDir, "docker-compose.yml") + if _, err := os.Stat(composePath); errors.Is(err, fs.ErrNotExist) { + return "", fmt.Errorf("%w for domain %q", ErrComposeNotFound, domain) + } else if err != nil { + return "", err + } + + return composePath, nil } // Otherwise search from current directory up to home directory - dir, _ := os.Getwd() + dir, err := os.Getwd() + if err != nil { + return "", err + } for { composePath := filepath.Join(dir, "docker-compose.yml") - if _, err := os.Stat(composePath); err == nil { - return composePath + return composePath, nil } - if dir == homeDir { - return "" + // Stop at the home directory, or at the filesystem root when the + // current directory is outside the home directory. + parent := filepath.Dir(dir) + if dir == homeDir || parent == dir { + return "", ErrComposeNotFound } - dir = filepath.Dir(dir) + dir = parent } } diff --git a/internal/utils/finder_test.go b/internal/utils/finder_test.go new file mode 100644 index 0000000..eeca70e --- /dev/null +++ b/internal/utils/finder_test.go @@ -0,0 +1,142 @@ +package utils + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/flywp/server-cli/internal/testutil" +) + +// findWithTimeout fails the test if FindComposeFile does not return quickly. +func findWithTimeout(t *testing.T, domain string) (string, error) { + t.Helper() + + type result struct { + path string + err error + } + done := make(chan result, 1) + go func() { + path, err := FindComposeFile(domain) + done <- result{path, err} + }() + + select { + case r := <-done: + return r.path, r.err + case <-time.After(2 * time.Second): + t.Fatal("FindComposeFile did not return within 2s") + return "", nil + } +} + +func TestFindComposeFileOutsideHomeStops(t *testing.T) { + root := testutil.TempDir(t) + t.Setenv("HOME", filepath.Join(root, "home")) + + outside := filepath.Join(root, "var", "www") + if err := os.MkdirAll(outside, 0o755); err != nil { + t.Fatal(err) + } + t.Chdir(outside) + + if _, err := findWithTimeout(t, ""); !errors.Is(err, ErrComposeNotFound) { + t.Errorf("FindComposeFile() error = %v, want ErrComposeNotFound", err) + } +} + +func TestFindComposeFileFromNestedDirectory(t *testing.T) { + home := testutil.TempDir(t) + t.Setenv("HOME", home) + + want := testutil.WriteSite(t, filepath.Join(home, "example.com"), "php") + nested := filepath.Join(home, "example.com", "app", "public", "wp-content") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatal(err) + } + t.Chdir(nested) + + got, err := findWithTimeout(t, "") + if err != nil || got != want { + t.Errorf("FindComposeFile() = %q, %v, want %q, nil", got, err, want) + } +} + +func TestFindComposeFileStopsAtHome(t *testing.T) { + root := testutil.TempDir(t) + testutil.WriteSite(t, root, "php") // above the home directory: not a site + home := filepath.Join(root, "home") + t.Setenv("HOME", home) + + dir := filepath.Join(home, "notes") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + t.Chdir(dir) + + if got, err := findWithTimeout(t, ""); !errors.Is(err, ErrComposeNotFound) { + t.Errorf("FindComposeFile() = %q, %v, want ErrComposeNotFound", got, err) + } +} + +func TestFindComposeFileWithDomain(t *testing.T) { + home := testutil.TempDir(t) + t.Setenv("HOME", home) + want := testutil.WriteSite(t, filepath.Join(home, "example.com"), "php") + + if got, err := findWithTimeout(t, "example.com"); err != nil || got != want { + t.Errorf("FindComposeFile(example.com) = %q, %v, want %q, nil", got, err, want) + } + + if _, err := findWithTimeout(t, "other.com"); !errors.Is(err, ErrComposeNotFound) { + t.Errorf("FindComposeFile(other.com) error = %v, want ErrComposeNotFound", err) + } + + if _, err := findWithTimeout(t, "../"); err == nil || errors.Is(err, ErrComposeNotFound) { + t.Errorf("FindComposeFile(../) error = %v, want an invalid domain error", err) + } +} + +func TestValidateDomain(t *testing.T) { + valid := []string{ + "example.com", + "www.example.co.uk", + "my-site.example.com", + "xn--mnchen-3ya.de", + "localhost", + strings.Repeat("a", 63) + ".com", + } + for _, d := range valid { + if err := ValidateDomain(d); err != nil { + t.Errorf("ValidateDomain(%q) = %v, want nil", d, err) + } + } + + invalid := []string{ + "", + ".", + "..", + "../", + "../etc", + "a/b", + "/etc", + ".example.com", + "example..com", + "example.com.", + "-example.com", + "example-.com", + "exa mple.com", + "exa_mple.com", + strings.Repeat("a", 64) + ".com", + strings.Repeat("a.", 127) + "com", + } + for _, d := range invalid { + if err := ValidateDomain(d); err == nil { + t.Errorf("ValidateDomain(%q) = nil, want an error", d) + } + } +} diff --git a/main_test.go b/main_test.go index 2d10b04..ae84c18 100644 --- a/main_test.go +++ b/main_test.go @@ -5,6 +5,7 @@ package main import ( "bytes" + "context" "errors" "fmt" "os" @@ -12,6 +13,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/flywp/server-cli/internal/testutil" ) @@ -66,11 +68,15 @@ func newEnv(t *testing.T, services ...string) *env { return &env{home: home, site: site, docker: testutil.NewFakeDocker(t)} } -// run executes fly in dir with the test environment. +// run executes fly in dir with the test environment. It kills fly and fails +// the test if fly does not finish within 10 seconds. func (e *env) run(t *testing.T, dir string, args ...string) result { t.Helper() - cmd := exec.Command(flyBin, args...) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, flyBin, args...) cmd.Dir = dir cmd.Env = append(append(e.docker.Env(), "HOME="+e.home), e.vars...) @@ -82,6 +88,8 @@ func (e *env) run(t *testing.T, dir string, args ...string) result { var exitErr *exec.ExitError switch { + case ctx.Err() != nil: + t.Fatalf("fly %s did not finish within 10s", strings.Join(args, " ")) case errors.As(err, &exitErr): res.code = exitErr.ExitCode() case err != nil: @@ -170,6 +178,38 @@ func TestNoSiteIsAnError(t *testing.T) { } } +func TestOutsideHomeDoesNotHang(t *testing.T) { + e := newEnv(t) + outside := testutil.TempDir(t) // not inside e.home + + // run fails the test if fly does not stop. + res := e.run(t, outside, "start") + if res.code != 1 || !strings.Contains(res.stderr, "no docker-compose.yml file found") { + t.Errorf("got exit %d, stderr %q, want exit 1 and the no-site error", res.code, res.stderr) + } +} + +func TestDomainFlag(t *testing.T) { + e := newEnv(t) + outside := testutil.TempDir(t) + + res := e.run(t, outside, "start", "--domain", "example.com") + if res.code != 0 { + t.Fatalf("--domain example.com: exit code = %d, want 0 (stderr %q)", res.code, res.stderr) + } + + for _, bad := range []string{"../", "..", "example.com/../..", "a/b"} { + res := e.run(t, outside, "start", "--domain", bad) + if res.code != 1 || !strings.Contains(res.stderr, "invalid domain") { + t.Errorf("--domain %q: exit %d, stderr %q, want exit 1 and an invalid domain error", bad, res.code, res.stderr) + } + } + + if calls := e.docker.Calls(t); len(calls) != 1 { + t.Errorf("docker calls = %q, want only the call for example.com", calls) + } +} + func TestUsageErrors(t *testing.T) { tests := []struct { args []string