From bdfa908ffd7fdb3b6f092c7b4ec03df3791adf76 Mon Sep 17 00:00:00 2001 From: Josh Miller Date: Wed, 25 Feb 2026 08:15:02 -0500 Subject: [PATCH 1/2] drive: fix two upload failures caused by Proton backend changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit fixes two bugs that prevent file uploads to Proton Drive: --- Bug 1: handleRevisionConflict ignores ReplaceExistingDraft when GetRevisions returns 2501 --- When a previous upload attempt fails after the file draft is created but before blocks are committed, a broken draft remains. On the next attempt the draft is found (2500 "file already exists"), and GetRevisions is called to decide what to do with it. However, broken drafts return 422/2501 from the /revisions endpoint, causing handleRevisionConflict to return an error immediately — even when ReplaceExistingDraft=true. Fix: if GetRevisions fails AND ReplaceExistingDraft is set AND the link is in draft state, treat it as a broken draft and delete the link so the caller can retry from scratch. --- Bug 2: block uploads missing required Verifier.Token --- Proton's storage backend now requires a Verifier.Token per block in the POST /drive/blocks request. Without it, the storage server rejects block uploads with HTTP 422 / Code=200501 "Operation failed: Please retry". The token is computed by fetching a VerificationCode for the revision via: GET /drive/v2/volumes/{volumeID}/links/{linkID}/revisions/{revisionID}/verification then XOR-ing it with the leading bytes of each block's ciphertext (algorithm sourced from the official Proton Drive JS SDK in ProtonDriveApps/sdk). Also bumps go-proton-api to v1.0.1-0.20260218123427-1a63a293e3a2 which updates the default API host from mail.proton.me/api to drive-api.proton.me. Note: the JS SDK performs an additional client-side decryption check before computing the XOR token (to detect bit-flips / bad hardware). That step is not implemented here; the server-side manifest signature still provides end-to-end integrity verification. A future improvement could add it. This fix was identified and generated with Claude Code (AI assistant) by a non-programmer user (GitHub: lmwashere). It has not been independently reviewed by a Go or cryptography expert. Expert review before merging is strongly recommended. Reproducer: rclone copy proton: --verbose Expected: upload succeeds Actual: 422 POST fra-storage.proton.me/storage/blocks (Code=200501) followed by 422 GET .../revisions (Code=2501) on retry --- file_upload.go | 42 ++++++++++++++++++++++++++++++++++++++++-- go.mod | 2 +- go.sum | 2 ++ 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/file_upload.go b/file_upload.go index 4318df8..cd8cc95 100644 --- a/file_upload.go +++ b/file_upload.go @@ -8,6 +8,7 @@ import ( "crypto/sha256" "encoding/base64" "encoding/hex" + "fmt" "io" "mime" "os" @@ -24,6 +25,17 @@ func (protonDrive *ProtonDrive) handleRevisionConflict(ctx context.Context, link draftRevision, err := protonDrive.GetRevisions(ctx, link, proton.RevisionStateDraft) if err != nil { + // If we can't list revisions but the link is already in draft state + // (e.g. a broken/incomplete upload from a previous failed attempt) + // and the user wants to replace existing drafts, delete the link and + // let the caller retry from scratch rather than failing outright. + if protonDrive.Config.ReplaceExistingDraft && link.State == proton.LinkStateDraft { + err = protonDrive.c.DeleteChildren(ctx, protonDrive.MainShare.ShareID, link.ParentLinkID, linkID) + if err != nil { + return "", false, err + } + return "", true, nil + } return "", false, err } @@ -250,6 +262,18 @@ func (protonDrive *ProtonDrive) uploadAndCollectBlockData(ctx context.Context, n return nil, 0, nil, "", ErrMissingInputUploadAndCollectBlockData } + // Fetch the per-revision verification code required by Proton's storage backend. + // Each block's Verifier.Token is produced by XOR-ing this code with the first + // bytes of that block's ciphertext (per the Proton Drive JS SDK spec). + revVerification, err := protonDrive.c.GetRevisionVerification(ctx, protonDrive.MainShare.VolumeID, linkID, revisionID) + if err != nil { + return nil, 0, nil, "", fmt.Errorf("uploadAndCollectBlockData: get revision verification: %w", err) + } + verificationCode, err := base64.StdEncoding.DecodeString(revVerification.VerificationCode) + if err != nil { + return nil, 0, nil, "", fmt.Errorf("uploadAndCollectBlockData: decode verification code: %w", err) + } + totalFileSize := int64(0) pendingUploadBlocks := make([]PendingUploadBlocks, 0) @@ -309,7 +333,7 @@ func (protonDrive *ProtonDrive) uploadAndCollectBlockData(ctx context.Context, n blockSizes := make([]int64, 0) for i := 1; shouldContinue; i++ { if (i-1) > 0 && (i-1)%UPLOAD_BATCH_BLOCK_SIZE == 0 { - err := uploadPendingBlocks() + err = uploadPendingBlocks() if err != nil { return nil, 0, nil, "", err } @@ -365,17 +389,31 @@ func (protonDrive *ProtonDrive) uploadAndCollectBlockData(ctx context.Context, n } manifestSignatureData = append(manifestSignatureData, hash...) + // Compute per-block verifier token: XOR verificationCode with the + // leading bytes of the encrypted block (zero-padded if block is shorter). + verificationToken := make([]byte, len(verificationCode)) + for j, v := range verificationCode { + var b byte + if j < len(encData) { + b = encData[j] + } + verificationToken[j] = v ^ b + } + pendingUploadBlocks = append(pendingUploadBlocks, PendingUploadBlocks{ blockUploadInfo: proton.BlockUploadInfo{ Index: i, // iOS drive: BE starts with 1 Size: int64(len(encData)), EncSignature: encSignatureStr, Hash: base64Hash, + Verifier: proton.BlockUploadVerifier{ + Token: base64.StdEncoding.EncodeToString(verificationToken), + }, }, encData: encData, }) } - err := uploadPendingBlocks() + err = uploadPendingBlocks() if err != nil { return nil, 0, nil, "", err } diff --git a/go.mod b/go.mod index 62ef8b4..2f60fce 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ toolchain go1.23.5 require ( github.com/ProtonMail/gluon v0.17.1-0.20230724134000-308be39be96e github.com/ProtonMail/gopenpgp/v2 v2.8.2 - github.com/rclone/go-proton-api v1.0.1-0.20260127173028-eb465cac3b18 + github.com/rclone/go-proton-api v1.0.1-0.20260218123427-1a63a293e3a2 github.com/relvacode/iso8601 v1.6.0 golang.org/x/sync v0.10.0 ) diff --git a/go.sum b/go.sum index 5b8df5d..125b082 100644 --- a/go.sum +++ b/go.sum @@ -77,6 +77,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/rclone/go-proton-api v1.0.1-0.20260127173028-eb465cac3b18 h1:Lc+d3ISfQaMJKWZOE7z4ZSY4RVmdzbn1B0IM8xN18qM= github.com/rclone/go-proton-api v1.0.1-0.20260127173028-eb465cac3b18/go.mod h1:LB2kCEaZMzNn3ocdz+qYfxXmuLxxN0ka62KJd2x53Bc= +github.com/rclone/go-proton-api v1.0.1-0.20260218123427-1a63a293e3a2 h1:QR87vlRq+z0JwJsUteEhsXcSrXGJ2yte5MocMSfajM4= +github.com/rclone/go-proton-api v1.0.1-0.20260218123427-1a63a293e3a2/go.mod h1:LB2kCEaZMzNn3ocdz+qYfxXmuLxxN0ka62KJd2x53Bc= github.com/relvacode/iso8601 v1.6.0 h1:eFXUhMJN3Gz8Rcq82f9DTMW0svjtAVuIEULglM7QHTU= github.com/relvacode/iso8601 v1.6.0/go.mod h1:FlNp+jz+TXpyRqgmM7tnzHHzBnz776kmAH2h3sZCn0I= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= From d9f00594ec47115b0ceddb0643a0419e699c827c Mon Sep 17 00:00:00 2001 From: Josh Miller Date: Thu, 26 Mar 2026 22:03:37 -0400 Subject: [PATCH 2/2] fix: move support, stale draft cleanup, and upload reliability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes for Proton Drive operations, developed with AI assistance (Claude Sonnet 4.6). Human review is strongly encouraged — especially for the cryptographic aspects of the move fix and any edge cases around anonymously-created nodes. 1. moveLink: use NameSignatureEmail instead of SignatureAddress. The Proton API requires NameSignatureEmail (the email used to sign the encrypted name) and the old SignatureAddress field maps to SignatureEmail in JSON (omitempty, only for anonymous nodes). NodePassphraseSignature is also removed from the move request as it is only needed for anonymously-created nodes. Ref: https://github.com/ProtonDriveApps/windows-drive/blob/main/src/ProtonDrive.Client/MoveLinkParameters.cs 2. handleRevisionConflict: unconditionally delete broken draft links. When GetRevisions returns 2501 on a link in draft state, the link is always corrupted and unrecoverable. Previously this cleanup was gated on the ReplaceExistingDraft flag, causing uploads to fail on retry unless the flag was explicitly set. 3. uploadPendingBlocks: fix semaphore leak and goroutine cleanup. - Use a per-batch cancellable context so sibling goroutines are cancelled promptly when one block fails, releasing semaphore slots before the outer retry begins. - Use a buffered errChan so goroutines never block after cancellation. - Fix: Acquire failure now returns early instead of falling through to the deferred Release (which would release a slot never acquired). Co-Authored-By: Claude Sonnet 4.6 --- file_upload.go | 45 ++++++++++++++++++++++++++++----------------- folder.go | 7 +++---- 2 files changed, 31 insertions(+), 21 deletions(-) diff --git a/file_upload.go b/file_upload.go index cd8cc95..20e3dec 100644 --- a/file_upload.go +++ b/file_upload.go @@ -2,7 +2,6 @@ package proton_api_bridge import ( "bufio" - "bytes" "context" "crypto/sha1" "crypto/sha256" @@ -25,11 +24,10 @@ func (protonDrive *ProtonDrive) handleRevisionConflict(ctx context.Context, link draftRevision, err := protonDrive.GetRevisions(ctx, link, proton.RevisionStateDraft) if err != nil { - // If we can't list revisions but the link is already in draft state - // (e.g. a broken/incomplete upload from a previous failed attempt) - // and the user wants to replace existing drafts, delete the link and - // let the caller retry from scratch rather than failing outright. - if protonDrive.Config.ReplaceExistingDraft && link.State == proton.LinkStateDraft { + // If we can't list revisions and the link is already in draft state, + // it's a broken/incomplete upload from a previous failed attempt with + // no recoverable state. Always delete it and retry from scratch. + if link.State == proton.LinkStateDraft { err = protonDrive.c.DeleteChildren(ctx, protonDrive.MainShare.ShareID, link.ParentLinkID, linkID) if err != nil { return "", false, err @@ -300,28 +298,41 @@ func (protonDrive *ProtonDrive) uploadAndCollectBlockData(ctx context.Context, n return err } - errChan := make(chan error) - uploadBlockWrapper := func(ctx context.Context, errChan chan error, bareURL, token string, block io.Reader) { - // log.Println("Before semaphore") - if err := protonDrive.blockUploadSemaphore.Acquire(ctx, 1); err != nil { + // Use a per-batch cancellable context so that when one block upload + // fails, all sibling goroutines are cancelled promptly and release + // their semaphore slots before the outer retry begins. + batchCtx, batchCancel := context.WithCancel(ctx) + defer batchCancel() + + // Buffered so every goroutine can always send without blocking, + // even after the first error has been received and batchCancel called. + errChan := make(chan error, len(blockUploadResp)) + uploadBlockWrapper := func(bareURL, token string, block []byte) { + if err := protonDrive.blockUploadSemaphore.Acquire(batchCtx, 1); err != nil { errChan <- err + return // must not defer-Release a slot we never acquired } defer protonDrive.blockUploadSemaphore.Release(1) - // log.Println("After semaphore") - // defer log.Println("Release semaphore") - errChan <- protonDrive.c.UploadBlock(ctx, bareURL, token, block) + errChan <- protonDrive.c.UploadBlock(batchCtx, bareURL, token, block) } for i := range blockUploadResp { - go uploadBlockWrapper(ctx, errChan, blockUploadResp[i].BareURL, blockUploadResp[i].Token, bytes.NewReader(pendingUploadBlocks[i].encData)) + go uploadBlockWrapper(blockUploadResp[i].BareURL, blockUploadResp[i].Token, pendingUploadBlocks[i].encData) } + // Drain all goroutines. Cancel on first error so the rest stop quickly, + // but still wait for all of them so semaphore slots are fully released + // before we return. + var firstErr error for i := 0; i < len(blockUploadResp); i++ { - err := <-errChan - if err != nil { - return err + if err := <-errChan; err != nil && firstErr == nil { + firstErr = err + batchCancel() } } + if firstErr != nil { + return firstErr + } pendingUploadBlocks = pendingUploadBlocks[:0] diff --git a/folder.go b/folder.go index 2aedf67..475a657 100644 --- a/folder.go +++ b/folder.go @@ -202,9 +202,9 @@ func (protonDrive *ProtonDrive) MoveFolder(ctx context.Context, srcLink *proton. func (protonDrive *ProtonDrive) moveLink(ctx context.Context, srcLink *proton.Link, dstParentLink *proton.Link, dstName string) error { // we are moving the srcLink to under dstParentLink, with name dstName req := proton.MoveLinkReq{ - ParentLinkID: dstParentLink.LinkID, - OriginalHash: srcLink.Hash, - SignatureAddress: protonDrive.signatureAddress, + ParentLinkID: dstParentLink.LinkID, + OriginalHash: srcLink.Hash, + NameSignatureEmail: protonDrive.signatureAddress, } dstParentKR, err := protonDrive.getLinkKR(ctx, dstParentLink) @@ -239,7 +239,6 @@ func (protonDrive *ProtonDrive) moveLink(ctx context.Context, srcLink *proton.Li return err } req.NodePassphrase = nodePassphrase - req.NodePassphraseSignature = srcLink.NodePassphraseSignature protonDrive.removeLinkIDFromCache(srcLink.LinkID, false)