From 20fe35916238a95ed6f8179f247a8b7b5059f20f Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sun, 28 Jun 2026 10:25:54 +0200 Subject: [PATCH] photo, volume op tests + fixes --- crates/proton-drive-sdk/src/client.rs | 17 +- .../proton-drive-sdk/tests/live_drive_ops.rs | 75 ++-- crates/proton-drive-sdk/tests/live_events.rs | 308 +++++++++++++++ .../tests/live_transfer_ops.rs | 363 ++++++++++++++++++ .../tests/live_upload_download.rs | 6 +- crates/proton-sdk/src/crypto.rs | 4 +- crates/proton-sdk/src/crypto/content.rs | 101 ++++- crates/proton-sdk/src/crypto/encrypt.rs | 21 +- 8 files changed, 839 insertions(+), 56 deletions(-) create mode 100644 crates/proton-drive-sdk/tests/live_events.rs create mode 100644 crates/proton-drive-sdk/tests/live_transfer_ops.rs diff --git a/crates/proton-drive-sdk/src/client.rs b/crates/proton-drive-sdk/src/client.rs index 1ef9167..06ca89f 100644 --- a/crates/proton-drive-sdk/src/client.rs +++ b/crates/proton-drive-sdk/src/client.rs @@ -13,8 +13,8 @@ use proton_sdk::account::AccountClient; use proton_sdk::cache::{CacheRepository, InMemoryCacheRepository}; use proton_sdk::crypto::PrivateKey; use proton_sdk::crypto::{ - build_volume_creation_material, generate_node_hash_key, generate_node_key, verify_detached, - ContentKey, VerificationKeyRing, VerificationStatus, + build_volume_creation_material, generate_node_hash_key, generate_node_key, + generate_node_key_aead, verify_detached, ContentKey, VerificationKeyRing, VerificationStatus, }; use proton_sdk::error::{ProtonError, Result}; use proton_sdk::http::ApiHttpClient; @@ -584,7 +584,7 @@ impl ProtonDriveClient { .http .get_storage_blob(&block.bare_url, &block.token) .await?; - let plaintext = content_key.decrypt_block(&ciphertext)?; + let plaintext = content_key.decrypt_thumbnail(&ciphertext)?; Ok(Some(plaintext)) } @@ -695,7 +695,7 @@ impl ProtonDriveClient { .await { Ok(ciphertext) => content_key - .decrypt_block(&ciphertext) + .decrypt_thumbnail(&ciphertext) .map_err(ProtonError::from), Err(e) => Err(e), }; @@ -1416,11 +1416,12 @@ impl ProtonDriveClient { }; // Generate the node key + content key and the file-creation secrets. - let node = generate_node_key()?; - let content_key = if aead { - ContentKey::generate_aead() + // An AEAD file uses a v6 node key (C# `PgpProfile.ProtonAead`) so its v6 + // content-key PKESK is addressed to a matching v6 recipient. + let (node, content_key) = if aead { + (generate_node_key_aead()?, ContentKey::generate_aead()) } else { - ContentKey::generate() + (generate_node_key()?, ContentKey::generate()) }; let encrypted_name = diff --git a/crates/proton-drive-sdk/tests/live_drive_ops.rs b/crates/proton-drive-sdk/tests/live_drive_ops.rs index 9a2d5ad..29b5197 100644 --- a/crates/proton-drive-sdk/tests/live_drive_ops.rs +++ b/crates/proton-drive-sdk/tests/live_drive_ops.rs @@ -46,7 +46,10 @@ async fn folder_create_rename_trash_restore() { }; 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 let name = format!("folder-ops-{}", common::unique_suffix()); @@ -55,7 +58,7 @@ async fn folder_create_rename_trash_restore() { .await .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_eq!(node.name, name, "name must round-trip"); assert_eq!( @@ -70,12 +73,15 @@ async fn folder_create_rename_trash_restore() { .rename_node(&uid, &renamed, None) .await .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"); // trash - client.trash_nodes(&[uid.clone()]).await.expect("trash"); - let node = get(&client, &uid, "after trash").await; + client + .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"); let trash = client .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"); // restore - client.restore_nodes(&[uid.clone()]).await.expect("restore"); - let node = get(&client, &uid, "after restore").await; + client + .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"); // delete (final) - cleanup(&client, &[uid]).await; + cleanup(client, &[uid]).await; } /// empty_trash removes a trashed node from the trash listing. @@ -101,7 +110,10 @@ async fn folder_empty_trash() { }; 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 uid = client @@ -109,7 +121,10 @@ async fn folder_empty_trash() { .await .expect("create_folder"); - client.trash_nodes(&[uid.clone()]).await.expect("trash"); + client + .trash_nodes(std::slice::from_ref(&uid)) + .await + .expect("trash"); assert!( client .enumerate_trash_node_uids() @@ -144,7 +159,10 @@ async fn move_node_single() { }; 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 a = client @@ -162,7 +180,7 @@ async fn move_node_single() { // Precondition: child parented to A. assert_eq!( - get(&client, &child, "before move").await.parent_uid.as_ref(), + get(client, &child, "before move").await.parent_uid.as_ref(), Some(&a), "child must start under A" ); @@ -170,12 +188,12 @@ async fn move_node_single() { client.move_node(&child, &b).await.expect("move_node"); assert_eq!( - get(&client, &child, "after move").await.parent_uid.as_ref(), + get(client, &child, "after move").await.parent_uid.as_ref(), Some(&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. @@ -187,7 +205,10 @@ async fn move_nodes_batch() { }; 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 dst = client @@ -209,17 +230,17 @@ async fn move_nodes_batch() { .expect("move_nodes"); 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), "c1 must be under dst" ); 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), "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 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 payload = b"verification probe payload".to_vec(); @@ -246,7 +270,7 @@ async fn verification_file_fully_verified() { .await .expect("upload_file"); - let node = get(&client, &uid, "uploaded file").await; + let node = get(client, &uid, "uploaded file").await; let v = node.verification; // 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:?}" ); - cleanup(&client, &[uid]).await; + cleanup(client, &[uid]).await; } /// 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 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 uid = client @@ -292,7 +319,7 @@ async fn verification_folder_fully_verified() { .await .expect("create_folder"); - let node = get(&client, &uid, "created folder").await; + let node = get(client, &uid, "created folder").await; let v = node.verification; assert_eq!(v.name, VerificationStatus::Ok, "folder name must verify"); assert_eq!( @@ -302,5 +329,5 @@ async fn verification_folder_fully_verified() { ); assert!(v.is_fully_verified(), "folder must fully verify: {v:?}"); - cleanup(&client, &[uid]).await; + cleanup(client, &[uid]).await; } diff --git a/crates/proton-drive-sdk/tests/live_events.rs b/crates/proton-drive-sdk/tests/live_events.rs new file mode 100644 index 0000000..6d1ab45 --- /dev/null +++ b/crates/proton-drive-sdk/tests/live_events.rs @@ -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 { + 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:?}"), + } +} diff --git a/crates/proton-drive-sdk/tests/live_transfer_ops.rs b/crates/proton-drive-sdk/tests/live_transfer_ops.rs new file mode 100644 index 0000000..ea5f9f8 --- /dev/null +++ b/crates/proton-drive-sdk/tests/live_transfer_ops.rs @@ -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 = (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 = 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 = 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 = (0..4096).map(|i| (i * 13 + 1) as u8).collect(); + let thumb_preview: Vec = (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; +} diff --git a/crates/proton-drive-sdk/tests/live_upload_download.rs b/crates/proton-drive-sdk/tests/live_upload_download.rs index f198e19..dcef073 100644 --- a/crates/proton-drive-sdk/tests/live_upload_download.rs +++ b/crates/proton-drive-sdk/tests/live_upload_download.rs @@ -46,7 +46,7 @@ async fn upload_download_small_roundtrip() { let got = client.download_file(&uid).await.expect("download_file"); 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 @@ -92,7 +92,7 @@ async fn upload_download_multiblock_roundtrip() { assert_eq!(got.len(), payload.len(), "size mismatch"); 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 @@ -127,5 +127,5 @@ async fn new_revision_roundtrip() { let got = client.download_file(&uid).await.expect("download"); assert_eq!(got, v2, "active revision must be v2"); - cleanup(&client, &[uid]).await; + cleanup(client, &[uid]).await; } diff --git a/crates/proton-sdk/src/crypto.rs b/crates/proton-sdk/src/crypto.rs index bbec312..2983c79 100644 --- a/crates/proton-sdk/src/crypto.rs +++ b/crates/proton-sdk/src/crypto.rs @@ -17,8 +17,8 @@ mod verify; pub use content::ContentKey; pub use derive::derive_key_passphrase; pub use encrypt::{ - build_volume_creation_material, generate_node_hash_key, generate_node_key, GeneratedNodeKey, - VolumeCreationMaterial, + build_volume_creation_material, generate_node_hash_key, generate_node_key, + generate_node_key_aead, GeneratedNodeKey, VolumeCreationMaterial, }; pub use errors::CryptoError; pub use keys::{decrypt_armored_with_keys, PrivateKey}; diff --git a/crates/proton-sdk/src/crypto/content.rs b/crates/proton-sdk/src/crypto/content.rs index 37a6c5a..e02a570 100644 --- a/crates/proton-sdk/src/crypto/content.rs +++ b/crates/proton-sdk/src/crypto/content.rs @@ -207,6 +207,61 @@ impl ContentKey { /// directly, locate the SEIPD (tolerating a leading PKESK, e.g. a full /// round-trip message), and decrypt it with the supplied session key. pub fn decrypt_block(&self, ciphertext: &[u8]) -> Result, 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, 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, CryptoError> { let (sym_alg, key) = self.session_parts()?; let mut parser = PacketParser::new(Cursor::new(ciphertext)); @@ -228,23 +283,9 @@ impl ContentKey { } }; - let inner = seipd + seipd .decrypt(key, Some(sym_alg)) - .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() - ))), - } + .map_err(|e| CryptoError::Decrypt(format!("block: {e}"))) } } @@ -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] fn aead_content_key_round_trip_through_x25519_node_key() { // 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 // 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(); assert!(content_key.is_aead()); diff --git a/crates/proton-sdk/src/crypto/encrypt.rs b/crates/proton-sdk/src/crypto/encrypt.rs index 509ea24..7773533 100644 --- a/crates/proton-sdk/src/crypto/encrypt.rs +++ b/crates/proton-sdk/src/crypto/encrypt.rs @@ -83,13 +83,30 @@ pub struct GeneratedNodeKey { /// (`PgpPrivateKey.Generate("Drive key", "no-reply@proton.me", Default)` then /// `key.Lock(passphrase)`). pub fn generate_node_key() -> Result { + 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 { + 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 { let mut rng = rand::thread_rng(); let passphrase = generate_passphrase(); let pw_string = String::from_utf8(passphrase.clone()) .map_err(|e| CryptoError::Encrypt(format!("passphrase is not ascii: {e}")))?; let subkey = SubkeyParamsBuilder::default() - .version(KeyVersion::V4) + .version(version) .key_type(KeyType::X25519) .can_encrypt(true) .passphrase(Some(pw_string.clone())) @@ -97,7 +114,7 @@ pub fn generate_node_key() -> Result { .map_err(|e| CryptoError::Encrypt(format!("node subkey params: {e}")))?; let params = SecretKeyParamsBuilder::default() - .version(KeyVersion::V4) + .version(version) .key_type(KeyType::Ed25519) .can_certify(true) .can_sign(true)