mirror of
https://github.com/narrrl/proton-sdk-rs.git
synced 2026-11-03 16:41:26 +01:00
added range + live test
This commit is contained in:
1 parent
643f5a2c3b
commit
c7e8467fe2
2 files changed
+270
No files matched your search
@@ -524,6 +524,146 @@ impl ProtonDriveClient {
|
||||
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.
|
||||
///
|
||||
/// Mirrors C# `FileOperations.EnumerateThumbnailsAsync` (single-file): pick
|
||||
|
||||
@@ -129,3 +129,133 @@ async fn new_revision_roundtrip() {
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user