diff --git a/basepath.go b/basepath.go index 2e72793a..54b9186a 100644 --- a/basepath.go +++ b/basepath.go @@ -55,12 +55,16 @@ func (b *BasePathFs) RealPath(name string) (path string, err error) { return name, err } + cleanName := filepath.Clean(name) + + if b.path == "" { + return cleanName, nil + } bpath := filepath.Clean(b.path) - path = filepath.Clean(filepath.Join(bpath, name)) + path = filepath.Join(bpath, cleanName) if !strings.HasPrefix(path, bpath) { return name, os.ErrNotExist } - return path, nil } diff --git a/githubfs.go b/githubfs.go new file mode 100644 index 00000000..a6476e20 --- /dev/null +++ b/githubfs.go @@ -0,0 +1,292 @@ +package afero + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "github.com/google/go-github/v62/github" +) + +// GitHubFS implements afero.Fs for a GitHub repository or a base filesystem +type GitHubFS struct { + client *github.Client + owner string + repo string + branch string // Optional, defaults to "main" + token string // Optional, for authenticated requests + base Fs // Optional base filesystem for testing +} + +// NewGitHubFS creates a new GitHubFS instance for a GitHub repository +func NewGitHubFS(owner, repo, branch, token string) *GitHubFS { + client := github.NewClient(nil) + if token != "" { + client = github.NewClient(nil).WithAuthToken(token) + } + if branch == "" { + branch = "main" // Default branch + } + return &GitHubFS{ + client: client, + owner: owner, + repo: repo, + branch: branch, + token: token, + } +} + +// NewGitHubFSWithBase creates a GitHubFS instance with a base filesystem for testing +func NewGitHubFSWithBase(base Fs) *GitHubFS { + return &GitHubFS{ + base: base, + } +} + +// Open opens a file from the base filesystem or GitHub repository +func (fs *GitHubFS) Open(name string) (File, error) { + cleanPath := filepath.Clean(name) + if cleanPath == "." || cleanPath == "/" || cleanPath == "" { + cleanPath = "" // Normalize root path + } + + // Use base filesystem if provided (for testing) + if fs.base != nil { + return fs.base.Open(cleanPath) + } + + // Otherwise, fetch from GitHub + ctx := context.Background() + fileContent, dirContent, resp, err := fs.client.Repositories.GetContents(ctx, fs.owner, fs.repo, cleanPath, &github.RepositoryContentGetOptions{Ref: fs.branch}) + if err != nil { + return nil, &os.PathError{Op: "open", Path: name, Err: os.ErrNotExist} + } + if resp.StatusCode != 200 { + return nil, &os.PathError{Op: "open", Path: name, Err: os.ErrNotExist} + } + + if fileContent != nil { + content, err := fileContent.GetContent() + if err != nil { + return nil, err + } + return &GitHubFile{fs: fs, path: cleanPath, content: []byte(content)}, nil + } + if dirContent != nil { + return &GitHubFile{fs: fs, path: cleanPath, isDir: true, dirEntries: dirContent}, nil + } + return nil, &os.PathError{Op: "open", Path: name, Err: os.ErrNotExist} +} + +// Stat returns file info for a path +func (fs *GitHubFS) Stat(name string) (os.FileInfo, error) { + file, err := fs.Open(name) + if err != nil { + return nil, err + } + defer file.Close() + return file.Stat() +} + +// Name returns the filesystem name +func (fs *GitHubFS) Name() string { return "GitHubFS" } + +// Create is not supported (read-only) +func (fs *GitHubFS) Create(name string) (File, error) { + return nil, fmt.Errorf("GitHubFS is read-only") +} + +// Mkdir is not supported (read-only) +func (fs *GitHubFS) Mkdir(name string, perm os.FileMode) error { + return fmt.Errorf("GitHubFS is read-only") +} + +// MkdirAll is not supported (read-only) +func (fs *GitHubFS) MkdirAll(path string, perm os.FileMode) error { + return fmt.Errorf("GitHubFS is read-only") +} + +// OpenFile is not supported for writing (read-only) +func (fs *GitHubFS) OpenFile(name string, flag int, perm os.FileMode) (File, error) { + if flag&(os.O_WRONLY|os.O_RDWR|os.O_CREATE|os.O_TRUNC) != 0 { + return nil, fmt.Errorf("GitHubFS is read-only") + } + return fs.Open(name) +} + +// Remove is not supported (read-only) +func (fs *GitHubFS) Remove(name string) error { + return fmt.Errorf("GitHubFS is read-only") +} + +// RemoveAll is not supported (read-only) +func (fs *GitHubFS) RemoveAll(path string) error { + return fmt.Errorf("GitHubFS is read-only") +} + +// Rename is not supported (read-only) +func (fs *GitHubFS) Rename(oldname, newname string) error { + return fmt.Errorf("GitHubFS is read-only") +} + +// Chmod is not supported (read-only) +func (fs *GitHubFS) Chmod(name string, mode os.FileMode) error { + return fmt.Errorf("GitHubFS is read-only") +} + +// Chtimes is not supported (read-only) +func (fs *GitHubFS) Chtimes(name string, atime, mtime time.Time) error { + return fmt.Errorf("GitHubFS is read-only") +} + +// GitHubFile represents a file or directory in GitHubFS +type GitHubFile struct { + fs *GitHubFS + path string + content []byte + isDir bool + dirEntries []*github.RepositoryContent + offset int64 +} + +// Close does nothing (no resources to release) +func (f *GitHubFile) Close() error { return nil } + +// Read reads file content +func (f *GitHubFile) Read(b []byte) (int, error) { + if f.isDir { + return 0, fmt.Errorf("cannot read from directory") + } + if f.offset >= int64(len(f.content)) { + return 0, io.EOF + } + n := copy(b, f.content[f.offset:]) + f.offset += int64(n) + return n, nil +} + +// ReadAt reads file content at an offset +func (f *GitHubFile) ReadAt(b []byte, off int64) (int, error) { + if f.isDir { + return 0, fmt.Errorf("cannot read from directory") + } + if off >= int64(len(f.content)) { + return 0, io.EOF + } + n := copy(b, f.content[off:]) + return n, nil +} + +// Seek adjusts the read offset +func (f *GitHubFile) Seek(offset int64, whence int) (int64, error) { + if f.isDir { + return 0, fmt.Errorf("cannot seek in directory") + } + switch whence { + case io.SeekStart: + f.offset = offset + case io.SeekCurrent: + f.offset += offset + case io.SeekEnd: + f.offset = int64(len(f.content)) + offset + } + if f.offset < 0 { + f.offset = 0 + } + if f.offset > int64(len(f.content)) { + f.offset = int64(len(f.content)) + } + return f.offset, nil +} + +// Write is not supported (read-only) +func (f *GitHubFile) Write(b []byte) (int, error) { + return 0, fmt.Errorf("GitHubFS is read-only") +} + +// WriteAt is not supported (read-only) +func (f *GitHubFile) WriteAt(b []byte, off int64) (int, error) { + return 0, fmt.Errorf("GitHubFS is read-only") +} + +// Name returns the file or directory name +func (f *GitHubFile) Name() string { + return filepath.Base(f.path) +} + +// Readdir reads directory entries +func (f *GitHubFile) Readdir(count int) ([]os.FileInfo, error) { + if !f.isDir { + return nil, fmt.Errorf("not a directory") + } + var infos []os.FileInfo + for _, entry := range f.dirEntries { + if entry.Name == nil || entry.Type == nil || entry.Size == nil { + continue // Skip invalid entries + } + infos = append(infos, &GitHubFileInfo{ + name: *entry.Name, + size: int64(*entry.Size), + isDir: *entry.Type == "dir", + modTime: time.Now(), // Placeholder, GitHub API lacks mod time + }) + } + if count <= 0 || count >= len(infos) { + return infos, nil + } + return infos[:count], nil +} + +// Readdirnames reads directory entry names +func (f *GitHubFile) Readdirnames(count int) ([]string, error) { + infos, err := f.Readdir(count) + if err != nil { + return nil, err + } + names := make([]string, len(infos)) + for i, info := range infos { + names[i] = info.Name() + } + return names, nil +} + +// Stat returns file info +func (f *GitHubFile) Stat() (os.FileInfo, error) { + if f.isDir { + return &GitHubFileInfo{name: f.Name(), isDir: true}, nil + } + return &GitHubFileInfo{name: f.Name(), size: int64(len(f.content))}, nil +} + +// Sync is not supported (read-only) +func (f *GitHubFile) Sync() error { return fmt.Errorf("GitHubFS is read-only") } + +// Truncate is not supported (read-only) +func (f *GitHubFile) Truncate(size int64) error { return fmt.Errorf("GitHubFS is read-only") } + +// WriteString is not supported (read-only) +func (f *GitHubFile) WriteString(s string) (int, error) { + return 0, fmt.Errorf("GitHubFS is read-only") +} + +type GitHubFileInfo struct { + name string + size int64 + isDir bool + modTime time.Time +} + +func (i *GitHubFileInfo) Name() string { return i.name } +func (i *GitHubFileInfo) Size() int64 { return i.size } +func (i *GitHubFileInfo) Mode() os.FileMode { + if i.isDir { + return os.ModeDir | 0755 + } + return 0644 +} +func (i *GitHubFileInfo) ModTime() time.Time { return i.modTime } +func (i *GitHubFileInfo) IsDir() bool { return i.isDir } +func (i *GitHubFileInfo) Sys() interface{} { return nil } diff --git a/githubfs_test.go b/githubfs_test.go new file mode 100644 index 00000000..f0a91c44 --- /dev/null +++ b/githubfs_test.go @@ -0,0 +1,103 @@ +package afero + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGitHubFS(t *testing.T) { + mockFs := NewMemMapFs() + err := mockFs.Mkdir("testdir", 0755) + if err != nil { + t.Fatalf("Failed to setup mock directory: %v", err) + } + for _, filename := range []string{"README.md", "LICENSE"} { + f, err := mockFs.Create(filename) + if err != nil { + t.Fatalf("Failed to create mock file %s: %v", filename, err) + } + _, err = f.Write([]byte("mock content")) + if err != nil { + t.Fatalf("Failed to write to mock file %s: %v", filename, err) + } + f.Close() + } + + fs := NewGitHubFSWithBase(mockFs) + + tests := []struct { + name string + path string + wantFile bool + wantDir bool + wantNames []string + wantErr bool + }{ + { + name: "Root directory", + path: "", + wantDir: true, + wantNames: []string{"README.md", "LICENSE", "testdir"}, + wantErr: false, + }, + { + name: "README file", + path: "README.md", + wantFile: true, + wantErr: false, + }, + { + name: "Non-existent file", + path: "nonexistent.txt", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + file, err := fs.Open(tt.path) + if tt.wantErr { + assert.Error(t, err) + assert.Nil(t, file) + return + } + if err != nil { + t.Fatalf("Failed to open %s: %v", tt.path, err) + } + if file == nil { + t.Fatal("Open returned nil file handle without an error") + } + + info, err := file.Stat() + assert.NoError(t, err) + if tt.wantFile { + assert.False(t, info.IsDir()) + content, err := io.ReadAll(file) + assert.NoError(t, err) + assert.NotEmpty(t, content) + } + if tt.wantDir { + assert.True(t, info.IsDir()) + names, err := file.Readdirnames(0) + assert.NoError(t, err) + for _, wantName := range tt.wantNames { + assert.Contains(t, names, wantName, "Directory should contain %s", wantName) + } + } + err = file.Close() + assert.NoError(t, err) + }) + } + + t.Run("Write operations fail", func(t *testing.T) { + _, err := fs.Create("test.txt") + assert.Error(t, err) + assert.Contains(t, err.Error(), "read-only") + + err = fs.Mkdir("testdir", 0755) + assert.Error(t, err) + assert.Contains(t, err.Error(), "read-only") + }) +} diff --git a/go.mod b/go.mod index 101c2865..96911688 100644 --- a/go.mod +++ b/go.mod @@ -3,3 +3,12 @@ module github.com/spf13/afero go 1.23.0 require golang.org/x/text v0.23.0 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/google/go-github/v62 v62.0.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/testify v1.10.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum index d00bb390..4f2b7e38 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,17 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-github/v62 v62.0.0 h1:/6mGCaRywZz9MuHyw9gD1CwsbmBX8GWsbFkwMmHdhl4= +github.com/google/go-github/v62 v62.0.0/go.mod h1:EMxeUqGJq2xRu9DYBMwel/mr7kZrzUOfQmmpYrZn2a4= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=