//! Public, decrypted node model returned to callers. use proton_sdk::crypto::VerificationStatus; use proton_sdk::ids::{NodeUid, ShareId}; use serde::{Deserialize, Serialize}; use crate::photos::PhotoTag; use crate::sharing::{MemberRole, ShareMembership}; /// One node for [`move_nodes_streaming`](crate::ProtonDriveClient::move_nodes_streaming): /// which node, and the name it should carry afterwards. /// /// Mirrors C# `NodeMoveItem` minus its `CurrentParentUid` / `CurrentName`: this /// client reads both off the link it is about to move rather than trusting a /// caller's possibly stale copy. #[derive(Debug, Clone, PartialEq, Eq)] pub struct NodeMoveItem { pub uid: NodeUid, /// The name after the move; `None` keeps the current one. pub target_name: Option, } /// A decrypted Drive node (folder or file). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Node { pub uid: NodeUid, pub parent_uid: Option, pub kind: NodeKind, /// Decrypted node name. pub name: String, /// Creation time, epoch seconds. pub creation_time: i64, /// Last modification time, epoch seconds. pub modification_time: i64, pub trashed: bool, /// Whether the node is shared at all — with members, by public link, or both /// (C# `Node.IsShared`: the link details carry a `Sharing` block). #[serde(default)] pub is_shared: bool, /// Whether the node is shared by *public link* (C# `Node.IsSharedPublicly`: /// the `Sharing` block carries a `ShareURLID`). Implies [`Node::is_shared`]. #[serde(default)] pub is_shared_publicly: bool, /// Email address that signed the node, if present. pub signature_email: Option, /// Our membership in the share this node was reached through, when it is /// shared *with* us — this is what says whether we may write to it. `None` /// for nodes we own (the link details carry no `Membership` block). /// /// `#[serde(default)]` is load-bearing: consumers persist `Node` blobs, and /// one written before this field existed must keep deserializing. #[serde(default)] pub membership: Option, /// Our role on this node itself (C# `Node.DirectRole`): [`MemberRole::Admin`] /// on our own main or photos volume, the membership's role on a node shared /// directly with us, [`MemberRole::Inherited`] on anything below it. /// /// `None` on a node persisted before this field existed, and on nodes built /// without an account (a public-link visitor has no role to report). #[serde(default)] pub direct_role: Option, /// The share this node is the root of, when it is shared — the `Sharing` /// block's `ShareID` (JS `deprecatedShareId`, carried by C# since `63a7fc1e`). /// Kept for callers still addressing sharing by share rather than by node. #[serde(default)] pub share_id: Option, /// 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, /// 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, /// 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. #[serde(default)] 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, /// Set when this is a *related* photo (live-photo video, burst sibling): /// the main photo it belongs to. pub main_photo_uid: Option, /// This (main) photo's related photos. pub related_photo_uids: Vec, /// Classification tags. Values the server sends that this SDK does not know /// are dropped rather than failing the node. pub tags: Vec, /// The albums this photo has been added to. pub album_uids: Vec, /// Where the photo was taken, from the active revision's extended /// attributes. `None` when the uploader wrote no location, the attributes /// failed to decrypt, or the coordinates are not a valid position. #[serde(default)] pub location: Option, } /// A photo's position in WGS 84 decimal degrees, as the uploading client /// claimed it (`XAttr` `Location`). #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] pub struct PhotoLocation { pub latitude: f64, pub longitude: f64, } impl PhotoLocation { /// A position from claimed coordinates: both present, finite, and on the /// globe. Anything else is no position at all rather than a wrong one. pub fn from_claimed(latitude: Option, longitude: Option) -> Option { let (latitude, longitude) = (latitude?, longitude?); ((-90.0..=90.0).contains(&latitude) && (-180.0..=180.0).contains(&longitude)).then_some( Self { latitude, longitude, }, ) } } /// 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, /// Epoch seconds of the last change to the album's contents; `None` when the /// server sends none. pub last_activity_time: Option, } /// Outcome of verifying the signatures encountered while decrypting a node. /// /// Each field carries the [`VerificationStatus`] of one signed artifact. The /// file-only fields are `None` for folders (and when the artifact is absent). /// Mirrors the set of authorship checks C# `NodeCrypto` records. #[derive(Debug, Clone, Copy, Serialize, Deserialize)] pub struct NodeVerification { /// The node name (inline-signed to the parent key). pub name: VerificationStatus, /// The node passphrase (detached `NodePassphraseSignature`). pub passphrase: VerificationStatus, /// The file content key (`ContentKeyPacketSignature`); `None` for folders. pub content_key: Option, /// The active revision's extended attributes; `None` when absent. pub extended_attributes: Option, } impl Default for NodeVerification { fn default() -> Self { Self { name: VerificationStatus::NotSigned, passphrase: VerificationStatus::NotSigned, content_key: None, extended_attributes: None, } } } impl NodeVerification { /// Whether every signature that was present verified successfully. /// /// `NotSigned` is treated as acceptable (Proton metadata is not always /// signed); only `NoVerifier`/`Failed` count against trust. pub fn is_fully_verified(&self) -> bool { let ok = |s: VerificationStatus| { matches!(s, VerificationStatus::Ok | VerificationStatus::NotSigned) }; ok(self.name) && ok(self.passphrase) && self.content_key.is_none_or(ok) && self.extended_attributes.is_none_or(ok) } } /// The state of a file revision. Mirrors C# `Proton.Drive.Sdk.Nodes.RevisionState` /// (the wire `ApiRevisionState` also has a `Draft = 0` that never surfaces on a /// node's *active* revision). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum RevisionState { Active, Superseded, } impl RevisionState { /// Map the wire `ApiRevisionState`. A link's active revision is Active by /// definition, so an absent/draft state reads as [`RevisionState::Active`] /// (C# `DtoToMetadataConverter` hardcodes it). pub(crate) fn from_raw(value: Option) -> Self { match value { Some(2) => Self::Superseded, _ => Self::Active, } } } /// Folder- or file-specific node data. #[derive(Debug, Clone, Serialize, Deserialize)] pub enum NodeKind { Folder, File { media_type: String, /// Total encrypted size on cloud storage, in bytes. total_size_on_storage: i64, /// State of the file's active revision (C# `Revision.State`); `None` when /// the file has no active revision (an unsealed draft). #[serde(default)] active_revision_state: Option, /// Server-assigned identifier of the file's active revision /// (`ActiveRevision.RevisionID`); `None` for an unsealed draft, or when /// read through a surface that does not carry it. Unlike /// `modification_time` this is a stable revision identity: it advances iff /// a *new* revision was sealed, so a consumer can tell "the same revision, /// re-stamped" from "someone wrote a new revision" without a download. #[serde(default)] active_revision_id: Option, /// Authoritative plaintext size from the active revision's decrypted /// extended attributes (C# `ClaimedSize`). `None` when the revision has /// no `XAttr` or it failed to decrypt. claimed_size: Option, /// ISO-8601 modification time from the decrypted extended attributes /// (C# `ClaimedModificationTime`), verbatim as written by the uploader. claimed_modification_time: Option, /// Lowercase-hex SHA-1 of the full plaintext, from the active revision's /// decrypted extended attributes (`Digests.SHA1`). A download-free content /// fingerprint: two files with the same size *and* the same digest hold /// the same bytes. `None` when the revision carries no digest (some /// clients omit it) or the `XAttr` failed to decrypt. content_sha1: Option, }, } impl Node { pub fn is_folder(&self) -> bool { matches!(self.kind, NodeKind::Folder) } pub fn is_file(&self) -> bool { matches!(self.kind, NodeKind::File { .. }) } /// The event scope of this node's tree, keyed by its volume. /// C# `Node.TreeEventScopeId => new(Uid.VolumeId)`. pub fn tree_event_scope_id(&self) -> crate::DriveEventScopeId { crate::DriveEventScopeId::new(self.uid.volume_id.clone()) } } /// The kind of a thumbnail. Mirrors C# `Proton.Drive.Sdk.Nodes.ThumbnailType`. /// /// The discriminant is the wire `Type` value sent to the API and the key the /// download path sorts by when building the content manifest. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] #[repr(i32)] pub enum ThumbnailType { /// The small thumbnail (`Type = 1`). Thumbnail = 1, /// The larger preview image (`Type = 2`). Preview = 2, } impl ThumbnailType { /// The wire `Type` discriminant. pub fn as_i32(self) -> i32 { self as i32 } } /// A caller-supplied, already-rendered thumbnail to attach to an upload. /// /// The SDK does not generate thumbnails — the caller renders the image bytes /// (matching the C# SDK, where the platform supplies the bitmap). Mirrors C# /// `Proton.Drive.Sdk.Nodes.Thumbnail`. #[derive(Debug, Clone)] pub struct Thumbnail { pub thumbnail_type: ThumbnailType, /// The rendered image bytes (e.g. JPEG/WebP). Must be non-empty. pub content: Vec, } impl Thumbnail { /// Create a thumbnail from rendered image bytes. /// /// Returns an error if the `content` is empty. pub fn new( thumbnail_type: ThumbnailType, content: Vec, ) -> Result { if content.is_empty() { return Err(proton_sdk::error::ProtonError::invalid_operation( "Thumbnail content must not be empty.", )); } Ok(Self { thumbnail_type, content, }) } } /// The result of enumerating one file's thumbnail. Mirrors C# /// `Proton.Drive.Sdk.Nodes.FileThumbnail(NodeUid, Result)`: a /// per-file outcome so a batch enumeration can report partial failures (node /// missing, not a file, no thumbnail of the requested type, block download /// error) without aborting the whole batch. #[derive(Debug)] pub struct FileThumbnail { /// The file the thumbnail belongs to. pub file_uid: NodeUid, /// The decrypted thumbnail bytes, or the error encountered for this file. pub result: proton_sdk::error::Result>, } impl FileThumbnail { pub fn ok(file_uid: NodeUid, bytes: Vec) -> Self { Self { file_uid, result: Ok(bytes), } } pub fn err(file_uid: NodeUid, error: proton_sdk::error::ProtonError) -> Self { Self { file_uid, result: Err(error), } } } #[cfg(test)] mod tests { use super::*; #[test] fn file_node_carries_active_revision_id_and_defaults_it() { // A File node round-trips its revision id. let kind = NodeKind::File { media_type: "text/plain".into(), total_size_on_storage: 10, active_revision_state: Some(RevisionState::Active), active_revision_id: Some("rev-abc".into()), claimed_size: Some(4), claimed_modification_time: None, content_sha1: Some("da39a3ee".into()), }; let json = serde_json::to_string(&kind).unwrap(); let back: NodeKind = serde_json::from_str(&json).unwrap(); match back { NodeKind::File { active_revision_id, .. } => assert_eq!(active_revision_id.as_deref(), Some("rev-abc")), NodeKind::Folder => panic!("expected a file"), } // A sidecar written before the field existed deserializes to `None`. let legacy = r#"{"File":{"media_type":"text/plain","total_size_on_storage":10,"claimed_size":null,"claimed_modification_time":null}}"#; let back: NodeKind = serde_json::from_str(legacy).unwrap(); match back { NodeKind::File { active_revision_id, .. } => assert!(active_revision_id.is_none()), NodeKind::Folder => panic!("expected a file"), } } #[test] fn revision_state_maps_the_wire_value() { assert_eq!(RevisionState::from_raw(Some(2)), RevisionState::Superseded); assert_eq!(RevisionState::from_raw(Some(1)), RevisionState::Active); // A link's active revision is Active even when the server omits the // state (or still calls it a draft, as it does mid-upload). assert_eq!(RevisionState::from_raw(Some(0)), RevisionState::Active); assert_eq!(RevisionState::from_raw(None), RevisionState::Active); } /// Consumers persist whole `Node` blobs (the Linux client keeps them in a /// SQLite column), so a node written before `membership` existed has to keep /// deserializing — otherwise adding the field bricks every cached row on /// upgrade. #[test] fn a_node_round_trips_with_and_without_a_membership() { let node = Node { uid: NodeUid::new("vol1".into(), "link1".into()), parent_uid: None, kind: NodeKind::Folder, name: "Team Budget".into(), creation_time: 1, modification_time: 2, trashed: false, is_shared: true, is_shared_publicly: false, signature_email: None, membership: Some(ShareMembership { share_id: "share-1".into(), membership_id: "member-1".into(), permissions: 6, invite_time: Some(1_700_000_000), inviter_email: Some("owner@proton.me".into()), inviter_verification: Some(VerificationStatus::Ok), }), direct_role: Some(MemberRole::Editor), share_id: Some("share-1".into()), photo: None, album: None, verification: NodeVerification::default(), }; let back: Node = serde_json::from_str(&serde_json::to_string(&node).unwrap()).unwrap(); let membership = back.membership.expect("membership survives the round trip"); assert_eq!(membership.role_exact(), Some(crate::MemberRole::Editor)); assert_eq!(membership.share_id.as_str(), "share-1"); // A blob written before the field existed reads back as "not shared with // us", which is the same thing an owned node says. let legacy = r#"{ "uid": {"volume_id": "vol1", "link_id": "link1"}, "parent_uid": null, "kind": "Folder", "name": "My Documents", "creation_time": 1, "modification_time": 2, "trashed": false, "signature_email": null }"#; let back: Node = serde_json::from_str(legacy).unwrap(); assert!(back.membership.is_none()); assert_eq!(back.name, "My Documents"); } #[test] fn a_claimed_location_off_the_globe_is_no_location() { assert_eq!( PhotoLocation::from_claimed(Some(52.52), Some(13.405)), Some(PhotoLocation { latitude: 52.52, longitude: 13.405 }) ); assert_eq!(PhotoLocation::from_claimed(Some(52.52), None), None); assert_eq!(PhotoLocation::from_claimed(Some(91.0), Some(0.0)), None); assert_eq!(PhotoLocation::from_claimed(Some(0.0), Some(f64::NAN)), None); } #[test] fn photo_properties_cached_before_locations_still_parse() { let legacy = r#"{"capture_time":1,"content_hash":null,"main_photo_uid":null,"related_photo_uids":[],"tags":[],"album_uids":[]}"#; let back: PhotoProperties = serde_json::from_str(legacy).unwrap(); assert!(back.location.is_none()); } }