├── LICENSE ├── README.md ├── gistfs.go ├── gistfs_test.go ├── go.mod └── go.sum /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Jean Hadrien Chabran 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GistFS 2 | 3 | GistFS is an `io/fs` implementation that enables to read files stored in a given Gist. 4 | 5 | ## Requirements 6 | 7 | This module depends on `io/fs` which is only available since [go 1.16](https://tip.golang.org/doc/go1.16). 8 | 9 | ## Usage 10 | 11 | GistFS is threadsafe. 12 | 13 | ```go 14 | package main 15 | 16 | import ( 17 | "context" 18 | "fmt" 19 | "net/http" 20 | 21 | "github.com/jhchabran/gistfs" 22 | ) 23 | 24 | func main() { 25 | // create a FS based on https://gist.github.com/jhchabran/ded2f6727d98e6b0095e62a7813aa7cf 26 | gfs := gistfs.New("ded2f6727d98e6b0095e62a7813aa7cf") 27 | 28 | // load the remote content once for all, 29 | // ie, no more API calls toward Github will be made. 30 | err := gfs.Load(context.Background()) 31 | if err != nil { 32 | panic(err) 33 | } 34 | 35 | // --- base API 36 | // open the "test1.txt" file 37 | f, err := gfs.Open("test1.txt") 38 | if err != nil { 39 | panic(err) 40 | } 41 | 42 | // read its content 43 | b := make([]byte, 1024) 44 | _, err = f.Read(b) 45 | 46 | if err != nil { 47 | panic(err) 48 | } 49 | 50 | fmt.Println(string(b)) 51 | 52 | // --- ReadFile API 53 | // directly read the "test1.txt" file 54 | b, err = gfs.ReadFile("test1.txt") 55 | if err != nil { 56 | panic(err) 57 | } 58 | 59 | fmt.Println(string(b)) 60 | 61 | // --- ReadDir API 62 | // there is only one directory in a gistfile, the root dir "." 63 | files, err := gfs.ReadDir(".") 64 | if err != nil { 65 | panic(err) 66 | } 67 | 68 | for _, entry := range files { 69 | fmt.Println(entry.Name()) 70 | } 71 | 72 | // --- Serve the files from the gists over http 73 | http.ListenAndServe(":8080", http.FileServer(http.FS(gfs))) 74 | } 75 | ``` 76 | 77 | ## See also 78 | 79 | - [io/fs godoc](https://pkg.go.dev/io/fs) 80 | -------------------------------------------------------------------------------- /gistfs.go: -------------------------------------------------------------------------------- 1 | package gistfs 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "io/fs" 10 | "sync" 11 | "time" 12 | 13 | "github.com/google/go-github/v33/github" 14 | ) 15 | 16 | // Ensure io/fs interfaces are implemented 17 | var ( 18 | _ fs.ReadDirFS = (*FS)(nil) 19 | _ fs.ReadFileFS = (*FS)(nil) 20 | 21 | _ fs.FileInfo = (*file)(nil) 22 | _ fs.DirEntry = (*file)(nil) 23 | _ fs.ReadDirFile = (*file)(nil) 24 | ) 25 | 26 | // ErrNotLoaded is an error that signals that the filesystem is being used 27 | // while not previously loaded. 28 | var ErrNotLoaded = fmt.Errorf("gist not loaded: %w", fs.ErrInvalid) 29 | 30 | // FS represents a filesystem based on a Github Gist. 31 | type FS struct { 32 | id string 33 | client *github.Client 34 | gist *github.Gist 35 | mu sync.RWMutex 36 | } 37 | 38 | // New returns a FS based on a given Gist ID, without the username portion. 39 | // Example "https://gist.github.com/jhchabran/ded2f6727d98e6b0095e62a7813aa7cf" 40 | // id = "ded2f6727d98e6b0095e62a7813aa7cf" 41 | func New(id string) *FS { 42 | return &FS{ 43 | client: github.NewClient(nil), 44 | id: id, 45 | } 46 | } 47 | 48 | // NewWithClient returns a FS based on a given Gist ID and a given Github Client. 49 | // Providing an authenticated client or a client with a custom http.Client are 50 | // possible use cases. 51 | func NewWithClient(client *github.Client, id string) *FS { 52 | return &FS{ 53 | client: client, 54 | id: id, 55 | } 56 | } 57 | 58 | // GetID returns the Github Gist ID that the filesystem was created with 59 | func (fsys *FS) GetID() string { 60 | return fsys.id 61 | } 62 | 63 | // Load fetches the gist content from github, making the file system ready 64 | // for use. If the underlying Github API call fails, it will return its error. 65 | func (fsys *FS) Load(ctx context.Context) error { 66 | fsys.mu.Lock() 67 | defer fsys.mu.Unlock() 68 | 69 | gist, _, err := fsys.client.Gists.Get(ctx, fsys.id) 70 | if err != nil { 71 | return err 72 | } 73 | 74 | fsys.gist = gist 75 | 76 | return nil 77 | } 78 | 79 | // file represents a file stored in a Gist and implements fs.File methods. 80 | // It is built out of a github.GistFile. 81 | type file struct { 82 | gistFile *github.GistFile 83 | modtime time.Time 84 | reader io.Reader 85 | mu sync.Mutex 86 | } 87 | 88 | // Open opens the named file for reading and return it as an fs.File. 89 | func (fsys *FS) Open(name string) (fs.File, error) { 90 | fsys.mu.RLock() 91 | defer fsys.mu.RUnlock() 92 | 93 | if fsys.gist == nil { 94 | return nil, ErrNotLoaded 95 | } 96 | 97 | if name == "./" || name == "." { 98 | return fsys.openRoot(), nil 99 | } 100 | 101 | f, ok := fsys.gist.Files[github.GistFilename(name)] 102 | if !ok { 103 | return nil, &fs.PathError{Op: "read", Path: name, Err: fs.ErrNotExist} 104 | } 105 | 106 | return fsys.wrapFile(&f), nil 107 | } 108 | 109 | // wrapFile wraps a github.GistFile into a file, which implements 110 | // the fs.File interface. 111 | func (fsys *FS) wrapFile(f *github.GistFile) *file { 112 | return &file{ 113 | gistFile: f, 114 | reader: bytes.NewReader([]byte(f.GetContent())), 115 | modtime: fsys.gist.GetUpdatedAt(), 116 | } 117 | } 118 | 119 | // ReadFile reads and returns the content of the named file. 120 | func (fsys *FS) ReadFile(name string) ([]byte, error) { 121 | fsys.mu.RLock() 122 | defer fsys.mu.RUnlock() 123 | 124 | if fsys.gist == nil { 125 | return nil, ErrNotLoaded 126 | } 127 | 128 | gistFile, ok := fsys.gist.Files[github.GistFilename(name)] 129 | if !ok { 130 | return nil, &fs.PathError{Op: "read", Path: name, Err: fs.ErrNotExist} 131 | } 132 | 133 | return []byte(gistFile.GetContent()), nil 134 | } 135 | 136 | // ReadDir reads and returns the entire named directory, which contains 137 | // all files that are stored in the Gist supporting the filesystem. 138 | // 139 | // Becaus a Github Gist can't have folders, the only directory that exists 140 | // is the root directory, named "." or "./". 141 | func (fsys *FS) ReadDir(name string) ([]fs.DirEntry, error) { 142 | fsys.mu.RLock() 143 | defer fsys.mu.RUnlock() 144 | 145 | if fsys.gist == nil { 146 | return nil, ErrNotLoaded 147 | } 148 | 149 | if name != "." && name != "./" { 150 | return nil, &fs.PathError{Op: "read", Path: name, Err: fs.ErrNotExist} 151 | } 152 | 153 | return fsys.openRoot().(*rootDir).ReadDir(-1) 154 | } 155 | 156 | func (f *file) isClosed() bool { 157 | return f.reader == nil 158 | } 159 | 160 | func (f *file) Read(b []byte) (int, error) { 161 | f.mu.Lock() 162 | defer f.mu.Unlock() 163 | 164 | if f.isClosed() { 165 | return 0, fs.ErrClosed 166 | } 167 | 168 | return f.reader.Read(b) 169 | } 170 | 171 | func (f *file) Close() error { 172 | f.mu.Lock() 173 | defer f.mu.Unlock() 174 | 175 | f.gistFile = nil 176 | f.reader = nil 177 | 178 | return nil 179 | } 180 | 181 | // Stat provides stat about the file. The modtime notably, is set to 182 | // when the underlying Gist was last updated. 183 | func (f *file) Stat() (fs.FileInfo, error) { 184 | f.mu.Lock() 185 | defer f.mu.Unlock() 186 | 187 | if f.isClosed() { 188 | return nil, fs.ErrClosed 189 | } 190 | 191 | return f, nil 192 | } 193 | 194 | func (f *file) Name() string { return f.gistFile.GetFilename() } 195 | func (f *file) Size() int64 { return int64(f.gistFile.GetSize()) } 196 | 197 | // Mode always return 0444. 198 | func (f *file) Mode() fs.FileMode { return 0444 } 199 | 200 | // ModTime always return the time of the underlying gist last update. 201 | func (f *file) ModTime() time.Time { return f.modtime } 202 | 203 | func (f *file) IsDir() bool { return false } 204 | func (f *file) Sys() interface{} { return f.gistFile } 205 | func (f *file) Type() fs.FileMode { return f.Mode().Type() } 206 | func (f *file) Info() (fs.FileInfo, error) { return f, nil } 207 | 208 | func (f *file) ReadDir(count int) ([]fs.DirEntry, error) { 209 | return nil, &fs.PathError{ 210 | Op: "read", 211 | Path: f.Name(), 212 | Err: errors.New("is not a directory"), 213 | } 214 | } 215 | 216 | type rootDir struct { 217 | files []*file 218 | offset int 219 | modtime time.Time 220 | mu sync.Mutex 221 | } 222 | 223 | func (fsys *FS) openRoot() fs.File { 224 | files := make([]*file, 0, len(fsys.gist.Files)) 225 | for _, f := range fsys.gist.Files { 226 | files = append(files, fsys.wrapFile(&f)) 227 | } 228 | 229 | return &rootDir{ 230 | files: files, 231 | modtime: fsys.gist.GetUpdatedAt(), 232 | } 233 | } 234 | 235 | func (d *rootDir) Close() error { return nil } 236 | func (d *rootDir) Stat() (fs.FileInfo, error) { return d, nil } 237 | func (d *rootDir) Name() string { return "./" } 238 | func (d *rootDir) Size() int64 { return 0 } 239 | func (d *rootDir) Mode() fs.FileMode { return fs.ModeDir | 0444 } 240 | 241 | // ModTime always return the time of the underlying gist last update. 242 | func (d *rootDir) ModTime() time.Time { return d.modtime } 243 | 244 | func (d *rootDir) IsDir() bool { return true } 245 | func (d *rootDir) Type() fs.FileMode { return d.Mode().Type() } 246 | func (d *rootDir) Sys() interface{} { return nil } 247 | 248 | func (d *rootDir) Read(b []byte) (int, error) { 249 | return 0, &fs.PathError{ 250 | Op: "read", 251 | Path: d.Name(), 252 | Err: errors.New("is a directory"), 253 | } 254 | } 255 | 256 | func (d *rootDir) ReadDir(count int) ([]fs.DirEntry, error) { 257 | d.mu.Lock() 258 | defer d.mu.Unlock() 259 | 260 | n := len(d.files) - d.offset 261 | 262 | if count > 0 && n > count { 263 | n = count 264 | } 265 | 266 | if n == 0 { 267 | if count <= 0 { 268 | return nil, nil 269 | } 270 | } 271 | 272 | files := make([]fs.DirEntry, n) 273 | for i := range files { 274 | files[i] = d.files[d.offset+i] 275 | } 276 | 277 | d.offset += n 278 | 279 | return files, nil 280 | } 281 | -------------------------------------------------------------------------------- /gistfs_test.go: -------------------------------------------------------------------------------- 1 | package gistfs 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "io/fs" 7 | "net/http" 8 | "testing" 9 | "time" 10 | 11 | "github.com/google/go-github/v33/github" 12 | "github.com/gregjones/httpcache" 13 | ) 14 | 15 | var referenceGistID = "ded2f6727d98e6b0095e62a7813aa7cf" 16 | var approxModTime, _ = time.Parse("2000-12-31", "2020-01-02") // when the gist was last edited 17 | 18 | // Avoid burning rate limit by using a caching http client. 19 | func cachingClient() *github.Client { 20 | c := &http.Client{ 21 | Transport: httpcache.NewMemoryCacheTransport(), 22 | } 23 | 24 | return github.NewClient(c) 25 | } 26 | 27 | var cacheClient = cachingClient() 28 | 29 | func TestErrorNotLoaded(t *testing.T) { 30 | if !errors.Is(ErrNotLoaded, fs.ErrInvalid) { 31 | t.Fatal("Err not loaded is not a wrapped ErrInvalid") 32 | } 33 | } 34 | 35 | func TestNew(t *testing.T) { 36 | t.Run("New OK", func(t *testing.T) { 37 | gfs := New(referenceGistID) 38 | if got, want := gfs.GetID(), referenceGistID; got != want { 39 | t.Fatalf("New returned a FS with ID=%#v, want %#v", got, want) 40 | } 41 | }) 42 | 43 | t.Run("NewWithClient OK", func(t *testing.T) { 44 | gfs := NewWithClient(cacheClient, referenceGistID) 45 | if got, want := gfs.GetID(), referenceGistID; got != want { 46 | t.Fatalf("NewWithClient returned a FS with ID=%#v, want %#v", got, want) 47 | } 48 | }) 49 | } 50 | 51 | func TestOpen(t *testing.T) { 52 | t.Run("Open OK", func(t *testing.T) { 53 | gfs := NewWithClient(cacheClient, referenceGistID) 54 | gfs.Load(context.Background()) 55 | 56 | tests := []struct { 57 | name string 58 | err error 59 | }{ 60 | {"test1.txt", nil}, 61 | {"test2.txt", nil}, 62 | {"non-existing-file.txt", fs.ErrNotExist}, 63 | } 64 | 65 | for _, test := range tests { 66 | f, err := gfs.Open(test.name) 67 | 68 | if test.err != nil && !errors.Is(err, test.err) { 69 | t.Fatalf("Opened %#v, got error %#v, want %#v", test.name, err, test.err) 70 | } 71 | 72 | if test.err == nil && f == nil { 73 | t.Fatalf("Opened %#v, got nil", test.name) 74 | } 75 | } 76 | }) 77 | 78 | t.Run("Open NOK not loaded", func(t *testing.T) { 79 | gfs := NewWithClient(cacheClient, referenceGistID) 80 | _, err := gfs.Open("test1.txt") 81 | 82 | if err == nil { 83 | t.Fatalf("Opened without loading, got no error, want %#v", ErrNotLoaded) 84 | } else if err != ErrNotLoaded { 85 | t.Fatalf("Opened without loading, got an error %#v, want %#v", err, ErrNotLoaded) 86 | } 87 | }) 88 | } 89 | 90 | func TestReadFile(t *testing.T) { 91 | t.Run("ReadFile OK", func(t *testing.T) { 92 | gfs := NewWithClient(cacheClient, referenceGistID) 93 | gfs.Load(context.Background()) 94 | 95 | tests := []struct { 96 | name string 97 | content string 98 | err error 99 | }{ 100 | {"test1.txt", "foobar\nbarfoo", nil}, 101 | {"test2.txt", "olala\n12345\nabcde", nil}, 102 | {"non-existing-file.txt", "", fs.ErrNotExist}, 103 | } 104 | 105 | for _, test := range tests { 106 | b, err := gfs.ReadFile(test.name) 107 | 108 | if test.err != nil && !errors.Is(err, test.err) { 109 | t.Fatalf("Read file %#v, got error %#v, want %#v", test.name, err, test.err) 110 | } 111 | 112 | if test.err == nil && b == nil { 113 | t.Fatalf("Read file %#v, got nil", test.name) 114 | } 115 | 116 | if test.content != string(b) { 117 | t.Fatalf("Read file %#v, expected %#v but got %#v", test.name, test.content, string(b)) 118 | } 119 | } 120 | }) 121 | 122 | } 123 | 124 | func TestRead(t *testing.T) { 125 | gfs := NewWithClient(cacheClient, referenceGistID) 126 | gfs.Load(context.Background()) 127 | 128 | t.Run("Read OK", func(t *testing.T) { 129 | f, err := gfs.Open("test1.txt") 130 | if err != nil { 131 | t.Fatalf("Opened file and got an error %#v, want no error", err) 132 | } 133 | 134 | b := make([]byte, len("foobar\n")) 135 | n, err := f.Read(b) 136 | 137 | if err != nil { 138 | t.Fatalf("Read and got an error %#v, want no error", err) 139 | } 140 | 141 | if got, want := n, len("foobar\n"); got != want { 142 | t.Fatalf("Read %d bytes in b, want %d bytes", got, want) 143 | } 144 | 145 | if got, want := string(b), "foobar\n"; got != want { 146 | t.Fatalf("Read %d bytes in b (%#v), want %#v", n, got, want) 147 | } 148 | }) 149 | 150 | t.Run("Read NOK closed file", func(t *testing.T) { 151 | f, err := gfs.Open("test1.txt") 152 | if err != nil { 153 | t.Fatalf("Opened file and got an error %#v, want no error", err) 154 | } 155 | 156 | b := make([]byte, len("foobar\n")) 157 | _ = f.Close() 158 | _, err = f.Read(b) 159 | 160 | if got, want := err, fs.ErrClosed; got != want { 161 | t.Fatalf("Read on a closed file and got %#v, want %#v", got, want) 162 | } 163 | }) 164 | } 165 | 166 | func TestStat(t *testing.T) { 167 | gfs := NewWithClient(cacheClient, referenceGistID) 168 | gfs.Load(context.Background()) 169 | 170 | t.Run("Stat OK", func(t *testing.T) { 171 | 172 | tests := []struct { 173 | filename string 174 | err error 175 | size int64 176 | mode fs.FileMode 177 | approxModTime time.Time 178 | isDir bool 179 | }{ 180 | { 181 | filename: "test1.txt", 182 | err: nil, 183 | size: int64(len("foobar\nbarfoo")), 184 | mode: fs.FileMode(0444), 185 | approxModTime: approxModTime, 186 | isDir: false, 187 | }, 188 | { 189 | filename: "test2.txt", 190 | err: nil, 191 | size: int64(len("olala\n12345\nabcde")), 192 | mode: fs.FileMode(0444), 193 | approxModTime: approxModTime, 194 | isDir: false, 195 | }, 196 | } 197 | 198 | for _, test := range tests { 199 | f, err := gfs.Open(test.filename) 200 | if err != nil { 201 | t.Fatalf("Opened file and got an error %#v, want no error", err) 202 | } 203 | 204 | stat, err := f.Stat() 205 | 206 | if err != test.err { 207 | t.Fatalf("Stat and got an error %#v, want %#v", err, test.err) 208 | } 209 | 210 | if test.err == nil { 211 | if got, want := stat.Name(), test.filename; got != want { 212 | t.Fatalf("got filename %#v, want %#v", got, want) 213 | } 214 | 215 | if got, want := stat.Size(), test.size; got != want { 216 | t.Fatalf("got size %#v, want %#v", got, want) 217 | } 218 | 219 | if got, want := stat.Mode(), test.mode; got != want { 220 | t.Fatalf("got mode %#v, want %#v", got, want) 221 | } 222 | 223 | if got, want := stat.ModTime(), test.approxModTime; got.After(approxModTime) && 224 | got.Before(test.approxModTime.Add(24*time.Hour)) { 225 | t.Fatalf("got modTime %#v, want approx %#v", got, want) 226 | } 227 | 228 | if got, want := stat.IsDir(), test.isDir; got != want { 229 | t.Fatalf("got isDir %#v, want %#v", got, want) 230 | } 231 | 232 | _, ok := stat.Sys().(*github.GistFile) 233 | if got, want := ok, true; got != want { 234 | t.Fatal("got Sys with type different from *github.GistFile, want it to be the case") 235 | } 236 | } 237 | } 238 | }) 239 | 240 | t.Run("Stat NOK closed file", func(t *testing.T) { 241 | f, err := gfs.Open("test1.txt") 242 | if err != nil { 243 | t.Fatalf("Opened file and got an error %#v, want no error", err) 244 | } 245 | 246 | _ = f.Close() 247 | _, err = f.Stat() 248 | 249 | if got, want := err, fs.ErrClosed; got != want { 250 | t.Fatalf("Read on a closed file and got %#v, want %#v", got, want) 251 | } 252 | }) 253 | } 254 | 255 | func TestReadDir(t *testing.T) { 256 | gfs := NewWithClient(cacheClient, referenceGistID) 257 | gfs.Load(context.Background()) 258 | 259 | t.Run("OK", func(t *testing.T) { 260 | file, err := gfs.Open(".") 261 | if err != nil { 262 | t.Fatalf("Opening root directory, expected no error but got %#v", err) 263 | } 264 | 265 | dir, ok := file.(fs.ReadDirFile) 266 | if !ok { 267 | t.Fatal("Reading root directory, expected a ReadDirFile but got something else") 268 | } 269 | 270 | files, err := dir.ReadDir(-1) 271 | if err != nil { 272 | t.Fatalf("Reading root directory, expected no error but got %#v", err) 273 | } 274 | 275 | if got, want := len(files), 2; got != want { 276 | t.Fatalf("Reading root directory, got %#v files, want %#v", got, want) 277 | } 278 | }) 279 | 280 | t.Run("NOK non existing dir", func(t *testing.T) { 281 | tests := []string{"/", "non-existing/", "..", "../"} 282 | 283 | for _, test := range tests { 284 | _, err := gfs.Open(test) 285 | 286 | var pathError *fs.PathError 287 | if !errors.As(err, &pathError) { 288 | t.Fatalf("Reading a non existing directory %#v, got %#v error, want %T", test, err, &fs.PathError{}) 289 | } 290 | } 291 | }) 292 | 293 | t.Run("OK subsequent reads", func(t *testing.T) { 294 | file, err := gfs.Open(".") 295 | if err != nil { 296 | t.Fatalf("Opening root directory, expected no error but got %#v", err) 297 | } 298 | 299 | dir, ok := file.(fs.ReadDirFile) 300 | if !ok { 301 | t.Fatal("Reading root directory, expected a ReadDirFile but got something else") 302 | } 303 | 304 | // first read 305 | files, err := dir.ReadDir(1) 306 | if err != nil { 307 | t.Fatalf("Reading root directory, expected no error but got %#v", err) 308 | } 309 | 310 | if got, want := len(files), 1; got != want { 311 | t.Fatalf("Reading root directory, got %#v files, want %#v", got, want) 312 | } 313 | 314 | // second read 315 | files, err = dir.ReadDir(1) 316 | if err != nil { 317 | t.Fatalf("Reading root directory, expected no error but got %#v", err) 318 | } 319 | 320 | if got, want := len(files), 1; got != want { 321 | t.Fatalf("Reading root directory, got %#v files, want %#v", got, want) 322 | } 323 | 324 | // last read (no entries left) 325 | files, err = dir.ReadDir(1) 326 | if err != nil { 327 | t.Fatalf("Reading root directory, expected no error but got %#v", err) 328 | } 329 | 330 | if got, want := len(files), 0; got != want { 331 | t.Fatalf("Reading root directory, got %#v files, want %#v", got, want) 332 | } 333 | }) 334 | 335 | t.Run("OK ReadDir", func(t *testing.T) { 336 | files, err := gfs.ReadDir(".") 337 | if err != nil { 338 | t.Fatalf("Reading root directory, expected no error but got %#v", err) 339 | } 340 | 341 | if got, want := len(files), 2; got != want { 342 | t.Fatalf("Reading root directory, got %#v files, want %#v", got, want) 343 | } 344 | }) 345 | 346 | t.Run("NOK ReadDir on a file", func(t *testing.T) { 347 | file, err := gfs.Open("test1.txt") 348 | if err != nil { 349 | t.Fatalf("Opening root directory, expected no error but got %#v", err) 350 | } 351 | 352 | dir, ok := file.(fs.ReadDirFile) 353 | if !ok { 354 | t.Fatal("Reading root directory, expected a ReadDirFile but got something else") 355 | } 356 | 357 | _, err = dir.ReadDir(-1) 358 | 359 | var pathError *fs.PathError 360 | if !errors.As(err, &pathError) { 361 | t.Fatalf("Reading directory on a file, got %#v error, want a %T", err, &fs.PathError{}) 362 | } 363 | }) 364 | 365 | t.Run("NOK Read on a directory", func(t *testing.T) { 366 | file, err := gfs.Open(".") 367 | if err != nil { 368 | t.Fatalf("Opening root directory, expected no error but got %#v", err) 369 | } 370 | 371 | dir, ok := file.(fs.ReadDirFile) 372 | if !ok { 373 | t.Fatal("Reading root directory, expected a ReadDirFile but got something else") 374 | } 375 | 376 | b := make([]byte, 1) 377 | _, err = dir.Read(b) 378 | 379 | var pathError *fs.PathError 380 | if !errors.As(err, &pathError) { 381 | t.Fatalf("Reading bytes on a directory, got %#v error, want %T", err, &fs.PathError{}) 382 | } 383 | }) 384 | 385 | t.Run("OK Stat", func(t *testing.T) { 386 | file, err := gfs.Open(".") 387 | if err != nil { 388 | t.Fatalf("Opening root directory, expected no error but got %#v", err) 389 | } 390 | 391 | dir, ok := file.(fs.ReadDirFile) 392 | if !ok { 393 | t.Fatal("Reading root directory, expected a ReadDirFile but got something else") 394 | } 395 | 396 | stat, err := dir.Stat() 397 | if err != nil { 398 | t.Fatalf("Getting stat of root directory, expected no error but got %#v", err) 399 | } 400 | 401 | if got, want := stat.Name(), "./"; got != want { 402 | t.Fatalf("Reading name of root directory, got %#v files, want %#v", got, want) 403 | } 404 | 405 | if got, want := stat.ModTime(), approxModTime; got.After(approxModTime) && 406 | got.Before(approxModTime.Add(24*time.Hour)) { 407 | t.Fatalf("got modTime %#v, want approx %#v", got, want) 408 | } 409 | 410 | if got, want := stat.IsDir(), true; got != want { 411 | t.Fatalf("got isDir %#v, want %#v", got, want) 412 | } 413 | }) 414 | } 415 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jhchabran/gistfs 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/google/go-github/v33 v33.0.0 7 | github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 8 | ) 9 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 2 | github.com/google/go-github/v33 v33.0.0 h1:qAf9yP0qc54ufQxzwv+u9H0tiVOnPJxo0lI/JXqw3ZM= 3 | github.com/google/go-github/v33 v33.0.0/go.mod h1:GMdDnVZY/2TsWgp/lkYnpSAh6TrzhANBBwm6k6TTEXg= 4 | github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= 5 | github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= 6 | github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= 7 | github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= 8 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M= 9 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 10 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 11 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 12 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 13 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 14 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 15 | --------------------------------------------------------------------------------