From ea59994250839204c6423f1700361d4cd9e5794d Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sat, 25 Jul 2026 12:48:41 +0200 Subject: [PATCH] implemented revisions --- Cargo.lock | 4 +- Cargo.toml | 2 +- crates/proton-drive-rs/Cargo.toml | 2 +- crates/proton-drive-rs/src/client.rs | 4 ++ crates/proton-drive-rs/src/node.rs | 46 +++++++++++++++++++++++ crates/proton-drive-rs/src/public_link.rs | 4 ++ 6 files changed, 58 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6fc1b22..6efb66d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2030,7 +2030,7 @@ dependencies = [ [[package]] name = "proton-drive-rs" -version = "0.2.1" +version = "0.2.2" dependencies = [ "async-trait", "base64", @@ -2055,7 +2055,7 @@ dependencies = [ [[package]] name = "proton-sdk" -version = "0.2.1" +version = "0.2.2" dependencies = [ "aes-gcm 0.11.0", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 0117da0..bc5a239 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/proton-sdk", "crates/proton-drive-rs"] [workspace.package] -version = "0.2.1" +version = "0.2.2" edition = "2024" license = "MIT" repository = "https://github.com/narrrl/proton-sdk-rs" diff --git a/crates/proton-drive-rs/Cargo.toml b/crates/proton-drive-rs/Cargo.toml index 6ba445b..d1bb15c 100644 --- a/crates/proton-drive-rs/Cargo.toml +++ b/crates/proton-drive-rs/Cargo.toml @@ -14,7 +14,7 @@ readme = "../../README.md" documentation = "https://docs.rs/proton-drive-rs" [dependencies] -proton-sdk = { path = "../proton-sdk", version = "0.2.0" } +proton-sdk = { path = "../proton-sdk", version = "0.2.2" } tokio.workspace = true reqwest.workspace = true serde.workspace = true diff --git a/crates/proton-drive-rs/src/client.rs b/crates/proton-drive-rs/src/client.rs index d79eef6..c196718 100644 --- a/crates/proton-drive-rs/src/client.rs +++ b/crates/proton-drive-rs/src/client.rs @@ -5770,6 +5770,7 @@ impl ProtonDriveClient { // leaves the claimed metadata absent rather than failing the node. let mut claimed_size = None; let mut claimed_modification_time = None; + let mut content_sha1 = None; if let Some(node_key) = node_key.as_ref() && let Some(rev) = file.active_revision.as_ref() && let Some(xattr) = rev.extended_attributes.as_deref() @@ -5787,6 +5788,7 @@ impl ProtonDriveClient { if let Some(common) = attrs.common { claimed_size = common.size; claimed_modification_time = common.modification_time; + content_sha1 = common.digests.and_then(|d| d.sha1); } } Err(e) => { @@ -5801,8 +5803,10 @@ impl ProtonDriveClient { .active_revision .as_ref() .map(|rev| RevisionState::from_raw(rev.state)), + active_revision_id: file.active_revision.as_ref().map(|rev| rev.id.clone()), claimed_size, claimed_modification_time, + content_sha1, } } LinkType::Unknown => { diff --git a/crates/proton-drive-rs/src/node.rs b/crates/proton-drive-rs/src/node.rs index 6b9ad38..4d8257a 100644 --- a/crates/proton-drive-rs/src/node.rs +++ b/crates/proton-drive-rs/src/node.rs @@ -111,6 +111,14 @@ pub enum NodeKind { /// 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. @@ -118,6 +126,12 @@ pub enum NodeKind { /// 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, }, } @@ -222,6 +236,38 @@ impl FileThumbnail { 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); diff --git a/crates/proton-drive-rs/src/public_link.rs b/crates/proton-drive-rs/src/public_link.rs index 81a6f66..b81787e 100644 --- a/crates/proton-drive-rs/src/public_link.rs +++ b/crates/proton-drive-rs/src/public_link.rs @@ -528,10 +528,14 @@ impl ProtonDrivePublicLinkClient { active_revision_state: file .and_then(|f| f.active_revision.as_ref()) .map(|revision| RevisionState::from_raw(revision.state)), + active_revision_id: file + .and_then(|f| f.active_revision.as_ref()) + .map(|revision| revision.id.clone()), // Claimed metadata lives in the revision's extended attributes, // which this read surface does not fetch. claimed_size: None, claimed_modification_time: None, + content_sha1: None, } };