protondrive: stop at a folder the server reports as its own ancestor

Fetching a file or folder fetches its folders up to one already known,
and a server reporting folders as each other's parents made that
recurse until rclone crashed. A cycle, or folders nested more than 2048
deep, is now an error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-11-11 20:44:19 +01:00
1 parent 52a44e41ce
commit 8666b66038
4 files changed
+97 -5

No files matched your search

+38 -4
View File
@@ -35,6 +35,12 @@ const (
// metadataBatchSize is the most node IDs one metadata request names.
metadataBatchSize = 100
// maxFolderDepth is the most ancestors fetched for a node: 2048
// levels of one-letter names fill Linux's 4096-byte PATH_MAX, so no
// usable path is deeper, and Proton refuses deep nesting itself
// (code 200301).
maxFolderDepth = 2048
// metadataConcurrency is the most metadata requests in flight for
// one listing.
metadataConcurrency = 15
@@ -91,7 +97,8 @@ type client struct {
// before it is sent; for tests.
verifierHook func(token []byte)
maxFileBlocks int // the most blocks a file may list: maxFileBlocks, unless a test lowers it
maxFileBlocks int // the most blocks a file may list: maxFileBlocks, unless a test lowers it
maxFolderDepth int // the most ancestors fetched for a node: maxFolderDepth, unless a test lowers it
mu sync.Mutex
folderKeys map[string]*nodeKeys // by folder ID; kept whatever the cache, as node keys never change
@@ -183,6 +190,7 @@ func newClient(ctx context.Context, s *session, opt clientOptions) (*client, err
folderKeys: make(map[string]*nodeKeys),
reported: make(map[string]bool),
maxFileBlocks: maxFileBlocks,
maxFolderDepth: maxFolderDepth,
}
if err := c.bootstrap(ctx); err != nil {
c.close()
@@ -610,13 +618,19 @@ func (c *client) rememberKeys(n *node) {
// folderKeysOf returns the node keys of folder id.
func (c *client) folderKeysOf(ctx context.Context, id string) (*nodeKeys, error) {
return c.folderKeysIn(ctx, id, nil)
}
// folderKeysIn is folderKeysOf for the parent of the nodes below,
// nearest first, which are being fetched.
func (c *client) folderKeysIn(ctx context.Context, id string, below []string) (*nodeKeys, error) {
c.mu.Lock()
keys := c.folderKeys[id]
c.mu.Unlock()
if keys != nil {
return keys, nil
}
n, err := c.getNode(ctx, id)
n, err := c.getNodeIn(ctx, id, below)
if err != nil {
return nil, err
}
@@ -640,18 +654,38 @@ func (c *client) fetchLinks(ctx context.Context, ids []string) ([]api.LinkDetail
// getNode returns the node id, of any state, wrapping errNotFound if
// it doesn't exist.
func (c *client) getNode(ctx context.Context, id string) (*node, error) {
return c.getNodeIn(ctx, id, nil)
}
// getNodeIn is getNode for the parent of the nodes below, nearest
// first, which are being fetched.
func (c *client) getNodeIn(ctx context.Context, id string, below []string) (*node, error) {
if id == c.root.ID {
return c.root, nil
}
if n := c.cache.get(id); n != nil {
return n, nil
}
return c.fetchNode(ctx, id)
return c.fetchNodeIn(ctx, id, below)
}
// fetchNode reads node id, of any state, from the server whatever the
// cache holds, and caches it. It wraps errNotFound if it doesn't exist.
func (c *client) fetchNode(ctx context.Context, id string) (*node, error) {
return c.fetchNodeIn(ctx, id, nil)
}
// fetchNodeIn is fetchNode for the parent of the nodes below, nearest
// first, which are being fetched, as the parents of a node not yet in
// the cache are fetched up to one that is. A server reporting a cycle
// or a chain deeper than maxFolderDepth gets an error.
func (c *client) fetchNodeIn(ctx context.Context, id string, below []string) (*node, error) {
switch {
case slices.Contains(below, id):
return nil, fmt.Errorf("get node %s: %w: the server reports it as its own ancestor", id, errIntegrity)
case len(below) >= c.maxFolderDepth:
return nil, fmt.Errorf("get node %s: %w: folders nested more than %d deep", id, errIntegrity, c.maxFolderDepth)
}
links, err := c.fetchLinks(ctx, []string{id})
if err != nil {
return nil, fmt.Errorf("get node %s: %w", id, err)
@@ -664,7 +698,7 @@ func (c *client) fetchNode(ctx context.Context, id string) (*node, error) {
if d.Link.ParentLinkID == "" {
return nil, fmt.Errorf("get node %s: not in the drive", id)
}
parent, err := c.folderKeysOf(ctx, d.Link.ParentLinkID)
parent, err := c.folderKeysIn(ctx, d.Link.ParentLinkID, append(below[:len(below):len(below)], id))
if err != nil {
return nil, fmt.Errorf("get node %s: %w", id, err)
}
@@ -1240,3 +1240,46 @@ func TestListingRacingChange(t *testing.T) {
})
}
}
func TestParentCycle(t *testing.T) {
t.Parallel()
srv := protontest.New(t)
u := srv.AddUser("user", "pass")
a := u.SeedFolder(u.RootID(), "a")
b := u.SeedFolder(a, "b")
self := u.SeedFolder(u.RootID(), "self")
u.SeedFile(b, "file", []byte("x"), time.Now())
u.SetParent(a, b)
u.SetParent(self, self)
c := newTestClientFor(t, srv, u, clientOptions{})
for name, id := range map[string]string{"two folders": b, "self": self} {
srv.ResetRequests()
_, err := c.getNode(context.Background(), id)
require.ErrorIs(t, err, errIntegrity, name)
assert.Contains(t, err.Error(), "its own ancestor", name)
assert.LessOrEqual(t, srv.Requests()["POST /drive/v2/volumes/{volumeID}/links"], 3, name)
}
}
func TestFolderDepthLimit(t *testing.T) {
t.Parallel()
srv := protontest.New(t)
u := srv.AddUser("user", "pass")
id := u.RootID()
var ids []string
for i := range 6 {
id = u.SeedFolder(id, fmt.Sprintf("d%d", i))
ids = append(ids, id)
}
c := newTestClientFor(t, srv, u, clientOptions{})
c.maxFolderDepth = 4
_, err := c.getNode(context.Background(), ids[5])
require.ErrorIs(t, err, errIntegrity)
assert.Contains(t, err.Error(), "more than 4 deep")
// Within the limit is fine.
c = newTestClientFor(t, srv, u, clientOptions{})
c.maxFolderDepth = 6
_, err = c.getNode(context.Background(), ids[5])
require.NoError(t, err)
}
+13
View File
@@ -821,6 +821,19 @@ func (u *User) ForgeRootSigner(signer *User, email string) {
l.name = encName
}
// SetParent reports link id as a child of parentID from now on,
// without encrypting anything anew, as a hostile server could, even if
// that makes a cycle.
func (u *User) SetParent(id, parentID string) {
u.srv.mu.Lock()
defer u.srv.mu.Unlock()
l := u.mustDriveLocked().links[id]
if l == nil {
panic("protontest: no link " + id)
}
l.parentID = parentID
}
// insertLink adds an active link l under parentID and returns its new
// ID, created now unless l has a creation time. It panics if parentID
// has an active or draft child with the same name hash, which a seeding
+3 -1
View File
@@ -596,7 +596,9 @@ library what must stay readable.
`func (s *Server) ServeKeysAs(email string, owner *User)`,
`func (u *User) SeedFolderSignedAs(signer *User, email, parentID, name string) (id string)` and
`func (u *User) SeedFileSignedAs(signer *User, email, parentID, name string, content []byte) (id string)`
(Task 16 fix round 3).
(Task 16 fix round 3), and
`func (u *User) SetParent(id, parentID string)` (a parent the server
reports, cycles included).
- `func (u *User) Dump() []DumpedNode` - decrypted tree for assertions
(`ID`, `Path`, `IsFolder`, `Content`, `ModTime`, `SHA1`, `State`, and
`Err` joining every decryption, signature, block-hash, manifest and