added range + live test

This commit is contained in:
nvrl committed 2026-06-28 16:14:37 +02:00
1 parent 643f5a2c3b
commit c7e8467fe2
2 files changed
+270

No files matched your search

+140
View File
@@ -524,6 +524,146 @@ impl ProtonDriveClient {
Ok(()) Ok(())
} }
/// Download and decrypt only the plaintext byte range `[offset, offset + length)`
/// of a file's active revision.
///
/// Each content block decrypts independently under the revision's content
/// key ([`ContentKey::decrypt_block`]), so an on-demand reader can fetch
/// just the blocks that overlap the requested range instead of the whole
/// file — the basis for a FUSE/placeholder mount that hydrates on access.
///
/// Block plaintext sizes come from the revision's extended attributes
/// (`Common.BlockSizes`); absent that, blocks are assumed to be
/// [`DEFAULT_BLOCK_SIZE`] with a possibly-shorter final block inferred from
/// the recorded total size. The range is clamped to the file's length, so a
/// read at or past EOF yields fewer bytes (or none).
///
/// Unlike [`download_file_to`](Self::download_file_to), a partial read
/// cannot recompute the full content manifest, so manifest-signature
/// verification is skipped.
pub async fn download_range(
&self,
uid: &NodeUid,
offset: u64,
length: u64,
) -> Result<Vec<u8>> {
let mut timer = self.telemetry.start("download_range");
let details = self
.get_link_details(&uid.volume_id, std::slice::from_ref(&uid.link_id))
.await?;
let detail = details
.links
.into_iter()
.next()
.ok_or_else(|| ProtonError::invalid_operation(format!("file {uid} not found")))?;
let link = detail.link;
let file = detail
.file
.ok_or_else(|| ProtonError::invalid_operation(format!("node {uid} is not a file")))?;
let content_key_packet_b64 = file.content_key_packet.ok_or_else(|| {
ProtonError::invalid_operation("file is missing its content key packet")
})?;
let content_key_packet = BASE64.decode(content_key_packet_b64.trim()).map_err(|e| {
ProtonError::invalid_operation(format!("decode content key packet: {e}"))
})?;
let revision_id = file
.active_revision
.map(|r| r.id)
.ok_or_else(|| ProtonError::invalid_operation("file has no active revision"))?;
let parent_key = self.resolve_parent_key(&uid.volume_id, &link).await?;
let node_key = decrypt_link(&parent_key, &link)?.node_key;
let content_key = node_key.decrypt_content_key(&content_key_packet)?;
let (revision, blocks) = self
.fetch_revision_blocks(&uid.volume_id, &uid.link_id, &revision_id)
.await?;
timer.attr("block_count", blocks.len());
let block_sizes = self
.resolve_block_sizes(&node_key, &revision, blocks.len())
.await;
let file_size: u64 = block_sizes.iter().sum();
if length == 0 || offset >= file_size {
timer.success();
return Ok(Vec::new());
}
let end = offset.saturating_add(length).min(file_size);
// Walk blocks in order, fetching and decrypting only those whose
// plaintext span overlaps `[offset, end)`.
let mut out = Vec::with_capacity((end - offset) as usize);
let mut block_start: u64 = 0;
for (block, &bsize) in blocks.iter().zip(block_sizes.iter()) {
let block_end = block_start + bsize;
if block_end <= offset {
block_start = block_end;
continue;
}
if block_start >= end {
break;
}
let ciphertext = self
.http
.get_storage_blob(&block.bare_url, &block.token)
.await?;
let plaintext = content_key.decrypt_block(&ciphertext)?;
let from = offset.saturating_sub(block_start) as usize;
let to = ((end - block_start) as usize).min(plaintext.len());
if from < to {
out.extend_from_slice(&plaintext[from..to]);
}
block_start = block_end;
}
timer.success();
Ok(out)
}
/// Plaintext size of each content block, in block order.
///
/// Prefers `Common.BlockSizes` from the revision's extended attributes (the
/// authoritative value written by the uploading client). When that is
/// absent, malformed, or the wrong length, assumes every block is
/// [`DEFAULT_BLOCK_SIZE`] with a final short block sized from `Common.Size`,
/// falling back to all-full blocks when even the total size is unknown.
async fn resolve_block_sizes(
&self,
node_key: &PrivateKey,
revision: &RevisionDto,
block_count: usize,
) -> Vec<u64> {
let common = match &revision.extended_attributes {
Some(xattr) => decrypt_extended_attributes_verified(
&self.account,
node_key,
revision.signature_email.as_deref(),
xattr,
)
.await
.ok()
.and_then(|(attrs, _status)| attrs.common),
None => None,
};
if let Some(sizes) = common.as_ref().and_then(|c| c.block_sizes.as_ref())
&& sizes.len() == block_count
{
return sizes.iter().map(|&n| n.max(0) as u64).collect();
}
let block = DEFAULT_BLOCK_SIZE as u64;
if let Some(total) = common.and_then(|c| c.size).filter(|&n| n >= 0) {
let total = total as u64;
return (0..block_count)
.map(|i| total.saturating_sub(block * i as u64).min(block))
.collect();
}
vec![block; block_count]
}
/// Download and decrypt a file's thumbnail of the given type, if it has one. /// Download and decrypt a file's thumbnail of the given type, if it has one.
/// ///
/// Mirrors C# `FileOperations.EnumerateThumbnailsAsync` (single-file): pick /// Mirrors C# `FileOperations.EnumerateThumbnailsAsync` (single-file): pick
@@ -129,3 +129,133 @@ async fn new_revision_roundtrip() {
cleanup(client, &[uid]).await; cleanup(client, &[uid]).await;
} }
/// Single-block partial reads: every `download_range` slice must equal the
/// corresponding plaintext slice, with clamping/empty edge cases.
#[tokio::test]
#[ignore = "live: needs test-account credentials"]
async fn download_range_single_block() {
let Some(live) = common::live_client().await else {
return;
};
let client = &live.client;
let root = client
.get_my_files_folder()
.await
.expect("get my-files root");
// Sub-block payload (well under 4 MiB) → exercises the single-block path.
let size = 64 * 1024usize;
let mut payload = vec![0u8; size];
for (i, b) in payload.iter_mut().enumerate() {
*b = (i as u32).wrapping_mul(2_654_435_761) as u8;
}
let name = format!("rt-range-1blk-{}.bin", common::unique_suffix());
let uid = client
.upload_file(&root.uid, &name, "application/octet-stream", &payload)
.await
.expect("upload_file");
let total = size as u64;
// (offset, length, expected slice end clamped to size)
let cases: &[(u64, u64)] = &[
(0, 100), // head
(1000, 5000), // interior
(size as u64 - 10, 10), // exact tail
(size as u64 - 10, 1000), // past EOF → clamps to tail
(0, total), // whole file via range
(total, 100), // offset == size → empty
(total + 50, 100), // offset past EOF → empty
(1000, 0), // zero length → empty
];
for &(off, len) in cases {
let got = client
.download_range(&uid, off, len)
.await
.unwrap_or_else(|e| panic!("download_range({off},{len}): {e}"));
let from = (off as usize).min(size);
let to = ((off + len) as usize).min(size);
let want = &payload[from..to];
assert_eq!(
got, want,
"range(off={off}, len={len}) mismatch: got {} bytes, want {}",
got.len(),
want.len()
);
}
cleanup(client, &[uid]).await;
}
/// Multi-block partial reads (> 4 MiB): ranges that land mid-block, straddle
/// block boundaries, and cover the short final block must all match.
#[tokio::test]
#[ignore = "live: needs test-account credentials"]
async fn download_range_multi_block() {
let Some(live) = common::live_client().await else {
return;
};
let client = &live.client;
let root = client
.get_my_files_folder()
.await
.expect("get my-files root");
// 10 MiB → blocks of 4 MiB, 4 MiB, 2 MiB (1-indexed, contiguous).
let block = 4 * 1024 * 1024u64;
let size = (10 * 1024 * 1024) as usize;
let mut payload = vec![0u8; size];
for (i, b) in payload.iter_mut().enumerate() {
*b = (i as u32).wrapping_mul(2_654_435_761) as u8;
}
let name = format!("rt-range-multi-{}.bin", common::unique_suffix());
let reader = std::io::Cursor::new(payload.clone());
let uid = client
.upload_file_from(
&root.uid,
&name,
"application/octet-stream",
reader,
size as i64,
Vec::new(),
None,
false,
)
.await
.expect("upload_file_from");
let total = size as u64;
let cases: &[(u64, u64)] = &[
(0, 256), // start of block 1
(block - 128, 256), // straddles block 1 → block 2
(block, 4096), // exact start of block 2
(2 * block - 1, 2), // straddles block 2 → block 3 (final short block)
(2 * block + 1000, 50_000),// interior of final short block
(total - 100, 500), // tail, length past EOF → clamps
(block - 10, block + 20), // spans a full block plus both neighbors
(0, total), // whole file
];
for &(off, len) in cases {
let got = client
.download_range(&uid, off, len)
.await
.unwrap_or_else(|e| panic!("download_range({off},{len}): {e}"));
let from = (off as usize).min(size);
let to = ((off + len) as usize).min(size);
let want = &payload[from..to];
assert_eq!(
got.len(),
want.len(),
"range(off={off}, len={len}) length mismatch"
);
assert_eq!(got, want, "range(off={off}, len={len}) byte mismatch");
}
cleanup(client, &[uid]).await;
}