From 4c37e3567b0f259c5eca8036adc62aed9dabcb57 Mon Sep 17 00:00:00 2001 From: seborama Date: Sun, 21 Apr 2024 22:09:45 +0100 Subject: [PATCH 1/2] WIP: add experimental and explorative read-only fuse drive --- README.md | 12 ++- fuse/README.md | 44 +++++++++++ fuse/mount.go | 189 +++++++++++++++++++++++++++++++++++++++++++++ fuse/mount_test.go | 74 ++++++++++++++++++ go.mod | 6 +- go.sum | 12 ++- sdk/README.md | 2 +- 7 files changed, 332 insertions(+), 7 deletions(-) create mode 100644 fuse/README.md create mode 100644 fuse/mount.go create mode 100644 fuse/mount_test.go diff --git a/README.md b/README.md index 2e1aa9e..6003bda 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,14 @@ This is a pCloud client written in Go for cross-platform compatibility, such as NOTE: I'm **not** affiliated to pCloud so this project is as good or as bad as it gets. -## Go SDK +## Go SDK 🤩 See [SDK](sdk/README.md). +## FUSE drive for pCloud (Linux and FreeBSD) 🤩😍 + +See [fuse](fuse/README.md) + ## Tracker (file system mutations) See [Tracker](tracker/README.md). @@ -28,11 +32,11 @@ While [pCloud's console client](https://github.com/pcloudcom/console-client) see 1. ✅ implement a Go version of the SDK. -2. implement a sync command. +2. 🧑‍💻 FUSE integration (Linux / FreeBSD) -3. CLI for basic pCloud interactions (copy, move, etc) +3. implement a sync command. -4. Fuse integration (Linux) +4. CLI for basic pCloud interactions (copy, move, etc) ## pCloud API documentation diff --git a/fuse/README.md b/fuse/README.md new file mode 100644 index 0000000..13aa114 --- /dev/null +++ b/fuse/README.md @@ -0,0 +1,44 @@ +# fuse + +This package offers a pCloud client for Linux and FreeBSD for the rest of us who have been forgotten... + +It uses FUSE to mount the pCloud drive. This is possible thanks to [Bazil](https://github.com/bazil) and his [FUSE library for Go](https://github.com/bazil/fuse). + +I am developing on a Linux ARM Raspberry Pi4. I haven't (yet) tried Linux x86_64 or FreeBSD, it simply is too early at this stage of the development to worry about more than one platform. + +## Status + +At this stage, this is purely explorative. The code base is entirely experimental, most features are not implemented or only partially. + +## Change log + +2024-Apr-21 - The pCloud drive can be mounted (via the test - see "Getting started"). `ls` on the root of the mount will list directories and files contained in the root of the pCloud drive. + +## Getting started + +While this is under construction, only a simple test exists. + +It mounts pCloud under `/tmp/pcloud_mnt`. + +To cleanly end the test, make sure you run `umount /tmp/pcloud_mnt` on your Linux / FreeBSD command line. + +Should the test end abruptly, or time out, run `umount /tmp/pcloud_mnt` to clean up the mount. + +The tests rely on the presence of environment variables to supply your credentials (**make sure you `export` the variables!**): +- `GO_PCLOUD_USERNAME` +- `GO_PCLOUD_PASSWORD` +- `GO_PCLOUD_TFA_CODE` - BETA. Note that the device is automatically marked as trusted so TFA is not required the next time. You can remove the trust manually in your [account security settings](https://my.pcloud.com/#page=settings&settings=tab-security). + +TFA was possible thanks to [Glib Dzevo](https://github.com/gdzevo) and his [console-client PR](https://github.com/pcloudcom/console-client/pull/94) where I found the info I needed! + +```bash +cd fuse +go test -v ./ + +# in a separate terminal window: +ls /tmp/pcloud_mnt +# ... + +# when you're done: +umount /tmp/pcloud_mnt +``` diff --git a/fuse/mount.go b/fuse/mount.go new file mode 100644 index 0000000..f046c64 --- /dev/null +++ b/fuse/mount.go @@ -0,0 +1,189 @@ +package fuse + +import ( + "context" + "log" + "os" + "syscall" + + "bazil.org/fuse" + "bazil.org/fuse/fs" + _ "bazil.org/fuse/fs/fstestutil" + "github.com/samber/lo" + "github.com/seborama/pcloud/sdk" +) + +type Drive struct { + fs fs.FS + conn *fuse.Conn // TODO: define an interface +} + +func Mount(mountpoint string, pcClient *sdk.Client) (Drive, error) { + conn, err := fuse.Mount( + mountpoint, + fuse.FSName("pcloud"), + fuse.Subtype("seborama"), + ) + if err != nil { + log.Fatal(err) + } + + return Drive{ + fs: &FS{ + pcClient: pcClient, + }, + conn: conn, + }, nil +} + +func (d *Drive) Unmount() error { + return d.conn.Close() +} + +func (d *Drive) Serve() error { + return fs.Serve(d.conn, d.fs) +} + +// FS implements the pCloud file system. +type FS struct { + pcClient *sdk.Client // TODO: define an interface +} + +// ensure interfaces conpliance +var ( + _ fs.FS = (*FS)(nil) +) + +func (fs *FS) Root() (fs.Node, error) { + log.Println("Root called") + fsList, err := fs.pcClient.ListFolder(context.Background(), sdk.T1FolderByID(sdk.RootFolderID), false, false, false, false) + if err != nil { + return nil, err + } + + entries := lo.SliceToMap(fsList.Metadata.Contents, func(item *sdk.Metadata) (string, interface{}) { + if item.IsFolder { + return item.Name, &Dir{ + Type: fuse.DT_Dir, + Attributes: fuse.Attr{ + Inode: item.FolderID, + Atime: item.Modified.Time, + Mtime: item.Modified.Time, + Ctime: item.Modified.Time, + Mode: os.ModeDir | 0o777, + }, + Entries: map[string]interface{}{}, // TODO + } + } + + return item.Name, &File{ + Type: fuse.DT_File, + // Content: content, // TODO + Attributes: fuse.Attr{ + Inode: item.FileID, + Size: item.Size, + Atime: item.Modified.Time, + Mtime: item.Modified.Time, + Ctime: item.Modified.Time, + Mode: 0o777, + }, + } + }) + + rootDir := &Dir{ + Type: fuse.DT_Dir, + Attributes: fuse.Attr{ + Inode: sdk.RootFolderID, + Atime: fsList.Metadata.Modified.Time, + Mtime: fsList.Metadata.Modified.Time, + Ctime: fsList.Metadata.Modified.Time, + Mode: os.ModeDir | 0o777, + }, + Entries: entries, + } + + return rootDir, nil +} + +// Dir implements both Node and Handle for the root directory. +type Dir struct { + Type fuse.DirentType + Attributes fuse.Attr + Entries map[string]interface{} +} + +// ensure interfaces conpliance +var ( + _ fs.Node = (*Dir)(nil) + _ fs.NodeStringLookuper = (*Dir)(nil) +) + +func (d Dir) Attr(ctx context.Context, a *fuse.Attr) error { + log.Println("Dir.Attr called") + log.Println("File.Attr called") + *a = d.Attributes + return nil +} + +func (d Dir) Lookup(ctx context.Context, name string) (fs.Node, error) { + log.Println("Dir.Lookup called") + node, ok := d.Entries[name] + if ok { + return node.(fs.Node), nil + } + return nil, syscall.ENOENT +} + +func (d Dir) ReadDirAll(ctx context.Context) ([]fuse.Dirent, error) { + log.Println("Dir.ReadDirAll called") + entries := lo.MapToSlice(d.Entries, func(key string, value interface{}) fuse.Dirent { + switch castEntry := value.(type) { + case *File: + return fuse.Dirent{ + Inode: castEntry.Attributes.Inode, + Type: castEntry.Type, + Name: key, + } + case *Dir: + return fuse.Dirent{ + Inode: castEntry.Attributes.Inode, + Type: castEntry.Type, + Name: key, + } + default: + log.Printf("unknown directory entry '%T'", castEntry) + return fuse.Dirent{ + Inode: 6_666_666_666_666_666_666, + Type: fuse.DT_Unknown, + Name: key, + } + } + }) + return entries, nil +} + +// File implements both Node and Handle for the hello file. +type File struct { + Type fuse.DirentType + Content []byte + Attributes fuse.Attr +} + +// ensure interfaces conpliance +var ( + _ = (fs.Node)((*File)(nil)) + // _ = (fs.HandleWriter)((*File)(nil)) + _ = (fs.HandleReadAller)((*File)(nil)) + // _ = (fs.NodeSetattrer)((*File)(nil)) + // _ = (EntryGetter)((*File)(nil)) +) + +func (f File) Attr(ctx context.Context, a *fuse.Attr) error { + log.Println("File.Attr called") + *a = f.Attributes + return nil +} + +func (File) ReadAll(ctx context.Context) ([]byte, error) { + return []byte(nil), nil // TODO +} diff --git a/fuse/mount_test.go b/fuse/mount_test.go new file mode 100644 index 0000000..5370d47 --- /dev/null +++ b/fuse/mount_test.go @@ -0,0 +1,74 @@ +package fuse_test + +import ( + "context" + "log" + "net/http" + "os" + "testing" + "time" + + pfuse "github.com/seborama/pcloud/fuse" + "github.com/seborama/pcloud/sdk" + "github.com/stretchr/testify/require" +) + +func Test(t *testing.T) { + pcClient := newPCloudClient(t) + + mountpoint := "/tmp/pcloud_mnt" + + c, err := pfuse.Mount( + mountpoint, + pcClient, + ) + if err != nil { + log.Fatal(err) + } + defer func() { _ = c.Unmount() }() + + log.Println("Mouting FS") + err = c.Serve() + if err != nil { + log.Fatal(err) + } +} + +func newPCloudClient(t *testing.T) *sdk.Client { + t.Helper() + + username := os.Getenv("GO_PCLOUD_USERNAME") + require.NotEmpty(t, username) + + password := os.Getenv("GO_PCLOUD_PASSWORD") + require.NotEmpty(t, password) + + otpCode := os.Getenv("GO_PCLOUD_TFA_CODE") + + c := &http.Client{ + Transport: &http.Transport{ + MaxIdleConnsPerHost: 1, + MaxConnsPerHost: 1, + ResponseHeaderTimeout: 20 * time.Second, + // Proxy: http.ProxyFromEnvironment, + // TLSClientConfig: &tls.Config{ + // InsecureSkipVerify: true, // only use this for debugging environments + // }, + }, + Timeout: 0, + } + + pcc := sdk.NewClient(c) + + err := pcc.Login( + context.Background(), + otpCode, + sdk.WithGlobalOptionUsername(username), + sdk.WithGlobalOptionPassword(password), + sdk.WithGlobalOptionAuthInactiveExpire(5*time.Minute), + ) + require.NoError(t, err) + + return pcc + +} diff --git a/go.mod b/go.mod index 28d1b41..f294ebe 100644 --- a/go.mod +++ b/go.mod @@ -3,10 +3,12 @@ module github.com/seborama/pcloud go 1.20 require ( + bazil.org/fuse v0.0.0-20230120002735-62a210ff1fd5 github.com/google/go-cmp v0.5.4 github.com/google/uuid v1.1.2 github.com/mattn/go-sqlite3 v1.14.22 github.com/pkg/errors v0.9.1 + github.com/samber/lo v1.39.0 github.com/stretchr/testify v1.8.1 github.com/urfave/cli/v2 v2.27.1 go.uber.org/zap v1.27.0 @@ -20,6 +22,8 @@ require ( github.com/stretchr/objx v0.5.0 // indirect github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 // indirect + golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect + golang.org/x/sys v0.4.0 // indirect + golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 7717d52..7ac62e5 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +bazil.org/fuse v0.0.0-20230120002735-62a210ff1fd5 h1:A0NsYy4lDBZAC6QiYeJ4N+XuHIKBpyhAVRMHRQZKTeQ= +bazil.org/fuse v0.0.0-20230120002735-62a210ff1fd5/go.mod h1:gG3RZAMXCa/OTes6rr9EwusmR1OH1tDDy+cg9c5YliY= github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -15,6 +17,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/samber/lo v1.39.0 h1:4gTz1wUhNYLhFSKl6O+8peW0v2F4BCY034GRpU9WnuA= +github.com/samber/lo v1.39.0/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= @@ -23,6 +27,7 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/tv42/httpunix v0.0.0-20191220191345-2ba4b9c3382c h1:u6SKchux2yDvFQnDHS3lPnIRmfVJ5Sxy3ao2SIdysLQ= github.com/urfave/cli/v2 v2.27.1 h1:8xSQ6szndafKVRmfyeUMxkNUJQMjL1F2zmsZ+qHpfho= github.com/urfave/cli/v2 v2.27.1/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 h1:+qGGcbkzsfDQNPPe9UDgpxAWQrhbbBXOYJFQDq/dtJw= @@ -32,8 +37,13 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 h1:3MTrJm4PyNL9NBqvYDSj3DHl46qQakyfqfWo4jgfaEM= +golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE= +golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18= +golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/sdk/README.md b/sdk/README.md index 931fb85..b8e8ec8 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -6,7 +6,7 @@ make test-sdk ``` -The tests rely on the presence of environment variables to supply your credentials: +The tests rely on the presence of environment variables to supply your credentials (**make sure you `export` the variables!**): - `GO_PCLOUD_USERNAME` - `GO_PCLOUD_PASSWORD` - `GO_PCLOUD_TFA_CODE` - BETA. Note that the device is automatically marked as trusted so TFA is not required the next time. You can remove the trust manually in your [account security settings](https://my.pcloud.com/#page=settings&settings=tab-security). From eea98cba387df519486e66979aa68c6d8d98092d Mon Sep 17 00:00:00 2001 From: seborama Date: Tue, 23 Apr 2024 00:34:42 +0100 Subject: [PATCH 2/2] implement basic read-only fuse drive --- fuse/README.md | 10 ++- fuse/mount.go | 184 +++++++++++++++++++++++++++++++++------------ fuse/mount_test.go | 6 +- sdk/README.md | 2 +- sdk/file.go | 17 +++++ sdk/file_test.go | 7 ++ sdk/folder.go | 45 +++++++++-- 7 files changed, 211 insertions(+), 60 deletions(-) diff --git a/fuse/README.md b/fuse/README.md index 13aa114..9858ae9 100644 --- a/fuse/README.md +++ b/fuse/README.md @@ -8,10 +8,18 @@ I am developing on a Linux ARM Raspberry Pi4. I haven't (yet) tried Linux x86_64 ## Status -At this stage, this is purely explorative. The code base is entirely experimental, most features are not implemented or only partially. +At this stage, this is explorative. The code base is entirely experimental, most features are not implemented or only partially. + +The drive can be mounted via the tests and it can be "walked" through. + +Files contents cannot be read just yet. + +No write operations are supported ## Change log +2024-Apr-22 - The pCloud drive can listed entirely. `ls` on the root of the mount will list directories and files contained in the root of the pCloud drive. + 2024-Apr-21 - The pCloud drive can be mounted (via the test - see "Getting started"). `ls` on the root of the mount will list directories and files contained in the root of the pCloud drive. ## Getting started diff --git a/fuse/mount.go b/fuse/mount.go index f046c64..e105e25 100644 --- a/fuse/mount.go +++ b/fuse/mount.go @@ -4,7 +4,10 @@ import ( "context" "log" "os" + "os/user" + "strconv" "syscall" + "time" "bazil.org/fuse" "bazil.org/fuse/fs" @@ -18,19 +21,37 @@ type Drive struct { conn *fuse.Conn // TODO: define an interface } -func Mount(mountpoint string, pcClient *sdk.Client) (Drive, error) { +func Mount(mountpoint string, pcClient *sdk.Client) (*Drive, error) { conn, err := fuse.Mount( mountpoint, fuse.FSName("pcloud"), fuse.Subtype("seborama"), ) if err != nil { - log.Fatal(err) + return nil, err } - return Drive{ + user, err := user.Current() + if err != nil { + return nil, err + } + uid, err := strconv.ParseUint(user.Uid, 10, 32) + if err != nil { + return nil, err + } + gid, err := strconv.ParseUint(user.Gid, 10, 32) + if err != nil { + return nil, err + } + + return &Drive{ fs: &FS{ - pcClient: pcClient, + pcClient: pcClient, + uid: uint32(uid), + gid: uint32(gid), + rdev: 531, + dirPerms: 0o750, + filePerms: 0o640, }, conn: conn, }, nil @@ -46,7 +67,12 @@ func (d *Drive) Serve() error { // FS implements the pCloud file system. type FS struct { - pcClient *sdk.Client // TODO: define an interface + pcClient *sdk.Client // TODO: define an interface + uid uint32 + gid uint32 + rdev uint32 + dirPerms os.FileMode + filePerms os.FileMode } // ensure interfaces conpliance @@ -56,23 +82,93 @@ var ( func (fs *FS) Root() (fs.Node, error) { log.Println("Root called") - fsList, err := fs.pcClient.ListFolder(context.Background(), sdk.T1FolderByID(sdk.RootFolderID), false, false, false, false) + + rootDir := &Dir{ + Type: fuse.DT_Dir, + fs: fs, + } + + err := rootDir.materialiseFolder(context.Background()) if err != nil { return nil, err } + return rootDir, nil +} + +// Dir implements both Node and Handle for the root directory. +type Dir struct { + Type fuse.DirentType + Attributes fuse.Attr + + // TODO: we must be able to find something better than interface{}, either a proper interface or perhaps a generic type + // TODO: we likely don't need this: we should always call `materialiseFolder()` because the source of truth is pCloud + // TODO: contents is subject to changes at anytime, and we should allow the fuse driver to be the judge of whether to + // TODO: ... refresh the folder or not via fuse.Attr.Validate + Entries map[string]interface{} + + fs *FS + parentFolderID uint64 + folderID uint64 +} + +// ensure interfaces conpliance +var ( + _ fs.Node = (*Dir)(nil) + _ fs.NodeStringLookuper = (*Dir)(nil) + _ fs.HandleReadDirAller = (*Dir)(nil) +) + +func (d *Dir) Attr(ctx context.Context, a *fuse.Attr) error { + log.Println("Dir.Attr called") + *a = d.Attributes + return nil +} + +// TODO: add support for . and .. +func (d *Dir) materialiseFolder(ctx context.Context) error { + fsList, err := d.fs.pcClient.ListFolder(ctx, sdk.T1FolderByID(d.folderID), false, false, false, false) + if err != nil { + return err + } + + // TODO: is this necessary? perhaps only for the root folder? + d.Attributes = fuse.Attr{ + Valid: time.Second, + Inode: d.folderID, + Atime: fsList.Metadata.Modified.Time, + Mtime: fsList.Metadata.Modified.Time, + Ctime: fsList.Metadata.Modified.Time, + Mode: os.ModeDir | d.fs.dirPerms, + Nlink: 1, // TODO: is that right? How else can we find this value? + Uid: d.fs.uid, + Gid: d.fs.gid, + Rdev: d.fs.rdev, + } + + d.parentFolderID = fsList.Metadata.ParentFolderID + d.folderID = fsList.Metadata.FolderID + entries := lo.SliceToMap(fsList.Metadata.Contents, func(item *sdk.Metadata) (string, interface{}) { if item.IsFolder { return item.Name, &Dir{ Type: fuse.DT_Dir, Attributes: fuse.Attr{ + Valid: time.Second, Inode: item.FolderID, Atime: item.Modified.Time, Mtime: item.Modified.Time, Ctime: item.Modified.Time, - Mode: os.ModeDir | 0o777, + Mode: os.ModeDir | d.fs.dirPerms, + Nlink: 1, // the official pCloud client can show other values that 1 - dunno how + Uid: d.fs.uid, + Gid: d.fs.gid, + Rdev: d.fs.rdev, }, - Entries: map[string]interface{}{}, // TODO + Entries: nil, // will be populated by Dir.Lookup + fs: d.fs, + parentFolderID: item.ParentFolderID, + folderID: item.FolderID, } } @@ -80,63 +176,54 @@ func (fs *FS) Root() (fs.Node, error) { Type: fuse.DT_File, // Content: content, // TODO Attributes: fuse.Attr{ + Valid: time.Second, Inode: item.FileID, Size: item.Size, Atime: item.Modified.Time, Mtime: item.Modified.Time, Ctime: item.Modified.Time, - Mode: 0o777, + Mode: d.fs.filePerms, + Nlink: 1, // TODO: is that right? How else can we find this value? + Uid: d.fs.uid, + Gid: d.fs.gid, + Rdev: d.fs.rdev, }, } }) - rootDir := &Dir{ - Type: fuse.DT_Dir, - Attributes: fuse.Attr{ - Inode: sdk.RootFolderID, - Atime: fsList.Metadata.Modified.Time, - Mtime: fsList.Metadata.Modified.Time, - Ctime: fsList.Metadata.Modified.Time, - Mode: os.ModeDir | 0o777, - }, - Entries: entries, - } - - return rootDir, nil -} - -// Dir implements both Node and Handle for the root directory. -type Dir struct { - Type fuse.DirentType - Attributes fuse.Attr - Entries map[string]interface{} -} - -// ensure interfaces conpliance -var ( - _ fs.Node = (*Dir)(nil) - _ fs.NodeStringLookuper = (*Dir)(nil) -) + d.Entries = entries -func (d Dir) Attr(ctx context.Context, a *fuse.Attr) error { - log.Println("Dir.Attr called") - log.Println("File.Attr called") - *a = d.Attributes return nil } -func (d Dir) Lookup(ctx context.Context, name string) (fs.Node, error) { - log.Println("Dir.Lookup called") +// Lookup looks up a specific entry in the receiver, +// which must be a directory. Lookup should return a Node +// corresponding to the entry. If the name does not exist in +// the directory, Lookup should return ENOENT. +// +// Lookup need not to handle the names "." and "..". +func (d *Dir) Lookup(ctx context.Context, name string) (fs.Node, error) { + log.Println("Dir.Lookup called on dir folderID:", d.folderID, "entries count:", len(d.Entries), "- with name:", name) + // TODO: this test is likely incorrect: we should always list entries in case the folder has changed + // TODO: ...at the very least, we should combine it with a TTL or simply rely on the fuse driver to manage that for us. + if len(d.Entries) == 0 { + // TODO: we can do better here: all this function wants is to get a single entry, not everything + d.materialiseFolder(ctx) + } + node, ok := d.Entries[name] if ok { return node.(fs.Node), nil } + return nil, syscall.ENOENT } -func (d Dir) ReadDirAll(ctx context.Context) ([]fuse.Dirent, error) { - log.Println("Dir.ReadDirAll called") - entries := lo.MapToSlice(d.Entries, func(key string, value interface{}) fuse.Dirent { +func (d *Dir) ReadDirAll(ctx context.Context) ([]fuse.Dirent, error) { + log.Println("Dir.ReadDirAll called - folderID:", d.folderID, "-", "parentFolderID:", d.parentFolderID) + d.materialiseFolder(ctx) // TODO: this should not be required here + + dirEntries := lo.MapToSlice(d.Entries, func(key string, value interface{}) fuse.Dirent { switch castEntry := value.(type) { case *File: return fuse.Dirent{ @@ -144,14 +231,16 @@ func (d Dir) ReadDirAll(ctx context.Context) ([]fuse.Dirent, error) { Type: castEntry.Type, Name: key, } + case *Dir: return fuse.Dirent{ Inode: castEntry.Attributes.Inode, Type: castEntry.Type, Name: key, } + default: - log.Printf("unknown directory entry '%T'", castEntry) + log.Printf("unknown directory entry type '%T'", castEntry) return fuse.Dirent{ Inode: 6_666_666_666_666_666_666, Type: fuse.DT_Unknown, @@ -159,7 +248,8 @@ func (d Dir) ReadDirAll(ctx context.Context) ([]fuse.Dirent, error) { } } }) - return entries, nil + + return dirEntries, nil } // File implements both Node and Handle for the hello file. diff --git a/fuse/mount_test.go b/fuse/mount_test.go index 5370d47..e206abb 100644 --- a/fuse/mount_test.go +++ b/fuse/mount_test.go @@ -18,17 +18,17 @@ func Test(t *testing.T) { mountpoint := "/tmp/pcloud_mnt" - c, err := pfuse.Mount( + drive, err := pfuse.Mount( mountpoint, pcClient, ) if err != nil { log.Fatal(err) } - defer func() { _ = c.Unmount() }() + defer func() { _ = drive.Unmount() }() log.Println("Mouting FS") - err = c.Serve() + err = drive.Serve() if err != nil { log.Fatal(err) } diff --git a/sdk/README.md b/sdk/README.md index b8e8ec8..38bb29d 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -54,7 +54,7 @@ TFA was possible thanks to [Glib Dzevo](https://github.com/gdzevo) and his [cons - ✅ checksumfile - ✅ deletefile - ✅ renamefile - - stat + - ✅ stat - Auth - sendverificationemail - verifyemail diff --git a/sdk/file.go b/sdk/file.go index 95cce55..19d6ff9 100644 --- a/sdk/file.go +++ b/sdk/file.go @@ -60,6 +60,23 @@ func (c *Client) RenameFile(ctx context.Context, file T3PathOrFileID, destinatio return r, nil } +// Stat returns information about the file pointed to by fileid or path. +// It's is recomended to use fileid. +// https://docs.pcloud.com/methods/file/stat.html +func (c *Client) Stat(ctx context.Context, file T3PathOrFileID, opts ...ClientOption) (*FileResult, error) { + q := toQuery(opts...) + file(q) + + r := &FileResult{} + + err := parseAPIOutput(r)(c.get(ctx, "stat", q)) + if err != nil { + return nil, err + } + + return r, nil +} + // CopyFile takes one file and copies it as another file in the user's filesystem. // Expects fileid or path to identify the source file and tofolderid+toname or topath to // identify destination filename. diff --git a/sdk/file_test.go b/sdk/file_test.go index 83dcb02..3d5de13 100644 --- a/sdk/file_test.go +++ b/sdk/file_test.go @@ -36,6 +36,13 @@ func (testsuite *IntegrationTestSuite) Test_UploadFile() { } } +func (testsuite *IntegrationTestSuite) Test_Stat() { + fs, err := testsuite.pcc.Stat(testsuite.ctx, sdk.T3FileByID(testsuite.testFileID)) + testsuite.Require().NoError(err) + testsuite.Equal(testsuite.testFileID, fs.Metadata.FileID) + testsuite.Equal("sample.file", fs.Metadata.Name) +} + func (testsuite *IntegrationTestSuite) createFiles() map[string]*os.File { num := 3 files := map[string]*os.File{} diff --git a/sdk/folder.go b/sdk/folder.go index 80bd5bf..3b63dcd 100644 --- a/sdk/folder.go +++ b/sdk/folder.go @@ -22,19 +22,27 @@ type Metadata struct { Path string // Generic - Name string - Created *APITime - IsMine bool // TODO: when true, there are more fields available. See: https://github.com/pcloudcom/pclouddoc/blob/master/api.txt + Name string + Created *APITime + + IsMine bool `json:"ismine"` + // BEGIN: if IsMine == false + CanRead bool `json:"canread,omitempty"` + CanModify bool `json:"canmodify,omitempty"` + CanDelete bool `json:"candelete,omitempty"` + CanCreate bool `json:"cancreate,omitempty"` // for folders only + // END: if IsMine == false + Thumb bool Modified *APITime Comments uint64 ID string - IsShared bool + IsShared bool `json:"isshared"` Icon string - IsFolder bool - ParentFolderID uint64 - IsDeleted bool // this may be set by DeleteFile, for instance - DeletedFileID uint64 // this may be set by RenameFile, for instance + IsFolder bool `json:"isfolder"` + ParentFolderID uint64 `json:"parentfolderid"` + IsDeleted bool `json:"isdeleted"` // this may be set by DeleteFile, for instance + DeletedFileID uint64 `json:"deletedfileid"` // this may be set by RenameFile, for instance // Folder-specific FolderID uint64 `json:"folderid,omitempty"` @@ -53,6 +61,27 @@ type Metadata struct { Category int32 `json:"category,omitempty"` Size uint64 `json:"size,omitempty"` ContentType string `json:"contenttype,omitempty"` + + // optionally, image/video files may have: + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + + // optionally, audio files may have: + Artist string `json:"artist,omitempty"` + Album string `json:"album,omitempty"` + Title string `json:"title,omitempty"` + Genre string `json:"genre,omitempty"` + TrackNo string `json:"trackno,omitempty"` + + // optionally, video files may have (see also Width and Height in image files above): + Duration string `json:"duration,omitempty"` // duration of the video in seconds (floating point number sent as string) + FPS string `json:"fps,omitempty"` // frames per second rate of the video (floating point number sent as string) + VideoCodec string `json:"videocodec,omitempty"` // codec used for enconding of the video + AudioCodec string `json:"audiocodec,omitempty"` // codec used for enconding of the audio + VideoBitrate int `json:"videobitrate,omitempty"` // bitrate of the video in kilobits + AudioBitrate int `json:"audiobitrate,omitempty"` // bitrate of the audio in kilobits + AudioSamplerate int `json:"audiosamplerate,omitempty"` // sampling rate of the audio in Hz + Rotate int `json:"rotate,omitempty"` // indicates that video should be rotated (0, 90, 180 or 270) degrees when playing} } // DeleteResult contains the properties returned by DeleteFolderRecursive.