diff --git a/go/engine/php/workspace.go b/go/engine/php/workspace.go index 035d425..7ff26c8 100644 --- a/go/engine/php/workspace.go +++ b/go/engine/php/workspace.go @@ -2,7 +2,6 @@ package php import ( core "dappco.re/go" - "dappco.re/go/io" "gopkg.in/yaml.v3" ) @@ -24,19 +23,31 @@ func defaultWorkspaceConfig() *workspaceConfig { // loadWorkspaceConfig tries to load workspace.yaml from the given directory's .core subfolder. // Returns nil if no config file exists. +// +// The probe walks UP an arbitrary ancestor chain, so it is deliberately +// not coreio.Local: since go-io v0.15.2 the local medium routes through +// os.Root, which by design refuses any path leaving its root — including +// one that merely traverses a symlink. Correct for sandboxed media, +// wrong for a search that is unbounded by construction. Reached through +// a symlinked project directory (an ordinary macOS layout), Local.Read +// fails "path escapes from parent" AND Local.IsFile returns false, so +// this walked past a config that exists and reported the workspace as +// unconfigured. The os-level core.Stat / core.ReadFile are the right +// tool for probing; coreio media stay correct for sandboxed I/O. func loadWorkspaceConfig(dir string) (*workspaceConfig, error) { // Result boundary path := core.PathJoin(dir, ".core", "workspace.yaml") - data, err := io.Local.Read(path) - if err != nil { - if !io.Local.IsFile(path) { + readResult := core.ReadFile(path) + if !readResult.OK { + if stat := core.Stat(path); !stat.OK { parent := core.PathDir(dir) if parent != dir { return loadWorkspaceConfig(parent) } return nil, nil } - return nil, core.Errorf("failed to read workspace config: %w", err) + return nil, core.Errorf("failed to read workspace config: %w", readResult.Err()) } + data := string(readResult.Value.([]byte)) config := defaultWorkspaceConfig() if err := yaml.Unmarshal([]byte(data), config); err != nil { @@ -60,7 +71,9 @@ func findWorkspaceRoot() (string, error) { // Result boundary dir, _ := cwdResult.Value.(string) for { - if io.Local.IsFile(core.PathJoin(dir, ".core", "workspace.yaml")) { + // os-level stat, for the same reason as loadWorkspaceConfig: this + // walks up to the filesystem root, so no medium root can contain it. + if isRegularFile(core.PathJoin(dir, ".core", "workspace.yaml")) { return dir, nil } @@ -73,3 +86,16 @@ func findWorkspaceRoot() (string, error) { // Result boundary return "", core.Errorf("not in a workspace") } + +// isRegularFile reports whether path names an existing non-directory, +// following symlinks. Uses core.Stat (os.Stat) rather than a coreio +// medium because the callers probe ancestor directories that no medium +// root contains — see loadWorkspaceConfig. +func isRegularFile(path string) bool { + result := core.Stat(path) + if !result.OK { + return false + } + info, ok := result.Value.(core.FsFileInfo) + return ok && !info.IsDir() +} diff --git a/go/engine/php/workspace_symlink_test.go b/go/engine/php/workspace_symlink_test.go new file mode 100644 index 0000000..4206fb4 --- /dev/null +++ b/go/engine/php/workspace_symlink_test.go @@ -0,0 +1,116 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package php + +import ( + "os" + "path/filepath" + "testing" +) + +// TestLoadWorkspaceConfig_ThroughSymlink pins the go-io v0.15.2 +// regression this code used to sit on. +// +// Since v0.15.2 the coreio local medium routes through os.Root, which by +// design refuses a path that leaves its root — including one that merely +// traverses a symlink. loadWorkspaceConfig probes ancestor directories, +// so no medium root can contain it: reached through a symlinked project +// directory (an ordinary macOS layout, and what any worktree or +// /tmp-based test produces), Local.Read failed "path escapes from +// parent" AND Local.IsFile returned false, so the walk continued past a +// config that exists and reported the workspace as unconfigured. +// +// The config must be found through the symlink exactly as through the +// real path. +func TestLoadWorkspaceConfig_ThroughSymlink(t *testing.T) { + base := t.TempDir() + real := filepath.Join(base, "project") + if err := os.MkdirAll(filepath.Join(real, ".core"), 0o755); err != nil { + t.Fatal(err) + } + body := "version: 1\nactive: web\npackages_dir: ./pkgs\n" + if err := os.WriteFile(filepath.Join(real, ".core", "workspace.yaml"), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + + link := filepath.Join(base, "linked-project") + if err := os.Symlink(real, link); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + + for _, tc := range []struct{ name, dir string }{ + {"direct", real}, + {"through_symlink", link}, + {"child_through_symlink", filepath.Join(link, "nested")}, + } { + t.Run(tc.name, func(t *testing.T) { + if tc.name == "child_through_symlink" { + if err := os.MkdirAll(tc.dir, 0o755); err != nil { + t.Fatal(err) + } + } + config, err := loadWorkspaceConfig(tc.dir) + if err != nil { + t.Fatalf("loadWorkspaceConfig: %v", err) + } + if config == nil { + t.Fatal("config not found; a symlinked path must resolve like the real one") + } + if config.Active != "web" || config.PackagesDir != "./pkgs" { + t.Fatalf("config = %+v, want active=web packages_dir=./pkgs", config) + } + }) + } +} + +// TestLoadWorkspaceConfig_Absent asserts a genuinely missing config +// still walks to the root and reports nothing, rather than erroring. +func TestLoadWorkspaceConfig_Absent(t *testing.T) { + dir := t.TempDir() + config, err := loadWorkspaceConfig(dir) + if err != nil { + t.Fatalf("loadWorkspaceConfig: %v", err) + } + if config != nil { + t.Fatalf("config = %+v, want nil for a directory with no workspace", config) + } +} + +// TestIsRegularFile covers the probe's three answers: a file, a +// directory, and an absent path — including each reached via a symlink, +// which is the case the coreio medium refuses. +func TestIsRegularFile(t *testing.T) { + base := t.TempDir() + file := filepath.Join(base, "workspace.yaml") + if err := os.WriteFile(file, []byte("version: 1\n"), 0o600); err != nil { + t.Fatal(err) + } + dir := filepath.Join(base, "subdir") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + link := filepath.Join(base, "link") + if err := os.Symlink(base, link); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + + cases := []struct { + name string + path string + want bool + }{ + {"file", file, true}, + {"directory", dir, false}, + {"absent", filepath.Join(base, "nope.yaml"), false}, + {"file_via_symlink", filepath.Join(link, "workspace.yaml"), true}, + {"directory_via_symlink", filepath.Join(link, "subdir"), false}, + {"empty", "", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := isRegularFile(tc.path); got != tc.want { + t.Fatalf("isRegularFile(%q) = %v, want %v", tc.path, got, tc.want) + } + }) + } +}