validate_node_name, albums, photo tags

This commit is contained in:
nvrl committed 2026-07-31 19:32:59 +02:00
1 parent db66211a4d
commit 3616f15cfc
10 files changed
+830 -43

No files matched your search

+2
View File
@@ -166,6 +166,8 @@ mod tests {
is_shared_publicly: false,
signature_email: None,
membership: None,
photo: None,
album: None,
verification: crate::node::NodeVerification::default(),
}
}
+389 -34
View File
@@ -44,35 +44,41 @@ use crate::crypto::{
};
use crate::devices::{Device, DeviceMetadata, DeviceType};
use crate::dtos::{
AcceptInvitationRequest, AggregateLinksResponse, BlockCreationRequest, BlockDto,
BlockUploadPreparationRequest, BlockUploadPreparationResponse, BlockUploadTarget,
BlockVerificationInputResponse, BlockVerifier, BookmarkShareUrlDto, BookmarksResponse,
CommonExtendedAttributes, ContextShareResponse, CreateBookmarkRequest, CreatePublicLinkRequest,
CreatePublicLinkResponse, CreateShareRequest, CreateShareResponse, DeviceCreationDeviceDto,
DeviceCreationLinkDto, DeviceCreationRequest, DeviceCreationResponse, DeviceCreationShareDto,
DeviceListResponse, DeviceUpdateRequest, DeviceUpdateShareDto, ExtendedAttributes,
ExternalInvitationDto, ExternalInvitationResponseDto, ExternalInvitationsResponse,
FileContentDigests, FileCreationRequest, FileCreationResponse, FindPhotoDuplicatesRequest,
FindPhotoDuplicatesResponse, FolderChildrenResponse, FolderCreationRequest,
FolderCreationResponse, InvitationDetailsResponse, InvitationsListResponse,
InviteEmailDetailsDto, InviteExternalUserRequest, InviteExternalUserResponse,
InviteProtonUserInvitationDto, InviteProtonUserRequest, InviteProtonUserResponse,
LatestVolumeEventResponse, LinkDetailsDto, LinkDetailsRequest, LinkDetailsResponse, LinkDto,
LinkType, ModulusResponse, MoveLinkRequest, MoveMultipleLinksItem, MoveMultipleLinksRequest,
MultipleLinksRequest, MyFilesShareResponse, NodeNameAvailabilityRequest,
NodeNameAvailabilityResponse, PhotosAttributesDto, RenameLinkRequest, RevisionConflict,
RevisionCreationRequest, RevisionCreationResponse, RevisionDto, RevisionListItemDto,
RevisionListResponse, RevisionMetadataResponse, RevisionResponse, RevisionUpdateRequest,
ShareInvitationDto, ShareInvitationsResponse, ShareMembersResponse, ShareMembershipSummaryDto,
ShareResponse, ShareTargetType, ShareUrlDto, ShareUrlsResponse, SharedByMeResponse,
AcceptInvitationRequest, AggregateLinksResponse, AlbumItemListResponse, AlbumListResponse,
BlockCreationRequest, BlockDto, BlockUploadPreparationRequest, BlockUploadPreparationResponse,
BlockUploadTarget, BlockVerificationInputResponse, BlockVerifier, BookmarkShareUrlDto,
BookmarksResponse, CommonExtendedAttributes, ContextShareResponse, CreateBookmarkRequest,
CreatePublicLinkRequest, CreatePublicLinkResponse, CreateShareRequest, CreateShareResponse,
DeviceCreationDeviceDto, DeviceCreationLinkDto, DeviceCreationRequest, DeviceCreationResponse,
DeviceCreationShareDto, DeviceListResponse, DeviceUpdateRequest, DeviceUpdateShareDto,
ExtendedAttributes, ExternalInvitationDto, ExternalInvitationResponseDto,
ExternalInvitationsResponse, FileContentDigests, FileCreationRequest, FileCreationResponse,
FindPhotoDuplicatesRequest, FindPhotoDuplicatesResponse, FolderChildrenResponse,
FolderCreationRequest, FolderCreationResponse, InvitationDetailsResponse,
InvitationsListResponse, InviteEmailDetailsDto, InviteExternalUserRequest,
InviteExternalUserResponse, InviteProtonUserInvitationDto, InviteProtonUserRequest,
InviteProtonUserResponse, LatestVolumeEventResponse, LinkDetailsDto, LinkDetailsRequest,
LinkDetailsResponse, LinkDto, LinkType, ModulusResponse, MoveLinkRequest,
MoveMultipleLinksItem, MoveMultipleLinksRequest, MultipleLinksRequest, MyFilesShareResponse,
NodeNameAvailabilityRequest, NodeNameAvailabilityResponse, PhotoTagsRequest,
PhotosAttributesDto, RenameLinkRequest, RevisionConflict, RevisionCreationRequest,
RevisionCreationResponse, RevisionDto, RevisionListItemDto, RevisionListResponse,
RevisionMetadataResponse, RevisionResponse, RevisionUpdateRequest, ShareInvitationDto,
ShareInvitationsResponse, ShareMembersResponse, ShareMembershipSummaryDto, ShareResponse,
ShareTargetType, ShareUrlDto, ShareUrlsResponse, SharedAlbumsResponse, SharedByMeResponse,
SharedWithMeResponse, SmallFileUploadMetadataRequest, SmallRevisionUploadMetadataRequest,
SmallUploadResponse, ThumbnailBlockListRequest, ThumbnailBlockListResponse,
ThumbnailCreationRequest, ThumbnailDto, TimelinePhotoListResponse, UpdatePermissionsRequest,
VolumeCreationRequest, VolumeEventDto, VolumeEventListResponse, VolumeTrashResponse,
};
use crate::events::{DriveEvent, DriveEventScopeId};
use crate::node::{FileThumbnail, Node, NodeKind, RevisionState, Thumbnail, ThumbnailType};
use crate::photos::{PhotoUploadMetadata, PhotosTimelineItem};
use crate::node::{
AlbumProperties, FileThumbnail, Node, NodeKind, PhotoProperties, RevisionState, Thumbnail,
ThumbnailType,
};
use crate::photos::{
AlbumItem, PhotoTag, PhotoTagsUpdate, PhotoUploadMetadata, PhotosTimelineItem,
};
use crate::revision::{
MAX_CONCURRENT_BLOCK_DOWNLOADS, Revision, RevisionReader, digest_and_decrypt_block_blocking,
};
@@ -178,6 +184,29 @@ enum NodeDetail {
/// `NodeOperations.GetAvailableNameAsync` `batchSize`).
const NAME_AVAILABILITY_BATCH: usize = 10;
/// Longest node name the API accepts (C# `NodeOperations.MaxNodeNameLength`).
const MAX_NODE_NAME_LENGTH: usize = 255;
/// Reject a caller-supplied node name before it is encrypted and sent.
///
/// C# `NodeOperations.ValidateNodeName` (itself mirroring the JS SDK's
/// `validateNodeName`), applied on create, rename and rename-during-move. The
/// length is counted in `char`s: C# counts UTF-16 code units, which differs only
/// for astral-plane characters, and neither matches the server's own byte
/// accounting — this is a client-side guard against obviously bad input, not the
/// authority.
fn validate_node_name(name: &str) -> Result<()> {
if name.is_empty() {
return Err(ProtonError::invalid_operation("Name must not be empty"));
}
if name.chars().count() > MAX_NODE_NAME_LENGTH {
return Err(ProtonError::invalid_operation(format!(
"Name must be {MAX_NODE_NAME_LENGTH} characters long at most"
)));
}
Ok(())
}
/// High-level Proton Drive client.
///
/// Holds an authenticated session plus an [`AccountClient`]. Because Proton's
@@ -1207,7 +1236,7 @@ impl ProtonDriveClient {
let link = detail.link;
let file = detail
.file
.or(detail.photo)
.or(detail.photo.map(|photo| photo.file))
.ok_or_else(|| ProtonError::invalid_operation(format!("node {uid} is not a file")))?;
let content_key_packet_b64 = file.content_key_packet.ok_or_else(|| {
@@ -1299,7 +1328,7 @@ impl ProtonDriveClient {
let link = detail.link;
let file = detail
.file
.or(detail.photo)
.or(detail.photo.map(|photo| photo.file))
.ok_or_else(|| ProtonError::invalid_operation(format!("node {uid} is not a file")))?;
let content_key_packet_b64 = file.content_key_packet.ok_or_else(|| {
@@ -1926,7 +1955,7 @@ impl ProtonDriveClient {
let link = detail.link;
let file = detail
.file
.or(detail.photo)
.or(detail.photo.map(|photo| photo.file))
.ok_or_else(|| ProtonError::invalid_operation(format!("node {uid} is not a file")))?;
let content_key_packet_b64 = file.content_key_packet.ok_or_else(|| {
@@ -2413,6 +2442,7 @@ impl ProtonDriveClient {
last_modification_time: Option<i64>,
) -> Result<NodeUid> {
let mut timer = self.telemetry.start("create_folder");
validate_node_name(name)?;
let volume_id = parent_uid.volume_id.clone();
// Resolve the parent folder key + hash key and the membership address.
@@ -2544,6 +2574,7 @@ impl ProtonDriveClient {
new_media_type: Option<&str>,
) -> Result<()> {
let mut timer = self.telemetry.start("rename_node");
validate_node_name(new_name)?;
let details = self
.get_link_details(&uid.volume_id, std::slice::from_ref(&uid.link_id))
.await?;
@@ -4959,13 +4990,18 @@ impl ProtonDriveClient {
for_photos,
)
.await?;
let folder = details
let detail = details
.links
.into_iter()
.next()
.and_then(|d| d.folder)
.ok_or_else(|| ProtonError::invalid_operation("parent node is not a folder"))?;
Ok(parent_key.decrypt_armored_message(&folder.hash_key)?)
// An album carries its hash key in the `Album` block instead of `Folder`
// (C# builds a synthetic `FolderDto` from `AlbumDto`), and album children
// are hashed under it exactly as a folder's are.
let folder = detail
.folder_properties()
.ok_or_else(|| ProtonError::invalid_operation("parent node is not a folder"))?;
Ok(parent_key.decrypt_armored_message(folder.hash_key)?)
}
/// Fetch the block verification code, validating that the returned content
@@ -5525,6 +5561,266 @@ impl ProtonDriveClient {
.collect())
}
/// Page the albums on the account's photos volume.
///
/// Mirrors C# `PhotosNodeOperations.EnumerateAlbumNodeUidsAsync`:
/// `GET photos/volumes/{vid}/albums`, paged on `AnchorID`. Empty when the
/// account has no photos volume (C# yields nothing rather than erroring).
pub(crate) async fn enumerate_album_node_uids(&self) -> Result<Vec<NodeUid>> {
let mut timer = self.telemetry.start("enumerate_album_node_uids");
if !self.ensure_photos().await? {
timer.success();
return Ok(Vec::new());
}
let volume_id = self
.cache
.lock()
.await
.photos_root
.clone()
.expect("ensure_photos populated the photos root")
.volume_id;
let mut uids = Vec::new();
let mut anchor: Option<LinkId> = None;
loop {
let path = match &anchor {
Some(anchor_id) => {
format!("photos/volumes/{volume_id}/albums?AnchorID={anchor_id}")
}
None => format!("photos/volumes/{volume_id}/albums"),
};
let page: AlbumListResponse = self.http.get(&path).await?;
for album in &page.albums {
uids.push(NodeUid::new(volume_id.clone(), album.id.clone()));
}
anchor = page.anchor_id.filter(|id| !id.as_str().is_empty());
if !page.more || anchor.is_none() {
break;
}
}
timer.success();
Ok(uids)
}
/// Page one album's photos, newest capture first.
///
/// Mirrors C# `PhotosNodeOperations.EnumerateAlbumAsync`:
/// `GET photos/volumes/{vid}/albums/{lid}/children?Sort=Captured&Desc=1`,
/// paged on `AnchorID`. The album's own volume is used, so an album shared
/// with us (on the sharer's photos volume) enumerates the same way.
pub(crate) async fn enumerate_album(&self, album_uid: &NodeUid) -> Result<Vec<AlbumItem>> {
let mut timer = self.telemetry.start("enumerate_album");
let mut items = Vec::new();
let mut anchor: Option<LinkId> = None;
loop {
let base = format!(
"photos/volumes/{}/albums/{}/children?Sort=Captured&Desc=1",
album_uid.volume_id, album_uid.link_id
);
let path = match &anchor {
Some(anchor_id) => format!("{base}&AnchorID={anchor_id}"),
None => base,
};
let page: AlbumItemListResponse = self.http.get(&path).await?;
for photo in &page.photos {
items.push(AlbumItem {
uid: NodeUid::new(album_uid.volume_id.clone(), photo.id.clone()),
capture_time: photo.capture_time,
});
}
anchor = page.anchor_id.filter(|id| !id.as_str().is_empty());
if !page.more || anchor.is_none() {
break;
}
}
timer.success();
Ok(items)
}
/// Page the albums other users share with us.
///
/// Mirrors C# `PhotoOperations.EnumerateSharedWithMeAlbumUidsAsync`:
/// `GET photos/albums/shared-with-me`, paged on `AnchorID`. Unlike
/// `v2/sharedwithme` each row carries its own `VolumeID` — a shared album
/// lives on the sharer's photos volume — and the listing is account-wide, so
/// it works even when *we* have no photos volume.
pub(crate) async fn enumerate_shared_with_me_album_uids(&self) -> Result<Vec<NodeUid>> {
let mut timer = self.telemetry.start("enumerate_shared_with_me_album_uids");
let mut uids = Vec::new();
let mut anchor: Option<LinkId> = None;
loop {
let path = match &anchor {
Some(anchor_id) => format!("photos/albums/shared-with-me?AnchorID={anchor_id}"),
None => "photos/albums/shared-with-me".to_string(),
};
let page: SharedAlbumsResponse = self.http.get(&path).await?;
for album in &page.albums {
uids.push(NodeUid::new(album.volume_id.clone(), album.link_id.clone()));
}
anchor = page.anchor_id.filter(|id| !id.as_str().is_empty());
if !page.more || anchor.is_none() {
break;
}
}
timer.success();
Ok(uids)
}
/// The photos and albums shared *with* us, from `v2/sharedwithme`.
///
/// The same listing [`enumerate_shared_with_me_node_uids`](Self::enumerate_shared_with_me_node_uids)
/// pages, kept to the target types the Photos client owns (C#
/// `ProtonPhotosClient.ShareTargetTypes` = Photo + Album). Exposed through
/// [`ProtonPhotosClient`](crate::ProtonPhotosClient).
pub(crate) async fn enumerate_photos_shared_with_me_node_uids(&self) -> Result<Vec<NodeUid>> {
let mut timer = self
.telemetry
.start("enumerate_photos_shared_with_me_node_uids");
let mut uids = Vec::new();
let mut anchor: Option<String> = None;
loop {
let path = match &anchor {
Some(anchor_id) => format!("v2/sharedwithme?AnchorID={anchor_id}"),
None => "v2/sharedwithme".to_string(),
};
let page: SharedWithMeResponse = self.http.get(&path).await?;
for link in &page.links {
if ShareTargetType::from_raw(link.share_target_type)
.is_some_and(ShareTargetType::is_photos_item)
{
uids.push(NodeUid::new(link.volume_id.clone(), link.link_id.clone()));
}
}
anchor = page.anchor_id.filter(|id| !id.is_empty());
if !page.more || anchor.is_none() {
break;
}
}
timer.success();
Ok(uids)
}
/// Apply tag additions/removals to photos.
///
/// Mirrors C# `PhotoOperations.UpdatePhotosAsync`: one outcome per input
/// update, in input order, so a photo that fails does not stop the others —
/// the outer `Err` is reserved for what makes the whole call impossible.
/// `Favorite` is special-cased exactly as upstream: it is *set* through
/// `POST photos/volumes/{vid}/links/{lid}/favorite` and only for photos on
/// our own photos volume, while every other tag (and every removal,
/// `Favorite` included) goes through the tags endpoints.
///
/// Favoriting a photo on someone else's volume needs the photo — and its
/// related photos — re-encrypted for our timeline root and sent as the
/// favorite request body (upstream `03b1cb7f`, `PhotoTransferPayloadBuilder`).
/// That is not ported, so such an update fails with an explicit error rather
/// than silently doing nothing.
pub(crate) async fn update_photos(
&self,
updates: &[PhotoTagsUpdate],
) -> Result<Vec<(NodeUid, Result<()>)>> {
let mut timer = self.telemetry.start("update_photos");
timer.attr("photo_count", updates.len());
if updates.is_empty() {
timer.success();
return Ok(Vec::new());
}
// Resolved once: the volume a bodyless favorite is valid on. `None` when
// the account has no photos volume, which makes every favorite fail below.
let own_photos_volume = if self.ensure_photos().await? {
self.cache
.lock()
.await
.photos_root
.clone()
.map(|root| root.volume_id)
} else {
None
};
let mut outcomes = Vec::with_capacity(updates.len());
for update in updates {
let outcome = self
.apply_tag_update(update, own_photos_volume.as_ref())
.await;
outcomes.push((update.node_uid.clone(), outcome));
}
timer.success();
Ok(outcomes)
}
/// One photo's tag update (C# `PhotoOperations.ApplyTagUpdateAsync`).
async fn apply_tag_update(
&self,
update: &PhotoTagsUpdate,
own_photos_volume: Option<&VolumeId>,
) -> Result<()> {
let volume_id = &update.node_uid.volume_id;
let link_id = &update.node_uid.link_id;
if update.tags_to_add.contains(&PhotoTag::Favorite) {
match own_photos_volume {
Some(own) if own == volume_id => {
let path = format!("photos/volumes/{volume_id}/links/{link_id}/favorite");
// An empty object, not `()`: serde renders the unit type as
// `null`, which the API rejects.
let _: proton_sdk::api::ApiResponse =
self.http.post(&path, &serde_json::json!({})).await?;
}
_ => {
return Err(ProtonError::invalid_operation(
"favoriting a photo that is not on this account's photos volume is not supported yet",
));
}
}
}
// `Favorite` has its own endpoint and is never sent as a plain tag.
let tags_to_add: Vec<i32> = update
.tags_to_add
.iter()
.filter(|&&tag| tag != PhotoTag::Favorite)
.map(|&tag| tag as i32)
.collect();
if !tags_to_add.is_empty() {
let path = format!("photos/volumes/{volume_id}/links/{link_id}/tags");
let _: proton_sdk::api::ApiResponse = self
.http
.post(&path, &PhotoTagsRequest { tags: tags_to_add })
.await?;
}
if !update.tags_to_remove.is_empty() {
let tags_to_remove: Vec<i32> = update
.tags_to_remove
.iter()
.map(|&tag| tag as i32)
.collect();
let path = format!("photos/volumes/{volume_id}/links/{link_id}/tags");
let _: proton_sdk::api::ApiResponse = self
.http
.delete_with_body(
&path,
&PhotoTagsRequest {
tags: tags_to_remove,
},
)
.await?;
}
Ok(())
}
/// Download and decrypt a photo's active revision into `output` (photos
/// routing). C# `PhotosFileDownloader`: the node is resolved via the photos
/// endpoint; blocks are fetched from their absolute storage URLs exactly as
@@ -5545,7 +5841,7 @@ impl ProtonDriveClient {
let link = detail.link;
let file = detail
.file
.or(detail.photo)
.or(detail.photo.map(|photo| photo.file))
.ok_or_else(|| ProtonError::invalid_operation(format!("node {uid} is not a file")))?;
let content_key_packet_b64 = file.content_key_packet.ok_or_else(|| {
@@ -5847,6 +6143,11 @@ impl ProtonDriveClient {
.clone()
.map(|pid| NodeUid::new(volume_id.clone(), pid));
// Album- and photo-only metadata, layered onto the folder/file node the
// way C# layers `AlbumNode : FolderNode` / `PhotoNode : FileNode`.
let mut album = None;
let mut photo = None;
let kind = match link.parsed_type() {
LinkType::Folder | LinkType::Album => {
let node_key = node_key.as_ref().ok_or_else(|| {
@@ -5858,6 +6159,15 @@ impl ProtonDriveClient {
.await
.folder_keys
.put(uid.clone(), node_key.clone());
album = details.album.as_ref().map(|album| AlbumProperties {
photo_count: album.photo_count,
cover_photo_uid: album
.cover_link_id
.clone()
.map(|link_id| NodeUid::new(volume_id.clone(), link_id)),
last_activity_time: (album.last_activity_time != 0)
.then_some(album.last_activity_time),
});
NodeKind::Folder
}
LinkType::File => {
@@ -5927,6 +6237,31 @@ impl ProtonDriveClient {
}
}
}
photo = details.photo.as_ref().map(|photo| PhotoProperties {
capture_time: photo.capture_time,
content_hash: photo.content_hash.clone(),
main_photo_uid: photo
.main_photo_link_id
.clone()
.map(|link_id| NodeUid::new(volume_id.clone(), link_id)),
related_photo_uids: photo
.related_photo_link_ids
.iter()
.map(|link_id| NodeUid::new(volume_id.clone(), link_id.clone()))
.collect(),
// An unknown tag discriminant is dropped: the server may add
// tags this SDK predates, and that must not fail the node.
tags: photo
.tags
.iter()
.filter_map(|&t| PhotoTag::from_raw(t))
.collect(),
album_uids: photo
.album_inclusions
.iter()
.map(|album| NodeUid::new(volume_id.clone(), album.album_link_id.clone()))
.collect(),
});
NodeKind::File {
media_type: file.media_type.clone(),
total_size_on_storage: file.total_size_on_storage,
@@ -5967,6 +6302,8 @@ impl ProtonDriveClient {
// Present only when the node is shared *with* us; it is what says
// whether we may write to it.
membership: details.membership.as_ref().map(share_membership_from_dto),
photo,
album,
verification,
};
@@ -6700,11 +7037,13 @@ fn drive_items(page: &SharedWithMeResponse) -> Vec<SharedWithMeItem> {
#[cfg(test)]
mod tests {
use super::{
CONTEXT_SHARE_CACHE_CAP, DriveCache, DriveEvent, FOLDER_KEY_CACHE_CAP, aggregate_outcomes,
alternate_names, assemble_manifest, check_aggregate, context_share_path, drive_items,
epoch_to_iso8601, is_expired_upload_token, is_listable_revision_state, is_upload_timeout,
join_node_path, path_segments, run_context_share_mutation, share_membership_from_dto,
CONTEXT_SHARE_CACHE_CAP, DriveCache, DriveEvent, FOLDER_KEY_CACHE_CAP,
MAX_NODE_NAME_LENGTH, aggregate_outcomes, alternate_names, assemble_manifest,
check_aggregate, context_share_path, drive_items, epoch_to_iso8601,
is_expired_upload_token, is_listable_revision_state, is_upload_timeout, join_node_path,
path_segments, run_context_share_mutation, share_membership_from_dto,
small_upload_applicable, take_block_target, take_thumbnail_target, to_drive_event,
validate_node_name,
};
use crate::dtos::{
AggregateLinksResponse, BlockUploadTarget, LinkIdResponsePair, ShareMembershipSummaryDto,
@@ -7138,6 +7477,22 @@ mod tests {
assert!(to_drive_event(&vid, &event(99, None)).is_err());
}
#[test]
fn node_names_are_validated_before_they_are_sent() {
// C# `NodeOperations.ValidateNodeName`: empty and over-long names are
// rejected client-side rather than round-tripping to a server error.
assert!(validate_node_name("report.txt").is_ok());
assert!(validate_node_name("").is_err());
let longest = "a".repeat(MAX_NODE_NAME_LENGTH);
assert!(validate_node_name(&longest).is_ok());
assert!(validate_node_name(&format!("{longest}a")).is_err());
// Counted in characters, not bytes: a 255-emoji name is 1020 bytes and
// still legal.
assert!(validate_node_name(&"🙂".repeat(MAX_NODE_NAME_LENGTH)).is_ok());
}
#[test]
fn alternate_names_keeps_extension() {
let got: Vec<String> = alternate_names("report.txt").take(3).collect();
+273 -4
View File
@@ -142,10 +142,15 @@ pub struct LinkDetailsDto {
#[serde(rename = "File")]
pub file: Option<FileDto>,
/// Photos-volume `/links` returns file properties under `Photo` (a superset
/// of `File`) rather than `File`. Deserialized as [`FileDto`]; the extra
/// photo fields are ignored. C# `linkDetailsDto.File ?? linkDetailsDto.Photo`.
/// of `File`) rather than `File`. C# `linkDetailsDto.File ?? linkDetailsDto.Photo`.
#[serde(rename = "Photo")]
pub photo: Option<FileDto>,
pub photo: Option<PhotoDto>,
/// Album properties, for a link of type [`LinkType::Album`]. An album
/// decrypts exactly like a folder — the block simply carries the hash key
/// under the same name plus the album-only metadata (C# `AlbumDto`, layered
/// onto a `FolderNode` by `DtoToMetadataConverter.ConvertAlbumMetadataAsync`).
#[serde(rename = "Album")]
pub album: Option<AlbumDto>,
/// Present when the node is shared (with members and/or via a public link).
#[serde(rename = "Sharing", default)]
pub sharing: Option<LinkSharingDto>,
@@ -180,8 +185,153 @@ impl LinkDetailsDto {
/// File properties for a file/photo node, preferring `File` and falling back
/// to the photos-volume `Photo` block.
pub fn file_properties(&self) -> Option<&FileDto> {
self.file.as_ref().or(self.photo.as_ref())
self.file
.as_ref()
.or_else(|| self.photo.as_ref().map(|photo| &photo.file))
}
/// Folder properties for a folder/album node. An album's hash key and
/// extended attributes live in the `Album` block, which is otherwise a
/// superset of `Folder` — C# builds a synthetic `FolderDto` from it.
pub fn folder_properties(&self) -> Option<FolderProperties<'_>> {
if let Some(folder) = self.folder.as_ref() {
return Some(FolderProperties {
hash_key: &folder.hash_key,
extended_attributes: folder.extended_attributes.as_deref(),
});
}
self.album.as_ref().map(|album| FolderProperties {
hash_key: &album.hash_key,
extended_attributes: album.extended_attributes.as_deref(),
})
}
}
/// The folder-shaped part of a `Folder` or `Album` block (C#
/// `ConvertAlbumMetadataAsync`'s synthetic `FolderDto`).
pub struct FolderProperties<'a> {
pub hash_key: &'a str,
pub extended_attributes: Option<&'a str>,
}
/// Photo properties on a photos-volume link (C# `PhotoDto : FileDto`).
#[derive(Debug, Deserialize)]
pub struct PhotoDto {
/// The file properties a photo shares with any other file.
#[serde(flatten)]
pub file: FileDto,
/// Capture time in seconds since the Unix epoch.
#[serde(rename = "CaptureTime", default)]
pub capture_time: i64,
/// Lowercase-hex HMAC of the plaintext SHA-1 under the photos root's hash
/// key — the second half of duplicate detection.
#[serde(rename = "ContentHash", default)]
pub content_hash: Option<String>,
/// Set when this photo is a *related* photo (live-photo video, burst
/// sibling): the link id of the main photo it belongs to.
#[serde(rename = "MainPhotoLinkID", default)]
pub main_photo_link_id: Option<LinkId>,
/// The related photos of this (main) photo.
#[serde(rename = "RelatedPhotosLinkIDs", default)]
pub related_photo_link_ids: Vec<LinkId>,
/// Classification tags as their `PhotoTag` discriminants; unknown values are
/// dropped when the node is built.
#[serde(rename = "Tags", default)]
pub tags: Vec<i32>,
/// The albums this photo has been added to.
#[serde(rename = "Albums", default)]
pub album_inclusions: Vec<PhotoAlbumInclusionDto>,
}
/// One album a photo belongs to (C# `PhotoAlbumInclusionDto`).
#[derive(Debug, Deserialize)]
pub struct PhotoAlbumInclusionDto {
#[serde(rename = "AlbumLinkID")]
pub album_link_id: LinkId,
#[serde(rename = "AddedTime", default)]
pub creation_time: i64,
}
/// Album properties on a photos-volume link (C# `AlbumDto`).
#[derive(Debug, Deserialize)]
pub struct AlbumDto {
/// The album's child-name hash key — same field a folder carries.
#[serde(rename = "NodeHashKey")]
pub hash_key: String,
#[serde(rename = "XAttr")]
pub extended_attributes: Option<String>,
#[serde(rename = "PhotoCount", default)]
pub photo_count: i32,
#[serde(rename = "CoverLinkID", default)]
pub cover_link_id: Option<LinkId>,
/// Epoch seconds of the last change to the album's contents.
#[serde(rename = "LastActivityTime", default)]
pub last_activity_time: i64,
}
/// `GET photos/volumes/{vid}/albums` — the albums on a photos volume, paged on
/// `AnchorID` (C# `AlbumListResponse`).
#[derive(Debug, Deserialize)]
pub struct AlbumListResponse {
#[serde(rename = "Albums", default)]
pub albums: Vec<AlbumListItemDto>,
#[serde(rename = "More", default)]
pub more: bool,
#[serde(rename = "AnchorID", default)]
pub anchor_id: Option<LinkId>,
}
#[derive(Debug, Deserialize)]
pub struct AlbumListItemDto {
#[serde(rename = "LinkID")]
pub id: LinkId,
}
/// `GET photos/volumes/{vid}/albums/{lid}/children` — an album's photos, newest
/// capture first, paged on `AnchorID` (C# `AlbumItemListResponse`).
#[derive(Debug, Deserialize)]
pub struct AlbumItemListResponse {
#[serde(rename = "Photos", default)]
pub photos: Vec<AlbumItemDto>,
#[serde(rename = "More", default)]
pub more: bool,
#[serde(rename = "AnchorID", default)]
pub anchor_id: Option<LinkId>,
}
#[derive(Debug, Deserialize)]
pub struct AlbumItemDto {
#[serde(rename = "LinkID")]
pub id: LinkId,
#[serde(rename = "CaptureTime", default)]
pub capture_time: i64,
}
/// `GET photos/albums/shared-with-me` — albums other users share with us. Unlike
/// `v2/sharedwithme` each row carries its own volume (C# `SharedAlbumsResponse`).
#[derive(Debug, Deserialize)]
pub struct SharedAlbumsResponse {
#[serde(rename = "Albums", default)]
pub albums: Vec<SharedAlbumDto>,
#[serde(rename = "More", default)]
pub more: bool,
#[serde(rename = "AnchorID", default)]
pub anchor_id: Option<LinkId>,
}
#[derive(Debug, Deserialize)]
pub struct SharedAlbumDto {
#[serde(rename = "VolumeID")]
pub volume_id: VolumeId,
#[serde(rename = "LinkID")]
pub link_id: LinkId,
}
/// `POST`/`DELETE photos/volumes/{vid}/links/{lid}/tags` (C# `PhotoTagsRequest`).
#[derive(Debug, Serialize)]
pub struct PhotoTagsRequest {
#[serde(rename = "Tags")]
pub tags: Vec<i32>,
}
#[derive(Debug, Clone, Deserialize)]
@@ -1136,6 +1286,12 @@ impl ShareTargetType {
pub fn is_drive_item(self) -> bool {
matches!(self, Self::Folder | Self::File | Self::ProtonVendor)
}
/// Whether the Photos client owns this kind (C#
/// `ProtonPhotosClient.ShareTargetTypes`).
pub fn is_photos_item(self) -> bool {
matches!(self, Self::Album | Self::Photo)
}
}
/// `GET devices` — the account's registered devices. C# `DeviceListResponse`.
@@ -1275,6 +1431,119 @@ mod tests {
assert_eq!(response.context_share_id, ShareId::new("context-share-1"));
}
#[test]
fn album_link_details_expose_folder_properties() {
// An album's hash key arrives in the `Album` block, not `Folder`; the
// rest of the client reads it through `folder_properties` (C# builds a
// synthetic `FolderDto` from `AlbumDto`).
let raw = r#"{
"Link": {
"LinkID": "album-1", "Type": 3, "ParentLinkID": "root", "State": 1,
"CreateTime": 1, "ModifyTime": 2, "Name": "enc", "NodeKey": "k",
"NodePassphrase": "p"
},
"Album": {
"NodeHashKey": "hash-key", "XAttr": "xattr", "PhotoCount": 7,
"CoverLinkID": "photo-9", "LastActivityTime": 1700000000
}
}"#;
let details: LinkDetailsDto = serde_json::from_str(raw).unwrap();
let album = details.album.as_ref().expect("album block");
assert_eq!(album.photo_count, 7);
assert_eq!(album.cover_link_id.as_ref().unwrap().as_str(), "photo-9");
assert_eq!(album.last_activity_time, 1_700_000_000);
let folder = details
.folder_properties()
.expect("album reads as a folder");
assert_eq!(folder.hash_key, "hash-key");
assert_eq!(folder.extended_attributes, Some("xattr"));
assert!(matches!(details.link.parsed_type(), LinkType::Album));
}
#[test]
fn photo_link_details_carry_file_and_photo_properties() {
// The photos volume returns file properties under `Photo` — a superset
// of `File` — so both halves have to come out of the one block.
let raw = r#"{
"Link": {
"LinkID": "photo-1", "Type": 2, "ParentLinkID": "root", "State": 1,
"CreateTime": 1, "ModifyTime": 2, "Name": "enc", "NodeKey": "k",
"NodePassphrase": "p"
},
"Photo": {
"MediaType": "image/jpeg", "TotalEncryptedSize": 42,
"ContentKeyPacket": "packet",
"CaptureTime": 1700000000, "ContentHash": "abcd",
"MainPhotoLinkID": "photo-main",
"RelatedPhotosLinkIDs": ["photo-2"],
"Tags": [0, 2, 99],
"Albums": [{ "AlbumLinkID": "album-1", "AddedTime": 1700000001 }]
}
}"#;
let details: LinkDetailsDto = serde_json::from_str(raw).unwrap();
let file = details.file_properties().expect("file properties");
assert_eq!(file.media_type, "image/jpeg");
assert_eq!(file.total_size_on_storage, 42);
let photo = details.photo.as_ref().expect("photo block");
assert_eq!(photo.capture_time, 1_700_000_000);
assert_eq!(photo.content_hash.as_deref(), Some("abcd"));
assert_eq!(
photo.main_photo_link_id.as_ref().unwrap().as_str(),
"photo-main"
);
assert_eq!(photo.related_photo_link_ids.len(), 1);
// Unknown discriminants survive the wire; they are dropped where the
// node is built, not here.
assert_eq!(photo.tags, [0, 2, 99]);
assert_eq!(photo.album_inclusions[0].album_link_id.as_str(), "album-1");
}
#[test]
fn album_listings_page_on_anchor() {
let albums: AlbumListResponse = serde_json::from_str(
r#"{"Albums":[{"LinkID":"album-1"}],"More":true,"AnchorID":"album-1"}"#,
)
.unwrap();
assert_eq!(albums.albums[0].id.as_str(), "album-1");
assert!(albums.more);
assert_eq!(albums.anchor_id.unwrap().as_str(), "album-1");
let items: AlbumItemListResponse = serde_json::from_str(
r#"{"Photos":[{"LinkID":"photo-1","CaptureTime":1700000000}],"More":false}"#,
)
.unwrap();
assert_eq!(items.photos[0].capture_time, 1_700_000_000);
assert!(!items.more);
assert!(items.anchor_id.is_none());
// Shared albums live on the sharer's volume, so each row carries one.
let shared: SharedAlbumsResponse = serde_json::from_str(
r#"{"Albums":[{"VolumeID":"vol-9","LinkID":"album-3"}],"More":false}"#,
)
.unwrap();
assert_eq!(shared.albums[0].volume_id.as_str(), "vol-9");
assert_eq!(shared.albums[0].link_id.as_str(), "album-3");
}
#[test]
fn only_photos_share_targets_are_photos_items() {
// C# `ProtonPhotosClient.ShareTargetTypes` — the complement of the Drive
// client's set, minus the root share, which is neither's.
for (raw, expected) in [
(0, false),
(1, false),
(2, false),
(3, true),
(4, true),
(5, false),
] {
let kind = ShareTargetType::from_raw(raw).expect("known target type");
assert_eq!(kind.is_photos_item(), expected, "target type {raw}");
}
}
#[test]
fn only_drive_share_targets_are_drive_items() {
// C# `SharingOperations.DriveShareTargetTypes`; albums and photos belong
+7 -2
View File
@@ -54,8 +54,13 @@ pub use event_manager::{
EventManagerConfig, MemoryCursorStore,
};
pub use events::{DriveEvent, DriveEventScopeId};
pub use node::{Node, NodeKind, RevisionState, Thumbnail, ThumbnailType};
pub use photos::{PhotoTag, PhotoUploadMetadata, PhotosTimelineItem, ProtonPhotosClient};
pub use node::{
AlbumProperties, Node, NodeKind, PhotoProperties, RevisionState, Thumbnail, ThumbnailType,
};
pub use photos::{
AlbumItem, PhotoTag, PhotoTagsUpdate, PhotoUploadMetadata, PhotosTimelineItem,
ProtonPhotosClient,
};
pub use proton_sdk::account::KeySalt;
pub use proton_sdk::cache::{CacheRepository, EncryptedCacheRepository, InMemoryCacheRepository};
pub use public_link::{ProtonDrivePublicLinkClient, PublicLinkInfo};
+46
View File
@@ -4,6 +4,7 @@ use proton_sdk::crypto::VerificationStatus;
use proton_sdk::ids::NodeUid;
use serde::{Deserialize, Serialize};
use crate::photos::PhotoTag;
use crate::sharing::ShareMembership;
/// A decrypted Drive node (folder or file).
@@ -37,6 +38,16 @@ pub struct Node {
/// one written before this field existed must keep deserializing.
#[serde(default)]
pub membership: Option<ShareMembership>,
/// Photo-only metadata, present on a photos-volume file node. C# layers this
/// as `PhotoNode : FileNode`; here it is an optional block so a `Node`
/// persisted before photos existed still deserializes.
#[serde(default)]
pub photo: Option<PhotoProperties>,
/// Album-only metadata, present on an album node. An album is otherwise a
/// folder (C# `AlbumNode : FolderNode`), so [`Node::kind`] stays
/// [`NodeKind::Folder`].
#[serde(default)]
pub album: Option<AlbumProperties>,
/// Per-field signature-verification results gathered while decrypting the
/// node. Non-fatal metadata (mirrors C# `AuthorshipVerificationFailure`):
/// the node is always returned; the caller inspects this to decide trust.
@@ -44,6 +55,39 @@ pub struct Node {
pub verification: NodeVerification,
}
/// Photo-only metadata on a photos-volume file node. Mirrors the fields C#
/// `PhotoNode` adds to `FileNode`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PhotoProperties {
/// Capture time in seconds since the Unix epoch.
pub capture_time: i64,
/// Lowercase-hex HMAC of the plaintext SHA-1 under the photos root's hash
/// key — what duplicate detection matches on. `None` on older photos.
pub content_hash: Option<String>,
/// Set when this is a *related* photo (live-photo video, burst sibling):
/// the main photo it belongs to.
pub main_photo_uid: Option<NodeUid>,
/// This (main) photo's related photos.
pub related_photo_uids: Vec<NodeUid>,
/// Classification tags. Values the server sends that this SDK does not know
/// are dropped rather than failing the node.
pub tags: Vec<PhotoTag>,
/// The albums this photo has been added to.
pub album_uids: Vec<NodeUid>,
}
/// Album-only metadata on an album node. Mirrors the fields C# `AlbumNode` adds
/// to `FolderNode`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AlbumProperties {
pub photo_count: i32,
/// The photo shown as the album's cover, when it has one.
pub cover_photo_uid: Option<NodeUid>,
/// Epoch seconds of the last change to the album's contents; `None` when the
/// server sends none.
pub last_activity_time: Option<i64>,
}
/// Outcome of verifying the signatures encountered while decrypting a node.
///
/// Each field carries the [`VerificationStatus`] of one signed artifact. The
@@ -310,6 +354,8 @@ mod tests {
membership_id: "member-1".into(),
permissions: 6,
}),
photo: None,
album: None,
verification: NodeVerification::default(),
};
+84
View File
@@ -46,6 +46,43 @@ pub enum PhotoTag {
Raw = 9,
}
impl PhotoTag {
/// Map a wire tag discriminant, or `None` for one this SDK does not know
/// (the server may add tags; an unknown one is dropped, not an error).
pub fn from_raw(value: i32) -> Option<Self> {
match value {
0 => Some(Self::Favorite),
1 => Some(Self::Screenshot),
2 => Some(Self::Video),
3 => Some(Self::LivePhoto),
4 => Some(Self::MotionPhoto),
5 => Some(Self::Selfie),
6 => Some(Self::Portrait),
7 => Some(Self::Burst),
8 => Some(Self::Panorama),
9 => Some(Self::Raw),
_ => None,
}
}
}
/// One photo's tag changes, applied by
/// [`ProtonPhotosClient::update_photos`]. Mirrors C# `PhotoTagsUpdate`.
#[derive(Debug, Clone)]
pub struct PhotoTagsUpdate {
pub node_uid: NodeUid,
pub tags_to_add: Vec<PhotoTag>,
pub tags_to_remove: Vec<PhotoTag>,
}
/// One entry of an album listing: a photo and its capture time (epoch seconds).
/// C# `AlbumItem(NodeUid Uid, DateTime CaptureTime)`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AlbumItem {
pub uid: NodeUid,
pub capture_time: i64,
}
/// Caller-supplied metadata for a photo upload. Mirrors C#
/// `PhotosFileUploadMetadata`: all fields are optional. `capture_time` defaults
/// to the upload time when unset; `main_photo_uid` links a related photo
@@ -220,6 +257,53 @@ impl ProtonPhotosClient {
pub async fn find_duplicates(&self, name: &str, contents: &[u8]) -> Result<Vec<NodeUid>> {
self.drive.find_photo_duplicates(name, contents).await
}
/// The albums on the account's photos volume, as [`NodeUid`]s.
/// C# `ProtonPhotosClient.EnumerateAlbumNodeUidsAsync`. Empty when the
/// account has no photos volume. Materialize with
/// [`enumerate_nodes`](Self::enumerate_nodes) — an album node is a folder
/// carrying [`Node::album`](crate::Node::album).
pub async fn enumerate_album_node_uids(&self) -> Result<Vec<NodeUid>> {
self.drive.enumerate_album_node_uids().await
}
/// The photos in `album_uid`, newest capture first.
/// C# `ProtonPhotosClient.EnumerateAlbumAsync`.
pub async fn enumerate_album(&self, album_uid: &NodeUid) -> Result<Vec<AlbumItem>> {
self.drive.enumerate_album(album_uid).await
}
/// The photos and albums other users share with us, as [`NodeUid`]s — the
/// photos counterpart of `ProtonDriveClient::enumerate_shared_with_me_node_uids`
/// (C# `ProtonPhotosClient.EnumerateSharedWithMeNodeUidsAsync`, which filters
/// `v2/sharedwithme` to the Photo/Album target types).
pub async fn enumerate_shared_with_me_node_uids(&self) -> Result<Vec<NodeUid>> {
self.drive.enumerate_photos_shared_with_me_node_uids().await
}
/// The albums shared with us, as [`NodeUid`]s, from the dedicated
/// `photos/albums/shared-with-me` listing (C#
/// `PhotoOperations.EnumerateSharedWithMeAlbumUidsAsync`). Each row carries
/// its own volume — these live on the sharer's photos volume.
pub async fn enumerate_shared_with_me_album_uids(&self) -> Result<Vec<NodeUid>> {
self.drive.enumerate_shared_with_me_album_uids().await
}
/// Add and/or remove classification tags on photos.
///
/// C# `ProtonPhotosClient.UpdatePhotosAsync`: one outcome per input update,
/// in input order — a photo that fails does not stop the others.
/// [`PhotoTag::Favorite`] is not a plain tag: it is set through the dedicated
/// `favorite` endpoint, and only for photos that already live on our own
/// photos volume. Favoriting a *shared* photo needs the photo re-encrypted
/// for our timeline root, which is not ported yet and fails that update.
/// Removing `Favorite` goes through the ordinary tag-removal endpoint.
pub async fn update_photos(
&self,
updates: &[PhotoTagsUpdate],
) -> Result<Vec<(NodeUid, Result<()>)>> {
self.drive.update_photos(updates).await
}
}
#[cfg(test)]
+8 -1
View File
@@ -556,6 +556,10 @@ impl ProtonDrivePublicLinkClient {
// A public-link visitor has no account membership — access comes
// from the link's own share password, not from a share member row.
membership: None,
// A public link points at a Drive folder or file; the photos volume
// is not reachable this way, so there is no photo/album metadata.
photo: None,
album: None,
verification: Default::default(),
})
}
@@ -567,7 +571,10 @@ impl ProtonDrivePublicLinkClient {
/// (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())
details
.file
.as_ref()
.or_else(|| details.photo.as_ref().map(|photo| &photo.file))
}
fn decode_base64(value: &str, what: &str) -> Result<Vec<u8>> {