public link consume, revision history, path helpers

Public links (public_link.rs, ported from the TS SDK — the C# SDK has no
equivalent): ProtonDrivePublicLinkClient lets a visitor with no Proton
account open someone else's share link. `info` reads pre-auth metadata,
`open` runs the SRP handshake against drive/urls/{token}/info + /auth and
unlocks the share key (bcrypt SharePasswordSalt -> key password -> the
SKESK'd SharePassphrase -> ShareKey). The read surface then reuses the
ordinary link/children/revision endpoints. Signature verification is
deliberately absent: an anonymous visitor cannot read core/v4/keys/all,
matching the TS SDK. Supporting primitives: http::get_unauthenticated and
crypto::decrypt_armored_with_password.

Revision history: enumerate_revisions / get_revision / restore_revision /
delete_revision, plus open_revision_at / download_revision{,_to} to read a
specific revision. Drafts are filtered on the wire value, since
RevisionState::from_raw maps 0 to Active. Three server behaviors found
live and worked around: ?NoBlockUrls=true still emits Blocks with null
BareURL, so get_revision deserializes into a DTO without that field; the
restore POST needs an empty object body, not null; and restore is async
(HTTP 202, non-1000 envelope) so the new active revision is not live on
return.

Path and hierarchy helpers: get_node_hierarchy, get_node_path,
get_node_by_path, create_folder_path. Names are encrypted and there is no
name-query endpoint, so lookup walks and decrypts one directory level per
segment.

Fix a silent-corruption path in block-size resolution. RevisionReader
accumulates block sizes to derive where each block starts in the
plaintext, so a vector that overstates any block but the last shifts every
block after it and the reader splices at the wrong offsets — full-length
reads of the wrong bytes, no error raised. resolve_block_sizes previously
guessed all-full blocks when both Common.BlockSizes and Common.Size were
unusable. It now errors for multi-block files and only assumes a full
block when there is exactly one (which cannot be misaligned). Regression
test in revision.rs::padded_block_sizes_shift_later_blocks.

Recover from stale upload drafts. An upload interrupted after the draft
node was created but before it was sealed leaves an unsealed draft, and
every later attempt at that name returns 422 AlreadyExists forever. Draft
creation now deletes the conflicting node and retries when the conflict is
an unsealed draft; by default only our own client's, with
upload_file_replacing_draft_from opting into any client's to recover
across a daemon restart (which rotates our client uid). A committed file
of that name is never touched.

Live tests for the public-link round trip, custom-password links, revision
history, and the path helpers.
This commit is contained in:
nvrl committed 2026-07-19 13:17:30 +02:00
1 parent f75ecb04b6
commit 24a99e6b9a
12 files changed
+2366 -73

No files matched your search

File diff suppressed because it is too large. Load diff
+158 -3
View File
@@ -692,11 +692,20 @@ pub struct RevisionCreationIdentity {
pub revision_id: String,
}
/// `Details` object attached to an `AlreadyExists` (2500) error from
/// `POST .../revisions`: the server names the draft revision already open on the
/// link, and which client left it there. Mirrors C# `RevisionConflict`.
/// `Details` object attached to an `AlreadyExists` (2500) error from a file
/// creation (`POST .../files`) or a revision creation (`POST .../revisions`):
/// the server names the conflicting link, its committed revision (if any) and
/// the draft revision already open on it, plus which client left that draft.
/// Mirrors C# `RevisionConflict`.
#[derive(Debug, Deserialize)]
pub struct RevisionConflict {
/// The link that already carries this name (set on file-creation conflicts).
#[serde(rename = "ConflictLinkID", default)]
pub link_id: Option<LinkId>,
/// The conflicting link's committed (active) revision, if it has one. A
/// value here means a real, sealed file — not a recoverable stale draft.
#[serde(rename = "ConflictRevisionID", default)]
pub revision_id: Option<String>,
#[serde(rename = "ConflictDraftRevisionID", default)]
pub draft_revision_id: Option<String>,
#[serde(rename = "ConflictDraftClientUID", default)]
@@ -1573,3 +1582,149 @@ pub struct ShareUrlDto {
#[serde(rename = "NumAccesses", default)]
pub num_accesses: i64,
}
// ---------------------------------------------------------------------------
// Public-link session (consuming someone else's shared link)
// ---------------------------------------------------------------------------
/// `GET urls/{token}/info` — opens the SRP handshake for a public link.
///
/// Callable without any session (TS `SharingPublicSessionAPIService.initPublicLinkSession`).
#[derive(Debug, Clone, Deserialize)]
pub struct PublicLinkInfoResponse {
/// SRP auth version.
#[serde(rename = "Version", default)]
pub version: i32,
/// The cleartext-signed SRP modulus.
#[serde(rename = "Modulus", default)]
pub modulus: String,
/// base64 server ephemeral `B`.
#[serde(rename = "ServerEphemeral", default)]
pub server_ephemeral: String,
/// base64 SRP salt for the URL password.
#[serde(rename = "UrlPasswordSalt", default)]
pub url_password_salt: String,
#[serde(rename = "SRPSession", default)]
pub srp_session: String,
/// Bit 0 set = the link also needs a custom password. `0` or `1` alone marks
/// a legacy link the SDK no longer supports.
#[serde(rename = "Flags", default)]
pub flags: i32,
/// Non-zero for vendor links (Proton Docs and friends) that belong to
/// another app rather than to Drive.
#[serde(rename = "VendorType", default)]
pub vendor_type: i32,
}
impl PublicLinkInfoResponse {
/// Whether the link needs a custom password on top of the URL fragment.
pub fn is_custom_password_protected(&self) -> bool {
self.flags & 1 == 1
}
/// Whether this is a legacy link, which neither this SDK nor the upstream
/// TypeScript SDK can open (TS: `Flags === 0 || Flags === 1`).
pub fn is_legacy(&self) -> bool {
self.flags == 0 || self.flags == 1
}
}
/// `POST urls/{token}/auth` — completes the SRP handshake.
#[derive(Debug, Serialize)]
pub struct PublicLinkAuthRequest {
#[serde(rename = "ClientProof")]
pub client_proof: String,
#[serde(rename = "ClientEphemeral")]
pub client_ephemeral: String,
#[serde(rename = "SRPSession")]
pub srp_session: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct PublicLinkAuthResponse {
/// base64 `M2`, to be checked against the locally computed expectation.
#[serde(rename = "ServerProof", default)]
pub server_proof: String,
/// The session id for subsequent requests (`x-pm-uid`).
#[serde(rename = "UID", default)]
pub uid: String,
/// Bearer token. Absent when the caller already has a Proton session that
/// the server accepted instead of minting an anonymous one.
#[serde(rename = "AccessToken", default)]
pub access_token: Option<String>,
#[serde(rename = "Share")]
pub share: PublicLinkShareDto,
}
/// The link's share crypto, returned by the auth handshake.
#[derive(Debug, Clone, Deserialize)]
pub struct PublicLinkShareDto {
#[serde(rename = "VolumeID")]
pub volume_id: VolumeId,
/// The link id of the shared node — the root of what the visitor can see.
#[serde(rename = "LinkID")]
pub link_id: LinkId,
/// base64 bcrypt salt for deriving the key password from the link password.
#[serde(rename = "SharePasswordSalt", default)]
pub share_password_salt: String,
/// The armored share private key.
#[serde(rename = "ShareKey", default)]
pub share_key: String,
/// The share key's passphrase, symmetrically encrypted under the derived
/// key password.
#[serde(rename = "SharePassphrase", default)]
pub share_passphrase: String,
#[serde(rename = "PublicPermissions", default)]
pub public_permissions: Option<i32>,
}
// ---------------------------------------------------------------------------
// Revision history
// ---------------------------------------------------------------------------
/// `GET v2/volumes/{vid}/files/{lid}/revisions` — a file's revision history.
#[derive(Debug, Deserialize)]
pub struct RevisionListResponse {
#[serde(rename = "Revisions", default)]
pub revisions: Vec<RevisionListItemDto>,
}
/// `GET …/revisions/{rid}?NoBlockUrls=true` — one revision's metadata.
///
/// Deliberately reuses [`RevisionListItemDto`], which has no `Blocks` field: with
/// `NoBlockUrls` the server still emits a `Blocks` array but with null `BareURL`
/// and `Token`, which [`BlockDto`] rejects. Ignoring the array outright is both
/// correct for a metadata read and stops a null from breaking it.
#[derive(Debug, Deserialize)]
pub struct RevisionMetadataResponse {
#[serde(rename = "Revision")]
pub revision: RevisionListItemDto,
}
/// One entry of a revision listing.
///
/// Distinct from [`RevisionDto`], which is the *single-revision* response and
/// carries the block table. The listing omits blocks but adds `State` and
/// `Size`, since a history view needs to tell active from superseded.
#[derive(Debug, Deserialize)]
pub struct RevisionListItemDto {
#[serde(rename = "ID")]
pub id: String,
/// Wire `ApiRevisionState`: 0 draft, 1 active, 2 obsolete/superseded.
#[serde(rename = "State", default)]
pub state: Option<i32>,
#[serde(rename = "CreateTime", default)]
pub creation_time: i64,
/// Encrypted size on cloud storage.
#[serde(rename = "Size", default)]
pub size: i64,
#[serde(rename = "ManifestSignature", default)]
pub manifest_signature: Option<String>,
/// Email of the signer; empty/absent means the node key signed.
#[serde(rename = "SignatureEmail", default)]
pub signature_email: Option<String>,
#[serde(rename = "XAttr", default)]
pub extended_attributes: Option<String>,
#[serde(rename = "Thumbnails", default)]
pub thumbnails: Vec<ThumbnailDto>,
}
+3 -1
View File
@@ -41,6 +41,7 @@ mod event_manager;
mod events;
mod node;
mod photos;
mod public_link;
mod revision;
mod sharing;
@@ -56,7 +57,8 @@ pub use node::{Node, NodeKind, RevisionState, Thumbnail, ThumbnailType};
pub use photos::{PhotoTag, PhotoUploadMetadata, PhotosTimelineItem, ProtonPhotosClient};
pub use proton_sdk::account::KeySalt;
pub use proton_sdk::cache::{CacheRepository, EncryptedCacheRepository, InMemoryCacheRepository};
pub use revision::RevisionReader;
pub use public_link::{ProtonDrivePublicLinkClient, PublicLinkInfo};
pub use revision::{Revision, RevisionReader};
pub use sharing::{
Bookmark, ExternalInvitation, ExternalInvitationState, IncomingInvitation, MemberRole,
PublicLink, ShareInvitation, ShareMember,
+646
View File
@@ -0,0 +1,646 @@
//! Consuming a public share link — the read side of someone *else's* link.
//!
//! Ported from the TypeScript SDK (`internal/sharingPublic`, exposed as
//! `ProtonDrivePublicLinkClient`); the C# SDK has no equivalent.
//! [`ProtonDriveClient::create_public_link`](crate::ProtonDriveClient::create_public_link)
//! is the mirror image of this module — it *mints* the link this opens.
//!
//! A visitor needs no Proton account. The flow is:
//!
//! 1. `GET urls/{token}/info` (unauthenticated) opens an SRP handshake and says
//! whether a custom password is also required.
//! 2. `POST urls/{token}/auth` completes it, returning an anonymous session
//! (`x-pm-uid` + bearer) and the link's encrypted share key.
//! 3. The share key is unlocked with the link password: bcrypt-derive the key
//! password from `SharePasswordSalt`, symmetrically decrypt `SharePassphrase`
//! with it, then unlock `ShareKey` with the recovered passphrase.
//!
//! After that the *ordinary* Drive endpoints serve the shared subtree — only the
//! session headers and the root key differ — so node listing and download reuse
//! the same wire shapes as [`crate::ProtonDriveClient`].
//!
//! ## Verification
//!
//! Signature verification is deliberately absent here. Verifying authorship
//! needs the owner's public keys from `core/v4/keys/all`, which an anonymous
//! visitor cannot read; the TypeScript SDK disables the same fallback
//! (`SharingPublicNodesCryptoService.allowContentKeyPacketFallbackVerification =
//! false`). Nodes come back with a default [`NodeVerification`], i.e. nothing
//! claimed, rather than a verification that silently passed.
use std::collections::HashMap;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use proton_sdk::config::ProtonClientConfiguration;
use proton_sdk::crypto::{
ContentKey, DEFAULT_BIT_LENGTH, PrivateKey, decrypt_armored_with_password,
derive_key_passphrase, generate_proofs,
};
use proton_sdk::error::{ProtonError, Result};
use proton_sdk::http::{ApiHttpClient, Tokens, get_unauthenticated};
use proton_sdk::ids::{LinkId, NodeUid, SessionId, VolumeId};
use crate::client::parse_public_link_url;
use crate::crypto::decrypt_link;
use crate::dtos::{
FileDto, FolderChildrenResponse, LinkDetailsDto, LinkDetailsRequest, LinkDetailsResponse,
LinkType, PublicLinkAuthRequest, PublicLinkAuthResponse, PublicLinkInfoResponse,
RevisionResponse,
};
use crate::node::{Node, NodeKind, RevisionState};
use crate::sharing::MemberRole;
/// Link metadata readable before authenticating, from `GET urls/{token}/info`.
///
/// Fetch it with [`ProtonDrivePublicLinkClient::info`] to learn whether the user
/// must also be prompted for a custom password before
/// [`open`](ProtonDrivePublicLinkClient::open) can succeed.
#[derive(Debug, Clone)]
pub struct PublicLinkInfo {
/// The link needs a custom password in addition to the URL fragment.
pub is_custom_password_protected: bool,
/// A legacy link, which neither this SDK nor the upstream TypeScript SDK
/// can open.
pub is_legacy: bool,
/// Non-zero for links owned by another Proton app (Docs and friends) rather
/// than by Drive.
pub vendor_type: i32,
}
/// An authenticated, anonymous session against one public link.
///
/// Mirrors TS `SharingPublicLinkSession`. Holds the link password so an expired
/// session can be re-established by replaying the handshake.
struct PublicLinkSession {
config: ProtonClientConfiguration,
token: String,
/// URL fragment password concatenated with any custom password — the TS SDK
/// treats the pair as one secret throughout.
password: String,
}
/// What one completed handshake yields.
struct AuthOutcome {
http: ApiHttpClient,
share_key: PrivateKey,
root_uid: NodeUid,
public_role: MemberRole,
}
impl PublicLinkSession {
async fn info(&self) -> Result<PublicLinkInfoResponse> {
get_unauthenticated(&self.config, &format!("drive/urls/{}/info", self.token)).await
}
/// Run the SRP handshake and unlock the share key.
async fn auth(&self) -> Result<AuthOutcome> {
let info = self.info().await?;
if info.is_legacy() {
return Err(ProtonError::invalid_operation(
"this public link uses a legacy format that is no longer supported",
));
}
let proofs = generate_proofs(
info.version,
self.password.as_bytes(),
&decode_base64(&info.url_password_salt, "UrlPasswordSalt")?,
&info.modulus,
&decode_base64(&info.server_ephemeral, "ServerEphemeral")?,
DEFAULT_BIT_LENGTH,
)?;
let request = PublicLinkAuthRequest {
client_proof: BASE64.encode(&proofs.client_proof),
client_ephemeral: BASE64.encode(&proofs.client_ephemeral),
srp_session: info.srp_session,
};
let response: PublicLinkAuthResponse = proton_sdk::http::post_unauthenticated(
&self.config,
&format!("drive/urls/{}/auth", self.token),
&request,
)
.await?;
// The server proves it knows the verifier. A mismatch means we are not
// talking to the party that holds the link's secret — refuse before
// handing it any further requests.
if response.server_proof != BASE64.encode(&proofs.expected_server_proof) {
return Err(ProtonError::invalid_operation(
"public link server proof did not match; refusing the session",
));
}
let share_key = self.unlock_share_key(&response)?;
// An absent AccessToken means the server accepted an existing Proton
// session instead of minting an anonymous one. We have no such session
// to fall back on, so it is an error rather than a silent no-auth state.
let access_token = response.access_token.clone().ok_or_else(|| {
ProtonError::invalid_operation("public link auth returned no access token")
})?;
let http = ApiHttpClient::new(
self.config.clone(),
SessionId::from(response.uid.clone()),
Tokens {
access_token,
// An anonymous public-link session cannot be refreshed; it is
// re-established by replaying the handshake instead.
refresh_token: String::new(),
},
)?
// Public-link data requests take the `drive/unauth/` prefix, not the
// `drive/` one an authenticated client uses (TS `getUnauthEndpoint`).
// The endpoints and payloads are otherwise identical; only this prefix
// tells the API to accept a session that has no `full`/`nondelinquent`
// scope. Sending them to `drive/` returns 403 `MissingScopes`.
// The session routes (`drive/urls/{token}/…`) are exempt — they run
// unauthenticated, before this client exists.
.with_base_route("drive/unauth/");
Ok(AuthOutcome {
http,
share_key,
root_uid: NodeUid::new(
response.share.volume_id.clone(),
response.share.link_id.clone(),
),
public_role: MemberRole::from_permissions(response.share.public_permissions),
})
}
/// Recover the share private key from the link password.
///
/// TS `SharingPublicSessionManager.decryptShareKey` →
/// `DriveCrypto.decryptKeyWithSrpPassword`. The salt is a bcrypt salt, not
/// the SRP one: the same `derive_key_passphrase` the create side used to
/// wrap this passphrase.
fn unlock_share_key(&self, response: &PublicLinkAuthResponse) -> Result<PrivateKey> {
let salt = decode_base64(&response.share.share_password_salt, "SharePasswordSalt")?;
let key_password = derive_key_passphrase(self.password.as_bytes(), &salt)?;
let passphrase =
decrypt_armored_with_password(&response.share.share_passphrase, &key_password)?;
Ok(PrivateKey::from_armored(
&response.share.share_key,
&passphrase,
)?)
}
}
/// Read access to one public share link, as a visitor.
///
/// Obtained from [`open`](Self::open). The client holds an anonymous session and
/// the link's share key; every operation is scoped to the shared subtree.
pub struct ProtonDrivePublicLinkClient {
session: PublicLinkSession,
http: ApiHttpClient,
/// The link's share key — the parent key of the shared root node.
share_key: PrivateKey,
root_uid: NodeUid,
public_role: MemberRole,
}
impl ProtonDrivePublicLinkClient {
/// Read a link's public metadata without opening it.
///
/// Use this to discover [`is_custom_password_protected`](PublicLinkInfo::is_custom_password_protected)
/// before prompting for a password. `url` is the full share URL including
/// its `#password` fragment.
pub async fn info(config: ProtonClientConfiguration, url: &str) -> Result<PublicLinkInfo> {
let (token, password) = parse_public_link_url(url)?;
let session = PublicLinkSession {
config,
token,
password,
};
let raw = session.info().await?;
Ok(PublicLinkInfo {
is_custom_password_protected: raw.is_custom_password_protected(),
is_legacy: raw.is_legacy(),
vendor_type: raw.vendor_type,
})
}
/// Authenticate against a public link and open it for reading.
///
/// `url` is the full share URL including its `#password` fragment (the form
/// [`PublicLink::url`](crate::PublicLink::url) hands back).
/// `custom_password` is required exactly when
/// [`info`](Self::info) reports the link is custom-password protected;
/// supplying the wrong one fails the SRP handshake, not the decryption.
pub async fn open(
config: ProtonClientConfiguration,
url: &str,
custom_password: Option<&str>,
) -> Result<Self> {
let (token, url_password) = parse_public_link_url(url)?;
let password = match custom_password {
Some(custom) if !custom.is_empty() => format!("{url_password}{custom}"),
_ => url_password,
};
let session = PublicLinkSession {
config,
token,
password,
};
let auth = session.auth().await?;
Ok(Self {
session,
http: auth.http,
share_key: auth.share_key,
root_uid: auth.root_uid,
public_role: auth.public_role,
})
}
/// The role the link grants its visitors — [`MemberRole::Viewer`] for a
/// read-only link, [`MemberRole::Editor`] for one that permits uploads.
pub fn public_role(&self) -> MemberRole {
self.public_role
}
/// The uid of the shared node: the folder or file the link points at.
pub fn root_uid(&self) -> &NodeUid {
&self.root_uid
}
/// The shared node itself, decrypted.
///
/// A link may point at a single file as easily as at a folder — check
/// [`Node::is_folder`] before listing children.
pub async fn get_root_node(&self) -> Result<Node> {
self.get_node(&self.root_uid.clone())
.await?
.ok_or_else(|| ProtonError::invalid_operation("the shared node no longer exists"))
}
/// Fetch one node inside the shared subtree.
///
/// Returns `Ok(None)` when the node does not exist. Nodes outside the shared
/// subtree are not readable — the server rejects them, and their parent key
/// is not derivable from the share key regardless.
pub async fn get_node(&self, uid: &NodeUid) -> Result<Option<Node>> {
let mut nodes = self.enumerate_nodes(std::slice::from_ref(uid)).await?;
Ok(nodes.pop())
}
/// Enumerate the [`NodeUid`]s of a shared folder's children.
///
/// Same paging contract as
/// [`ProtonDriveClient::enumerate_folder_children_node_uids`](crate::ProtonDriveClient::enumerate_folder_children_node_uids):
/// uids only, materialized on demand via [`enumerate_nodes`](Self::enumerate_nodes).
pub async fn enumerate_folder_children_node_uids(
&self,
folder_uid: &NodeUid,
) -> Result<Vec<NodeUid>> {
let mut uids = Vec::new();
let mut anchor: Option<LinkId> = None;
loop {
let mut path = format!(
"v2/volumes/{}/folders/{}/children",
folder_uid.volume_id, folder_uid.link_id
);
if let Some(anchor_id) = &anchor {
path.push_str(&format!("?AnchorID={anchor_id}"));
}
let page: FolderChildrenResponse = self.http.get(&path).await?;
if page.link_ids.is_empty() {
break;
}
for link_id in page.link_ids {
uids.push(NodeUid::new(folder_uid.volume_id.clone(), link_id));
}
if !page.more_results_exist {
break;
}
anchor = page.anchor_id;
if anchor.is_none() {
break;
}
}
Ok(uids)
}
/// Fetch decrypted metadata for many nodes in the shared subtree.
///
/// A node that does not exist is omitted; one that fails to decrypt is
/// logged and skipped, so the result may be shorter than `uids` — the same
/// partial-listing behavior as the authenticated client.
pub async fn enumerate_nodes(&self, uids: &[NodeUid]) -> Result<Vec<Node>> {
if uids.is_empty() {
return Ok(Vec::new());
}
let mut nodes = Vec::with_capacity(uids.len());
for (volume_id, link_ids) in group_by_volume(uids) {
let response: LinkDetailsResponse = self
.http
.post(
&format!("v2/volumes/{volume_id}/links"),
&LinkDetailsRequest {
link_ids: &link_ids,
},
)
.await?;
for details in &response.links {
let parent_key = match self.resolve_parent_key(&volume_id, details).await {
Ok(key) => key,
Err(e) => {
tracing::warn!(link_id = %details.link.id, "skipping node: {e}");
continue;
}
};
match self.build_node(&volume_id, details, &parent_key) {
Ok(node) => nodes.push(node),
Err(e) => {
tracing::warn!(link_id = %details.link.id, "skipping node: {e}")
}
}
}
}
Ok(nodes)
}
/// Download a shared file's active revision.
///
/// Unlike the authenticated client's
/// [`download_file`](crate::ProtonDriveClient::download_file), the content
/// manifest is **not** verified: doing so needs the uploader's address keys,
/// which an anonymous visitor cannot fetch. The bytes are decrypted with the
/// file's content key, so they are still authentic to whoever holds the node
/// key — but nothing here attests *who* that was.
pub async fn download_file(&self, uid: &NodeUid) -> Result<Vec<u8>> {
let mut out = Vec::new();
self.download_file_to(uid, &mut out).await?;
Ok(out)
}
/// As [`download_file`](Self::download_file), streaming into `writer`.
pub async fn download_file_to<W: std::io::Write>(
&self,
uid: &NodeUid,
writer: &mut W,
) -> Result<()> {
let details = self.link_details(uid).await?;
let revision_id = file_properties(&details)
.and_then(|file| file.active_revision.as_ref())
.map(|revision| revision.id.clone())
.ok_or_else(|| {
ProtonError::invalid_operation("the shared file has no active revision")
})?;
let parent_key = self.resolve_parent_key(&uid.volume_id, &details).await?;
let decrypted = decrypt_link(&parent_key, &details.link)?;
let content_key = self.content_key(&details, &decrypted.node_key)?;
let response: RevisionResponse = self
.http
.get(&format!(
"v2/volumes/{}/files/{}/revisions/{}",
uid.volume_id, uid.link_id, revision_id
))
.await?;
// Blocks arrive in index order; decrypt each in turn straight into the
// writer so a large file never has to be resident in full.
let mut blocks = response.revision.blocks;
blocks.sort_by_key(|block| block.index);
for block in blocks {
let ciphertext = self
.http
.get_storage_blob(&block.bare_url, &block.token)
.await?;
let plaintext = content_key.decrypt_block(&ciphertext)?;
writer
.write_all(&plaintext)
.map_err(|e| ProtonError::invalid_operation(format!("write block: {e}")))?;
}
Ok(())
}
/// Re-run the handshake, replacing the session behind this client.
///
/// The anonymous session carries no refresh token, so an expiry is recovered
/// from by authenticating again (TS `SharingPublicLinkSession.reauth`).
/// Returns a fresh client rather than mutating this one, so an in-flight
/// caller never observes a half-swapped session.
pub async fn reauth(&self) -> Result<Self> {
let auth = self.session.auth().await?;
Ok(Self {
session: PublicLinkSession {
config: self.session.config.clone(),
token: self.session.token.clone(),
password: self.session.password.clone(),
},
http: auth.http,
share_key: auth.share_key,
root_uid: auth.root_uid,
public_role: auth.public_role,
})
}
/// The key that decrypts `link`: the share key at the shared root, otherwise
/// the parent node's key, resolved recursively up to the root.
///
/// The recursion terminates at the shared root because the visitor cannot
/// see past it — a link whose parent chain does not reach the root is not
/// readable with this session's material.
async fn resolve_parent_key(
&self,
volume_id: &VolumeId,
details: &LinkDetailsDto,
) -> Result<PrivateKey> {
if details.link.id == self.root_uid.link_id {
return Ok(self.share_key.clone());
}
let parent_id = details.link.parent_id.clone().ok_or_else(|| {
ProtonError::invalid_operation("node has no parent and is not the shared root")
})?;
let parent = self
.link_details(&NodeUid::new(volume_id.clone(), parent_id))
.await?;
// Recursion is boxed: the chain length is the tree depth, which the
// compiler cannot bound for an `async fn` that calls itself.
let grandparent_key = Box::pin(self.resolve_parent_key(volume_id, &parent)).await?;
Ok(decrypt_link(&grandparent_key, &parent.link)?.node_key)
}
async fn link_details(&self, uid: &NodeUid) -> Result<LinkDetailsDto> {
let response: LinkDetailsResponse = self
.http
.post(
&format!("v2/volumes/{}/links", uid.volume_id),
&LinkDetailsRequest {
link_ids: std::slice::from_ref(&uid.link_id),
},
)
.await?;
response
.links
.into_iter()
.next()
.ok_or_else(|| ProtonError::invalid_operation(format!("node {uid} was not found")))
}
/// Unwrap a file's content key from its `ContentKeyPacket`.
fn content_key(&self, details: &LinkDetailsDto, node_key: &PrivateKey) -> Result<ContentKey> {
let packet = file_properties(details)
.and_then(|file| file.content_key_packet.as_ref())
.ok_or_else(|| ProtonError::invalid_operation("the file has no content key packet"))?;
let packet_bytes = decode_base64(packet, "ContentKeyPacket")?;
Ok(node_key.decrypt_content_key(&packet_bytes)?)
}
/// Build a public [`Node`] from a decrypted link.
///
/// Verification is left at its default (nothing claimed) — see the module
/// note on why an anonymous visitor cannot check authorship.
fn build_node(
&self,
volume_id: &VolumeId,
details: &LinkDetailsDto,
parent_key: &PrivateKey,
) -> Result<Node> {
let link = &details.link;
let decrypted = decrypt_link(parent_key, link)?;
let kind = if matches!(link.parsed_type(), LinkType::Folder) {
NodeKind::Folder
} else {
let file = file_properties(details);
NodeKind::File {
media_type: file.map(|f| f.media_type.clone()).unwrap_or_default(),
total_size_on_storage: file.map(|f| f.total_size_on_storage).unwrap_or_default(),
active_revision_state: file
.and_then(|f| f.active_revision.as_ref())
.map(|revision| RevisionState::from_raw(revision.state)),
// Claimed metadata lives in the revision's extended attributes,
// which this read surface does not fetch.
claimed_size: None,
claimed_modification_time: None,
}
};
Ok(Node {
uid: NodeUid::new(volume_id.clone(), link.id.clone()),
parent_uid: link
.parent_id
.clone()
.map(|parent| NodeUid::new(volume_id.clone(), parent)),
kind,
name: decrypted.name,
creation_time: link.creation_time,
modification_time: link.modification_time,
trashed: link.is_trashed(),
is_shared: true,
is_shared_publicly: true,
signature_email: link.name_signature_email.clone(),
verification: Default::default(),
})
}
}
/// A node's file properties, wherever the server chose to put them.
///
/// The photos volume returns them under `Photo` rather than `File`
/// (C# `linkDetailsDto.File ?? linkDetailsDto.Photo`) — a public link may point
/// into either.
fn file_properties(details: &LinkDetailsDto) -> Option<&FileDto> {
details.file.as_ref().or(details.photo.as_ref())
}
fn decode_base64(value: &str, what: &str) -> Result<Vec<u8>> {
BASE64
.decode(value)
.map_err(|e| ProtonError::invalid_operation(format!("{what} is not valid base64: {e}")))
}
/// Group uids by volume, preserving first-seen order.
fn group_by_volume(uids: &[NodeUid]) -> Vec<(VolumeId, Vec<LinkId>)> {
let mut order: Vec<VolumeId> = Vec::new();
let mut groups: HashMap<VolumeId, Vec<LinkId>> = HashMap::new();
for uid in uids {
let entry = groups.entry(uid.volume_id.clone()).or_insert_with(|| {
order.push(uid.volume_id.clone());
Vec::new()
});
entry.push(uid.link_id.clone());
}
order
.into_iter()
.map(|volume_id| {
let link_ids = groups.remove(&volume_id).unwrap_or_default();
(volume_id, link_ids)
})
.collect()
}
#[cfg(test)]
mod tests {
use crate::client::parse_public_link_url;
use crate::dtos::PublicLinkInfoResponse;
fn info(flags: i32) -> PublicLinkInfoResponse {
PublicLinkInfoResponse {
version: 4,
modulus: String::new(),
server_ephemeral: String::new(),
url_password_salt: String::new(),
srp_session: String::new(),
flags,
vendor_type: 0,
}
}
#[test]
fn parses_token_and_password_from_a_share_url() {
let (token, password) =
parse_public_link_url("https://drive.proton.me/urls/ABC123#s3cr3t").unwrap();
assert_eq!(token, "ABC123");
assert_eq!(password, "s3cr3t");
}
#[test]
fn tolerates_a_trailing_slash_before_the_fragment() {
let (token, password) =
parse_public_link_url("https://drive.proton.me/urls/ABC123/#s3cr3t").unwrap();
assert_eq!(token, "ABC123");
assert_eq!(password, "s3cr3t");
}
#[test]
fn rejects_urls_without_a_usable_secret() {
// No fragment at all: the URL carries no password to open anything with.
assert!(parse_public_link_url("https://drive.proton.me/urls/ABC123").is_err());
// Empty fragment, empty token.
assert!(parse_public_link_url("https://drive.proton.me/urls/ABC123#").is_err());
assert!(parse_public_link_url("https://drive.proton.me/urls/#s3cr3t").is_err());
}
#[test]
fn link_flags_classify_custom_password_and_legacy_links() {
// TS: isCustomPasswordProtected = (Flags & 1) === 1,
// isLegacy = Flags === 0 || Flags === 1.
assert!(info(0).is_legacy());
assert!(info(1).is_legacy());
assert!(!info(2).is_legacy());
assert!(!info(3).is_legacy());
assert!(!info(2).is_custom_password_protected());
assert!(info(3).is_custom_password_protected());
}
}
+176 -17
View File
@@ -9,6 +9,8 @@ use futures::stream::{self, StreamExt, TryStreamExt};
use proton_sdk::crypto::ContentKey;
use proton_sdk::error::{ProtonError, Result};
use proton_sdk::ids::NodeUid;
use crate::node::RevisionState;
use sha2::{Digest, Sha256};
use tokio::sync::{Mutex, OwnedSemaphorePermit, RwLock};
@@ -72,6 +74,45 @@ async fn join_decrypt<T>(
.map_err(Into::into)
}
/// One entry of a file's revision history.
///
/// Mirrors C# `Proton.Drive.Sdk.Nodes.Revision` and the TypeScript SDK's
/// `DecryptedRevision`. The `claimed_*` fields come from the revision's
/// decrypted extended attributes — they are what the *uploader* asserted, not
/// something the server computed, hence the name (C# `ClaimedSize` /
/// `ClaimedModificationTime`). They are `None` when the revision carries no
/// `XAttr` or it failed to decrypt.
#[derive(Debug, Clone)]
pub struct Revision {
/// The file this revision belongs to.
pub file_uid: NodeUid,
/// Server-assigned revision id, unique within the file.
pub revision_id: String,
pub state: RevisionState,
/// Creation time, epoch seconds.
pub creation_time: i64,
/// Encrypted size on cloud storage, in bytes.
pub size_on_storage: i64,
/// Plaintext size as claimed by the uploader.
pub claimed_size: Option<i64>,
/// ISO-8601 modification time as claimed by the uploader, verbatim.
pub claimed_modification_time: Option<String>,
/// Lowercase-hex SHA-1 of the full plaintext, as claimed by the uploader.
pub claimed_sha1: Option<String>,
/// Email that signed the revision manifest; `None`/empty means the node key
/// signed it anonymously.
pub signature_email: Option<String>,
/// Whether the revision carries any thumbnails.
pub has_thumbnails: bool,
}
impl Revision {
/// Whether this is the file's current revision.
pub fn is_active(&self) -> bool {
matches!(self.state, RevisionState::Active)
}
}
/// The block table, versioned so a refresh can be deduplicated: a caller that
/// hit an expired URL records the generation it saw, and only re-lists the
/// revision if nobody else has already replaced that generation.
@@ -173,18 +214,7 @@ impl RevisionReader {
// Which blocks overlap the range, and where each one starts in the
// plaintext — resolved up front so the fetches can run concurrently.
let mut wanted: Vec<(usize, u64)> = Vec::new();
let mut block_start: u64 = 0;
for (index, &block_size) in self.block_sizes.iter().enumerate() {
if block_start >= end {
break;
}
let block_end = block_start + block_size;
if block_end > offset {
wanted.push((index, block_start));
}
block_start = block_end;
}
let wanted = plan_blocks(&self.block_sizes, offset, end);
// `buffered` yields in input order, so the slices append in block order.
// The closure takes the index *by value*: a closure taking a reference
@@ -202,11 +232,7 @@ impl RevisionReader {
let (_, block_start) = wanted[next];
next += 1;
let from = offset.saturating_sub(block_start) as usize;
let to = ((end - block_start) as usize).min(plaintext.len());
if from < to {
out.extend_from_slice(&plaintext[from..to]);
}
splice_block(&mut out, &plaintext, block_start, offset, end);
}
Ok(out)
@@ -294,6 +320,42 @@ impl RevisionReader {
}
}
/// Which blocks overlap the plaintext range `[offset, end)`, paired with where
/// each one starts in the plaintext.
///
/// A block's start is the running sum of the *preceding* blocks' sizes, so this
/// is only correct if `block_sizes` describes the revision truthfully. A vector
/// that pads a short block up to the full block size does not merely misreport
/// the file's length — it shifts the computed start of every block after it, and
/// the caller then splices those blocks at the wrong plaintext offsets. See
/// `padded_block_sizes_shift_later_blocks` below, and the guarantee
/// `ProtonDriveClient::resolve_block_sizes` has to uphold to make this sound.
fn plan_blocks(block_sizes: &[u64], offset: u64, end: u64) -> Vec<(usize, u64)> {
let mut wanted = Vec::new();
let mut block_start: u64 = 0;
for (index, &block_size) in block_sizes.iter().enumerate() {
if block_start >= end {
break;
}
let block_end = block_start + block_size;
if block_end > offset {
wanted.push((index, block_start));
}
block_start = block_end;
}
wanted
}
/// Append the part of `plaintext` — a block beginning at `block_start` in the
/// file — that falls inside the requested range `[offset, end)`.
fn splice_block(out: &mut Vec<u8>, plaintext: &[u8], block_start: u64, offset: u64, end: u64) {
let from = offset.saturating_sub(block_start) as usize;
let to = ((end - block_start) as usize).min(plaintext.len());
if from < to {
out.extend_from_slice(&plaintext[from..to]);
}
}
/// Whether a storage fetch failed in a way that an expired block URL would
/// produce. Block URLs carry their own authorization and are handed out with a
/// server-side lifetime we neither control nor are told, so recovery is
@@ -304,3 +366,100 @@ fn is_expired_block_url(error: &ProtonError) -> bool {
ProtonError::Api(e) if matches!(e.http_status, 401 | 403 | 404)
)
}
#[cfg(test)]
mod tests {
use super::*;
/// Assemble a read exactly as [`RevisionReader::read_at`] does: plan the
/// blocks from what the reader *believes* the sizes are, then splice the
/// blocks the server *actually* returns. Splitting belief from reality is
/// the whole point — that gap is the bug.
fn read_as_reader_would(
believed_sizes: &[u64],
actual_blocks: &[Vec<u8>],
offset: u64,
length: u64,
) -> Vec<u8> {
let file_size: u64 = believed_sizes.iter().sum();
if length == 0 || offset >= file_size {
return Vec::new();
}
let end = offset.saturating_add(length).min(file_size);
let mut out = Vec::new();
for (index, block_start) in plan_blocks(believed_sizes, offset, end) {
splice_block(&mut out, &actual_blocks[index], block_start, offset, end);
}
out
}
/// The file used throughout: two genuinely 3 MiB blocks, 6 MiB total, with
/// every byte carrying its own offset so a misplaced slice is identifiable
/// rather than just unequal.
fn short_block_file() -> (Vec<u64>, Vec<Vec<u8>>) {
const SHORT: usize = 3 << 20;
let truthful = vec![SHORT as u64, SHORT as u64];
let blocks: Vec<Vec<u8>> = (0..2)
.map(|b| (0..SHORT).map(|i| ((b * SHORT + i) % 251) as u8).collect())
.collect();
(truthful, blocks)
}
/// The whole file's plaintext, for comparison.
fn flatten(blocks: &[Vec<u8>]) -> Vec<u8> {
blocks.iter().flatten().copied().collect()
}
/// Truthful block sizes read the file correctly — the control.
#[test]
fn truthful_block_sizes_read_correctly() {
let (truthful, blocks) = short_block_file();
let whole = flatten(&blocks);
for (offset, length) in [(0, 1 << 20), (3 << 20, 1 << 20), (4 << 20, 1 << 20)] {
let got = read_as_reader_would(&truthful, &blocks, offset, length);
let want = &whole[offset as usize..(offset + length) as usize];
assert_eq!(got, want, "offset={offset} length={length}");
}
}
/// **The A1 reproduce.** `resolve_block_sizes`'s terminal fallback pads every
/// block to the full 4 MiB. For a file whose blocks are actually shorter,
/// that shifts each later block's computed start and the reader serves bytes
/// from the wrong part of the file — silently, with no error and no short
/// read to hint at it.
#[test]
fn padded_block_sizes_shift_later_blocks() {
let (_, blocks) = short_block_file();
let whole = flatten(&blocks);
// What the fallback would produce for a 2-block file: `vec![4 MiB; 2]`.
let padded = [4u64 << 20; 2];
// Read 1 MiB at 4 MiB. Block 1 truly starts at 3 MiB, but the padded
// sizes place it at 4 MiB, so the splice is off by exactly 1 MiB.
let got = read_as_reader_would(&padded, &blocks, 4 << 20, 1 << 20);
let want = &whole[(4 << 20)..(5 << 20)];
let shifted = &whole[(3 << 20)..(4 << 20)];
assert_eq!(
got.len(),
1 << 20,
"full length returned: nothing looks wrong"
);
assert_eq!(got, shifted, "bytes come from 1 MiB earlier than requested");
assert_ne!(got, want, "and they are not the bytes that were asked for");
}
/// The same padding also inflates the reported file size, so a read wholly
/// past the real EOF returns bytes instead of nothing.
#[test]
fn padded_block_sizes_inflate_file_size() {
let (truthful, _) = short_block_file();
let padded = [4u64 << 20; 2];
assert_eq!(truthful.iter().sum::<u64>(), 6 << 20, "the real size");
assert_eq!(
padded.iter().sum::<u64>(),
8 << 20,
"the size stat would show"
);
}
}
+12 -2
View File
@@ -23,8 +23,18 @@ use sha1::Sha1;
// Must follow Proton's required shape (sdk/README.md "Operational requirements").
// A malformed channel fails honest-identification → 422 "unusual activity".
const APP_VERSION: &str = "external-drive-rust@0.1.0-alpha";
const USER_AGENT: &str = "external-drive-rust/0.1.0";
pub const APP_VERSION: &str = "external-drive-rust@0.1.0-alpha";
pub const USER_AGENT: &str = "external-drive-rust/0.1.0";
/// A fresh, unauthenticated client configuration — what the public-link client
/// takes, since a visitor has no session to borrow one from.
///
/// `allow(dead_code)`: `common` is compiled into every live-test binary, and
/// only `live_sharing` needs this one.
#[allow(dead_code)]
pub fn config() -> ProtonClientConfiguration {
ProtonClientConfiguration::new(APP_VERSION).with_user_agent(USER_AGENT)
}
/// An authenticated Drive client plus the data password it was built with.
pub struct LiveClient {
@@ -331,3 +331,109 @@ async fn verification_folder_fully_verified() {
cleanup(client, &[uid]).await;
}
// ---------------------------------------------------------------------------
// Hierarchy & path helpers
// ---------------------------------------------------------------------------
/// `create_folder_path` → `get_node_by_path` → `get_node_hierarchy` →
/// `get_node_path`, asserting the four agree on the same nested tree.
#[tokio::test]
#[ignore = "live: needs test-account credentials"]
async fn folder_path_helpers_round_trip() {
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 top = format!("path-ops-{}", common::unique_suffix());
let path = format!("{top}/nested/deep");
// mkdir -p
let deep = client
.create_folder_path(&root.uid, &path)
.await
.expect("create_folder_path");
// Idempotent: a second call adopts the existing folders, same uid.
let again = client
.create_folder_path(&root.uid, &path)
.await
.expect("create_folder_path is idempotent");
assert_eq!(again, deep, "re-creating a path must reuse the folders");
// Lookup by path, tolerating separator noise.
for variant in [path.as_str(), &format!("/{path}"), &format!("//{path}//")] {
let found = client
.get_node_by_path(&root.uid, variant)
.await
.unwrap_or_else(|e| panic!("get_node_by_path({variant}) errored: {e}"))
.unwrap_or_else(|| panic!("get_node_by_path({variant}) returned None"));
assert_eq!(found.uid, deep, "path {variant} must resolve to the folder");
}
// An empty path is the root itself.
let at_root = client
.get_node_by_path(&root.uid, "/")
.await
.expect("get_node_by_path(/)")
.expect("root must resolve");
assert_eq!(at_root.uid, root.uid, "empty path is the root");
// A missing segment resolves to None, not an error.
assert!(
client
.get_node_by_path(&root.uid, &format!("{top}/nope"))
.await
.expect("missing path must not error")
.is_none(),
"a missing segment must resolve to None"
);
// Hierarchy is root-first and ends at the queried node.
let hierarchy = client
.get_node_hierarchy(&deep)
.await
.expect("get_node_hierarchy")
.expect("hierarchy for an existing node");
assert_eq!(
hierarchy.first().map(|n| &n.uid),
Some(&root.uid),
"hierarchy must start at the tree root"
);
assert_eq!(
hierarchy.last().map(|n| &n.uid),
Some(&deep),
"hierarchy must end at the queried node"
);
let names: Vec<&str> = hierarchy.iter().skip(1).map(|n| n.name.as_str()).collect();
assert_eq!(names, [top.as_str(), "nested", "deep"]);
// The rendered path round-trips back through the path lookup.
let rendered = client
.get_node_path(&deep)
.await
.expect("get_node_path")
.expect("path for an existing node");
assert_eq!(rendered, format!("/{path}"));
let reresolved = client
.get_node_by_path(&root.uid, &rendered)
.await
.expect("re-resolve rendered path")
.expect("rendered path must resolve");
assert_eq!(reresolved.uid, deep, "rendered path must round-trip");
// Only the top folder needs cleaning — delete cascades to its children.
let top_uid = client
.get_node_by_path(&root.uid, &top)
.await
.expect("locate top folder")
.expect("top folder must exist")
.uid;
cleanup(client, &[top_uid]).await;
}
+159 -1
View File
@@ -18,7 +18,7 @@
mod common;
use proton_drive_rs::{MemberRole, ProtonDriveClient};
use proton_drive_rs::{MemberRole, ProtonDriveClient, ProtonDrivePublicLinkClient};
use proton_sdk::ids::NodeUid;
/// Trash then permanently delete the given node; best-effort, logs on failure.
@@ -250,3 +250,161 @@ async fn shared_with_me_and_incoming_invitations_read() {
.expect("list_incoming_invitations must not error");
eprintln!("[info] incoming invitations count: {}", incoming.len());
}
// ---------------------------------------------------------------------------
// Public link — consuming a link as an anonymous visitor
// ---------------------------------------------------------------------------
/// Full loop: upload a file, publish a link on its folder, then open that link
/// with [`ProtonDrivePublicLinkClient`] — no session — and read the file back.
///
/// This is the only test that exercises both halves of the public-link feature
/// against each other, so a break in either the mint or the consume side shows
/// up here.
#[tokio::test]
#[ignore = "live: needs test-account credentials"]
async fn public_link_round_trips_through_the_visitor_client() {
let Some(live) = common::live_client().await else {
return;
};
let client = &live.client;
let folder = scratch_folder(client, "public-visit").await;
let contents = b"public link visitor payload".to_vec();
let name = format!("shared-{}.txt", common::unique_suffix());
let file = client
.upload_file(&folder, &name, "text/plain", &contents)
.await
.expect("upload_file");
let link = client
.create_public_link(&folder, MemberRole::Viewer, None, None)
.await
.expect("create_public_link");
// Pre-auth metadata: a link with no custom password must say so, or a caller
// would prompt for one that does not exist.
let url = link.url.clone().expect("a created link must carry its URL");
let info = ProtonDrivePublicLinkClient::info(common::config(), &url)
.await
.expect("public link info");
assert!(
!info.is_legacy,
"a freshly minted link must not read as legacy"
);
assert!(
!info.is_custom_password_protected,
"no custom password was set"
);
// Open it as a visitor — no session, only the URL.
let visitor = ProtonDrivePublicLinkClient::open(common::config(), &url, None)
.await
.expect("open public link");
assert_eq!(
visitor.public_role(),
MemberRole::Viewer,
"the link was minted read-only"
);
let root = visitor.get_root_node().await.expect("visitor root node");
assert_eq!(root.uid, folder, "the link points at the shared folder");
assert!(root.is_folder(), "the shared node is a folder");
// The shared subtree lists and decrypts with the share key alone.
let child_uids = visitor
.enumerate_folder_children_node_uids(&folder)
.await
.expect("visitor lists children");
assert!(
child_uids.contains(&file),
"the uploaded file must appear in the shared listing"
);
let children = visitor
.enumerate_nodes(&child_uids)
.await
.expect("visitor decrypts children");
let shared_file = children
.iter()
.find(|node| node.uid == file)
.expect("the uploaded file must decrypt for the visitor");
assert_eq!(
shared_file.name, name,
"the name must decrypt to what was uploaded"
);
// The payload itself: content key unwrapped from the node key, blocks pulled
// from storage with the anonymous session.
let downloaded = visitor
.download_file(&file)
.await
.expect("visitor download");
assert_eq!(
downloaded, contents,
"the visitor must read back exactly what was uploaded"
);
// Revoking the link must actually close the door.
client
.remove_public_link(&link)
.await
.expect("remove_public_link");
assert!(
ProtonDrivePublicLinkClient::open(common::config(), &url, None)
.await
.is_err(),
"a revoked link must no longer open"
);
cleanup(client, &folder).await;
}
/// A custom-password link must advertise the requirement and refuse to open
/// without it.
#[tokio::test]
#[ignore = "live: needs test-account credentials"]
async fn custom_password_link_requires_the_password() {
let Some(live) = common::live_client().await else {
return;
};
let client = &live.client;
let folder = scratch_folder(client, "public-custompw").await;
let custom = "s3cr3t!";
let link = client
.create_public_link(&folder, MemberRole::Viewer, Some(custom), None)
.await
.expect("create_public_link with custom password");
let url = link.url.clone().expect("a created link must carry its URL");
let info = ProtonDrivePublicLinkClient::info(common::config(), &url)
.await
.expect("public link info");
assert!(
info.is_custom_password_protected,
"the link must advertise that a custom password is needed"
);
// Without the custom password the SRP handshake itself fails — the server
// never hands over the encrypted share key.
assert!(
ProtonDrivePublicLinkClient::open(common::config(), &url, None)
.await
.is_err(),
"opening without the custom password must fail"
);
let visitor = ProtonDrivePublicLinkClient::open(common::config(), &url, Some(custom))
.await
.expect("open with the custom password");
let root = visitor.get_root_node().await.expect("visitor root node");
assert_eq!(root.uid, folder);
cleanup(client, &folder).await;
}
@@ -395,3 +395,202 @@ async fn open_revision_reader_reuses_resolved_metadata() {
cleanup(&client, &[uid]).await;
}
// ---------------------------------------------------------------------------
// Revision history
// ---------------------------------------------------------------------------
/// Upload a file, replace it twice, then walk the history: list → read an old
/// revision → restore it → delete a superseded one.
///
/// This is the only test that exercises revisions as a *history* rather than as
/// upload plumbing, so it covers `enumerate_revisions`, `get_revision`,
/// `download_revision`, `restore_revision` and `delete_revision` together.
#[tokio::test]
#[ignore = "live: needs test-account credentials"]
async fn revision_history_lists_reads_restores_and_deletes() {
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!("revisions-{}.txt", common::unique_suffix());
let v1 = b"first revision contents".to_vec();
let v2 = b"second revision contents, longer than the first".to_vec();
let v3 = b"third".to_vec();
let file = client
.upload_file(&root.uid, &name, "text/plain", &v1)
.await
.expect("upload_file");
client
.upload_new_revision(&file, &v2)
.await
.expect("upload second revision");
client
.upload_new_revision(&file, &v3)
.await
.expect("upload third revision");
// Three sealed revisions, exactly one of them active.
let revisions = client
.enumerate_revisions(&file)
.await
.expect("enumerate_revisions");
assert_eq!(revisions.len(), 3, "three uploads means three revisions");
let active: Vec<_> = revisions.iter().filter(|r| r.is_active()).collect();
assert_eq!(active.len(), 1, "exactly one revision may be active");
// The active one must describe the newest upload. `claimed_size` is the
// uploader's plaintext size, so it is comparable to what we wrote —
// `size_on_storage` is the ciphertext and is not.
let active = active[0];
assert_eq!(
active.claimed_size,
Some(v3.len() as i64),
"the active revision must claim the newest payload's size"
);
assert!(
active.size_on_storage > 0,
"a sealed revision occupies storage"
);
// Fetching one by id must agree with the listing.
let fetched = client
.get_revision(&file, &active.revision_id)
.await
.expect("get_revision")
.expect("the active revision must exist");
assert_eq!(fetched.revision_id, active.revision_id);
assert!(fetched.is_active());
assert_eq!(fetched.claimed_size, active.claimed_size);
// A malformed id is a caller bug, not a missing entity: the server rejects
// it with 400 `InvalidEncryptedIdFormat` and that must surface as an error,
// not be flattened into `None`. (The genuine not-found path is checked
// against a deleted-but-well-formed id at the end of this test.)
assert!(
client.get_revision(&file, "does-not-exist").await.is_err(),
"a malformed revision id must error rather than read as absent"
);
// Superseded revisions, oldest first, are the two earlier uploads.
let mut superseded: Vec<_> = revisions.iter().filter(|r| !r.is_active()).collect();
superseded.sort_by_key(|r| r.creation_time);
assert_eq!(superseded.len(), 2);
let oldest = superseded[0];
assert_eq!(
oldest.claimed_size,
Some(v1.len() as i64),
"the oldest revision must still claim the first payload's size"
);
// Reading a superseded revision must give back its own content, not the
// active one's — the whole point of keeping history.
let old_bytes = client
.download_revision(&file, &oldest.revision_id)
.await
.expect("download_revision");
assert_eq!(
old_bytes, v1,
"a superseded revision must read back verbatim"
);
assert_eq!(
client.download_file(&file).await.expect("download_file"),
v3,
"the file itself must still serve the active revision"
);
// Restoring does not move the pointer in place: it mints a *new* active
// revision carrying the old content. The server applies it asynchronously
// (HTTP 202), so poll rather than assume it is live on return.
client
.restore_revision(&file, &oldest.revision_id)
.await
.expect("restore_revision");
let mut restored = false;
for _ in 0..30 {
if client
.download_file(&file)
.await
.expect("download after restore")
== v1
{
restored = true;
break;
}
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
assert!(
restored,
"after restore the file must serve the restored content within 30s"
);
let after_restore = client
.enumerate_revisions(&file)
.await
.expect("enumerate after restore");
assert!(
after_restore.len() >= 3,
"restoring must not drop history, got {}",
after_restore.len()
);
assert_eq!(
after_restore.iter().filter(|r| r.is_active()).count(),
1,
"still exactly one active revision after restore"
);
// Deleting a superseded revision removes it and leaves the rest alone.
let victim = after_restore
.iter()
.find(|r| !r.is_active())
.expect("a superseded revision to delete");
client
.delete_revision(&file, &victim.revision_id)
.await
.expect("delete_revision");
let after_delete = client
.enumerate_revisions(&file)
.await
.expect("enumerate after delete");
assert_eq!(
after_delete.len(),
after_restore.len() - 1,
"exactly one revision must disappear"
);
assert!(
!after_delete
.iter()
.any(|r| r.revision_id == victim.revision_id),
"the deleted revision must be gone from the history"
);
assert_eq!(
client
.download_file(&file)
.await
.expect("download after delete"),
v1,
"deleting a superseded revision must not disturb the active one"
);
// Now the real not-found path: a well-formed id whose revision is gone.
assert!(
client
.get_revision(&file, &victim.revision_id)
.await
.expect("a deleted revision must not error")
.is_none(),
"a deleted revision must resolve to None"
);
cleanup(client, &[file]).await;
}
+1
View File
@@ -24,6 +24,7 @@ pub use encrypt::{
};
pub use errors::CryptoError;
pub use keys::{PrivateKey, decrypt_armored_with_keys};
pub use messages::decrypt_armored_with_password;
pub use srp::{DEFAULT_BIT_LENGTH, SrpProofs, SrpVerifier, generate_proofs, generate_verifier};
pub use verify::{PublicKey, VerificationKeyRing, VerificationStatus, verify_detached};
+70
View File
@@ -41,6 +41,28 @@ pub fn decrypt_binary(
read_plaintext(decrypted)
}
/// Decrypt an armored, **symmetrically** encrypted message (SKESK + SEIPD).
///
/// The counterpart of [`ContentKey::encrypt_with_password`](super::ContentKey::encrypt_with_password):
/// no PGP key is involved, only a passphrase. Proton uses this for a public
/// share link's `SharePassphrase`, where the passphrase is the bcrypt-derived
/// key password (see [`derive_key_passphrase`](super::derive_key_passphrase))
/// rather than the raw link password. The S2K parameters travel in the packet,
/// so they need not be agreed on out of band.
pub fn decrypt_armored_with_password(
armored: &str,
password: &[u8],
) -> Result<Vec<u8>, CryptoError> {
let (message, _headers) =
Message::from_string(armored).map_err(|e| CryptoError::Parse(format!("message: {e}")))?;
let decrypted = message
.decrypt_with_password(&Password::from(password))
.map_err(|e| CryptoError::Decrypt(e.to_string()))?;
read_plaintext(decrypted)
}
/// Decrypt an armored message, trying each candidate key until one succeeds.
///
/// Proton key tokens are encrypted to one of several user keys; we don't know
@@ -71,3 +93,51 @@ pub(crate) fn read_plaintext(mut message: Message<'_>) -> Result<Vec<u8>, Crypto
.as_data_vec()
.map_err(|e| CryptoError::Decrypt(format!("read literal: {e}")))
}
#[cfg(test)]
mod tests {
use super::decrypt_armored_with_password;
use crate::crypto::derive_key_passphrase;
use pgp::composed::MessageBuilder;
use pgp::crypto::sym::SymmetricKeyAlgorithm;
use pgp::types::{Password, StringToKey};
/// Build the shape Proton's `SharePassphrase` has: an armored, symmetrically
/// encrypted message whose passphrase is the bcrypt-derived key password.
fn encrypt_armored_with_password(plaintext: &[u8], password: &[u8]) -> String {
let mut rng = rand_08::thread_rng();
let s2k = StringToKey::new_default(&mut rng);
let mut builder = MessageBuilder::from_bytes(String::new(), plaintext.to_vec())
.seipd_v1(&mut rng, SymmetricKeyAlgorithm::AES256);
builder
.encrypt_with_password(s2k, &Password::from(password))
.expect("encrypt with password");
builder
.to_armored_string(&mut rng, Default::default())
.expect("armor")
}
#[test]
fn password_encrypted_message_round_trips() {
let password = b"link-password-and-custom".as_slice();
let salt = [7u8; 16];
let key_password = derive_key_passphrase(password, &salt).expect("derive");
let armored = encrypt_armored_with_password(b"the share passphrase", &key_password);
let plaintext = decrypt_armored_with_password(&armored, &key_password).expect("decrypt");
assert_eq!(plaintext, b"the share passphrase");
}
#[test]
fn the_wrong_password_does_not_decrypt() {
// A wrong *link* password derives a different key password, which is how
// a bad custom password surfaces if it ever gets past the SRP handshake.
let salt = [7u8; 16];
let right = derive_key_passphrase(b"correct", &salt).expect("derive");
let wrong = derive_key_passphrase(b"incorrect", &salt).expect("derive");
let armored = encrypt_armored_with_password(b"secret", &right);
assert!(decrypt_armored_with_password(&armored, &wrong).is_err());
}
}
+32
View File
@@ -444,6 +444,38 @@ struct SessionRefreshResponse {
/// Used by the SRP login flow (`auth/v4/info`, `auth/v4`), which runs before a
/// session exists. Mirrors the C# SDK's `BeginAsync`, which issues these calls
/// on a session-less `HttpClient`.
/// Issue a GET with no session, for endpoints reachable before authentication.
///
/// The public-link flow needs this: `drive/urls/{token}/info` opens the SRP
/// handshake and is by definition callable by a visitor who has no Proton
/// session at all.
pub async fn get_unauthenticated<T: DeserializeOwned>(
config: &ProtonClientConfiguration,
path: &str,
) -> Result<T> {
let http = reqwest::Client::builder()
.timeout(config.request_timeout)
.gzip(true)
.build()?;
let base_url = ensure_trailing_slash(&config.base_url);
let url = format!("{}{}", base_url, path.trim_start_matches('/'));
let response = send_retrying(&config.retry_policy, || {
let mut request = http
.get(&url)
.header(APP_VERSION_HEADER, &config.app_version)
.header(reqwest::header::ACCEPT, API_CONTENT_TYPE);
if !config.user_agent.is_empty() {
request = request.header(reqwest::header::USER_AGENT, &config.user_agent);
}
request
})
.await?;
parse_response(response).await
}
pub async fn post_unauthenticated<B: Serialize, T: DeserializeOwned>(
config: &ProtonClientConfiguration,
path: &str,