protondrive: fix ranged reads of files which don't record block sizes
A file written by an old client without recorded block sizes was read in ranges by assuming 4 MiB blocks, so a range past the first block could return the wrong bytes, or too few, without an error. Such a range is now read from the first block, discarding the bytes before it, with every block still checked against its hash. Also identify the SDK the conformance checklist was checked against by commit, and align the enable_caching help with the docs page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7be50f9e22
commit
22b9c4fdc3
7 files changed
+170
-108
No files matched your search
@@ -39,7 +39,7 @@ const signatureHint = "set ignore_signature_errors to read the file anyway"
|
||||
|
||||
const (
|
||||
// defaultBlockSize is the plaintext size of every block but the
|
||||
// last of a file which doesn't record its block sizes.
|
||||
// last of a file the client uploads.
|
||||
defaultBlockSize = 4 << 20
|
||||
|
||||
// blocksPerPage is the most blocks one revision request lists.
|
||||
@@ -139,14 +139,15 @@ func (c *client) openRead(ctx context.Context, n *node, offset, limit int64, ful
|
||||
if limit == 0 {
|
||||
return io.NopCloser(bytes.NewReader(nil)), nil
|
||||
}
|
||||
// FIXME: without recorded block sizes, the blocks before the first
|
||||
// one read are assumed to be 4 MiB and can't be checked, so a ranged
|
||||
// read of a file written with other block sizes returns the wrong
|
||||
// bytes.
|
||||
first, skip := blockAt(sizes, offset)
|
||||
last := -1
|
||||
if limit > 0 && !full {
|
||||
last, _ = blockAt(sizes, offset+limit-1)
|
||||
// Without recorded block sizes nothing tells which block holds
|
||||
// offset, as old clients didn't all write 4 MiB blocks, so the read
|
||||
// starts at the first block and discards the bytes before offset.
|
||||
first, skip, last := 1, offset, -1
|
||||
if sizes != nil {
|
||||
first, skip = blockAt(sizes, offset)
|
||||
if limit > 0 && !full {
|
||||
last, _ = blockAt(sizes, offset+limit-1)
|
||||
}
|
||||
}
|
||||
maxBlocks := len(sizes)
|
||||
if sizes == nil {
|
||||
@@ -154,17 +155,15 @@ func (c *client) openRead(ctx context.Context, n *node, offset, limit int64, ful
|
||||
}
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
r := &blockReader{
|
||||
c: c,
|
||||
n: n,
|
||||
sk: sk,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
slots: make(chan chan blockResult, downloadAhead),
|
||||
sizes: sizes,
|
||||
checkAssumed: sizes == nil && !full,
|
||||
prevSize: -1,
|
||||
skip: skip,
|
||||
remaining: limit,
|
||||
c: c,
|
||||
n: n,
|
||||
sk: sk,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
slots: make(chan chan blockResult, downloadAhead),
|
||||
sizes: sizes,
|
||||
skip: skip,
|
||||
remaining: limit,
|
||||
}
|
||||
r.wg.Add(1)
|
||||
go r.list(first, last, maxBlocks, full)
|
||||
@@ -172,12 +171,9 @@ func (c *client) openRead(ctx context.Context, n *node, offset, limit int64, ful
|
||||
}
|
||||
|
||||
// blockAt returns the index, from 1, of the block holding plaintext
|
||||
// offset pos of a file with block sizes sizes, or 4 MiB blocks if
|
||||
// sizes is nil, and the offset of pos in it.
|
||||
// offset pos of a file with block sizes sizes, and the offset of pos in
|
||||
// it.
|
||||
func blockAt(sizes []int64, pos int64) (index int, offset int64) {
|
||||
if sizes == nil {
|
||||
return int(pos/defaultBlockSize) + 1, pos % defaultBlockSize
|
||||
}
|
||||
for i, size := range sizes {
|
||||
if pos < size {
|
||||
return i + 1, pos
|
||||
@@ -316,20 +312,18 @@ type blockResult struct {
|
||||
// blockReader reads a range of a file, fetching blocks ahead of the
|
||||
// reader in the background.
|
||||
type blockReader struct {
|
||||
c *client
|
||||
n *node
|
||||
sk *crypto.SessionKey
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
slots chan chan blockResult // results to read, in order
|
||||
wg sync.WaitGroup // the lister and the fetches
|
||||
sizes []int64 // claimed plaintext block sizes; nil if not recorded
|
||||
checkAssumed bool // whether the range relies on blocks being 4 MiB
|
||||
c *client
|
||||
n *node
|
||||
sk *crypto.SessionKey
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
slots chan chan blockResult // results to read, in order
|
||||
wg sync.WaitGroup // the lister and the fetches
|
||||
sizes []int64 // claimed plaintext block sizes; nil if not recorded
|
||||
|
||||
mu sync.Mutex // held by Read and Close
|
||||
cur *pool.RW // block being read
|
||||
prevSize int64 // size of the block read before cur; -1 for none
|
||||
skip int64 // bytes to discard from the first block
|
||||
skip int64 // bytes to discard before the range, from the first block on
|
||||
remaining int64 // bytes left to read; negative for all
|
||||
err error // returned by every Read once set
|
||||
closed bool
|
||||
@@ -588,42 +582,45 @@ func (r *blockReader) Read(p []byte) (int, error) {
|
||||
r.err = err
|
||||
continue
|
||||
}
|
||||
r.prevSize = r.cur.Size()
|
||||
_ = r.cur.Close()
|
||||
r.cur = nil
|
||||
}
|
||||
}
|
||||
|
||||
// next makes the next block current, returning io.EOF at the end.
|
||||
// next makes the next block holding bytes of the range current,
|
||||
// returning io.EOF at the end.
|
||||
func (r *blockReader) next() error {
|
||||
var slot chan blockResult
|
||||
select {
|
||||
case slot = <-r.slots:
|
||||
case <-r.ctx.Done():
|
||||
return r.ctx.Err()
|
||||
}
|
||||
res := <-slot
|
||||
switch {
|
||||
case res.err != nil && r.ctx.Err() != nil:
|
||||
return r.ctx.Err()
|
||||
case res.err != nil:
|
||||
return res.err
|
||||
case res.end:
|
||||
return io.EOF
|
||||
}
|
||||
if r.checkAssumed && r.prevSize >= 0 && r.prevSize != defaultBlockSize {
|
||||
_ = res.plain.Close()
|
||||
return fmt.Errorf("block %d is %d bytes, not the %d assumed as the file doesn't record its block sizes: read it from the start", res.index-1, r.prevSize, defaultBlockSize)
|
||||
}
|
||||
if r.skip > 0 {
|
||||
if _, err := res.plain.Seek(min(r.skip, res.plain.Size()), io.SeekStart); err != nil {
|
||||
_ = res.plain.Close()
|
||||
return err
|
||||
for {
|
||||
var slot chan blockResult
|
||||
select {
|
||||
case slot = <-r.slots:
|
||||
case <-r.ctx.Done():
|
||||
return r.ctx.Err()
|
||||
}
|
||||
r.skip = 0
|
||||
res := <-slot
|
||||
switch {
|
||||
case res.err != nil && r.ctx.Err() != nil:
|
||||
return r.ctx.Err()
|
||||
case res.err != nil:
|
||||
return res.err
|
||||
case res.end:
|
||||
return io.EOF
|
||||
}
|
||||
if size := res.plain.Size(); r.skip > 0 && r.skip >= size {
|
||||
r.skip -= size
|
||||
_ = res.plain.Close()
|
||||
continue
|
||||
}
|
||||
if r.skip > 0 {
|
||||
if _, err := res.plain.Seek(r.skip, io.SeekStart); err != nil {
|
||||
_ = res.plain.Close()
|
||||
return err
|
||||
}
|
||||
r.skip = 0
|
||||
}
|
||||
r.cur = res.plain
|
||||
return nil
|
||||
}
|
||||
r.cur = res.plain
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close stops the download and frees its buffers. It may be called
|
||||
|
||||
@@ -137,21 +137,21 @@ func TestDownloadRanges(t *testing.T) {
|
||||
c, srv, u := newTestClient(t)
|
||||
content := testContent(bigSize)
|
||||
claimed := seedFile(t, c, u, "big", content)
|
||||
assumed := withoutBlockSizes(claimed)
|
||||
unrecorded := withoutBlockSizes(claimed)
|
||||
for _, tc := range []struct {
|
||||
offset, limit int64
|
||||
blocks int // blocks fetched with the block sizes claimed
|
||||
blocksAssumed int // blocks fetched with 4 MiB blocks assumed
|
||||
offset, limit int64
|
||||
blocks int // blocks fetched with the block sizes claimed
|
||||
blocksUnrecorded int // fewest blocks fetched without them, read from the start
|
||||
}{
|
||||
{0, -1, 3, 3},
|
||||
{0, 1, 1, 1},
|
||||
{0, 0, 0, 0},
|
||||
{4<<20 - 1, 2, 2, 2},
|
||||
{4 << 20, -1, 2, 2},
|
||||
{bigSize - 1, -1, 1, 1},
|
||||
{bigSize, -1, 0, 1},
|
||||
{8 << 20, 100, 1, 1},
|
||||
{bigSize - 10, 100, 1, 1},
|
||||
{4 << 20, -1, 2, 3},
|
||||
{bigSize - 1, -1, 1, 3},
|
||||
{bigSize, -1, 0, 3},
|
||||
{8 << 20, 100, 1, 3},
|
||||
{bigSize - 10, 100, 1, 3},
|
||||
} {
|
||||
want := content[tc.offset:]
|
||||
if tc.limit >= 0 {
|
||||
@@ -161,18 +161,58 @@ func TestDownloadRanges(t *testing.T) {
|
||||
name string
|
||||
n *node
|
||||
blocks int
|
||||
}{{"claimed", claimed, tc.blocks}, {"assumed", assumed, tc.blocksAssumed}} {
|
||||
}{{"claimed", claimed, tc.blocks}, {"unrecorded", unrecorded, tc.blocksUnrecorded}} {
|
||||
t.Run(v.name+"/"+strconv.FormatInt(tc.offset, 10)+"+"+strconv.FormatInt(tc.limit, 10), func(t *testing.T) {
|
||||
srv.ResetRequests()
|
||||
data, err := readRange(t, c, v.n, tc.offset, tc.limit)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, bytes.Equal(want, data), "got %d bytes, want %d", len(data), len(want))
|
||||
assert.Equal(t, v.blocks, srv.Requests()[storagePattern], "blocks fetched")
|
||||
fetched := srv.Requests()[storagePattern]
|
||||
if v.n == claimed {
|
||||
assert.Equal(t, v.blocks, fetched, "blocks fetched")
|
||||
return
|
||||
}
|
||||
// Blocks past the range may be fetched ahead before
|
||||
// the reader is closed.
|
||||
assert.GreaterOrEqual(t, fetched, v.blocks, "blocks fetched")
|
||||
assert.LessOrEqual(t, fetched, min(v.blocks, 1)*3, "blocks fetched")
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadRangesUnrecordedBlockSizes(t *testing.T) {
|
||||
t.Parallel()
|
||||
c, _, u := newTestClient(t)
|
||||
const size = 6<<20 + 5
|
||||
content := testContent(size)
|
||||
// Blocks of 1 MiB, which a reader assuming 4 MiB blocks would
|
||||
// place wrongly.
|
||||
n := getTestNode(t, c, u.SeedFileInBlocks(u.RootID(), "small-blocks", content, 1<<20))
|
||||
require.Nil(t, n.X.BlockSizes)
|
||||
for _, tc := range []struct{ offset, limit int64 }{
|
||||
{0, -1},
|
||||
{0, 3 << 20},
|
||||
{10, 100},
|
||||
{1<<20 - 3, 10},
|
||||
{4<<20 + 10, 100},
|
||||
{4<<20 + 10, -1},
|
||||
{2<<20 + 7, 3 << 20},
|
||||
{size - 1, -1},
|
||||
{size, -1},
|
||||
} {
|
||||
t.Run(strconv.FormatInt(tc.offset, 10)+"+"+strconv.FormatInt(tc.limit, 10), func(t *testing.T) {
|
||||
want := content[tc.offset:]
|
||||
if tc.limit >= 0 {
|
||||
want = want[:min(tc.limit, int64(len(want)))]
|
||||
}
|
||||
data, err := readRange(t, c, n, tc.offset, tc.limit)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, bytes.Equal(want, data), "got %d bytes, want %d", len(data), len(want))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadLegacyFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
c, _, u := newTestClient(t)
|
||||
|
||||
@@ -188,14 +188,11 @@ client is uploading the file.`,
|
||||
Name: "enable_caching",
|
||||
Help: `Caches the files and folders metadata to reduce API calls
|
||||
|
||||
Notice: If you are mounting ProtonDrive as a VFS, please disable this feature,
|
||||
as the current implementation doesn't update or clear the cache when there are
|
||||
external changes.
|
||||
|
||||
The cache holds decrypted file and folder metadata in memory, which saves
|
||||
API calls and decryption. Changes made by other clients are not seen while
|
||||
it holds an entry, so if other clients change the same files at the same
|
||||
time, rclone may act on out of date metadata.`,
|
||||
it holds an entry, so if other clients change the same files while rclone
|
||||
runs, as can happen with a mount, disable it: otherwise rclone may act on
|
||||
out of date metadata.`,
|
||||
Advanced: true,
|
||||
Default: true,
|
||||
}, {
|
||||
|
||||
@@ -496,6 +496,7 @@ type seedSpec struct {
|
||||
createTime int64 // Unix seconds; the current time if zero
|
||||
|
||||
claimedBlockSizes []int64 // BlockSizes in the extended attributes if not nil
|
||||
blockSize int // plaintext size of every block but the last; blockSize if zero
|
||||
}
|
||||
|
||||
// SeedFolder creates folder name in parentID as another client using
|
||||
@@ -632,6 +633,20 @@ func (u *User) SeedFileClaimingBlockSizes(parentID, name string, content []byte,
|
||||
})
|
||||
}
|
||||
|
||||
// SeedFileInBlocks is SeedFile with blocks of blockSize plaintext bytes
|
||||
// but the last, and no block sizes in the extended attributes, as some
|
||||
// old clients wrote files.
|
||||
func (u *User) SeedFileInBlocks(parentID, name string, content []byte, blockSize int) (id string) {
|
||||
return u.seedFile(parentID, seedSpec{
|
||||
name: name,
|
||||
content: content,
|
||||
modTime: time.Now().UTC().Format("2006-01-02T15:04:05.000Z"),
|
||||
mediaType: mediaTypeOf(name),
|
||||
claimedBlockSizes: []int64{},
|
||||
blockSize: blockSize,
|
||||
})
|
||||
}
|
||||
|
||||
// primaryAddressKey returns the first key and the email of u's primary
|
||||
// address.
|
||||
func (u *User) primaryAddressKey() (*crypto.Key, string) {
|
||||
@@ -700,7 +715,7 @@ func (u *User) seedFile(parentID string, spec seedSpec) string {
|
||||
ck.signature = signArmored(ck.sk.Key, addr.keys[0].key)
|
||||
}
|
||||
}
|
||||
rev := ck.newRevision(spec.content, node.key, signKey, signEmail, spec.modTime, spec.noBlocks, spec.claimedBlockSizes)
|
||||
rev := ck.newRevision(spec.content, spec.blockSize, node.key, signKey, signEmail, spec.modTime, spec.noBlocks, spec.claimedBlockSizes)
|
||||
return u.insertLink(parentID, &link{
|
||||
typ: linkTypeFile,
|
||||
name: encryptArmored([]byte(spec.name), parentKey, nameSignKey, false),
|
||||
@@ -718,10 +733,14 @@ func (u *User) seedFile(parentID string, spec seedSpec) string {
|
||||
}
|
||||
|
||||
// newRevision returns an active revision of content encrypted with ck
|
||||
// in blocks of blockSize and signed by signKey as signEmail, with its
|
||||
// in blocks of size bytes, or blockSize if size is zero, and signed by
|
||||
// signKey as signEmail, with its
|
||||
// manifest and, unless modTime is empty, extended attributes claiming
|
||||
// claimedBlockSizes if not nil. With noBlocks it has no blocks.
|
||||
func (ck contentKey) newRevision(content []byte, nodeKey, signKey *crypto.Key, signEmail, modTime string, noBlocks bool, claimedBlockSizes []int64) *revision {
|
||||
func (ck contentKey) newRevision(content []byte, size int, nodeKey, signKey *crypto.Key, signEmail, modTime string, noBlocks bool, claimedBlockSizes []int64) *revision {
|
||||
if size == 0 {
|
||||
size = blockSize
|
||||
}
|
||||
rev := &revision{
|
||||
id: randomID(),
|
||||
state: revisionStateActive,
|
||||
@@ -730,8 +749,8 @@ func (ck contentKey) newRevision(content []byte, nodeKey, signKey *crypto.Key, s
|
||||
}
|
||||
var manifest []byte
|
||||
var blockSizes []int64
|
||||
for i := 0; !noBlocks && i < len(content); i += blockSize {
|
||||
plain := content[i:min(i+blockSize, len(content))]
|
||||
for i := 0; !noBlocks && i < len(content); i += size {
|
||||
plain := content[i:min(i+size, len(content))]
|
||||
data, encSig := ck.encryptBlock(plain, nodeKey, signKey)
|
||||
sum := sha256.Sum256(data)
|
||||
rev.blocks = append(rev.blocks, &block{
|
||||
@@ -765,7 +784,7 @@ func (u *User) SeedRevision(id string, content []byte, modTime time.Time) (revis
|
||||
}
|
||||
key, sk := v.contentKey(l)
|
||||
ck := contentKey{sk: sk, aead: sk.IsV6()}
|
||||
rev := ck.newRevision(content, key, v.addrKey, v.email, modTime.UTC().Format("2006-01-02T15:04:05.000Z"), false, nil)
|
||||
rev := ck.newRevision(content, 0, key, v.addrKey, v.email, modTime.UTC().Format("2006-01-02T15:04:05.000Z"), false, nil)
|
||||
u.srv.mu.Lock()
|
||||
defer u.srv.mu.Unlock()
|
||||
stored := u.mustDriveLocked().links[id]
|
||||
|
||||
+5
-2
@@ -14,14 +14,17 @@ something as Proton changes the service, and the user docs still
|
||||
described the old libraries.
|
||||
**Decision:** The backend is maintained as a port of Proton's Drive SDK,
|
||||
module by module, tracked in `docs/specs/001-conformance-checklist.md`
|
||||
against tag `js/v0.23.0`, with its deliberate differences (stricter trust
|
||||
against SDK commit `05c9115955589d9f6ab7392a36f8f0cb39327133` (whose
|
||||
`client/js/CHANGELOG.md` ends at `js/v0.23.0`; that tag is not on the
|
||||
commit), with its deliberate differences (stricter trust
|
||||
rules, no small-file path, no events, no thumbnails) listed per module.
|
||||
The user docs describe only what the code does and the protocol limits
|
||||
the SDK shares, with no fork-only content, so they could go upstream.
|
||||
**Alternatives:** Binding to the SDK itself (it is TypeScript and C#;
|
||||
rclone refuses cgo and helper binaries); writing from observed traffic
|
||||
alone, as the old libraries did (no reference to re-check against).
|
||||
**Consequences:** Each SDK release needs a walk of the checklist. The
|
||||
**Consequences:** Each SDK update needs a walk of the checklist: a diff
|
||||
of the listed files between the checked commit and the new one. The
|
||||
docs page must change with any behaviour change, and its generated
|
||||
options section is left to `make backenddocs` at release.
|
||||
|
||||
|
||||
@@ -204,10 +204,11 @@ make, checks each block against its listed hash but can't check the
|
||||
manifest, so a malicious server could serve validly encrypted blocks of
|
||||
the same file in the wrong place, or from an older version of the same
|
||||
size. Proton's SDK has the same limit for ranged reads. A range which
|
||||
covers the whole file is checked as a full read. Ranged reads of files
|
||||
uploaded by clients which don't record their block sizes only work from
|
||||
the start of the file: one from elsewhere may fail with an error saying
|
||||
so.
|
||||
covers the whole file is checked as a full read. A file uploaded by a
|
||||
client which doesn't record its block sizes can't be entered in the
|
||||
middle, so a ranged read of it downloads, checks and discards every
|
||||
block before the range, which makes it slower; its manifest still isn't
|
||||
checked.
|
||||
|
||||
**Trust is rooted in your own addresses.** The share holding your files
|
||||
must have been created, and its root folder signed, by one of your
|
||||
@@ -323,8 +324,8 @@ in memory, to save requests and decryption. Changes made by other clients
|
||||
(Proton's apps, or another rclone) to folders it has already listed are
|
||||
not seen until rclone restarts, so a long-running `rclone mount` or
|
||||
`rclone serve` can show out of date listings. If other clients change
|
||||
the same files at the same time, set the `enable_caching` advanced option
|
||||
to `false`.
|
||||
the same files while rclone runs, as can happen with a mount, set the
|
||||
`enable_caching` advanced option to `false`.
|
||||
|
||||
### Logs and `--dump`
|
||||
|
||||
|
||||
@@ -4,23 +4,28 @@ Fork-only. Maps each module of Proton's Drive SDK to the Go file of
|
||||
`backend/protondrive` that follows it, so a later SDK release can be
|
||||
walked module by module. Spec: `docs/specs/001-protondrive-tier1.md`.
|
||||
|
||||
SDK: github.com/ProtonDriveApps/sdk, tag `js/v0.23.0` (commit `05c9115`).
|
||||
SDK paths are relative to `client/js/src/` unless they start with
|
||||
`incubating/`. Go paths are relative to `backend/protondrive/`.
|
||||
SDK: github.com/ProtonDriveApps/sdk at commit
|
||||
`05c9115955589d9f6ab7392a36f8f0cb39327133` (2026-11-05). The newest entry
|
||||
of its `client/js/CHANGELOG.md` is `js/v0.23.0` (2026-10-19); the only
|
||||
tag on the commit is `cs/v0.33.0`, of the C# client, so the commit, not a
|
||||
tag, identifies what was checked. SDK paths are relative to
|
||||
`client/js/src/` unless they start with `incubating/`. Go paths are
|
||||
relative to `backend/protondrive/`.
|
||||
|
||||
To re-check against a newer tag: diff each SDK file listed below between
|
||||
the two tags, and port or record every change in the module's row. Wire
|
||||
To re-check against a newer SDK: diff each SDK file listed below between
|
||||
`05c9115` and the new commit, port or record every change in the
|
||||
module's row, and update the "Last checked" column. Wire
|
||||
shapes come from `internal/apiService/driveTypes.ts` and `coreTypes.ts`;
|
||||
where this backend and the SDK disagree about the wire format, the SDK
|
||||
wins.
|
||||
|
||||
| SDK module | SDK files | Go files | Last checked | Known deliberate differences |
|
||||
|---|---|---|---|---|
|
||||
| `apiService` | `internal/apiService/apiService.ts`, `driveTypes.ts`, `coreTypes.ts`, `errorCodes.ts`, `errors.ts` | `session.go` (HTTP, auth headers, server time), `nodes.go` (`apiCall`, `shouldRetry`, pacers), `api/types.go` | `js/v0.23.0` | All HTTP goes through rclone's `fshttp`, with separate pacers for the API and storage hosts. `Retry-After` is honoured up to 60 s. API codes map to rclone errors in `translateError` (`protondrive.go`). `x-pm-appversion` is `external-drive-rclone@<semver>-<channel>`, always with a channel; `x-pm-drive-sdk-version` is never sent. No telemetry. |
|
||||
| `shares` | `internal/shares/manager.ts`, `cryptoService.ts`, `apiService.ts` | `nodes.go` (`bootstrap`, `createVolume`), `crypto.go` | `js/v0.23.0` | Only the My Files share (`drive/v2/shares/my-files`) is used; no devices, photos or sharing. Root of trust: the share's creator and the root folder's signer must be one of the account's own addresses (compared after lowercasing ASCII letters only, verified with the account's own keys), else the whole drive is unverified. |
|
||||
| `nodes` | `internal/nodes/nodesAccess.ts`, `nodesManagement.ts`, `cryptoService.ts`, `extendedAttributes.ts`, `validations.ts`, `nodeName.ts`, `mediaTypes.ts`, `cache.ts` | `nodes.go`, `cache.go`, `crypto.go` | `js/v0.23.0` | A key verified against an unverified folder key is itself unverified, all the way down, reads included; moving, copying, renaming or writing into such nodes needs `ignore_signature_errors`, which logs an ERROR per node and operation. Parent chains deeper than 2048, or cyclic, are an integrity error. Names that are not valid UTF-8 are refused on write. Proton Docs and Sheets are left out of listings. The node cache is in memory only and is not invalidated by events. The intended-recipient check on signatures is kept, which may be stricter than the SDK. |
|
||||
| `upload` | `internal/upload/fileUploader.ts`, `streamUploader.ts`, `blockVerifier.ts`, `cryptoService.ts`, `apiService.ts`, `digests.ts`, `manager.ts`, `queue.ts` | `upload.go`, `crypto.go` | `js/v0.23.0` | No small-file path (`smallFileUploader.ts`, `files/small`; planned for M6). No thumbnails. A commit whose outcome can't be found out deletes nothing and returns `errUploadOutcomeUnknown`; a draft is deleted only on positive proof it never committed. Drafts are identified by a per-remote `draft_client_uid`, not the session UID. |
|
||||
| `download` | `internal/download/fileDownloader.ts`, `seekableStream.ts`, `blockIndex.ts`, `cryptoService.ts`, `apiService.ts`, `queue.ts` | `download.go`, `crypto.go` | `js/v0.23.0` | No thumbnails (`thumbnailDownloader.ts`). A full read lists every block and verifies the manifest before releasing any byte; a range covering the whole file is read the same way. Other ranged reads check block hashes but not the manifest, as in the SDK. A file may list at most 2^19 blocks. A re-listed block whose hash changed is an integrity error. |
|
||||
| `crypto` | `crypto/driveCrypto.ts`, `crypto/openPGPCrypto.ts` | `crypto.go` | `js/v0.23.0` | gopenpgp v3 in place of openpgp.js. Written format follows the SDK: v4 node key advertising AEAD, v6 PKESK content key, SEIPDv2 blocks; names, passphrases, hash keys and extended attributes without AEAD. |
|
||||
| account / auth | `incubating/account/js/src/auth.ts`, `authWeb.ts`, `srp.ts`, `addresses.ts`, `accountApi.ts`, `sessionCredentials.ts`, `api-auth-types.ts`; `interface/account.ts` | `session.go` (SRP, session fork, refresh, logout), `account.go` (user and address keys, public keys) | `js/v0.23.0` | Browser sign-in is the session-fork flow with client ID `external-drive`. The key salt is chosen by the primary user key's ID. Several sessions sharing one config re-read it for up to 3 s after a rejected refresh to adopt another's new tokens; there is no cross-process lock. Key unlocking is serialised across `Fs` instances. |
|
||||
| `events` | `internal/events/` | none | `js/v0.23.0` | Not implemented: no event polling and no `ChangeNotify` (M5). |
|
||||
| `apiService` | `internal/apiService/apiService.ts`, `driveTypes.ts`, `coreTypes.ts`, `errorCodes.ts`, `errors.ts` | `session.go` (HTTP, auth headers, server time), `nodes.go` (`apiCall`, `shouldRetry`, pacers), `api/types.go` | `05c9115` (js/v0.23.0) | All HTTP goes through rclone's `fshttp`, with separate pacers for the API and storage hosts. `Retry-After` is honoured up to 60 s. API codes map to rclone errors in `translateError` (`protondrive.go`). `x-pm-appversion` is `external-drive-rclone@<semver>-<channel>`, always with a channel; `x-pm-drive-sdk-version` is never sent. No telemetry. |
|
||||
| `shares` | `internal/shares/manager.ts`, `cryptoService.ts`, `apiService.ts` | `nodes.go` (`bootstrap`, `createVolume`), `crypto.go` | `05c9115` (js/v0.23.0) | Only the My Files share (`drive/v2/shares/my-files`) is used; no devices, photos or sharing. Root of trust: the share's creator and the root folder's signer must be one of the account's own addresses (compared after lowercasing ASCII letters only, verified with the account's own keys), else the whole drive is unverified. |
|
||||
| `nodes` | `internal/nodes/nodesAccess.ts`, `nodesManagement.ts`, `cryptoService.ts`, `extendedAttributes.ts`, `validations.ts`, `nodeName.ts`, `mediaTypes.ts`, `cache.ts` | `nodes.go`, `cache.go`, `crypto.go` | `05c9115` (js/v0.23.0) | A key verified against an unverified folder key is itself unverified, all the way down, reads included; moving, copying, renaming or writing into such nodes needs `ignore_signature_errors`, which logs an ERROR per node and operation. Parent chains deeper than 2048, or cyclic, are an integrity error. Names that are not valid UTF-8 are refused on write. Proton Docs and Sheets are left out of listings. The node cache is in memory only and is not invalidated by events. The intended-recipient check on signatures is kept, which may be stricter than the SDK. |
|
||||
| `upload` | `internal/upload/fileUploader.ts`, `streamUploader.ts`, `blockVerifier.ts`, `cryptoService.ts`, `apiService.ts`, `digests.ts`, `manager.ts`, `queue.ts` | `upload.go`, `crypto.go` | `05c9115` (js/v0.23.0) | No small-file path (`smallFileUploader.ts`, `files/small`; planned for M6). No thumbnails. A commit whose outcome can't be found out deletes nothing and returns `errUploadOutcomeUnknown`; a draft is deleted only on positive proof it never committed. Drafts are identified by a per-remote `draft_client_uid`, not the session UID. |
|
||||
| `download` | `internal/download/fileDownloader.ts`, `seekableStream.ts`, `blockIndex.ts`, `cryptoService.ts`, `apiService.ts`, `queue.ts` | `download.go`, `crypto.go` | `05c9115` (js/v0.23.0) | No thumbnails (`thumbnailDownloader.ts`). A full read lists every block and verifies the manifest before releasing any byte; a range covering the whole file is read the same way. Other ranged reads check block hashes but not the manifest, as in the SDK. A file may list at most 2^19 blocks. A re-listed block whose hash changed is an integrity error. A ranged read of a file whose block sizes aren't recorded starts at the first block and discards the bytes before the range. |
|
||||
| `crypto` | `crypto/driveCrypto.ts`, `crypto/openPGPCrypto.ts` | `crypto.go` | `05c9115` (js/v0.23.0) | gopenpgp v3 in place of openpgp.js. Written format follows the SDK: v4 node key advertising AEAD, v6 PKESK content key, SEIPDv2 blocks; names, passphrases, hash keys and extended attributes without AEAD. |
|
||||
| account / auth | `incubating/account/js/src/auth.ts`, `authWeb.ts`, `srp.ts`, `addresses.ts`, `accountApi.ts`, `sessionCredentials.ts`, `api-auth-types.ts`; `interface/account.ts` | `session.go` (SRP, session fork, refresh, logout), `account.go` (user and address keys, public keys) | `05c9115` (js/v0.23.0) | Browser sign-in is the session-fork flow with client ID `external-drive`. The key salt is chosen by the primary user key's ID. Several sessions sharing one config re-read it for up to 3 s after a rejected refresh to adopt another's new tokens; there is no cross-process lock. Key unlocking is serialised across `Fs` instances. |
|
||||
| `events` | `internal/events/` | none | `05c9115` (js/v0.23.0) | Not implemented: no event polling and no `ChangeNotify` (M5). |
|
||||
Reference in new issue
Block a user