mirror of
https://github.com/narrrl/proton-sdk-rs.git
synced 2026-11-03 16:41:26 +01:00
photo, volume op tests + fixes
This commit is contained in:
1 parent
52b594e524
commit
20fe359162
8 files changed
+839
-56
No files matched your search
@@ -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 =
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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};
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in new issue
Block a user