client: range-block overlap helper; document pre-offset skip blocker

ober

7e3f0969635d6c711131e8c857d9d7ce90d283e8

diff --git a/protonstorage/drive/client.ss b/protonstorage/drive/client.ss
index 1366909..3705004 100644
--- a/protonstorage/drive/client.ss
+++ b/protonstorage/drive/client.ss
@@ -26,6 +26,7 @@
     proton-drive-active-file-revision
     proton-drive-read-file-bytes
     proton-drive-read-file-range
+    proton-drive-overlapping-blocks
     proton-drive-create-folder-at-path
     proton-drive-create-root-folder
     proton-drive-upload-file-at-path-bytes
@@ -397,6 +398,39 @@
             (download-decrypt-block session content-key block file-key address-key))
           blocks))))
 
+  ;; Revision blocks carry only their ENCRYPTED size ("Size"); the per-block
+  ;; PLAINTEXT size is not published in the block list. The only authenticated
+  ;; source of plaintext block sizes is the revision XAttr "Common.BlockSizes"
+  ;; (see drive/write.ss), which is encrypted+signed and which the read path
+  ;; does not decrypt: no RustSec-clean Proton OpenPGP backend exists yet (see
+  ;; SECURITY.md and the disabled native crypto in drive/crypto.ss). The
+  ;; manifest signature covers only the concatenated encrypted block hashes, not
+  ;; sizes. A fixed block size (proton-drive-default-upload-block-size) cannot
+  ;; be assumed either: the upload block size is configurable and differs for
+  ;; files written by other Proton clients, so guessing boundaries could skip
+  ;; the wrong blocks and return corrupted plaintext. Until authenticated
+  ;; plaintext block sizes are available on the read path, pre-offset blocks
+  ;; cannot be skipped safely; return #f to select the sequential
+  ;; decrypt-from-start path in proton-drive-read-file-range.
+  (def (revision-plaintext-block-sizes revision)
+    #f)
+
+  ;; Given authenticated plaintext block sizes (in block order) and a byte
+  ;; offset `start`, drop the leading blocks whose plaintext range ends at or
+  ;; before `start`. Returns (values remaining-blocks head-offset) where
+  ;; head-offset is the plaintext byte offset of the first remaining block, so a
+  ;; ranged read can decrypt only the blocks overlapping the range instead of
+  ;; every block from the start.
+  (def (proton-drive-overlapping-blocks blocks plain-sizes start)
+    (let loop ([bs blocks] [sizes plain-sizes] [pos 0])
+      (cond
+        [(or (null? bs) (null? sizes)) (values bs pos)]
+        [else
+         (let ([block-end (+ pos (car sizes))])
+           (if (<= block-end start)
+               (loop (cdr bs) (cdr sizes) block-end)
+               (values bs pos)))])))
+
   (def (proton-drive-read-file-range session share-id parent-key file-link size offset
                                      address-key)
     (unless (proton-drive-link-file? file-link)
@@ -412,33 +446,43 @@
       (let* ([total (link-size file-link)]
              [start (if (and (number? offset) (> offset 0)) offset 0)]
              [count (if (and (number? size) (>= size 0)) size total)]
-             [end (min (+ start count) total)])
-        ;; Revision blocks carry no plaintext size, so blocks before `start`
-        ;; cannot be skipped; decrypt in index order but stop once past `end`,
-        ;; keeping only the portion overlapping [start,end).
+             [end (min (+ start count) total)]
+             [plain-sizes (revision-plaintext-block-sizes revision)])
+        ;; When authenticated plaintext block sizes are available, drop the
+        ;; blocks ending at or before `start` so they are never downloaded or
+        ;; decrypted (proton-drive-overlapping-blocks). Until then plain-sizes is
+        ;; #f (see revision-plaintext-block-sizes) and blocks must be decrypted
+        ;; from the start to learn each plaintext length, stopping once past
+        ;; `end` and keeping only the portion overlapping [start,end).
         (if (>= start end)
             (make-bytevector 0)
-            (let loop ([bs blocks] [pos 0] [acc '()])
-              (cond
-                [(or (null? bs) (>= pos end))
-                 (concat-bytevectors (reverse acc))]
-                [else
-                 (let* ([block (car bs)]
-                        [plain
-                         (download-decrypt-block
-                           session content-key block file-key address-key)]
-                        [blen (bytevector-length plain)]
-                        [block-end (+ pos blen)])
-                   (if (<= block-end start)
-                       (loop (cdr bs) block-end acc)
-                       (let* ([sel-start (max 0 (- start pos))]
-                              [sel-end (min blen (- end pos))]
-                              [sel (bv-slice plain sel-start sel-end)])
-                         (loop (cdr bs)
-                               block-end
-                               (if (> (bytevector-length sel) 0)
-                                   (cons sel acc)
-                                   acc)))))]))))))
+            (call-with-values
+              (lambda ()
+                (if plain-sizes
+                    (proton-drive-overlapping-blocks blocks plain-sizes start)
+                    (values blocks 0)))
+              (lambda (bs pos)
+                (let loop ([bs bs] [pos pos] [acc '()])
+                  (cond
+                    [(or (null? bs) (>= pos end))
+                     (concat-bytevectors (reverse acc))]
+                    [else
+                     (let* ([block (car bs)]
+                            [plain
+                             (download-decrypt-block
+                               session content-key block file-key address-key)]
+                            [blen (bytevector-length plain)]
+                            [block-end (+ pos blen)])
+                       (if (<= block-end start)
+                           (loop (cdr bs) block-end acc)
+                           (let* ([sel-start (max 0 (- start pos))]
+                                  [sel-end (min blen (- end pos))]
+                                  [sel (bv-slice plain sel-start sel-end)])
+                             (loop (cdr bs)
+                                   block-end
+                                   (if (> (bytevector-length sel) 0)
+                                       (cons sel acc)
+                                       acc)))))]))))))))
 
   (def (link-state link)
     (let ([value (jmaybe link "State" 0)])
diff --git a/test/test-all.ss b/test/test-all.ss
index 8ef5881..1947670 100644
--- a/test/test-all.ss
+++ b/test/test-all.ss
@@ -970,6 +970,37 @@
            (lambda ()
              (proton-drive-current-manifest-signature-verifier old-verifier)))))
 
+(check "ranged read skips pre-offset blocks when plaintext block sizes are known"
+       (let* ([mk-block
+               (lambda (index)
+                 (let ([b (make-hashtable equal-hash equal?)])
+                   (hashtable-set! b "Index" index)
+                   b))]
+              [blocks
+               (list (mk-block 1) (mk-block 2) (mk-block 3) (mk-block 4))]
+              [sizes '(10 10 10 5)])
+         (and
+           (call-with-values
+             (lambda () (proton-drive-overlapping-blocks blocks sizes 0))
+             (lambda (remaining head-offset)
+               (and (= head-offset 0) (= (length remaining) 4))))
+           (call-with-values
+             (lambda () (proton-drive-overlapping-blocks blocks sizes 12))
+             (lambda (remaining head-offset)
+               (and (= head-offset 10)
+                    (= (length remaining) 3)
+                    (= (hashtable-ref (car remaining) "Index" 0) 2))))
+           (call-with-values
+             (lambda () (proton-drive-overlapping-blocks blocks sizes 30))
+             (lambda (remaining head-offset)
+               (and (= head-offset 30)
+                    (= (length remaining) 1)
+                    (= (hashtable-ref (car remaining) "Index" 0) 4))))
+           (call-with-values
+             (lambda () (proton-drive-overlapping-blocks blocks sizes 100))
+             (lambda (remaining head-offset)
+               (and (= head-offset 35) (null? remaining)))))))
+
 (check "content session key decrypt verifies packet signature before decrypting"
        (let* ([file-properties (make-hashtable equal-hash equal?)]
               [file-link (make-hashtable equal-hash equal?)]