mirror of
https://github.com/narrrl/proton-sdk-rs.git
synced 2026-11-03 16:41:26 +01:00
added device support + sharing
This commit is contained in:
1 parent
a24785d1a9
commit
7c9d16b405
9 files changed
+771
-25
No files matched your search
+8
-3
@@ -5,9 +5,13 @@ Tracks reconciliation of this pure-Rust port against the canonical **C#** SDK
|
||||
gitignored, so this file is the only durable record of what we last reviewed.
|
||||
|
||||
- **Upstream**: https://github.com/ProtonDriveApps/sdk
|
||||
- **Reconciled subtree**: `client/cs/sdk/src`
|
||||
- **Pinned**: `f2496161c2f704b72511aa1b804961285993850c`
|
||||
- **Date**: 2026-07-10
|
||||
- **Reconciled subtree**: `client/cs/src` (was `client/cs/sdk/src` before the
|
||||
mid-2026 projects reorg; the script watches both)
|
||||
- **Pinned**: `2219a42f018cce42d156379fbda2405034c7779a`
|
||||
- **Date**: 2026-07-14
|
||||
|
||||
> **Note**: upstream force-pushes — the SHAs in rows before 2026-07-14 no longer
|
||||
> exist in its history. Triage by commit subject/date when a pin goes stale.
|
||||
|
||||
## Workflow
|
||||
|
||||
@@ -24,3 +28,4 @@ gitignored, so this file is the only durable record of what we last reviewed.
|
||||
| 2026-06-25 | initial pin `fb4173ab` | — | baseline; delta reviewed, 0 behavioral changes outstanding |
|
||||
| 2026-06-26 | `fb4173ab` → `36430318` | `36430318` | ported: enumeration returns `NodeUid`s (`enumerate_folder_children_node_uids` / `enumerate_trash_node_uids`), caller materializes via `enumerate_nodes`. noise dropped: BOM/deps/kt-enum-order/cs-account-refactor |
|
||||
| 2026-07-10 | `36430318` → `f2496161` | `9a1c39b3` | ported: validation check in `Thumbnail::new` returning `Result` instead of panicking. noise dropped: projects reorg / C# Account client move to incubating. deferred: device support (`32a8eed0`). |
|
||||
| 2026-07-14 | `f2496161` → `2219a42f` | `d15fa9b9`, `bead8f65`, `f6b12911`, `c0940f52`, `b88b8041`, `2eb69c46` | upstream force-pushed + moved the cs subtree, so the old pin was dangling and the script silently reported "up to date" (both fixed). ported: **devices** (`devices.rs` + `enumerate_devices` / `create_device` / `rename_device` / `delete_device`; volume-free `DeviceUid`), **node sharing flags** (`Node::is_shared` / `is_shared_publicly` from the link `Sharing` block), **revision state** (`NodeKind::File::active_revision_state`), **shared-with-me** (`enumerate_shared_with_me_node_uids`, `GET v2/sharedwithme`), **leave shared node** (`leave_shared_node`, `DELETE v2/shares/{sid}/members/{mid}`). no-op: `15fa845e` (non-JSON error bodies — `http.rs` already falls back to a status-only error when the envelope won't parse). diverged deliberately: `9a7f1d02` + `b4dadf02` removed the C# entity cache and its tags; ours stays. cosmetic, not ported: `30ab32c1` / `388d3900` (C# field renames), `1a781ade` (buffer lengths). |
|
||||
@@ -162,6 +162,8 @@ mod tests {
|
||||
creation_time: 1,
|
||||
modification_time: 2,
|
||||
trashed: false,
|
||||
is_shared: false,
|
||||
is_shared_publicly: false,
|
||||
signature_email: None,
|
||||
verification: crate::node::NodeVerification::default(),
|
||||
}
|
||||
|
||||
@@ -18,7 +18,10 @@ use proton_sdk::crypto::{
|
||||
};
|
||||
use proton_sdk::error::{ProtonError, Result};
|
||||
use proton_sdk::http::ApiHttpClient;
|
||||
use proton_sdk::ids::{AddressId, DriveEventId, LinkId, NodeUid, ShareId, VolumeId};
|
||||
use proton_sdk::ids::{
|
||||
AddressId, AddressKeyId, DeviceUid, DriveEventId, LinkId, NodeUid, ShareId, ShareMembershipId,
|
||||
VolumeId,
|
||||
};
|
||||
use proton_sdk::session::ProtonApiSession;
|
||||
use proton_sdk::telemetry::{NoopTelemetry, Telemetry, TelemetryExt};
|
||||
|
||||
@@ -30,22 +33,26 @@ use crate::crypto::{
|
||||
decrypt_content_key_verified, decrypt_extended_attributes_verified, decrypt_link,
|
||||
decrypt_link_verified, decrypt_share_key,
|
||||
};
|
||||
use crate::devices::{Device, DeviceMetadata, DeviceType};
|
||||
use crate::dtos::{
|
||||
AggregateLinksResponse, BlockCreationRequest, BlockDto, BlockUploadPreparationRequest,
|
||||
BlockUploadPreparationResponse, BlockVerificationInputResponse, BlockVerifier,
|
||||
CommonExtendedAttributes, ExtendedAttributes, FileContentDigests, FileCreationRequest,
|
||||
FileCreationResponse, FolderChildrenResponse, FolderCreationRequest, FolderCreationResponse,
|
||||
LatestVolumeEventResponse, LinkDetailsDto, LinkDetailsRequest, LinkDetailsResponse, LinkDto,
|
||||
LinkType, MoveLinkRequest, MoveMultipleLinksItem, MoveMultipleLinksRequest,
|
||||
MultipleLinksRequest, MyFilesShareResponse, NodeNameAvailabilityRequest,
|
||||
NodeNameAvailabilityResponse, PhotosAttributesDto, RenameLinkRequest, RevisionCreationRequest,
|
||||
RevisionCreationResponse, RevisionDto, RevisionResponse, RevisionUpdateRequest,
|
||||
CommonExtendedAttributes, DeviceCreationDeviceDto, DeviceCreationLinkDto,
|
||||
DeviceCreationRequest, DeviceCreationResponse, DeviceCreationShareDto, DeviceListResponse,
|
||||
DeviceUpdateRequest, DeviceUpdateShareDto, ExtendedAttributes, FileContentDigests,
|
||||
FileCreationRequest, FileCreationResponse, FolderChildrenResponse, FolderCreationRequest,
|
||||
FolderCreationResponse, LatestVolumeEventResponse, LinkDetailsDto, LinkDetailsRequest,
|
||||
LinkDetailsResponse, LinkDto, LinkType, MoveLinkRequest, MoveMultipleLinksItem,
|
||||
MoveMultipleLinksRequest, MultipleLinksRequest, MyFilesShareResponse,
|
||||
NodeNameAvailabilityRequest, NodeNameAvailabilityResponse, PhotosAttributesDto,
|
||||
RenameLinkRequest, RevisionCreationRequest, RevisionCreationResponse, RevisionDto,
|
||||
RevisionResponse, RevisionUpdateRequest, ShareResponse, ShareTargetType, SharedWithMeResponse,
|
||||
ThumbnailBlockListRequest, ThumbnailBlockListResponse, ThumbnailCreationRequest, ThumbnailDto,
|
||||
TimelinePhotoListResponse, VolumeCreationRequest, VolumeEventDto, VolumeEventListResponse,
|
||||
VolumeTrashResponse,
|
||||
};
|
||||
use crate::events::{DriveEvent, DriveEventScopeId};
|
||||
use crate::node::{FileThumbnail, Node, NodeKind, Thumbnail, ThumbnailType};
|
||||
use crate::node::{FileThumbnail, Node, NodeKind, RevisionState, Thumbnail, ThumbnailType};
|
||||
use crate::photos::{PhotoUploadMetadata, PhotosTimelineItem};
|
||||
|
||||
/// Content blocks are 4 MiB of plaintext each (C# `RevisionWriter.DefaultBlockSize`).
|
||||
@@ -1555,8 +1562,294 @@ impl ProtonDriveClient {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The nodes other users share with us, as [`NodeUid`]s.
|
||||
///
|
||||
/// Mirrors C# `SharingOperations.EnumerateSharedWithMeNodeUidsAsync`: page
|
||||
/// `GET v2/sharedwithme` on its `AnchorID` cursor and keep only the target
|
||||
/// types the Drive client owns (folder / file / vendor) — albums and photos
|
||||
/// belong to the Photos client. Materialize the uids with
|
||||
/// [`enumerate_nodes`](Self::enumerate_nodes).
|
||||
pub async fn enumerate_shared_with_me_node_uids(&self) -> Result<Vec<NodeUid>> {
|
||||
let mut timer = self.telemetry.start("enumerate_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 {
|
||||
let is_drive_item = ShareTargetType::from_raw(link.share_target_type)
|
||||
.is_some_and(ShareTargetType::is_drive_item);
|
||||
if is_drive_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)
|
||||
}
|
||||
|
||||
/// Leave a node someone shared with us, giving up access to it.
|
||||
///
|
||||
/// Mirrors C# `SharingOperations.LeaveSharedNodeAsync`: read the node's link
|
||||
/// details, take our membership in the sharer's share, and delete it
|
||||
/// (`DELETE v2/shares/{sid}/members/{mid}`). Errors when the node is not
|
||||
/// shared with us — there is nothing to leave (C# throws `ValidationException`).
|
||||
pub async fn leave_shared_node(&self, uid: &NodeUid) -> Result<()> {
|
||||
let mut timer = self.telemetry.start("leave_shared_node");
|
||||
let details = self
|
||||
.get_link_details(&uid.volume_id, std::slice::from_ref(&uid.link_id))
|
||||
.await?;
|
||||
let membership = details
|
||||
.links
|
||||
.into_iter()
|
||||
.find(|detail| detail.link.id == uid.link_id)
|
||||
.and_then(|detail| detail.membership)
|
||||
.ok_or_else(|| {
|
||||
ProtonError::invalid_operation("you can leave only an item that is shared with you")
|
||||
})?;
|
||||
|
||||
self.remove_share_member(&membership.share_id, &membership.membership_id)
|
||||
.await?;
|
||||
timer.success();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The account's registered devices, with their root-folder names decrypted.
|
||||
///
|
||||
/// Mirrors C# `DeviceOperations.EnumerateDevicesAsync` (`GET devices` then
|
||||
/// resolve each root folder's name). A device's root folder lives in its own
|
||||
/// share, so the name is decrypted with that share's key rather than the My
|
||||
/// Files one; a per-device name failure is carried in [`Device::name`] rather
|
||||
/// than failing the enumeration. Each resolved root-folder key is cached, so
|
||||
/// enumerating a device's children works straight afterwards.
|
||||
pub async fn enumerate_devices(&self) -> Result<Vec<Device>> {
|
||||
let mut timer = self.telemetry.start("enumerate_devices");
|
||||
let metadata = self.device_metadata().await?;
|
||||
|
||||
let mut devices = Vec::with_capacity(metadata.len());
|
||||
for device in metadata {
|
||||
let name = self.device_root_folder_name(&device).await;
|
||||
devices.push(device.into_device(name));
|
||||
}
|
||||
|
||||
timer.success();
|
||||
Ok(devices)
|
||||
}
|
||||
|
||||
/// Register a new device with its own share and root folder.
|
||||
///
|
||||
/// Mirrors C# `DeviceOperations.CreateDeviceAsync` / `DeviceCrypto`: generate
|
||||
/// a share key and a root-folder key, wrap the share passphrase to the My
|
||||
/// Files membership address key, the folder passphrase to the share key, and
|
||||
/// the folder name + hash key to the share/folder keys, then `POST devices`.
|
||||
/// The crypto material is exactly a volume's root share + root folder, so it
|
||||
/// is built by the same `build_volume_creation_material` — with the device
|
||||
/// name in place of the root folder name. Live validation pending.
|
||||
pub async fn create_device(&self, name: &str, device_type: DeviceType) -> Result<Device> {
|
||||
let mut timer = self.telemetry.start("create_device");
|
||||
let root = self.get_my_files_folder().await?;
|
||||
let volume_id = root.uid.volume_id.clone();
|
||||
|
||||
let (address_id, _email, address_key) = self.membership_address().await?;
|
||||
let address_key_id = self.address_primary_key_id(&address_id).await?;
|
||||
|
||||
let material = build_volume_creation_material(&address_key, name)?;
|
||||
|
||||
let request = DeviceCreationRequest {
|
||||
device: DeviceCreationDeviceDto {
|
||||
device_type: device_type.as_i32(),
|
||||
sync_state: 0,
|
||||
},
|
||||
share: DeviceCreationShareDto {
|
||||
address_id,
|
||||
address_key_id,
|
||||
key: material.share_key_armored,
|
||||
passphrase: material.share_passphrase,
|
||||
passphrase_signature: material.share_passphrase_signature,
|
||||
},
|
||||
link: DeviceCreationLinkDto {
|
||||
name: material.folder_name,
|
||||
key: material.folder_key_armored,
|
||||
passphrase: material.folder_passphrase,
|
||||
passphrase_signature: material.folder_passphrase_signature,
|
||||
node_hash_key: material.folder_hash_key,
|
||||
},
|
||||
};
|
||||
|
||||
let created: DeviceCreationResponse = self.http.post("devices", &request).await?;
|
||||
|
||||
timer.success();
|
||||
Ok(Device {
|
||||
uid: created.device.id,
|
||||
device_type,
|
||||
name: Ok(name.to_string()),
|
||||
root_folder_uid: NodeUid::new(volume_id, created.device.root_link_id),
|
||||
creation_time: now_epoch_seconds(),
|
||||
last_sync_time: None,
|
||||
share_id: created.device.share_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Rename a device, i.e. rename its root folder.
|
||||
///
|
||||
/// Mirrors C# `DeviceOperations.RenameDeviceAsync`. A root node has no parent
|
||||
/// and no siblings, so — unlike [`rename_node`](Self::rename_node) — the name
|
||||
/// is encrypted to the device's *share* key and carries no name hash. Devices
|
||||
/// registered before the name moved onto the root folder also keep a copy on
|
||||
/// the share; that copy is cleared first (best-effort, as in C#).
|
||||
pub async fn rename_device(&self, device_uid: &DeviceUid, name: &str) -> Result<Device> {
|
||||
let mut timer = self.telemetry.start("rename_device");
|
||||
let device = self.device_metadata_by_uid(device_uid).await?;
|
||||
|
||||
if device.has_deprecated_name {
|
||||
let request = DeviceUpdateRequest {
|
||||
share: DeviceUpdateShareDto {
|
||||
name: String::new(),
|
||||
},
|
||||
};
|
||||
let path = format!("devices/{device_uid}");
|
||||
let removed: Result<proton_sdk::api::ApiResponse> =
|
||||
self.http.put(&path, &request).await;
|
||||
if let Err(e) = removed {
|
||||
tracing::warn!(device_uid = %device_uid, error = %e, "failed to remove deprecated device name");
|
||||
}
|
||||
}
|
||||
|
||||
let (share_key, membership_address_id) = self.share_key_by_id(&device.share_id).await?;
|
||||
let (_address_id, email, signing_key) = self
|
||||
.resolve_membership_address(membership_address_id)
|
||||
.await?;
|
||||
|
||||
let encrypted_name =
|
||||
share_key.encrypt_and_sign(&signing_key, name.as_bytes(), true, false)?;
|
||||
let request = RenameLinkRequest {
|
||||
name: encrypted_name,
|
||||
name_hash: String::new(),
|
||||
name_signature_email: email,
|
||||
media_type: None,
|
||||
original_hash: String::new(),
|
||||
};
|
||||
let path = format!(
|
||||
"v2/volumes/{}/links/{}/rename",
|
||||
device.root_folder_uid.volume_id, device.root_folder_uid.link_id
|
||||
);
|
||||
let _: proton_sdk::api::ApiResponse = self.http.put(&path, &request).await?;
|
||||
|
||||
timer.success();
|
||||
Ok(device.into_device(Ok(name.to_string())))
|
||||
}
|
||||
|
||||
/// Delete a device and its root folder (`DELETE devices/{uid}`).
|
||||
/// Mirrors C# `DeviceOperations.DeleteDeviceAsync`.
|
||||
pub async fn delete_device(&self, device_uid: &DeviceUid) -> Result<()> {
|
||||
let mut timer = self.telemetry.start("delete_device");
|
||||
let path = format!("devices/{device_uid}");
|
||||
let _: proton_sdk::api::ApiResponse = self.http.delete(&path).await?;
|
||||
timer.success();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- internals ---------------------------------------------------------
|
||||
|
||||
/// `DELETE v2/shares/{sid}/members/{mid}` — drop a membership from a share.
|
||||
/// C# `SharesApiClient.RemoveMemberAsync`.
|
||||
async fn remove_share_member(
|
||||
&self,
|
||||
share_id: &ShareId,
|
||||
membership_id: &ShareMembershipId,
|
||||
) -> Result<()> {
|
||||
let path = format!("v2/shares/{share_id}/members/{membership_id}");
|
||||
let _: proton_sdk::api::ApiResponse = self.http.delete(&path).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// All registered devices, without their (decrypted) names.
|
||||
/// C# `DeviceOperations.GetDeviceMetadataAsync`.
|
||||
async fn device_metadata(&self) -> Result<Vec<DeviceMetadata>> {
|
||||
let response: DeviceListResponse = self.http.get("devices").await?;
|
||||
|
||||
let mut devices = Vec::with_capacity(response.devices.len());
|
||||
for item in response.devices {
|
||||
devices.push(DeviceMetadata {
|
||||
uid: item.device.id,
|
||||
device_type: DeviceType::from_raw(item.device.device_type)?,
|
||||
root_folder_uid: NodeUid::new(item.device.volume_id, item.share.root_link_id),
|
||||
creation_time: item.device.creation_time,
|
||||
last_sync_time: item.device.last_sync_time,
|
||||
has_deprecated_name: item.share.name.is_some_and(|name| !name.is_empty()),
|
||||
share_id: item.share.id,
|
||||
});
|
||||
}
|
||||
Ok(devices)
|
||||
}
|
||||
|
||||
async fn device_metadata_by_uid(&self, device_uid: &DeviceUid) -> Result<DeviceMetadata> {
|
||||
self.device_metadata()
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|device| &device.uid == device_uid)
|
||||
.ok_or_else(|| ProtonError::invalid_operation(format!("device {device_uid} not found")))
|
||||
}
|
||||
|
||||
/// Decrypt a device's name: the name of its root folder, which is encrypted
|
||||
/// to the device's own share key. Caches the root-folder key so the device's
|
||||
/// children can be enumerated without re-resolving the share.
|
||||
async fn device_root_folder_name(&self, device: &DeviceMetadata) -> Result<String> {
|
||||
let (share_key, _address_id) = self.share_key_by_id(&device.share_id).await?;
|
||||
|
||||
let uid = &device.root_folder_uid;
|
||||
let details = self
|
||||
.get_link_details(&uid.volume_id, std::slice::from_ref(&uid.link_id))
|
||||
.await?;
|
||||
let link = &details
|
||||
.links
|
||||
.first()
|
||||
.ok_or_else(|| {
|
||||
ProtonError::invalid_operation(format!("device root folder {uid} not found"))
|
||||
})?
|
||||
.link;
|
||||
|
||||
let decrypted = decrypt_link(&share_key, link)?;
|
||||
self.cache
|
||||
.lock()
|
||||
.await
|
||||
.folder_keys
|
||||
.insert(uid.clone(), decrypted.node_key);
|
||||
Ok(decrypted.name)
|
||||
}
|
||||
|
||||
/// Fetch a share by id and unlock its key, returning it with the share's
|
||||
/// membership address id. C# `ShareOperations.GetShareAsync`.
|
||||
async fn share_key_by_id(&self, share_id: &ShareId) -> Result<(PrivateKey, AddressId)> {
|
||||
let path = format!("shares/{share_id}");
|
||||
let response: ShareResponse = self.http.get(&path).await?;
|
||||
let key = decrypt_share_key(&self.account, &response.share).await?;
|
||||
Ok((key, response.share.address_id))
|
||||
}
|
||||
|
||||
/// The `AddressKeyID` of an address's primary key (write requests that bind
|
||||
/// material to an address key need it alongside the `AddressID`).
|
||||
async fn address_primary_key_id(&self, address_id: &AddressId) -> Result<AddressKeyId> {
|
||||
self.account
|
||||
.addresses()
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|address| &address.id == address_id)
|
||||
.map(|address| address.primary_key_id)
|
||||
.ok_or_else(|| ProtonError::invalid_operation("membership address not found"))
|
||||
}
|
||||
|
||||
/// Create a fresh-file draft: generate a node key + content key, encrypt the
|
||||
/// name/passphrase/content-key-packet to the parent, and POST the draft.
|
||||
/// Mirrors C# `NewFileDraftProvider`.
|
||||
@@ -2625,6 +2918,10 @@ impl ProtonDriveClient {
|
||||
NodeKind::File {
|
||||
media_type: file.media_type.clone(),
|
||||
total_size_on_storage: file.total_size_on_storage,
|
||||
active_revision_state: file
|
||||
.active_revision
|
||||
.as_ref()
|
||||
.map(|rev| RevisionState::from_raw(rev.state)),
|
||||
claimed_size,
|
||||
claimed_modification_time,
|
||||
}
|
||||
@@ -2637,6 +2934,8 @@ impl ProtonDriveClient {
|
||||
}
|
||||
};
|
||||
|
||||
// C# `DtoToMetadataConverter`: the `Sharing` block's presence marks the
|
||||
// node as shared; a `ShareURLID` inside it marks it as publicly shared.
|
||||
let node = Node {
|
||||
uid: uid.clone(),
|
||||
parent_uid,
|
||||
@@ -2645,6 +2944,11 @@ impl ProtonDriveClient {
|
||||
creation_time: link.creation_time,
|
||||
modification_time: link.modification_time,
|
||||
trashed: link.is_trashed(),
|
||||
is_shared: details.sharing.is_some(),
|
||||
is_shared_publicly: details
|
||||
.sharing
|
||||
.as_ref()
|
||||
.is_some_and(|sharing| sharing.share_url_id.is_some()),
|
||||
signature_email: link.signature_email.clone(),
|
||||
verification,
|
||||
};
|
||||
@@ -2785,6 +3089,14 @@ fn is_my_files_missing(error: &ProtonError) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// The current Unix epoch, in seconds.
|
||||
fn now_epoch_seconds() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Format a Unix epoch (seconds, UTC) as an ISO-8601 `YYYY-MM-DDTHH:MM:SSZ`
|
||||
/// string for the `ExtendedAttributes.ModificationTime` field. C# writes the
|
||||
/// round-trip ("O") format; this drops the fractional-second component, which
|
||||
@@ -2904,12 +3216,7 @@ fn build_photos_attributes(
|
||||
written: &BlockWriteResult,
|
||||
metadata: &PhotoUploadMetadata,
|
||||
) -> PhotosAttributesDto {
|
||||
let capture_time = metadata.capture_time.unwrap_or_else(|| {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
});
|
||||
let capture_time = metadata.capture_time.unwrap_or_else(now_epoch_seconds);
|
||||
PhotosAttributesDto {
|
||||
capture_time,
|
||||
content_hash: hex::encode(hmac_sha256(parent_hash_key, written.sha1_hex.as_bytes())),
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
//! Devices: the synced folders a desktop client registers on the main volume.
|
||||
//!
|
||||
//! Mirrors C# `Proton.Drive.Sdk.Devices`. A device owns its own share and root
|
||||
//! folder inside the main volume; the device *name* is the name of that root
|
||||
//! folder (older devices kept it on the share instead — see
|
||||
//! [`Device::has_deprecated_name`] handling in `ProtonDriveClient::rename_device`).
|
||||
|
||||
use proton_sdk::error::{ProtonError, Result};
|
||||
use proton_sdk::ids::{DeviceUid, NodeUid, ShareId};
|
||||
|
||||
/// The platform a device runs on. Mirrors C# `DeviceType`; the discriminant is
|
||||
/// the wire value.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(i32)]
|
||||
pub enum DeviceType {
|
||||
Windows = 1,
|
||||
MacOs = 2,
|
||||
Linux = 3,
|
||||
}
|
||||
|
||||
impl DeviceType {
|
||||
pub fn as_i32(self) -> i32 {
|
||||
self as i32
|
||||
}
|
||||
|
||||
pub(crate) fn from_raw(value: i32) -> Result<Self> {
|
||||
match value {
|
||||
1 => Ok(Self::Windows),
|
||||
2 => Ok(Self::MacOs),
|
||||
3 => Ok(Self::Linux),
|
||||
other => Err(ProtonError::invalid_operation(format!(
|
||||
"unsupported device type {other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A registered device. Mirrors C# `Device`.
|
||||
///
|
||||
/// `name` is a `Result` because it is decrypted with the device's share key and
|
||||
/// may fail independently of the rest of the device (C# models it as
|
||||
/// `Result<string, ProtonDriveError>`): the device is still returned.
|
||||
#[derive(Debug)]
|
||||
pub struct Device {
|
||||
pub uid: DeviceUid,
|
||||
pub device_type: DeviceType,
|
||||
/// The decrypted device name, i.e. the name of its root folder.
|
||||
pub name: Result<String>,
|
||||
/// The device's root folder — the sync root.
|
||||
pub root_folder_uid: NodeUid,
|
||||
/// Creation time, epoch seconds.
|
||||
pub creation_time: i64,
|
||||
/// Last time the device synced, epoch seconds; `None` if it never did.
|
||||
pub last_sync_time: Option<i64>,
|
||||
/// The device's own share. Kept because a device root is not reachable from
|
||||
/// the My Files share key (C# `Device.ShareId`, deprecated there pending
|
||||
/// volume-based navigation, but still the only way to unlock the root).
|
||||
pub share_id: ShareId,
|
||||
}
|
||||
|
||||
/// A device without its (decrypted) name. Mirrors C# `DeviceMetadata`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct DeviceMetadata {
|
||||
pub uid: DeviceUid,
|
||||
pub device_type: DeviceType,
|
||||
pub root_folder_uid: NodeUid,
|
||||
pub creation_time: i64,
|
||||
pub last_sync_time: Option<i64>,
|
||||
/// The device name used to live on the share. Old devices still carry it
|
||||
/// there, and it must be removed when renaming.
|
||||
pub has_deprecated_name: bool,
|
||||
pub share_id: ShareId,
|
||||
}
|
||||
|
||||
impl DeviceMetadata {
|
||||
pub(crate) fn into_device(self, name: Result<String>) -> Device {
|
||||
Device {
|
||||
uid: self.uid,
|
||||
device_type: self.device_type,
|
||||
name,
|
||||
root_folder_uid: self.root_folder_uid,
|
||||
creation_time: self.creation_time,
|
||||
last_sync_time: self.last_sync_time,
|
||||
share_id: self.share_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn device_type_round_trips_through_the_wire_value() {
|
||||
for device_type in [DeviceType::Windows, DeviceType::MacOs, DeviceType::Linux] {
|
||||
assert_eq!(
|
||||
DeviceType::from_raw(device_type.as_i32()).expect("known device type"),
|
||||
device_type
|
||||
);
|
||||
}
|
||||
assert!(DeviceType::from_raw(0).is_err());
|
||||
assert!(DeviceType::from_raw(4).is_err());
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,9 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use proton_sdk::ids::{AddressId, AddressKeyId, DriveEventId, LinkId, ShareId, VolumeId};
|
||||
use proton_sdk::ids::{
|
||||
AddressId, AddressKeyId, DeviceUid, DriveEventId, LinkId, ShareId, ShareMembershipId, VolumeId,
|
||||
};
|
||||
|
||||
/// `GET v2/shares/my-files`
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -105,6 +107,34 @@ pub struct LinkDetailsDto {
|
||||
/// photo fields are ignored. C# `linkDetailsDto.File ?? linkDetailsDto.Photo`.
|
||||
#[serde(rename = "Photo")]
|
||||
pub photo: Option<FileDto>,
|
||||
/// Present when the node is shared (with members and/or via a public link).
|
||||
#[serde(rename = "Sharing", default)]
|
||||
pub sharing: Option<LinkSharingDto>,
|
||||
/// Present when the node is shared *with us*: our membership in the sharer's
|
||||
/// share. Carries the ids needed to leave the share.
|
||||
#[serde(rename = "Membership", default)]
|
||||
pub membership: Option<ShareMembershipSummaryDto>,
|
||||
}
|
||||
|
||||
/// The sharing state of a node (C# `LinkSharingDto`). Its mere presence means
|
||||
/// the node is shared; a `ShareURLID` means it is also shared by public link.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LinkSharingDto {
|
||||
#[serde(rename = "ShareID")]
|
||||
pub share_id: ShareId,
|
||||
#[serde(rename = "ShareURLID", default)]
|
||||
pub share_url_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Our membership in a share someone else owns (C# `ShareMembershipSummaryDto`).
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ShareMembershipSummaryDto {
|
||||
#[serde(rename = "ShareID")]
|
||||
pub share_id: ShareId,
|
||||
#[serde(rename = "MembershipID")]
|
||||
pub membership_id: ShareMembershipId,
|
||||
#[serde(rename = "Permissions", default)]
|
||||
pub permissions: i32,
|
||||
}
|
||||
|
||||
impl LinkDetailsDto {
|
||||
@@ -190,6 +220,11 @@ pub struct ActiveRevisionDto {
|
||||
pub id: String,
|
||||
#[serde(rename = "CreateTime")]
|
||||
pub creation_time: i64,
|
||||
/// Wire revision state (C# `ApiRevisionState`): 0 draft, 1 active, 2 obsolete.
|
||||
/// Absent on older responses; a link's active revision is Active by
|
||||
/// definition, which is what C# `DtoToMetadataConverter` records.
|
||||
#[serde(rename = "State", default)]
|
||||
pub state: Option<i32>,
|
||||
#[serde(rename = "EncryptedSize")]
|
||||
pub encrypted_size: i64,
|
||||
/// Email of the revision signer; empty/absent means the node key signed.
|
||||
@@ -863,3 +898,224 @@ pub struct VolumeEventLinkDto {
|
||||
#[serde(rename = "IsTrashed", default)]
|
||||
pub is_trashed: bool,
|
||||
}
|
||||
|
||||
/// `GET shares/{sid}` — a share and the material needed to unlock its key.
|
||||
/// C# `ShareResponse`; the share fields sit at the top level of the envelope, so
|
||||
/// they are flattened into the same [`ShareDto`] the my-files lookup returns.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ShareResponse {
|
||||
#[serde(flatten)]
|
||||
pub share: ShareDto,
|
||||
#[serde(rename = "VolumeID")]
|
||||
pub volume_id: VolumeId,
|
||||
#[serde(rename = "LinkID")]
|
||||
pub root_link_id: LinkId,
|
||||
}
|
||||
|
||||
/// `GET v2/sharedwithme` — one page of the items other users share with us.
|
||||
/// C# `SharedWithMeResponse`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SharedWithMeResponse {
|
||||
#[serde(rename = "Links", default)]
|
||||
pub links: Vec<SharedWithMeLinkDto>,
|
||||
/// Cursor for the next page.
|
||||
#[serde(rename = "AnchorID", default)]
|
||||
pub anchor_id: Option<String>,
|
||||
#[serde(rename = "More", default)]
|
||||
pub more: bool,
|
||||
}
|
||||
|
||||
/// One shared-with-me item. C# `SharedWithMeLinkDto`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SharedWithMeLinkDto {
|
||||
#[serde(rename = "VolumeID")]
|
||||
pub volume_id: VolumeId,
|
||||
#[serde(rename = "ShareID")]
|
||||
pub share_id: ShareId,
|
||||
#[serde(rename = "LinkID")]
|
||||
pub link_id: LinkId,
|
||||
/// What kind of item is shared. See [`ShareTargetType`].
|
||||
#[serde(rename = "ShareTargetType", default)]
|
||||
pub share_target_type: i32,
|
||||
}
|
||||
|
||||
/// The kind of item a share points at. C# `ShareTargetType`.
|
||||
///
|
||||
/// The Drive client exposes folders, files and vendor items; albums and photos
|
||||
/// belong to the Photos client (C# `SharingOperations.DriveShareTargetTypes`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(i32)]
|
||||
pub enum ShareTargetType {
|
||||
Root = 0,
|
||||
Folder = 1,
|
||||
File = 2,
|
||||
Album = 3,
|
||||
Photo = 4,
|
||||
ProtonVendor = 5,
|
||||
}
|
||||
|
||||
impl ShareTargetType {
|
||||
pub fn from_raw(value: i32) -> Option<Self> {
|
||||
match value {
|
||||
0 => Some(Self::Root),
|
||||
1 => Some(Self::Folder),
|
||||
2 => Some(Self::File),
|
||||
3 => Some(Self::Album),
|
||||
4 => Some(Self::Photo),
|
||||
5 => Some(Self::ProtonVendor),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the Drive client (rather than the Photos client) owns this kind.
|
||||
pub fn is_drive_item(self) -> bool {
|
||||
matches!(self, Self::Folder | Self::File | Self::ProtonVendor)
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET devices` — the account's registered devices. C# `DeviceListResponse`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DeviceListResponse {
|
||||
#[serde(rename = "Devices", default)]
|
||||
pub devices: Vec<DeviceListItemDto>,
|
||||
}
|
||||
|
||||
/// C# `DeviceListItemDto`: a device and the share holding its root folder.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DeviceListItemDto {
|
||||
#[serde(rename = "Device")]
|
||||
pub device: DeviceDataDto,
|
||||
#[serde(rename = "Share")]
|
||||
pub share: DeviceShareDataDto,
|
||||
}
|
||||
|
||||
/// C# `DeviceDataDto`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DeviceDataDto {
|
||||
#[serde(rename = "DeviceID")]
|
||||
pub id: DeviceUid,
|
||||
#[serde(rename = "VolumeID")]
|
||||
pub volume_id: VolumeId,
|
||||
/// `DeviceType`: 1 = Windows, 2 = macOS, 3 = Linux.
|
||||
#[serde(rename = "Type")]
|
||||
pub device_type: i32,
|
||||
#[serde(rename = "CreateTime")]
|
||||
pub creation_time: i64,
|
||||
#[serde(rename = "LastSyncTime", default)]
|
||||
pub last_sync_time: Option<i64>,
|
||||
}
|
||||
|
||||
/// C# `DeviceShareDataDto`. `Name` is the *deprecated* device name: it used to
|
||||
/// live on the share and must be cleared when renaming an old device.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DeviceShareDataDto {
|
||||
#[serde(rename = "ShareID")]
|
||||
pub id: ShareId,
|
||||
#[serde(rename = "LinkID")]
|
||||
pub root_link_id: LinkId,
|
||||
#[serde(rename = "Name", default)]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
/// `POST devices` — register a device with its own share and root folder.
|
||||
/// C# `DeviceCreationRequest`.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DeviceCreationRequest {
|
||||
#[serde(rename = "Device")]
|
||||
pub device: DeviceCreationDeviceDto,
|
||||
#[serde(rename = "Share")]
|
||||
pub share: DeviceCreationShareDto,
|
||||
#[serde(rename = "Link")]
|
||||
pub link: DeviceCreationLinkDto,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DeviceCreationDeviceDto {
|
||||
#[serde(rename = "Type")]
|
||||
pub device_type: i32,
|
||||
/// Synchronisation state; 0 (off) when registering a new device.
|
||||
#[serde(rename = "SyncState")]
|
||||
pub sync_state: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DeviceCreationShareDto {
|
||||
#[serde(rename = "AddressID")]
|
||||
pub address_id: AddressId,
|
||||
#[serde(rename = "AddressKeyID")]
|
||||
pub address_key_id: AddressKeyId,
|
||||
#[serde(rename = "Key")]
|
||||
pub key: String,
|
||||
#[serde(rename = "Passphrase")]
|
||||
pub passphrase: String,
|
||||
#[serde(rename = "PassphraseSignature")]
|
||||
pub passphrase_signature: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DeviceCreationLinkDto {
|
||||
#[serde(rename = "Name")]
|
||||
pub name: String,
|
||||
#[serde(rename = "NodeKey")]
|
||||
pub key: String,
|
||||
#[serde(rename = "NodePassphrase")]
|
||||
pub passphrase: String,
|
||||
#[serde(rename = "NodePassphraseSignature")]
|
||||
pub passphrase_signature: String,
|
||||
#[serde(rename = "NodeHashKey")]
|
||||
pub node_hash_key: String,
|
||||
}
|
||||
|
||||
/// `POST devices` response. C# `DeviceCreationResponse`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DeviceCreationResponse {
|
||||
#[serde(rename = "Device")]
|
||||
pub device: DeviceCreationResultDto,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DeviceCreationResultDto {
|
||||
#[serde(rename = "DeviceID")]
|
||||
pub id: DeviceUid,
|
||||
#[serde(rename = "ShareID")]
|
||||
pub share_id: ShareId,
|
||||
#[serde(rename = "LinkID")]
|
||||
pub root_link_id: LinkId,
|
||||
}
|
||||
|
||||
/// `PUT devices/{uid}` — only ever used to clear the deprecated share-held name.
|
||||
/// C# `DeviceUpdateRequest`.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DeviceUpdateRequest {
|
||||
#[serde(rename = "Share")]
|
||||
pub share: DeviceUpdateShareDto,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DeviceUpdateShareDto {
|
||||
#[serde(rename = "Name")]
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_drive_share_targets_are_drive_items() {
|
||||
// C# `SharingOperations.DriveShareTargetTypes`; albums and photos belong
|
||||
// to the Photos client, and a root share is not a shared item.
|
||||
for (raw, expected) in [
|
||||
(0, false),
|
||||
(1, true),
|
||||
(2, true),
|
||||
(3, false),
|
||||
(4, false),
|
||||
(5, true),
|
||||
] {
|
||||
let kind = ShareTargetType::from_raw(raw).expect("known target type");
|
||||
assert_eq!(kind.is_drive_item(), expected, "target type {raw}");
|
||||
}
|
||||
assert!(ShareTargetType::from_raw(6).is_none());
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@
|
||||
mod cache;
|
||||
mod client;
|
||||
mod crypto;
|
||||
mod devices;
|
||||
mod dtos;
|
||||
mod events;
|
||||
mod node;
|
||||
@@ -43,8 +44,9 @@ mod photos;
|
||||
|
||||
pub use cache::{CachedNodeInfo, DriveEntityCache};
|
||||
pub use client::ProtonDriveClient;
|
||||
pub use devices::{Device, DeviceType};
|
||||
pub use events::{DriveEvent, DriveEventScopeId};
|
||||
pub use node::{Node, NodeKind, Thumbnail, ThumbnailType};
|
||||
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};
|
||||
|
||||
@@ -17,6 +17,14 @@ pub struct Node {
|
||||
/// 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<String>,
|
||||
/// Per-field signature-verification results gathered while decrypting the
|
||||
@@ -70,6 +78,27 @@ impl NodeVerification {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<i32>) -> Self {
|
||||
match value {
|
||||
Some(2) => Self::Superseded,
|
||||
_ => Self::Active,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Folder- or file-specific node data.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum NodeKind {
|
||||
@@ -78,6 +107,10 @@ pub enum NodeKind {
|
||||
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<RevisionState>,
|
||||
/// 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.
|
||||
@@ -184,3 +217,18 @@ impl FileThumbnail {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,18 @@ string_id!(
|
||||
/// Identifies a Drive volume event; doubles as the enumeration cursor.
|
||||
DriveEventId
|
||||
);
|
||||
string_id!(
|
||||
/// Identifies a membership of an address in a Drive share.
|
||||
ShareMembershipId
|
||||
);
|
||||
string_id!(
|
||||
/// Identifies a Drive device (a synced folder registered by a desktop client).
|
||||
///
|
||||
/// Opaque and volume-free: the C# `DeviceUid` dropped its volume component
|
||||
/// (upstream `chore: remove volume id from device uid`), so it is a plain id
|
||||
/// rather than a `{volume}~{link}` pair like [`NodeUid`].
|
||||
DeviceUid
|
||||
);
|
||||
|
||||
/// Globally addresses a Drive node: a [`LinkId`] qualified by its [`VolumeId`].
|
||||
#[derive(Clone, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)]
|
||||
|
||||
@@ -16,7 +16,9 @@ set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
SDK="$ROOT/sdk"
|
||||
PIN_FILE="$ROOT/UPSTREAM_SYNC.md"
|
||||
SUBTREE="client/cs/sdk/src"
|
||||
# The C# sources moved from client/cs/sdk/src to client/cs/src in the mid-2026
|
||||
# projects reorg; watch both so ranges spanning the move stay complete.
|
||||
SUBTREE=(client/cs/src client/cs/sdk/src)
|
||||
REMOTE_REF="origin/main"
|
||||
|
||||
# noise = conventional-commit prefixes that never carry portable behavior
|
||||
@@ -40,14 +42,22 @@ PIN="$(grep -oE '[0-9a-f]{40}' "$PIN_FILE" | head -1)"
|
||||
echo "fetching upstream..." >&2
|
||||
git -C "$SDK" fetch --quiet origin
|
||||
|
||||
# Upstream force-pushes: a pin whose object is gone would make the log below
|
||||
# fail, which used to read as "up to date". Fail loudly instead.
|
||||
if ! git -C "$SDK" cat-file -e "${PIN}^{commit}" 2>/dev/null; then
|
||||
echo "error: pinned commit $PIN is not in the upstream history." >&2
|
||||
echo " upstream rewrote its history; re-pin from a commit date in the log." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
HEAD_SHA="$(git -C "$SDK" rev-parse --short "$REMOTE_REF")"
|
||||
echo "pinned: ${PIN:0:8}"
|
||||
echo "head: $HEAD_SHA ($REMOTE_REF)"
|
||||
echo "subtree: $SUBTREE"
|
||||
echo "subtree: ${SUBTREE[*]}"
|
||||
echo
|
||||
|
||||
RANGE="$PIN..$REMOTE_REF"
|
||||
commits="$(git -C "$SDK" log --oneline --no-decorate "$RANGE" -- "$SUBTREE" || true)"
|
||||
commits="$(git -C "$SDK" log --oneline --no-decorate "$RANGE" -- "${SUBTREE[@]}")"
|
||||
|
||||
if [ -z "$commits" ]; then
|
||||
echo "up to date — no cs commits since pin."
|
||||
@@ -74,11 +84,11 @@ echo "$relevant"
|
||||
|
||||
if $show_diffs; then
|
||||
echo
|
||||
echo "=== diffs (scoped to $SUBTREE) ==="
|
||||
echo "=== diffs (scoped to ${SUBTREE[*]}) ==="
|
||||
echo "$relevant" | awk '{print $1}' | while read -r sha; do
|
||||
echo
|
||||
echo "----- $sha -----"
|
||||
git -C "$SDK" show --stat --format="%H%n%an %ci%n%n %s%n" "$sha" -- "$SUBTREE"
|
||||
git -C "$SDK" show --stat --format="%H%n%an %ci%n%n %s%n" "$sha" -- "${SUBTREE[@]}"
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
Reference in new issue
Block a user