photo, volume op tests + fixes

This commit is contained in:
nvrl committed 2026-06-28 10:25:54 +02:00
1 parent 52b594e524
commit 20fe359162
8 files changed
+839 -56

No files matched your search

+9 -8
View File
@@ -13,8 +13,8 @@ use proton_sdk::account::AccountClient;
use proton_sdk::cache::{CacheRepository, InMemoryCacheRepository}; use proton_sdk::cache::{CacheRepository, InMemoryCacheRepository};
use proton_sdk::crypto::PrivateKey; use proton_sdk::crypto::PrivateKey;
use proton_sdk::crypto::{ use proton_sdk::crypto::{
build_volume_creation_material, generate_node_hash_key, generate_node_key, verify_detached, build_volume_creation_material, generate_node_hash_key, generate_node_key,
ContentKey, VerificationKeyRing, VerificationStatus, generate_node_key_aead, verify_detached, ContentKey, VerificationKeyRing, VerificationStatus,
}; };
use proton_sdk::error::{ProtonError, Result}; use proton_sdk::error::{ProtonError, Result};
use proton_sdk::http::ApiHttpClient; use proton_sdk::http::ApiHttpClient;
@@ -584,7 +584,7 @@ impl ProtonDriveClient {
.http .http
.get_storage_blob(&block.bare_url, &block.token) .get_storage_blob(&block.bare_url, &block.token)
.await?; .await?;
let plaintext = content_key.decrypt_block(&ciphertext)?; let plaintext = content_key.decrypt_thumbnail(&ciphertext)?;
Ok(Some(plaintext)) Ok(Some(plaintext))
} }
@@ -695,7 +695,7 @@ impl ProtonDriveClient {
.await .await
{ {
Ok(ciphertext) => content_key Ok(ciphertext) => content_key
.decrypt_block(&ciphertext) .decrypt_thumbnail(&ciphertext)
.map_err(ProtonError::from), .map_err(ProtonError::from),
Err(e) => Err(e), Err(e) => Err(e),
}; };
@@ -1416,11 +1416,12 @@ impl ProtonDriveClient {
}; };
// Generate the node key + content key and the file-creation secrets. // Generate the node key + content key and the file-creation secrets.
let node = generate_node_key()?; // An AEAD file uses a v6 node key (C# `PgpProfile.ProtonAead`) so its v6
let content_key = if aead { // content-key PKESK is addressed to a matching v6 recipient.
ContentKey::generate_aead() let (node, content_key) = if aead {
(generate_node_key_aead()?, ContentKey::generate_aead())
} else { } else {
ContentKey::generate() (generate_node_key()?, ContentKey::generate())
}; };
let encrypted_name = let encrypted_name =
+51 -24
View File
@@ -46,7 +46,10 @@ async fn folder_create_rename_trash_restore() {
}; };
let client = &live.client; let client = &live.client;
let root = client.get_my_files_folder().await.expect("get my-files root"); let root = client
.get_my_files_folder()
.await
.expect("get my-files root");
// create // create
let name = format!("folder-ops-{}", common::unique_suffix()); let name = format!("folder-ops-{}", common::unique_suffix());
@@ -55,7 +58,7 @@ async fn folder_create_rename_trash_restore() {
.await .await
.expect("create_folder"); .expect("create_folder");
let node = get(&client, &uid, "after create").await; let node = get(client, &uid, "after create").await;
assert!(matches!(node.kind, NodeKind::Folder), "must be a folder"); assert!(matches!(node.kind, NodeKind::Folder), "must be a folder");
assert_eq!(node.name, name, "name must round-trip"); assert_eq!(node.name, name, "name must round-trip");
assert_eq!( assert_eq!(
@@ -70,12 +73,15 @@ async fn folder_create_rename_trash_restore() {
.rename_node(&uid, &renamed, None) .rename_node(&uid, &renamed, None)
.await .await
.expect("rename_node"); .expect("rename_node");
let node = get(&client, &uid, "after rename").await; let node = get(client, &uid, "after rename").await;
assert_eq!(node.name, renamed, "rename must take effect"); assert_eq!(node.name, renamed, "rename must take effect");
// trash // trash
client.trash_nodes(&[uid.clone()]).await.expect("trash"); client
let node = get(&client, &uid, "after trash").await; .trash_nodes(std::slice::from_ref(&uid))
.await
.expect("trash");
let node = get(client, &uid, "after trash").await;
assert!(node.trashed, "node must report trashed"); assert!(node.trashed, "node must report trashed");
let trash = client let trash = client
.enumerate_trash_node_uids() .enumerate_trash_node_uids()
@@ -84,12 +90,15 @@ async fn folder_create_rename_trash_restore() {
assert!(trash.contains(&uid), "trash listing must include the node"); assert!(trash.contains(&uid), "trash listing must include the node");
// restore // restore
client.restore_nodes(&[uid.clone()]).await.expect("restore"); client
let node = get(&client, &uid, "after restore").await; .restore_nodes(std::slice::from_ref(&uid))
.await
.expect("restore");
let node = get(client, &uid, "after restore").await;
assert!(!node.trashed, "node must no longer be trashed"); assert!(!node.trashed, "node must no longer be trashed");
// delete (final) // delete (final)
cleanup(&client, &[uid]).await; cleanup(client, &[uid]).await;
} }
/// empty_trash removes a trashed node from the trash listing. /// empty_trash removes a trashed node from the trash listing.
@@ -101,7 +110,10 @@ async fn folder_empty_trash() {
}; };
let client = &live.client; let client = &live.client;
let root = client.get_my_files_folder().await.expect("get my-files root"); let root = client
.get_my_files_folder()
.await
.expect("get my-files root");
let name = format!("empty-trash-{}", common::unique_suffix()); let name = format!("empty-trash-{}", common::unique_suffix());
let uid = client let uid = client
@@ -109,7 +121,10 @@ async fn folder_empty_trash() {
.await .await
.expect("create_folder"); .expect("create_folder");
client.trash_nodes(&[uid.clone()]).await.expect("trash"); client
.trash_nodes(std::slice::from_ref(&uid))
.await
.expect("trash");
assert!( assert!(
client client
.enumerate_trash_node_uids() .enumerate_trash_node_uids()
@@ -144,7 +159,10 @@ async fn move_node_single() {
}; };
let client = &live.client; let client = &live.client;
let root = client.get_my_files_folder().await.expect("get my-files root"); let root = client
.get_my_files_folder()
.await
.expect("get my-files root");
let suffix = common::unique_suffix(); let suffix = common::unique_suffix();
let a = client let a = client
@@ -162,7 +180,7 @@ async fn move_node_single() {
// Precondition: child parented to A. // Precondition: child parented to A.
assert_eq!( assert_eq!(
get(&client, &child, "before move").await.parent_uid.as_ref(), get(client, &child, "before move").await.parent_uid.as_ref(),
Some(&a), Some(&a),
"child must start under A" "child must start under A"
); );
@@ -170,12 +188,12 @@ async fn move_node_single() {
client.move_node(&child, &b).await.expect("move_node"); client.move_node(&child, &b).await.expect("move_node");
assert_eq!( assert_eq!(
get(&client, &child, "after move").await.parent_uid.as_ref(), get(client, &child, "after move").await.parent_uid.as_ref(),
Some(&b), Some(&b),
"child must be reparented to B" "child must be reparented to B"
); );
cleanup(&client, &[child, a, b]).await; cleanup(client, &[child, a, b]).await;
} }
/// Batch move: two children relocate to a destination folder in one call. /// Batch move: two children relocate to a destination folder in one call.
@@ -187,7 +205,10 @@ async fn move_nodes_batch() {
}; };
let client = &live.client; let client = &live.client;
let root = client.get_my_files_folder().await.expect("get my-files root"); let root = client
.get_my_files_folder()
.await
.expect("get my-files root");
let suffix = common::unique_suffix(); let suffix = common::unique_suffix();
let dst = client let dst = client
@@ -209,17 +230,17 @@ async fn move_nodes_batch() {
.expect("move_nodes"); .expect("move_nodes");
assert_eq!( assert_eq!(
get(&client, &c1, "c1 after move").await.parent_uid.as_ref(), get(client, &c1, "c1 after move").await.parent_uid.as_ref(),
Some(&dst), Some(&dst),
"c1 must be under dst" "c1 must be under dst"
); );
assert_eq!( assert_eq!(
get(&client, &c2, "c2 after move").await.parent_uid.as_ref(), get(client, &c2, "c2 after move").await.parent_uid.as_ref(),
Some(&dst), Some(&dst),
"c2 must be under dst" "c2 must be under dst"
); );
cleanup(&client, &[c1, c2, dst]).await; cleanup(client, &[c1, c2, dst]).await;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -237,7 +258,10 @@ async fn verification_file_fully_verified() {
}; };
let client = &live.client; let client = &live.client;
let root = client.get_my_files_folder().await.expect("get my-files root"); let root = client
.get_my_files_folder()
.await
.expect("get my-files root");
let name = format!("verify-{}.txt", common::unique_suffix()); let name = format!("verify-{}.txt", common::unique_suffix());
let payload = b"verification probe payload".to_vec(); let payload = b"verification probe payload".to_vec();
@@ -246,7 +270,7 @@ async fn verification_file_fully_verified() {
.await .await
.expect("upload_file"); .expect("upload_file");
let node = get(&client, &uid, "uploaded file").await; let node = get(client, &uid, "uploaded file").await;
let v = node.verification; let v = node.verification;
// Name is inline-signed to the parent by our address key; an `Ok` here // Name is inline-signed to the parent by our address key; an `Ok` here
@@ -271,7 +295,7 @@ async fn verification_file_fully_verified() {
"no signature may be NoVerifier/Failed: {v:?}" "no signature may be NoVerifier/Failed: {v:?}"
); );
cleanup(&client, &[uid]).await; cleanup(client, &[uid]).await;
} }
/// A created folder must verify cleanly (name + passphrase signed; no /// A created folder must verify cleanly (name + passphrase signed; no
@@ -284,7 +308,10 @@ async fn verification_folder_fully_verified() {
}; };
let client = &live.client; let client = &live.client;
let root = client.get_my_files_folder().await.expect("get my-files root"); let root = client
.get_my_files_folder()
.await
.expect("get my-files root");
let name = format!("verify-folder-{}", common::unique_suffix()); let name = format!("verify-folder-{}", common::unique_suffix());
let uid = client let uid = client
@@ -292,7 +319,7 @@ async fn verification_folder_fully_verified() {
.await .await
.expect("create_folder"); .expect("create_folder");
let node = get(&client, &uid, "created folder").await; let node = get(client, &uid, "created folder").await;
let v = node.verification; let v = node.verification;
assert_eq!(v.name, VerificationStatus::Ok, "folder name must verify"); assert_eq!(v.name, VerificationStatus::Ok, "folder name must verify");
assert_eq!( assert_eq!(
@@ -302,5 +329,5 @@ async fn verification_folder_fully_verified() {
); );
assert!(v.is_fully_verified(), "folder must fully verify: {v:?}"); assert!(v.is_fully_verified(), "folder must fully verify: {v:?}");
cleanup(&client, &[uid]).await; cleanup(client, &[uid]).await;
} }
@@ -0,0 +1,308 @@
//! Live integration: the incremental-sync event surface (`latest_event_id` +
//! `enumerate_events`) against a real Proton account.
//!
//! Skipped by default. Run against the test account with:
//! cargo test -p proton-drive-sdk --test live_events -- --ignored --nocapture --test-threads=1
//!
//! Each test cleans up after itself (trash + delete-from-trash) so the account
//! stays reusable across runs.
//!
//! Event delivery is eventually consistent: a mutation may take a moment to show
//! up in the volume feed. Tests that assert on a specific change poll the feed a
//! few times from a fixed cursor (the feed is cumulative from a cursor) before
//! giving up.
mod common;
use std::time::Duration;
use proton_drive_sdk::{DriveEvent, DriveEventScopeId};
use proton_sdk::ids::{DriveEventId, NodeUid};
/// Trash then permanently delete the given nodes; best-effort, logs on failure.
async fn cleanup(client: &proton_drive_sdk::ProtonDriveClient, uids: &[NodeUid]) {
if let Err(e) = client.trash_nodes(uids).await {
eprintln!("[cleanup] trash failed: {e}");
return;
}
if let Err(e) = client.delete_nodes(uids).await {
eprintln!("[cleanup] delete failed: {e}");
}
}
/// How many times to re-poll the feed before concluding a change never arrived.
const POLL_ATTEMPTS: usize = 10;
/// Delay between feed polls; eventual consistency usually settles inside one.
const POLL_DELAY: Duration = Duration::from_secs(2);
/// Drain the feed from `cursor` repeatedly until `predicate` matches one of the
/// returned events, returning that event. The feed from a fixed cursor is
/// cumulative (everything after the cursor), so we can re-poll the same cursor
/// without losing earlier events. Returns `None` if nothing matched in time.
async fn poll_for(
client: &proton_drive_sdk::ProtonDriveClient,
scope: &DriveEventScopeId,
cursor: &DriveEventId,
mut predicate: impl FnMut(&DriveEvent) -> bool,
) -> Option<DriveEvent> {
for attempt in 0..POLL_ATTEMPTS {
let events = client
.enumerate_events(scope, Some(cursor))
.await
.expect("enumerate_events from cursor");
if let Some(found) = events.into_iter().find(|e| predicate(e)) {
return Some(found);
}
if attempt + 1 < POLL_ATTEMPTS {
tokio::time::sleep(POLL_DELAY).await;
}
}
None
}
// ---------------------------------------------------------------------------
// Cursor primitives
// ---------------------------------------------------------------------------
/// `latest_event_id` returns a non-empty cursor for the main volume.
#[tokio::test]
#[ignore = "live: needs test-account credentials"]
async fn latest_event_id_smoke() {
let Some(live) = common::live_client().await else {
return;
};
let client = &live.client;
let volume_id = client.main_volume_id().await.expect("main_volume_id");
let id = client
.latest_event_id(&volume_id)
.await
.expect("latest_event_id");
assert!(
!id.to_string().is_empty(),
"latest event id must be non-empty"
);
}
/// Seeding with a `None` cursor must yield exactly one `CursorAdvanced` carrying
/// the latest event id (the documented stream-seed contract).
#[tokio::test]
#[ignore = "live: needs test-account credentials"]
async fn seed_cursor_returns_single_cursor_advanced() {
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");
let scope = root.tree_event_scope_id();
let seeded = client
.enumerate_events(&scope, None)
.await
.expect("seed enumerate_events");
assert_eq!(seeded.len(), 1, "seed must yield exactly one event");
let id = match &seeded[0] {
DriveEvent::CursorAdvanced { id } => id.clone(),
other => panic!("seed event must be CursorAdvanced, got {other:?}"),
};
assert!(!id.to_string().is_empty(), "seed cursor must be non-empty");
// The seed cursor is a usable cursor: enumerating from it succeeds (a
// strict equality check against `latest_event_id` is racy — the feed can
// advance between the two calls — so we only assert it round-trips).
client
.enumerate_events(&scope, Some(&id))
.await
.expect("seed cursor must be a usable enumeration cursor");
}
/// Re-enumerating immediately from a just-seeded cursor (with no intervening
/// mutation) must not report spurious node changes.
#[tokio::test]
#[ignore = "live: needs test-account credentials"]
async fn quiescent_feed_reports_no_node_events() {
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");
let scope = root.tree_event_scope_id();
let cursor = seed(client, &scope).await;
let events = client
.enumerate_events(&scope, Some(&cursor))
.await
.expect("enumerate from fresh cursor");
let node_events: Vec<_> = events
.iter()
.filter(|e| {
matches!(
e,
DriveEvent::NodeUpdated { .. } | DriveEvent::NodeDeleted { .. }
)
})
.collect();
assert!(
node_events.is_empty(),
"a quiescent feed must report no node events, got {node_events:?}"
);
}
// ---------------------------------------------------------------------------
// Change capture
// ---------------------------------------------------------------------------
/// Creating a node after seeding the cursor must surface a `NodeUpdated` for it.
#[tokio::test]
#[ignore = "live: needs test-account credentials"]
async fn node_creation_surfaces_node_updated() {
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");
let scope = root.tree_event_scope_id();
// Seed the cursor *before* the mutation so the change lands after it.
let cursor = seed(client, &scope).await;
let suffix = common::unique_suffix();
let folder = client
.create_folder(&root.uid, &format!("evt-create-{suffix}"), None)
.await
.expect("create folder");
let found = poll_for(client, &scope, &cursor, |e| {
matches!(e, DriveEvent::NodeUpdated { node_uid, .. } if *node_uid == folder)
})
.await;
match found {
Some(DriveEvent::NodeUpdated {
node_uid,
is_trashed,
..
}) => {
assert_eq!(node_uid, folder, "event must reference the new folder");
assert!(!is_trashed, "a freshly created node is not trashed");
}
other => panic!("expected NodeUpdated for the new folder, got {other:?}"),
}
cleanup(client, &[folder]).await;
}
/// Trashing a node must surface a `NodeUpdated` with `is_trashed = true`.
#[tokio::test]
#[ignore = "live: needs test-account credentials"]
async fn trashing_surfaces_is_trashed() {
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");
let scope = root.tree_event_scope_id();
let suffix = common::unique_suffix();
let folder = client
.create_folder(&root.uid, &format!("evt-trash-{suffix}"), None)
.await
.expect("create folder");
// Seed after creation so the trash is the change we look for.
let cursor = seed(client, &scope).await;
client
.trash_nodes(std::slice::from_ref(&folder))
.await
.expect("trash folder");
let found = poll_for(client, &scope, &cursor, |e| {
matches!(
e,
DriveEvent::NodeUpdated { node_uid, is_trashed: true, .. } if *node_uid == folder
)
})
.await;
assert!(
found.is_some(),
"trashing must surface a NodeUpdated with is_trashed = true"
);
// Best-effort permanent delete (already trashed).
if let Err(e) = client.delete_nodes(std::slice::from_ref(&folder)).await {
eprintln!("[cleanup] delete failed: {e}");
}
}
/// Permanently deleting a node must surface a `NodeDeleted` for it.
#[tokio::test]
#[ignore = "live: needs test-account credentials"]
async fn permanent_delete_surfaces_node_deleted() {
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");
let scope = root.tree_event_scope_id();
let suffix = common::unique_suffix();
let folder = client
.create_folder(&root.uid, &format!("evt-delete-{suffix}"), None)
.await
.expect("create folder");
client
.trash_nodes(std::slice::from_ref(&folder))
.await
.expect("trash folder");
// Seed after trashing so the permanent delete is the awaited change.
let cursor = seed(client, &scope).await;
client
.delete_nodes(std::slice::from_ref(&folder))
.await
.expect("delete folder");
let found = poll_for(client, &scope, &cursor, |e| {
matches!(e, DriveEvent::NodeDeleted { node_uid, .. } if *node_uid == folder)
})
.await;
assert!(
found.is_some(),
"permanent delete must surface a NodeDeleted for the node"
);
}
/// Seed the feed and return the cursor to enumerate from next.
async fn seed(
client: &proton_drive_sdk::ProtonDriveClient,
scope: &DriveEventScopeId,
) -> DriveEventId {
let seeded = client
.enumerate_events(scope, None)
.await
.expect("seed enumerate_events");
match seeded.into_iter().next() {
Some(DriveEvent::CursorAdvanced { id }) => id,
other => panic!("seed must be a single CursorAdvanced, got {other:?}"),
}
}
@@ -0,0 +1,363 @@
//! Live integration: enumeration, transfer variants (AEAD, streaming,
//! download-to-writer), thumbnails, and name availability against a real
//! Proton account.
//!
//! Skipped by default. Run against the test account with:
//! cargo test -p proton-drive-sdk --test live_transfer_ops -- --ignored --nocapture --test-threads=1
//!
//! Each test cleans up after itself (trash + delete-from-trash) so the account
//! stays reusable across runs.
mod common;
use std::io::Cursor;
use proton_drive_sdk::{NodeKind, Thumbnail, ThumbnailType};
use proton_sdk::ids::NodeUid;
/// Trash then permanently delete the given nodes; best-effort, logs on failure.
async fn cleanup(client: &proton_drive_sdk::ProtonDriveClient, uids: &[NodeUid]) {
if let Err(e) = client.trash_nodes(uids).await {
eprintln!("[cleanup] trash failed: {e}");
return;
}
if let Err(e) = client.delete_nodes(uids).await {
eprintln!("[cleanup] delete failed: {e}");
}
}
// ---------------------------------------------------------------------------
// Enumeration
// ---------------------------------------------------------------------------
/// Create children under a folder, list their uids, then materialize them via
/// `enumerate_nodes` and assert the names round-trip. Exercises the uid-only
/// listing + lazy materialization split end-to-end against the live API.
#[tokio::test]
#[ignore = "live: needs test-account credentials"]
async fn enumerate_children_and_materialize() {
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");
let suffix = common::unique_suffix();
let parent = client
.create_folder(&root.uid, &format!("enum-parent-{suffix}"), None)
.await
.expect("create parent");
let name_a = format!("enum-child-a-{suffix}");
let name_b = format!("enum-child-b-{suffix}");
let a = client
.create_folder(&parent, &name_a, None)
.await
.expect("create child a");
let b = client
.create_folder(&parent, &name_b, None)
.await
.expect("create child b");
// uid-only listing must contain exactly the two children.
let uids = client
.enumerate_folder_children_node_uids(&parent)
.await
.expect("enumerate children uids");
assert_eq!(uids.len(), 2, "expected exactly two children: {uids:?}");
assert!(uids.contains(&a), "listing must include child a");
assert!(uids.contains(&b), "listing must include child b");
// Materialization must decrypt the names back.
let nodes = client
.enumerate_nodes(&uids)
.await
.expect("enumerate (materialize) nodes");
assert_eq!(nodes.len(), 2, "materialized count must match");
let names: Vec<&str> = nodes.iter().map(|n| n.name.as_str()).collect();
assert!(names.contains(&name_a.as_str()), "name a must round-trip");
assert!(names.contains(&name_b.as_str()), "name b must round-trip");
for n in &nodes {
assert!(matches!(n.kind, NodeKind::Folder), "children are folders");
assert_eq!(
n.parent_uid.as_ref(),
Some(&parent),
"children must report parent"
);
}
cleanup(client, &[a, b, parent]).await;
}
// ---------------------------------------------------------------------------
// Transfer variants
// ---------------------------------------------------------------------------
/// AEAD (SEIPDv2 / AES-256-GCM) upload must decrypt back byte-for-byte.
///
/// AEAD is **server-gated** behind the `DriveCryptoEncryptBlocksWithPgpAead`
/// feature flag (C# defaults to `AlwaysDisabledFeatureFlagProvider`, i.e. off).
/// When the flag is not enabled for the account, the server rejects the AEAD
/// draft with 422 "Could not verify the nodeKey was used for encrypting
/// contentKeyPacket" — this test skips cleanly in that case rather than failing,
/// and validates the full round-trip when AEAD is available. The crypto itself
/// (v6 node key + v6 PKESK + SEIPDv2 framing) is covered by offline round-trip
/// tests regardless.
#[tokio::test]
#[ignore = "live: needs test-account credentials"]
async fn aead_upload_download_roundtrip() {
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");
let name = format!("aead-{}.bin", common::unique_suffix());
// Span more than one 4 MiB block so chunked AEAD framing is exercised.
let payload: Vec<u8> = (0..(5 * 1024 * 1024)).map(|i| (i * 31 + 7) as u8).collect();
let uid = match client
.upload_file_from(
&root.uid,
&name,
"application/octet-stream",
Cursor::new(payload.clone()),
payload.len() as i64,
Vec::new(),
None,
true, // aead
)
.await
{
Ok(uid) => uid,
Err(e) if is_aead_disabled(&e) => {
eprintln!("[skip] AEAD not enabled for this account (server-gated): {e}");
return;
}
Err(e) => panic!("aead upload_file_from: {e}"),
};
let downloaded = client.download_file(&uid).await.expect("download_file");
assert_eq!(downloaded.len(), payload.len(), "size must match");
assert!(
downloaded == payload,
"AEAD download must match upload byte-for-byte"
);
cleanup(client, &[uid]).await;
}
/// True when an upload error is the server's AEAD feature-flag rejection (the
/// account lacks `DriveCryptoEncryptBlocksWithPgpAead`).
fn is_aead_disabled(e: &proton_sdk::error::ProtonError) -> bool {
e.to_string().contains("Could not verify the nodeKey")
}
/// `download_file_to` must stream the same bytes a buffered `download_file`
/// returns.
#[tokio::test]
#[ignore = "live: needs test-account credentials"]
async fn download_file_to_writer() {
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");
let name = format!("dl-to-{}.txt", common::unique_suffix());
let payload = b"download_file_to streaming probe payload".to_vec();
let uid = client
.upload_file(&root.uid, &name, "text/plain", &payload)
.await
.expect("upload_file");
let mut sink: Vec<u8> = Vec::new();
client
.download_file_to(&uid, &mut sink)
.await
.expect("download_file_to");
assert_eq!(sink, payload, "streamed bytes must match the upload");
cleanup(client, &[uid]).await;
}
/// A streaming new revision (`upload_new_revision_from`) must replace content;
/// downloading the file afterwards yields the new bytes.
#[tokio::test]
#[ignore = "live: needs test-account credentials"]
async fn streaming_new_revision() {
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");
let name = format!("revision-{}.txt", common::unique_suffix());
let v1 = b"first revision contents".to_vec();
let uid = client
.upload_file(&root.uid, &name, "text/plain", &v1)
.await
.expect("upload v1");
let v2: Vec<u8> = b"second revision contents, streamed and noticeably longer".to_vec();
client
.upload_new_revision_from(
&uid,
Cursor::new(v2.clone()),
v2.len() as i64,
Vec::new(),
None,
)
.await
.expect("upload_new_revision_from");
let downloaded = client
.download_file(&uid)
.await
.expect("download after revision");
assert_eq!(downloaded, v2, "download must reflect the new revision");
cleanup(client, &[uid]).await;
}
// ---------------------------------------------------------------------------
// Thumbnails
// ---------------------------------------------------------------------------
/// Upload a file with thumbnails, then fetch them via the single
/// (`download_thumbnail`) and batch (`enumerate_thumbnails`) paths. Both must
/// decrypt back to the bytes that were uploaded.
#[tokio::test]
#[ignore = "live: needs test-account credentials"]
async fn thumbnails_upload_and_fetch() {
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");
let name = format!("thumb-{}.bin", common::unique_suffix());
let thumb_small: Vec<u8> = (0..4096).map(|i| (i * 13 + 1) as u8).collect();
let thumb_preview: Vec<u8> = (0..8192).map(|i| (i * 17 + 3) as u8).collect();
let thumbnails = vec![
Thumbnail::new(ThumbnailType::Thumbnail, thumb_small.clone()),
Thumbnail::new(ThumbnailType::Preview, thumb_preview.clone()),
];
let payload = b"file body for thumbnail upload".to_vec();
let uid = client
.upload_file_from(
&root.uid,
&name,
"application/octet-stream",
Cursor::new(payload.clone()),
payload.len() as i64,
thumbnails,
None,
false,
)
.await
.expect("upload_file_from with thumbnails");
// Single fetch: small thumbnail.
let got_small = client
.download_thumbnail(&uid, ThumbnailType::Thumbnail)
.await
.expect("download_thumbnail(small)")
.expect("small thumbnail must be present");
assert_eq!(got_small, thumb_small, "small thumbnail must round-trip");
// Single fetch: preview thumbnail.
let got_preview = client
.download_thumbnail(&uid, ThumbnailType::Preview)
.await
.expect("download_thumbnail(preview)")
.expect("preview thumbnail must be present");
assert_eq!(
got_preview, thumb_preview,
"preview thumbnail must round-trip"
);
// Batch fetch over the small type.
let batch = client
.enumerate_thumbnails(std::slice::from_ref(&uid), ThumbnailType::Thumbnail)
.await
.expect("enumerate_thumbnails");
let entry = batch
.iter()
.find(|t| t.file_uid == uid)
.expect("batch must contain our file");
let bytes = entry
.result
.as_ref()
.unwrap_or_else(|e| panic!("batch thumbnail errored: {e}"));
assert_eq!(bytes, &thumb_small, "batch thumbnail must match upload");
cleanup(client, &[uid]).await;
}
// ---------------------------------------------------------------------------
// Name availability
// ---------------------------------------------------------------------------
/// `get_available_name` returns the original name when free, and a distinct
/// alternate once it is taken.
#[tokio::test]
#[ignore = "live: needs test-account credentials"]
async fn get_available_name_resolves_collision() {
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");
let base = format!("avail-{}", common::unique_suffix());
// Free name resolves to itself.
let free = client
.get_available_name(&root.uid, &base)
.await
.expect("get_available_name (free)");
assert_eq!(free, base, "an unused name must resolve to itself");
// Occupy it, then the same request must yield a different name.
let uid = client
.create_folder(&root.uid, &base, None)
.await
.expect("create_folder to occupy the name");
let alt = client
.get_available_name(&root.uid, &base)
.await
.expect("get_available_name (taken)");
assert_ne!(alt, base, "a taken name must resolve to an alternate");
assert!(
alt.starts_with(&base),
"alternate should derive from the base name: {alt}"
);
cleanup(client, &[uid]).await;
}
@@ -46,7 +46,7 @@ async fn upload_download_small_roundtrip() {
let got = client.download_file(&uid).await.expect("download_file"); let got = client.download_file(&uid).await.expect("download_file");
assert_eq!(got, payload, "downloaded bytes must match uploaded"); assert_eq!(got, payload, "downloaded bytes must match uploaded");
cleanup(&client, &[uid]).await; cleanup(client, &[uid]).await;
} }
/// Multi-block streaming upload (> 4 MiB) exercising `upload_file_from` and the /// Multi-block streaming upload (> 4 MiB) exercising `upload_file_from` and the
@@ -92,7 +92,7 @@ async fn upload_download_multiblock_roundtrip() {
assert_eq!(got.len(), payload.len(), "size mismatch"); assert_eq!(got.len(), payload.len(), "size mismatch");
assert_eq!(got, payload, "multi-block bytes must match"); assert_eq!(got, payload, "multi-block bytes must match");
cleanup(&client, &[uid]).await; cleanup(client, &[uid]).await;
} }
/// New revision over an existing file: upload v1, replace with v2, download must /// New revision over an existing file: upload v1, replace with v2, download must
@@ -127,5 +127,5 @@ async fn new_revision_roundtrip() {
let got = client.download_file(&uid).await.expect("download"); let got = client.download_file(&uid).await.expect("download");
assert_eq!(got, v2, "active revision must be v2"); assert_eq!(got, v2, "active revision must be v2");
cleanup(&client, &[uid]).await; cleanup(client, &[uid]).await;
} }
+2 -2
View File
@@ -17,8 +17,8 @@ mod verify;
pub use content::ContentKey; pub use content::ContentKey;
pub use derive::derive_key_passphrase; pub use derive::derive_key_passphrase;
pub use encrypt::{ pub use encrypt::{
build_volume_creation_material, generate_node_hash_key, generate_node_key, GeneratedNodeKey, build_volume_creation_material, generate_node_hash_key, generate_node_key,
VolumeCreationMaterial, generate_node_key_aead, GeneratedNodeKey, VolumeCreationMaterial,
}; };
pub use errors::CryptoError; pub use errors::CryptoError;
pub use keys::{decrypt_armored_with_keys, PrivateKey}; pub use keys::{decrypt_armored_with_keys, PrivateKey};
+84 -17
View File
@@ -207,6 +207,61 @@ impl ContentKey {
/// directly, locate the SEIPD (tolerating a leading PKESK, e.g. a full /// directly, locate the SEIPD (tolerating a leading PKESK, e.g. a full
/// round-trip message), and decrypt it with the supplied session key. /// round-trip message), and decrypt it with the supplied session key.
pub fn decrypt_block(&self, ciphertext: &[u8]) -> Result<Vec<u8>, CryptoError> { pub fn decrypt_block(&self, ciphertext: &[u8]) -> Result<Vec<u8>, CryptoError> {
let inner = self.decrypt_seipd(ciphertext)?;
// A content block's payload is a single literal-data packet.
let mut inner_parser = PacketParser::new(Cursor::new(&inner));
let literal = inner_parser
.next()
.ok_or_else(|| CryptoError::Parse("block payload is empty".into()))?
.map_err(|e| CryptoError::Parse(format!("block payload packet: {e}")))?;
match literal {
Packet::LiteralData(data) => Ok(data.data().to_vec()),
other => Err(CryptoError::Parse(format!(
"block payload is not literal data: {:?}",
other.tag()
))),
}
}
/// Decrypt an inline-signed thumbnail block under this content key.
///
/// Unlike a content block, a thumbnail payload is encrypt-**and-inline-sign**
/// (`encrypt_thumbnail`): the SEIPD payload is a signed message
/// (`OnePassSignature`, `LiteralData`, `Signature`), so the literal data is
/// not the first packet. The signature is metadata; we scan past the
/// one-pass-signature / signature packets and return the literal bytes.
/// Mirrors C# `FileOperations` reading the thumbnail through a decrypt+verify
/// stream and keeping the plaintext regardless of verification outcome.
pub fn decrypt_thumbnail(&self, ciphertext: &[u8]) -> Result<Vec<u8>, CryptoError> {
let inner = self.decrypt_seipd(ciphertext)?;
let parser = PacketParser::new(Cursor::new(&inner));
for packet in parser {
let packet =
packet.map_err(|e| CryptoError::Parse(format!("thumbnail payload packet: {e}")))?;
match packet {
Packet::LiteralData(data) => return Ok(data.data().to_vec()),
// Inline-signature framing around the literal data.
Packet::OnePassSignature(_) | Packet::Signature(_) => continue,
other => {
return Err(CryptoError::Parse(format!(
"unexpected thumbnail payload packet: {:?}",
other.tag()
)))
}
}
}
Err(CryptoError::Parse(
"thumbnail payload had no literal data".into(),
))
}
/// Locate the SEIPD packet (tolerating a leading content-key PKESK) and
/// decrypt it under this content key, returning the serialized inner payload.
/// rPGP's `Message` parser rejects a message that *starts* with a SEIPD
/// packet, so we parse packets directly.
fn decrypt_seipd(&self, ciphertext: &[u8]) -> Result<Vec<u8>, CryptoError> {
let (sym_alg, key) = self.session_parts()?; let (sym_alg, key) = self.session_parts()?;
let mut parser = PacketParser::new(Cursor::new(ciphertext)); let mut parser = PacketParser::new(Cursor::new(ciphertext));
@@ -228,23 +283,9 @@ impl ContentKey {
} }
}; };
let inner = seipd seipd
.decrypt(key, Some(sym_alg)) .decrypt(key, Some(sym_alg))
.map_err(|e| CryptoError::Decrypt(format!("block: {e}")))?; .map_err(|e| CryptoError::Decrypt(format!("block: {e}")))
// The decrypted payload is a serialized literal-data packet.
let mut inner_parser = PacketParser::new(Cursor::new(&inner));
let literal = inner_parser
.next()
.ok_or_else(|| CryptoError::Parse("block payload is empty".into()))?
.map_err(|e| CryptoError::Parse(format!("block payload packet: {e}")))?;
match literal {
Packet::LiteralData(data) => Ok(data.data().to_vec()),
other => Err(CryptoError::Parse(format!(
"block payload is not literal data: {:?}",
other.tag()
))),
}
} }
} }
@@ -556,12 +597,38 @@ mod tests {
} }
} }
#[test]
fn decrypt_thumbnail_reads_inline_signed_payload() {
// The download path must read an inline-signed thumbnail back to its
// plaintext: `decrypt_block` rejects the leading one-pass-signature, so
// `decrypt_thumbnail` scans past the signature framing. Covers both
// content-key modes (legacy SEIPDv1 + AEAD SEIPDv2).
for content_key in [ContentKey::generate(), ContentKey::generate_aead()] {
let signer = super::super::encrypt::generate_node_key().expect("generate signer");
let plaintext = b"thumbnail image bytes for download".to_vec();
let block = content_key
.encrypt_thumbnail(&signer.key, &plaintext)
.expect("encrypt thumbnail");
assert_eq!(
content_key.decrypt_thumbnail(&block).expect("decrypt"),
plaintext
);
// A content block reader must still reject the signed payload.
assert!(content_key.decrypt_block(&block).is_err());
}
}
#[test] #[test]
fn aead_content_key_round_trip_through_x25519_node_key() { fn aead_content_key_round_trip_through_x25519_node_key() {
// AEAD upload path: generate an AEAD content key (V6 session key), seal // AEAD upload path: generate an AEAD content key (V6 session key), seal
// it to the node key as a v6 PKESK, recover it, and round-trip a SEIPDv2 // it to the node key as a v6 PKESK, recover it, and round-trip a SEIPDv2
// block. The recovered key must still report as AEAD. // block. The recovered key must still report as AEAD.
let node = super::super::encrypt::generate_node_key().expect("generate node key"); //
// The node key must be **v6** here (C# `PgpProfile.ProtonAead`): a v6
// PKESK addressed to a v4 key round-trips in rPGP but the Proton server
// rejects the draft (the recipient fingerprint can't match a v4 key), so
// use `generate_node_key_aead` to mirror the real upload path.
let node = super::super::encrypt::generate_node_key_aead().expect("generate node key");
let content_key = ContentKey::generate_aead(); let content_key = ContentKey::generate_aead();
assert!(content_key.is_aead()); assert!(content_key.is_aead());
+19 -2
View File
@@ -83,13 +83,30 @@ pub struct GeneratedNodeKey {
/// (`PgpPrivateKey.Generate("Drive key", "no-reply@proton.me", Default)` then /// (`PgpPrivateKey.Generate("Drive key", "no-reply@proton.me", Default)` then
/// `key.Lock(passphrase)`). /// `key.Lock(passphrase)`).
pub fn generate_node_key() -> Result<GeneratedNodeKey, CryptoError> { pub fn generate_node_key() -> Result<GeneratedNodeKey, CryptoError> {
generate_node_key_versioned(KeyVersion::V4)
}
/// Generate a node key for an AEAD file (C# `PgpProfile.ProtonAead`).
///
/// An AEAD upload seals its content key as a **v6** PKESK, so the node key it is
/// addressed to must itself be a v6 key — otherwise the server rejects the draft
/// with 422 "Could not verify the nodeKey was used for encrypting
/// contentKeyPacket" (the v6 PKESK recipient fingerprint can't match a v4 key).
/// The legacy (non-AEAD) path stays on v4 via [`generate_node_key`].
pub fn generate_node_key_aead() -> Result<GeneratedNodeKey, CryptoError> {
generate_node_key_versioned(KeyVersion::V6)
}
/// Generate a node key (Ed25519 primary + X25519 encryption subkey) at the given
/// OpenPGP key version. v4 for legacy files/folders, v6 for AEAD files.
fn generate_node_key_versioned(version: KeyVersion) -> Result<GeneratedNodeKey, CryptoError> {
let mut rng = rand::thread_rng(); let mut rng = rand::thread_rng();
let passphrase = generate_passphrase(); let passphrase = generate_passphrase();
let pw_string = String::from_utf8(passphrase.clone()) let pw_string = String::from_utf8(passphrase.clone())
.map_err(|e| CryptoError::Encrypt(format!("passphrase is not ascii: {e}")))?; .map_err(|e| CryptoError::Encrypt(format!("passphrase is not ascii: {e}")))?;
let subkey = SubkeyParamsBuilder::default() let subkey = SubkeyParamsBuilder::default()
.version(KeyVersion::V4) .version(version)
.key_type(KeyType::X25519) .key_type(KeyType::X25519)
.can_encrypt(true) .can_encrypt(true)
.passphrase(Some(pw_string.clone())) .passphrase(Some(pw_string.clone()))
@@ -97,7 +114,7 @@ pub fn generate_node_key() -> Result<GeneratedNodeKey, CryptoError> {
.map_err(|e| CryptoError::Encrypt(format!("node subkey params: {e}")))?; .map_err(|e| CryptoError::Encrypt(format!("node subkey params: {e}")))?;
let params = SecretKeyParamsBuilder::default() let params = SecretKeyParamsBuilder::default()
.version(KeyVersion::V4) .version(version)
.key_type(KeyType::Ed25519) .key_type(KeyType::Ed25519)
.can_certify(true) .can_certify(true)
.can_sign(true) .can_sign(true)