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:
Lucas WintherandClaude Opus 5.5 committed 2026-11-12 10:19:53 +01:00
1 parent 7be50f9e22
commit 22b9c4fdc3
7 files changed
+170 -108

No files matched your search

+61 -64
View File
@@ -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
+51 -11
View File
@@ -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)
+3 -6
View File
@@ -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,
}, {
+25 -6
View File
@@ -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
View File
@@ -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.
+7 -6
View File
@@ -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`
+18 -13
View File
@@ -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). |