···11+# Binaries for programs and plugins
22+*.exe
33+*.exe~
44+*.dll
55+*.so
66+*.dylib
77+88+# Test binary, built with `go test -c`
99+*.test
1010+1111+# Output of the go coverage tool, specifically when used with LiteIDE
1212+*.out
1313+1414+# Dependency directories (remove the comment below to include it)
1515+# vendor/
1616+1717+.env
1818+.DS_Store
1919+tmp/
+21
internal/s3fs/LICENSE
···11+MIT License
22+33+Copyright (c) 2022 Austin Poor
44+55+Permission is hereby granted, free of charge, to any person obtaining a copy
66+of this software and associated documentation files (the "Software"), to deal
77+in the Software without restriction, including without limitation the rights
88+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
99+copies of the Software, and to permit persons to whom the Software is
1010+furnished to do so, subject to the following conditions:
1111+1212+The above copyright notice and this permission notice shall be included in all
1313+copies or substantial portions of the Software.
1414+1515+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1616+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1717+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1818+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1919+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
2020+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2121+SOFTWARE.
+5
internal/s3fs/README.md
···11+# s3fs
22+33+_created by Austin Poor_
44+55+An implementation of the [go-billy `Filesystem`](https://pkg.go.dev/github.com/go-git/go-billy/v5#Filesystem) for treating an S3 bucket like a filesystem.
+227
internal/s3fs/basic.go
···11+// basic.go implements the interface billy.Basic
22+33+package s3fs
44+55+import (
66+ "context"
77+ "errors"
88+ "fmt"
99+ "io/fs"
1010+ "os"
1111+ "path"
1212+ "strings"
1313+1414+ "github.com/aws/aws-sdk-go-v2/aws"
1515+ "github.com/aws/aws-sdk-go-v2/service/s3"
1616+ "github.com/aws/smithy-go"
1717+ "github.com/go-git/go-billy/v6"
1818+)
1919+2020+const (
2121+ O_RDONLY int = os.O_RDONLY // open the file read-only.
2222+ O_WRONLY int = os.O_WRONLY // open the file write-only.
2323+ O_WRMULTIPART int = 0x4 // open the file for write-only using multipart upload.
2424+2525+ SupportedOFlags = O_RDONLY | O_WRONLY | O_WRMULTIPART // supported open flags for s3fs
2626+)
2727+2828+var (
2929+ ErrOpenFlagNotSupported = errors.New("open flag not supported")
3030+)
3131+3232+// Create implements billy.Basic
3333+// Create creates the named file with mode 0666 (before umask), truncating
3434+// it if it already exists. If successful, methods on the returned File can
3535+// be used for I/O; the associated file descriptor has mode O_RDWR.
3636+func (fs3 *S3FS) Create(filename string) (billy.File, error) {
3737+ return fs3.OpenFile(filename, O_WRONLY, 0666)
3838+}
3939+4040+// Open opens the named file for reading. If successful, methods on the
4141+// returned file can be used for reading; the associated file descriptor has
4242+// mode O_RDONLY.
4343+func (fs3 *S3FS) Open(filename string) (billy.File, error) {
4444+ return fs3.OpenFile(filename, O_RDONLY, 0666)
4545+}
4646+4747+// OpenFile is the generalized open call; most users will use Open or Create
4848+// instead. It opens the named file with specified flag (O_RDONLY etc.) and
4949+// perm, (0666 etc.) if applicable. If successful, methods on the returned
5050+// File can be used for I/O.
5151+func (fs3 *S3FS) OpenFile(filename string, flag int, perm os.FileMode) (billy.File, error) {
5252+ // Is the supplied flag supported?
5353+ if flag&SupportedOFlags != flag {
5454+ return nil, errors.New("unsupported open flag")
5555+ }
5656+5757+ // Get the file path
5858+ p := path.Join(fs3.root, filename)
5959+6060+ switch flag & SupportedOFlags {
6161+ case O_RDONLY:
6262+ // The bucket root is always a directory; short-circuit so WASI
6363+ // preopens (which OpenFile(".", O_RDONLY)) don't issue an S3 call.
6464+ key := strings.TrimPrefix(fs3.cleanPath(filename), "/")
6565+ if key == "" || key == "." {
6666+ return newS3DirFile(p, fs3.bucket, fs3.client), nil
6767+ }
6868+6969+ f, err := newS3ReadFile(fs3.client, fs3.bucket, p)
7070+ if err == nil {
7171+ return f, nil
7272+ }
7373+7474+ // If the object simply doesn't exist, the path may still be a
7575+ // directory prefix in S3. Probe for that before giving up.
7676+ var apiErr smithy.APIError
7777+ if !errors.As(err, &apiErr) {
7878+ return nil, err
7979+ }
8080+ switch apiErr.ErrorCode() {
8181+ case "NoSuchKey", "NotFound":
8282+ default:
8383+ return nil, err
8484+ }
8585+8686+ ctx := context.TODO()
8787+ prefix := key + "/"
8888+ maxKeys := int32(1)
8989+ list, lerr := fs3.client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
9090+ Bucket: &fs3.bucket,
9191+ Prefix: &prefix,
9292+ Delimiter: &fs3.separator,
9393+ MaxKeys: &maxKeys,
9494+ })
9595+ if lerr != nil {
9696+ return nil, lerr
9797+ }
9898+ if len(list.Contents) > 0 || len(list.CommonPrefixes) > 0 {
9999+ return newS3DirFile(p, fs3.bucket, fs3.client), nil
100100+ }
101101+ return nil, &os.PathError{Op: "open", Path: filename, Err: fs.ErrNotExist}
102102+103103+ case O_WRONLY:
104104+ return newS3WriteFile(fs3.client, fs3.bucket, p)
105105+106106+ case O_WRMULTIPART:
107107+ return newS3MultipartUploadFile(fs3.client, fs3.bucket, p)
108108+109109+ default:
110110+ return nil, errors.New("unsupported open flag")
111111+ }
112112+}
113113+114114+// Stat returns a FileInfo describing the named file.
115115+func (fs3 *S3FS) Stat(filename string) (os.FileInfo, error) {
116116+ key := strings.TrimPrefix(fs3.cleanPath(filename), "/")
117117+ if key == "" || key == "." {
118118+ return newDirInfo("/"), nil
119119+ }
120120+121121+ ctx := context.TODO()
122122+123123+ head, err := fs3.client.HeadObject(ctx, &s3.HeadObjectInput{
124124+ Bucket: &fs3.bucket,
125125+ Key: &key,
126126+ })
127127+ if err == nil {
128128+ return newFileInfo(
129129+ path.Base(key),
130130+ aws.ToInt64(head.ContentLength),
131131+ aws.ToTime(head.LastModified),
132132+ ), nil
133133+ }
134134+135135+ var apiErr smithy.APIError
136136+ if !errors.As(err, &apiErr) {
137137+ return nil, err
138138+ }
139139+ switch apiErr.ErrorCode() {
140140+ case "NotFound", "NoSuchKey":
141141+ // fall through to directory probe below
142142+ default:
143143+ return nil, err
144144+ }
145145+146146+ prefix := key + "/"
147147+ maxKeys := int32(1)
148148+ list, lerr := fs3.client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
149149+ Bucket: &fs3.bucket,
150150+ Prefix: &prefix,
151151+ Delimiter: &fs3.separator,
152152+ MaxKeys: &maxKeys,
153153+ })
154154+ if lerr != nil {
155155+ return nil, lerr
156156+ }
157157+ if len(list.Contents) > 0 || len(list.CommonPrefixes) > 0 {
158158+ return newDirInfo(path.Base(key)), nil
159159+ }
160160+ return nil, &os.PathError{Op: "stat", Path: filename, Err: fs.ErrNotExist}
161161+}
162162+163163+// Rename renames (moves) oldpath to newpath. If newpath already exists and
164164+// is not a directory, Rename replaces it. OS-specific restrictions may
165165+// apply when oldpath and newpath are in different directories.
166166+func (fs3 *S3FS) Rename(oldpath, newpath string) error {
167167+ // TODO: Validate the paths?
168168+169169+ // Create a context
170170+ ctx := context.TODO() // TODO: Get user-supplied context?
171171+172172+ // Format the paths
173173+ src := path.Join(fs3.root, oldpath)
174174+ dst := path.Join(fs3.root, newpath)
175175+176176+ // Send the copy request
177177+ _, err := fs3.client.CopyObject(ctx, &s3.CopyObjectInput{
178178+ Bucket: &fs3.bucket,
179179+ CopySource: &src,
180180+ Key: &dst,
181181+ })
182182+ if err != nil {
183183+ return fmt.Errorf("failed to rename file: %s", err)
184184+ }
185185+186186+ // Delete the old file
187187+ // TODO: Parse the response?
188188+ _, err = fs3.client.DeleteObject(ctx, &s3.DeleteObjectInput{
189189+ Bucket: &fs3.bucket,
190190+ Key: &src,
191191+ })
192192+ if err != nil {
193193+ return fmt.Errorf("failed to remove file: %s", err)
194194+ }
195195+196196+ return nil
197197+}
198198+199199+// Remove removes the named file or directory.
200200+func (fs3 *S3FS) Remove(filename string) error {
201201+ // TODO: Validate the path?
202202+ // ...
203203+204204+ // Create a context
205205+ ctx := context.TODO() // TODO: Get user-supplied context?
206206+207207+ // Format the path
208208+ p := path.Join(fs3.root, filename)
209209+210210+ // Send the request
211211+ // TODO: Parse the response?
212212+ _, err := fs3.client.DeleteObject(ctx, &s3.DeleteObjectInput{
213213+ Bucket: &fs3.bucket,
214214+ Key: &p,
215215+ })
216216+ if err != nil {
217217+ return fmt.Errorf("failed to remove file: %s", err)
218218+ }
219219+ return nil
220220+}
221221+222222+// Join joins any number of path elements into a single path
223223+func (fs3 *S3FS) Join(elem ...string) string {
224224+ j := path.Join(elem...)
225225+ c := path.Clean(j)
226226+ return c
227227+}
+29
internal/s3fs/chroot.go
···11+// chroot.go implements the interface billy.Chroot
22+33+package s3fs
44+55+import "github.com/go-git/go-billy/v6"
66+77+// Chroot returns a new filesystem from the same type where the new root is
88+// the given path. Files outside of the designated directory tree cannot be
99+// accessed.
1010+func (fs3 *S3FS) Chroot(path string) (billy.Filesystem, error) {
1111+ // TODO: Check that path is a valid subdirectory of the current root
1212+ // ...
1313+1414+ // Calculate the new root
1515+ p := fs3.Join(fs3.root, path)
1616+1717+ // Create the new S3FS with the new root directory
1818+ nfs := &S3FS{
1919+ client: fs3.client,
2020+ bucket: fs3.bucket,
2121+ root: p,
2222+ }
2323+ return nfs, nil
2424+}
2525+2626+// Root returns the root path of the filesystem.
2727+func (fs3 *S3FS) Root() string {
2828+ return fs3.root
2929+}
+84
internal/s3fs/dir.go
···11+// dir.go implements the interface billy.Dir
22+33+package s3fs
44+55+import (
66+ "context"
77+ "errors"
88+ "io/fs"
99+ "os"
1010+ pathpkg "path"
1111+ "strings"
1212+1313+ "github.com/aws/aws-sdk-go-v2/aws"
1414+ "github.com/aws/aws-sdk-go-v2/service/s3"
1515+)
1616+1717+// ReadDir reads the directory named by dirname and returns a list of
1818+// directory entries sorted by filename.
1919+func (fs3 *S3FS) ReadDir(dir string) ([]fs.DirEntry, error) {
2020+ key := strings.TrimPrefix(fs3.cleanPath(dir), "/")
2121+ var prefix string
2222+ if key != "" && key != "." {
2323+ prefix = key + "/"
2424+ }
2525+2626+ ctx := context.TODO()
2727+2828+ var ct *string
2929+ var dirs []fs.DirEntry
3030+ var files []fs.DirEntry
3131+ for {
3232+ res, err := fs3.client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
3333+ Bucket: &fs3.bucket,
3434+ Prefix: &prefix,
3535+ ContinuationToken: ct,
3636+ Delimiter: &fs3.separator,
3737+ })
3838+ if err != nil {
3939+ return nil, err
4040+ }
4141+4242+ for _, d := range res.CommonPrefixes {
4343+ name := strings.TrimSuffix(strings.TrimPrefix(aws.ToString(d.Prefix), prefix), "/")
4444+ if name == "" {
4545+ continue
4646+ }
4747+ dirs = append(dirs, fs.FileInfoToDirEntry(newDirInfo(name)))
4848+ }
4949+5050+ for _, f := range res.Contents {
5151+ full := aws.ToString(f.Key)
5252+ if full == prefix {
5353+ // zero-byte directory placeholder; skip
5454+ continue
5555+ }
5656+ name := strings.TrimPrefix(full, prefix)
5757+ if name == "" {
5858+ continue
5959+ }
6060+ files = append(files,
6161+ fs.FileInfoToDirEntry(newFileInfo(
6262+ pathpkg.Base(name),
6363+ aws.ToInt64(f.Size),
6464+ aws.ToTime(f.LastModified),
6565+ )),
6666+ )
6767+ }
6868+6969+ if !aws.ToBool(res.IsTruncated) {
7070+ break
7171+ }
7272+ ct = res.NextContinuationToken
7373+ }
7474+7575+ return append(dirs, files...), nil
7676+}
7777+7878+// MkdirAll creates a directory named path, along with any necessary
7979+// parents, and returns nil, or else returns an error. The permission bits
8080+// perm are used for all directories that MkdirAll creates. If path is/
8181+// already a directory, MkdirAll does nothing and returns nil.
8282+func (fs3 *S3FS) MkdirAll(filename string, perm os.FileMode) error {
8383+ return errors.New("not implemented")
8484+}
+452
internal/s3fs/file.go
···11+package s3fs
22+33+import (
44+ "bytes"
55+ "context"
66+ "errors"
77+ "fmt"
88+ "io/fs"
99+ "io/ioutil"
1010+ "os"
1111+ "strings"
1212+ "syscall"
1313+ "time"
1414+1515+ "github.com/aws/aws-sdk-go-v2/service/s3"
1616+ "github.com/tigrisdata/storage-go"
1717+ "go.uber.org/atomic"
1818+)
1919+2020+const (
2121+ ModeMultipartUpload os.FileMode = fs.ModePerm + 1 // Custom os.FileMode for S3 multipart upload
2222+)
2323+2424+var (
2525+ ErrNotImplemented = errors.New("not implemented")
2626+ ErrLockNotSupported = errors.New("lock not supported by s3")
2727+ ErrTruncateNotSupported = errors.New("truncate not supported by s3")
2828+ ErrFileClosed = errors.New("file is closed")
2929+ ErrCantWriteToReadOnly = errors.New("can't write to read-only file")
3030+ ErrCantReadFromWriteOnly = errors.New("can't read from write-only file")
3131+)
3232+3333+// s3ReadFile implements billy.File for S3, and represents a file opened in read mode.
3434+//
3535+// Upon creation, the file is loaded from S3.
3636+type s3ReadFile struct {
3737+ client *storage.Client // S3 SDK client
3838+ bucket string // S3 bucket name
3939+ key string // File object's key in S3
4040+ closed bool // Is the file closed?
4141+ reader *bytes.Reader // Buffer for file contents
4242+ head *s3.HeadObjectOutput // File metadata from S3
4343+}
4444+4545+// newS3ReadFile creates a new s3ReadFile.
4646+func newS3ReadFile(client *storage.Client, bucket, key string) (*s3ReadFile, error) {
4747+ // Create the context
4848+ ctx := context.TODO() // TODO: How can user-supplied contexts be supported?
4949+5050+ ho, err := client.HeadObject(ctx, &s3.HeadObjectInput{
5151+ Bucket: new(bucket),
5252+ Key: new(key),
5353+ })
5454+ if err != nil {
5555+ return nil, &os.PathError{Op: "read", Path: key, Err: err}
5656+ }
5757+5858+ // Run the GetObject operation
5959+ res, err := client.GetObject(ctx, &s3.GetObjectInput{
6060+ Bucket: &bucket,
6161+ Key: &key,
6262+ })
6363+ if err != nil {
6464+ return nil, fmt.Errorf("unable to perform GetObject operation: %w", err)
6565+ }
6666+6767+ // Read the file contents and store in a bytes reader
6868+ buf, err := ioutil.ReadAll(res.Body)
6969+ if err != nil {
7070+ return nil, fmt.Errorf("unable to read file body: %w", err)
7171+ }
7272+ reader := bytes.NewReader(buf)
7373+7474+ // Return the file
7575+ return &s3ReadFile{
7676+ client: client,
7777+ bucket: bucket,
7878+ key: key,
7979+ reader: reader,
8080+ head: ho,
8181+ }, nil
8282+}
8383+8484+// Name returns the name of the file as presented to Open.
8585+func (f *s3ReadFile) Name() string {
8686+ return f.key
8787+}
8888+8989+// Write implements io.Writer for billy.File
9090+func (f *s3ReadFile) Write(p []byte) (n int, err error) {
9191+ return 0, ErrCantWriteToReadOnly
9292+}
9393+9494+// WriteAt implements io.WriterAt for billy.File
9595+func (f *s3ReadFile) WriteAt(p []byte, off int64) (n int, err error) {
9696+ return 0, ErrCantWriteToReadOnly
9797+}
9898+9999+// Read implements io.Reader for billy.File
100100+func (f *s3ReadFile) Read(p []byte) (n int, err error) {
101101+ return f.reader.Read(p)
102102+}
103103+104104+// ReadAt implements io.ReaderAt for billy.File
105105+func (f *s3ReadFile) ReadAt(p []byte, off int64) (n int, err error) {
106106+ return f.reader.ReadAt(p, off)
107107+}
108108+109109+// Seek implements io.Seeker for billy.File
110110+func (f *s3ReadFile) Seek(offset int64, whence int) (int64, error) {
111111+ return f.reader.Seek(offset, whence)
112112+}
113113+114114+// Close implements io.Closer for billy.File
115115+func (f *s3ReadFile) Close() error {
116116+ // Was the file already closed?
117117+ if f.closed {
118118+ return ErrFileClosed
119119+ }
120120+121121+ // Close the underlying file
122122+ f.reader = nil
123123+124124+ // Mark the file as closed
125125+ f.closed = true
126126+127127+ return nil
128128+}
129129+130130+// Lock locks the file like e.g. flock. It protects against access from
131131+// other processes.
132132+func (f *s3ReadFile) Lock() error {
133133+ return ErrLockNotSupported
134134+}
135135+136136+// Unlock unlocks the file.
137137+func (f *s3ReadFile) Unlock() error {
138138+ return ErrLockNotSupported
139139+}
140140+141141+// Truncate the file.
142142+func (f *s3ReadFile) Truncate(size int64) error {
143143+ return ErrTruncateNotSupported
144144+}
145145+146146+func (f *s3ReadFile) Stat() (fs.FileInfo, error) {
147147+ return enrichedFileInfo{
148148+ HeadObjectOutput: *f.head,
149149+ key: f.key,
150150+ mode: fs.ModePerm,
151151+ }, nil
152152+}
153153+154154+// s3WriteFile stores a file opened in write mode and implements billy.File
155155+//
156156+// Upon creation, a buffer is created to store the file contents. Upon close,
157157+// the file is uploaded to S3.
158158+type s3WriteFile struct {
159159+ client *storage.Client // s3 skd client
160160+ bucket string // S3 bucket name
161161+ key string // File object's key in S3
162162+ closed bool // Is the file closed?
163163+ buf *bytes.Buffer // Buffer for storing the file before it's uploaded
164164+}
165165+166166+// newS3WriteFile creates a new s3ReadFile.
167167+func newS3WriteFile(client *storage.Client, bucket, key string) (*s3WriteFile, error) {
168168+ // TODO: Validate the key
169169+ // ...
170170+171171+ return &s3WriteFile{
172172+ client: client,
173173+ bucket: bucket,
174174+ key: key,
175175+ buf: bytes.NewBuffer(nil),
176176+ }, nil
177177+}
178178+179179+// Name returns the name of the file as presented to Open.
180180+func (f *s3WriteFile) Name() string {
181181+ return f.key
182182+}
183183+184184+// Write implements os.Writer for billy.File
185185+func (f *s3WriteFile) Write(p []byte) (n int, err error) {
186186+ if f.closed {
187187+ return 0, ErrFileClosed
188188+ }
189189+ return f.buf.Write(p)
190190+}
191191+192192+func (f *s3WriteFile) WriteAt(p []byte, off int64) (n int, err error) {
193193+ return 0, &os.PathError{Op: "write", Path: f.key, Err: ErrNotImplemented}
194194+}
195195+196196+// Read implements os.Reader for billy.File
197197+func (f *s3WriteFile) Read(p []byte) (n int, err error) {
198198+ return 0, ErrCantReadFromWriteOnly
199199+}
200200+201201+// ReadAt implements io.ReaderAt for billy.File
202202+func (f *s3WriteFile) ReadAt(p []byte, off int64) (n int, err error) {
203203+ return 0, ErrCantReadFromWriteOnly
204204+}
205205+206206+// Seek implements io.Seeker for billy.File
207207+func (f *s3WriteFile) Seek(offset int64, whence int) (int64, error) {
208208+ return 0, ErrNotImplemented
209209+}
210210+211211+// Close implements io.Closer for billy.File
212212+func (f *s3WriteFile) Close() error {
213213+ if f.closed {
214214+ return ErrFileClosed
215215+ }
216216+217217+ // Set to closed
218218+ f.closed = true
219219+220220+ // Extract the body from the buffer
221221+ body := bytes.NewReader(f.buf.Bytes())
222222+223223+ // Create the context
224224+ ctx := context.TODO() // TODO: How can user-supplied contexts be supported?
225225+226226+ // Run the GetObject operation
227227+ // TODO: Currently `res` is not used. Should it be?
228228+ _, err := f.client.PutObject(ctx, &s3.PutObjectInput{
229229+ Bucket: &f.bucket,
230230+ Key: &f.key,
231231+ Body: body,
232232+ })
233233+ if err != nil {
234234+ return fmt.Errorf("unable to perform GetObject operation: %w", err)
235235+ }
236236+237237+ return nil
238238+}
239239+240240+func (f *s3WriteFile) Stat() (fs.FileInfo, error) {
241241+ return newFileInfo(f.key, -1, time.Now()), nil
242242+}
243243+244244+// Lock locks the file like e.g. flock. It protects against access from
245245+// other processes.
246246+func (f *s3WriteFile) Lock() error {
247247+ return ErrLockNotSupported
248248+}
249249+250250+// Unlock unlocks the file.
251251+func (f *s3WriteFile) Unlock() error {
252252+ return ErrLockNotSupported
253253+}
254254+255255+// Truncate the file.
256256+func (f *s3WriteFile) Truncate(size int64) error {
257257+ return ErrTruncateNotSupported
258258+}
259259+260260+// s3MultipartUploadFile implements billy.File
261261+type s3MultipartUploadFile struct {
262262+ client *storage.Client // s3 skd client
263263+ bucket string // S3 bucket name
264264+ key string // File object's key in S3
265265+ closed bool // Is the file closed?
266266+ uploadID string // S3 multipart upload ID
267267+ uploadN *atomic.Int32 // Counter tracking the number of uploads
268268+}
269269+270270+// newS3MultipartUploadFile creates a new s3ReadFile.
271271+func newS3MultipartUploadFile(client *storage.Client, bucket, key string) (*s3MultipartUploadFile, error) {
272272+ // TODO: Check if the file exists
273273+ // ...
274274+275275+ // Create the context
276276+ ctx := context.TODO() // TODO: How can user-supplied contexts be supported?
277277+278278+ // Run the GetObject operation
279279+ res, err := client.CreateMultipartUpload(ctx, &s3.CreateMultipartUploadInput{
280280+ Bucket: &bucket,
281281+ Key: &key,
282282+ })
283283+ if err != nil {
284284+ return nil, fmt.Errorf("unable to create multipart upload: %w", err)
285285+ }
286286+287287+ // Return the file
288288+ return &s3MultipartUploadFile{
289289+ client: client,
290290+ bucket: bucket,
291291+ key: key,
292292+ uploadID: *res.UploadId,
293293+ uploadN: atomic.NewInt32(1),
294294+ }, nil
295295+}
296296+297297+// Name returns the name of the file as presented to Open.
298298+func (f *s3MultipartUploadFile) Name() string { return f.key }
299299+300300+// Write implements os.Writer for billy.File
301301+func (f *s3MultipartUploadFile) Write(p []byte) (n int, err error) {
302302+ // Get the size of the data being written
303303+ n = len(p)
304304+305305+ // Create a context for the operation
306306+ ctx := context.TODO() // TODO: How can user-supplied contexts be supported?
307307+308308+ // Create a reader for the data
309309+ r := bytes.NewReader(p)
310310+311311+ // Get the part number
312312+ pn := f.uploadN.Load()
313313+314314+ // Run the UploadPart operation
315315+ _, err = f.client.UploadPart(ctx, &s3.UploadPartInput{
316316+ Bucket: &f.bucket,
317317+ Key: &f.key,
318318+ UploadId: &f.uploadID,
319319+ PartNumber: new(pn),
320320+ Body: r,
321321+ })
322322+ if err != nil {
323323+ return 0, fmt.Errorf("unable to upload part %d: %w", pn, err)
324324+ }
325325+326326+ // Increment the part number
327327+ f.uploadN.Add(1)
328328+329329+ // Return the number of bytes written
330330+ return n, nil
331331+}
332332+333333+func (f *s3MultipartUploadFile) WriteAt(p []byte, off int64) (n int, err error) {
334334+ return 0, &os.PathError{Op: "write", Path: f.key, Err: ErrNotImplemented}
335335+}
336336+337337+// Read implements os.Reader for billy.File
338338+func (f *s3MultipartUploadFile) Read(p []byte) (n int, err error) {
339339+ return 0, ErrCantReadFromWriteOnly
340340+}
341341+342342+// ReadAt implements io.ReaderAt for billy.File
343343+func (f *s3MultipartUploadFile) ReadAt(p []byte, off int64) (n int, err error) {
344344+ return 0, ErrCantReadFromWriteOnly
345345+}
346346+347347+// Seek implements io.Seeker for billy.File
348348+func (f *s3MultipartUploadFile) Seek(offset int64, whence int) (int64, error) {
349349+ return 0, errors.New("seek not implemented")
350350+}
351351+352352+// Close implements io.Closer for billy.File
353353+func (f *s3MultipartUploadFile) Close() error {
354354+ // Check if the file has been closed
355355+ if f.closed {
356356+ return ErrFileClosed
357357+ }
358358+359359+ // Set to closed
360360+ f.closed = true
361361+362362+ // Create the context
363363+ ctx := context.TODO() // TODO: How can user-supplied contexts be supported?
364364+365365+ // Complete the multipart upload
366366+ // TODO: Currently `res` is not used. Should it be?
367367+ _, err := f.client.CompleteMultipartUpload(ctx, &s3.CompleteMultipartUploadInput{
368368+ Bucket: &f.bucket,
369369+ Key: &f.key,
370370+ UploadId: &f.uploadID,
371371+ })
372372+ if err != nil {
373373+ return fmt.Errorf("unable to complete multipart upload: %w", err)
374374+ }
375375+376376+ return nil
377377+}
378378+379379+func (f *s3MultipartUploadFile) Lock() error { return ErrLockNotSupported }
380380+func (f *s3MultipartUploadFile) Unlock() error { return ErrLockNotSupported }
381381+func (f *s3MultipartUploadFile) Truncate(size int64) error { return ErrTruncateNotSupported }
382382+383383+func (f *s3MultipartUploadFile) Stat() (fs.FileInfo, error) {
384384+ return newFileInfo(f.key, -1, time.Now()), nil
385385+}
386386+387387+// s3DirFile is a billy.File handle for a directory in S3. S3 has no real
388388+// directories, but WASI guests (via wazero) open the preopen root by calling
389389+// OpenFile(".", O_RDONLY) and then asking IsDir() — so we return a pseudo-file
390390+// that reports as a directory and rejects byte I/O with EISDIR.
391391+type s3DirFile struct {
392392+ name, bucket string
393393+ closed bool
394394+ cli *storage.Client
395395+}
396396+397397+func newS3DirFile(name, bucket string, cli *storage.Client) *s3DirFile {
398398+ return &s3DirFile{
399399+ name: name,
400400+ bucket: bucket,
401401+ cli: cli,
402402+ }
403403+}
404404+405405+// Name returns the name of the file as presented to Open.
406406+func (f *s3DirFile) Name() string {
407407+ return f.name
408408+}
409409+410410+func (f *s3DirFile) eisdir(op string) error {
411411+ return &os.PathError{Op: op, Path: f.name, Err: syscall.EISDIR}
412412+}
413413+414414+func (f *s3DirFile) Read(p []byte) (int, error) { return 0, f.eisdir("read") }
415415+func (f *s3DirFile) ReadAt(p []byte, off int64) (int, error) { return 0, f.eisdir("read") }
416416+func (f *s3DirFile) Write(p []byte) (int, error) { return 0, f.eisdir("write") }
417417+func (f *s3DirFile) WriteAt(p []byte, off int64) (int, error) { return 0, f.eisdir("write") }
418418+func (f *s3DirFile) Seek(offset int64, whence int) (int64, error) { return 0, f.eisdir("seek") }
419419+func (f *s3DirFile) Truncate(size int64) error { return f.eisdir("truncate") }
420420+421421+func (f *s3DirFile) Stat() (fs.FileInfo, error) {
422422+ ho, err := f.cli.HeadObject(context.Background(), &s3.HeadObjectInput{
423423+ Bucket: new(f.bucket),
424424+ Key: new(f.name),
425425+ })
426426+ if err != nil {
427427+ return nil, err
428428+ }
429429+430430+ var mode fs.FileMode
431431+432432+ if strings.HasSuffix(f.name, "/") || *ho.ContentLength == 0 {
433433+ mode = fs.ModeDir
434434+ }
435435+436436+ return enrichedFileInfo{
437437+ HeadObjectOutput: *ho,
438438+ key: f.name,
439439+ mode: mode,
440440+ }, nil
441441+}
442442+443443+func (f *s3DirFile) Close() error {
444444+ if f.closed {
445445+ return ErrFileClosed
446446+ }
447447+ f.closed = true
448448+ return nil
449449+}
450450+451451+func (f *s3DirFile) Lock() error { return ErrLockNotSupported }
452452+func (f *s3DirFile) Unlock() error { return ErrLockNotSupported }
+79
internal/s3fs/file_test.go
···11+package s3fs
22+33+import (
44+ "bytes"
55+ "errors"
66+ "testing"
77+)
88+99+// TestS3WriteFileWrite locks in two invariants on the write path that a prior
1010+// stub silently broke: every Write must append its bytes to the buffer, and
1111+// writes after Close must return ErrFileClosed. The stub returned (0, nil)
1212+// for all writes regardless of state, which made every gzip/cp/etc. against
1313+// s3fs produce empty objects without surfacing an error.
1414+func TestS3WriteFileWrite(t *testing.T) {
1515+ tests := []struct {
1616+ name string
1717+ closedPre bool
1818+ chunks [][]byte
1919+ wantBuf []byte
2020+ wantErr error // expected error from the first Write (nil = all chunks succeed)
2121+ }{
2222+ {
2323+ name: "single chunk",
2424+ chunks: [][]byte{[]byte("hello")},
2525+ wantBuf: []byte("hello"),
2626+ },
2727+ {
2828+ name: "multiple chunks accumulate",
2929+ chunks: [][]byte{[]byte("hello "), []byte("world"), []byte("!")},
3030+ wantBuf: []byte("hello world!"),
3131+ },
3232+ {
3333+ name: "empty chunks are no-ops",
3434+ chunks: [][]byte{[]byte("a"), {}, []byte("b")},
3535+ wantBuf: []byte("ab"),
3636+ },
3737+ {
3838+ name: "write after close returns ErrFileClosed",
3939+ closedPre: true,
4040+ chunks: [][]byte{[]byte("nope")},
4141+ wantBuf: nil,
4242+ wantErr: ErrFileClosed,
4343+ },
4444+ }
4545+4646+ for _, tt := range tests {
4747+ t.Run(tt.name, func(t *testing.T) {
4848+ f := &s3WriteFile{
4949+ bucket: "test",
5050+ key: "k",
5151+ buf: bytes.NewBuffer(nil),
5252+ closed: tt.closedPre,
5353+ }
5454+5555+ for i, c := range tt.chunks {
5656+ n, err := f.Write(c)
5757+ if i == 0 && tt.wantErr != nil {
5858+ if !errors.Is(err, tt.wantErr) {
5959+ t.Errorf("Write: err = %v, want %v", err, tt.wantErr)
6060+ }
6161+ if n != 0 {
6262+ t.Errorf("Write: n = %d, want 0", n)
6363+ }
6464+ break
6565+ }
6666+ if err != nil {
6767+ t.Fatalf("Write(%q): unexpected error: %v", c, err)
6868+ }
6969+ if n != len(c) {
7070+ t.Fatalf("Write(%q): n=%d, want %d", c, n, len(c))
7171+ }
7272+ }
7373+7474+ if got := f.buf.Bytes(); !bytes.Equal(got, tt.wantBuf) {
7575+ t.Errorf("buffer = %q, want %q", got, tt.wantBuf)
7676+ }
7777+ })
7878+ }
7979+}
···11+package s3fs
22+33+import (
44+ "fmt"
55+ "path"
66+77+ "github.com/go-git/go-billy/v6"
88+ "github.com/tigrisdata/storage-go"
99+)
1010+1111+const (
1212+ DefaultSeparator = "/"
1313+)
1414+1515+type S3FS struct {
1616+ client *storage.Client
1717+ bucket string
1818+ root string
1919+ separator string
2020+}
2121+2222+// NewS3FS creates a new S3FS Filesystem.
2323+func NewS3FS(client *storage.Client, bucket string) (billy.Filesystem, error) {
2424+ // Check for a non-nil client
2525+ if client == nil {
2626+ return nil, fmt.Errorf("s3 client cannot be nil")
2727+ }
2828+ return &S3FS{
2929+ client: client,
3030+ bucket: bucket,
3131+ root: "",
3232+ separator: DefaultSeparator,
3333+ }, nil
3434+}
3535+3636+// Capabilities returns the filesystem capabilities.
3737+func (fs3 *S3FS) Capabilities() billy.Capability {
3838+ return billy.ReadCapability | billy.WriteCapability
3939+}
4040+4141+func (fs3 *S3FS) cleanPath(p ...string) string {
4242+ // Join the path elements
4343+ j := path.Join(p...)
4444+4545+ // Clean the path before joining to root
4646+ c := path.Clean(j)
4747+4848+ // Join the root and cleaned path
4949+ f := path.Join(fs3.root, c)
5050+5151+ // Return the full path
5252+ return path.Clean(f)
5353+}
+37
internal/s3fs/symlink.go
···11+// symlink.go implements the interface billy.Symlink
22+33+package s3fs
44+55+import (
66+ "errors"
77+ "os"
88+)
99+1010+var (
1111+ ErrSymLinkNotSupported = errors.New("symlink not supported by s3")
1212+)
1313+1414+// Lstat returns a FileInfo describing the named file. S3 has no symlinks,
1515+// so Lstat is equivalent to Stat. We still implement it so callers that
1616+// type-assert billy.Symlink (rm, du, cp, ls, touch, file, billyfs,
1717+// billysh) get a usable FileInfo instead of ErrSymLinkNotSupported.
1818+func (fs3 *S3FS) Lstat(filename string) (os.FileInfo, error) {
1919+ return fs3.Stat(filename)
2020+}
2121+2222+// Symlink creates a symbolic-link from link to target. target may be an
2323+// absolute or relative path, and need not refer to an existing node.
2424+// Parent directories of link are created as necessary.
2525+//
2626+// NOTE: Symlink is not supported by s3. It always returns an error.
2727+func (fs3 *S3FS) Symlink(target, link string) error {
2828+ return ErrSymLinkNotSupported
2929+}
3030+3131+// Readlink returns the target path of link.
3232+//
3333+// NOTE: Readlink is not supported by s3. It always returns an error.
3434+// (This may be revised in the future.)
3535+func (fs3 *S3FS) Readlink(link string) (string, error) {
3636+ return "", ErrSymLinkNotSupported
3737+}
+17
internal/s3fs/tempfile.go
···11+// tempfile.go implements the interface billy.TempFile
22+33+package s3fs
44+55+import "github.com/go-git/go-billy/v6"
66+77+// TempFile creates a new temporary file in the directory dir with a name
88+// beginning with prefix, opens the file for reading and writing, and
99+// returns the resulting *os.File. If dir is the empty string, TempFile
1010+// uses the default directory for temporary files (see os.TempDir).
1111+// Multiple programs calling TempFile simultaneously will not choose the
1212+// same file. The caller can use f.Name() to find the pathname of the file.
1313+// It is the caller's responsibility to remove the file when no longer
1414+// needed.
1515+func (fs3 *S3FS) TempFile(dir, prefix string) (billy.File, error) {
1616+ return nil, nil
1717+}