From 8ee7384ae1bdfd8cbbf35d58bd10475cf4b89971 Mon Sep 17 00:00:00 2001 From: BagToad <47394200+BagToad@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:13:11 -0600 Subject: [PATCH 01/14] Fix skills search install argument ordering Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/skills/search/search.go | 33 +++++++++----- pkg/cmd/skills/search/search_test.go | 65 ++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 11 deletions(-) diff --git a/pkg/cmd/skills/search/search.go b/pkg/cmd/skills/search/search.go index 1a5353d59eb..dbd6a2ce900 100644 --- a/pkg/cmd/skills/search/search.go +++ b/pkg/cmd/skills/search/search.go @@ -587,17 +587,8 @@ func promptInstall(opts *SearchOptions, skills []skillResult) error { fmt.Fprintf(opts.IO.ErrOut, "\n%s Installing %s from %s...\n", cs.Blue("::"), displayName, s.Repo) - // Use the repo-relative directory path (e.g. "skills/author/name") - // for disambiguation when installing namespaced skills, so the - // install command can resolve the exact skill without ambiguity. - installArg := s.SkillName - if s.Namespace != "" { - installArg = strings.TrimSuffix(s.Path, "/SKILL.md") - } - - //nolint:gosec // arguments are from user-selected search results, not arbitrary input - cmd := exec.Command(opts.ExecutablePath, "skills", "install", s.Repo, installArg, - "--agent", host.ID, "--scope", scope) + //nolint:gosec // executable is the current gh binary + cmd := exec.Command(opts.ExecutablePath, installCommandArgs(s, host.ID, scope)...) cmd.Stdin = os.Stdin cmd.Stdout = opts.IO.Out cmd.Stderr = opts.IO.ErrOut @@ -610,6 +601,26 @@ func promptInstall(opts *SearchOptions, skills []skillResult) error { return nil } +func installCommandArgs(skill skillResult, agent, scope string) []string { + // Use the repo-relative directory path (e.g. "skills/author/name") + // for disambiguation when installing namespaced skills, so the + // install command can resolve the exact skill without ambiguity. + installArg := skill.SkillName + if skill.Namespace != "" { + installArg = strings.TrimSuffix(skill.Path, "/SKILL.md") + } + + return []string{ + "skills", "install", + "--agent", agent, + "--scope", scope, + // Keep untrusted repository and skill-selector values after -- so + // they cannot be parsed as flags. + "--", + skill.Repo, installArg, + } +} + // relevanceScore computes a numeric ranking score for a search result. // Higher scores rank first. Signals (in priority order): // - Exact skill name match (3 000 points) diff --git a/pkg/cmd/skills/search/search_test.go b/pkg/cmd/skills/search/search_test.go index 2b412f037ba..1185bd62e86 100644 --- a/pkg/cmd/skills/search/search_test.go +++ b/pkg/cmd/skills/search/search_test.go @@ -10,6 +10,7 @@ import ( "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/telemetry" + "github.com/cli/cli/v2/pkg/cmd/skills/install" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" @@ -463,6 +464,70 @@ func TestExtractSkillInfo(t *testing.T) { } } +func TestInstallCommandArgs(t *testing.T) { + tests := []struct { + name string + resultPath string + wantSkill string + want []string + }{ + { + name: "namespaced skill", + resultPath: "skills/monalisa/code-review/SKILL.md", + wantSkill: "skills/monalisa/code-review", + want: []string{ + "skills", "install", + "--agent", "github-copilot", + "--scope", "project", + "--", + "org/repo", "skills/monalisa/code-review", + }, + }, + { + name: "flag-like result path", + resultPath: "--option/skills/monalisa/code-review/SKILL.md", + wantSkill: "--option/skills/monalisa/code-review", + want: []string{ + "skills", "install", + "--agent", "github-copilot", + "--scope", "project", + "--", + "org/repo", "--option/skills/monalisa/code-review", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + results := deduplicateResults([]codeSearchItem{{ + Path: tt.resultPath, + Repository: codeSearchRepository{FullName: "org/repo"}, + }}) + require.Len(t, results, 1) + + args := installCommandArgs(results[0], "github-copilot", "project") + assert.Equal(t, tt.want, args) + + var installOpts *install.InstallOptions + cmd := install.NewCmdInstall(&cmdutil.Factory{}, &telemetry.NoOpService{}, func(opts *install.InstallOptions) error { + installOpts = opts + return nil + }) + cmd.SetArgs(args[2:]) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + _, err := cmd.ExecuteC() + require.NoError(t, err) + require.NotNil(t, installOpts) + assert.Equal(t, "org/repo", installOpts.SkillSource) + assert.Equal(t, tt.wantSkill, installOpts.SkillName) + assert.Equal(t, "github-copilot", installOpts.Agent) + assert.Equal(t, "project", installOpts.Scope) + }) + } +} + func TestFilterByRelevance(t *testing.T) { skills := []skillResult{ {Repo: "org/repo1", Owner: "org", RepoName: "repo1", SkillName: "terraform"}, From be9b3f4046e720240daa3848bd5821c37a68911d Mon Sep 17 00:00:00 2001 From: BagToad <47394200+BagToad@users.noreply.github.com> Date: Mon, 31 Aug 2026 21:35:56 -0600 Subject: [PATCH 02/14] Harden filesystem writes against symlinks Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- internal/safepaths/absolute.go | 9 +- internal/safepaths/absolute_test.go | 6 + internal/safepaths/link_other.go | 9 + internal/safepaths/link_windows.go | 37 + internal/safepaths/link_windows_test.go | 172 +++++ internal/safepaths/open_nonblock_other.go | 13 + internal/safepaths/open_nonblock_unix.go | 16 + internal/safepaths/root.go | 666 ++++++++++++++++++ internal/safepaths/root_race_test.go | 91 +++ internal/safepaths/root_special_unix_test.go | 96 +++ internal/safepaths/root_test.go | 405 +++++++++++ internal/safepaths/root_validation_test.go | 142 ++++ internal/skills/installer/installer.go | 166 +++-- internal/skills/installer/installer_test.go | 59 +- internal/zip/fixtures/myproject.zip | Bin 1710 -> 0 bytes internal/zip/zip.go | 28 +- internal/zip/zip_test.go | 106 ++- pkg/cmd/attestation/download/download_test.go | 24 + pkg/cmd/attestation/download/metadata.go | 28 +- .../download/metadata_mode_unix_test.go | 32 + pkg/cmd/copilot/copilot.go | 48 +- pkg/cmd/copilot/copilot_test.go | 110 +++ pkg/cmd/release/download/download.go | 76 +- pkg/cmd/release/download/download_test.go | 212 +++++- pkg/cmd/repo/read-file/read_file.go | 52 +- pkg/cmd/repo/read-file/read_file_test.go | 246 ++++++- pkg/cmd/run/download/download.go | 69 +- pkg/cmd/run/download/download_test.go | 106 ++- pkg/cmd/run/download/fixtures/myproject.zip | Bin 1710 -> 0 bytes pkg/cmd/run/download/http.go | 15 +- pkg/cmd/run/download/http_test.go | 159 +++-- pkg/cmd/skills/install/install.go | 65 +- pkg/cmd/skills/install/install_test.go | 88 +++ pkg/cmd/skills/update/update.go | 333 ++++++++- pkg/cmd/skills/update/update_test.go | 523 +++++++++++++- 35 files changed, 3871 insertions(+), 336 deletions(-) create mode 100644 internal/safepaths/link_other.go create mode 100644 internal/safepaths/link_windows.go create mode 100644 internal/safepaths/link_windows_test.go create mode 100644 internal/safepaths/open_nonblock_other.go create mode 100644 internal/safepaths/open_nonblock_unix.go create mode 100644 internal/safepaths/root.go create mode 100644 internal/safepaths/root_race_test.go create mode 100644 internal/safepaths/root_special_unix_test.go create mode 100644 internal/safepaths/root_test.go create mode 100644 internal/safepaths/root_validation_test.go delete mode 100644 internal/zip/fixtures/myproject.zip create mode 100644 pkg/cmd/attestation/download/metadata_mode_unix_test.go delete mode 100644 pkg/cmd/run/download/fixtures/myproject.zip diff --git a/internal/safepaths/absolute.go b/internal/safepaths/absolute.go index db0551b32d4..5f1014314f6 100644 --- a/internal/safepaths/absolute.go +++ b/internal/safepaths/absolute.go @@ -61,7 +61,7 @@ func (a Absolute) isSubpathOf(dir Absolute) (bool, error) { if err != nil { return false, err } - return !strings.HasPrefix(relativePath, ".."), nil + return relativePath != ".." && !strings.HasPrefix(relativePath, ".."+string(filepath.Separator)), nil } type PathTraversalError struct { @@ -70,5 +70,12 @@ type PathTraversalError struct { } func (e PathTraversalError) Error() string { + if e.Base.path == "" { + path := filepath.Join(e.Elems...) + if len(e.Elems) == 1 { + path = e.Elems[0] + } + return fmt.Sprintf("path %q is not a local child path", path) + } return fmt.Sprintf("joining %s and %s would be a traversal", e.Base, filepath.Join(e.Elems...)) } diff --git a/internal/safepaths/absolute_test.go b/internal/safepaths/absolute_test.go index 8446fbfcce2..35d5a5aec4e 100644 --- a/internal/safepaths/absolute_test.go +++ b/internal/safepaths/absolute_test.go @@ -81,6 +81,12 @@ func TestJoin(t *testing.T) { elems: []string{""}, want: mustParseAbsolute("/base"), }, + { + name: "child beginning with two dots", + base: mustParseAbsolute("/base"), + elems: []string{"..child"}, + want: mustParseAbsolute("/base/..child"), + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/safepaths/link_other.go b/internal/safepaths/link_other.go new file mode 100644 index 00000000000..ca5f624390f --- /dev/null +++ b/internal/safepaths/link_other.go @@ -0,0 +1,9 @@ +//go:build !windows + +package safepaths + +import "os" + +func isPathRedirect(_ *Root, _ *os.Root, _ string, info os.FileInfo) (bool, error) { + return info.Mode()&os.ModeSymlink != 0, nil +} diff --git a/internal/safepaths/link_windows.go b/internal/safepaths/link_windows.go new file mode 100644 index 00000000000..0e5725b91fb --- /dev/null +++ b/internal/safepaths/link_windows.go @@ -0,0 +1,37 @@ +package safepaths + +import ( + "errors" + "os" + "syscall" +) + +// os.Root intentionally refuses every final reparse point. Windows does not +// expose a supported handle-relative open that both follows non-link reparse +// data and atomically refuses a race to a name-surrogate redirect, so fail +// closed rather than re-resolving the pinned parent by path. +var errUnsupportedNonLinkReparse = errors.New("non-link reparse points cannot be safely opened beneath a rooted directory on Windows") + +func isPathRedirect(owner *Root, root *os.Root, name string, info os.FileInfo) (bool, error) { + return classifyWindowsPathRedirect(info, func() error { + _, err := owner.readlink(root, name) + return err + }) +} + +func classifyWindowsPathRedirect(info os.FileInfo, readlink func() error) (bool, error) { + if info.Mode()&os.ModeSymlink != 0 { + return true, nil + } + data, ok := info.Sys().(*syscall.Win32FileAttributeData) + if !ok || data.FileAttributes&syscall.FILE_ATTRIBUTE_REPARSE_POINT == 0 { + return false, nil + } + if err := readlink(); err != nil { + if os.IsNotExist(err) { + return false, errUnsupportedNonLinkReparse + } + return false, err + } + return true, nil +} diff --git a/internal/safepaths/link_windows_test.go b/internal/safepaths/link_windows_test.go new file mode 100644 index 00000000000..b80af0669ea --- /dev/null +++ b/internal/safepaths/link_windows_test.go @@ -0,0 +1,172 @@ +package safepaths + +import ( + "errors" + "io/fs" + "os" + "os/exec" + "path/filepath" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReparsePointClassification(t *testing.T) { + t.Run("name surrogate redirects", func(t *testing.T) { + info := reparsePointInfo{mode: fs.ModeSymlink} + redirect, err := classifyWindowsPathRedirect(info, func() error { + return errors.New("should not be called") + }) + require.NoError(t, err) + assert.True(t, redirect) + }) + t.Run("mount point redirects", func(t *testing.T) { + info := reparsePointInfo{ + mode: fs.ModeIrregular, + attributes: syscall.FILE_ATTRIBUTE_REPARSE_POINT, + } + redirect, err := classifyWindowsPathRedirect(info, func() error { return nil }) + require.NoError(t, err) + assert.True(t, redirect) + }) + t.Run("non-link reparse point fails closed", func(t *testing.T) { + info := reparsePointInfo{ + mode: fs.ModeDir, + attributes: syscall.FILE_ATTRIBUTE_REPARSE_POINT, + } + redirect, err := classifyWindowsPathRedirect(info, func() error { return os.ErrNotExist }) + assert.False(t, redirect) + require.ErrorIs(t, err, errUnsupportedNonLinkReparse) + }) + t.Run("unexpected readlink error is returned", func(t *testing.T) { + info := reparsePointInfo{ + mode: fs.ModeIrregular, + attributes: syscall.FILE_ATTRIBUTE_REPARSE_POINT, + } + + redirect, err := classifyWindowsPathRedirect(info, func() error { + return errors.New("readlink failed") + }) + assert.False(t, redirect) + require.ErrorContains(t, err, "readlink failed") + }) +} + +func TestCreateReplaceFailsClosedForNonLinkReparse(t *testing.T) { + rootDir := t.TempDir() + path := filepath.Join(rootDir, "entry") + require.NoError(t, os.WriteFile(path, []byte("original"), 0o644)) + before, err := os.Stat(path) + require.NoError(t, err) + + root, err := OpenRoot(rootDir) + require.NoError(t, err) + defer root.Close() + root.lstat = func(*os.Root, string) (os.FileInfo, error) { + return reparsePointInfo{ + mode: fs.ModeIrregular, + attributes: syscall.FILE_ATTRIBUTE_REPARSE_POINT, + }, nil + } + root.readlink = func(*os.Root, string) (string, error) { + return "", os.ErrNotExist + } + + file, err := root.Create("entry", 0o644, true) + assert.Nil(t, file) + require.ErrorIs(t, err, errUnsupportedNonLinkReparse) + after, err := os.Stat(path) + require.NoError(t, err) + assert.True(t, os.SameFile(before, after)) + content, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, "original", string(content)) +} + +func TestRootRejectsJunction(t *testing.T) { + rootDir := t.TempDir() + target := t.TempDir() + junction := filepath.Join(rootDir, "junction") + if output, err := exec.Command("cmd", "/c", "mklink", "/J", junction, target).CombinedOutput(); err != nil { + t.Skipf("junctions unavailable: %v: %s", err, output) + } + + root, err := OpenRoot(rootDir) + require.NoError(t, err) + defer root.Close() + + err = root.WriteFile(filepath.Join("junction", "file.txt"), []byte("content"), 0o644, false) + require.ErrorContains(t, err, "symbolic link") + assert.NoFileExists(t, filepath.Join(target, "file.txt")) + + err = root.WriteFile("junction", []byte("content"), 0o644, true) + require.Error(t, err) + info, err := os.Lstat(junction) + require.NoError(t, err) + assert.NotZero(t, info.Sys().(*syscall.Win32FileAttributeData).FileAttributes&syscall.FILE_ATTRIBUTE_REPARSE_POINT) +} + +func TestOpenFileRejectsWindowsTrailingSeparator(t *testing.T) { + tests := []struct { + name string + separator string + }{ + {name: "backslash", separator: `\`}, + {name: "slash", separator: `/`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "output") + tt.separator + + file, err := OpenFile(path, 0o644, false) + if file != nil { + _ = file.Close() + } + require.ErrorContains(t, err, "ends in a separator") + _, statErr := os.Lstat(filepath.Join(root, "output")) + require.True(t, os.IsNotExist(statErr)) + }) + } +} + +func TestRootCreateRejectsWindowsTrailingSeparator(t *testing.T) { + tests := []struct { + name string + separator string + }{ + {name: "backslash", separator: `\`}, + {name: "slash", separator: `/`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rootDir := t.TempDir() + root, err := OpenRoot(rootDir) + require.NoError(t, err) + defer root.Close() + + err = root.WriteFile("output"+tt.separator, []byte("content"), 0o644, false) + require.ErrorContains(t, err, "ends in a separator") + _, statErr := os.Lstat(filepath.Join(rootDir, "output")) + require.True(t, os.IsNotExist(statErr)) + assert.NoFileExists(t, filepath.Join(rootDir, "output", "output")) + }) + } +} + +type reparsePointInfo struct { + mode fs.FileMode + attributes uint32 +} + +func (reparsePointInfo) Name() string { return "reparse" } +func (reparsePointInfo) Size() int64 { return 0 } +func (i reparsePointInfo) Mode() fs.FileMode { return i.mode } +func (reparsePointInfo) ModTime() time.Time { return time.Time{} } +func (i reparsePointInfo) IsDir() bool { return i.mode.IsDir() } +func (i reparsePointInfo) Sys() any { + return &syscall.Win32FileAttributeData{FileAttributes: i.attributes} +} diff --git a/internal/safepaths/open_nonblock_other.go b/internal/safepaths/open_nonblock_other.go new file mode 100644 index 00000000000..811e11af76c --- /dev/null +++ b/internal/safepaths/open_nonblock_other.go @@ -0,0 +1,13 @@ +//go:build !unix + +package safepaths + +import "os" + +func nonBlockingOpenFlag() int { + return 0 +} + +func clearNonblocking(*os.File) error { + return nil +} diff --git a/internal/safepaths/open_nonblock_unix.go b/internal/safepaths/open_nonblock_unix.go new file mode 100644 index 00000000000..4bd24868da4 --- /dev/null +++ b/internal/safepaths/open_nonblock_unix.go @@ -0,0 +1,16 @@ +//go:build unix + +package safepaths + +import ( + "os" + "syscall" +) + +func nonBlockingOpenFlag() int { + return syscall.O_NONBLOCK +} + +func clearNonblocking(file *os.File) error { + return syscall.SetNonblock(int(file.Fd()), false) +} diff --git a/internal/safepaths/root.go b/internal/safepaths/root.go new file mode 100644 index 00000000000..c8f6d8e6388 --- /dev/null +++ b/internal/safepaths/root.go @@ -0,0 +1,666 @@ +package safepaths + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "syscall" +) + +// Root restricts filesystem operations to a directory tree and refuses to +// traverse symbolic links below that directory. +type Root struct { + path Absolute + root *os.Root + openRoot func(*os.Root, string) (*os.Root, error) + openFile func(*os.Root, string, int, os.FileMode) (*os.File, error) + lstat func(*os.Root, string) (os.FileInfo, error) + mkdir func(*os.Root, string, os.FileMode) error + random func([]byte) (int, error) + readlink func(*os.Root, string) (string, error) + rename func(*os.Root, string, string) error + stat func(*os.Root, string) (os.FileInfo, error) +} + +// OpenRoot opens an existing directory as a rooted filesystem. +func OpenRoot(path string) (*Root, error) { + absolute, err := ParseAbsolute(path) + if err != nil { + return nil, err + } + + root, err := os.OpenRoot(absolute.String()) + if err != nil { + return nil, err + } + + return &Root{ + path: absolute, + root: root, + openRoot: func(root *os.Root, name string) (*os.Root, error) { + return root.OpenRoot(name) + }, + openFile: func(root *os.Root, name string, flag int, perm os.FileMode) (*os.File, error) { + return root.OpenFile(name, flag, perm) + }, + lstat: func(root *os.Root, name string) (os.FileInfo, error) { + return root.Lstat(name) + }, + mkdir: func(root *os.Root, name string, perm os.FileMode) error { + return root.Mkdir(name, perm) + }, + random: rand.Read, + readlink: func(root *os.Root, name string) (string, error) { + return root.Readlink(name) + }, + rename: func(root *os.Root, oldName, newName string) error { + return root.Rename(oldName, newName) + }, + stat: func(root *os.Root, name string) (os.FileInfo, error) { + return root.Stat(name) + }, + }, nil +} + +// OpenRootDir creates and opens a user-selected directory as a rooted filesystem. +// The selected path may contain symbolic links; child paths beneath the opened +// root may not. +func OpenRootDir(path string, perm os.FileMode) (*Root, error) { + if path == "" { + path = "." + } + if err := os.MkdirAll(path, perm); err != nil { + return nil, err + } + return OpenRoot(path) +} + +// OpenRootWithin opens rootDir and a contained targetDir. It returns both +// handles so callers may keep the trusted parent pinned with the child. +func OpenRootWithin(rootDir, targetDir string, perm os.FileMode) (*Root, *Root, error) { + if err := os.MkdirAll(rootDir, perm); err != nil { + return nil, nil, err + } + rootAbs, err := filepath.Abs(rootDir) + if err != nil { + return nil, nil, err + } + targetAbs, err := filepath.Abs(targetDir) + if err != nil { + return nil, nil, err + } + rel, err := filepath.Rel(rootAbs, targetAbs) + if err != nil { + return nil, nil, err + } + if rel != "." && !filepath.IsLocal(rel) { + return nil, nil, fmt.Errorf("target directory %s is outside root %s", targetDir, rootDir) + } + + root, err := OpenRoot(rootAbs) + if err != nil { + return nil, nil, err + } + targetRoot, err := root.Sub(rel, perm) + if err != nil { + _ = root.Close() + return nil, nil, err + } + return root, targetRoot, nil +} + +// OpenFile creates a file without traversing symbolic links below its rooted +// parent. Creation is always exclusive, including when replace is false. +func OpenFile(path string, perm os.FileMode, replace bool) (*os.File, error) { + if hasTrailingPathSeparator(path) { + return nil, trailingPathSeparatorError(path) + } + parent := filepath.Dir(path) + if err := os.MkdirAll(parent, 0o755); err != nil { + return nil, err + } + root, err := OpenRoot(parent) + if err != nil { + return nil, err + } + file, createErr := root.Create(filepath.Base(path), perm, replace) + closeErr := root.Close() + if createErr != nil { + return nil, createErr + } + if closeErr != nil { + _ = file.Close() + return nil, closeErr + } + return file, nil +} + +// WriteFile writes a complete file without traversing symbolic links. +func WriteFile(path string, data []byte, perm os.FileMode, replace bool) (err error) { + file, err := OpenFile(path, perm, replace) + if err != nil { + return err + } + defer func() { + if closeErr := file.Close(); err == nil && closeErr != nil { + err = closeErr + } + }() + + _, err = file.Write(data) + return err +} + +// Close closes the rooted filesystem. +func (r *Root) Close() error { + return r.root.Close() +} + +// String returns the absolute path to the root directory. +func (r *Root) String() string { + return r.path.String() +} + +// Sub creates and opens a rooted subdirectory. +func (r *Root) Sub(name string, perm os.FileMode) (*Root, error) { + local, err := r.localPath(name) + if err != nil { + return nil, err + } + absolute, err := r.path.Join(local) + if err != nil { + return nil, err + } + root, err := r.openDirectories(local, perm, true) + if err != nil { + return nil, err + } + + return &Root{ + path: absolute, + root: root, + openRoot: r.openRoot, + openFile: r.openFile, + lstat: r.lstat, + mkdir: r.mkdir, + random: r.random, + readlink: r.readlink, + rename: r.rename, + stat: r.stat, + }, nil +} + +// Lstat returns information about a path without following a final symbolic +// link. Symbolic links in parent components are rejected. +func (r *Root) Lstat(name string) (os.FileInfo, error) { + local, err := r.localPath(name) + if err != nil { + return nil, err + } + parent, err := r.openDirectories(filepath.Dir(local), 0, false) + if err != nil { + return nil, err + } + defer parent.Close() + return parent.Lstat(filepath.Base(local)) +} + +// MkdirAll creates a directory path without traversing symbolic links. +func (r *Root) MkdirAll(name string, perm os.FileMode) error { + local, err := r.localPath(name) + if err != nil { + return err + } + if local == "." { + return nil + } + root, err := r.openDirectories(local, perm, true) + if err != nil { + return err + } + return root.Close() +} + +// Validate reports whether name is lexically contained beneath the root. +func (r *Root) Validate(name string) error { + _, err := r.localPath(name) + return err +} + +// Create creates a new file beneath the root. Creation is exclusive unless an +// existing regular file is safely opened and truncated for replacement. +func (r *Root) Create(name string, perm os.FileMode, replace bool) (*os.File, error) { + if hasTrailingPathSeparator(name) { + return nil, trailingPathSeparatorError(name) + } + local, err := r.localPath(name) + if err != nil { + return nil, err + } + if local == "." { + return nil, fmt.Errorf("cannot create root directory as a file") + } + parent, err := r.openDirectories(filepath.Dir(local), 0o755, true) + if err != nil { + return nil, err + } + defer parent.Close() + base := filepath.Base(local) + + if replace { + info, err := r.lstat(parent, base) + if err == nil { + redirect, err := isPathRedirect(r, parent, base, info) + if err != nil { + return nil, err + } + if redirect { + targetIsDir, err := r.redirectTargetIsDir(parent, base) + if err != nil { + return nil, err + } + if targetIsDir { + return nil, directoryWriteError(name) + } + if err := parent.Remove(base); err != nil { + return nil, err + } + } else { + if info.IsDir() { + return nil, directoryWriteError(name) + } + if info.Mode()&os.ModeNamedPipe != 0 { + return nil, namedPipeWriteError(name) + } + file, err := r.openVerifiedEntry(parent, base, local, os.O_WRONLY, false) + if err != nil { + return nil, err + } + if info.Mode().IsRegular() || info.Mode()&os.ModeIrregular != 0 { + if err := file.Truncate(0); err != nil { + _ = file.Close() + return nil, err + } + if _, err := file.Seek(0, io.SeekStart); err != nil { + _ = file.Close() + return nil, err + } + } + return file, nil + } + } else if !os.IsNotExist(err) { + return nil, err + } + } + + return parent.OpenFile(base, os.O_WRONLY|os.O_CREATE|os.O_EXCL, perm) +} + +// WriteFile writes a complete file beneath the root. +func (r *Root) WriteFile(name string, data []byte, perm os.FileMode, replace bool) (err error) { + file, err := r.Create(name, perm, replace) + if err != nil { + return err + } + defer func() { + if closeErr := file.Close(); err == nil && closeErr != nil { + err = closeErr + } + }() + + _, err = file.Write(data) + return err +} + +// CopyFile copies content into a file beneath the root. +func (r *Root) CopyFile(name string, src io.Reader, perm os.FileMode, replace bool) (err error) { + file, err := r.Create(name, perm, replace) + if err != nil { + return err + } + defer func() { + if closeErr := file.Close(); err == nil && closeErr != nil { + err = closeErr + } + }() + + _, err = io.Copy(file, src) + return err +} + +// TempDir creates and opens a uniquely named directory directly beneath the root. +func (r *Root) TempDir(prefix string) (string, *Root, error) { + if prefix == "" || filepath.Base(prefix) != prefix || !filepath.IsLocal(prefix) { + return "", nil, fmt.Errorf("invalid temporary directory prefix %q", prefix) + } + + for range 100 { + var random [8]byte + if _, err := r.random(random[:]); err != nil { + return "", nil, err + } + name := prefix + hex.EncodeToString(random[:]) + if err := r.mkdir(r.root, name, 0o700); err != nil { + if os.IsExist(err) { + continue + } + return "", nil, err + } + + root, err := r.Sub(name, 0o700) + if err != nil { + _ = r.root.RemoveAll(name) + return "", nil, err + } + return name, root, nil + } + return "", nil, fmt.Errorf("could not create a unique temporary directory") +} + +// ReadDir reads a directory beneath the root without traversing symbolic links. +func (r *Root) ReadDir(name string) ([]os.DirEntry, error) { + local, err := r.localPath(name) + if err != nil { + return nil, err + } + root, err := r.openDirectories(local, 0, false) + if err != nil { + return nil, err + } + defer root.Close() + + dir, err := root.Open(".") + if err != nil { + return nil, err + } + defer dir.Close() + return dir.ReadDir(-1) +} + +// Open opens a regular file beneath the root without traversing symbolic links. +func (r *Root) Open(name string) (*os.File, error) { + local, err := r.localPath(name) + if err != nil { + return nil, err + } + if local == "." { + return nil, fmt.Errorf("cannot open root directory as a file") + } + parent, err := r.openDirectories(filepath.Dir(local), 0, false) + if err != nil { + return nil, err + } + defer parent.Close() + base := filepath.Base(local) + + return r.openVerifiedEntry(parent, base, local, os.O_RDONLY, true) +} + +// Rename renames an entry beneath one pinned, verified parent directory. +func (r *Root) Rename(oldName, newName string) error { + oldLocal, err := r.localPath(oldName) + if err != nil { + return err + } + newLocal, err := r.localPath(newName) + if err != nil { + return err + } + oldParent := filepath.Dir(oldLocal) + if oldParent != filepath.Dir(newLocal) { + return fmt.Errorf("rooted rename requires a common parent directory") + } + parent, err := r.openDirectories(oldParent, 0, false) + if err != nil { + return err + } + defer parent.Close() + return r.rename(parent, filepath.Base(oldLocal), filepath.Base(newLocal)) +} + +// RemoveAll removes a path beneath the root without following a final symbolic link. +func (r *Root) RemoveAll(name string) error { + local, err := r.localPath(name) + if err != nil { + return err + } + if local == "." { + return fmt.Errorf("cannot remove root directory") + } + parent, err := r.openDirectories(filepath.Dir(local), 0, false) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + defer parent.Close() + return parent.RemoveAll(filepath.Base(local)) +} + +func (r *Root) localPath(name string) (string, error) { + return validateChildPath(name) +} + +// ValidateChild reports whether name is a lexically contained child path. +func ValidateChild(name string) error { + _, err := validateChildPath(name) + return err +} + +func validateChildPath(name string) (string, error) { + local := filepath.Clean(filepath.FromSlash(name)) + if !filepath.IsLocal(local) { + return "", PathTraversalError{Elems: []string{name}} + } + return local, nil +} + +func (r *Root) openDirectories(name string, perm os.FileMode, create bool) (*os.Root, error) { + var current string + parent := r.root + parentOwned := false + closeParent := func() { + if parentOwned { + _ = parent.Close() + } + } + + if name == "." { + root, err := r.openVerifiedDirectory(parent, ".", ".") + if err != nil { + return nil, err + } + return root, nil + } + + for _, component := range strings.Split(name, string(filepath.Separator)) { + if component == "" || component == "." { + continue + } + current = filepath.Join(current, component) + + info, err := parent.Lstat(component) + if os.IsNotExist(err) && create { + if err := r.mkdir(parent, component, perm); err != nil && !os.IsExist(err) { + closeParent() + return nil, err + } + info, err = parent.Lstat(component) + } + if err != nil { + closeParent() + return nil, err + } + redirect, redirectErr := isPathRedirect(r, parent, component, info) + if redirectErr != nil { + closeParent() + return nil, redirectErr + } + if redirect { + closeParent() + return nil, SymlinkTraversalError{Path: current} + } + if !info.IsDir() { + closeParent() + return nil, &os.PathError{Op: "open", Path: current, Err: fmt.Errorf("not a directory")} + } + + next, err := r.openVerifiedDirectory(parent, component, current) + if err != nil { + closeParent() + return nil, err + } + if parentOwned { + if err := parent.Close(); err != nil { + _ = next.Close() + return nil, err + } + } + parent = next + parentOwned = true + } + return parent, nil +} + +func (r *Root) openVerifiedDirectory(parent *os.Root, name, displayPath string) (*os.Root, error) { + before, err := parent.Lstat(name) + if err != nil { + return nil, err + } + redirect, err := isPathRedirect(r, parent, name, before) + if err != nil { + return nil, err + } + if redirect { + return nil, SymlinkTraversalError{Path: displayPath} + } + + root, err := r.openRoot(parent, name) + if err != nil { + return nil, err + } + opened, err := root.Stat(".") + if err != nil { + _ = root.Close() + return nil, err + } + after, err := parent.Lstat(name) + if err != nil { + _ = root.Close() + return nil, err + } + redirect, err = isPathRedirect(r, parent, name, after) + if err != nil { + _ = root.Close() + return nil, err + } + if redirect { + _ = root.Close() + return nil, SymlinkTraversalError{Path: displayPath} + } + if !os.SameFile(before, opened) || !os.SameFile(before, after) { + _ = root.Close() + return nil, fmt.Errorf("directory %q changed while opening", displayPath) + } + return root, nil +} + +func (r *Root) openVerifiedEntry(parent *os.Root, name, displayPath string, flag int, requireRegular bool) (*os.File, error) { + before, err := parent.Lstat(name) + if err != nil { + return nil, err + } + redirect, err := isPathRedirect(r, parent, name, before) + if err != nil { + return nil, err + } + if redirect { + return nil, SymlinkTraversalError{Path: displayPath} + } + if requireRegular && !before.Mode().IsRegular() { + return nil, &os.PathError{Op: "open", Path: displayPath, Err: fmt.Errorf("not a regular file")} + } + if !requireRegular && before.Mode()&os.ModeNamedPipe != 0 { + return nil, namedPipeWriteError(displayPath) + } + + openFlag := flag + if !requireRegular && flag&(os.O_WRONLY|os.O_RDWR) != 0 { + openFlag |= nonBlockingOpenFlag() + } + file, err := r.openFile(parent, name, openFlag, 0) + if err != nil { + return nil, err + } + opened, err := file.Stat() + if err != nil { + _ = file.Close() + return nil, err + } + after, err := parent.Lstat(name) + if err != nil { + _ = file.Close() + return nil, err + } + redirect, err = isPathRedirect(r, parent, name, after) + if err != nil { + _ = file.Close() + return nil, err + } + if redirect { + _ = file.Close() + return nil, SymlinkTraversalError{Path: displayPath} + } + if !os.SameFile(before, opened) || !os.SameFile(before, after) { + _ = file.Close() + return nil, fmt.Errorf("file %q changed while opening", displayPath) + } + if openFlag != flag { + if err := clearNonblocking(file); err != nil { + _ = file.Close() + return nil, err + } + } + return file, nil +} + +func (r *Root) redirectTargetIsDir(parent *os.Root, name string) (bool, error) { + info, err := r.stat(parent, name) + if os.IsNotExist(err) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("could not safely classify redirect target %q: %w", name, err) + } + return info.IsDir(), nil +} + +func directoryWriteError(path string) error { + return &os.PathError{Op: "open", Path: path, Err: syscall.EISDIR} +} + +func namedPipeWriteError(path string) error { + return &os.PathError{Op: "open", Path: path, Err: fmt.Errorf("named pipes cannot be overwritten safely")} +} + +// SymlinkTraversalError reports a symbolic link in a rooted path. +type SymlinkTraversalError struct { + Path string +} + +func (e SymlinkTraversalError) Error() string { + return fmt.Sprintf("refusing to traverse symbolic link %q", e.Path) +} + +func hasTrailingPathSeparator(path string) bool { + return len(path) > 0 && os.IsPathSeparator(path[len(path)-1]) +} + +func trailingPathSeparatorError(path string) error { + return &os.PathError{Op: "open", Path: path, Err: fmt.Errorf("file path ends in a separator")} +} diff --git a/internal/safepaths/root_race_test.go b/internal/safepaths/root_race_test.go new file mode 100644 index 00000000000..c6a57a4e386 --- /dev/null +++ b/internal/safepaths/root_race_test.go @@ -0,0 +1,91 @@ +package safepaths + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRootRejectsDirectorySwapDuringOpen(t *testing.T) { + rootDir := t.TempDir() + originalDir := filepath.Join(rootDir, "nested") + require.NoError(t, os.Mkdir(originalDir, 0o755)) + redirectedDir := filepath.Join(rootDir, "redirected") + require.NoError(t, os.Mkdir(redirectedDir, 0o755)) + + root, err := OpenRoot(rootDir) + require.NoError(t, err) + defer root.Close() + + defaultOpenRoot := root.openRoot + swapped := false + root.openRoot = func(parent *os.Root, name string) (*os.Root, error) { + if name == "nested" && !swapped { + swapped = true + moved := filepath.Join(rootDir, "moved") + require.NoError(t, os.Rename(originalDir, moved)) + if err := os.Symlink("redirected", originalDir); err != nil { + t.Skipf("directory symlinks unavailable: %v", err) + } + } + return defaultOpenRoot(parent, name) + } + + err = root.WriteFile(filepath.Join("nested", "file.txt"), []byte("content"), 0o644, false) + require.Error(t, err) + assert.NoFileExists(t, filepath.Join(redirectedDir, "file.txt")) + assert.NoFileExists(t, filepath.Join(rootDir, "moved", "file.txt")) +} + +func TestRootRenameUsesPinnedParentDuringSwap(t *testing.T) { + rootDir := t.TempDir() + parentDir := filepath.Join(rootDir, "nested") + require.NoError(t, os.Mkdir(parentDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(parentDir, "source"), []byte("original"), 0o644)) + redirectedDir := filepath.Join(rootDir, "redirected") + require.NoError(t, os.Mkdir(redirectedDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(redirectedDir, "source"), []byte("redirected"), 0o644)) + + root, err := OpenRoot(rootDir) + require.NoError(t, err) + defer root.Close() + + defaultRename := root.rename + root.rename = func(parent *os.Root, oldName, newName string) error { + moved := filepath.Join(rootDir, "moved") + require.NoError(t, os.Rename(parentDir, moved)) + if err := os.Symlink("redirected", parentDir); err != nil { + t.Skipf("directory symlinks unavailable: %v", err) + } + return defaultRename(parent, oldName, newName) + } + + require.NoError(t, root.Rename(filepath.Join("nested", "source"), filepath.Join("nested", "dest"))) + assert.FileExists(t, filepath.Join(rootDir, "moved", "dest")) + assert.NoFileExists(t, filepath.Join(redirectedDir, "dest")) + redirected, err := os.ReadFile(filepath.Join(redirectedDir, "source")) + require.NoError(t, err) + assert.Equal(t, "redirected", string(redirected)) +} + +func TestRootRenameRejectsCrossParent(t *testing.T) { + rootDir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(rootDir, "one"), 0o755)) + require.NoError(t, os.Mkdir(filepath.Join(rootDir, "two"), 0o755)) + source := filepath.Join(rootDir, "one", "source") + require.NoError(t, os.WriteFile(source, []byte("content"), 0o644)) + + root, err := OpenRoot(rootDir) + require.NoError(t, err) + defer root.Close() + + err = root.Rename(filepath.Join("one", "source"), filepath.Join("two", "dest")) + require.ErrorContains(t, err, "common parent") + content, err := os.ReadFile(source) + require.NoError(t, err) + assert.Equal(t, "content", string(content)) + assert.NoFileExists(t, filepath.Join(rootDir, "two", "dest")) +} diff --git a/internal/safepaths/root_special_unix_test.go b/internal/safepaths/root_special_unix_test.go new file mode 100644 index 00000000000..9d74782a862 --- /dev/null +++ b/internal/safepaths/root_special_unix_test.go @@ -0,0 +1,96 @@ +//go:build unix + +package safepaths + +import ( + "os" + "path/filepath" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateReplacePreservesDevice(t *testing.T) { + info, err := os.Lstat("/dev/null") + if err != nil || info.Mode()&os.ModeDevice == 0 { + t.Skip("/dev/null is unavailable") + } + + root, err := OpenRoot("/dev") + require.NoError(t, err) + defer root.Close() + file, err := root.Create("null", 0o644, true) + require.NoError(t, err) + _, err = file.Write([]byte("content")) + require.NoError(t, err) + require.NoError(t, file.Close()) + + after, err := os.Lstat("/dev/null") + require.NoError(t, err) + assert.NotZero(t, after.Mode()&os.ModeDevice) + assert.True(t, os.SameFile(info, after)) +} + +func TestCreateReplaceDoesNotUnlinkFIFO(t *testing.T) { + rootDir := t.TempDir() + fifo := filepath.Join(rootDir, "fifo") + require.NoError(t, syscall.Mkfifo(fifo, 0o600)) + before, err := os.Lstat(fifo) + require.NoError(t, err) + + root, err := OpenRoot(rootDir) + require.NoError(t, err) + defer root.Close() + + result := make(chan error, 1) + go func() { + file, err := root.Create("fifo", 0o644, true) + if file != nil { + _ = file.Close() + } + result <- err + }() + select { + case err := <-result: + require.ErrorContains(t, err, "named pipes cannot be overwritten safely") + case <-time.After(time.Second): + reader, openErr := os.OpenFile(fifo, os.O_RDONLY|syscall.O_NONBLOCK, 0) + if openErr == nil { + _ = reader.Close() + } + t.Fatal("FIFO replacement blocked without a reader") + } + + after, err := os.Lstat(fifo) + require.NoError(t, err) + assert.NotZero(t, after.Mode()&os.ModeNamedPipe) + assert.True(t, os.SameFile(before, after)) +} + +func TestCreateReplaceDoesNotBlockOnFIFOSwap(t *testing.T) { + rootDir := t.TempDir() + path := filepath.Join(rootDir, "target") + require.NoError(t, os.WriteFile(path, []byte("regular"), 0o600)) + + root, err := OpenRoot(rootDir) + require.NoError(t, err) + defer root.Close() + defaultOpenFile := root.openFile + root.openFile = func(parent *os.Root, name string, flag int, perm os.FileMode) (*os.File, error) { + require.NoError(t, os.Remove(path)) + require.NoError(t, syscall.Mkfifo(path, 0o600)) + return defaultOpenFile(parent, name, flag, perm) + } + + start := time.Now() + file, err := root.Create("target", 0o644, true) + assert.Nil(t, file) + require.Error(t, err) + assert.Less(t, time.Since(start), time.Second) + info, err := os.Lstat(path) + require.NoError(t, err) + assert.NotZero(t, info.Mode()&os.ModeNamedPipe) +} diff --git a/internal/safepaths/root_test.go b/internal/safepaths/root_test.go new file mode 100644 index 00000000000..8b8cde37028 --- /dev/null +++ b/internal/safepaths/root_test.go @@ -0,0 +1,405 @@ +package safepaths_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/cli/cli/v2/internal/safepaths" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRootCreate(t *testing.T) { + var preservedMode os.FileMode + + tests := []struct { + name string + path string + replace bool + setup func(t *testing.T, root, outside string) + wantErr string + verify func(t *testing.T, root, outside string) + }{ + { + name: "creates nested file", + path: "nested/file.txt", + verify: func(t *testing.T, root, _ string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(root, "nested", "file.txt")) + require.NoError(t, err) + assert.Equal(t, "new", string(content)) + }, + }, + { + name: "rejects lexical escape", + path: "../file.txt", + wantErr: "not a local child path", + }, + { + name: "rejects trailing separator", + path: "nested" + string(os.PathSeparator), + wantErr: "ends in a separator", + verify: func(t *testing.T, root, _ string) { + t.Helper() + _, err := os.Lstat(filepath.Join(root, "nested")) + require.True(t, os.IsNotExist(err)) + assert.NoFileExists(t, filepath.Join(root, "nested", "nested")) + }, + }, + { + name: "rejects ancestor symlink", + path: "nested/file.txt", + setup: func(t *testing.T, root, outside string) { + t.Helper() + require.NoError(t, os.Symlink(outside, filepath.Join(root, "nested"))) + }, + wantErr: "symbolic link", + verify: func(t *testing.T, _, outside string) { + t.Helper() + assert.NoFileExists(t, filepath.Join(outside, "file.txt")) + }, + }, + { + name: "no clobber rejects dangling final symlink", + path: "file.txt", + setup: func(t *testing.T, root, outside string) { + t.Helper() + require.NoError(t, os.Symlink(filepath.Join(outside, "missing.txt"), filepath.Join(root, "file.txt"))) + }, + wantErr: "file exists", + verify: func(t *testing.T, _, outside string) { + t.Helper() + assert.NoFileExists(t, filepath.Join(outside, "missing.txt")) + }, + }, + { + name: "clobber replaces final symlink", + path: "file.txt", + replace: true, + setup: func(t *testing.T, root, outside string) { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(root, "target.txt"), []byte("old"), 0o644)) + require.NoError(t, os.Symlink("target.txt", filepath.Join(root, "file.txt"))) + }, + verify: func(t *testing.T, root, outside string) { + t.Helper() + info, err := os.Lstat(filepath.Join(root, "file.txt")) + require.NoError(t, err) + assert.Zero(t, info.Mode()&os.ModeSymlink) + content, err := os.ReadFile(filepath.Join(root, "file.txt")) + require.NoError(t, err) + assert.Equal(t, "new", string(content)) + content, err = os.ReadFile(filepath.Join(root, "target.txt")) + require.NoError(t, err) + assert.Equal(t, "old", string(content)) + }, + }, + { + name: "clobber refuses symlink to directory", + path: "file.txt", + replace: true, + setup: func(t *testing.T, root, outside string) { + t.Helper() + require.NoError(t, os.Mkdir(filepath.Join(root, "target"), 0o755)) + require.NoError(t, os.Symlink("target", filepath.Join(root, "file.txt"))) + }, + wantErr: "is a directory", + verify: func(t *testing.T, root, _ string) { + t.Helper() + info, err := os.Lstat(filepath.Join(root, "file.txt")) + require.NoError(t, err) + assert.NotZero(t, info.Mode()&os.ModeSymlink) + }, + }, + { + name: "clobber refuses redirect outside root", + path: "file.txt", + replace: true, + setup: func(t *testing.T, root, outside string) { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(outside, "target.txt"), []byte("old"), 0o644)) + require.NoError(t, os.Symlink(filepath.Join(outside, "target.txt"), filepath.Join(root, "file.txt"))) + }, + wantErr: "could not safely classify redirect target", + verify: func(t *testing.T, root, outside string) { + t.Helper() + info, err := os.Lstat(filepath.Join(root, "file.txt")) + require.NoError(t, err) + assert.NotZero(t, info.Mode()&os.ModeSymlink) + content, err := os.ReadFile(filepath.Join(outside, "target.txt")) + require.NoError(t, err) + assert.Equal(t, "old", string(content)) + }, + }, + { + name: "clobber replaces dangling redirect", + path: "file.txt", + replace: true, + setup: func(t *testing.T, root, _ string) { + t.Helper() + require.NoError(t, os.Symlink("missing.txt", filepath.Join(root, "file.txt"))) + }, + verify: func(t *testing.T, root, _ string) { + t.Helper() + info, err := os.Lstat(filepath.Join(root, "file.txt")) + require.NoError(t, err) + assert.Zero(t, info.Mode()&os.ModeSymlink) + assert.NoFileExists(t, filepath.Join(root, "missing.txt")) + }, + }, + { + name: "clobber preserves regular file mode", + path: "file.txt", + replace: true, + setup: func(t *testing.T, root, _ string) { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(root, "file.txt"), []byte("old"), 0o600)) + info, err := os.Stat(filepath.Join(root, "file.txt")) + require.NoError(t, err) + preservedMode = info.Mode().Perm() + }, + verify: func(t *testing.T, root, _ string) { + t.Helper() + info, err := os.Stat(filepath.Join(root, "file.txt")) + require.NoError(t, err) + assert.Equal(t, preservedMode, info.Mode().Perm()) + content, err := os.ReadFile(filepath.Join(root, "file.txt")) + require.NoError(t, err) + assert.Equal(t, "new", string(content)) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rootDir := t.TempDir() + outside := t.TempDir() + if tt.setup != nil { + tt.setup(t, rootDir, outside) + } + + root, err := safepaths.OpenRoot(rootDir) + require.NoError(t, err) + defer root.Close() + + err = root.WriteFile(tt.path, []byte("new"), 0o644, tt.replace) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + } else { + require.NoError(t, err) + } + if tt.verify != nil { + tt.verify(t, rootDir, outside) + } + }) + } +} + +func TestOpenRootWithinRejectsOutsideTarget(t *testing.T) { + rootDir := t.TempDir() + outside := t.TempDir() + + root, target, err := safepaths.OpenRootWithin(rootDir, outside, 0o755) + require.ErrorContains(t, err, "outside root") + assert.Nil(t, root) + assert.Nil(t, target) +} + +func TestValidateChild(t *testing.T) { + tests := []struct { + name string + path string + wantErr bool + }{ + {name: "local child", path: "nested/file.txt"}, + {name: "parent escape", path: "../file.txt", wantErr: true}, + {name: "absolute", path: filepath.Join(string(os.PathSeparator), "file.txt"), wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := safepaths.ValidateChild(tt.path) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestRootRejectsDotFileOperations(t *testing.T) { + root, err := safepaths.OpenRoot(t.TempDir()) + require.NoError(t, err) + defer root.Close() + + file, err := root.Create(".", 0o644, false) + assert.Nil(t, file) + require.ErrorContains(t, err, "root directory") + file, err = root.Open(".") + assert.Nil(t, file) + require.ErrorContains(t, err, "root directory") + require.ErrorContains(t, root.RemoveAll("."), "root directory") +} + +func TestRootOpenRejectsNonregularFile(t *testing.T) { + root, err := safepaths.OpenRoot(t.TempDir()) + require.NoError(t, err) + defer root.Close() + require.NoError(t, root.MkdirAll("directory", 0o755)) + + file, err := root.Open("directory") + assert.Nil(t, file) + require.ErrorContains(t, err, "not a regular file") +} + +func TestTempDirRejectsInvalidPrefix(t *testing.T) { + root, err := safepaths.OpenRoot(t.TempDir()) + require.NoError(t, err) + defer root.Close() + + for _, prefix := range []string{"", "../prefix", "nested/prefix"} { + name, tempRoot, err := root.TempDir(prefix) + assert.Empty(t, name) + assert.Nil(t, tempRoot) + require.ErrorContains(t, err, "invalid temporary directory prefix") + } +} + +func TestOpenFileRejectsTrailingSeparator(t *testing.T) { + tests := []struct { + name string + path func(root string) string + }{ + { + name: "relative path", + path: func(string) string { + return "output" + string(os.PathSeparator) + }, + }, + { + name: "absolute path", + path: func(root string) string { + return filepath.Join(root, "output") + string(os.PathSeparator) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root := t.TempDir() + t.Chdir(root) + path := tt.path(root) + + file, err := safepaths.OpenFile(path, 0o644, false) + if file != nil { + _ = file.Close() + } + require.ErrorContains(t, err, "ends in a separator") + _, statErr := os.Lstat(filepath.Join(root, "output")) + require.True(t, os.IsNotExist(statErr)) + assert.NoFileExists(t, filepath.Join(root, "output", "output")) + }) + } +} + +func TestOpenFileAllowsTrustedParentSymlinks(t *testing.T) { + tests := []struct { + name string + path func(sandbox string) string + }{ + { + name: "relative", + path: func(string) string { + return filepath.Join("link", "file.txt") + }, + }, + { + name: "absolute", + path: func(sandbox string) string { + return filepath.Join(sandbox, "link", "file.txt") + }, + }, + { + name: "parent relative", + path: func(string) string { + return filepath.Join("..", "link", "file.txt") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sandbox := t.TempDir() + target := filepath.Join(sandbox, "target") + require.NoError(t, os.Mkdir(target, 0o755)) + require.NoError(t, os.Symlink(target, filepath.Join(sandbox, "link"))) + if tt.name == "parent relative" { + work := filepath.Join(sandbox, "work") + require.NoError(t, os.Mkdir(work, 0o755)) + t.Chdir(work) + } else { + t.Chdir(sandbox) + } + + file, err := safepaths.OpenFile(tt.path(sandbox), 0o644, false) + require.NoError(t, err) + _, err = file.Write([]byte("content")) + require.NoError(t, err) + require.NoError(t, file.Close()) + content, err := os.ReadFile(filepath.Join(target, "file.txt")) + require.NoError(t, err) + assert.Equal(t, "content", string(content)) + }) + } +} + +func TestOpenRootDirAllowsTrustedPathSymlinks(t *testing.T) { + tests := []struct { + name string + path func(sandbox string) string + }{ + { + name: "relative", + path: func(string) string { + return filepath.Join("link", "selected") + }, + }, + { + name: "absolute", + path: func(sandbox string) string { + return filepath.Join(sandbox, "link", "selected") + }, + }, + { + name: "parent relative", + path: func(string) string { + return filepath.Join("..", "link", "selected") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sandbox := t.TempDir() + target := filepath.Join(sandbox, "target") + require.NoError(t, os.Mkdir(target, 0o755)) + require.NoError(t, os.Symlink(target, filepath.Join(sandbox, "link"))) + if tt.name == "parent relative" { + work := filepath.Join(sandbox, "work") + require.NoError(t, os.Mkdir(work, 0o755)) + t.Chdir(work) + } else { + t.Chdir(sandbox) + } + + root, err := safepaths.OpenRootDir(tt.path(sandbox), 0o755) + require.NoError(t, err) + require.NoError(t, root.WriteFile("file.txt", []byte("content"), 0o644, false)) + require.NoError(t, root.Close()) + content, err := os.ReadFile(filepath.Join(target, "selected", "file.txt")) + require.NoError(t, err) + assert.Equal(t, "content", string(content)) + }) + } +} diff --git a/internal/safepaths/root_validation_test.go b/internal/safepaths/root_validation_test.go new file mode 100644 index 00000000000..f69926d6874 --- /dev/null +++ b/internal/safepaths/root_validation_test.go @@ -0,0 +1,142 @@ +package safepaths + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTempDirFailurePaths(t *testing.T) { + tests := []struct { + name string + configure func(root *Root) + wantErr string + verify func(t *testing.T, root *Root) + }{ + { + name: "random source error", + configure: func(root *Root) { + root.random = func([]byte) (int, error) { + return 0, errors.New("random failed") + } + }, + wantErr: "random failed", + }, + { + name: "mkdir error", + configure: func(root *Root) { + root.mkdir = func(*os.Root, string, os.FileMode) error { + return errors.New("mkdir failed") + } + }, + wantErr: "mkdir failed", + }, + { + name: "name exhaustion", + configure: func(root *Root) { + root.mkdir = func(*os.Root, string, os.FileMode) error { + return os.ErrExist + } + }, + wantErr: "could not create a unique temporary directory", + }, + { + name: "opening created directory fails", + configure: func(root *Root) { + root.openRoot = func(*os.Root, string) (*os.Root, error) { + return nil, errors.New("open failed") + } + }, + wantErr: "open failed", + verify: func(t *testing.T, root *Root) { + t.Helper() + entries, err := os.ReadDir(root.String()) + require.NoError(t, err) + assert.Empty(t, entries) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root, err := OpenRoot(t.TempDir()) + require.NoError(t, err) + defer root.Close() + tt.configure(root) + + name, tempRoot, err := root.TempDir(".temp-") + assert.Empty(t, name) + assert.Nil(t, tempRoot) + require.ErrorContains(t, err, tt.wantErr) + if tt.verify != nil { + tt.verify(t, root) + } + }) + } +} + +func TestDirectoryIdentityMismatch(t *testing.T) { + rootDir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(rootDir, "expected"), 0o755)) + require.NoError(t, os.Mkdir(filepath.Join(rootDir, "other"), 0o755)) + + root, err := OpenRoot(rootDir) + require.NoError(t, err) + defer root.Close() + defaultOpenRoot := root.openRoot + root.openRoot = func(parent *os.Root, name string) (*os.Root, error) { + if name == "expected" { + return defaultOpenRoot(parent, "other") + } + return defaultOpenRoot(parent, name) + } + + child, err := root.Sub("expected", 0o755) + assert.Nil(t, child) + require.ErrorContains(t, err, "changed while opening") +} + +func TestFileIdentityMismatch(t *testing.T) { + rootDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(rootDir, "expected"), []byte("expected"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(rootDir, "other"), []byte("other"), 0o644)) + + root, err := OpenRoot(rootDir) + require.NoError(t, err) + defer root.Close() + defaultOpenFile := root.openFile + root.openFile = func(parent *os.Root, name string, flag int, perm os.FileMode) (*os.File, error) { + if name == "expected" { + return defaultOpenFile(parent, "other", flag, perm) + } + return defaultOpenFile(parent, name, flag, perm) + } + + file, err := root.Open("expected") + assert.Nil(t, file) + require.ErrorContains(t, err, "changed while opening") +} + +func TestRedirectTargetClassificationError(t *testing.T) { + rootDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(rootDir, "target"), []byte("target"), 0o644)) + require.NoError(t, os.Symlink("target", filepath.Join(rootDir, "link"))) + + root, err := OpenRoot(rootDir) + require.NoError(t, err) + defer root.Close() + root.stat = func(*os.Root, string) (os.FileInfo, error) { + return nil, errors.New("stat failed") + } + + file, err := root.Create("link", 0o644, true) + assert.Nil(t, file) + require.ErrorContains(t, err, "could not safely classify redirect target") + info, err := os.Lstat(filepath.Join(rootDir, "link")) + require.NoError(t, err) + assert.NotZero(t, info.Mode()&os.ModeSymlink) +} diff --git a/internal/skills/installer/installer.go b/internal/skills/installer/installer.go index a0b0bfb708f..28f8f0fefc9 100644 --- a/internal/skills/installer/installer.go +++ b/internal/skills/installer/installer.go @@ -24,20 +24,24 @@ const maxConcurrency = 5 // Options configures an installation. type Options struct { - Host string // GitHub API hostname - Owner string - Repo string - Ref string // resolved ref name - SHA string // resolved commit SHA - PinnedRef string // user-supplied --pin value (empty if unpinned) - Skills []discovery.Skill - AgentHost *registry.AgentHost - Scope registry.Scope - Dir string // explicit target directory (overrides AgentHost+Scope) - GitRoot string // git repository root (for project scope) - HomeDir string // user home directory (for user scope) - Client *api.Client - OnProgress func(done, total int) // called after each skill is installed + Host string // GitHub API hostname + Owner string + Repo string + Ref string // resolved ref name + SHA string // resolved commit SHA + PinnedRef string // user-supplied --pin value (empty if unpinned) + Skills []discovery.Skill + AgentHost *registry.AgentHost + Scope registry.Scope + Dir string // explicit target directory (overrides AgentHost+Scope) + RootDir string // trusted root containing Dir + GitRoot string // git repository root (for project scope) + HomeDir string // user home directory (for user scope) + Client *api.Client + TargetRoot *safepaths.Root // optional pre-opened root for Dir + DirectRoot bool // write skill contents directly into TargetRoot + ReplaceExisting map[string]bool + OnProgress func(done, total int) // called after each skill is installed } // Result tracks what was installed. @@ -143,13 +147,15 @@ func Install(opts *Options) (*Result, error) { // LocalOptions configures a local directory installation. type LocalOptions struct { - SourceDir string - Skills []discovery.Skill - AgentHost *registry.AgentHost - Scope registry.Scope - Dir string - GitRoot string - HomeDir string + SourceDir string + Skills []discovery.Skill + AgentHost *registry.AgentHost + Scope registry.Scope + Dir string + RootDir string + GitRoot string + HomeDir string + ReplaceExisting map[string]bool } // InstallLocal copies skills from a local directory to the target install location. @@ -168,7 +174,7 @@ func InstallLocal(opts *LocalOptions) (*Result, error) { var installed []string for _, skill := range opts.Skills { - if err := installLocalSkill(opts.SourceDir, skill, targetDir); err != nil { + if err := installLocalSkill(opts, skill, targetDir); err != nil { return nil, fmt.Errorf("failed to install skill %q: %w", skill.InstallName(), err) } installed = append(installed, skill.InstallName()) @@ -177,26 +183,28 @@ func InstallLocal(opts *LocalOptions) (*Result, error) { return &Result{Installed: installed, Dir: targetDir}, nil } -func installLocalSkill(sourceRoot string, skill discovery.Skill, baseDir string) error { +func installLocalSkill(opts *LocalOptions, skill discovery.Skill, baseDir string) error { // Use skill.Name (not InstallName) so skills are always installed flat. // Most agent clients only discover immediate subdirectories of their // skills folder and do not find skills nested under namespace directories. skillDir := filepath.Join(baseDir, skill.Name) - if err := os.MkdirAll(skillDir, 0o755); err != nil { + targetRoot, err := openInstallRoot(opts.RootDir, baseDir) + if err != nil { + return fmt.Errorf("could not open install directory %s: %w", baseDir, err) + } + defer targetRoot.Close() + skillRoot, err := targetRoot.Sub(skill.Name, 0o755) + if err != nil { return fmt.Errorf("could not create directory %s: %w", skillDir, err) } + defer skillRoot.Close() - srcDir := filepath.Join(sourceRoot, filepath.FromSlash(skill.Path)) + srcDir := filepath.Join(opts.SourceDir, filepath.FromSlash(skill.Path)) absSource, err := filepath.Abs(srcDir) if err != nil { return fmt.Errorf("could not resolve source path: %w", err) } - safeSkillDir, err := safepaths.ParseAbsolute(skillDir) - if err != nil { - return fmt.Errorf("could not resolve target path: %w", err) - } - return filepath.WalkDir(srcDir, func(p string, d os.DirEntry, walkErr error) error { if walkErr != nil { return walkErr @@ -213,24 +221,6 @@ func installLocalSkill(sourceRoot string, skill discovery.Skill, baseDir string) return err } - // Defensive: filepath.WalkDir cannot produce traversal paths, but we - // guard against it in case the walk input is ever changed. - safeDest, err := safeSkillDir.Join(relPath) - if err != nil { - var traversalErr safepaths.PathTraversalError - if errors.As(err, &traversalErr) { - return fmt.Errorf("blocked path traversal in %q", relPath) - } - return fmt.Errorf("could not resolve destination path: %w", err) - } - destPath := safeDest.String() - - if dir := filepath.Dir(destPath); dir != skillDir { - if err := os.MkdirAll(dir, 0o755); err != nil { - return fmt.Errorf("could not create directory: %w", err) - } - } - content, err := os.ReadFile(p) if err != nil { return fmt.Errorf("could not read %s: %w", p, err) @@ -244,25 +234,49 @@ func installLocalSkill(sourceRoot string, skill discovery.Skill, baseDir string) content = []byte(injected) } - return os.WriteFile(destPath, content, 0o644) + if err := skillRoot.WriteFile(relPath, content, 0o644, opts.ReplaceExisting[skill.Name]); err != nil { + var traversalErr safepaths.PathTraversalError + if errors.As(err, &traversalErr) { + return fmt.Errorf("blocked path traversal in %q", relPath) + } + return fmt.Errorf("could not write %s: %w", filepath.Join(skillDir, relPath), err) + } + return nil }) } func installSkill(opts *Options, skill discovery.Skill, baseDir string) error { // Use skill.Name (not InstallName) for a flat directory layout. skillDir := filepath.Join(baseDir, skill.Name) - if err := os.MkdirAll(skillDir, 0o755); err != nil { - return fmt.Errorf("could not create directory %s: %w", skillDir, err) + targetRoot := opts.TargetRoot + ownsTargetRoot := false + if targetRoot == nil { + var err error + targetRoot, err = openInstallRoot(opts.RootDir, baseDir) + if err != nil { + return fmt.Errorf("could not open install directory %s: %w", baseDir, err) + } + ownsTargetRoot = true + } + if ownsTargetRoot { + defer targetRoot.Close() } - files, err := discovery.DiscoverSkillFiles(opts.Client, opts.Host, opts.Owner, opts.Repo, skill.TreeSHA, skill.Path) - if err != nil { - return fmt.Errorf("could not list skill files: %w", err) + skillRoot := targetRoot + if !opts.DirectRoot { + var err error + skillRoot, err = targetRoot.Sub(skill.Name, 0o755) + if err != nil { + return fmt.Errorf("could not create directory %s: %w", skillDir, err) + } + defer skillRoot.Close() + } else { + skillDir = baseDir } - safeSkillDir, err := safepaths.ParseAbsolute(skillDir) + files, err := discovery.DiscoverSkillFiles(opts.Client, opts.Host, opts.Owner, opts.Repo, skill.TreeSHA, skill.Path) if err != nil { - return fmt.Errorf("could not resolve skill directory path: %w", err) + return fmt.Errorf("could not list skill files: %w", err) } for _, file := range files { @@ -277,22 +291,6 @@ func installSkill(opts *Options, skill discovery.Skill, baseDir string) error { relPath := strings.TrimPrefix(file.Path, skill.Path+"/") - safeDest, err := safeSkillDir.Join(relPath) - if err != nil { - var traversalErr safepaths.PathTraversalError - if errors.As(err, &traversalErr) { - return fmt.Errorf("blocked path traversal in %q", relPath) - } - return fmt.Errorf("could not resolve destination path: %w", err) - } - destPath := safeDest.String() - - if dir := filepath.Dir(destPath); dir != skillDir { - if err := os.MkdirAll(dir, 0o755); err != nil { - return fmt.Errorf("could not create directory: %w", err) - } - } - if filepath.Base(relPath) == "SKILL.md" { content, err = frontmatter.InjectGitHubMetadata(content, opts.Host, opts.Owner, opts.Repo, opts.Ref, skill.TreeSHA, opts.PinnedRef, skill.Path) if err != nil { @@ -300,14 +298,34 @@ func installSkill(opts *Options, skill discovery.Skill, baseDir string) error { } } - if err := os.WriteFile(destPath, []byte(content), 0o644); err != nil { - return fmt.Errorf("could not write %s: %w", destPath, err) + if err := skillRoot.WriteFile(relPath, []byte(content), 0o644, opts.ReplaceExisting[skill.Name]); err != nil { + var traversalErr safepaths.PathTraversalError + if errors.As(err, &traversalErr) { + return fmt.Errorf("blocked path traversal in %q", relPath) + } + return fmt.Errorf("could not write %s: %w", filepath.Join(skillDir, relPath), err) } } return nil } +func openInstallRoot(rootDir, targetDir string) (*safepaths.Root, error) { + if rootDir == "" { + rootDir = targetDir + } + root, targetRoot, err := safepaths.OpenRootWithin(rootDir, targetDir, 0o755) + if err != nil { + return nil, err + } + closeErr := root.Close() + if closeErr != nil { + _ = targetRoot.Close() + return nil, closeErr + } + return targetRoot, nil +} + // ResolveGitRoot returns the git repository root using the provided client, // falling back to the current working directory on error. func ResolveGitRoot(gc *git.Client) string { diff --git a/internal/skills/installer/installer_test.go b/internal/skills/installer/installer_test.go index 771e225890e..08b053aacb8 100644 --- a/internal/skills/installer/installer_test.go +++ b/internal/skills/installer/installer_test.go @@ -183,11 +183,15 @@ func TestInstallLocal(t *testing.T) { } func TestInstallSkill(t *testing.T) { + var preservedSkillMode os.FileMode + tests := []struct { - name string - skill discovery.Skill - stubs func(*httpmock.Registry) - verify func(t *testing.T, destDir string) + name string + skill discovery.Skill + setup func(t *testing.T, destDir string) + stubs func(*httpmock.Registry) + verify func(t *testing.T, destDir string) + replace bool }{ { name: "installs files from remote", @@ -284,10 +288,54 @@ func TestInstallSkill(t *testing.T) { assert.True(t, os.IsNotExist(err), "traversal path should not be written") }, }, + { + name: "forced replacement preserves regular file mode", + skill: discovery.Skill{Name: "code-review", Path: "skills/code-review", TreeSHA: "tree-mode"}, + setup: func(t *testing.T, destDir string) { + t.Helper() + skillDir := filepath.Join(destDir, "code-review") + require.NoError(t, os.Mkdir(skillDir, 0o755)) + skillFile := filepath.Join(skillDir, "SKILL.md") + require.NoError(t, os.WriteFile(skillFile, []byte("old"), 0o600)) + info, err := os.Stat(skillFile) + require.NoError(t, err) + preservedSkillMode = info.Mode().Perm() + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree-mode"), + httpmock.JSONResponse(map[string]any{ + "sha": "tree-mode", "truncated": false, + "tree": []map[string]any{ + {"path": "SKILL.md", "type": "blob", "sha": "mode-sha", "size": 10}, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/mode-sha"), + httpmock.JSONResponse(map[string]any{ + "sha": "mode-sha", "encoding": "base64", + "content": base64.StdEncoding.EncodeToString([]byte("# Updated")), + })) + }, + verify: func(t *testing.T, destDir string) { + t.Helper() + skillFile := filepath.Join(destDir, "code-review", "SKILL.md") + info, err := os.Stat(skillFile) + require.NoError(t, err) + assert.Equal(t, preservedSkillMode, info.Mode().Perm()) + content, err := os.ReadFile(skillFile) + require.NoError(t, err) + assert.Contains(t, string(content), "# Updated") + }, + replace: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { destDir := t.TempDir() + if tt.setup != nil { + tt.setup(t, destDir) + } reg := &httpmock.Registry{} defer reg.Verify(t) tt.stubs(reg) @@ -300,6 +348,9 @@ func TestInstallSkill(t *testing.T) { SHA: "commit123", Client: client, } + if tt.replace { + opts.ReplaceExisting = map[string]bool{tt.skill.Name: true} + } err := installSkill(opts, tt.skill, destDir) if tt.name == "fails on path traversal from malicious tree" { diff --git a/internal/zip/fixtures/myproject.zip b/internal/zip/fixtures/myproject.zip deleted file mode 100644 index 2fdf3f90c6e00c9f4b59a47fb84b6fd3486be007..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1710 zcmWIWW@h1H0D(o3?Ll9~jbb@~Y!K#TkYOlFO-#v6)yqu@4dG;9UQnK!0K%me+zgB? zUl|z~SVVvdl@&Zvb8_;z0^mkv1C0VozyS-;sHDt1eXuE3Kogc>nu4lZcJ6Pmvp`M* zIRvCTw=%UN6>bE`7<7jy>Vwo3XCOJH7K>Agi;^Kui3OSwhiMMNDInbqTFZoZfzAM7 zZlF^@x=TwkbM(^l!HxkL0>WsHDM(DtPE1c#04YUsPchI$zH_!Z0zlJ17-nK_VrCx9 zL@%IeAdF^WT4`Rg0!W>Prb4wsE!a>{_;WDaUDg`7NkYZAl!<{shlPQG3+PNeJ$)Zf zXIH;qSFkH5Jv|){!f0m326^Y-HsIO!I$XeVYr{p`yxYE8r$)?(pD;De(rTMfcEOqh zlf=K=?=QJjCM}>KaPqyY)pRMN+p{i8A4@8)ocm@a|Az~9hpwNG-*@m>TZvtR%x(2! zTE=tNe!te6VEZM_s6E`m_pF4E_`yOWwmX8i_RaKDYkIIiGX7zbT0cWMN3ZPLiYB(G zD~T48RZBj;lUR2)rC>+%vFhu$*nTc3ep_RfoWb-`_HBZVP0PIltM(~}^>=%u&uY9W zxZ?Y@rq#TAXEXa8Yd^W|h+w1a?ZkH{4o6pSNUsi8_#t%9SY@uQn!jNS9PPKzm@`gqd!!C3=?lnpPcPyTwCH&gMocvQxY^(TU#-rD_n zzvr6tTFi415Y2X`abRb?8JuVo%%ebg?rD&7phJA&d8rXn^7+>>dU|W&iEcb)+HWeOX8ms;oW(p@9;GjJiFIB% z&TZ?|G5gT7W6=@*h-X2{EA<*#-iHKTahb*~y8OqJ^G1`aLa8Y5g?h+QsSfcPhq<40lr@iSe-Ss)zT87(nC{kqc#@W_4mNt1UIsQ>RJ5o$US&w_|2gyXQ zrS}(FM|N}Um}IJ(vi6is(iT6LDcqcoxC)aOh90&TKPXt0pZ(E~qw|4e(N&gZ9r=g- z-|z=`Gcw6B<0^F|fJvHx0a&;&ENKL>kjfxdNEw8dhLMfKEO{Wtfil}$M{l5Uka7rU z5|#o4-6Ygf2-#tsP?Ml#5@F*o%OPZkT>}~iDV2aGVJS?I9fnyZAv;V27;q>h6k!7~ zizH+N)1U^z3n-w0SPCbI3vm@k$Oi5P8i-st0nNiwFa>zCvH=5yfgK1{fW=w~uvlYY F002;O_<#Ta diff --git a/internal/zip/zip.go b/internal/zip/zip.go index 8cef5c30bfe..14db7c0b0d8 100644 --- a/internal/zip/zip.go +++ b/internal/zip/zip.go @@ -6,7 +6,6 @@ import ( "fmt" "io" "os" - "path/filepath" "github.com/cli/cli/v2/internal/safepaths" ) @@ -18,31 +17,24 @@ const ( ) // ExtractZip extracts the contents of a zip archive to destDir. -// Files that would result in path traversal are silently skipped. -// Files that would produce any other error cause the extraction to be aborted, -// and the error is returned. -func ExtractZip(zr *zip.Reader, destDir safepaths.Absolute) error { +// Entries that would escape destDir lexically are skipped. +func ExtractZip(zr *zip.Reader, destDir *safepaths.Root) error { for _, zf := range zr.File { - fpath, err := destDir.Join(zf.Name) - if err != nil { - var pathTraversalError safepaths.PathTraversalError - if errors.As(err, &pathTraversalError) { + if err := extractZipFile(zf, destDir); err != nil { + var traversalError safepaths.PathTraversalError + if errors.As(err, &traversalError) { continue } - return err - } - - if err := extractZipFile(zf, fpath); err != nil { return fmt.Errorf("error extracting %q: %w", zf.Name, err) } } return nil } -func extractZipFile(zf *zip.File, dest safepaths.Absolute) (extractErr error) { +func extractZipFile(zf *zip.File, dest *safepaths.Root) (extractErr error) { zm := zf.Mode() if zm.IsDir() { - extractErr = os.MkdirAll(dest.String(), dirMode) + extractErr = dest.MkdirAll(zf.Name, dirMode) return } @@ -53,12 +45,8 @@ func extractZipFile(zf *zip.File, dest safepaths.Absolute) (extractErr error) { } defer f.Close() - if extractErr = os.MkdirAll(filepath.Dir(dest.String()), dirMode); extractErr != nil { - return - } - var df *os.File - if df, extractErr = os.OpenFile(dest.String(), os.O_WRONLY|os.O_CREATE|os.O_EXCL, getPerm(zm)); extractErr != nil { + if df, extractErr = dest.Create(zf.Name, getPerm(zm), false); extractErr != nil { return } diff --git a/internal/zip/zip_test.go b/internal/zip/zip_test.go index 37e83661cf0..6e58f4c4036 100644 --- a/internal/zip/zip_test.go +++ b/internal/zip/zip_test.go @@ -2,26 +2,114 @@ package zip import ( "archive/zip" + "bytes" "os" "path/filepath" "testing" "github.com/cli/cli/v2/internal/safepaths" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func Test_extractZip(t *testing.T) { - tmpDir := t.TempDir() - extractPath, err := safepaths.ParseAbsolute(filepath.Join(tmpDir, "artifact")) - require.NoError(t, err) + tests := []struct { + name string + directories []string + entries []string + setup func(t *testing.T, extractPath, outside string) + wantExtract []string + wantDirs []string + wantErr string + }{ + { + name: "extracts nested file", + entries: []string{"src/main.go"}, + wantExtract: []string{"src/main.go"}, + }, + { + name: "skips lexical escape", + entries: []string{"../outside.txt"}, + }, + { + name: "skips escapes among safe entries", + entries: []string{ + "../outside-before.txt", + "safe-one.txt", + "../../outside-between.txt", + "nested/safe-two.txt", + }, + wantExtract: []string{"safe-one.txt", "nested/safe-two.txt"}, + }, + { + name: "extracts explicit directory", + directories: []string{"nested/"}, + wantDirs: []string{"nested"}, + }, + { + name: "explicit directory rejects planted link", + directories: []string{"nested/"}, + setup: func(t *testing.T, extractPath, outside string) { + t.Helper() + require.NoError(t, os.Symlink(outside, filepath.Join(extractPath, "nested"))) + }, + wantErr: "symbolic link", + }, + } - zipFile, err := zip.OpenReader("./fixtures/myproject.zip") - require.NoError(t, err) - defer zipFile.Close() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var archive bytes.Buffer + writer := zip.NewWriter(&archive) + for _, name := range tt.directories { + header := &zip.FileHeader{Name: name} + header.SetMode(os.ModeDir | 0o755) + _, err := writer.CreateHeader(header) + require.NoError(t, err) + } + for _, name := range tt.entries { + file, err := writer.Create(name) + require.NoError(t, err) + _, err = file.Write([]byte("content")) + require.NoError(t, err) + } + require.NoError(t, writer.Close()) - err = ExtractZip(&zipFile.Reader, extractPath) - require.NoError(t, err) + reader, err := zip.NewReader(bytes.NewReader(archive.Bytes()), int64(archive.Len())) + require.NoError(t, err) + extractPath := t.TempDir() + outside := t.TempDir() + if tt.setup != nil { + tt.setup(t, extractPath, outside) + } + root, err := safepaths.OpenRoot(extractPath) + require.NoError(t, err) + defer root.Close() + + err = ExtractZip(reader, root) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + assert.Empty(t, mustReadDir(t, outside)) + return + } + require.NoError(t, err) + for _, name := range tt.wantExtract { + content, err := os.ReadFile(filepath.Join(extractPath, filepath.FromSlash(name))) + require.NoError(t, err) + assert.Equal(t, "content", string(content)) + } + for _, name := range tt.wantDirs { + require.DirExists(t, filepath.Join(extractPath, filepath.FromSlash(name))) + } + assert.NoFileExists(t, filepath.Join(extractPath, "..", "outside-before.txt")) + assert.NoFileExists(t, filepath.Join(extractPath, "..", "..", "outside-between.txt")) + }) + } +} - _, err = os.Stat(filepath.Join(extractPath.String(), "src", "main.go")) +func mustReadDir(t *testing.T, path string) []os.DirEntry { + t.Helper() + entries, err := os.ReadDir(path) require.NoError(t, err) + return entries } diff --git a/pkg/cmd/attestation/download/download_test.go b/pkg/cmd/attestation/download/download_test.go index d470c7afbce..f9491f70560 100644 --- a/pkg/cmd/attestation/download/download_test.go +++ b/pkg/cmd/attestation/download/download_test.go @@ -4,6 +4,8 @@ import ( "bytes" "fmt" "net/http" + "os" + "path/filepath" "runtime" "strings" "testing" @@ -325,4 +327,26 @@ func TestRunDownload(t *testing.T) { require.Error(t, err) require.ErrorAs(t, err, &ErrAttestationFileCreation) }) + + t.Run("replaces destination symlink without following it", func(t *testing.T) { + opts := baseOpts + outputDir := t.TempDir() + opts.Store = NewLiveStore(outputDir) + + digested, err := artifact.NewDigestedArtifact(opts.OCIClient, opts.ArtifactPath, opts.DigestAlgorithm) + require.NoError(t, err) + metadataPath := opts.Store.(*LiveStore).createJSONLinesFilePath(digested.DigestWithAlg()) + targetPath := filepath.Join(outputDir, "target.jsonl") + require.NoError(t, os.WriteFile(targetPath, []byte("unchanged"), 0o644)) + require.NoError(t, os.Symlink(filepath.Base(targetPath), metadataPath)) + + require.NoError(t, runDownload(&opts)) + + info, err := os.Lstat(metadataPath) + require.NoError(t, err) + assert.Zero(t, info.Mode()&os.ModeSymlink) + target, err := os.ReadFile(targetPath) + require.NoError(t, err) + assert.Equal(t, "unchanged", string(target)) + }) } diff --git a/pkg/cmd/attestation/download/metadata.go b/pkg/cmd/attestation/download/metadata.go index 4bc353a96fd..aa39a2c838a 100644 --- a/pkg/cmd/attestation/download/metadata.go +++ b/pkg/cmd/attestation/download/metadata.go @@ -4,10 +4,10 @@ import ( "encoding/json" "errors" "fmt" - "os" "runtime" "strings" + "github.com/cli/cli/v2/internal/safepaths" "github.com/cli/cli/v2/pkg/cmd/attestation/api" ) @@ -22,23 +22,37 @@ type LiveStore struct { } func (s *LiveStore) createJSONLinesFilePath(artifact string) string { + path := s.createJSONLinesFileName(artifact) + if s.outputPath != "" { + return fmt.Sprintf("%s/%s", s.outputPath, path) + } + return path +} + +func (s *LiveStore) createJSONLinesFileName(artifact string) string { if runtime.GOOS == "windows" { // Colons are special characters in Windows and cannot be used in file names. // Replace them with dashes to avoid issues. artifact = strings.ReplaceAll(artifact, ":", "-") } - path := fmt.Sprintf("%s.jsonl", artifact) - if s.outputPath != "" { - return fmt.Sprintf("%s/%s", s.outputPath, path) - } - return path + return fmt.Sprintf("%s.jsonl", artifact) } func (s *LiveStore) createMetadataFile(artifactDigest string, attestationsResp []*api.Attestation) (string, error) { metadataFilePath := s.createJSONLinesFilePath(artifactDigest) + outputPath := s.outputPath + if outputPath == "" { + outputPath = "." + } + + root, err := safepaths.OpenRootDir(outputPath, 0o755) + if err != nil { + return "", errors.Join(ErrAttestationFileCreation, fmt.Errorf("failed to open output directory: %v", err)) + } + defer root.Close() - f, err := os.Create(metadataFilePath) + f, err := root.Create(s.createJSONLinesFileName(artifactDigest), 0o666, true) if err != nil { return "", errors.Join(ErrAttestationFileCreation, fmt.Errorf("failed to create file: %v", err)) } diff --git a/pkg/cmd/attestation/download/metadata_mode_unix_test.go b/pkg/cmd/attestation/download/metadata_mode_unix_test.go new file mode 100644 index 00000000000..a43a63dea5e --- /dev/null +++ b/pkg/cmd/attestation/download/metadata_mode_unix_test.go @@ -0,0 +1,32 @@ +//go:build unix + +package download + +import ( + "os" + "syscall" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateMetadataFileMode(t *testing.T) { + oldUmask := syscall.Umask(0) + defer syscall.Umask(oldUmask) + + store := NewLiveStore(t.TempDir()) + path, err := store.createMetadataFile("sha256:value", nil) + require.NoError(t, err) + + info, err := os.Stat(path) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o666), info.Mode().Perm()) + + require.NoError(t, os.Chmod(path, 0o600)) + _, err = store.createMetadataFile("sha256:value", nil) + require.NoError(t, err) + info, err = os.Stat(path) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) +} diff --git a/pkg/cmd/copilot/copilot.go b/pkg/cmd/copilot/copilot.go index a0a0ce5348b..01828b2c610 100644 --- a/pkg/cmd/copilot/copilot.go +++ b/pkg/cmd/copilot/copilot.go @@ -382,25 +382,19 @@ func extractZip(path, destDir string) error { } defer zipReader.Close() - absPath, err := safepaths.ParseAbsolute(destDir) + root, err := safepaths.OpenRoot(destDir) if err != nil { return err } + defer root.Close() - // As of the time of writing, ghzip.ExtractZip will safely skip files that - // would result in path traversal. This is an issue for our use-case because - // we want to error out before extracting if there's any such file. - // To avoid breaking the shared ghzip.ExtractZip code that expects unsafe - // paths to be ignored and no error produced, we pre-validate here, - // producing an error if any such file is found. - for _, f := range zipReader.File { - _, err := absPath.Join(f.Name) - if err != nil { + for _, file := range zipReader.File { + if err := root.Validate(file.Name); err != nil { return err } } - if err := ghzip.ExtractZip(&zipReader.Reader, absPath); err != nil { + if err := ghzip.ExtractZip(&zipReader.Reader, root); err != nil { return err } @@ -417,10 +411,11 @@ func extractTarGz(r io.Reader, destDir string) error { } defer gzr.Close() - absDestDirPath, err := safepaths.ParseAbsolute(destDir) + root, err := safepaths.OpenRoot(destDir) if err != nil { return err } + defer root.Close() tr := tar.NewReader(gzr) for { @@ -431,38 +426,15 @@ func extractTarGz(r io.Reader, destDir string) error { if err != nil { return fmt.Errorf("failed to read tar: %w", err) } - - absFilePath, err := absDestDirPath.Join(header.Name) - if err != nil { + if err := safepaths.ValidateChild(header.Name); err != nil { return err } - target := absFilePath.String() if header.Typeflag == tar.TypeReg { - if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { - return fmt.Errorf("failed to create parent directory: %w", err) + if err := root.CopyFile(header.Name, tr, os.FileMode(header.Mode)&0o777, true); err != nil { + return fmt.Errorf("failed to extract file: %w", err) } - if err := extractFile(target, os.FileMode(header.Mode)&0777, tr); err != nil { - return err - } - } - } - return nil -} - -// extractFile creates a file at target with the given mode and copies content from r. -func extractFile(target string, mode os.FileMode, r io.Reader) (err error) { - out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) - if err != nil { - return fmt.Errorf("failed to create file: %w", err) - } - defer func() { - if cerr := out.Close(); err == nil && cerr != nil { - err = fmt.Errorf("failed to close file: %w", cerr) } - }() - if _, err := io.Copy(out, r); err != nil { - return fmt.Errorf("failed to write file: %w", err) } return nil } diff --git a/pkg/cmd/copilot/copilot_test.go b/pkg/cmd/copilot/copilot_test.go index 18e3efe78d2..8a0745cda04 100644 --- a/pkg/cmd/copilot/copilot_test.go +++ b/pkg/cmd/copilot/copilot_test.go @@ -271,6 +271,76 @@ func TestExtractTarGz(t *testing.T) { err := extractTarGz(bytes.NewReader([]byte("not valid gzip")), destDir) require.Error(t, err, "expected error for invalid gzip, got nil") }) + + t.Run("rejects invalid nonregular entry before extraction", func(t *testing.T) { + var archive bytes.Buffer + gzipWriter := gzip.NewWriter(&archive) + tarWriter := tar.NewWriter(gzipWriter) + require.NoError(t, tarWriter.WriteHeader(&tar.Header{ + Name: "../outside", + Typeflag: tar.TypeDir, + Mode: 0o755, + })) + content := []byte("content") + require.NoError(t, tarWriter.WriteHeader(&tar.Header{ + Name: "copilot", + Mode: 0o755, + Size: int64(len(content)), + })) + _, err := tarWriter.Write(content) + require.NoError(t, err) + require.NoError(t, tarWriter.Close()) + require.NoError(t, gzipWriter.Close()) + + destDir := t.TempDir() + err = extractTarGz(bytes.NewReader(archive.Bytes()), destDir) + require.ErrorContains(t, err, "not a local child path") + assert.NoFileExists(t, filepath.Join(destDir, "copilot")) + }) + + t.Run("rejects ancestor symlink", func(t *testing.T) { + content := []byte("nested content") + archive := createTarGzBuffer(t, map[string][]byte{ + "subdir/file.txt": content, + }) + destDir := t.TempDir() + outside := t.TempDir() + require.NoError(t, os.Symlink(outside, filepath.Join(destDir, "subdir"))) + + err := extractTarGz(bytes.NewReader(archive), destDir) + require.ErrorContains(t, err, "symbolic link") + assert.NoFileExists(t, filepath.Join(outside, "file.txt")) + }) + + t.Run("replaces dangling final symlink", func(t *testing.T) { + archive := createTarGzBuffer(t, map[string][]byte{ + "copilot": []byte("content"), + }) + destDir := t.TempDir() + missing := filepath.Join(destDir, "missing") + require.NoError(t, os.Symlink("missing", filepath.Join(destDir, "copilot"))) + + err := extractTarGz(bytes.NewReader(archive), destDir) + require.NoError(t, err) + assert.NoFileExists(t, missing) + info, err := os.Lstat(filepath.Join(destDir, "copilot")) + require.NoError(t, err) + assert.Zero(t, info.Mode()&os.ModeSymlink) + }) + + t.Run("replaces leftover file on retry", func(t *testing.T) { + archive := createTarGzBuffer(t, map[string][]byte{ + "copilot": []byte("complete"), + }) + destDir := t.TempDir() + target := filepath.Join(destDir, "copilot") + require.NoError(t, os.WriteFile(target, []byte("partial"), 0o644)) + + require.NoError(t, extractTarGz(bytes.NewReader(archive), destDir)) + content, err := os.ReadFile(target) + require.NoError(t, err) + assert.Equal(t, "complete", string(content)) + }) } func TestExtractZip(t *testing.T) { @@ -333,6 +403,46 @@ func TestExtractZip(t *testing.T) { err := extractZip(zipPath, destDir) require.Error(t, err, "expected error for path traversal, got nil") }) + + t.Run("validates all entries before extraction", func(t *testing.T) { + zipDir := t.TempDir() + zipPath := filepath.Join(zipDir, "archive.zip") + + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + valid, err := zw.Create("copilot.exe") + require.NoError(t, err) + _, err = valid.Write([]byte("content")) + require.NoError(t, err) + invalid, err := zw.Create("../outside.txt") + require.NoError(t, err) + _, err = invalid.Write([]byte("content")) + require.NoError(t, err) + require.NoError(t, zw.Close()) + require.NoError(t, os.WriteFile(zipPath, buf.Bytes(), 0o755)) + + destDir := t.TempDir() + err = extractZip(zipPath, destDir) + require.ErrorContains(t, err, "not a local child path") + assert.NoFileExists(t, filepath.Join(destDir, "copilot.exe")) + }) + + t.Run("rejects ancestor symlink", func(t *testing.T) { + zipDir := t.TempDir() + zipPath := filepath.Join(zipDir, "archive.zip") + archive := createZipBuffer(t, map[string][]byte{ + "subdir/file.txt": []byte("content"), + }) + require.NoError(t, os.WriteFile(zipPath, archive, 0o755)) + + destDir := t.TempDir() + outside := t.TempDir() + require.NoError(t, os.Symlink(outside, filepath.Join(destDir, "subdir"))) + + err := extractZip(zipPath, destDir) + require.ErrorContains(t, err, "symbolic link") + assert.NoFileExists(t, filepath.Join(outside, "file.txt")) + }) } func TestFetchExpectedChecksum(t *testing.T) { diff --git a/pkg/cmd/release/download/download.go b/pkg/cmd/release/download/download.go index 1d56fb969b0..776b3c3ac2a 100644 --- a/pkg/cmd/release/download/download.go +++ b/pkg/cmd/release/download/download.go @@ -13,10 +13,12 @@ import ( "runtime" "slices" "strings" + "sync" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safepaths" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/release/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -224,7 +226,7 @@ func downloadRun(opts *DownloadOptions) error { // is made per copy below. Writing to a file keeps the raw bytes. opts.IO.SetContentSanitization(false) - dest := destinationWriter{ + dest := &destinationWriter{ file: opts.OutputFile, dir: opts.Destination, skipExisting: opts.SkipExisting, @@ -233,6 +235,7 @@ func downloadRun(opts *DownloadOptions) error { allowEscapes: opts.AllowEscapeSequences, isTTY: opts.IO.IsStdoutTTY(), } + defer dest.Close() targets := make([]downloadTarget, len(toDownload)) for i, a := range toDownload { @@ -242,7 +245,7 @@ func downloadRun(opts *DownloadOptions) error { } } - return downloadAssets(&dest, httpClient, targets, opts.Concurrency, isArchive, opts.IO) + return downloadAssets(dest, httpClient, targets, opts.Concurrency, isArchive, opts.IO) } func matchAny(patterns []string, name string) bool { @@ -374,9 +377,29 @@ type destinationWriter struct { stdout io.Writer allowEscapes bool isTTY bool + root *safepaths.Root + rootOnce sync.Once + rootErr error } -func (w destinationWriter) makePath(name string) string { +func (w *destinationWriter) ensureRoot() error { + if w.file != "" { + return nil + } + w.rootOnce.Do(func() { + w.root, w.rootErr = safepaths.OpenRootDir(w.dir, 0o755) + }) + return w.rootErr +} + +func (w *destinationWriter) Close() error { + if w.root == nil { + return nil + } + return w.root.Close() +} + +func (w *destinationWriter) makePath(name string) string { if w.file == "" { return filepath.Join(w.dir, name) } @@ -384,7 +407,7 @@ func (w destinationWriter) makePath(name string) string { } // Check returns an error if a file already exists at destination -func (w destinationWriter) Check(name string) error { +func (w *destinationWriter) Check(name string) error { if name == "" { // skip check as file name will only be known after the API request return nil @@ -394,11 +417,22 @@ func (w destinationWriter) Check(name string) error { // writing to stdout should always proceed return nil } + if err := w.validateName(name); err != nil { + return err + } return w.check(fp) } -func (w destinationWriter) check(fp string) error { - if _, err := os.Stat(fp); err == nil { +func (w *destinationWriter) validateName(name string) error { + if w.file != "" { + return nil + } + return safepaths.ValidateChild(name) +} + +func (w *destinationWriter) check(fp string) error { + _, err := os.Lstat(fp) + if err == nil { if w.skipExisting { return errSkipped } @@ -408,12 +442,14 @@ func (w destinationWriter) check(fp string) error { fp, ) } + } else if !os.IsNotExist(err) { + return err } return nil } // Copy writes the data from r into a file specified by name. -func (w destinationWriter) Copy(name string, r io.Reader) (copyErr error) { +func (w *destinationWriter) Copy(name string, r io.Reader) (copyErr error) { fp := w.makePath(name) if fp == "-" { if w.allowEscapes { @@ -428,18 +464,34 @@ func (w destinationWriter) Copy(name string, r io.Reader) (copyErr error) { } return } + if copyErr = w.validateName(name); copyErr != nil { + return + } if copyErr = w.check(fp); copyErr != nil { return } - if dir := filepath.Dir(fp); dir != "." { - if copyErr = os.MkdirAll(dir, 0755); copyErr != nil { + var f *os.File + if w.file == "" { + if copyErr = w.ensureRoot(); copyErr != nil { return } + f, copyErr = w.root.Create(name, 0o644, w.overwrite) + } else { + f, copyErr = safepaths.OpenFile(fp, 0o644, w.overwrite) } - - var f *os.File - if f, copyErr = os.OpenFile(fp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644); copyErr != nil { + if copyErr != nil { + if os.IsExist(copyErr) { + if w.skipExisting { + return errSkipped + } + if !w.overwrite { + return fmt.Errorf( + "%s already exists (use `--clobber` to overwrite file or `--skip-existing` to skip file)", + fp, + ) + } + } return } diff --git a/pkg/cmd/release/download/download_test.go b/pkg/cmd/release/download/download_test.go index 855380c3856..61e2df058de 100644 --- a/pkg/cmd/release/download/download_test.go +++ b/pkg/cmd/release/download/download_test.go @@ -16,6 +16,7 @@ import ( "github.com/cli/cli/v2/pkg/iostreams" "github.com/google/shlex" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func Test_NewCmdDownload(t *testing.T) { @@ -174,15 +175,22 @@ func Test_NewCmdDownload(t *testing.T) { } func Test_downloadRun(t *testing.T) { + var clobberTarget string + var ancestorTarget string + var directoryTarget string + tests := []struct { - name string - isTTY bool - opts DownloadOptions - httpStubs func(*httpmock.Registry) - wantErr string - wantStdout string - wantStderr string - wantFiles []string + name string + isTTY bool + opts DownloadOptions + httpStubs func(*httpmock.Registry) + setup func(t *testing.T) + verify func(t *testing.T) + wantErr string + wantErrContains string + wantStdout string + wantStderr string + wantFiles []string }{ { name: "download all assets", @@ -218,6 +226,24 @@ func Test_downloadRun(t *testing.T) { "windows-64bit.zip", }, }, + { + name: "empty destination uses current directory", + isTTY: true, + opts: DownloadOptions{ + TagName: "v1.2.3", + Destination: "", + Concurrency: 1, + }, + httpStubs: func(reg *httpmock.Registry) { + shared.StubFetchRelease(t, reg, "OWNER", "REPO", "v1.2.3", `{ + "assets": [ + { "name": "asset.bin", "size": 3, "url": "https://api.github.com/assets/1234" } + ] + }`) + reg.Register(httpmock.REST("GET", "assets/1234"), httpmock.StringResponse("new")) + }, + wantFiles: []string{"asset.bin"}, + }, { name: "downloads published release when the draft lookup is unauthorized", isTTY: true, @@ -282,6 +308,149 @@ func Test_downloadRun(t *testing.T) { "tmp/assets/windows-64bit.zip", }, }, + { + name: "HTTP failure does not create destination directory", + isTTY: true, + opts: DownloadOptions{ + TagName: "v1.2.3", + Destination: "missing", + Concurrency: 1, + }, + httpStubs: func(reg *httpmock.Registry) { + shared.StubFetchRelease(t, reg, "OWNER", "REPO", "v1.2.3", `{ + "assets": [ + { "name": "asset.bin", "size": 3, "url": "https://api.github.com/assets/1234" } + ] + }`) + reg.Register(httpmock.REST("GET", "assets/1234"), httpmock.StatusStringResponse(500, "server error")) + }, + verify: func(t *testing.T) { + t.Helper() + assert.NoDirExists(t, "missing") + }, + wantErrContains: "HTTP 500", + }, + { + name: "clobber replaces final symlink", + isTTY: true, + opts: DownloadOptions{ + TagName: "v1.2.3", + Destination: ".", + Concurrency: 1, + OverwriteExisting: true, + }, + httpStubs: func(reg *httpmock.Registry) { + shared.StubFetchRelease(t, reg, "OWNER", "REPO", "v1.2.3", `{ + "assets": [ + { "name": "asset.bin", "size": 3, "url": "https://api.github.com/assets/1234" } + ] + }`) + reg.Register(httpmock.REST("GET", "assets/1234"), httpmock.StringResponse("new")) + }, + setup: func(t *testing.T) { + t.Helper() + clobberTarget = "target.bin" + require.NoError(t, os.WriteFile(clobberTarget, []byte("old"), 0o644)) + require.NoError(t, os.Symlink(clobberTarget, "asset.bin")) + }, + verify: func(t *testing.T) { + t.Helper() + info, err := os.Lstat("asset.bin") + require.NoError(t, err) + assert.Zero(t, info.Mode()&os.ModeSymlink) + content, err := os.ReadFile("asset.bin") + require.NoError(t, err) + assert.Equal(t, "new", string(content)) + content, err = os.ReadFile(clobberTarget) + require.NoError(t, err) + assert.Equal(t, "old", string(content)) + }, + wantFiles: []string{"asset.bin", "target.bin"}, + }, + { + name: "clobber refuses symlink to directory", + isTTY: true, + opts: DownloadOptions{ + TagName: "v1.2.3", + Destination: ".", + Concurrency: 1, + OverwriteExisting: true, + }, + httpStubs: func(reg *httpmock.Registry) { + shared.StubFetchRelease(t, reg, "OWNER", "REPO", "v1.2.3", `{ + "assets": [ + { "name": "asset.bin", "size": 3, "url": "https://api.github.com/assets/1234" } + ] + }`) + reg.Register(httpmock.REST("GET", "assets/1234"), httpmock.StringResponse("new")) + }, + setup: func(t *testing.T) { + t.Helper() + directoryTarget = "directory-target" + require.NoError(t, os.Mkdir(directoryTarget, 0o755)) + require.NoError(t, os.Symlink(directoryTarget, "asset.bin")) + }, + verify: func(t *testing.T) { + t.Helper() + info, err := os.Lstat("asset.bin") + require.NoError(t, err) + assert.NotZero(t, info.Mode()&os.ModeSymlink) + entries, err := os.ReadDir(directoryTarget) + require.NoError(t, err) + assert.Empty(t, entries) + }, + wantErrContains: "is a directory", + wantFiles: []string{"asset.bin"}, + }, + { + name: "rejects ancestor symlink", + isTTY: true, + opts: DownloadOptions{ + TagName: "v1.2.3", + Destination: ".", + Concurrency: 1, + }, + httpStubs: func(reg *httpmock.Registry) { + shared.StubFetchRelease(t, reg, "OWNER", "REPO", "v1.2.3", `{ + "assets": [ + { "name": "nested/asset.bin", "size": 3, "url": "https://api.github.com/assets/1234" } + ] + }`) + reg.Register(httpmock.REST("GET", "assets/1234"), httpmock.StringResponse("new")) + }, + setup: func(t *testing.T) { + t.Helper() + ancestorTarget = t.TempDir() + require.NoError(t, os.Symlink(ancestorTarget, "nested")) + }, + verify: func(t *testing.T) { + t.Helper() + assert.NoFileExists(t, filepath.Join(ancestorTarget, "asset.bin")) + }, + wantErr: `refusing to traverse symbolic link "nested"`, + wantFiles: []string{"nested"}, + }, + { + name: "rejects nonlocal asset name before request", + isTTY: true, + opts: DownloadOptions{ + TagName: "v1.2.3", + Destination: ".", + Concurrency: 1, + }, + httpStubs: func(reg *httpmock.Registry) { + shared.StubFetchRelease(t, reg, "OWNER", "REPO", "v1.2.3", `{ + "assets": [ + { "name": "/asset.bin", "size": 3, "url": "https://api.github.com/assets/1234" } + ] + }`) + }, + verify: func(t *testing.T) { + t.Helper() + assert.NoFileExists(t, "asset.bin") + }, + wantErr: `path "/asset.bin" is not a local child path`, + }, { name: "no match for pattern", isTTY: true, @@ -676,6 +845,9 @@ func Test_downloadRun(t *testing.T) { t.Run(tt.name, func(t *testing.T) { tempDir := t.TempDir() t.Chdir(tempDir) + if tt.setup != nil { + tt.setup(t) + } ios, _, stdout, stderr := iostreams.Test() ios.SetStdoutTTY(tt.isTTY) @@ -698,8 +870,17 @@ func Test_downloadRun(t *testing.T) { } err := downloadRun(&tt.opts) - if tt.wantErr != "" { - assert.EqualError(t, err, tt.wantErr) + if tt.wantErr != "" || tt.wantErrContains != "" { + require.Error(t, err) + if tt.wantErr != "" { + assert.EqualError(t, err, tt.wantErr) + } + if tt.wantErrContains != "" { + assert.ErrorContains(t, err, tt.wantErrContains) + } + if tt.verify != nil { + tt.verify(t) + } return } assert.NoError(t, err) @@ -723,6 +904,9 @@ func Test_downloadRun(t *testing.T) { downloadedFiles, err := listFiles(".") assert.NoError(t, err) assert.Equal(t, tt.wantFiles, downloadedFiles) + if tt.verify != nil { + tt.verify(t) + } }) } } @@ -834,11 +1018,17 @@ func Test_downloadRun_cloberAndSkip(t *testing.T) { _, err = f1.WriteString(oldAssetContents) assert.NoError(t, err) f1.Close() + require.NoError(t, os.Chmod(file, 0o600)) f2, err := os.Create(archive) assert.NoError(t, err) _, err = f2.WriteString(oldZipballContents) assert.NoError(t, err) f2.Close() + require.NoError(t, os.Chmod(archive, 0o600)) + fileBefore, err := os.Stat(file) + require.NoError(t, err) + archiveBefore, err := os.Stat(archive) + require.NoError(t, err) tt.opts.Destination = dest @@ -879,6 +1069,8 @@ func Test_downloadRun_cloberAndSkip(t *testing.T) { assert.NoError(t, err) assert.Equal(t, tt.wantFileSize, fs.Size()) assert.Equal(t, tt.wantArchiveSize, as.Size()) + assert.Equal(t, fileBefore.Mode().Perm(), fs.Mode().Perm()) + assert.Equal(t, archiveBefore.Mode().Perm(), as.Mode().Perm()) }) } } diff --git a/pkg/cmd/repo/read-file/read_file.go b/pkg/cmd/repo/read-file/read_file.go index 5940236cf72..e16d9662e4d 100644 --- a/pkg/cmd/repo/read-file/read_file.go +++ b/pkg/cmd/repo/read-file/read_file.go @@ -11,6 +11,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safepaths" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -250,21 +251,22 @@ func lstat(path string) (*lstatResult, error) { // These indirect the file system operations used when writing output, so tests can // substitute them without touching the real file system. var ( - lstatF = lstat - mkdirAllF = os.MkdirAll - writeFileF = os.WriteFile + lstatF = lstat + writeOutputFile = rootedWriteOutputFile ) // writeToOutput writes file content to a local path and returns the final destination. -// A symlink target is refused, a directory target receives the file under its remote basename, -// and missing parent directories are created. Existing files are only overwritten when clobber is true. +// Symlink output paths are refused. A directory target receives the file under +// its remote basename, and existing regular files are only replaced when +// clobber is true. func writeToOutput(file *repoFile, output string, clobber bool) (string, error) { dest := output + outputDir := "" // A trailing separator signals the user intends dest to be a directory even if it does not exist yet. asDir := strings.HasSuffix(dest, "/") || strings.HasSuffix(dest, string(os.PathSeparator)) - if lr, err := lstatF(dest); err == nil { + if lr, err := lstatF(trimTrailingPathSeparators(dest)); err == nil { if lr.isSymlink { return "", fmt.Errorf("output path is a symlink") } @@ -276,6 +278,7 @@ func writeToOutput(file *repoFile, output string, clobber bool) (string, error) } if asDir { + outputDir = dest dest = filepath.Join(dest, file.Name) } @@ -290,15 +293,40 @@ func writeToOutput(file *repoFile, output string, clobber bool) (string, error) return "", err } - if dir := filepath.Dir(dest); dir != "" && dir != "." { - if err := mkdirAllF(dir, 0755); err != nil { - return "", err - } + name := dest + if outputDir != "" { + name = file.Name } - - if err := writeFileF(dest, file.Content, 0644); err != nil { + if err := writeOutputFile(outputDir, name, file.Content, 0o644, clobber); err != nil { + if os.IsExist(err) && !clobber { + return "", fmt.Errorf("output path already exists: %q (use --clobber to overwrite)", dest) + } return "", err } return dest, nil } + +func trimTrailingPathSeparators(path string) string { + end := len(path) + for end > 0 && os.IsPathSeparator(path[end-1]) { + end-- + } + if end == 0 || end <= len(filepath.VolumeName(path)) { + return path + } + return path[:end] +} + +func rootedWriteOutputFile(dir, name string, data []byte, perm os.FileMode, replace bool) error { + if dir == "" { + return safepaths.WriteFile(name, data, perm, replace) + } + + root, err := safepaths.OpenRootDir(dir, 0o755) + if err != nil { + return err + } + defer root.Close() + return root.WriteFile(name, data, perm, replace) +} diff --git a/pkg/cmd/repo/read-file/read_file_test.go b/pkg/cmd/repo/read-file/read_file_test.go index a2ec4529591..7c5cbcd1036 100644 --- a/pkg/cmd/repo/read-file/read_file_test.go +++ b/pkg/cmd/repo/read-file/read_file_test.go @@ -8,6 +8,7 @@ import ( "io" "io/fs" "net/http" + "os" "path/filepath" "strings" "testing" @@ -565,16 +566,26 @@ func Test_readFileRun(t *testing.T) { } func Test_writeToOutput(t *testing.T) { + var clobberTarget string + var danglingTarget string + var ancestorTarget string + var outputDirTarget string + var regularMode os.FileMode + tests := []struct { - name string - file *repoFile - output string - clobber bool - lstat func(path string) (*lstatResult, error) - wantDest string - wantWrite string - wantDir string - wantErr string + name string + file *repoFile + output string + clobber bool + lstat func(path string) (*lstatResult, error) + setup func(t *testing.T) + useRealFS bool + verify func(t *testing.T) + wantDest string + wantWrite string + wantDir string + wantErr string + wantErrContains string }{ { name: "writes to a new file path", @@ -641,41 +652,230 @@ func Test_writeToOutput(t *testing.T) { }, wantErr: "something went wrong", }, + { + name: "clobber rejects output symlink", + file: &repoFile{Name: "README.md", Content: []byte("hi")}, + output: "README.md", + clobber: true, + lstat: lstat, + useRealFS: true, + setup: func(t *testing.T) { + t.Helper() + clobberTarget = filepath.Join(t.TempDir(), "target.md") + require.NoError(t, os.WriteFile(clobberTarget, []byte("old"), 0o644)) + require.NoError(t, os.Symlink(clobberTarget, "README.md")) + }, + verify: func(t *testing.T) { + t.Helper() + info, err := os.Lstat("README.md") + require.NoError(t, err) + assert.NotZero(t, info.Mode()&os.ModeSymlink) + content, err := os.ReadFile(clobberTarget) + require.NoError(t, err) + assert.Equal(t, "old", string(content)) + }, + wantErr: "output path is a symlink", + }, + { + name: "no clobber rejects dangling final symlink", + file: &repoFile{Name: "README.md", Content: []byte("hi")}, + output: "README.md", + lstat: lstat, + useRealFS: true, + setup: func(t *testing.T) { + t.Helper() + danglingTarget = filepath.Join(t.TempDir(), "missing.md") + require.NoError(t, os.Symlink(danglingTarget, "README.md")) + }, + verify: func(t *testing.T) { + t.Helper() + assert.NoFileExists(t, danglingTarget) + }, + wantErr: "output path is a symlink", + }, + { + name: "trailing separator rejects output symlink", + file: &repoFile{Name: "README.md", Content: []byte("hi")}, + output: "linked/", + lstat: lstat, + useRealFS: true, + setup: func(t *testing.T) { + t.Helper() + outputDirTarget = t.TempDir() + require.NoError(t, os.Symlink(outputDirTarget, "linked")) + }, + verify: func(t *testing.T) { + t.Helper() + assert.NoFileExists(t, filepath.Join(outputDirTarget, "README.md")) + }, + wantErr: "output path is a symlink", + }, + { + name: "trailing separator clobber rejects output symlink", + file: &repoFile{Name: "README.md", Content: []byte("hi")}, + output: "linked/", + clobber: true, + lstat: lstat, + useRealFS: true, + setup: func(t *testing.T) { + t.Helper() + outputDirTarget = t.TempDir() + require.NoError(t, os.Symlink(outputDirTarget, "linked")) + }, + verify: func(t *testing.T) { + t.Helper() + info, err := os.Lstat("linked") + require.NoError(t, err) + assert.NotZero(t, info.Mode()&os.ModeSymlink) + assert.NoFileExists(t, filepath.Join(outputDirTarget, "README.md")) + }, + wantErr: "output path is a symlink", + }, + { + name: "directory output clobber rejects derived symlink", + file: &repoFile{Name: "README.md", Content: []byte("hi")}, + output: "out/", + clobber: true, + lstat: lstat, + useRealFS: true, + setup: func(t *testing.T) { + t.Helper() + require.NoError(t, os.Mkdir("out", 0o755)) + clobberTarget = filepath.Join(t.TempDir(), "target.md") + require.NoError(t, os.WriteFile(clobberTarget, []byte("old"), 0o644)) + require.NoError(t, os.Symlink(clobberTarget, filepath.Join("out", "README.md"))) + }, + verify: func(t *testing.T) { + t.Helper() + info, err := os.Lstat(filepath.Join("out", "README.md")) + require.NoError(t, err) + assert.NotZero(t, info.Mode()&os.ModeSymlink) + content, err := os.ReadFile(clobberTarget) + require.NoError(t, err) + assert.Equal(t, "old", string(content)) + }, + wantErr: "output path is a symlink", + }, + { + name: "clobber preserves regular file mode", + file: &repoFile{Name: "README.md", Content: []byte("hi")}, + output: "README.md", + clobber: true, + lstat: lstat, + useRealFS: true, + setup: func(t *testing.T) { + t.Helper() + require.NoError(t, os.WriteFile("README.md", []byte("old"), 0o600)) + info, err := os.Stat("README.md") + require.NoError(t, err) + regularMode = info.Mode().Perm() + }, + verify: func(t *testing.T) { + t.Helper() + info, err := os.Stat("README.md") + require.NoError(t, err) + assert.Equal(t, regularMode, info.Mode().Perm()) + content, err := os.ReadFile("README.md") + require.NoError(t, err) + assert.Equal(t, "hi", string(content)) + }, + wantDest: "README.md", + }, + { + name: "allows ancestor symlink in explicit output parent", + file: &repoFile{Name: "README.md", Content: []byte("hi")}, + output: filepath.Join("out", "nested", "README.md"), + lstat: lstat, + useRealFS: true, + setup: func(t *testing.T) { + t.Helper() + ancestorTarget = t.TempDir() + require.NoError(t, os.Symlink(ancestorTarget, "out")) + }, + verify: func(t *testing.T) { + t.Helper() + content, err := os.ReadFile(filepath.Join(ancestorTarget, "nested", "README.md")) + require.NoError(t, err) + assert.Equal(t, "hi", string(content)) + }, + wantDest: filepath.Join("out", "nested", "README.md"), + }, + { + name: "directory target rejects remote name escape", + file: &repoFile{Name: "../README.md", Content: []byte("hi")}, + output: "out/", + lstat: lstat, + useRealFS: true, + setup: func(t *testing.T) { + t.Helper() + require.NoError(t, os.Mkdir("out", 0o755)) + }, + verify: func(t *testing.T) { + t.Helper() + assert.NoFileExists(t, "README.md") + }, + wantErrContains: "not a local child path", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Chdir(t.TempDir()) + if tt.setup != nil { + tt.setup(t) + } + var gotDir string var gotWritePath string var gotWriteContent []byte - origLstat, origMkdir, origWrite := lstatF, mkdirAllF, writeFileF + origLstat, origWrite := lstatF, writeOutputFile defer func() { - lstatF, mkdirAllF, writeFileF = origLstat, origMkdir, origWrite + lstatF, writeOutputFile = origLstat, origWrite }() lstatF = tt.lstat - mkdirAllF = func(path string, _ fs.FileMode) error { - gotDir = path - return nil - } - writeFileF = func(path string, data []byte, _ fs.FileMode) error { - gotWritePath = path - gotWriteContent = data - return nil + if !tt.useRealFS { + writeOutputFile = func(dir, name string, data []byte, _ fs.FileMode, _ bool) error { + gotDir = dir + if dir == "" { + gotDir = filepath.Dir(name) + gotWritePath = name + } else { + gotDir = filepath.Clean(dir) + gotWritePath = filepath.Join(dir, name) + } + gotWriteContent = data + return nil + } } dest, err := writeToOutput(tt.file, tt.output, tt.clobber) + if tt.wantErrContains != "" { + require.ErrorContains(t, err, tt.wantErrContains) + if tt.verify != nil { + tt.verify(t) + } + return + } if tt.wantErr != "" { require.EqualError(t, err, tt.wantErr) + if tt.verify != nil { + tt.verify(t) + } return } require.NoError(t, err) assert.Equal(t, tt.wantDest, dest) - assert.Equal(t, tt.wantDest, gotWritePath) - assert.Equal(t, tt.wantWrite, string(gotWriteContent)) - if tt.wantDir != "" { - assert.Equal(t, tt.wantDir, gotDir) + if !tt.useRealFS { + assert.Equal(t, tt.wantDest, gotWritePath) + assert.Equal(t, tt.wantWrite, string(gotWriteContent)) + if tt.wantDir != "" { + assert.Equal(t, tt.wantDir, gotDir) + } + } + if tt.verify != nil { + tt.verify(t) } }) } diff --git a/pkg/cmd/run/download/download.go b/pkg/cmd/run/download/download.go index c9ddacb37cd..ca7733bda01 100644 --- a/pkg/cmd/run/download/download.go +++ b/pkg/cmd/run/download/download.go @@ -30,7 +30,7 @@ type DownloadOptions struct { type platform interface { List(runID string) ([]shared.Artifact, error) - Download(url safeurl.SafeURL, dir safepaths.Absolute) error + Download(url safeurl.SafeURL, openDestination func() (*safepaths.Root, error)) error } type iprompter interface { @@ -152,23 +152,11 @@ func runDownload(opts *DownloadOptions) error { } } - opts.IO.StartProgressIndicator() - defer opts.IO.StopProgressIndicator() - - // track downloaded artifacts and avoid re-downloading any of the same name, isolate if multiple artifacts - downloaded := set.NewStringSet() - isolateArtifacts := isolateArtifacts(wantNames, wantPatterns) - - absoluteDestinationDir, err := safepaths.ParseAbsolute(opts.DestinationDir) - if err != nil { - return fmt.Errorf("error parsing destination directory: %w", err) - } - + // Select matching artifacts before creating the destination directory. + selectedNames := set.NewStringSet() + var selected []shared.Artifact for _, a := range artifacts { - if a.Expired { - continue - } - if downloaded.Contains(a.Name) { + if a.Expired || selectedNames.Contains(a.Name) { continue } if len(wantNames) > 0 || len(wantPatterns) > 0 { @@ -176,28 +164,49 @@ func runDownload(opts *DownloadOptions) error { continue } } + selected = append(selected, a) + selectedNames.Add(a.Name) + } + if len(selected) == 0 { + return errors.New("no artifact matches any of the names or patterns provided") + } + + opts.IO.StartProgressIndicator() + defer opts.IO.StopProgressIndicator() + + isolateArtifacts := isolateArtifacts(wantNames, wantPatterns) - destDir := absoluteDestinationDir + for _, a := range selected { if isolateArtifacts { - destDir, err = absoluteDestinationDir.Join(a.Name) + if err := safepaths.ValidateChild(a.Name); err != nil { + return fmt.Errorf("error downloading %s: would result in path traversal", a.Name) + } + } + + openDestination := func() (*safepaths.Root, error) { + root, err := safepaths.OpenRootDir(opts.DestinationDir, 0o755) if err != nil { - var pathTraversalError safepaths.PathTraversalError - if errors.As(err, &pathTraversalError) { - return fmt.Errorf("error downloading %s: would result in path traversal", a.Name) - } - return err + return nil, fmt.Errorf("error opening destination directory: %w", err) } + if !isolateArtifacts { + return root, nil + } + destDir, err := root.Sub(a.Name, 0o755) + closeErr := root.Close() + if err != nil { + return nil, err + } + if closeErr != nil { + _ = destDir.Close() + return nil, closeErr + } + return destDir, nil } - err := opts.Platform.Download(safeurl.NewImmutableSafeURL(a.DownloadURL), destDir) + err := opts.Platform.Download(safeurl.NewImmutableSafeURL(a.DownloadURL), openDestination) if err != nil { return fmt.Errorf("error downloading %s: %w", a.Name, err) } - downloaded.Add(a.Name) - } - - if downloaded.Len() == 0 { - return errors.New("no artifact matches any of the names or patterns provided") } return nil diff --git a/pkg/cmd/run/download/download_test.go b/pkg/cmd/run/download/download_test.go index a001cd78111..b1b602a1ffe 100644 --- a/pkg/cmd/run/download/download_test.go +++ b/pkg/cmd/run/download/download_test.go @@ -158,7 +158,8 @@ type testArtifact struct { } type fakePlatform struct { - runs []run + runs []run + downloadErr error } func (f *fakePlatform) List(runID string) ([]shared.Artifact, error) { @@ -187,10 +188,16 @@ func (f *fakePlatform) List(runID string) ([]shared.Artifact, error) { return artifacts, nil } -func (f *fakePlatform) Download(url safeurl.SafeURL, dir safepaths.Absolute) error { - if err := os.MkdirAll(dir.String(), 0755); err != nil { +func (f *fakePlatform) Download(url safeurl.SafeURL, openDestination func() (*safepaths.Root, error)) error { + if f.downloadErr != nil { + return f.downloadErr + } + dir, err := openDestination() + if err != nil { return err } + defer dir.Close() + // Now to be consistent, we find the artifact with the provided URL. // It's a bit janky to iterate the runs, to find the right artifact // rather than keying directly to it, but it allows the setup of the @@ -200,8 +207,7 @@ func (f *fakePlatform) Download(url safeurl.SafeURL, dir safepaths.Absolute) err for _, testArtifact := range run.testArtifacts { if testArtifact.artifact.DownloadURL == url.String() { for _, file := range testArtifact.files { - path := filepath.Join(dir.String(), file) - return os.WriteFile(path, []byte{}, 0600) + return dir.WriteFile(file, []byte{}, 0o600, false) } } } @@ -212,12 +218,14 @@ func (f *fakePlatform) Download(url safeurl.SafeURL, dir safepaths.Absolute) err func Test_runDownload(t *testing.T) { tests := []struct { - name string - opts DownloadOptions - platform *fakePlatform - promptStubs func(*prompter.MockPrompter) - expectedFiles []string - wantErr string + name string + opts DownloadOptions + platform *fakePlatform + promptStubs func(*prompter.MockPrompter) + expectedFiles []string + wantErr string + wantDestinationMissing bool + useEmptyDestination bool }{ { name: "download non-expired to relative directory", @@ -269,6 +277,31 @@ func Test_runDownload(t *testing.T) { filepath.Join("artifact-2", "artifact-2-file"), }, }, + { + name: "empty destination uses current directory", + opts: DownloadOptions{ + RunID: "2345", + Names: []string{"artifact-1"}, + }, + platform: &fakePlatform{ + runs: []run{ + { + id: "2345", + testArtifacts: []testArtifact{ + { + artifact: shared.Artifact{ + Name: "artifact-1", + DownloadURL: "http://download.com/artifact1.zip", + }, + files: []string{"artifact-file"}, + }, + }, + }, + }, + }, + expectedFiles: []string{"artifact-file"}, + useEmptyDestination: true, + }, { name: "download non-expired to absolute directory", opts: DownloadOptions{ @@ -359,8 +392,9 @@ func Test_runDownload(t *testing.T) { { name: "no name matches", opts: DownloadOptions{ - RunID: "2345", - Names: []string{"artifact-3"}, + RunID: "2345", + Names: []string{"artifact-3"}, + DestinationDir: "missing", }, platform: &fakePlatform{ runs: []run{ @@ -391,8 +425,34 @@ func Test_runDownload(t *testing.T) { }, }, }, - expectedFiles: []string{}, - wantErr: "no artifact matches any of the names or patterns provided", + expectedFiles: []string{}, + wantErr: "no artifact matches any of the names or patterns provided", + wantDestinationMissing: true, + }, + { + name: "download error does not create destination", + opts: DownloadOptions{ + RunID: "2345", + DestinationDir: "missing", + }, + platform: &fakePlatform{ + downloadErr: errors.New("transfer failed"), + runs: []run{ + { + id: "2345", + testArtifacts: []testArtifact{ + { + artifact: shared.Artifact{ + Name: "artifact-1", + DownloadURL: "http://download.com/artifact1.zip", + }, + }, + }, + }, + }, + }, + wantErr: "error downloading artifact-1: transfer failed", + wantDestinationMissing: true, }, { name: "pattern matches", @@ -716,10 +776,16 @@ func Test_runDownload(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { opts := &tt.opts - if opts.DestinationDir == "" { + assertionDir := "" + if tt.useEmptyDestination { + t.Chdir(t.TempDir()) + assertionDir = "." + } else if opts.DestinationDir == "" { opts.DestinationDir = t.TempDir() + assertionDir = opts.DestinationDir } else { opts.DestinationDir = filepath.Join(t.TempDir(), opts.DestinationDir) + assertionDir = opts.DestinationDir } ios, _, stdout, stderr := iostreams.Test() @@ -738,13 +804,17 @@ func Test_runDownload(t *testing.T) { } else { require.NoError(t, err) } + if tt.wantDestinationMissing { + assert.NoDirExists(t, opts.DestinationDir) + return + } // Check that the exact number of files exist - require.Equal(t, len(tt.expectedFiles), countFilesInDirRecursively(t, opts.DestinationDir)) + require.Equal(t, len(tt.expectedFiles), countFilesInDirRecursively(t, assertionDir)) // Then check that the exact files are correct for _, name := range tt.expectedFiles { - require.FileExists(t, filepath.Join(opts.DestinationDir, name)) + require.FileExists(t, filepath.Join(assertionDir, name)) } assert.Equal(t, "", stdout.String()) diff --git a/pkg/cmd/run/download/fixtures/myproject.zip b/pkg/cmd/run/download/fixtures/myproject.zip deleted file mode 100644 index 2fdf3f90c6e00c9f4b59a47fb84b6fd3486be007..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1710 zcmWIWW@h1H0D(o3?Ll9~jbb@~Y!K#TkYOlFO-#v6)yqu@4dG;9UQnK!0K%me+zgB? zUl|z~SVVvdl@&Zvb8_;z0^mkv1C0VozyS-;sHDt1eXuE3Kogc>nu4lZcJ6Pmvp`M* zIRvCTw=%UN6>bE`7<7jy>Vwo3XCOJH7K>Agi;^Kui3OSwhiMMNDInbqTFZoZfzAM7 zZlF^@x=TwkbM(^l!HxkL0>WsHDM(DtPE1c#04YUsPchI$zH_!Z0zlJ17-nK_VrCx9 zL@%IeAdF^WT4`Rg0!W>Prb4wsE!a>{_;WDaUDg`7NkYZAl!<{shlPQG3+PNeJ$)Zf zXIH;qSFkH5Jv|){!f0m326^Y-HsIO!I$XeVYr{p`yxYE8r$)?(pD;De(rTMfcEOqh zlf=K=?=QJjCM}>KaPqyY)pRMN+p{i8A4@8)ocm@a|Az~9hpwNG-*@m>TZvtR%x(2! zTE=tNe!te6VEZM_s6E`m_pF4E_`yOWwmX8i_RaKDYkIIiGX7zbT0cWMN3ZPLiYB(G zD~T48RZBj;lUR2)rC>+%vFhu$*nTc3ep_RfoWb-`_HBZVP0PIltM(~}^>=%u&uY9W zxZ?Y@rq#TAXEXa8Yd^W|h+w1a?ZkH{4o6pSNUsi8_#t%9SY@uQn!jNS9PPKzm@`gqd!!C3=?lnpPcPyTwCH&gMocvQxY^(TU#-rD_n zzvr6tTFi415Y2X`abRb?8JuVo%%ebg?rD&7phJA&d8rXn^7+>>dU|W&iEcb)+HWeOX8ms;oW(p@9;GjJiFIB% z&TZ?|G5gT7W6=@*h-X2{EA<*#-iHKTahb*~y8OqJ^G1`aLa8Y5g?h+QsSfcPhq<40lr@iSe-Ss)zT87(nC{kqc#@W_4mNt1UIsQ>RJ5o$US&w_|2gyXQ zrS}(FM|N}Um}IJ(vi6is(iT6LDcqcoxC)aOh90&TKPXt0pZ(E~qw|4e(N&gZ9r=g- z-|z=`Gcw6B<0^F|fJvHx0a&;&ENKL>kjfxdNEw8dhLMfKEO{Wtfil}$M{l5Uka7rU z5|#o4-6Ygf2-#tsP?Ml#5@F*o%OPZkT>}~iDV2aGVJS?I9fnyZAv;V27;q>h6k!7~ zizH+N)1U^z3n-w0SPCbI3vm@k$Oi5P8i-st0nNiwFa>zCvH=5yfgK1{fW=w~uvlYY F002;O_<#Ta diff --git a/pkg/cmd/run/download/http.go b/pkg/cmd/run/download/http.go index 645c0a9a7a7..cea8f9c555d 100644 --- a/pkg/cmd/run/download/http.go +++ b/pkg/cmd/run/download/http.go @@ -24,11 +24,11 @@ func (p *apiPlatform) List(runID string) ([]shared.Artifact, error) { return shared.ListArtifacts(p.client, p.repo, runID) } -func (p *apiPlatform) Download(url safeurl.SafeURL, dir safepaths.Absolute) error { - return downloadArtifact(p.client, url, dir) +func (p *apiPlatform) Download(url safeurl.SafeURL, openDestination func() (*safepaths.Root, error)) error { + return downloadArtifact(p.client, url, openDestination) } -func downloadArtifact(httpClient *http.Client, url safeurl.SafeURL, destDir safepaths.Absolute) error { +func downloadArtifact(httpClient *http.Client, url safeurl.SafeURL, openDestination func() (*safepaths.Root, error)) (downloadErr error) { // TODO(api-client-rollout) // This has been deferred from moving to api.Client due to streaming the artifact ZIP response body to disk instead of decoding JSON. req, err := http.NewRequest("GET", url.String(), nil) @@ -66,6 +66,15 @@ func downloadArtifact(httpClient *http.Client, url safeurl.SafeURL, destDir safe if err != nil { return fmt.Errorf("error extracting zip archive: %w", err) } + destDir, err := openDestination() + if err != nil { + return err + } + defer func() { + if err := destDir.Close(); downloadErr == nil && err != nil { + downloadErr = err + } + }() if err := ghzip.ExtractZip(zipfile, destDir); err != nil { return fmt.Errorf("error extracting zip archive: %w", err) } diff --git a/pkg/cmd/run/download/http_test.go b/pkg/cmd/run/download/http_test.go index 5b68dff82d4..50e3d16a789 100644 --- a/pkg/cmd/run/download/http_test.go +++ b/pkg/cmd/run/download/http_test.go @@ -1,6 +1,8 @@ package download import ( + "archive/zip" + "bytes" "net/http" "os" "path/filepath" @@ -59,50 +61,127 @@ func Test_List_perRepository(t *testing.T) { } func Test_Download(t *testing.T) { - tmpDir := t.TempDir() - destDir, err := safepaths.ParseAbsolute(filepath.Join(tmpDir, "artifact")) - require.NoError(t, err) + tests := []struct { + name string + setup func(t *testing.T, destDir, outside string) + wantErr string + verify func(t *testing.T, tmpDir, outside string) + }{ + { + name: "extracts artifact", + verify: func(t *testing.T, tmpDir, _ string) { + t.Helper() + var paths []string + parentPrefix := tmpDir + string(filepath.Separator) + err := filepath.Walk(tmpDir, func(p string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if p == tmpDir { + return nil + } + entry := strings.TrimPrefix(p, parentPrefix) + if info.IsDir() { + entry += "/" + } else if info.Mode()&0o111 != 0 { + entry += "(X)" + } + paths = append(paths, entry) + return nil + }) + require.NoError(t, err) - reg := &httpmock.Registry{} - defer reg.Verify(t) + sort.Strings(paths) + assert.Equal(t, []string{ + "artifact/", + filepath.Join("artifact", "bin") + "/", + filepath.Join("artifact", "bin", "myexe"), + filepath.Join("artifact", "empty") + "/", + filepath.Join("artifact", "readme.md"), + filepath.Join("artifact", "src") + "/", + filepath.Join("artifact", "src", "main.go"), + filepath.Join("artifact", "src", "util.go"), + }, paths) + }, + }, + { + name: "rejects artifact ancestor symlink", + setup: func(t *testing.T, destDir, outside string) { + t.Helper() + require.NoError(t, os.Symlink(outside, filepath.Join(destDir, "src"))) + }, + wantErr: "symbolic link", + verify: func(t *testing.T, _, outside string) { + t.Helper() + assert.NoFileExists(t, filepath.Join(outside, "main.go")) + assert.NoFileExists(t, filepath.Join(outside, "util.go")) + }, + }, + } - reg.Register( - httpmock.REST("GET", "repos/OWNER/REPO/actions/artifacts/12345/zip"), - httpmock.FileResponse("./fixtures/myproject.zip")) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + destDir := filepath.Join(tmpDir, "artifact") + require.NoError(t, os.MkdirAll(destDir, 0o755)) + outside := t.TempDir() + if tt.setup != nil { + tt.setup(t, destDir, outside) + } - api := &apiPlatform{ - client: &http.Client{Transport: reg}, + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/artifacts/12345/zip"), + httpmock.BinaryResponse(testArtifactZip(t))) + + api := &apiPlatform{ + client: &http.Client{Transport: reg}, + } + err := api.Download( + safeurl.NewImmutableSafeURL("https://api.github.com/repos/OWNER/REPO/actions/artifacts/12345/zip"), + func() (*safepaths.Root, error) { + return safepaths.OpenRoot(destDir) + }, + ) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + } else { + require.NoError(t, err) + } + if tt.verify != nil { + tt.verify(t, tmpDir, outside) + } + }) } - require.NoError(t, api.Download(safeurl.NewImmutableSafeURL("https://api.github.com/repos/OWNER/REPO/actions/artifacts/12345/zip"), destDir)) +} - var paths []string - parentPrefix := tmpDir + string(filepath.Separator) - err = filepath.Walk(tmpDir, func(p string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if p == tmpDir { - return nil - } - entry := strings.TrimPrefix(p, parentPrefix) - if info.IsDir() { - entry += "/" - } else if info.Mode()&0111 != 0 { - entry += "(X)" - } - paths = append(paths, entry) - return nil - }) - require.NoError(t, err) +func testArtifactZip(t *testing.T) []byte { + t.Helper() - sort.Strings(paths) - assert.Equal(t, []string{ - "artifact/", - filepath.Join("artifact", "bin") + "/", - filepath.Join("artifact", "bin", "myexe"), - filepath.Join("artifact", "readme.md"), - filepath.Join("artifact", "src") + "/", - filepath.Join("artifact", "src", "main.go"), - filepath.Join("artifact", "src", "util.go"), - }, paths) + var archive bytes.Buffer + writer := zip.NewWriter(&archive) + files := []struct { + name string + mode os.FileMode + }{ + {name: "bin/myexe", mode: 0o644}, + {name: "empty/", mode: os.ModeDir | 0o755}, + {name: "../outside.txt", mode: 0o644}, + {name: "readme.md", mode: 0o644}, + {name: "src/main.go", mode: 0o644}, + {name: "src/util.go", mode: 0o644}, + } + for _, file := range files { + header := &zip.FileHeader{Name: file.name, Method: zip.Store} + header.SetMode(file.mode) + entry, err := writer.CreateHeader(header) + require.NoError(t, err) + if !file.mode.IsDir() { + _, err = entry.Write([]byte("content")) + require.NoError(t, err) + } + } + require.NoError(t, writer.Close()) + return archive.Bytes() } diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go index 64be5da5a79..698493abff4 100644 --- a/pkg/cmd/skills/install/install.go +++ b/pkg/cmd/skills/install/install.go @@ -399,16 +399,18 @@ func installRun(opts *InstallOptions) error { } result, err := installer.Install(&installer.Options{ - Host: hostname, - Owner: opts.repo.RepoOwner(), - Repo: opts.repo.RepoName(), - Ref: resolved.Ref, - SHA: resolved.SHA, - PinnedRef: opts.Pin, - Skills: plan.skills, - Dir: plan.dir, - Client: apiClient, - OnProgress: installProgress(opts.IO, len(plan.skills)), + Host: hostname, + Owner: opts.repo.RepoOwner(), + Repo: opts.repo.RepoName(), + Ref: resolved.Ref, + SHA: resolved.SHA, + PinnedRef: opts.Pin, + Skills: plan.skills, + Dir: plan.dir, + RootDir: plan.root, + Client: apiClient, + ReplaceExisting: plan.replaceExisting, + OnProgress: installProgress(opts.IO, len(plan.skills)), }) if result != nil { @@ -556,9 +558,11 @@ func runLocalInstall(opts *InstallOptions) error { } result, err := installer.InstallLocal(&installer.LocalOptions{ - SourceDir: absSource, - Skills: plan.skills, - Dir: plan.dir, + SourceDir: absSource, + Skills: plan.skills, + Dir: plan.dir, + RootDir: plan.root, + ReplaceExisting: plan.replaceExisting, }) if err != nil { return err @@ -684,9 +688,11 @@ type skillSelector struct { } type installPlan struct { - dir string - hosts []*registry.AgentHost - skills []discovery.Skill + dir string + root string + hosts []*registry.AgentHost + skills []discovery.Skill + replaceExisting map[string]bool } // errSkillsListed is a sentinel returned by selectSkillsWithSelector when @@ -996,7 +1002,11 @@ func buildInstallPlans(opts *InstallOptions, selectedSkills []discovery.Skill, s plan, ok := byDir[targetDir] if !ok { - plan = &installPlan{dir: targetDir} + rootDir := targetDir + if opts.Dir == "" && scope == registry.ScopeProject { + rootDir = gitRoot + } + plan = &installPlan{dir: targetDir, root: rootDir} byDir[targetDir] = plan orderedDirs = append(orderedDirs, targetDir) } @@ -1006,7 +1016,7 @@ func buildInstallPlans(opts *InstallOptions, selectedSkills []discovery.Skill, s plans := make([]installPlan, 0, len(orderedDirs)) for _, dir := range orderedDirs { plan := byDir[dir] - installSkills, err := checkOverwrite(opts, selectedSkills, plan.dir, canPrompt) + installSkills, replaceExisting, err := checkOverwrite(opts, selectedSkills, plan.dir, canPrompt) if err != nil { return nil, err } @@ -1015,6 +1025,7 @@ func buildInstallPlans(opts *InstallOptions, selectedSkills []discovery.Skill, s continue } plan.skills = installSkills + plan.replaceExisting = replaceExisting plans = append(plans, *plan) } @@ -1036,7 +1047,7 @@ func formatPlanHosts(hosts []*registry.AgentHost) string { return strings.Join(names, ", ") } -func checkOverwrite(opts *InstallOptions, skills []discovery.Skill, targetDir string, canPrompt bool) ([]discovery.Skill, error) { +func checkOverwrite(opts *InstallOptions, skills []discovery.Skill, targetDir string, canPrompt bool) ([]discovery.Skill, map[string]bool, error) { var existing, fresh []discovery.Skill for _, s := range skills { dir := filepath.Join(targetDir, s.Name) @@ -1048,11 +1059,15 @@ func checkOverwrite(opts *InstallOptions, skills []discovery.Skill, targetDir st } if len(existing) == 0 { - return skills, nil + return skills, nil, nil } if opts.Force { - return skills, nil + replaceExisting := make(map[string]bool, len(existing)) + for _, skill := range existing { + replaceExisting[skill.Name] = true + } + return skills, replaceExisting, nil } if !canPrompt { @@ -1060,24 +1075,26 @@ func checkOverwrite(opts *InstallOptions, skills []discovery.Skill, targetDir st for i, s := range existing { names[i] = s.DisplayName() } - return nil, fmt.Errorf("skills already installed: %s (use --force to overwrite)", strings.Join(names, ", ")) + return nil, nil, fmt.Errorf("skills already installed: %s (use --force to overwrite)", strings.Join(names, ", ")) } var confirmed []discovery.Skill + replaceExisting := make(map[string]bool) for _, s := range existing { prompt := existingSkillPrompt(targetDir, s) ok, err := opts.Prompter.Confirm(prompt, false) if err != nil { - return nil, err + return nil, nil, err } if ok { confirmed = append(confirmed, s) + replaceExisting[s.Name] = true } else { fmt.Fprintf(opts.IO.ErrOut, "Skipping %s\n", s.DisplayName()) } } - return append(fresh, confirmed...), nil + return append(fresh, confirmed...), replaceExisting, nil } func existingSkillPrompt(targetDir string, incoming discovery.Skill) string { diff --git a/pkg/cmd/skills/install/install_test.go b/pkg/cmd/skills/install/install_test.go index 850f925f832..63e73aee03e 100644 --- a/pkg/cmd/skills/install/install_test.go +++ b/pkg/cmd/skills/install/install_test.go @@ -302,6 +302,11 @@ func hiddenDirSkillTreeJSON(name, treeSHA, blobSHA string) string { } func TestInstallRun(t *testing.T) { + var projectRoot string + var outsideRoot string + var clobberProjectRoot string + var clobberTarget string + tests := []struct { name string isTTY bool @@ -526,6 +531,83 @@ func TestInstallRun(t *testing.T) { }, wantStdout: "Installed git-commit", }, + { + name: "project install rejects ancestor symlink", + isTTY: false, + setup: func(t *testing.T) { + t.Helper() + projectRoot = t.TempDir() + outsideRoot = t.TempDir() + require.NoError(t, os.Symlink(outsideRoot, filepath.Join(projectRoot, ".agents"))) + }, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: projectRoot}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + } + }, + verify: func(t *testing.T) { + t.Helper() + assert.NoFileExists(t, filepath.Join(outsideRoot, "skills", "git-commit", "SKILL.md")) + }, + wantErr: "symbolic link", + }, + { + name: "project force install replaces final symlink", + isTTY: false, + setup: func(t *testing.T) { + t.Helper() + clobberProjectRoot = t.TempDir() + skillDir := filepath.Join(clobberProjectRoot, ".agents", "skills", "git-commit") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + clobberTarget = filepath.Join(skillDir, "target.md") + require.NoError(t, os.WriteFile(clobberTarget, []byte("old"), 0o644)) + require.NoError(t, os.Symlink(filepath.Base(clobberTarget), filepath.Join(skillDir, "SKILL.md"))) + }, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: clobberProjectRoot}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Force: true, + } + }, + verify: func(t *testing.T) { + t.Helper() + skillFile := filepath.Join(clobberProjectRoot, ".agents", "skills", "git-commit", "SKILL.md") + info, err := os.Lstat(skillFile) + require.NoError(t, err) + assert.Zero(t, info.Mode()&os.ModeSymlink) + content, err := os.ReadFile(clobberTarget) + require.NoError(t, err) + assert.Equal(t, "old", string(content)) + }, + wantStdout: "Installed git-commit", + }, { name: "remote install existing skill without force non-interactive errors", isTTY: false, @@ -1621,6 +1703,12 @@ func TestInstallRun(t *testing.T) { if tt.wantErr != "" { require.Error(t, err) assert.Contains(t, err.Error(), tt.wantErr) + if tt.verify != nil { + tt.verify(t) + } + if tt.assert != nil { + tt.assert(t) + } return } diff --git a/pkg/cmd/skills/update/update.go b/pkg/cmd/skills/update/update.go index a6ba369192e..4f5c8761ffe 100644 --- a/pkg/cmd/skills/update/update.go +++ b/pkg/cmd/skills/update/update.go @@ -1,7 +1,9 @@ package update import ( + "errors" "fmt" + "io" "net/http" "os" "path/filepath" @@ -13,6 +15,7 @@ import ( "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/internal/safepaths" "github.com/cli/cli/v2/internal/skills/discovery" "github.com/cli/cli/v2/internal/skills/frontmatter" "github.com/cli/cli/v2/internal/skills/installer" @@ -386,9 +389,14 @@ func updateRun(opts *UpdateOptions) error { var failed bool for _, u := range updates { if err := updateSkillInPlace(opts, u, apiClient, gitRoot, homeDir); err != nil { - fmt.Fprintf(opts.IO.ErrOut, "%s Failed to update %s: %v\n", cs.FailureIcon(), u.local.name, err) - failed = true - continue + var committedWarning *committedUpdateWarning + if errors.As(err, &committedWarning) { + fmt.Fprintf(opts.IO.ErrOut, "%s Updated %s with warning: %v\n", cs.WarningIcon(), u.local.name, committedWarning) + } else { + fmt.Fprintf(opts.IO.ErrOut, "%s Failed to update %s: %v\n", cs.FailureIcon(), u.local.name, err) + failed = true + continue + } } if opts.IO.IsStdoutTTY() { fmt.Fprintf(opts.IO.Out, "%s Updated %s\n", cs.SuccessIcon(), u.local.name) @@ -419,6 +427,12 @@ func updateSkillInPlace(opts *UpdateOptions, u pendingUpdate, apiClient *api.Cli if u.local.dir == "" { return fmt.Errorf("cannot update %s: no install location recorded", u.local.name) } + // Project paths are derived beneath the Git root and stay pinned through + // publication. User-scope and explicit --dir updates retain the existing + // same-user application-directory implementation below. + if u.local.scope == registry.ScopeProject { + return updateProjectSkillInPlace(u, apiClient, gitRoot, homeDir) + } parent := filepath.Dir(u.local.dir) if err := os.MkdirAll(parent, 0o755); err != nil { @@ -461,6 +475,319 @@ func updateSkillInPlace(opts *UpdateOptions, u pendingUpdate, apiClient *api.Cli return swapDirectoryContents(u.local.dir, stagedSkillDir) } +func updateProjectSkillInPlace(u pendingUpdate, apiClient *api.Client, gitRoot, homeDir string) (retErr error) { + projectRoot, skillRoot, err := openProjectSkillRoot(gitRoot, u.local.dir) + if err != nil { + return fmt.Errorf("could not safely access project skill directory %s: %w", u.local.dir, err) + } + defer projectRoot.Close() + defer skillRoot.Close() + + stagingName, stagingRoot, err := skillRoot.TempDir(".gh-skill-update-") + if err != nil { + return fmt.Errorf("could not create staging directory: %w", err) + } + committed := false + defer func() { + var cleanupErrors []error + if err := stagingRoot.Close(); err != nil { + cleanupErrors = append(cleanupErrors, fmt.Errorf("could not close staging directory %s: %w", stagingName, err)) + } + if err := removeRootStaging(skillRoot, stagingName); err != nil { + cleanupErrors = append(cleanupErrors, fmt.Errorf("retained staging directory %s: %w", stagingName, err)) + } + retErr = addCommittedCleanupWarning(retErr, committed, errors.Join(cleanupErrors...)) + }() + + installOpts := &installer.Options{ + Host: u.local.repoHost, + Owner: u.local.owner, + Repo: u.local.repo, + Ref: u.resolved.Ref, + SHA: u.resolved.SHA, + Skills: []discovery.Skill{u.skill}, + Dir: filepath.Join(u.local.dir, stagingName), + GitRoot: gitRoot, + HomeDir: homeDir, + Client: apiClient, + TargetRoot: stagingRoot, + DirectRoot: true, + } + if _, err := installer.Install(installOpts); err != nil { + return err + } + if _, err := stagingRoot.Lstat("SKILL.md"); err != nil { + return fmt.Errorf("installer did not produce SKILL.md: %w", err) + } + swapErr := swapRootDirectoryContents(skillRoot, stagingRoot, stagingName) + if swapErr != nil { + var committedWarning *committedUpdateWarning + if !errors.As(swapErr, &committedWarning) { + return swapErr + } + } + committed = true + return swapErr +} + +func openProjectSkillRoot(gitRoot, skillDir string) (*safepaths.Root, *safepaths.Root, error) { + if gitRoot == "" { + return nil, nil, fmt.Errorf("could not determine project root directory") + } + return safepaths.OpenRootWithin(gitRoot, skillDir, 0o755) +} + +type rootBackupEntry struct { + original string + backup string +} + +type rootPublishEntry struct { + final string + hidden string +} + +type committedUpdateWarning struct { + err error +} + +func (e *committedUpdateWarning) Error() string { + return fmt.Sprintf("new content is active, but post-commit cleanup was incomplete: %v", e.err) +} + +func (e *committedUpdateWarning) Unwrap() error { + return e.err +} + +var renameRootEntry = func(root *safepaths.Root, oldName, newName string) error { + return root.Rename(oldName, newName) +} + +var removeRootBackup = func(root *safepaths.Root, name string) error { + return root.RemoveAll(name) +} + +var removeRootStaging = func(root *safepaths.Root, name string) error { + return root.RemoveAll(name) +} + +var removeRootHidden = func(root *safepaths.Root, name string) error { + return root.RemoveAll(name) +} + +var copyRootFile = func(dst io.Writer, src io.Reader) (int64, error) { + return io.Copy(dst, src) +} + +func swapRootDirectoryContents(root, stagingRoot *safepaths.Root, stagingName string) error { + staged, err := stagingRoot.ReadDir(".") + if err != nil { + return fmt.Errorf("could not read staged skill directory: %w", err) + } + + var publishEntries []rootPublishEntry + for i, entry := range staged { + hiddenName := fmt.Sprintf("%s.publish-%d", stagingName, i) + if _, err := root.Lstat(hiddenName); err == nil { + cleanupErr := cleanupRootEntries(root, publishEntryNames(publishEntries)) + return errors.Join( + fmt.Errorf("publish path already exists: %s", hiddenName), + hiddenCleanupFailure(cleanupErr), + ) + } else if !os.IsNotExist(err) { + cleanupErr := cleanupRootEntries(root, publishEntryNames(publishEntries)) + return errors.Join( + fmt.Errorf("could not inspect publish path %s: %w", hiddenName, err), + hiddenCleanupFailure(cleanupErr), + ) + } + publishEntries = append(publishEntries, rootPublishEntry{final: entry.Name(), hidden: hiddenName}) + if err := copyRootEntry(stagingRoot, root, entry.Name(), hiddenName); err != nil { + cleanupErr := cleanupRootEntries(root, publishEntryNames(publishEntries)) + return errors.Join( + fmt.Errorf("could not prepare %s: %w", entry.Name(), err), + hiddenCleanupFailure(cleanupErr), + ) + } + } + + existing, err := root.ReadDir(".") + if err != nil { + cleanupErr := cleanupRootEntries(root, publishEntryNames(publishEntries)) + return errors.Join( + fmt.Errorf("could not read skill directory: %w", err), + hiddenCleanupFailure(cleanupErr), + ) + } + publishNames := make(map[string]bool, len(publishEntries)) + for _, entry := range publishEntries { + publishNames[entry.hidden] = true + } + var backups []rootBackupEntry + for i, entry := range existing { + if entry.Name() == stagingName || publishNames[entry.Name()] { + continue + } + backupName := fmt.Sprintf("%s.backup-%d", stagingName, i) + if _, err := root.Lstat(backupName); err == nil { + cleanupErr := cleanupRootEntries(root, publishEntryNames(publishEntries)) + rollbackErr := restoreRootBackup(root, backups, nil) + return errors.Join( + fmt.Errorf("backup path already exists: %s", backupName), + rollbackFailure(rollbackErr), + hiddenCleanupFailure(cleanupErr), + ) + } else if !os.IsNotExist(err) { + cleanupErr := cleanupRootEntries(root, publishEntryNames(publishEntries)) + rollbackErr := restoreRootBackup(root, backups, nil) + return errors.Join( + fmt.Errorf("could not inspect backup path %s: %w", backupName, err), + rollbackFailure(rollbackErr), + hiddenCleanupFailure(cleanupErr), + ) + } + if err := renameRootEntry(root, entry.Name(), backupName); err != nil { + rollbackErr := restoreRootBackup(root, backups, nil) + cleanupErr := cleanupRootEntries(root, publishEntryNames(publishEntries)) + return errors.Join( + fmt.Errorf("could not move %s aside: %w", entry.Name(), err), + rollbackFailure(rollbackErr), + hiddenCleanupFailure(cleanupErr), + ) + } + backups = append(backups, rootBackupEntry{original: entry.Name(), backup: backupName}) + } + + var movedIn []string + for _, entry := range publishEntries { + if err := renameRootEntry(root, entry.hidden, entry.final); err != nil { + rollbackErr := restoreRootBackup(root, backups, movedIn) + cleanupErr := cleanupRootEntries(root, publishEntryNames(publishEntries)) + return errors.Join( + fmt.Errorf("could not publish %s: %w", entry.final, err), + rollbackFailure(rollbackErr), + hiddenCleanupFailure(cleanupErr), + ) + } + movedIn = append(movedIn, entry.final) + } + + var cleanupErrors []error + for _, entry := range backups { + if err := removeRootBackup(root, entry.backup); err != nil { + cleanupErrors = append(cleanupErrors, fmt.Errorf("retained backup %s: %w", entry.backup, err)) + } + } + if err := errors.Join(cleanupErrors...); err != nil { + return &committedUpdateWarning{err: err} + } + return nil +} + +func copyRootEntry(src, dest *safepaths.Root, srcName, destName string) error { + info, err := src.Lstat(srcName) + if err != nil { + return err + } + if info.IsDir() { + if err := dest.MkdirAll(destName, 0o755); err != nil { + return err + } + entries, err := src.ReadDir(srcName) + if err != nil { + return err + } + for _, entry := range entries { + if err := copyRootEntry( + src, + dest, + filepath.Join(srcName, entry.Name()), + filepath.Join(destName, entry.Name()), + ); err != nil { + return err + } + } + return nil + } + + input, err := src.Open(srcName) + if err != nil { + return err + } + defer input.Close() + output, err := dest.Create(destName, info.Mode().Perm(), false) + if err != nil { + return err + } + if _, err := copyRootFile(output, input); err != nil { + _ = output.Close() + return err + } + return output.Close() +} + +func restoreRootBackup(root *safepaths.Root, backups []rootBackupEntry, movedIn []string) error { + var restoreErrors []error + for _, name := range movedIn { + if err := root.RemoveAll(name); err != nil { + restoreErrors = append(restoreErrors, fmt.Errorf("could not remove new entry %s: %w", name, err)) + } + } + for _, entry := range backups { + if err := renameRootEntry(root, entry.backup, entry.original); err != nil { + restoreErrors = append(restoreErrors, fmt.Errorf("could not restore %s from %s: %w", entry.original, entry.backup, err)) + } + } + return errors.Join(restoreErrors...) +} + +func publishEntryNames(entries []rootPublishEntry) []string { + names := make([]string, len(entries)) + for i, entry := range entries { + names[i] = entry.hidden + } + return names +} + +func cleanupRootEntries(root *safepaths.Root, names []string) error { + var cleanupErrors []error + for _, name := range names { + if err := removeRootHidden(root, name); err != nil { + cleanupErrors = append(cleanupErrors, fmt.Errorf("could not remove hidden entry %s: %w", name, err)) + } + } + return errors.Join(cleanupErrors...) +} + +func rollbackFailure(err error) error { + if err == nil { + return nil + } + return fmt.Errorf("rollback failed; recoverable backups were retained: %w", err) +} + +func hiddenCleanupFailure(err error) error { + if err == nil { + return nil + } + return fmt.Errorf("hidden prepared entries were retained: %w", err) +} + +func addCommittedCleanupWarning(result error, committed bool, cleanupErr error) error { + if cleanupErr == nil { + return result + } + if !committed { + return errors.Join(result, cleanupErr) + } + + var warning *committedUpdateWarning + if errors.As(result, &warning) { + return &committedUpdateWarning{err: errors.Join(warning.err, cleanupErr)} + } + return &committedUpdateWarning{err: cleanupErr} +} + // swapDirectoryContents replaces the entries inside dest with the entries // inside src, preserving dest's inode. It first moves every existing entry // into a sibling backup directory, then moves the staged entries into dest. diff --git a/pkg/cmd/skills/update/update_test.go b/pkg/cmd/skills/update/update_test.go index 8046c63340c..6688fd1155f 100644 --- a/pkg/cmd/skills/update/update_test.go +++ b/pkg/cmd/skills/update/update_test.go @@ -2,9 +2,11 @@ package update import ( "fmt" + "io" "net/http" "os" "path/filepath" + "strings" "testing" "github.com/MakeNowJust/heredoc" @@ -12,6 +14,7 @@ import ( "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/internal/safepaths" "github.com/cli/cli/v2/internal/skills/registry" "github.com/cli/cli/v2/pkg/cmdutil" @@ -311,15 +314,21 @@ func TestScanAllAgentsDeduplicatesSharedProjectDirs(t *testing.T) { } func TestUpdateRun(t *testing.T) { + var projectSymlinkTarget string + var projectSwapRoot string + var projectSwapMovedRoot string + var projectSwapOutside string + tests := []struct { - name string - setup func(t *testing.T, dir string) - stubs func(reg *httpmock.Registry) - opts func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions - verify func(t *testing.T, dir string) - wantErr string - wantStderr string - wantStdout string + name string + setup func(t *testing.T, dir string) + stubs func(reg *httpmock.Registry) + opts func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions + verify func(t *testing.T, dir string) + wantErr string + wantStderr string + wantStderrAll []string + wantStdout string }{ { name: "scans all agents when no --dir is set", @@ -708,6 +717,305 @@ func TestUpdateRun(t *testing.T) { }, wantStdout: "Updated code-review", }, + { + name: "project update rejects ancestor symlink", + setup: func(t *testing.T, dir string) { + t.Helper() + t.Setenv("HOME", t.TempDir()) + t.Setenv("USERPROFILE", os.Getenv("HOME")) + projectSymlinkTarget = t.TempDir() + skillDir := filepath.Join(projectSymlinkTarget, "skills", "code-review") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: code-review + metadata: + github-repo: https://github.com/monalisa/octocat-skills + github-tree-sha: oldsha000 + github-path: skills/code-review + --- + Old content + `)), 0o644)) + require.NoError(t, os.Symlink(projectSymlinkTarget, filepath.Join(dir, ".agents"))) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v3.0.0"}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags%2Fv3.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit789", "type": "commit"}}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newcommit789"), + httpmock.StringResponse(`{"sha": "newcommit789", "tree": [{"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "newblob1"}, {"path": "skills/code-review", "type": "tree", "sha": "newsha999"}, {"path": "skills", "type": "tree", "sha": "treeshaZ"}], "truncated": false}`)) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { + ios.SetStdoutTTY(false) + return &UpdateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewMockConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + All: true, + Force: true, + } + }, + verify: func(t *testing.T, _ string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(projectSymlinkTarget, "skills", "code-review", "SKILL.md")) + require.NoError(t, err) + assert.Contains(t, string(content), "Old content") + }, + wantErr: "SilentError", + wantStderr: "symbolic link", + }, + { + name: "project update stays rooted when ancestor changes", + setup: func(t *testing.T, dir string) { + t.Helper() + t.Setenv("HOME", t.TempDir()) + t.Setenv("USERPROFILE", os.Getenv("HOME")) + projectSwapRoot = dir + projectSwapMovedRoot = filepath.Join(dir, ".agents-moved") + projectSwapOutside = t.TempDir() + + skillDir := filepath.Join(dir, ".agents", "skills", "code-review") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: code-review + metadata: + github-repo: https://github.com/monalisa/octocat-skills + github-tree-sha: oldsha000 + github-path: skills/code-review + --- + Old content + `)), 0o644)) + + outsideSkillDir := filepath.Join(projectSwapOutside, "skills", "code-review") + require.NoError(t, os.MkdirAll(outsideSkillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(outsideSkillDir, "SKILL.md"), []byte("outside content"), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v3.0.0"}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags%2Fv3.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit789", "type": "commit"}}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newcommit789"), + httpmock.StringResponse(`{"sha": "newcommit789", "tree": [{"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "newblob1"}, {"path": "skills/code-review", "type": "tree", "sha": "newsha999"}, {"path": "skills", "type": "tree", "sha": "treeshaZ"}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newsha999"), + httpmock.StringResponse(`{"sha": "newsha999", "tree": [{"path": "SKILL.md", "type": "blob", "sha": "newblob1", "size": 20}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/newblob1"), + func(req *http.Request) (*http.Response, error) { + require.NoError(t, os.Rename(filepath.Join(projectSwapRoot, ".agents"), projectSwapMovedRoot)) + require.NoError(t, os.Symlink(projectSwapOutside, filepath.Join(projectSwapRoot, ".agents"))) + return httpmock.StringResponse(fmt.Sprintf(`{"sha": "newblob1", "encoding": "base64", "content": "%s"}`, + "IyBDb2RlIFJldmlldyBVcGRhdGVk"))(req) + }) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { + ios.SetStdoutTTY(false) + return &UpdateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewMockConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + All: true, + Force: true, + } + }, + verify: func(t *testing.T, _ string) { + t.Helper() + outside, err := os.ReadFile(filepath.Join(projectSwapOutside, "skills", "code-review", "SKILL.md")) + require.NoError(t, err) + assert.Equal(t, "outside content", string(outside)) + + updatedDir := filepath.Join(projectSwapMovedRoot, "skills", "code-review") + updated, err := os.ReadFile(filepath.Join(updatedDir, "SKILL.md")) + require.NoError(t, err) + assert.NotContains(t, string(updated), "Old content") + entries, err := os.ReadDir(updatedDir) + require.NoError(t, err) + require.Len(t, entries, 1) + }, + wantStdout: "Updated code-review", + }, + { + name: "project update publishes nested staged subtree", + setup: func(t *testing.T, dir string) { + t.Helper() + t.Setenv("HOME", t.TempDir()) + t.Setenv("USERPROFILE", os.Getenv("HOME")) + skillDir := filepath.Join(dir, ".agents", "skills", "code-review") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: code-review + metadata: + github-repo: https://github.com/monalisa/octocat-skills + github-tree-sha: oldsha000 + github-path: skills/code-review + --- + Old content + `)), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "stale.txt"), []byte("stale"), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v3.0.0"}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags%2Fv3.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit789", "type": "commit"}}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newcommit789"), + httpmock.StringResponse(`{"sha": "newcommit789", "tree": [{"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "newblob1"}, {"path": "skills/code-review/templates/config.txt", "type": "blob", "sha": "newblob2"}, {"path": "skills/code-review", "type": "tree", "sha": "newsha999"}, {"path": "skills", "type": "tree", "sha": "treeshaZ"}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newsha999"), + httpmock.StringResponse(`{"sha": "newsha999", "tree": [{"path": "SKILL.md", "type": "blob", "sha": "newblob1", "size": 20}, {"path": "templates/config.txt", "type": "blob", "sha": "newblob2", "size": 6}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/newblob1"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "newblob1", "encoding": "base64", "content": "%s"}`, + "IyBDb2RlIFJldmlldyBVcGRhdGVk"))) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/newblob2"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "newblob2", "encoding": "base64", "content": "%s"}`, + "bmVzdGVk"))) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { + ios.SetStdoutTTY(false) + return &UpdateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewMockConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + All: true, + Force: true, + } + }, + verify: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, ".agents", "skills", "code-review") + updated, err := os.ReadFile(filepath.Join(skillDir, "SKILL.md")) + require.NoError(t, err) + assert.NotContains(t, string(updated), "Old content") + nested, err := os.ReadFile(filepath.Join(skillDir, "templates", "config.txt")) + require.NoError(t, err) + assert.Equal(t, "nested", string(nested)) + assert.NoFileExists(t, filepath.Join(skillDir, "stale.txt")) + entries, err := os.ReadDir(skillDir) + require.NoError(t, err) + require.Len(t, entries, 2) + }, + wantStdout: "Updated code-review", + }, + { + name: "project update warns after committed cleanup failures", + setup: func(t *testing.T, dir string) { + t.Helper() + t.Setenv("HOME", t.TempDir()) + t.Setenv("USERPROFILE", os.Getenv("HOME")) + skillDir := filepath.Join(dir, ".agents", "skills", "code-review") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: code-review + metadata: + github-repo: https://github.com/monalisa/octocat-skills + github-tree-sha: oldsha000 + github-path: skills/code-review + --- + Old content + `)), 0o644)) + + originalRemove := removeRootBackup + originalStagingRemove := removeRootStaging + t.Cleanup(func() { + removeRootBackup = originalRemove + removeRootStaging = originalStagingRemove + }) + removeRootBackup = func(_ *safepaths.Root, _ string) error { + return fmt.Errorf("backup cleanup blocked") + } + removeRootStaging = func(_ *safepaths.Root, _ string) error { + return fmt.Errorf("staging cleanup blocked") + } + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v3.0.0"}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags%2Fv3.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit789", "type": "commit"}}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newcommit789"), + httpmock.StringResponse(`{"sha": "newcommit789", "tree": [{"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "newblob1"}, {"path": "skills/code-review", "type": "tree", "sha": "newsha999"}, {"path": "skills", "type": "tree", "sha": "treeshaZ"}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newsha999"), + httpmock.StringResponse(`{"sha": "newsha999", "tree": [{"path": "SKILL.md", "type": "blob", "sha": "newblob1", "size": 20}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/newblob1"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "newblob1", "encoding": "base64", "content": "%s"}`, + "IyBDb2RlIFJldmlldyBVcGRhdGVk"))) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { + ios.SetStdoutTTY(false) + return &UpdateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewMockConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + All: true, + Force: true, + } + }, + verify: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, ".agents", "skills", "code-review") + updated, err := os.ReadFile(filepath.Join(skillDir, "SKILL.md")) + require.NoError(t, err) + assert.NotContains(t, string(updated), "Old content") + + entries, err := os.ReadDir(skillDir) + require.NoError(t, err) + var backupPath string + var stagingPath string + for _, entry := range entries { + if strings.Contains(entry.Name(), ".backup-") { + backupPath = filepath.Join(skillDir, entry.Name()) + } + if strings.HasPrefix(entry.Name(), ".gh-skill-update-") && + !strings.Contains(entry.Name(), ".backup-") && + !strings.Contains(entry.Name(), ".publish-") { + stagingPath = filepath.Join(skillDir, entry.Name()) + } + } + require.NotEmpty(t, backupPath) + backup, err := os.ReadFile(backupPath) + require.NoError(t, err) + assert.Contains(t, string(backup), "Old content") + require.DirExists(t, stagingPath) + staged, err := os.ReadFile(filepath.Join(stagingPath, "SKILL.md")) + require.NoError(t, err) + assert.NotContains(t, string(staged), "Old content") + }, + wantStdout: "Updated code-review", + wantStderrAll: []string{"retained backup", "retained staging directory"}, + }, { name: "namespaced skill with --dir updates in-place", setup: func(t *testing.T, dir string) { @@ -1233,6 +1541,9 @@ func TestUpdateRun(t *testing.T) { if tt.wantStderr != "" { assert.Contains(t, stderr.String(), tt.wantStderr) } + for _, want := range tt.wantStderrAll { + assert.Contains(t, stderr.String(), want) + } if tt.wantStdout != "" { assert.Contains(t, stdout.String(), tt.wantStdout) } @@ -1321,3 +1632,199 @@ func TestSwapDirectoryContents_PreservesDestInode(t *testing.T) { require.NoError(t, err) assert.Equal(t, "new", string(content), "staged content must be installed") } + +func TestSwapRootDirectoryContents_RetainsBackupOnRollbackFailure(t *testing.T) { + rootDir := t.TempDir() + root, err := safepaths.OpenRoot(rootDir) + require.NoError(t, err) + defer root.Close() + require.NoError(t, root.WriteFile("SKILL.md", []byte("original"), 0o644, false)) + + stagingName, stagingRoot, err := root.TempDir(".stage-") + require.NoError(t, err) + defer stagingRoot.Close() + require.NoError(t, stagingRoot.WriteFile("SKILL.md", []byte("replacement"), 0o644, false)) + + originalRename := renameRootEntry + defer func() { renameRootEntry = originalRename }() + renameRootEntry = func(root *safepaths.Root, oldName, newName string) error { + if strings.HasPrefix(oldName, stagingName+".publish-") { + return fmt.Errorf("publish blocked") + } + if strings.HasPrefix(oldName, stagingName+".backup-") { + return fmt.Errorf("restore blocked") + } + return root.Rename(oldName, newName) + } + + err = swapRootDirectoryContents(root, stagingRoot, stagingName) + require.ErrorContains(t, err, "publish blocked") + require.ErrorContains(t, err, "rollback failed; recoverable backups were retained") + require.ErrorContains(t, err, "restore blocked") + assert.NoFileExists(t, filepath.Join(rootDir, "SKILL.md")) + + entries, err := root.ReadDir(".") + require.NoError(t, err) + var backupName string + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), stagingName+".backup-") { + backupName = entry.Name() + break + } + } + require.NotEmpty(t, backupName) + content, err := os.ReadFile(filepath.Join(rootDir, backupName)) + require.NoError(t, err) + assert.Equal(t, "original", string(content)) +} + +func TestSwapRootDirectoryContents_DoesNotExposePartialFiles(t *testing.T) { + rootDir := t.TempDir() + root, err := safepaths.OpenRoot(rootDir) + require.NoError(t, err) + defer root.Close() + require.NoError(t, root.WriteFile("SKILL.md", []byte("original"), 0o644, false)) + + stagingName, stagingRoot, err := root.TempDir(".stage-") + require.NoError(t, err) + defer stagingRoot.Close() + require.NoError(t, stagingRoot.WriteFile("SKILL.md", []byte("replacement"), 0o644, false)) + + originalCopy := copyRootFile + defer func() { copyRootFile = originalCopy }() + copyRootFile = func(dst io.Writer, src io.Reader) (int64, error) { + buffer := make([]byte, 3) + n, readErr := src.Read(buffer) + require.NoError(t, readErr) + written, writeErr := dst.Write(buffer[:n]) + require.NoError(t, writeErr) + + visible, err := os.ReadFile(filepath.Join(rootDir, "SKILL.md")) + require.NoError(t, err) + assert.Equal(t, "original", string(visible)) + return int64(written), fmt.Errorf("copy interrupted") + } + + err = swapRootDirectoryContents(root, stagingRoot, stagingName) + require.ErrorContains(t, err, "copy interrupted") + visible, err := os.ReadFile(filepath.Join(rootDir, "SKILL.md")) + require.NoError(t, err) + assert.Equal(t, "original", string(visible)) + + entries, err := root.ReadDir(".") + require.NoError(t, err) + for _, entry := range entries { + assert.NotContains(t, entry.Name(), ".publish-") + } +} + +func TestSwapRootDirectoryContents_CleansPreparedEntriesOnSecondNameFailure(t *testing.T) { + rootDir := t.TempDir() + root, err := safepaths.OpenRoot(rootDir) + require.NoError(t, err) + defer root.Close() + + stagingName, stagingRoot, err := root.TempDir(".stage-") + require.NoError(t, err) + defer stagingRoot.Close() + require.NoError(t, stagingRoot.WriteFile("first.txt", []byte("first"), 0o644, false)) + require.NoError(t, stagingRoot.WriteFile("second.txt", []byte("second"), 0o644, false)) + collision := stagingName + ".publish-1" + require.NoError(t, root.WriteFile(collision, []byte("existing"), 0o644, false)) + + originalRemove := removeRootHidden + defer func() { removeRootHidden = originalRemove }() + removeRootHidden = func(_ *safepaths.Root, name string) error { + if name == stagingName+".publish-0" { + return fmt.Errorf("cleanup blocked for %s", name) + } + return originalRemove(root, name) + } + + err = swapRootDirectoryContents(root, stagingRoot, stagingName) + require.ErrorContains(t, err, "publish path already exists") + require.ErrorContains(t, err, "hidden prepared entries were retained") + require.ErrorContains(t, err, stagingName+".publish-0") + assert.FileExists(t, filepath.Join(rootDir, stagingName+".publish-0")) + content, err := os.ReadFile(filepath.Join(rootDir, collision)) + require.NoError(t, err) + assert.Equal(t, "existing", string(content)) + assert.NoFileExists(t, filepath.Join(rootDir, "first.txt")) + assert.NoFileExists(t, filepath.Join(rootDir, "second.txt")) +} + +func TestSwapRootDirectoryContents_RestoresAfterDestructiveFailure(t *testing.T) { + tests := []struct { + name string + configure func(stagingName string) + }{ + { + name: "backup move fails after first original", + configure: func(stagingName string) { + originalRename := renameRootEntry + renameRootEntry = func(root *safepaths.Root, oldName, newName string) error { + if oldName == "b.txt" && strings.HasPrefix(newName, stagingName+".backup-") { + return fmt.Errorf("backup move failed") + } + return originalRename(root, oldName, newName) + } + }, + }, + { + name: "publication fails after first new entry", + configure: func(stagingName string) { + originalRename := renameRootEntry + published := 0 + renameRootEntry = func(root *safepaths.Root, oldName, newName string) error { + if strings.HasPrefix(oldName, stagingName+".publish-") { + published++ + if published == 2 { + return fmt.Errorf("publication failed") + } + } + return originalRename(root, oldName, newName) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rootDir := t.TempDir() + root, err := safepaths.OpenRoot(rootDir) + require.NoError(t, err) + defer root.Close() + require.NoError(t, root.WriteFile("a.txt", []byte("original-a"), 0o644, false)) + require.NoError(t, root.WriteFile("b.txt", []byte("original-b"), 0o644, false)) + + stagingName, stagingRoot, err := root.TempDir(".stage-") + require.NoError(t, err) + defer stagingRoot.Close() + require.NoError(t, stagingRoot.WriteFile("a.txt", []byte("new-a"), 0o644, false)) + require.NoError(t, stagingRoot.WriteFile("b.txt", []byte("new-b"), 0o644, false)) + require.NoError(t, stagingRoot.WriteFile("c.txt", []byte("new-c"), 0o644, false)) + + originalRename := renameRootEntry + defer func() { renameRootEntry = originalRename }() + tt.configure(stagingName) + + err = swapRootDirectoryContents(root, stagingRoot, stagingName) + require.Error(t, err) + a, err := os.ReadFile(filepath.Join(rootDir, "a.txt")) + require.NoError(t, err) + assert.Equal(t, "original-a", string(a)) + b, err := os.ReadFile(filepath.Join(rootDir, "b.txt")) + require.NoError(t, err) + assert.Equal(t, "original-b", string(b)) + assert.NoFileExists(t, filepath.Join(rootDir, "c.txt")) + + entries, err := root.ReadDir(".") + require.NoError(t, err) + for _, entry := range entries { + assert.NotContains(t, entry.Name(), ".backup-") + assert.NotContains(t, entry.Name(), ".publish-") + } + require.Len(t, entries, 3) + }) + } +} From 1a5a947e419c68b6cef8f1ff848d20d860d35c59 Mon Sep 17 00:00:00 2001 From: BagToad <47394200+BagToad@users.noreply.github.com> Date: Fri, 18 Sep 2026 17:54:53 -0600 Subject: [PATCH 03/14] Use iterator-based path splitting Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- internal/safepaths/root.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/safepaths/root.go b/internal/safepaths/root.go index c8f6d8e6388..9bed3f642dc 100644 --- a/internal/safepaths/root.go +++ b/internal/safepaths/root.go @@ -478,7 +478,7 @@ func (r *Root) openDirectories(name string, perm os.FileMode, create bool) (*os. return root, nil } - for _, component := range strings.Split(name, string(filepath.Separator)) { + for component := range strings.SplitSeq(name, string(filepath.Separator)) { if component == "" || component == "." { continue } From e4270ae938db717f6ec792293622ebd300140b58 Mon Sep 17 00:00:00 2001 From: BagToad <47394200+BagToad@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:37:22 -0600 Subject: [PATCH 04/14] Restore extraction rationale and align test cleanup Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/copilot/copilot.go | 6 ++++++ pkg/cmd/skills/update/update_test.go | 8 ++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/pkg/cmd/copilot/copilot.go b/pkg/cmd/copilot/copilot.go index 01828b2c610..a3a4fe33a31 100644 --- a/pkg/cmd/copilot/copilot.go +++ b/pkg/cmd/copilot/copilot.go @@ -388,6 +388,12 @@ func extractZip(path, destDir string) error { } defer root.Close() + // As of the time of writing, ghzip.ExtractZip will safely skip files that + // would result in path traversal. This is an issue for our use-case because + // we want to error out before extracting if there's any such file. + // To avoid breaking the shared ghzip.ExtractZip code that expects unsafe + // paths to be ignored and no error produced, we pre-validate here, + // producing an error if any such file is found. for _, file := range zipReader.File { if err := root.Validate(file.Name); err != nil { return err diff --git a/pkg/cmd/skills/update/update_test.go b/pkg/cmd/skills/update/update_test.go index 6688fd1155f..ac82a81c952 100644 --- a/pkg/cmd/skills/update/update_test.go +++ b/pkg/cmd/skills/update/update_test.go @@ -1646,7 +1646,7 @@ func TestSwapRootDirectoryContents_RetainsBackupOnRollbackFailure(t *testing.T) require.NoError(t, stagingRoot.WriteFile("SKILL.md", []byte("replacement"), 0o644, false)) originalRename := renameRootEntry - defer func() { renameRootEntry = originalRename }() + t.Cleanup(func() { renameRootEntry = originalRename }) renameRootEntry = func(root *safepaths.Root, oldName, newName string) error { if strings.HasPrefix(oldName, stagingName+".publish-") { return fmt.Errorf("publish blocked") @@ -1691,7 +1691,7 @@ func TestSwapRootDirectoryContents_DoesNotExposePartialFiles(t *testing.T) { require.NoError(t, stagingRoot.WriteFile("SKILL.md", []byte("replacement"), 0o644, false)) originalCopy := copyRootFile - defer func() { copyRootFile = originalCopy }() + t.Cleanup(func() { copyRootFile = originalCopy }) copyRootFile = func(dst io.Writer, src io.Reader) (int64, error) { buffer := make([]byte, 3) n, readErr := src.Read(buffer) @@ -1733,7 +1733,7 @@ func TestSwapRootDirectoryContents_CleansPreparedEntriesOnSecondNameFailure(t *t require.NoError(t, root.WriteFile(collision, []byte("existing"), 0o644, false)) originalRemove := removeRootHidden - defer func() { removeRootHidden = originalRemove }() + t.Cleanup(func() { removeRootHidden = originalRemove }) removeRootHidden = func(_ *safepaths.Root, name string) error { if name == stagingName+".publish-0" { return fmt.Errorf("cleanup blocked for %s", name) @@ -1805,7 +1805,7 @@ func TestSwapRootDirectoryContents_RestoresAfterDestructiveFailure(t *testing.T) require.NoError(t, stagingRoot.WriteFile("c.txt", []byte("new-c"), 0o644, false)) originalRename := renameRootEntry - defer func() { renameRootEntry = originalRename }() + t.Cleanup(func() { renameRootEntry = originalRename }) tt.configure(stagingName) err = swapRootDirectoryContents(root, stagingRoot, stagingName) From 3c4e972c3b201f66539da174e430ea5c58e0140b Mon Sep 17 00:00:00 2001 From: BagToad <47394200+BagToad@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:52:15 -0600 Subject: [PATCH 05/14] Remove skill changes from filesystem update Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- internal/safepaths/absolute.go | 2 +- internal/safepaths/absolute_test.go | 6 - internal/safepaths/root.go | 214 +------- internal/safepaths/root_race_test.go | 50 -- internal/safepaths/root_test.go | 53 +- internal/safepaths/root_validation_test.go | 74 +-- internal/skills/installer/installer.go | 166 +++---- internal/skills/installer/installer_test.go | 59 +-- pkg/cmd/skills/install/install.go | 65 +-- pkg/cmd/skills/install/install_test.go | 88 ---- pkg/cmd/skills/update/update.go | 333 +------------ pkg/cmd/skills/update/update_test.go | 523 +------------------- 12 files changed, 134 insertions(+), 1499 deletions(-) diff --git a/internal/safepaths/absolute.go b/internal/safepaths/absolute.go index 5f1014314f6..c3039f19f99 100644 --- a/internal/safepaths/absolute.go +++ b/internal/safepaths/absolute.go @@ -61,7 +61,7 @@ func (a Absolute) isSubpathOf(dir Absolute) (bool, error) { if err != nil { return false, err } - return relativePath != ".." && !strings.HasPrefix(relativePath, ".."+string(filepath.Separator)), nil + return !strings.HasPrefix(relativePath, ".."), nil } type PathTraversalError struct { diff --git a/internal/safepaths/absolute_test.go b/internal/safepaths/absolute_test.go index 35d5a5aec4e..8446fbfcce2 100644 --- a/internal/safepaths/absolute_test.go +++ b/internal/safepaths/absolute_test.go @@ -81,12 +81,6 @@ func TestJoin(t *testing.T) { elems: []string{""}, want: mustParseAbsolute("/base"), }, - { - name: "child beginning with two dots", - base: mustParseAbsolute("/base"), - elems: []string{"..child"}, - want: mustParseAbsolute("/base/..child"), - }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/safepaths/root.go b/internal/safepaths/root.go index 9bed3f642dc..47393d26c67 100644 --- a/internal/safepaths/root.go +++ b/internal/safepaths/root.go @@ -1,8 +1,6 @@ package safepaths import ( - "crypto/rand" - "encoding/hex" "fmt" "io" "os" @@ -14,15 +12,11 @@ import ( // Root restricts filesystem operations to a directory tree and refuses to // traverse symbolic links below that directory. type Root struct { - path Absolute root *os.Root openRoot func(*os.Root, string) (*os.Root, error) openFile func(*os.Root, string, int, os.FileMode) (*os.File, error) lstat func(*os.Root, string) (os.FileInfo, error) - mkdir func(*os.Root, string, os.FileMode) error - random func([]byte) (int, error) readlink func(*os.Root, string) (string, error) - rename func(*os.Root, string, string) error stat func(*os.Root, string) (os.FileInfo, error) } @@ -39,7 +33,6 @@ func OpenRoot(path string) (*Root, error) { } return &Root{ - path: absolute, root: root, openRoot: func(root *os.Root, name string) (*os.Root, error) { return root.OpenRoot(name) @@ -50,16 +43,9 @@ func OpenRoot(path string) (*Root, error) { lstat: func(root *os.Root, name string) (os.FileInfo, error) { return root.Lstat(name) }, - mkdir: func(root *os.Root, name string, perm os.FileMode) error { - return root.Mkdir(name, perm) - }, - random: rand.Read, readlink: func(root *os.Root, name string) (string, error) { return root.Readlink(name) }, - rename: func(root *os.Root, oldName, newName string) error { - return root.Rename(oldName, newName) - }, stat: func(root *os.Root, name string) (os.FileInfo, error) { return root.Stat(name) }, @@ -79,40 +65,6 @@ func OpenRootDir(path string, perm os.FileMode) (*Root, error) { return OpenRoot(path) } -// OpenRootWithin opens rootDir and a contained targetDir. It returns both -// handles so callers may keep the trusted parent pinned with the child. -func OpenRootWithin(rootDir, targetDir string, perm os.FileMode) (*Root, *Root, error) { - if err := os.MkdirAll(rootDir, perm); err != nil { - return nil, nil, err - } - rootAbs, err := filepath.Abs(rootDir) - if err != nil { - return nil, nil, err - } - targetAbs, err := filepath.Abs(targetDir) - if err != nil { - return nil, nil, err - } - rel, err := filepath.Rel(rootAbs, targetAbs) - if err != nil { - return nil, nil, err - } - if rel != "." && !filepath.IsLocal(rel) { - return nil, nil, fmt.Errorf("target directory %s is outside root %s", targetDir, rootDir) - } - - root, err := OpenRoot(rootAbs) - if err != nil { - return nil, nil, err - } - targetRoot, err := root.Sub(rel, perm) - if err != nil { - _ = root.Close() - return nil, nil, err - } - return root, targetRoot, nil -} - // OpenFile creates a file without traversing symbolic links below its rooted // parent. Creation is always exclusive, including when replace is false. func OpenFile(path string, perm os.FileMode, replace bool) (*os.File, error) { @@ -160,55 +112,27 @@ func (r *Root) Close() error { return r.root.Close() } -// String returns the absolute path to the root directory. -func (r *Root) String() string { - return r.path.String() -} - // Sub creates and opens a rooted subdirectory. func (r *Root) Sub(name string, perm os.FileMode) (*Root, error) { local, err := r.localPath(name) if err != nil { return nil, err } - absolute, err := r.path.Join(local) - if err != nil { - return nil, err - } - root, err := r.openDirectories(local, perm, true) + root, err := r.openDirectories(local, perm) if err != nil { return nil, err } return &Root{ - path: absolute, root: root, openRoot: r.openRoot, openFile: r.openFile, lstat: r.lstat, - mkdir: r.mkdir, - random: r.random, readlink: r.readlink, - rename: r.rename, stat: r.stat, }, nil } -// Lstat returns information about a path without following a final symbolic -// link. Symbolic links in parent components are rejected. -func (r *Root) Lstat(name string) (os.FileInfo, error) { - local, err := r.localPath(name) - if err != nil { - return nil, err - } - parent, err := r.openDirectories(filepath.Dir(local), 0, false) - if err != nil { - return nil, err - } - defer parent.Close() - return parent.Lstat(filepath.Base(local)) -} - // MkdirAll creates a directory path without traversing symbolic links. func (r *Root) MkdirAll(name string, perm os.FileMode) error { local, err := r.localPath(name) @@ -218,7 +142,7 @@ func (r *Root) MkdirAll(name string, perm os.FileMode) error { if local == "." { return nil } - root, err := r.openDirectories(local, perm, true) + root, err := r.openDirectories(local, perm) if err != nil { return err } @@ -244,7 +168,7 @@ func (r *Root) Create(name string, perm os.FileMode, replace bool) (*os.File, er if local == "." { return nil, fmt.Errorf("cannot create root directory as a file") } - parent, err := r.openDirectories(filepath.Dir(local), 0o755, true) + parent, err := r.openDirectories(filepath.Dir(local), 0o755) if err != nil { return nil, err } @@ -276,7 +200,7 @@ func (r *Root) Create(name string, perm os.FileMode, replace bool) (*os.File, er if info.Mode()&os.ModeNamedPipe != 0 { return nil, namedPipeWriteError(name) } - file, err := r.openVerifiedEntry(parent, base, local, os.O_WRONLY, false) + file, err := r.openVerifiedEntry(parent, base, local) if err != nil { return nil, err } @@ -332,116 +256,6 @@ func (r *Root) CopyFile(name string, src io.Reader, perm os.FileMode, replace bo return err } -// TempDir creates and opens a uniquely named directory directly beneath the root. -func (r *Root) TempDir(prefix string) (string, *Root, error) { - if prefix == "" || filepath.Base(prefix) != prefix || !filepath.IsLocal(prefix) { - return "", nil, fmt.Errorf("invalid temporary directory prefix %q", prefix) - } - - for range 100 { - var random [8]byte - if _, err := r.random(random[:]); err != nil { - return "", nil, err - } - name := prefix + hex.EncodeToString(random[:]) - if err := r.mkdir(r.root, name, 0o700); err != nil { - if os.IsExist(err) { - continue - } - return "", nil, err - } - - root, err := r.Sub(name, 0o700) - if err != nil { - _ = r.root.RemoveAll(name) - return "", nil, err - } - return name, root, nil - } - return "", nil, fmt.Errorf("could not create a unique temporary directory") -} - -// ReadDir reads a directory beneath the root without traversing symbolic links. -func (r *Root) ReadDir(name string) ([]os.DirEntry, error) { - local, err := r.localPath(name) - if err != nil { - return nil, err - } - root, err := r.openDirectories(local, 0, false) - if err != nil { - return nil, err - } - defer root.Close() - - dir, err := root.Open(".") - if err != nil { - return nil, err - } - defer dir.Close() - return dir.ReadDir(-1) -} - -// Open opens a regular file beneath the root without traversing symbolic links. -func (r *Root) Open(name string) (*os.File, error) { - local, err := r.localPath(name) - if err != nil { - return nil, err - } - if local == "." { - return nil, fmt.Errorf("cannot open root directory as a file") - } - parent, err := r.openDirectories(filepath.Dir(local), 0, false) - if err != nil { - return nil, err - } - defer parent.Close() - base := filepath.Base(local) - - return r.openVerifiedEntry(parent, base, local, os.O_RDONLY, true) -} - -// Rename renames an entry beneath one pinned, verified parent directory. -func (r *Root) Rename(oldName, newName string) error { - oldLocal, err := r.localPath(oldName) - if err != nil { - return err - } - newLocal, err := r.localPath(newName) - if err != nil { - return err - } - oldParent := filepath.Dir(oldLocal) - if oldParent != filepath.Dir(newLocal) { - return fmt.Errorf("rooted rename requires a common parent directory") - } - parent, err := r.openDirectories(oldParent, 0, false) - if err != nil { - return err - } - defer parent.Close() - return r.rename(parent, filepath.Base(oldLocal), filepath.Base(newLocal)) -} - -// RemoveAll removes a path beneath the root without following a final symbolic link. -func (r *Root) RemoveAll(name string) error { - local, err := r.localPath(name) - if err != nil { - return err - } - if local == "." { - return fmt.Errorf("cannot remove root directory") - } - parent, err := r.openDirectories(filepath.Dir(local), 0, false) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return err - } - defer parent.Close() - return parent.RemoveAll(filepath.Base(local)) -} - func (r *Root) localPath(name string) (string, error) { return validateChildPath(name) } @@ -460,7 +274,7 @@ func validateChildPath(name string) (string, error) { return local, nil } -func (r *Root) openDirectories(name string, perm os.FileMode, create bool) (*os.Root, error) { +func (r *Root) openDirectories(name string, perm os.FileMode) (*os.Root, error) { var current string parent := r.root parentOwned := false @@ -485,8 +299,8 @@ func (r *Root) openDirectories(name string, perm os.FileMode, create bool) (*os. current = filepath.Join(current, component) info, err := parent.Lstat(component) - if os.IsNotExist(err) && create { - if err := r.mkdir(parent, component, perm); err != nil && !os.IsExist(err) { + if os.IsNotExist(err) { + if err := parent.Mkdir(component, perm); err != nil && !os.IsExist(err) { closeParent() return nil, err } @@ -570,7 +384,7 @@ func (r *Root) openVerifiedDirectory(parent *os.Root, name, displayPath string) return root, nil } -func (r *Root) openVerifiedEntry(parent *os.Root, name, displayPath string, flag int, requireRegular bool) (*os.File, error) { +func (r *Root) openVerifiedEntry(parent *os.Root, name, displayPath string) (*os.File, error) { before, err := parent.Lstat(name) if err != nil { return nil, err @@ -582,17 +396,11 @@ func (r *Root) openVerifiedEntry(parent *os.Root, name, displayPath string, flag if redirect { return nil, SymlinkTraversalError{Path: displayPath} } - if requireRegular && !before.Mode().IsRegular() { - return nil, &os.PathError{Op: "open", Path: displayPath, Err: fmt.Errorf("not a regular file")} - } - if !requireRegular && before.Mode()&os.ModeNamedPipe != 0 { + if before.Mode()&os.ModeNamedPipe != 0 { return nil, namedPipeWriteError(displayPath) } - openFlag := flag - if !requireRegular && flag&(os.O_WRONLY|os.O_RDWR) != 0 { - openFlag |= nonBlockingOpenFlag() - } + openFlag := os.O_WRONLY | nonBlockingOpenFlag() file, err := r.openFile(parent, name, openFlag, 0) if err != nil { return nil, err @@ -620,7 +428,7 @@ func (r *Root) openVerifiedEntry(parent *os.Root, name, displayPath string, flag _ = file.Close() return nil, fmt.Errorf("file %q changed while opening", displayPath) } - if openFlag != flag { + if openFlag != os.O_WRONLY { if err := clearNonblocking(file); err != nil { _ = file.Close() return nil, err diff --git a/internal/safepaths/root_race_test.go b/internal/safepaths/root_race_test.go index c6a57a4e386..a2a59593885 100644 --- a/internal/safepaths/root_race_test.go +++ b/internal/safepaths/root_race_test.go @@ -39,53 +39,3 @@ func TestRootRejectsDirectorySwapDuringOpen(t *testing.T) { assert.NoFileExists(t, filepath.Join(redirectedDir, "file.txt")) assert.NoFileExists(t, filepath.Join(rootDir, "moved", "file.txt")) } - -func TestRootRenameUsesPinnedParentDuringSwap(t *testing.T) { - rootDir := t.TempDir() - parentDir := filepath.Join(rootDir, "nested") - require.NoError(t, os.Mkdir(parentDir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(parentDir, "source"), []byte("original"), 0o644)) - redirectedDir := filepath.Join(rootDir, "redirected") - require.NoError(t, os.Mkdir(redirectedDir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(redirectedDir, "source"), []byte("redirected"), 0o644)) - - root, err := OpenRoot(rootDir) - require.NoError(t, err) - defer root.Close() - - defaultRename := root.rename - root.rename = func(parent *os.Root, oldName, newName string) error { - moved := filepath.Join(rootDir, "moved") - require.NoError(t, os.Rename(parentDir, moved)) - if err := os.Symlink("redirected", parentDir); err != nil { - t.Skipf("directory symlinks unavailable: %v", err) - } - return defaultRename(parent, oldName, newName) - } - - require.NoError(t, root.Rename(filepath.Join("nested", "source"), filepath.Join("nested", "dest"))) - assert.FileExists(t, filepath.Join(rootDir, "moved", "dest")) - assert.NoFileExists(t, filepath.Join(redirectedDir, "dest")) - redirected, err := os.ReadFile(filepath.Join(redirectedDir, "source")) - require.NoError(t, err) - assert.Equal(t, "redirected", string(redirected)) -} - -func TestRootRenameRejectsCrossParent(t *testing.T) { - rootDir := t.TempDir() - require.NoError(t, os.Mkdir(filepath.Join(rootDir, "one"), 0o755)) - require.NoError(t, os.Mkdir(filepath.Join(rootDir, "two"), 0o755)) - source := filepath.Join(rootDir, "one", "source") - require.NoError(t, os.WriteFile(source, []byte("content"), 0o644)) - - root, err := OpenRoot(rootDir) - require.NoError(t, err) - defer root.Close() - - err = root.Rename(filepath.Join("one", "source"), filepath.Join("two", "dest")) - require.ErrorContains(t, err, "common parent") - content, err := os.ReadFile(source) - require.NoError(t, err) - assert.Equal(t, "content", string(content)) - assert.NoFileExists(t, filepath.Join(rootDir, "two", "dest")) -} diff --git a/internal/safepaths/root_test.go b/internal/safepaths/root_test.go index 8b8cde37028..07febc4b57f 100644 --- a/internal/safepaths/root_test.go +++ b/internal/safepaths/root_test.go @@ -31,6 +31,11 @@ func TestRootCreate(t *testing.T) { assert.Equal(t, "new", string(content)) }, }, + { + name: "rejects root directory as file", + path: ".", + wantErr: "root directory", + }, { name: "rejects lexical escape", path: "../file.txt", @@ -196,16 +201,6 @@ func TestRootCreate(t *testing.T) { } } -func TestOpenRootWithinRejectsOutsideTarget(t *testing.T) { - rootDir := t.TempDir() - outside := t.TempDir() - - root, target, err := safepaths.OpenRootWithin(rootDir, outside, 0o755) - require.ErrorContains(t, err, "outside root") - assert.Nil(t, root) - assert.Nil(t, target) -} - func TestValidateChild(t *testing.T) { tests := []struct { name string @@ -228,44 +223,6 @@ func TestValidateChild(t *testing.T) { } } -func TestRootRejectsDotFileOperations(t *testing.T) { - root, err := safepaths.OpenRoot(t.TempDir()) - require.NoError(t, err) - defer root.Close() - - file, err := root.Create(".", 0o644, false) - assert.Nil(t, file) - require.ErrorContains(t, err, "root directory") - file, err = root.Open(".") - assert.Nil(t, file) - require.ErrorContains(t, err, "root directory") - require.ErrorContains(t, root.RemoveAll("."), "root directory") -} - -func TestRootOpenRejectsNonregularFile(t *testing.T) { - root, err := safepaths.OpenRoot(t.TempDir()) - require.NoError(t, err) - defer root.Close() - require.NoError(t, root.MkdirAll("directory", 0o755)) - - file, err := root.Open("directory") - assert.Nil(t, file) - require.ErrorContains(t, err, "not a regular file") -} - -func TestTempDirRejectsInvalidPrefix(t *testing.T) { - root, err := safepaths.OpenRoot(t.TempDir()) - require.NoError(t, err) - defer root.Close() - - for _, prefix := range []string{"", "../prefix", "nested/prefix"} { - name, tempRoot, err := root.TempDir(prefix) - assert.Empty(t, name) - assert.Nil(t, tempRoot) - require.ErrorContains(t, err, "invalid temporary directory prefix") - } -} - func TestOpenFileRejectsTrailingSeparator(t *testing.T) { tests := []struct { name string diff --git a/internal/safepaths/root_validation_test.go b/internal/safepaths/root_validation_test.go index f69926d6874..20c593f8297 100644 --- a/internal/safepaths/root_validation_test.go +++ b/internal/safepaths/root_validation_test.go @@ -10,75 +10,6 @@ import ( "github.com/stretchr/testify/require" ) -func TestTempDirFailurePaths(t *testing.T) { - tests := []struct { - name string - configure func(root *Root) - wantErr string - verify func(t *testing.T, root *Root) - }{ - { - name: "random source error", - configure: func(root *Root) { - root.random = func([]byte) (int, error) { - return 0, errors.New("random failed") - } - }, - wantErr: "random failed", - }, - { - name: "mkdir error", - configure: func(root *Root) { - root.mkdir = func(*os.Root, string, os.FileMode) error { - return errors.New("mkdir failed") - } - }, - wantErr: "mkdir failed", - }, - { - name: "name exhaustion", - configure: func(root *Root) { - root.mkdir = func(*os.Root, string, os.FileMode) error { - return os.ErrExist - } - }, - wantErr: "could not create a unique temporary directory", - }, - { - name: "opening created directory fails", - configure: func(root *Root) { - root.openRoot = func(*os.Root, string) (*os.Root, error) { - return nil, errors.New("open failed") - } - }, - wantErr: "open failed", - verify: func(t *testing.T, root *Root) { - t.Helper() - entries, err := os.ReadDir(root.String()) - require.NoError(t, err) - assert.Empty(t, entries) - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - root, err := OpenRoot(t.TempDir()) - require.NoError(t, err) - defer root.Close() - tt.configure(root) - - name, tempRoot, err := root.TempDir(".temp-") - assert.Empty(t, name) - assert.Nil(t, tempRoot) - require.ErrorContains(t, err, tt.wantErr) - if tt.verify != nil { - tt.verify(t, root) - } - }) - } -} - func TestDirectoryIdentityMismatch(t *testing.T) { rootDir := t.TempDir() require.NoError(t, os.Mkdir(filepath.Join(rootDir, "expected"), 0o755)) @@ -116,9 +47,12 @@ func TestFileIdentityMismatch(t *testing.T) { return defaultOpenFile(parent, name, flag, perm) } - file, err := root.Open("expected") + file, err := root.Create("expected", 0o644, true) assert.Nil(t, file) require.ErrorContains(t, err, "changed while opening") + content, err := os.ReadFile(filepath.Join(rootDir, "other")) + require.NoError(t, err) + assert.Equal(t, "other", string(content)) } func TestRedirectTargetClassificationError(t *testing.T) { diff --git a/internal/skills/installer/installer.go b/internal/skills/installer/installer.go index 28f8f0fefc9..a0b0bfb708f 100644 --- a/internal/skills/installer/installer.go +++ b/internal/skills/installer/installer.go @@ -24,24 +24,20 @@ const maxConcurrency = 5 // Options configures an installation. type Options struct { - Host string // GitHub API hostname - Owner string - Repo string - Ref string // resolved ref name - SHA string // resolved commit SHA - PinnedRef string // user-supplied --pin value (empty if unpinned) - Skills []discovery.Skill - AgentHost *registry.AgentHost - Scope registry.Scope - Dir string // explicit target directory (overrides AgentHost+Scope) - RootDir string // trusted root containing Dir - GitRoot string // git repository root (for project scope) - HomeDir string // user home directory (for user scope) - Client *api.Client - TargetRoot *safepaths.Root // optional pre-opened root for Dir - DirectRoot bool // write skill contents directly into TargetRoot - ReplaceExisting map[string]bool - OnProgress func(done, total int) // called after each skill is installed + Host string // GitHub API hostname + Owner string + Repo string + Ref string // resolved ref name + SHA string // resolved commit SHA + PinnedRef string // user-supplied --pin value (empty if unpinned) + Skills []discovery.Skill + AgentHost *registry.AgentHost + Scope registry.Scope + Dir string // explicit target directory (overrides AgentHost+Scope) + GitRoot string // git repository root (for project scope) + HomeDir string // user home directory (for user scope) + Client *api.Client + OnProgress func(done, total int) // called after each skill is installed } // Result tracks what was installed. @@ -147,15 +143,13 @@ func Install(opts *Options) (*Result, error) { // LocalOptions configures a local directory installation. type LocalOptions struct { - SourceDir string - Skills []discovery.Skill - AgentHost *registry.AgentHost - Scope registry.Scope - Dir string - RootDir string - GitRoot string - HomeDir string - ReplaceExisting map[string]bool + SourceDir string + Skills []discovery.Skill + AgentHost *registry.AgentHost + Scope registry.Scope + Dir string + GitRoot string + HomeDir string } // InstallLocal copies skills from a local directory to the target install location. @@ -174,7 +168,7 @@ func InstallLocal(opts *LocalOptions) (*Result, error) { var installed []string for _, skill := range opts.Skills { - if err := installLocalSkill(opts, skill, targetDir); err != nil { + if err := installLocalSkill(opts.SourceDir, skill, targetDir); err != nil { return nil, fmt.Errorf("failed to install skill %q: %w", skill.InstallName(), err) } installed = append(installed, skill.InstallName()) @@ -183,28 +177,26 @@ func InstallLocal(opts *LocalOptions) (*Result, error) { return &Result{Installed: installed, Dir: targetDir}, nil } -func installLocalSkill(opts *LocalOptions, skill discovery.Skill, baseDir string) error { +func installLocalSkill(sourceRoot string, skill discovery.Skill, baseDir string) error { // Use skill.Name (not InstallName) so skills are always installed flat. // Most agent clients only discover immediate subdirectories of their // skills folder and do not find skills nested under namespace directories. skillDir := filepath.Join(baseDir, skill.Name) - targetRoot, err := openInstallRoot(opts.RootDir, baseDir) - if err != nil { - return fmt.Errorf("could not open install directory %s: %w", baseDir, err) - } - defer targetRoot.Close() - skillRoot, err := targetRoot.Sub(skill.Name, 0o755) - if err != nil { + if err := os.MkdirAll(skillDir, 0o755); err != nil { return fmt.Errorf("could not create directory %s: %w", skillDir, err) } - defer skillRoot.Close() - srcDir := filepath.Join(opts.SourceDir, filepath.FromSlash(skill.Path)) + srcDir := filepath.Join(sourceRoot, filepath.FromSlash(skill.Path)) absSource, err := filepath.Abs(srcDir) if err != nil { return fmt.Errorf("could not resolve source path: %w", err) } + safeSkillDir, err := safepaths.ParseAbsolute(skillDir) + if err != nil { + return fmt.Errorf("could not resolve target path: %w", err) + } + return filepath.WalkDir(srcDir, func(p string, d os.DirEntry, walkErr error) error { if walkErr != nil { return walkErr @@ -221,6 +213,24 @@ func installLocalSkill(opts *LocalOptions, skill discovery.Skill, baseDir string return err } + // Defensive: filepath.WalkDir cannot produce traversal paths, but we + // guard against it in case the walk input is ever changed. + safeDest, err := safeSkillDir.Join(relPath) + if err != nil { + var traversalErr safepaths.PathTraversalError + if errors.As(err, &traversalErr) { + return fmt.Errorf("blocked path traversal in %q", relPath) + } + return fmt.Errorf("could not resolve destination path: %w", err) + } + destPath := safeDest.String() + + if dir := filepath.Dir(destPath); dir != skillDir { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("could not create directory: %w", err) + } + } + content, err := os.ReadFile(p) if err != nil { return fmt.Errorf("could not read %s: %w", p, err) @@ -234,44 +244,15 @@ func installLocalSkill(opts *LocalOptions, skill discovery.Skill, baseDir string content = []byte(injected) } - if err := skillRoot.WriteFile(relPath, content, 0o644, opts.ReplaceExisting[skill.Name]); err != nil { - var traversalErr safepaths.PathTraversalError - if errors.As(err, &traversalErr) { - return fmt.Errorf("blocked path traversal in %q", relPath) - } - return fmt.Errorf("could not write %s: %w", filepath.Join(skillDir, relPath), err) - } - return nil + return os.WriteFile(destPath, content, 0o644) }) } func installSkill(opts *Options, skill discovery.Skill, baseDir string) error { // Use skill.Name (not InstallName) for a flat directory layout. skillDir := filepath.Join(baseDir, skill.Name) - targetRoot := opts.TargetRoot - ownsTargetRoot := false - if targetRoot == nil { - var err error - targetRoot, err = openInstallRoot(opts.RootDir, baseDir) - if err != nil { - return fmt.Errorf("could not open install directory %s: %w", baseDir, err) - } - ownsTargetRoot = true - } - if ownsTargetRoot { - defer targetRoot.Close() - } - - skillRoot := targetRoot - if !opts.DirectRoot { - var err error - skillRoot, err = targetRoot.Sub(skill.Name, 0o755) - if err != nil { - return fmt.Errorf("could not create directory %s: %w", skillDir, err) - } - defer skillRoot.Close() - } else { - skillDir = baseDir + if err := os.MkdirAll(skillDir, 0o755); err != nil { + return fmt.Errorf("could not create directory %s: %w", skillDir, err) } files, err := discovery.DiscoverSkillFiles(opts.Client, opts.Host, opts.Owner, opts.Repo, skill.TreeSHA, skill.Path) @@ -279,6 +260,11 @@ func installSkill(opts *Options, skill discovery.Skill, baseDir string) error { return fmt.Errorf("could not list skill files: %w", err) } + safeSkillDir, err := safepaths.ParseAbsolute(skillDir) + if err != nil { + return fmt.Errorf("could not resolve skill directory path: %w", err) + } + for _, file := range files { fetchedContent, err := discovery.FetchBlob(opts.Client, opts.Host, opts.Owner, opts.Repo, file.SHA) if err != nil { @@ -291,6 +277,22 @@ func installSkill(opts *Options, skill discovery.Skill, baseDir string) error { relPath := strings.TrimPrefix(file.Path, skill.Path+"/") + safeDest, err := safeSkillDir.Join(relPath) + if err != nil { + var traversalErr safepaths.PathTraversalError + if errors.As(err, &traversalErr) { + return fmt.Errorf("blocked path traversal in %q", relPath) + } + return fmt.Errorf("could not resolve destination path: %w", err) + } + destPath := safeDest.String() + + if dir := filepath.Dir(destPath); dir != skillDir { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("could not create directory: %w", err) + } + } + if filepath.Base(relPath) == "SKILL.md" { content, err = frontmatter.InjectGitHubMetadata(content, opts.Host, opts.Owner, opts.Repo, opts.Ref, skill.TreeSHA, opts.PinnedRef, skill.Path) if err != nil { @@ -298,34 +300,14 @@ func installSkill(opts *Options, skill discovery.Skill, baseDir string) error { } } - if err := skillRoot.WriteFile(relPath, []byte(content), 0o644, opts.ReplaceExisting[skill.Name]); err != nil { - var traversalErr safepaths.PathTraversalError - if errors.As(err, &traversalErr) { - return fmt.Errorf("blocked path traversal in %q", relPath) - } - return fmt.Errorf("could not write %s: %w", filepath.Join(skillDir, relPath), err) + if err := os.WriteFile(destPath, []byte(content), 0o644); err != nil { + return fmt.Errorf("could not write %s: %w", destPath, err) } } return nil } -func openInstallRoot(rootDir, targetDir string) (*safepaths.Root, error) { - if rootDir == "" { - rootDir = targetDir - } - root, targetRoot, err := safepaths.OpenRootWithin(rootDir, targetDir, 0o755) - if err != nil { - return nil, err - } - closeErr := root.Close() - if closeErr != nil { - _ = targetRoot.Close() - return nil, closeErr - } - return targetRoot, nil -} - // ResolveGitRoot returns the git repository root using the provided client, // falling back to the current working directory on error. func ResolveGitRoot(gc *git.Client) string { diff --git a/internal/skills/installer/installer_test.go b/internal/skills/installer/installer_test.go index 08b053aacb8..771e225890e 100644 --- a/internal/skills/installer/installer_test.go +++ b/internal/skills/installer/installer_test.go @@ -183,15 +183,11 @@ func TestInstallLocal(t *testing.T) { } func TestInstallSkill(t *testing.T) { - var preservedSkillMode os.FileMode - tests := []struct { - name string - skill discovery.Skill - setup func(t *testing.T, destDir string) - stubs func(*httpmock.Registry) - verify func(t *testing.T, destDir string) - replace bool + name string + skill discovery.Skill + stubs func(*httpmock.Registry) + verify func(t *testing.T, destDir string) }{ { name: "installs files from remote", @@ -288,54 +284,10 @@ func TestInstallSkill(t *testing.T) { assert.True(t, os.IsNotExist(err), "traversal path should not be written") }, }, - { - name: "forced replacement preserves regular file mode", - skill: discovery.Skill{Name: "code-review", Path: "skills/code-review", TreeSHA: "tree-mode"}, - setup: func(t *testing.T, destDir string) { - t.Helper() - skillDir := filepath.Join(destDir, "code-review") - require.NoError(t, os.Mkdir(skillDir, 0o755)) - skillFile := filepath.Join(skillDir, "SKILL.md") - require.NoError(t, os.WriteFile(skillFile, []byte("old"), 0o600)) - info, err := os.Stat(skillFile) - require.NoError(t, err) - preservedSkillMode = info.Mode().Perm() - }, - stubs: func(reg *httpmock.Registry) { - reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree-mode"), - httpmock.JSONResponse(map[string]any{ - "sha": "tree-mode", "truncated": false, - "tree": []map[string]any{ - {"path": "SKILL.md", "type": "blob", "sha": "mode-sha", "size": 10}, - }, - })) - reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/mode-sha"), - httpmock.JSONResponse(map[string]any{ - "sha": "mode-sha", "encoding": "base64", - "content": base64.StdEncoding.EncodeToString([]byte("# Updated")), - })) - }, - verify: func(t *testing.T, destDir string) { - t.Helper() - skillFile := filepath.Join(destDir, "code-review", "SKILL.md") - info, err := os.Stat(skillFile) - require.NoError(t, err) - assert.Equal(t, preservedSkillMode, info.Mode().Perm()) - content, err := os.ReadFile(skillFile) - require.NoError(t, err) - assert.Contains(t, string(content), "# Updated") - }, - replace: true, - }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { destDir := t.TempDir() - if tt.setup != nil { - tt.setup(t, destDir) - } reg := &httpmock.Registry{} defer reg.Verify(t) tt.stubs(reg) @@ -348,9 +300,6 @@ func TestInstallSkill(t *testing.T) { SHA: "commit123", Client: client, } - if tt.replace { - opts.ReplaceExisting = map[string]bool{tt.skill.Name: true} - } err := installSkill(opts, tt.skill, destDir) if tt.name == "fails on path traversal from malicious tree" { diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go index 698493abff4..64be5da5a79 100644 --- a/pkg/cmd/skills/install/install.go +++ b/pkg/cmd/skills/install/install.go @@ -399,18 +399,16 @@ func installRun(opts *InstallOptions) error { } result, err := installer.Install(&installer.Options{ - Host: hostname, - Owner: opts.repo.RepoOwner(), - Repo: opts.repo.RepoName(), - Ref: resolved.Ref, - SHA: resolved.SHA, - PinnedRef: opts.Pin, - Skills: plan.skills, - Dir: plan.dir, - RootDir: plan.root, - Client: apiClient, - ReplaceExisting: plan.replaceExisting, - OnProgress: installProgress(opts.IO, len(plan.skills)), + Host: hostname, + Owner: opts.repo.RepoOwner(), + Repo: opts.repo.RepoName(), + Ref: resolved.Ref, + SHA: resolved.SHA, + PinnedRef: opts.Pin, + Skills: plan.skills, + Dir: plan.dir, + Client: apiClient, + OnProgress: installProgress(opts.IO, len(plan.skills)), }) if result != nil { @@ -558,11 +556,9 @@ func runLocalInstall(opts *InstallOptions) error { } result, err := installer.InstallLocal(&installer.LocalOptions{ - SourceDir: absSource, - Skills: plan.skills, - Dir: plan.dir, - RootDir: plan.root, - ReplaceExisting: plan.replaceExisting, + SourceDir: absSource, + Skills: plan.skills, + Dir: plan.dir, }) if err != nil { return err @@ -688,11 +684,9 @@ type skillSelector struct { } type installPlan struct { - dir string - root string - hosts []*registry.AgentHost - skills []discovery.Skill - replaceExisting map[string]bool + dir string + hosts []*registry.AgentHost + skills []discovery.Skill } // errSkillsListed is a sentinel returned by selectSkillsWithSelector when @@ -1002,11 +996,7 @@ func buildInstallPlans(opts *InstallOptions, selectedSkills []discovery.Skill, s plan, ok := byDir[targetDir] if !ok { - rootDir := targetDir - if opts.Dir == "" && scope == registry.ScopeProject { - rootDir = gitRoot - } - plan = &installPlan{dir: targetDir, root: rootDir} + plan = &installPlan{dir: targetDir} byDir[targetDir] = plan orderedDirs = append(orderedDirs, targetDir) } @@ -1016,7 +1006,7 @@ func buildInstallPlans(opts *InstallOptions, selectedSkills []discovery.Skill, s plans := make([]installPlan, 0, len(orderedDirs)) for _, dir := range orderedDirs { plan := byDir[dir] - installSkills, replaceExisting, err := checkOverwrite(opts, selectedSkills, plan.dir, canPrompt) + installSkills, err := checkOverwrite(opts, selectedSkills, plan.dir, canPrompt) if err != nil { return nil, err } @@ -1025,7 +1015,6 @@ func buildInstallPlans(opts *InstallOptions, selectedSkills []discovery.Skill, s continue } plan.skills = installSkills - plan.replaceExisting = replaceExisting plans = append(plans, *plan) } @@ -1047,7 +1036,7 @@ func formatPlanHosts(hosts []*registry.AgentHost) string { return strings.Join(names, ", ") } -func checkOverwrite(opts *InstallOptions, skills []discovery.Skill, targetDir string, canPrompt bool) ([]discovery.Skill, map[string]bool, error) { +func checkOverwrite(opts *InstallOptions, skills []discovery.Skill, targetDir string, canPrompt bool) ([]discovery.Skill, error) { var existing, fresh []discovery.Skill for _, s := range skills { dir := filepath.Join(targetDir, s.Name) @@ -1059,15 +1048,11 @@ func checkOverwrite(opts *InstallOptions, skills []discovery.Skill, targetDir st } if len(existing) == 0 { - return skills, nil, nil + return skills, nil } if opts.Force { - replaceExisting := make(map[string]bool, len(existing)) - for _, skill := range existing { - replaceExisting[skill.Name] = true - } - return skills, replaceExisting, nil + return skills, nil } if !canPrompt { @@ -1075,26 +1060,24 @@ func checkOverwrite(opts *InstallOptions, skills []discovery.Skill, targetDir st for i, s := range existing { names[i] = s.DisplayName() } - return nil, nil, fmt.Errorf("skills already installed: %s (use --force to overwrite)", strings.Join(names, ", ")) + return nil, fmt.Errorf("skills already installed: %s (use --force to overwrite)", strings.Join(names, ", ")) } var confirmed []discovery.Skill - replaceExisting := make(map[string]bool) for _, s := range existing { prompt := existingSkillPrompt(targetDir, s) ok, err := opts.Prompter.Confirm(prompt, false) if err != nil { - return nil, nil, err + return nil, err } if ok { confirmed = append(confirmed, s) - replaceExisting[s.Name] = true } else { fmt.Fprintf(opts.IO.ErrOut, "Skipping %s\n", s.DisplayName()) } } - return append(fresh, confirmed...), replaceExisting, nil + return append(fresh, confirmed...), nil } func existingSkillPrompt(targetDir string, incoming discovery.Skill) string { diff --git a/pkg/cmd/skills/install/install_test.go b/pkg/cmd/skills/install/install_test.go index 63e73aee03e..850f925f832 100644 --- a/pkg/cmd/skills/install/install_test.go +++ b/pkg/cmd/skills/install/install_test.go @@ -302,11 +302,6 @@ func hiddenDirSkillTreeJSON(name, treeSHA, blobSHA string) string { } func TestInstallRun(t *testing.T) { - var projectRoot string - var outsideRoot string - var clobberProjectRoot string - var clobberTarget string - tests := []struct { name string isTTY bool @@ -531,83 +526,6 @@ func TestInstallRun(t *testing.T) { }, wantStdout: "Installed git-commit", }, - { - name: "project install rejects ancestor symlink", - isTTY: false, - setup: func(t *testing.T) { - t.Helper() - projectRoot = t.TempDir() - outsideRoot = t.TempDir() - require.NoError(t, os.Symlink(outsideRoot, filepath.Join(projectRoot, ".agents"))) - }, - stubs: func(reg *httpmock.Registry) { - stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") - stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", - singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) - }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { - t.Helper() - return &InstallOptions{ - IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, - GitClient: &git.Client{RepoDir: projectRoot}, - SkillSource: "monalisa/skills-repo", - SkillName: "git-commit", - Agent: "github-copilot", - Scope: "project", - ScopeChanged: true, - } - }, - verify: func(t *testing.T) { - t.Helper() - assert.NoFileExists(t, filepath.Join(outsideRoot, "skills", "git-commit", "SKILL.md")) - }, - wantErr: "symbolic link", - }, - { - name: "project force install replaces final symlink", - isTTY: false, - setup: func(t *testing.T) { - t.Helper() - clobberProjectRoot = t.TempDir() - skillDir := filepath.Join(clobberProjectRoot, ".agents", "skills", "git-commit") - require.NoError(t, os.MkdirAll(skillDir, 0o755)) - clobberTarget = filepath.Join(skillDir, "target.md") - require.NoError(t, os.WriteFile(clobberTarget, []byte("old"), 0o644)) - require.NoError(t, os.Symlink(filepath.Base(clobberTarget), filepath.Join(skillDir, "SKILL.md"))) - }, - stubs: func(reg *httpmock.Registry) { - stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") - stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", - singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) - stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) - }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { - t.Helper() - return &InstallOptions{ - IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, - GitClient: &git.Client{RepoDir: clobberProjectRoot}, - SkillSource: "monalisa/skills-repo", - SkillName: "git-commit", - Agent: "github-copilot", - Scope: "project", - ScopeChanged: true, - Force: true, - } - }, - verify: func(t *testing.T) { - t.Helper() - skillFile := filepath.Join(clobberProjectRoot, ".agents", "skills", "git-commit", "SKILL.md") - info, err := os.Lstat(skillFile) - require.NoError(t, err) - assert.Zero(t, info.Mode()&os.ModeSymlink) - content, err := os.ReadFile(clobberTarget) - require.NoError(t, err) - assert.Equal(t, "old", string(content)) - }, - wantStdout: "Installed git-commit", - }, { name: "remote install existing skill without force non-interactive errors", isTTY: false, @@ -1703,12 +1621,6 @@ func TestInstallRun(t *testing.T) { if tt.wantErr != "" { require.Error(t, err) assert.Contains(t, err.Error(), tt.wantErr) - if tt.verify != nil { - tt.verify(t) - } - if tt.assert != nil { - tt.assert(t) - } return } diff --git a/pkg/cmd/skills/update/update.go b/pkg/cmd/skills/update/update.go index 4f5c8761ffe..a6ba369192e 100644 --- a/pkg/cmd/skills/update/update.go +++ b/pkg/cmd/skills/update/update.go @@ -1,9 +1,7 @@ package update import ( - "errors" "fmt" - "io" "net/http" "os" "path/filepath" @@ -15,7 +13,6 @@ import ( "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" - "github.com/cli/cli/v2/internal/safepaths" "github.com/cli/cli/v2/internal/skills/discovery" "github.com/cli/cli/v2/internal/skills/frontmatter" "github.com/cli/cli/v2/internal/skills/installer" @@ -389,14 +386,9 @@ func updateRun(opts *UpdateOptions) error { var failed bool for _, u := range updates { if err := updateSkillInPlace(opts, u, apiClient, gitRoot, homeDir); err != nil { - var committedWarning *committedUpdateWarning - if errors.As(err, &committedWarning) { - fmt.Fprintf(opts.IO.ErrOut, "%s Updated %s with warning: %v\n", cs.WarningIcon(), u.local.name, committedWarning) - } else { - fmt.Fprintf(opts.IO.ErrOut, "%s Failed to update %s: %v\n", cs.FailureIcon(), u.local.name, err) - failed = true - continue - } + fmt.Fprintf(opts.IO.ErrOut, "%s Failed to update %s: %v\n", cs.FailureIcon(), u.local.name, err) + failed = true + continue } if opts.IO.IsStdoutTTY() { fmt.Fprintf(opts.IO.Out, "%s Updated %s\n", cs.SuccessIcon(), u.local.name) @@ -427,12 +419,6 @@ func updateSkillInPlace(opts *UpdateOptions, u pendingUpdate, apiClient *api.Cli if u.local.dir == "" { return fmt.Errorf("cannot update %s: no install location recorded", u.local.name) } - // Project paths are derived beneath the Git root and stay pinned through - // publication. User-scope and explicit --dir updates retain the existing - // same-user application-directory implementation below. - if u.local.scope == registry.ScopeProject { - return updateProjectSkillInPlace(u, apiClient, gitRoot, homeDir) - } parent := filepath.Dir(u.local.dir) if err := os.MkdirAll(parent, 0o755); err != nil { @@ -475,319 +461,6 @@ func updateSkillInPlace(opts *UpdateOptions, u pendingUpdate, apiClient *api.Cli return swapDirectoryContents(u.local.dir, stagedSkillDir) } -func updateProjectSkillInPlace(u pendingUpdate, apiClient *api.Client, gitRoot, homeDir string) (retErr error) { - projectRoot, skillRoot, err := openProjectSkillRoot(gitRoot, u.local.dir) - if err != nil { - return fmt.Errorf("could not safely access project skill directory %s: %w", u.local.dir, err) - } - defer projectRoot.Close() - defer skillRoot.Close() - - stagingName, stagingRoot, err := skillRoot.TempDir(".gh-skill-update-") - if err != nil { - return fmt.Errorf("could not create staging directory: %w", err) - } - committed := false - defer func() { - var cleanupErrors []error - if err := stagingRoot.Close(); err != nil { - cleanupErrors = append(cleanupErrors, fmt.Errorf("could not close staging directory %s: %w", stagingName, err)) - } - if err := removeRootStaging(skillRoot, stagingName); err != nil { - cleanupErrors = append(cleanupErrors, fmt.Errorf("retained staging directory %s: %w", stagingName, err)) - } - retErr = addCommittedCleanupWarning(retErr, committed, errors.Join(cleanupErrors...)) - }() - - installOpts := &installer.Options{ - Host: u.local.repoHost, - Owner: u.local.owner, - Repo: u.local.repo, - Ref: u.resolved.Ref, - SHA: u.resolved.SHA, - Skills: []discovery.Skill{u.skill}, - Dir: filepath.Join(u.local.dir, stagingName), - GitRoot: gitRoot, - HomeDir: homeDir, - Client: apiClient, - TargetRoot: stagingRoot, - DirectRoot: true, - } - if _, err := installer.Install(installOpts); err != nil { - return err - } - if _, err := stagingRoot.Lstat("SKILL.md"); err != nil { - return fmt.Errorf("installer did not produce SKILL.md: %w", err) - } - swapErr := swapRootDirectoryContents(skillRoot, stagingRoot, stagingName) - if swapErr != nil { - var committedWarning *committedUpdateWarning - if !errors.As(swapErr, &committedWarning) { - return swapErr - } - } - committed = true - return swapErr -} - -func openProjectSkillRoot(gitRoot, skillDir string) (*safepaths.Root, *safepaths.Root, error) { - if gitRoot == "" { - return nil, nil, fmt.Errorf("could not determine project root directory") - } - return safepaths.OpenRootWithin(gitRoot, skillDir, 0o755) -} - -type rootBackupEntry struct { - original string - backup string -} - -type rootPublishEntry struct { - final string - hidden string -} - -type committedUpdateWarning struct { - err error -} - -func (e *committedUpdateWarning) Error() string { - return fmt.Sprintf("new content is active, but post-commit cleanup was incomplete: %v", e.err) -} - -func (e *committedUpdateWarning) Unwrap() error { - return e.err -} - -var renameRootEntry = func(root *safepaths.Root, oldName, newName string) error { - return root.Rename(oldName, newName) -} - -var removeRootBackup = func(root *safepaths.Root, name string) error { - return root.RemoveAll(name) -} - -var removeRootStaging = func(root *safepaths.Root, name string) error { - return root.RemoveAll(name) -} - -var removeRootHidden = func(root *safepaths.Root, name string) error { - return root.RemoveAll(name) -} - -var copyRootFile = func(dst io.Writer, src io.Reader) (int64, error) { - return io.Copy(dst, src) -} - -func swapRootDirectoryContents(root, stagingRoot *safepaths.Root, stagingName string) error { - staged, err := stagingRoot.ReadDir(".") - if err != nil { - return fmt.Errorf("could not read staged skill directory: %w", err) - } - - var publishEntries []rootPublishEntry - for i, entry := range staged { - hiddenName := fmt.Sprintf("%s.publish-%d", stagingName, i) - if _, err := root.Lstat(hiddenName); err == nil { - cleanupErr := cleanupRootEntries(root, publishEntryNames(publishEntries)) - return errors.Join( - fmt.Errorf("publish path already exists: %s", hiddenName), - hiddenCleanupFailure(cleanupErr), - ) - } else if !os.IsNotExist(err) { - cleanupErr := cleanupRootEntries(root, publishEntryNames(publishEntries)) - return errors.Join( - fmt.Errorf("could not inspect publish path %s: %w", hiddenName, err), - hiddenCleanupFailure(cleanupErr), - ) - } - publishEntries = append(publishEntries, rootPublishEntry{final: entry.Name(), hidden: hiddenName}) - if err := copyRootEntry(stagingRoot, root, entry.Name(), hiddenName); err != nil { - cleanupErr := cleanupRootEntries(root, publishEntryNames(publishEntries)) - return errors.Join( - fmt.Errorf("could not prepare %s: %w", entry.Name(), err), - hiddenCleanupFailure(cleanupErr), - ) - } - } - - existing, err := root.ReadDir(".") - if err != nil { - cleanupErr := cleanupRootEntries(root, publishEntryNames(publishEntries)) - return errors.Join( - fmt.Errorf("could not read skill directory: %w", err), - hiddenCleanupFailure(cleanupErr), - ) - } - publishNames := make(map[string]bool, len(publishEntries)) - for _, entry := range publishEntries { - publishNames[entry.hidden] = true - } - var backups []rootBackupEntry - for i, entry := range existing { - if entry.Name() == stagingName || publishNames[entry.Name()] { - continue - } - backupName := fmt.Sprintf("%s.backup-%d", stagingName, i) - if _, err := root.Lstat(backupName); err == nil { - cleanupErr := cleanupRootEntries(root, publishEntryNames(publishEntries)) - rollbackErr := restoreRootBackup(root, backups, nil) - return errors.Join( - fmt.Errorf("backup path already exists: %s", backupName), - rollbackFailure(rollbackErr), - hiddenCleanupFailure(cleanupErr), - ) - } else if !os.IsNotExist(err) { - cleanupErr := cleanupRootEntries(root, publishEntryNames(publishEntries)) - rollbackErr := restoreRootBackup(root, backups, nil) - return errors.Join( - fmt.Errorf("could not inspect backup path %s: %w", backupName, err), - rollbackFailure(rollbackErr), - hiddenCleanupFailure(cleanupErr), - ) - } - if err := renameRootEntry(root, entry.Name(), backupName); err != nil { - rollbackErr := restoreRootBackup(root, backups, nil) - cleanupErr := cleanupRootEntries(root, publishEntryNames(publishEntries)) - return errors.Join( - fmt.Errorf("could not move %s aside: %w", entry.Name(), err), - rollbackFailure(rollbackErr), - hiddenCleanupFailure(cleanupErr), - ) - } - backups = append(backups, rootBackupEntry{original: entry.Name(), backup: backupName}) - } - - var movedIn []string - for _, entry := range publishEntries { - if err := renameRootEntry(root, entry.hidden, entry.final); err != nil { - rollbackErr := restoreRootBackup(root, backups, movedIn) - cleanupErr := cleanupRootEntries(root, publishEntryNames(publishEntries)) - return errors.Join( - fmt.Errorf("could not publish %s: %w", entry.final, err), - rollbackFailure(rollbackErr), - hiddenCleanupFailure(cleanupErr), - ) - } - movedIn = append(movedIn, entry.final) - } - - var cleanupErrors []error - for _, entry := range backups { - if err := removeRootBackup(root, entry.backup); err != nil { - cleanupErrors = append(cleanupErrors, fmt.Errorf("retained backup %s: %w", entry.backup, err)) - } - } - if err := errors.Join(cleanupErrors...); err != nil { - return &committedUpdateWarning{err: err} - } - return nil -} - -func copyRootEntry(src, dest *safepaths.Root, srcName, destName string) error { - info, err := src.Lstat(srcName) - if err != nil { - return err - } - if info.IsDir() { - if err := dest.MkdirAll(destName, 0o755); err != nil { - return err - } - entries, err := src.ReadDir(srcName) - if err != nil { - return err - } - for _, entry := range entries { - if err := copyRootEntry( - src, - dest, - filepath.Join(srcName, entry.Name()), - filepath.Join(destName, entry.Name()), - ); err != nil { - return err - } - } - return nil - } - - input, err := src.Open(srcName) - if err != nil { - return err - } - defer input.Close() - output, err := dest.Create(destName, info.Mode().Perm(), false) - if err != nil { - return err - } - if _, err := copyRootFile(output, input); err != nil { - _ = output.Close() - return err - } - return output.Close() -} - -func restoreRootBackup(root *safepaths.Root, backups []rootBackupEntry, movedIn []string) error { - var restoreErrors []error - for _, name := range movedIn { - if err := root.RemoveAll(name); err != nil { - restoreErrors = append(restoreErrors, fmt.Errorf("could not remove new entry %s: %w", name, err)) - } - } - for _, entry := range backups { - if err := renameRootEntry(root, entry.backup, entry.original); err != nil { - restoreErrors = append(restoreErrors, fmt.Errorf("could not restore %s from %s: %w", entry.original, entry.backup, err)) - } - } - return errors.Join(restoreErrors...) -} - -func publishEntryNames(entries []rootPublishEntry) []string { - names := make([]string, len(entries)) - for i, entry := range entries { - names[i] = entry.hidden - } - return names -} - -func cleanupRootEntries(root *safepaths.Root, names []string) error { - var cleanupErrors []error - for _, name := range names { - if err := removeRootHidden(root, name); err != nil { - cleanupErrors = append(cleanupErrors, fmt.Errorf("could not remove hidden entry %s: %w", name, err)) - } - } - return errors.Join(cleanupErrors...) -} - -func rollbackFailure(err error) error { - if err == nil { - return nil - } - return fmt.Errorf("rollback failed; recoverable backups were retained: %w", err) -} - -func hiddenCleanupFailure(err error) error { - if err == nil { - return nil - } - return fmt.Errorf("hidden prepared entries were retained: %w", err) -} - -func addCommittedCleanupWarning(result error, committed bool, cleanupErr error) error { - if cleanupErr == nil { - return result - } - if !committed { - return errors.Join(result, cleanupErr) - } - - var warning *committedUpdateWarning - if errors.As(result, &warning) { - return &committedUpdateWarning{err: errors.Join(warning.err, cleanupErr)} - } - return &committedUpdateWarning{err: cleanupErr} -} - // swapDirectoryContents replaces the entries inside dest with the entries // inside src, preserving dest's inode. It first moves every existing entry // into a sibling backup directory, then moves the staged entries into dest. diff --git a/pkg/cmd/skills/update/update_test.go b/pkg/cmd/skills/update/update_test.go index ac82a81c952..8046c63340c 100644 --- a/pkg/cmd/skills/update/update_test.go +++ b/pkg/cmd/skills/update/update_test.go @@ -2,11 +2,9 @@ package update import ( "fmt" - "io" "net/http" "os" "path/filepath" - "strings" "testing" "github.com/MakeNowJust/heredoc" @@ -14,7 +12,6 @@ import ( "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/prompter" - "github.com/cli/cli/v2/internal/safepaths" "github.com/cli/cli/v2/internal/skills/registry" "github.com/cli/cli/v2/pkg/cmdutil" @@ -314,21 +311,15 @@ func TestScanAllAgentsDeduplicatesSharedProjectDirs(t *testing.T) { } func TestUpdateRun(t *testing.T) { - var projectSymlinkTarget string - var projectSwapRoot string - var projectSwapMovedRoot string - var projectSwapOutside string - tests := []struct { - name string - setup func(t *testing.T, dir string) - stubs func(reg *httpmock.Registry) - opts func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions - verify func(t *testing.T, dir string) - wantErr string - wantStderr string - wantStderrAll []string - wantStdout string + name string + setup func(t *testing.T, dir string) + stubs func(reg *httpmock.Registry) + opts func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions + verify func(t *testing.T, dir string) + wantErr string + wantStderr string + wantStdout string }{ { name: "scans all agents when no --dir is set", @@ -717,305 +708,6 @@ func TestUpdateRun(t *testing.T) { }, wantStdout: "Updated code-review", }, - { - name: "project update rejects ancestor symlink", - setup: func(t *testing.T, dir string) { - t.Helper() - t.Setenv("HOME", t.TempDir()) - t.Setenv("USERPROFILE", os.Getenv("HOME")) - projectSymlinkTarget = t.TempDir() - skillDir := filepath.Join(projectSymlinkTarget, "skills", "code-review") - require.NoError(t, os.MkdirAll(skillDir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` - --- - name: code-review - metadata: - github-repo: https://github.com/monalisa/octocat-skills - github-tree-sha: oldsha000 - github-path: skills/code-review - --- - Old content - `)), 0o644)) - require.NoError(t, os.Symlink(projectSymlinkTarget, filepath.Join(dir, ".agents"))) - }, - stubs: func(reg *httpmock.Registry) { - reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), - httpmock.StringResponse(`{"tag_name": "v3.0.0"}`)) - reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags%2Fv3.0.0"), - httpmock.StringResponse(`{"object": {"sha": "newcommit789", "type": "commit"}}`)) - reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newcommit789"), - httpmock.StringResponse(`{"sha": "newcommit789", "tree": [{"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "newblob1"}, {"path": "skills/code-review", "type": "tree", "sha": "newsha999"}, {"path": "skills", "type": "tree", "sha": "treeshaZ"}], "truncated": false}`)) - }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { - ios.SetStdoutTTY(false) - return &UpdateOptions{ - IO: ios, - Config: func() (gh.Config, error) { return config.NewMockConfig(), nil }, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - }, - GitClient: &git.Client{RepoDir: dir}, - All: true, - Force: true, - } - }, - verify: func(t *testing.T, _ string) { - t.Helper() - content, err := os.ReadFile(filepath.Join(projectSymlinkTarget, "skills", "code-review", "SKILL.md")) - require.NoError(t, err) - assert.Contains(t, string(content), "Old content") - }, - wantErr: "SilentError", - wantStderr: "symbolic link", - }, - { - name: "project update stays rooted when ancestor changes", - setup: func(t *testing.T, dir string) { - t.Helper() - t.Setenv("HOME", t.TempDir()) - t.Setenv("USERPROFILE", os.Getenv("HOME")) - projectSwapRoot = dir - projectSwapMovedRoot = filepath.Join(dir, ".agents-moved") - projectSwapOutside = t.TempDir() - - skillDir := filepath.Join(dir, ".agents", "skills", "code-review") - require.NoError(t, os.MkdirAll(skillDir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` - --- - name: code-review - metadata: - github-repo: https://github.com/monalisa/octocat-skills - github-tree-sha: oldsha000 - github-path: skills/code-review - --- - Old content - `)), 0o644)) - - outsideSkillDir := filepath.Join(projectSwapOutside, "skills", "code-review") - require.NoError(t, os.MkdirAll(outsideSkillDir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(outsideSkillDir, "SKILL.md"), []byte("outside content"), 0o644)) - }, - stubs: func(reg *httpmock.Registry) { - reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), - httpmock.StringResponse(`{"tag_name": "v3.0.0"}`)) - reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags%2Fv3.0.0"), - httpmock.StringResponse(`{"object": {"sha": "newcommit789", "type": "commit"}}`)) - reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newcommit789"), - httpmock.StringResponse(`{"sha": "newcommit789", "tree": [{"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "newblob1"}, {"path": "skills/code-review", "type": "tree", "sha": "newsha999"}, {"path": "skills", "type": "tree", "sha": "treeshaZ"}], "truncated": false}`)) - reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newsha999"), - httpmock.StringResponse(`{"sha": "newsha999", "tree": [{"path": "SKILL.md", "type": "blob", "sha": "newblob1", "size": 20}], "truncated": false}`)) - reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/newblob1"), - func(req *http.Request) (*http.Response, error) { - require.NoError(t, os.Rename(filepath.Join(projectSwapRoot, ".agents"), projectSwapMovedRoot)) - require.NoError(t, os.Symlink(projectSwapOutside, filepath.Join(projectSwapRoot, ".agents"))) - return httpmock.StringResponse(fmt.Sprintf(`{"sha": "newblob1", "encoding": "base64", "content": "%s"}`, - "IyBDb2RlIFJldmlldyBVcGRhdGVk"))(req) - }) - }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { - ios.SetStdoutTTY(false) - return &UpdateOptions{ - IO: ios, - Config: func() (gh.Config, error) { return config.NewMockConfig(), nil }, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - }, - GitClient: &git.Client{RepoDir: dir}, - All: true, - Force: true, - } - }, - verify: func(t *testing.T, _ string) { - t.Helper() - outside, err := os.ReadFile(filepath.Join(projectSwapOutside, "skills", "code-review", "SKILL.md")) - require.NoError(t, err) - assert.Equal(t, "outside content", string(outside)) - - updatedDir := filepath.Join(projectSwapMovedRoot, "skills", "code-review") - updated, err := os.ReadFile(filepath.Join(updatedDir, "SKILL.md")) - require.NoError(t, err) - assert.NotContains(t, string(updated), "Old content") - entries, err := os.ReadDir(updatedDir) - require.NoError(t, err) - require.Len(t, entries, 1) - }, - wantStdout: "Updated code-review", - }, - { - name: "project update publishes nested staged subtree", - setup: func(t *testing.T, dir string) { - t.Helper() - t.Setenv("HOME", t.TempDir()) - t.Setenv("USERPROFILE", os.Getenv("HOME")) - skillDir := filepath.Join(dir, ".agents", "skills", "code-review") - require.NoError(t, os.MkdirAll(skillDir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` - --- - name: code-review - metadata: - github-repo: https://github.com/monalisa/octocat-skills - github-tree-sha: oldsha000 - github-path: skills/code-review - --- - Old content - `)), 0o644)) - require.NoError(t, os.WriteFile(filepath.Join(skillDir, "stale.txt"), []byte("stale"), 0o644)) - }, - stubs: func(reg *httpmock.Registry) { - reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), - httpmock.StringResponse(`{"tag_name": "v3.0.0"}`)) - reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags%2Fv3.0.0"), - httpmock.StringResponse(`{"object": {"sha": "newcommit789", "type": "commit"}}`)) - reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newcommit789"), - httpmock.StringResponse(`{"sha": "newcommit789", "tree": [{"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "newblob1"}, {"path": "skills/code-review/templates/config.txt", "type": "blob", "sha": "newblob2"}, {"path": "skills/code-review", "type": "tree", "sha": "newsha999"}, {"path": "skills", "type": "tree", "sha": "treeshaZ"}], "truncated": false}`)) - reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newsha999"), - httpmock.StringResponse(`{"sha": "newsha999", "tree": [{"path": "SKILL.md", "type": "blob", "sha": "newblob1", "size": 20}, {"path": "templates/config.txt", "type": "blob", "sha": "newblob2", "size": 6}], "truncated": false}`)) - reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/newblob1"), - httpmock.StringResponse(fmt.Sprintf(`{"sha": "newblob1", "encoding": "base64", "content": "%s"}`, - "IyBDb2RlIFJldmlldyBVcGRhdGVk"))) - reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/newblob2"), - httpmock.StringResponse(fmt.Sprintf(`{"sha": "newblob2", "encoding": "base64", "content": "%s"}`, - "bmVzdGVk"))) - }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { - ios.SetStdoutTTY(false) - return &UpdateOptions{ - IO: ios, - Config: func() (gh.Config, error) { return config.NewMockConfig(), nil }, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - }, - GitClient: &git.Client{RepoDir: dir}, - All: true, - Force: true, - } - }, - verify: func(t *testing.T, dir string) { - t.Helper() - skillDir := filepath.Join(dir, ".agents", "skills", "code-review") - updated, err := os.ReadFile(filepath.Join(skillDir, "SKILL.md")) - require.NoError(t, err) - assert.NotContains(t, string(updated), "Old content") - nested, err := os.ReadFile(filepath.Join(skillDir, "templates", "config.txt")) - require.NoError(t, err) - assert.Equal(t, "nested", string(nested)) - assert.NoFileExists(t, filepath.Join(skillDir, "stale.txt")) - entries, err := os.ReadDir(skillDir) - require.NoError(t, err) - require.Len(t, entries, 2) - }, - wantStdout: "Updated code-review", - }, - { - name: "project update warns after committed cleanup failures", - setup: func(t *testing.T, dir string) { - t.Helper() - t.Setenv("HOME", t.TempDir()) - t.Setenv("USERPROFILE", os.Getenv("HOME")) - skillDir := filepath.Join(dir, ".agents", "skills", "code-review") - require.NoError(t, os.MkdirAll(skillDir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` - --- - name: code-review - metadata: - github-repo: https://github.com/monalisa/octocat-skills - github-tree-sha: oldsha000 - github-path: skills/code-review - --- - Old content - `)), 0o644)) - - originalRemove := removeRootBackup - originalStagingRemove := removeRootStaging - t.Cleanup(func() { - removeRootBackup = originalRemove - removeRootStaging = originalStagingRemove - }) - removeRootBackup = func(_ *safepaths.Root, _ string) error { - return fmt.Errorf("backup cleanup blocked") - } - removeRootStaging = func(_ *safepaths.Root, _ string) error { - return fmt.Errorf("staging cleanup blocked") - } - }, - stubs: func(reg *httpmock.Registry) { - reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), - httpmock.StringResponse(`{"tag_name": "v3.0.0"}`)) - reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags%2Fv3.0.0"), - httpmock.StringResponse(`{"object": {"sha": "newcommit789", "type": "commit"}}`)) - reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newcommit789"), - httpmock.StringResponse(`{"sha": "newcommit789", "tree": [{"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "newblob1"}, {"path": "skills/code-review", "type": "tree", "sha": "newsha999"}, {"path": "skills", "type": "tree", "sha": "treeshaZ"}], "truncated": false}`)) - reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newsha999"), - httpmock.StringResponse(`{"sha": "newsha999", "tree": [{"path": "SKILL.md", "type": "blob", "sha": "newblob1", "size": 20}], "truncated": false}`)) - reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/newblob1"), - httpmock.StringResponse(fmt.Sprintf(`{"sha": "newblob1", "encoding": "base64", "content": "%s"}`, - "IyBDb2RlIFJldmlldyBVcGRhdGVk"))) - }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { - ios.SetStdoutTTY(false) - return &UpdateOptions{ - IO: ios, - Config: func() (gh.Config, error) { return config.NewMockConfig(), nil }, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - }, - GitClient: &git.Client{RepoDir: dir}, - All: true, - Force: true, - } - }, - verify: func(t *testing.T, dir string) { - t.Helper() - skillDir := filepath.Join(dir, ".agents", "skills", "code-review") - updated, err := os.ReadFile(filepath.Join(skillDir, "SKILL.md")) - require.NoError(t, err) - assert.NotContains(t, string(updated), "Old content") - - entries, err := os.ReadDir(skillDir) - require.NoError(t, err) - var backupPath string - var stagingPath string - for _, entry := range entries { - if strings.Contains(entry.Name(), ".backup-") { - backupPath = filepath.Join(skillDir, entry.Name()) - } - if strings.HasPrefix(entry.Name(), ".gh-skill-update-") && - !strings.Contains(entry.Name(), ".backup-") && - !strings.Contains(entry.Name(), ".publish-") { - stagingPath = filepath.Join(skillDir, entry.Name()) - } - } - require.NotEmpty(t, backupPath) - backup, err := os.ReadFile(backupPath) - require.NoError(t, err) - assert.Contains(t, string(backup), "Old content") - require.DirExists(t, stagingPath) - staged, err := os.ReadFile(filepath.Join(stagingPath, "SKILL.md")) - require.NoError(t, err) - assert.NotContains(t, string(staged), "Old content") - }, - wantStdout: "Updated code-review", - wantStderrAll: []string{"retained backup", "retained staging directory"}, - }, { name: "namespaced skill with --dir updates in-place", setup: func(t *testing.T, dir string) { @@ -1541,9 +1233,6 @@ func TestUpdateRun(t *testing.T) { if tt.wantStderr != "" { assert.Contains(t, stderr.String(), tt.wantStderr) } - for _, want := range tt.wantStderrAll { - assert.Contains(t, stderr.String(), want) - } if tt.wantStdout != "" { assert.Contains(t, stdout.String(), tt.wantStdout) } @@ -1632,199 +1321,3 @@ func TestSwapDirectoryContents_PreservesDestInode(t *testing.T) { require.NoError(t, err) assert.Equal(t, "new", string(content), "staged content must be installed") } - -func TestSwapRootDirectoryContents_RetainsBackupOnRollbackFailure(t *testing.T) { - rootDir := t.TempDir() - root, err := safepaths.OpenRoot(rootDir) - require.NoError(t, err) - defer root.Close() - require.NoError(t, root.WriteFile("SKILL.md", []byte("original"), 0o644, false)) - - stagingName, stagingRoot, err := root.TempDir(".stage-") - require.NoError(t, err) - defer stagingRoot.Close() - require.NoError(t, stagingRoot.WriteFile("SKILL.md", []byte("replacement"), 0o644, false)) - - originalRename := renameRootEntry - t.Cleanup(func() { renameRootEntry = originalRename }) - renameRootEntry = func(root *safepaths.Root, oldName, newName string) error { - if strings.HasPrefix(oldName, stagingName+".publish-") { - return fmt.Errorf("publish blocked") - } - if strings.HasPrefix(oldName, stagingName+".backup-") { - return fmt.Errorf("restore blocked") - } - return root.Rename(oldName, newName) - } - - err = swapRootDirectoryContents(root, stagingRoot, stagingName) - require.ErrorContains(t, err, "publish blocked") - require.ErrorContains(t, err, "rollback failed; recoverable backups were retained") - require.ErrorContains(t, err, "restore blocked") - assert.NoFileExists(t, filepath.Join(rootDir, "SKILL.md")) - - entries, err := root.ReadDir(".") - require.NoError(t, err) - var backupName string - for _, entry := range entries { - if strings.HasPrefix(entry.Name(), stagingName+".backup-") { - backupName = entry.Name() - break - } - } - require.NotEmpty(t, backupName) - content, err := os.ReadFile(filepath.Join(rootDir, backupName)) - require.NoError(t, err) - assert.Equal(t, "original", string(content)) -} - -func TestSwapRootDirectoryContents_DoesNotExposePartialFiles(t *testing.T) { - rootDir := t.TempDir() - root, err := safepaths.OpenRoot(rootDir) - require.NoError(t, err) - defer root.Close() - require.NoError(t, root.WriteFile("SKILL.md", []byte("original"), 0o644, false)) - - stagingName, stagingRoot, err := root.TempDir(".stage-") - require.NoError(t, err) - defer stagingRoot.Close() - require.NoError(t, stagingRoot.WriteFile("SKILL.md", []byte("replacement"), 0o644, false)) - - originalCopy := copyRootFile - t.Cleanup(func() { copyRootFile = originalCopy }) - copyRootFile = func(dst io.Writer, src io.Reader) (int64, error) { - buffer := make([]byte, 3) - n, readErr := src.Read(buffer) - require.NoError(t, readErr) - written, writeErr := dst.Write(buffer[:n]) - require.NoError(t, writeErr) - - visible, err := os.ReadFile(filepath.Join(rootDir, "SKILL.md")) - require.NoError(t, err) - assert.Equal(t, "original", string(visible)) - return int64(written), fmt.Errorf("copy interrupted") - } - - err = swapRootDirectoryContents(root, stagingRoot, stagingName) - require.ErrorContains(t, err, "copy interrupted") - visible, err := os.ReadFile(filepath.Join(rootDir, "SKILL.md")) - require.NoError(t, err) - assert.Equal(t, "original", string(visible)) - - entries, err := root.ReadDir(".") - require.NoError(t, err) - for _, entry := range entries { - assert.NotContains(t, entry.Name(), ".publish-") - } -} - -func TestSwapRootDirectoryContents_CleansPreparedEntriesOnSecondNameFailure(t *testing.T) { - rootDir := t.TempDir() - root, err := safepaths.OpenRoot(rootDir) - require.NoError(t, err) - defer root.Close() - - stagingName, stagingRoot, err := root.TempDir(".stage-") - require.NoError(t, err) - defer stagingRoot.Close() - require.NoError(t, stagingRoot.WriteFile("first.txt", []byte("first"), 0o644, false)) - require.NoError(t, stagingRoot.WriteFile("second.txt", []byte("second"), 0o644, false)) - collision := stagingName + ".publish-1" - require.NoError(t, root.WriteFile(collision, []byte("existing"), 0o644, false)) - - originalRemove := removeRootHidden - t.Cleanup(func() { removeRootHidden = originalRemove }) - removeRootHidden = func(_ *safepaths.Root, name string) error { - if name == stagingName+".publish-0" { - return fmt.Errorf("cleanup blocked for %s", name) - } - return originalRemove(root, name) - } - - err = swapRootDirectoryContents(root, stagingRoot, stagingName) - require.ErrorContains(t, err, "publish path already exists") - require.ErrorContains(t, err, "hidden prepared entries were retained") - require.ErrorContains(t, err, stagingName+".publish-0") - assert.FileExists(t, filepath.Join(rootDir, stagingName+".publish-0")) - content, err := os.ReadFile(filepath.Join(rootDir, collision)) - require.NoError(t, err) - assert.Equal(t, "existing", string(content)) - assert.NoFileExists(t, filepath.Join(rootDir, "first.txt")) - assert.NoFileExists(t, filepath.Join(rootDir, "second.txt")) -} - -func TestSwapRootDirectoryContents_RestoresAfterDestructiveFailure(t *testing.T) { - tests := []struct { - name string - configure func(stagingName string) - }{ - { - name: "backup move fails after first original", - configure: func(stagingName string) { - originalRename := renameRootEntry - renameRootEntry = func(root *safepaths.Root, oldName, newName string) error { - if oldName == "b.txt" && strings.HasPrefix(newName, stagingName+".backup-") { - return fmt.Errorf("backup move failed") - } - return originalRename(root, oldName, newName) - } - }, - }, - { - name: "publication fails after first new entry", - configure: func(stagingName string) { - originalRename := renameRootEntry - published := 0 - renameRootEntry = func(root *safepaths.Root, oldName, newName string) error { - if strings.HasPrefix(oldName, stagingName+".publish-") { - published++ - if published == 2 { - return fmt.Errorf("publication failed") - } - } - return originalRename(root, oldName, newName) - } - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - rootDir := t.TempDir() - root, err := safepaths.OpenRoot(rootDir) - require.NoError(t, err) - defer root.Close() - require.NoError(t, root.WriteFile("a.txt", []byte("original-a"), 0o644, false)) - require.NoError(t, root.WriteFile("b.txt", []byte("original-b"), 0o644, false)) - - stagingName, stagingRoot, err := root.TempDir(".stage-") - require.NoError(t, err) - defer stagingRoot.Close() - require.NoError(t, stagingRoot.WriteFile("a.txt", []byte("new-a"), 0o644, false)) - require.NoError(t, stagingRoot.WriteFile("b.txt", []byte("new-b"), 0o644, false)) - require.NoError(t, stagingRoot.WriteFile("c.txt", []byte("new-c"), 0o644, false)) - - originalRename := renameRootEntry - t.Cleanup(func() { renameRootEntry = originalRename }) - tt.configure(stagingName) - - err = swapRootDirectoryContents(root, stagingRoot, stagingName) - require.Error(t, err) - a, err := os.ReadFile(filepath.Join(rootDir, "a.txt")) - require.NoError(t, err) - assert.Equal(t, "original-a", string(a)) - b, err := os.ReadFile(filepath.Join(rootDir, "b.txt")) - require.NoError(t, err) - assert.Equal(t, "original-b", string(b)) - assert.NoFileExists(t, filepath.Join(rootDir, "c.txt")) - - entries, err := root.ReadDir(".") - require.NoError(t, err) - for _, entry := range entries { - assert.NotContains(t, entry.Name(), ".backup-") - assert.NotContains(t, entry.Name(), ".publish-") - } - require.Len(t, entries, 3) - }) - } -} From a89d820f6b6df044f900712ab1551eca608df553 Mon Sep 17 00:00:00 2001 From: BagToad <47394200+BagToad@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:16:37 -0600 Subject: [PATCH 06/14] Document rooted filesystem ownership Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- internal/safepaths/root.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/safepaths/root.go b/internal/safepaths/root.go index 47393d26c67..43ebaf70f9c 100644 --- a/internal/safepaths/root.go +++ b/internal/safepaths/root.go @@ -11,6 +11,9 @@ import ( // Root restricts filesystem operations to a directory tree and refuses to // traverse symbolic links below that directory. +// +// The caller owns each returned Root and must call Close when it is +// no longer needed. type Root struct { root *os.Root openRoot func(*os.Root, string) (*os.Root, error) From f0215ad6776bdf8f4eabc0b6f193809d0619d171 Mon Sep 17 00:00:00 2001 From: BagToad <47394200+BagToad@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:29:40 -0600 Subject: [PATCH 07/14] Allow callers to choose directory creation modes Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- internal/safepaths/link_windows_test.go | 10 +- internal/safepaths/root.go | 30 +++--- internal/safepaths/root_mode_unix_test.go | 102 +++++++++++++++++++ internal/safepaths/root_race_test.go | 2 +- internal/safepaths/root_special_unix_test.go | 6 +- internal/safepaths/root_test.go | 8 +- internal/safepaths/root_validation_test.go | 4 +- internal/zip/zip.go | 2 +- pkg/cmd/attestation/download/metadata.go | 2 +- pkg/cmd/copilot/copilot.go | 2 +- pkg/cmd/release/download/download.go | 4 +- pkg/cmd/repo/read-file/read_file.go | 4 +- pkg/cmd/run/download/download_test.go | 2 +- 13 files changed, 143 insertions(+), 35 deletions(-) create mode 100644 internal/safepaths/root_mode_unix_test.go diff --git a/internal/safepaths/link_windows_test.go b/internal/safepaths/link_windows_test.go index b80af0669ea..484d26a1d1d 100644 --- a/internal/safepaths/link_windows_test.go +++ b/internal/safepaths/link_windows_test.go @@ -75,7 +75,7 @@ func TestCreateReplaceFailsClosedForNonLinkReparse(t *testing.T) { return "", os.ErrNotExist } - file, err := root.Create("entry", 0o644, true) + file, err := root.Create("entry", 0o644, 0o755, true) assert.Nil(t, file) require.ErrorIs(t, err, errUnsupportedNonLinkReparse) after, err := os.Stat(path) @@ -98,11 +98,11 @@ func TestRootRejectsJunction(t *testing.T) { require.NoError(t, err) defer root.Close() - err = root.WriteFile(filepath.Join("junction", "file.txt"), []byte("content"), 0o644, false) + err = root.WriteFile(filepath.Join("junction", "file.txt"), []byte("content"), 0o644, 0o755, false) require.ErrorContains(t, err, "symbolic link") assert.NoFileExists(t, filepath.Join(target, "file.txt")) - err = root.WriteFile("junction", []byte("content"), 0o644, true) + err = root.WriteFile("junction", []byte("content"), 0o644, 0o755, true) require.Error(t, err) info, err := os.Lstat(junction) require.NoError(t, err) @@ -122,7 +122,7 @@ func TestOpenFileRejectsWindowsTrailingSeparator(t *testing.T) { root := t.TempDir() path := filepath.Join(root, "output") + tt.separator - file, err := OpenFile(path, 0o644, false) + file, err := OpenFile(path, 0o644, 0o755, false) if file != nil { _ = file.Close() } @@ -148,7 +148,7 @@ func TestRootCreateRejectsWindowsTrailingSeparator(t *testing.T) { require.NoError(t, err) defer root.Close() - err = root.WriteFile("output"+tt.separator, []byte("content"), 0o644, false) + err = root.WriteFile("output"+tt.separator, []byte("content"), 0o644, 0o755, false) require.ErrorContains(t, err, "ends in a separator") _, statErr := os.Lstat(filepath.Join(rootDir, "output")) require.True(t, os.IsNotExist(statErr)) diff --git a/internal/safepaths/root.go b/internal/safepaths/root.go index 43ebaf70f9c..c18cd07c95a 100644 --- a/internal/safepaths/root.go +++ b/internal/safepaths/root.go @@ -70,19 +70,20 @@ func OpenRootDir(path string, perm os.FileMode) (*Root, error) { // OpenFile creates a file without traversing symbolic links below its rooted // parent. Creation is always exclusive, including when replace is false. -func OpenFile(path string, perm os.FileMode, replace bool) (*os.File, error) { +// filePerm and dirPerm specify creation modes before applying the umask. +func OpenFile(path string, filePerm, dirPerm os.FileMode, replace bool) (*os.File, error) { if hasTrailingPathSeparator(path) { return nil, trailingPathSeparatorError(path) } parent := filepath.Dir(path) - if err := os.MkdirAll(parent, 0o755); err != nil { + if err := os.MkdirAll(parent, dirPerm); err != nil { return nil, err } root, err := OpenRoot(parent) if err != nil { return nil, err } - file, createErr := root.Create(filepath.Base(path), perm, replace) + file, createErr := root.Create(filepath.Base(path), filePerm, dirPerm, replace) closeErr := root.Close() if createErr != nil { return nil, createErr @@ -95,8 +96,9 @@ func OpenFile(path string, perm os.FileMode, replace bool) (*os.File, error) { } // WriteFile writes a complete file without traversing symbolic links. -func WriteFile(path string, data []byte, perm os.FileMode, replace bool) (err error) { - file, err := OpenFile(path, perm, replace) +// Creation permissions follow [OpenFile]. +func WriteFile(path string, data []byte, filePerm, dirPerm os.FileMode, replace bool) (err error) { + file, err := OpenFile(path, filePerm, dirPerm, replace) if err != nil { return err } @@ -160,7 +162,9 @@ func (r *Root) Validate(name string) error { // Create creates a new file beneath the root. Creation is exclusive unless an // existing regular file is safely opened and truncated for replacement. -func (r *Root) Create(name string, perm os.FileMode, replace bool) (*os.File, error) { +// filePerm and dirPerm specify creation modes before applying the umask. +// Existing regular files and directories retain their permissions. +func (r *Root) Create(name string, filePerm, dirPerm os.FileMode, replace bool) (*os.File, error) { if hasTrailingPathSeparator(name) { return nil, trailingPathSeparatorError(name) } @@ -171,7 +175,7 @@ func (r *Root) Create(name string, perm os.FileMode, replace bool) (*os.File, er if local == "." { return nil, fmt.Errorf("cannot create root directory as a file") } - parent, err := r.openDirectories(filepath.Dir(local), 0o755) + parent, err := r.openDirectories(filepath.Dir(local), dirPerm) if err != nil { return nil, err } @@ -224,12 +228,13 @@ func (r *Root) Create(name string, perm os.FileMode, replace bool) (*os.File, er } } - return parent.OpenFile(base, os.O_WRONLY|os.O_CREATE|os.O_EXCL, perm) + return parent.OpenFile(base, os.O_WRONLY|os.O_CREATE|os.O_EXCL, filePerm) } // WriteFile writes a complete file beneath the root. -func (r *Root) WriteFile(name string, data []byte, perm os.FileMode, replace bool) (err error) { - file, err := r.Create(name, perm, replace) +// Creation permissions follow [Root.Create]. +func (r *Root) WriteFile(name string, data []byte, filePerm, dirPerm os.FileMode, replace bool) (err error) { + file, err := r.Create(name, filePerm, dirPerm, replace) if err != nil { return err } @@ -244,8 +249,9 @@ func (r *Root) WriteFile(name string, data []byte, perm os.FileMode, replace boo } // CopyFile copies content into a file beneath the root. -func (r *Root) CopyFile(name string, src io.Reader, perm os.FileMode, replace bool) (err error) { - file, err := r.Create(name, perm, replace) +// Creation permissions follow [Root.Create]. +func (r *Root) CopyFile(name string, src io.Reader, filePerm, dirPerm os.FileMode, replace bool) (err error) { + file, err := r.Create(name, filePerm, dirPerm, replace) if err != nil { return err } diff --git a/internal/safepaths/root_mode_unix_test.go b/internal/safepaths/root_mode_unix_test.go new file mode 100644 index 00000000000..0ac081914b4 --- /dev/null +++ b/internal/safepaths/root_mode_unix_test.go @@ -0,0 +1,102 @@ +//go:build unix + +package safepaths_test + +import ( + "os" + "path/filepath" + "strings" + "syscall" + "testing" + + "github.com/cli/cli/v2/internal/safepaths" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFileCreationModes(t *testing.T) { + tests := []struct { + name string + filePerm os.FileMode + dirPerm os.FileMode + umask int + replace bool + wantFilePerm os.FileMode + wantDirPerm os.FileMode + }{ + { + name: "separate file and directory modes", + filePerm: 0o600, + dirPerm: 0o700, + wantFilePerm: 0o600, + wantDirPerm: 0o700, + }, + { + name: "creation respects umask", + filePerm: 0o666, + dirPerm: 0o777, + umask: 0o027, + wantFilePerm: 0o640, + wantDirPerm: 0o750, + }, + { + name: "replacement preserves existing modes", + filePerm: 0o666, + dirPerm: 0o777, + replace: true, + wantFilePerm: 0o640, + wantDirPerm: 0o750, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Umask is process-wide, so these tests must stay sequential. + oldUmask := syscall.Umask(tt.umask) + t.Cleanup(func() { syscall.Umask(oldUmask) }) + + for _, operation := range []string{"OpenFile", "WriteFile", "Root.Create", "Root.WriteFile", "Root.CopyFile"} { + t.Run(operation, func(t *testing.T) { + rootDir := t.TempDir() + name := filepath.Join("one", "two", "file.txt") + path := filepath.Join(rootDir, name) + if tt.replace { + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o750)) + require.NoError(t, os.WriteFile(path, []byte("old"), 0o640)) + } + + root, err := safepaths.OpenRoot(rootDir) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, root.Close()) }) + + var file *os.File + switch operation { + case "OpenFile": + file, err = safepaths.OpenFile(path, tt.filePerm, tt.dirPerm, tt.replace) + case "WriteFile": + err = safepaths.WriteFile(path, []byte("new"), tt.filePerm, tt.dirPerm, tt.replace) + case "Root.Create": + file, err = root.Create(name, tt.filePerm, tt.dirPerm, tt.replace) + case "Root.WriteFile": + err = root.WriteFile(name, []byte("new"), tt.filePerm, tt.dirPerm, tt.replace) + case "Root.CopyFile": + err = root.CopyFile(name, strings.NewReader("new"), tt.filePerm, tt.dirPerm, tt.replace) + } + require.NoError(t, err) + if file != nil { + require.NoError(t, file.Close()) + } + + info, err := os.Stat(path) + require.NoError(t, err) + assert.Equal(t, tt.wantFilePerm, info.Mode().Perm()) + for _, dir := range []string{"one", filepath.Join("one", "two")} { + info, err := os.Stat(filepath.Join(rootDir, dir)) + require.NoError(t, err) + assert.Equal(t, tt.wantDirPerm, info.Mode().Perm(), dir) + } + }) + } + }) + } +} diff --git a/internal/safepaths/root_race_test.go b/internal/safepaths/root_race_test.go index a2a59593885..7c79d2a39d0 100644 --- a/internal/safepaths/root_race_test.go +++ b/internal/safepaths/root_race_test.go @@ -34,7 +34,7 @@ func TestRootRejectsDirectorySwapDuringOpen(t *testing.T) { return defaultOpenRoot(parent, name) } - err = root.WriteFile(filepath.Join("nested", "file.txt"), []byte("content"), 0o644, false) + err = root.WriteFile(filepath.Join("nested", "file.txt"), []byte("content"), 0o644, 0o755, false) require.Error(t, err) assert.NoFileExists(t, filepath.Join(redirectedDir, "file.txt")) assert.NoFileExists(t, filepath.Join(rootDir, "moved", "file.txt")) diff --git a/internal/safepaths/root_special_unix_test.go b/internal/safepaths/root_special_unix_test.go index 9d74782a862..d6a7437733d 100644 --- a/internal/safepaths/root_special_unix_test.go +++ b/internal/safepaths/root_special_unix_test.go @@ -22,7 +22,7 @@ func TestCreateReplacePreservesDevice(t *testing.T) { root, err := OpenRoot("/dev") require.NoError(t, err) defer root.Close() - file, err := root.Create("null", 0o644, true) + file, err := root.Create("null", 0o644, 0o755, true) require.NoError(t, err) _, err = file.Write([]byte("content")) require.NoError(t, err) @@ -47,7 +47,7 @@ func TestCreateReplaceDoesNotUnlinkFIFO(t *testing.T) { result := make(chan error, 1) go func() { - file, err := root.Create("fifo", 0o644, true) + file, err := root.Create("fifo", 0o644, 0o755, true) if file != nil { _ = file.Close() } @@ -86,7 +86,7 @@ func TestCreateReplaceDoesNotBlockOnFIFOSwap(t *testing.T) { } start := time.Now() - file, err := root.Create("target", 0o644, true) + file, err := root.Create("target", 0o644, 0o755, true) assert.Nil(t, file) require.Error(t, err) assert.Less(t, time.Since(start), time.Second) diff --git a/internal/safepaths/root_test.go b/internal/safepaths/root_test.go index 07febc4b57f..50bfb61f2b8 100644 --- a/internal/safepaths/root_test.go +++ b/internal/safepaths/root_test.go @@ -188,7 +188,7 @@ func TestRootCreate(t *testing.T) { require.NoError(t, err) defer root.Close() - err = root.WriteFile(tt.path, []byte("new"), 0o644, tt.replace) + err = root.WriteFile(tt.path, []byte("new"), 0o644, 0o755, tt.replace) if tt.wantErr != "" { require.ErrorContains(t, err, tt.wantErr) } else { @@ -248,7 +248,7 @@ func TestOpenFileRejectsTrailingSeparator(t *testing.T) { t.Chdir(root) path := tt.path(root) - file, err := safepaths.OpenFile(path, 0o644, false) + file, err := safepaths.OpenFile(path, 0o644, 0o755, false) if file != nil { _ = file.Close() } @@ -299,7 +299,7 @@ func TestOpenFileAllowsTrustedParentSymlinks(t *testing.T) { t.Chdir(sandbox) } - file, err := safepaths.OpenFile(tt.path(sandbox), 0o644, false) + file, err := safepaths.OpenFile(tt.path(sandbox), 0o644, 0o755, false) require.NoError(t, err) _, err = file.Write([]byte("content")) require.NoError(t, err) @@ -352,7 +352,7 @@ func TestOpenRootDirAllowsTrustedPathSymlinks(t *testing.T) { root, err := safepaths.OpenRootDir(tt.path(sandbox), 0o755) require.NoError(t, err) - require.NoError(t, root.WriteFile("file.txt", []byte("content"), 0o644, false)) + require.NoError(t, root.WriteFile("file.txt", []byte("content"), 0o644, 0o755, false)) require.NoError(t, root.Close()) content, err := os.ReadFile(filepath.Join(target, "selected", "file.txt")) require.NoError(t, err) diff --git a/internal/safepaths/root_validation_test.go b/internal/safepaths/root_validation_test.go index 20c593f8297..3705e386ee5 100644 --- a/internal/safepaths/root_validation_test.go +++ b/internal/safepaths/root_validation_test.go @@ -47,7 +47,7 @@ func TestFileIdentityMismatch(t *testing.T) { return defaultOpenFile(parent, name, flag, perm) } - file, err := root.Create("expected", 0o644, true) + file, err := root.Create("expected", 0o644, 0o755, true) assert.Nil(t, file) require.ErrorContains(t, err, "changed while opening") content, err := os.ReadFile(filepath.Join(rootDir, "other")) @@ -67,7 +67,7 @@ func TestRedirectTargetClassificationError(t *testing.T) { return nil, errors.New("stat failed") } - file, err := root.Create("link", 0o644, true) + file, err := root.Create("link", 0o644, 0o755, true) assert.Nil(t, file) require.ErrorContains(t, err, "could not safely classify redirect target") info, err := os.Lstat(filepath.Join(rootDir, "link")) diff --git a/internal/zip/zip.go b/internal/zip/zip.go index 14db7c0b0d8..db122e277b9 100644 --- a/internal/zip/zip.go +++ b/internal/zip/zip.go @@ -46,7 +46,7 @@ func extractZipFile(zf *zip.File, dest *safepaths.Root) (extractErr error) { defer f.Close() var df *os.File - if df, extractErr = dest.Create(zf.Name, getPerm(zm), false); extractErr != nil { + if df, extractErr = dest.Create(zf.Name, getPerm(zm), dirMode, false); extractErr != nil { return } diff --git a/pkg/cmd/attestation/download/metadata.go b/pkg/cmd/attestation/download/metadata.go index aa39a2c838a..4e900f92388 100644 --- a/pkg/cmd/attestation/download/metadata.go +++ b/pkg/cmd/attestation/download/metadata.go @@ -52,7 +52,7 @@ func (s *LiveStore) createMetadataFile(artifactDigest string, attestationsResp [ } defer root.Close() - f, err := root.Create(s.createJSONLinesFileName(artifactDigest), 0o666, true) + f, err := root.Create(s.createJSONLinesFileName(artifactDigest), 0o666, 0o755, true) if err != nil { return "", errors.Join(ErrAttestationFileCreation, fmt.Errorf("failed to create file: %v", err)) } diff --git a/pkg/cmd/copilot/copilot.go b/pkg/cmd/copilot/copilot.go index a3a4fe33a31..9b4baadf3aa 100644 --- a/pkg/cmd/copilot/copilot.go +++ b/pkg/cmd/copilot/copilot.go @@ -437,7 +437,7 @@ func extractTarGz(r io.Reader, destDir string) error { } if header.Typeflag == tar.TypeReg { - if err := root.CopyFile(header.Name, tr, os.FileMode(header.Mode)&0o777, true); err != nil { + if err := root.CopyFile(header.Name, tr, os.FileMode(header.Mode)&0o777, 0o755, true); err != nil { return fmt.Errorf("failed to extract file: %w", err) } } diff --git a/pkg/cmd/release/download/download.go b/pkg/cmd/release/download/download.go index 776b3c3ac2a..e0a8f59a054 100644 --- a/pkg/cmd/release/download/download.go +++ b/pkg/cmd/release/download/download.go @@ -476,9 +476,9 @@ func (w *destinationWriter) Copy(name string, r io.Reader) (copyErr error) { if copyErr = w.ensureRoot(); copyErr != nil { return } - f, copyErr = w.root.Create(name, 0o644, w.overwrite) + f, copyErr = w.root.Create(name, 0o644, 0o755, w.overwrite) } else { - f, copyErr = safepaths.OpenFile(fp, 0o644, w.overwrite) + f, copyErr = safepaths.OpenFile(fp, 0o644, 0o755, w.overwrite) } if copyErr != nil { if os.IsExist(copyErr) { diff --git a/pkg/cmd/repo/read-file/read_file.go b/pkg/cmd/repo/read-file/read_file.go index e16d9662e4d..38995793e9d 100644 --- a/pkg/cmd/repo/read-file/read_file.go +++ b/pkg/cmd/repo/read-file/read_file.go @@ -320,7 +320,7 @@ func trimTrailingPathSeparators(path string) string { func rootedWriteOutputFile(dir, name string, data []byte, perm os.FileMode, replace bool) error { if dir == "" { - return safepaths.WriteFile(name, data, perm, replace) + return safepaths.WriteFile(name, data, perm, 0o755, replace) } root, err := safepaths.OpenRootDir(dir, 0o755) @@ -328,5 +328,5 @@ func rootedWriteOutputFile(dir, name string, data []byte, perm os.FileMode, repl return err } defer root.Close() - return root.WriteFile(name, data, perm, replace) + return root.WriteFile(name, data, perm, 0o755, replace) } diff --git a/pkg/cmd/run/download/download_test.go b/pkg/cmd/run/download/download_test.go index b1b602a1ffe..7fc88b2d217 100644 --- a/pkg/cmd/run/download/download_test.go +++ b/pkg/cmd/run/download/download_test.go @@ -207,7 +207,7 @@ func (f *fakePlatform) Download(url safeurl.SafeURL, openDestination func() (*sa for _, testArtifact := range run.testArtifacts { if testArtifact.artifact.DownloadURL == url.String() { for _, file := range testArtifact.files { - return dir.WriteFile(file, []byte{}, 0o600, false) + return dir.WriteFile(file, []byte{}, 0o600, 0o755, false) } } } From f1722876718bdc427028db63ae7ba790bcfbadb4 Mon Sep 17 00:00:00 2001 From: BagToad <47394200+BagToad@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:50:15 -0600 Subject: [PATCH 08/14] Clarify and test lexical child-path validation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- internal/safepaths/root.go | 11 ++++++--- internal/safepaths/root_test.go | 40 ++++++++++++++++++++++++++++----- 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/internal/safepaths/root.go b/internal/safepaths/root.go index c18cd07c95a..215aa210dad 100644 --- a/internal/safepaths/root.go +++ b/internal/safepaths/root.go @@ -154,7 +154,7 @@ func (r *Root) MkdirAll(name string, perm os.FileMode) error { return root.Close() } -// Validate reports whether name is lexically contained beneath the root. +// Validate checks name using the same lexical rules as [ValidateChild]. func (r *Root) Validate(name string) error { _, err := r.localPath(name) return err @@ -269,14 +269,19 @@ func (r *Root) localPath(name string) (string, error) { return validateChildPath(name) } -// ValidateChild reports whether name is a lexically contained child path. +// ValidateChild reports whether the cleaned name is a lexically contained path, +// using the host operating system's path rules. Joining it to a root stays +// lexically within that root. Nested paths and names that clean to "." are allowed. +// +// This check does not consult the filesystem or the current working directory, +// and does not account for symbolic links. func ValidateChild(name string) error { _, err := validateChildPath(name) return err } func validateChildPath(name string) (string, error) { - local := filepath.Clean(filepath.FromSlash(name)) + local := filepath.Clean(name) if !filepath.IsLocal(local) { return "", PathTraversalError{Elems: []string{name}} } diff --git a/internal/safepaths/root_test.go b/internal/safepaths/root_test.go index 50bfb61f2b8..d086ed39a22 100644 --- a/internal/safepaths/root_test.go +++ b/internal/safepaths/root_test.go @@ -3,6 +3,7 @@ package safepaths_test import ( "os" "path/filepath" + "runtime" "testing" "github.com/cli/cli/v2/internal/safepaths" @@ -202,22 +203,51 @@ func TestRootCreate(t *testing.T) { } func TestValidateChild(t *testing.T) { + root, err := safepaths.OpenRoot(t.TempDir()) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, root.Close()) }) + + validators := []struct { + name string + validate func(string) error + }{ + {name: "ValidateChild", validate: safepaths.ValidateChild}, + {name: "Root.Validate", validate: root.Validate}, + } tests := []struct { name string path string wantErr bool }{ + {name: "empty path cleans to root", path: ""}, + {name: "root", path: "."}, {name: "local child", path: "nested/file.txt"}, + {name: "contained parent component", path: "nested/../file.txt"}, + {name: "trailing separator", path: "nested/"}, {name: "parent escape", path: "../file.txt", wantErr: true}, + {name: "nested parent escape", path: "nested/../../file.txt", wantErr: true}, {name: "absolute", path: filepath.Join(string(os.PathSeparator), "file.txt"), wantErr: true}, + {name: "backslash child", path: `nested\file.txt`}, + {name: "backslash parent escape", path: `..\..\file.txt`, wantErr: runtime.GOOS == "windows"}, + {name: "mixed separator escape", path: `nested/..\..\file.txt`, wantErr: runtime.GOOS == "windows"}, + {name: "drive relative path", path: `C:file.txt`, wantErr: runtime.GOOS == "windows"}, + {name: "drive absolute path", path: `C:\file.txt`, wantErr: runtime.GOOS == "windows"}, + {name: "UNC path", path: `\\server\share\file.txt`, wantErr: runtime.GOOS == "windows"}, + {name: "reserved Windows name", path: "NUL", wantErr: runtime.GOOS == "windows"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := safepaths.ValidateChild(tt.path) - if tt.wantErr { - require.Error(t, err) - } else { - require.NoError(t, err) + for _, validator := range validators { + t.Run(validator.name, func(t *testing.T) { + err := validator.validate(tt.path) + if tt.wantErr { + var traversalErr safepaths.PathTraversalError + require.ErrorAs(t, err, &traversalErr) + assert.Equal(t, []string{tt.path}, traversalErr.Elems) + } else { + require.NoError(t, err) + } + }) } }) } From c906a6ad5a6951f02c420b594300777eb203d568 Mon Sep 17 00:00:00 2001 From: Brian DeHamer Date: Thu, 17 Sep 2026 16:37:48 -0700 Subject: [PATCH 09/14] Anchor the end of the --signer-workflow SAN pattern validateSignerWorkflow built a pattern anchored only at the start, and sigstore-go applies SAN patterns with regexp.MatchString, which is unanchored. The pin was therefore a prefix match: a workflow named release.yml.attacker.yml satisfied a pin on release.yml. Append (@[^@]*)?$ to both return paths so the pinned value must run to the end of the SAN, optionally followed by a single @. The ref is matched without an "@" so that an "@" in a workflow file name cannot be presented as the separator. Refs appear as both git refs and commit SHAs, so the suffix cannot assume a refs/ prefix. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/attestation/verify/policy.go | 16 +++- pkg/cmd/attestation/verify/policy_test.go | 93 +++++++++++++++++++++-- 2 files changed, 102 insertions(+), 7 deletions(-) diff --git a/pkg/cmd/attestation/verify/policy.go b/pkg/cmd/attestation/verify/policy.go index 9f15653686e..a8b132a2527 100644 --- a/pkg/cmd/attestation/verify/policy.go +++ b/pkg/cmd/attestation/verify/policy.go @@ -15,6 +15,18 @@ import ( const hostRegex = `^[a-zA-Z0-9-]+\.[a-zA-Z0-9-]+.*$` +// signerWorkflowSANSuffix terminates the pattern built for --signer-workflow. +// +// A certificate issued to a GitHub Actions workflow carries a SubjectAlternativeName +// of the form "@", where the ref is either a git ref or a commit +// SHA. sigstore-go matches SAN patterns with regexp.MatchString, which is unanchored, +// so a pattern anchored only at the start is a prefix match: a workflow named +// release.yml.attacker.yml would satisfy a pin on release.yml. Requiring the pinned +// value to run to the end of the SAN, optionally followed by a single "@", makes +// the pin an identity match. The ref may not itself contain "@", so that an "@" in a +// workflow file name cannot be passed off as the separator. +const signerWorkflowSANSuffix = `(@[^@]*)?$` + func expandToGitHubURL(tenant, ownerOrRepo string) string { if tenant == "" { return fmt.Sprintf("https://github.com/%s", ownerOrRepo) @@ -155,7 +167,7 @@ func validateSignerWorkflow(hostname, signerWorkflow string) (string, error) { } if match { - return "^" + regexp.QuoteMeta(fmt.Sprintf("https://%s", signerWorkflow)), nil + return "^" + regexp.QuoteMeta(fmt.Sprintf("https://%s", signerWorkflow)) + signerWorkflowSANSuffix, nil } // if the provided workflow did not match the expect format @@ -164,5 +176,5 @@ func validateSignerWorkflow(hostname, signerWorkflow string) (string, error) { return "", errors.New("unknown signer workflow host") } - return "^" + regexp.QuoteMeta(fmt.Sprintf("https://%s/%s", hostname, signerWorkflow)), nil + return "^" + regexp.QuoteMeta(fmt.Sprintf("https://%s/%s", hostname, signerWorkflow)) + signerWorkflowSANSuffix, nil } diff --git a/pkg/cmd/attestation/verify/policy_test.go b/pkg/cmd/attestation/verify/policy_test.go index ae6e022f05f..97fb46173e9 100644 --- a/pkg/cmd/attestation/verify/policy_test.go +++ b/pkg/cmd/attestation/verify/policy_test.go @@ -6,6 +6,8 @@ import ( "github.com/cli/cli/v2/pkg/cmd/attestation/verification" + "github.com/sigstore/sigstore-go/pkg/fulcio/certificate" + "github.com/sigstore/sigstore-go/pkg/verify" "github.com/stretchr/testify/require" ) @@ -71,7 +73,7 @@ func TestNewEnforcementCriteria(t *testing.T) { c, err := newEnforcementCriteria(opts) require.NoError(t, err) - require.Equal(t, `^https://github\.com/foo/bar/\.github/workflows/attest\.yml`, c.SANRegex) + require.Equal(t, `^https://github\.com/foo/bar/\.github/workflows/attest\.yml(@[^@]*)?$`, c.SANRegex) require.Zero(t, c.SAN) }) @@ -297,25 +299,25 @@ func TestValidateSignerWorkflow(t *testing.T) { { name: "workflow with default host", providedSignerWorkflow: "github/artifact-attestations-workflows/.github/workflows/attest.yml", - expectedWorkflowRegex: `^https://github\.com/github/artifact-attestations-workflows/\.github/workflows/attest\.yml`, + expectedWorkflowRegex: `^https://github\.com/github/artifact-attestations-workflows/\.github/workflows/attest\.yml(@[^@]*)?$`, host: "github.com", }, { name: "workflow with workflow URL included", providedSignerWorkflow: "github.com/github/artifact-attestations-workflows/.github/workflows/attest.yml", - expectedWorkflowRegex: `^https://github\.com/github/artifact-attestations-workflows/\.github/workflows/attest\.yml`, + expectedWorkflowRegex: `^https://github\.com/github/artifact-attestations-workflows/\.github/workflows/attest\.yml(@[^@]*)?$`, host: "github.com", }, { name: "workflow with GH_HOST set", providedSignerWorkflow: "github/artifact-attestations-workflows/.github/workflows/attest.yml", - expectedWorkflowRegex: `^https://myhost\.github\.com/github/artifact-attestations-workflows/\.github/workflows/attest\.yml`, + expectedWorkflowRegex: `^https://myhost\.github\.com/github/artifact-attestations-workflows/\.github/workflows/attest\.yml(@[^@]*)?$`, host: "myhost.github.com", }, { name: "workflow with authenticated host", providedSignerWorkflow: "github/artifact-attestations-workflows/.github/workflows/attest.yml", - expectedWorkflowRegex: `^https://authedhost\.github\.com/github/artifact-attestations-workflows/\.github/workflows/attest\.yml`, + expectedWorkflowRegex: `^https://authedhost\.github\.com/github/artifact-attestations-workflows/\.github/workflows/attest\.yml(@[^@]*)?$`, host: "authedhost.github.com", }, } @@ -334,3 +336,84 @@ func TestValidateSignerWorkflow(t *testing.T) { } } } + +// TestSignerWorkflowSANMatching drives the pattern built by validateSignerWorkflow +// through the sigstore-go matcher that consumes it, because that matcher applies the +// pattern with regexp.MatchString and so does not anchor it on the caller's behalf. +func TestSignerWorkflowSANMatching(t *testing.T) { + const pinnedWorkflow = "owner/builder/.github/workflows/release.yml" + const workflowDir = "https://github.com/owner/builder/.github/workflows/" + + testcases := []struct { + name string + san string + expectMatch bool + }{ + { + name: "pinned workflow at a branch ref", + san: workflowDir + "release.yml@refs/heads/main", + expectMatch: true, + }, + { + name: "pinned workflow at a commit SHA", + san: workflowDir + "release.yml@09b495c3f12c7881b3cc17209a327792065c1a1d", + expectMatch: true, + }, + { + name: "pinned workflow with no ref", + san: workflowDir + "release.yml", + expectMatch: true, + }, + { + name: "workflow whose file name extends the pinned name", + san: workflowDir + "release.yml.attacker.yml@refs/heads/attacker", + expectMatch: false, + }, + { + name: "workflow whose file name contains the ref separator", + san: workflowDir + "release.yml@attacker.yml@refs/heads/attacker", + expectMatch: false, + }, + { + name: "different workflow in the pinned repository", + san: workflowDir + "other.yml@refs/heads/main", + expectMatch: false, + }, + { + name: "pinned workflow in a lookalike repository", + san: "https://github.com/owner/builder-attacker/.github/workflows/release.yml@refs/heads/main", + expectMatch: false, + }, + } + + sanRegex, err := validateSignerWorkflow("github.com", pinnedWorkflow) + require.NoError(t, err) + + matcher, err := verify.NewSANMatcher("", sanRegex) + require.NoError(t, err) + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + err := matcher.Verify(certificate.Summary{SubjectAlternativeName: tc.san}) + if tc.expectMatch { + require.NoError(t, err) + } else { + require.Error(t, err) + } + }) + } +} + +// TestSignerWorkflowSANMatchingRepositoryValue covers a --signer-workflow value that +// names a repository instead of a workflow. The value is still matched as a complete +// identity, so it cannot match a repository whose name merely starts with it. +func TestSignerWorkflowSANMatchingRepositoryValue(t *testing.T) { + sanRegex, err := validateSignerWorkflow("github.com", "owner/builder") + require.NoError(t, err) + + matcher, err := verify.NewSANMatcher("", sanRegex) + require.NoError(t, err) + + san := "https://github.com/owner/builder-attacker/.github/workflows/release.yml@refs/heads/main" + require.Error(t, matcher.Verify(certificate.Summary{SubjectAlternativeName: san})) +} From 8c2454d5a9514eae6dc9137f307d2c0e6e1d017e Mon Sep 17 00:00:00 2001 From: Eric Pickard Date: Wed, 23 Sep 2026 17:26:19 -0400 Subject: [PATCH 10/14] make SourceRepositoryRef check case-sensitive --- pkg/cmd/attestation/verification/extensions.go | 3 ++- .../attestation/verification/extensions_test.go | 15 +++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/attestation/verification/extensions.go b/pkg/cmd/attestation/verification/extensions.go index 3ac9ac0a02b..2708e1f4ae2 100644 --- a/pkg/cmd/attestation/verification/extensions.go +++ b/pkg/cmd/attestation/verification/extensions.go @@ -65,7 +65,8 @@ func verifyCertExtensions(given, expected certificate.Summary) error { if expected.SourceRepositoryDigest != "" && !strings.EqualFold(expected.SourceRepositoryDigest, given.SourceRepositoryDigest) { return fmt.Errorf("expected SourceRepositoryDigest to be %s, got %s", expected.SourceRepositoryDigest, given.SourceRepositoryDigest) } - if expected.SourceRepositoryRef != "" && !strings.EqualFold(expected.SourceRepositoryRef, given.SourceRepositoryRef) { + // SourceRepositoryRef must be a case-sensitive comparison + if expected.SourceRepositoryRef != "" && expected.SourceRepositoryRef != given.SourceRepositoryRef { return fmt.Errorf("expected SourceRepositoryRef to be %s, got %s", expected.SourceRepositoryRef, given.SourceRepositoryRef) } diff --git a/pkg/cmd/attestation/verification/extensions_test.go b/pkg/cmd/attestation/verification/extensions_test.go index de00abcef04..af2e50c7f0d 100644 --- a/pkg/cmd/attestation/verification/extensions_test.go +++ b/pkg/cmd/attestation/verification/extensions_test.go @@ -16,6 +16,7 @@ func createSampleResult() *AttestationProcessingResult { SourceRepositoryOwnerURI: "https://github.com/owner", SourceRepositoryURI: "https://github.com/owner/repo", Issuer: "https://token.actions.githubusercontent.com", + SourceRepositoryRef: "refs/heads/main", }, }, }, @@ -28,7 +29,9 @@ func TestVerifyCertExtensions(t *testing.T) { certSummary := certificate.Summary{ SourceRepositoryOwnerURI: "https://github.com/owner", SourceRepositoryURI: "https://github.com/owner/repo", - Issuer: GitHubOIDCIssuer} + Issuer: GitHubOIDCIssuer, + SourceRepositoryRef: "refs/heads/main", + } c := EnforcementCriteria{ Certificate: certSummary, @@ -86,10 +89,18 @@ func TestVerifyCertExtensions(t *testing.T) { }) t.Run("with partial OIDCIssuer match", func(t *testing.T) { - expectedResults := results + expectedResults := []*AttestationProcessingResult{createSampleResult()} expectedResults[0].VerificationResult.Signature.Certificate.Extensions.Issuer = "https://token.actions.githubusercontent.com/foo-bar" verified, err := VerifyCertExtensions(expectedResults, c) require.ErrorContains(t, err, "expected Issuer to be https://token.actions.githubusercontent.com, got https://token.actions.githubusercontent.com/foo-bar -- if you have a custom OIDC issuer") require.Nil(t, verified) }) + + t.Run("with wrong case-sensitive SourceRepositoryRef", func(t *testing.T) { + expectedResults := []*AttestationProcessingResult{createSampleResult()} + expectedResults[0].VerificationResult.Signature.Certificate.Extensions.SourceRepositoryRef = "refs/heads/MAIN" + verified, err := VerifyCertExtensions(expectedResults, c) + require.ErrorContains(t, err, "expected SourceRepositoryRef to be refs/heads/main, got refs/heads/MAIN") + require.Nil(t, verified) + }) } From 4fe4b593fdb4b22374925daf58baa0eeb4eb2305 Mon Sep 17 00:00:00 2001 From: BagToad <47394200+BagToad@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:05:13 -0600 Subject: [PATCH 11/14] Normalize filenames for repository directory output Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/repo/read-file/read_file.go | 15 +- pkg/cmd/repo/read-file/read_file_test.go | 205 +++++++++++++++++++---- 2 files changed, 185 insertions(+), 35 deletions(-) diff --git a/pkg/cmd/repo/read-file/read_file.go b/pkg/cmd/repo/read-file/read_file.go index 38995793e9d..3b9e6451cd6 100644 --- a/pkg/cmd/repo/read-file/read_file.go +++ b/pkg/cmd/repo/read-file/read_file.go @@ -257,8 +257,8 @@ var ( // writeToOutput writes file content to a local path and returns the final destination. // Symlink output paths are refused. A directory target receives the file under -// its remote basename, and existing regular files are only replaced when -// clobber is true. +// its remote basename using the local operating system's path rules, and existing +// regular files are only replaced when clobber is true. func writeToOutput(file *repoFile, output string, clobber bool) (string, error) { dest := output outputDir := "" @@ -277,9 +277,14 @@ func writeToOutput(file *repoFile, output string, clobber bool) (string, error) return "", err } + name := dest if asDir { + name = filepath.Base(file.Name) + if name == "." || !filepath.IsLocal(name) { + return "", fmt.Errorf("invalid output filename %q", file.Name) + } outputDir = dest - dest = filepath.Join(dest, file.Name) + dest = filepath.Join(dest, name) } if lr, err := lstatF(dest); err == nil { @@ -293,10 +298,6 @@ func writeToOutput(file *repoFile, output string, clobber bool) (string, error) return "", err } - name := dest - if outputDir != "" { - name = file.Name - } if err := writeOutputFile(outputDir, name, file.Content, 0o644, clobber); err != nil { if os.IsExist(err) && !clobber { return "", fmt.Errorf("output path already exists: %q (use --clobber to overwrite)", dest) diff --git a/pkg/cmd/repo/read-file/read_file_test.go b/pkg/cmd/repo/read-file/read_file_test.go index 7c5cbcd1036..8e9aef09636 100644 --- a/pkg/cmd/repo/read-file/read_file_test.go +++ b/pkg/cmd/repo/read-file/read_file_test.go @@ -10,6 +10,7 @@ import ( "net/http" "os" "path/filepath" + "runtime" "strings" "testing" @@ -168,6 +169,8 @@ func Test_readFileRun(t *testing.T) { opts ReadFileOptions httpStubs func(*httpmock.Registry) jsonFields []string + setup func(t *testing.T) + verify func(t *testing.T) wantOut string wantStderr string wantErrMsg string @@ -197,6 +200,41 @@ func Test_readFileRun(t *testing.T) { }, wantOut: "hello world\n", }, + { + name: "directory output saves only the remote basename", + opts: ReadFileOptions{Output: "out/"}, + setup: func(t *testing.T) { + t.Helper() + t.Chdir(t.TempDir()) + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/contents/README.md"), + httpmock.JSONResponse(fileContentResponse(filepath.Join("nested", "notes.txt"), "hello world\n")), + ) + }, + verify: func(t *testing.T) { + t.Helper() + entries, err := os.ReadDir("out") + require.NoError(t, err) + require.Len(t, entries, 1) + assert.Equal(t, "notes.txt", entries[0].Name()) + content, err := os.ReadFile(filepath.Join("out", "notes.txt")) + require.NoError(t, err) + assert.Equal(t, "hello world\n", string(content)) + }, + }, + { + name: "json preserves the original remote name", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/contents/README.md"), + httpmock.JSONResponse(fileContentResponse(`nested\notes.txt`, "hello world\n")), + ) + }, + jsonFields: []string{"name"}, + wantOut: "{\"name\":\"nested\\\\notes.txt\"}\n", + }, { name: "writes file content through pager (tty)", tty: true, @@ -523,6 +561,9 @@ func Test_readFileRun(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + if tt.setup != nil { + tt.setup(t) + } reg := &httpmock.Registry{} defer reg.Verify(t) if tt.httpStubs != nil { @@ -561,6 +602,9 @@ func Test_readFileRun(t *testing.T) { require.NoError(t, err) assert.Equal(t, tt.wantOut, stdout.String()) assert.Equal(t, tt.wantStderr, stderr.String()) + if tt.verify != nil { + tt.verify(t) + } }) } } @@ -571,21 +615,27 @@ func Test_writeToOutput(t *testing.T) { var ancestorTarget string var outputDirTarget string var regularMode os.FileMode + backslashBasename := `nested\README.md` + backslashTraversalBasename := `..\..\README.md` + if runtime.GOOS == "windows" { + backslashBasename = "README.md" + backslashTraversalBasename = "README.md" + } tests := []struct { - name string - file *repoFile - output string - clobber bool - lstat func(path string) (*lstatResult, error) - setup func(t *testing.T) - useRealFS bool - verify func(t *testing.T) - wantDest string - wantWrite string - wantDir string - wantErr string - wantErrContains string + name string + file *repoFile + output string + clobber bool + lstat func(path string) (*lstatResult, error) + setup func(t *testing.T) + useRealFS bool + verify func(t *testing.T) + wantDest string + wantWrite string + wantDir string + wantLstatPaths []string + wantErr string }{ { name: "writes to a new file path", @@ -598,17 +648,109 @@ func Test_writeToOutput(t *testing.T) { }, { name: "directory target uses remote basename", - file: &repoFile{Name: "README.md", Content: []byte("hi")}, + file: &repoFile{Name: "nested/README.md", Content: []byte("hi")}, output: "out/", lstat: func(path string) (*lstatResult, error) { - if path == "out/" { + if path == "out" { return &lstatResult{isDir: true}, nil } return nil, fs.ErrNotExist }, - wantDest: filepath.Join("out", "README.md"), + wantDest: filepath.Join("out", "README.md"), + wantWrite: "hi", + wantDir: "out", + wantLstatPaths: []string{"out", filepath.Join("out", "README.md")}, + }, + { + name: "existing directory without separator uses remote basename", + file: &repoFile{Name: "nested/README.md", Content: []byte("hi")}, + output: "out", + lstat: func(path string) (*lstatResult, error) { + if path == "out" { + return &lstatResult{isDir: true}, nil + } + return nil, fs.ErrNotExist + }, + wantDest: filepath.Join("out", "README.md"), + wantWrite: "hi", + wantDir: "out", + wantLstatPaths: []string{"out", filepath.Join("out", "README.md")}, + }, + { + name: "directory target handles native backslash separators", + file: &repoFile{Name: `nested\README.md`, Content: []byte("hi")}, + output: "out/", + lstat: func(string) (*lstatResult, error) { return nil, fs.ErrNotExist }, + wantDest: filepath.Join("out", backslashBasename), + wantWrite: "hi", + wantDir: "out", + wantLstatPaths: []string{"out", filepath.Join("out", backslashBasename)}, + }, + { + name: "directory target handles native backslash parent components", + file: &repoFile{Name: `..\..\README.md`, Content: []byte("hi")}, + output: "out/", + lstat: func(string) (*lstatResult, error) { return nil, fs.ErrNotExist }, + wantDest: filepath.Join("out", backslashTraversalBasename), + wantWrite: "hi", + wantDir: "out", + wantLstatPaths: []string{"out", filepath.Join("out", backslashTraversalBasename)}, + }, + { + name: "directory target uses basename of absolute remote name", + file: &repoFile{Name: filepath.Join(string(os.PathSeparator), "nested", "README.md"), Content: []byte("hi")}, + output: "out/", + lstat: func(string) (*lstatResult, error) { return nil, fs.ErrNotExist }, + wantDest: filepath.Join("out", "README.md"), + wantWrite: "hi", + wantDir: "out", + wantLstatPaths: []string{"out", filepath.Join("out", "README.md")}, + }, + { + name: "explicit output retains user selected path", + file: &repoFile{Name: "../remote.md", Content: []byte("hi")}, + output: filepath.Join("out", "nested", "local.md"), + lstat: func(string) (*lstatResult, error) { return nil, fs.ErrNotExist }, + wantDest: filepath.Join("out", "nested", "local.md"), wantWrite: "hi", - wantDir: "out", + wantDir: filepath.Join("out", "nested"), + }, + { + name: "basename collision without clobber errors", + file: &repoFile{Name: "nested/README.md", Content: []byte("hi")}, + output: "out/", + lstat: func(path string) (*lstatResult, error) { + if path == "out" { + return &lstatResult{isDir: true}, nil + } + return &lstatResult{}, nil + }, + wantLstatPaths: []string{"out", filepath.Join("out", "README.md")}, + wantErr: fmt.Sprintf("output path already exists: %q (use --clobber to overwrite)", filepath.Join("out", "README.md")), + }, + { + name: "directory target rejects empty remote name", + file: &repoFile{Name: "", Content: []byte("hi")}, + output: "out/", + lstat: func(string) (*lstatResult, error) { return nil, fs.ErrNotExist }, + wantLstatPaths: []string{"out"}, + wantErr: `invalid output filename ""`, + }, + { + name: "directory target rejects parent-only remote name", + file: &repoFile{Name: "..", Content: []byte("hi")}, + output: "out/", + lstat: func(string) (*lstatResult, error) { return nil, fs.ErrNotExist }, + wantLstatPaths: []string{"out"}, + wantErr: `invalid output filename ".."`, + }, + { + name: "directory target rejects root-only remote name", + file: &repoFile{Name: string(os.PathSeparator), Content: []byte("hi")}, + output: "out/", + lstat: func(string) (*lstatResult, error) { return nil, fs.ErrNotExist }, + wantLstatPaths: []string{"out"}, + wantErr: fmt.Sprintf("invalid output filename %q", string(os.PathSeparator)), }, { name: "existing file without clobber errors", @@ -732,8 +874,8 @@ func Test_writeToOutput(t *testing.T) { wantErr: "output path is a symlink", }, { - name: "directory output clobber rejects derived symlink", - file: &repoFile{Name: "README.md", Content: []byte("hi")}, + name: "directory output clobber rejects basename symlink", + file: &repoFile{Name: "nested/README.md", Content: []byte("hi")}, output: "out/", clobber: true, lstat: lstat, @@ -801,7 +943,7 @@ func Test_writeToOutput(t *testing.T) { wantDest: filepath.Join("out", "nested", "README.md"), }, { - name: "directory target rejects remote name escape", + name: "directory target flattens remote parent components", file: &repoFile{Name: "../README.md", Content: []byte("hi")}, output: "out/", lstat: lstat, @@ -813,8 +955,12 @@ func Test_writeToOutput(t *testing.T) { verify: func(t *testing.T) { t.Helper() assert.NoFileExists(t, "README.md") + content, err := os.ReadFile(filepath.Join("out", "README.md")) + require.NoError(t, err) + assert.Equal(t, "hi", string(content)) }, - wantErrContains: "not a local child path", + wantDest: filepath.Join("out", "README.md"), + wantLstatPaths: []string{"out", filepath.Join("out", "README.md")}, }, } @@ -828,13 +974,17 @@ func Test_writeToOutput(t *testing.T) { var gotDir string var gotWritePath string var gotWriteContent []byte + var gotLstatPaths []string origLstat, origWrite := lstatF, writeOutputFile defer func() { lstatF, writeOutputFile = origLstat, origWrite }() - lstatF = tt.lstat + lstatF = func(path string) (*lstatResult, error) { + gotLstatPaths = append(gotLstatPaths, path) + return tt.lstat(path) + } if !tt.useRealFS { writeOutputFile = func(dir, name string, data []byte, _ fs.FileMode, _ bool) error { gotDir = dir @@ -850,16 +1000,15 @@ func Test_writeToOutput(t *testing.T) { } } + originalName := tt.file.Name dest, err := writeToOutput(tt.file, tt.output, tt.clobber) - if tt.wantErrContains != "" { - require.ErrorContains(t, err, tt.wantErrContains) - if tt.verify != nil { - tt.verify(t) - } - return + assert.Equal(t, originalName, tt.file.Name) + if tt.wantLstatPaths != nil { + assert.Equal(t, tt.wantLstatPaths, gotLstatPaths) } if tt.wantErr != "" { require.EqualError(t, err, tt.wantErr) + assert.Empty(t, gotWritePath) if tt.verify != nil { tt.verify(t) } From 41aeb5352f50a367b91b766c027c7cd26bcfc768 Mon Sep 17 00:00:00 2001 From: Brian DeHamer Date: Thu, 24 Sep 2026 14:31:58 -0700 Subject: [PATCH 12/14] Match the signer workflow ref by shape rather than excluding "@" The first cut of this fix ended the pattern with (@[^@]*)?$, which treats the last "@" as the separator between workflow path and ref. That rejects legitimate signers: "@" is valid in a git ref, and refs/tags/pkg@1.2.3 is the tag convention used by JavaScript monorepos, whose release workflows are exactly what --signer-workflow is meant to pin. Allowing any ref instead, with (@.*)?$, is not an option. Actions loads a workflow file whose name contains "@", the OIDC job_workflow_ref claim carries the name verbatim, and Fulcio leaves "@" unescaped because it is a legal path character, so a file named release.yml@evil.yml yields a SAN that a permissive pattern accepts for a pin on release.yml. Match the ref by shape instead: either a "refs/"-prefixed path or a bare object ID. A workflow file name can be neither, because Actions only loads workflows stored directly in .github/workflows so the name cannot contain "/", and the name must carry a .yml or .yaml extension so it cannot be a bare object ID. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/attestation/verify/policy.go | 22 +++++++++----- pkg/cmd/attestation/verify/policy_test.go | 37 ++++++++++++++++++++--- 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/pkg/cmd/attestation/verify/policy.go b/pkg/cmd/attestation/verify/policy.go index a8b132a2527..dfc2e87be97 100644 --- a/pkg/cmd/attestation/verify/policy.go +++ b/pkg/cmd/attestation/verify/policy.go @@ -18,14 +18,20 @@ const hostRegex = `^[a-zA-Z0-9-]+\.[a-zA-Z0-9-]+.*$` // signerWorkflowSANSuffix terminates the pattern built for --signer-workflow. // // A certificate issued to a GitHub Actions workflow carries a SubjectAlternativeName -// of the form "@", where the ref is either a git ref or a commit -// SHA. sigstore-go matches SAN patterns with regexp.MatchString, which is unanchored, -// so a pattern anchored only at the start is a prefix match: a workflow named -// release.yml.attacker.yml would satisfy a pin on release.yml. Requiring the pinned -// value to run to the end of the SAN, optionally followed by a single "@", makes -// the pin an identity match. The ref may not itself contain "@", so that an "@" in a -// workflow file name cannot be passed off as the separator. -const signerWorkflowSANSuffix = `(@[^@]*)?$` +// of the form "@". sigstore-go matches SAN patterns with +// regexp.MatchString, which is unanchored, so a pattern anchored only at the start is +// a prefix match: a workflow named release.yml.attacker.yml satisfies a pin on +// release.yml. Requiring the pinned value to run to the end of the SAN makes the pin +// an identity match. +// +// The separator cannot be located by splitting on "@", because both sides may contain +// one. A git ref may contain "@", as in the refs/tags/pkg@1.2.3 convention used by +// JavaScript monorepos, and so may a workflow file name. What separates them is shape: +// a ref is either a "refs/"-prefixed path or a bare object ID, and a workflow file name +// can be neither. It cannot contain "/", since Actions only loads workflows stored +// directly in .github/workflows, and it cannot be a bare object ID, since it must carry +// a .yml or .yaml extension. +const signerWorkflowSANSuffix = `(@(refs/.*|[0-9a-fA-F]+))?$` func expandToGitHubURL(tenant, ownerOrRepo string) string { if tenant == "" { diff --git a/pkg/cmd/attestation/verify/policy_test.go b/pkg/cmd/attestation/verify/policy_test.go index 97fb46173e9..4870dd4b3ba 100644 --- a/pkg/cmd/attestation/verify/policy_test.go +++ b/pkg/cmd/attestation/verify/policy_test.go @@ -73,7 +73,7 @@ func TestNewEnforcementCriteria(t *testing.T) { c, err := newEnforcementCriteria(opts) require.NoError(t, err) - require.Equal(t, `^https://github\.com/foo/bar/\.github/workflows/attest\.yml(@[^@]*)?$`, c.SANRegex) + require.Equal(t, `^https://github\.com/foo/bar/\.github/workflows/attest\.yml(@(refs/.*|[0-9a-fA-F]+))?$`, c.SANRegex) require.Zero(t, c.SAN) }) @@ -299,25 +299,25 @@ func TestValidateSignerWorkflow(t *testing.T) { { name: "workflow with default host", providedSignerWorkflow: "github/artifact-attestations-workflows/.github/workflows/attest.yml", - expectedWorkflowRegex: `^https://github\.com/github/artifact-attestations-workflows/\.github/workflows/attest\.yml(@[^@]*)?$`, + expectedWorkflowRegex: `^https://github\.com/github/artifact-attestations-workflows/\.github/workflows/attest\.yml(@(refs/.*|[0-9a-fA-F]+))?$`, host: "github.com", }, { name: "workflow with workflow URL included", providedSignerWorkflow: "github.com/github/artifact-attestations-workflows/.github/workflows/attest.yml", - expectedWorkflowRegex: `^https://github\.com/github/artifact-attestations-workflows/\.github/workflows/attest\.yml(@[^@]*)?$`, + expectedWorkflowRegex: `^https://github\.com/github/artifact-attestations-workflows/\.github/workflows/attest\.yml(@(refs/.*|[0-9a-fA-F]+))?$`, host: "github.com", }, { name: "workflow with GH_HOST set", providedSignerWorkflow: "github/artifact-attestations-workflows/.github/workflows/attest.yml", - expectedWorkflowRegex: `^https://myhost\.github\.com/github/artifact-attestations-workflows/\.github/workflows/attest\.yml(@[^@]*)?$`, + expectedWorkflowRegex: `^https://myhost\.github\.com/github/artifact-attestations-workflows/\.github/workflows/attest\.yml(@(refs/.*|[0-9a-fA-F]+))?$`, host: "myhost.github.com", }, { name: "workflow with authenticated host", providedSignerWorkflow: "github/artifact-attestations-workflows/.github/workflows/attest.yml", - expectedWorkflowRegex: `^https://authedhost\.github\.com/github/artifact-attestations-workflows/\.github/workflows/attest\.yml(@[^@]*)?$`, + expectedWorkflowRegex: `^https://authedhost\.github\.com/github/artifact-attestations-workflows/\.github/workflows/attest\.yml(@(refs/.*|[0-9a-fA-F]+))?$`, host: "authedhost.github.com", }, } @@ -359,6 +359,16 @@ func TestSignerWorkflowSANMatching(t *testing.T) { san: workflowDir + "release.yml@09b495c3f12c7881b3cc17209a327792065c1a1d", expectMatch: true, }, + { + name: "pinned workflow at a tag containing @", + san: workflowDir + "release.yml@refs/tags/pkg@1.2.3", + expectMatch: true, + }, + { + name: "pinned workflow at a branch containing @", + san: workflowDir + "release.yml@refs/heads/feature@v2", + expectMatch: true, + }, { name: "pinned workflow with no ref", san: workflowDir + "release.yml", @@ -404,6 +414,23 @@ func TestSignerWorkflowSANMatching(t *testing.T) { } } +// TestSignerWorkflowSANMatchingObservedCertificate uses the SubjectAlternativeName from +// a certificate Fulcio actually issued for a workflow stored at +// .github/workflows/provenance-image.yml@evil.yml. Actions loads a workflow file whose +// name contains "@", and neither the OIDC claim nor Fulcio escapes it, so the separator +// really can appear more than once in a SAN. +func TestSignerWorkflowSANMatchingObservedCertificate(t *testing.T) { + const observedSAN = "https://github.com/bdehamer/attest-demo/.github/workflows/provenance-image.yml@evil.yml@refs/heads/main" + + sanRegex, err := validateSignerWorkflow("github.com", "bdehamer/attest-demo/.github/workflows/provenance-image.yml") + require.NoError(t, err) + + matcher, err := verify.NewSANMatcher("", sanRegex) + require.NoError(t, err) + + require.Error(t, matcher.Verify(certificate.Summary{SubjectAlternativeName: observedSAN})) +} + // TestSignerWorkflowSANMatchingRepositoryValue covers a --signer-workflow value that // names a repository instead of a workflow. The value is still matched as a complete // identity, so it cannot match a repository whose name merely starts with it. From b473ca8c43eb96fa88bbc4cfefe6afbac0560f80 Mon Sep 17 00:00:00 2001 From: Brian DeHamer Date: Mon, 28 Sep 2026 16:21:45 -0700 Subject: [PATCH 13/14] Anchor exactly when --signer-workflow pins a ref of its own The optional "@" suffix is right for the documented flag format, which carries no ref and has to keep matching a certificate from any ref. Applied to a value that pins a ref, though, it allows a second ref to follow the pinned one. A git ref may be named "main@refs/heads/attacker": git accepts it, GitHub accepts the push, and Actions runs a workflow from it, issuing a certificate whose job_workflow_ref ends "@refs/heads/main@refs/ heads/attacker". A pin on refs/heads/main matched that, so the pin no longer constrained which ref the workflow ran from, and a branch protection rule on main does not cover the attacker's branch name. Append the optional suffix only when the value does not already end in a ref. When it does, anchor the pattern at the end of the SAN. This is narrower than the prefix match it replaces rather than a regression against it: a bare prefix match already admitted both this SAN and "@refs/heads/mainattacker", which stays rejected either way. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/attestation/verify/policy.go | 40 +++++++++---- pkg/cmd/attestation/verify/policy_test.go | 73 +++++++++++++++++++++++ 2 files changed, 102 insertions(+), 11 deletions(-) diff --git a/pkg/cmd/attestation/verify/policy.go b/pkg/cmd/attestation/verify/policy.go index dfc2e87be97..5829b39c6b1 100644 --- a/pkg/cmd/attestation/verify/policy.go +++ b/pkg/cmd/attestation/verify/policy.go @@ -15,14 +15,8 @@ import ( const hostRegex = `^[a-zA-Z0-9-]+\.[a-zA-Z0-9-]+.*$` -// signerWorkflowSANSuffix terminates the pattern built for --signer-workflow. -// -// A certificate issued to a GitHub Actions workflow carries a SubjectAlternativeName -// of the form "@". sigstore-go matches SAN patterns with -// regexp.MatchString, which is unanchored, so a pattern anchored only at the start is -// a prefix match: a workflow named release.yml.attacker.yml satisfies a pin on -// release.yml. Requiring the pinned value to run to the end of the SAN makes the pin -// an identity match. +// signerWorkflowRefPattern matches the "@" that terminates the +// SubjectAlternativeName of a certificate issued to a GitHub Actions workflow. // // The separator cannot be located by splitting on "@", because both sides may contain // one. A git ref may contain "@", as in the refs/tags/pkg@1.2.3 convention used by @@ -31,7 +25,31 @@ const hostRegex = `^[a-zA-Z0-9-]+\.[a-zA-Z0-9-]+.*$` // can be neither. It cannot contain "/", since Actions only loads workflows stored // directly in .github/workflows, and it cannot be a bare object ID, since it must carry // a .yml or .yaml extension. -const signerWorkflowSANSuffix = `(@(refs/.*|[0-9a-fA-F]+))?$` +const signerWorkflowRefPattern = `@(refs/.*|[0-9a-fA-F]+)` + +// signerWorkflowHasRef reports whether a --signer-workflow value already pins a ref. +var signerWorkflowHasRef = regexp.MustCompile(signerWorkflowRefPattern + `$`) + +// signerWorkflowSANPattern builds the SAN pattern enforced for --signer-workflow. +// +// sigstore-go matches SAN patterns with regexp.MatchString, which is unanchored, so a +// pattern anchored only at the start is a prefix match: a workflow named +// release.yml.attacker.yml satisfies a pin on release.yml. Anchoring the end makes the +// pin an identity match. +// +// The documented flag format carries no ref, and such a value has to keep matching a +// certificate from any ref, so the ref is optional. Once the value pins a ref of its +// own it must match the end of the SAN exactly. Leaving the ref optional there would +// let a second ref follow the pinned one, and since a branch may be named +// "main@refs/heads/attacker", a pin on refs/heads/main would still admit a certificate +// built from a different branch. +func signerWorkflowSANPattern(workflowURL string) string { + pattern := "^" + regexp.QuoteMeta(workflowURL) + if signerWorkflowHasRef.MatchString(workflowURL) { + return pattern + "$" + } + return pattern + "(" + signerWorkflowRefPattern + ")?$" +} func expandToGitHubURL(tenant, ownerOrRepo string) string { if tenant == "" { @@ -173,7 +191,7 @@ func validateSignerWorkflow(hostname, signerWorkflow string) (string, error) { } if match { - return "^" + regexp.QuoteMeta(fmt.Sprintf("https://%s", signerWorkflow)) + signerWorkflowSANSuffix, nil + return signerWorkflowSANPattern(fmt.Sprintf("https://%s", signerWorkflow)), nil } // if the provided workflow did not match the expect format @@ -182,5 +200,5 @@ func validateSignerWorkflow(hostname, signerWorkflow string) (string, error) { return "", errors.New("unknown signer workflow host") } - return "^" + regexp.QuoteMeta(fmt.Sprintf("https://%s/%s", hostname, signerWorkflow)) + signerWorkflowSANSuffix, nil + return signerWorkflowSANPattern(fmt.Sprintf("https://%s/%s", hostname, signerWorkflow)), nil } diff --git a/pkg/cmd/attestation/verify/policy_test.go b/pkg/cmd/attestation/verify/policy_test.go index 4870dd4b3ba..ccabf4bd0f8 100644 --- a/pkg/cmd/attestation/verify/policy_test.go +++ b/pkg/cmd/attestation/verify/policy_test.go @@ -431,6 +431,79 @@ func TestSignerWorkflowSANMatchingObservedCertificate(t *testing.T) { require.Error(t, matcher.Verify(certificate.Summary{SubjectAlternativeName: observedSAN})) } +// TestSignerWorkflowSANMatchingPinnedRef covers a --signer-workflow value that pins a +// ref as well as a workflow. Such a value has to match the end of the SAN exactly, +// because a git ref may itself be named so that it begins with the pinned ref and is +// followed by something ref-shaped. +func TestSignerWorkflowSANMatchingPinnedRef(t *testing.T) { + const pinnedWorkflow = "owner/builder/.github/workflows/release.yml@refs/heads/main" + const workflowURL = "https://github.com/owner/builder/.github/workflows/release.yml" + + testcases := []struct { + name string + san string + expectMatch bool + }{ + { + name: "the pinned ref", + san: workflowURL + "@refs/heads/main", + expectMatch: true, + }, + { + name: "a branch whose name extends the pinned ref", + san: workflowURL + "@refs/heads/mainattacker", + expectMatch: false, + }, + { + name: "a branch named so that a second ref follows the pinned ref", + san: workflowURL + "@refs/heads/main@refs/heads/attacker", + expectMatch: false, + }, + { + name: "a branch named so that an object ID follows the pinned ref", + san: workflowURL + "@refs/heads/main@09b495c3f12c7881b3cc17209a327792065c1a1d", + expectMatch: false, + }, + { + name: "a different ref", + san: workflowURL + "@refs/heads/attacker", + expectMatch: false, + }, + } + + sanRegex, err := validateSignerWorkflow("github.com", pinnedWorkflow) + require.NoError(t, err) + + matcher, err := verify.NewSANMatcher("", sanRegex) + require.NoError(t, err) + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + err := matcher.Verify(certificate.Summary{SubjectAlternativeName: tc.san}) + if tc.expectMatch { + require.NoError(t, err) + } else { + require.Error(t, err) + } + }) + } +} + +// TestSignerWorkflowSANMatchingObservedNestedRef uses the job_workflow_ref claim +// observed from a workflow run on a branch named "main@refs/heads/evil". Actions runs a +// workflow from such a branch, so a pin on refs/heads/main must not admit it. +func TestSignerWorkflowSANMatchingObservedNestedRef(t *testing.T) { + const observedSAN = "https://github.com/bdehamer/attest-demo/.github/workflows/control-probe.yml@refs/heads/main@refs/heads/evil" + + sanRegex, err := validateSignerWorkflow("github.com", "bdehamer/attest-demo/.github/workflows/control-probe.yml@refs/heads/main") + require.NoError(t, err) + + matcher, err := verify.NewSANMatcher("", sanRegex) + require.NoError(t, err) + + require.Error(t, matcher.Verify(certificate.Summary{SubjectAlternativeName: observedSAN})) +} + // TestSignerWorkflowSANMatchingRepositoryValue covers a --signer-workflow value that // names a repository instead of a workflow. The value is still matched as a complete // identity, so it cannot match a repository whose name merely starts with it. From 8b97df3915dee30e4bae01fb7c5d719e6555c285 Mon Sep 17 00:00:00 2001 From: BagToad <47394200+BagToad@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:11:46 -0600 Subject: [PATCH 14/14] Close the destination root in the archive download test The trunk merge brought in a test that calls downloadAsset directly. destinationWriter now keeps its destination directory open until Close, so on Windows the test could not remove its temporary directory and failed during cleanup. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/release/download/download_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/cmd/release/download/download_test.go b/pkg/cmd/release/download/download_test.go index 6e80495a451..8b440c08ca0 100644 --- a/pkg/cmd/release/download/download_test.go +++ b/pkg/cmd/release/download/download_test.go @@ -1201,6 +1201,7 @@ func Test_downloadAsset_archiveAvoidsLegacyCodeload(t *testing.T) { tempDir := t.TempDir() dest := destinationWriter{dir: tempDir} + defer dest.Close() err := downloadAsset(&dest, ts.Client(), safeurl.NewImmutableSafeURL(ts.URL+"/asset"), "", true) require.NoError(t, err)