implemented shared files, folder uploads, caching

This commit is contained in:
nvrl committed 2026-07-16 01:18:03 +02:00
1 parent cc744d56c7
commit a38d0371ec
8 files changed
+4141 -129

No files matched your search

Generated
+147 -27
View File
@@ -19,6 +19,16 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "aead"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99"
dependencies = [
"crypto-common 0.2.2",
"inout 0.2.2",
]
[[package]] [[package]]
name = "aes" name = "aes"
version = "0.8.4" version = "0.8.4"
@@ -30,17 +40,42 @@ dependencies = [
"cpufeatures 0.2.17", "cpufeatures 0.2.17",
] ]
[[package]]
name = "aes"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138"
dependencies = [
"cipher 0.5.2",
"cpubits",
"cpufeatures 0.3.0",
]
[[package]] [[package]]
name = "aes-gcm" name = "aes-gcm"
version = "0.10.3" version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
dependencies = [ dependencies = [
"aead", "aead 0.5.2",
"aes", "aes 0.8.4",
"cipher 0.4.4", "cipher 0.4.4",
"ctr", "ctr 0.9.2",
"ghash", "ghash 0.5.1",
"subtle",
]
[[package]]
name = "aes-gcm"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdf011db2e21ce0d575593d749db5554b47fed37aff429e4dc50bc91ac93a028"
dependencies = [
"aead 0.6.1",
"aes 0.9.1",
"cipher 0.5.2",
"ctr 0.10.1",
"ghash 0.6.0",
"subtle", "subtle",
] ]
@@ -50,7 +85,7 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69fa2b352dcefb5f7f3a5fb840e02665d311d878955380515e4fd50095dd3d8c" checksum = "69fa2b352dcefb5f7f3a5fb840e02665d311d878955380515e4fd50095dd3d8c"
dependencies = [ dependencies = [
"aes", "aes 0.8.4",
] ]
[[package]] [[package]]
@@ -740,6 +775,17 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"rand_core 0.10.1",
]
[[package]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.45" version = "0.4.45"
@@ -769,6 +815,7 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
dependencies = [ dependencies = [
"block-buffer 0.12.1",
"crypto-common 0.2.2", "crypto-common 0.2.2",
"inout 0.2.2", "inout 0.2.2",
] ]
@@ -922,6 +969,12 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpubits"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
[[package]] [[package]]
name = "cpufeatures" name = "cpufeatures"
version = "0.2.17" version = "0.2.17"
@@ -1015,7 +1068,9 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [ dependencies = [
"getrandom 0.4.3",
"hybrid-array", "hybrid-array",
"rand_core 0.10.1",
] ]
[[package]] [[package]]
@@ -1027,6 +1082,15 @@ dependencies = [
"cipher 0.4.4", "cipher 0.4.4",
] ]
[[package]]
name = "ctr"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21"
dependencies = [
"cipher 0.5.2",
]
[[package]] [[package]]
name = "ctutils" name = "ctutils"
version = "0.4.2" version = "0.4.2"
@@ -1152,7 +1216,7 @@ version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6"
dependencies = [ dependencies = [
"aes", "aes 0.8.4",
"block-padding", "block-padding",
"cbc", "cbc",
"dbus", "dbus",
@@ -1331,10 +1395,10 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9954fabd903b82b9d7a68f65f97dc96dd9ad368e40ccc907a7c19d53e6bfac28" checksum = "9954fabd903b82b9d7a68f65f97dc96dd9ad368e40ccc907a7c19d53e6bfac28"
dependencies = [ dependencies = [
"aead", "aead 0.5.2",
"cipher 0.4.4", "cipher 0.4.4",
"cmac", "cmac",
"ctr", "ctr 0.9.2",
"subtle", "subtle",
] ]
@@ -1838,6 +1902,7 @@ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"r-efi 6.0.0", "r-efi 6.0.0",
"rand_core 0.10.1",
] ]
[[package]] [[package]]
@@ -1847,7 +1912,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
dependencies = [ dependencies = [
"opaque-debug", "opaque-debug",
"polyval", "polyval 0.6.2",
]
[[package]]
name = "ghash"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5"
dependencies = [
"polyval 0.7.2",
] ]
[[package]] [[package]]
@@ -2976,7 +3050,7 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
dependencies = [ dependencies = [
"num-bigint", "num-bigint 0.4.6",
"num-complex", "num-complex",
"num-integer", "num-integer",
"num-iter", "num-iter",
@@ -2994,6 +3068,16 @@ dependencies = [
"num-traits", "num-traits",
] ]
[[package]]
name = "num-bigint"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0"
dependencies = [
"num-integer",
"num-traits",
]
[[package]] [[package]]
name = "num-bigint-dig" name = "num-bigint-dig"
version = "0.8.6" version = "0.8.6"
@@ -3058,7 +3142,7 @@ version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [ dependencies = [
"num-bigint", "num-bigint 0.4.6",
"num-integer", "num-integer",
"num-traits", "num-traits",
] ]
@@ -3101,9 +3185,9 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c196e0276c471c843dd5777e7543a36a298a4be942a2a688d8111cd43390dedb" checksum = "c196e0276c471c843dd5777e7543a36a298a4be942a2a688d8111cd43390dedb"
dependencies = [ dependencies = [
"aead", "aead 0.5.2",
"cipher 0.4.4", "cipher 0.4.4",
"ctr", "ctr 0.9.2",
"subtle", "subtle",
] ]
@@ -3356,9 +3440,9 @@ version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1cfa4743b28656065ff4c0ba09e46b357a65e8c00fc2341e89084b82f87cbdf1" checksum = "1cfa4743b28656065ff4c0ba09e46b357a65e8c00fc2341e89084b82f87cbdf1"
dependencies = [ dependencies = [
"aead", "aead 0.5.2",
"aes", "aes 0.8.4",
"aes-gcm", "aes-gcm 0.10.3",
"aes-kw", "aes-kw",
"argon2", "argon2",
"base64", "base64",
@@ -3500,7 +3584,18 @@ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures 0.2.17", "cpufeatures 0.2.17",
"opaque-debug", "opaque-debug",
"universal-hash", "universal-hash 0.5.1",
]
[[package]]
name = "polyval"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b20f20e954175de5f463f67781b35583397d916b1d148738923711b2ad16bee8"
dependencies = [
"cpubits",
"cpufeatures 0.3.0",
"universal-hash 0.6.1",
] ]
[[package]] [[package]]
@@ -3569,14 +3664,13 @@ dependencies = [
[[package]] [[package]]
name = "proton-drive-rs" name = "proton-drive-rs"
version = "0.1.5" version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6862368afe7da79766f4d86845ba0ad7cdfc619ef6d962aeb45ac444612dc42"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"base64", "base64",
"bytes", "bytes",
"futures", "futures",
"getrandom 0.4.3",
"hex", "hex",
"hmac 0.13.0", "hmac 0.13.0",
"pgp", "pgp",
@@ -3594,24 +3688,23 @@ dependencies = [
[[package]] [[package]]
name = "proton-sdk" name = "proton-sdk"
version = "0.1.5" version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fa00fcf9c1ad4866ab8bc145295efe12e518232ab7cebb6cf2bf0caa4f7e3da"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm 0.11.0",
"async-trait", "async-trait",
"base64", "base64",
"bcrypt", "bcrypt",
"bytes", "bytes",
"chrono", "chrono",
"futures", "futures",
"getrandom 0.2.17", "getrandom 0.4.3",
"hex", "hex",
"hkdf 0.13.0", "hkdf 0.13.0",
"hmac 0.13.0", "hmac 0.13.0",
"num-bigint", "num-bigint 0.5.1",
"num-traits", "num-traits",
"pgp", "pgp",
"rand 0.10.2",
"rand 0.8.6", "rand 0.8.6",
"reqwest", "reqwest",
"serde", "serde",
@@ -3762,6 +3855,17 @@ dependencies = [
"rand_core 0.9.5", "rand_core 0.9.5",
] ]
[[package]]
name = "rand"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [
"chacha20",
"getrandom 0.4.3",
"rand_core 0.10.1",
]
[[package]] [[package]]
name = "rand_chacha" name = "rand_chacha"
version = "0.3.1" version = "0.3.1"
@@ -3800,6 +3904,12 @@ dependencies = [
"getrandom 0.3.4", "getrandom 0.3.4",
] ]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]] [[package]]
name = "rav1e" name = "rav1e"
version = "0.8.1" version = "0.8.1"
@@ -4238,7 +4348,7 @@ version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4"
dependencies = [ dependencies = [
"aes", "aes 0.8.4",
"cbc", "cbc",
"futures-util", "futures-util",
"generic-array", "generic-array",
@@ -5012,6 +5122,16 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "universal-hash"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96"
dependencies = [
"crypto-common 0.2.2",
"ctutils",
]
[[package]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.9.0" version = "0.9.0"
+8 -2
View File
@@ -10,8 +10,8 @@ authors = ["Nils Pukropp <contact@narl.io>"]
rust-version = "1.96" rust-version = "1.96"
[workspace.dependencies] [workspace.dependencies]
proton-sdk = "0.1.5" proton-sdk = "0.1.6"
proton-drive-rs = "0.1.5" proton-drive-rs = "0.1.6"
# Internal crates. # Internal crates.
pdfs-core = { path = "crates/pdfs-core" } pdfs-core = { path = "crates/pdfs-core" }
@@ -37,3 +37,9 @@ clap = { version = "4", features = ["derive"] }
rpassword = "7" rpassword = "7"
fuser = "0.17" fuser = "0.17"
libc = "0.2" libc = "0.2"
# Development override: build against the local SDK checkout until 0.1.6 is
# published to crates.io. Remove this block to consume the released crate.
[patch.crates-io]
proton-sdk = { path = "../proton-sdk-rs/crates/proton-sdk" }
proton-drive-rs = { path = "../proton-sdk-rs/crates/proton-drive-rs" }
+501 -16
View File
@@ -10,7 +10,7 @@ use clap::{Parser, Subcommand};
use pdfs_core::auth; use pdfs_core::auth;
use pdfs_core::cache::ContentCache; use pdfs_core::cache::ContentCache;
use pdfs_core::config::AppDirs; use pdfs_core::config::AppDirs;
use pdfs_core::control::{Request as CtlRequest, Response as CtlResponse}; use pdfs_core::control::{Request as CtlRequest, Response as CtlResponse, ShareEntryKind};
use pdfs_core::db::Db; use pdfs_core::db::Db;
#[derive(Parser)] #[derive(Parser)]
@@ -113,11 +113,15 @@ enum Command {
/// New folder name (a single path component). /// New folder name (a single path component).
name: String, name: String,
}, },
/// Upload a local file into a Drive folder via the running daemon. /// Upload local files and/or folders into a Drive folder via the running
/// daemon. Folders are uploaded recursively. The daemon uploads in the
/// background; watch progress with `pdfs transfers`.
Upload { Upload {
/// Local file to upload. /// Local files and/or folders to upload.
file: PathBuf, #[arg(required = true)]
/// Destination folder path, inside the mountpoint or relative to it. sources: Vec<PathBuf>,
/// Destination folder, inside the mountpoint or relative to it.
#[arg(short = 't', long = "to", default_value = ".")]
parent: PathBuf, parent: PathBuf,
}, },
/// Show the daemon's in-flight transfers (active uploads/downloads). /// Show the daemon's in-flight transfers (active uploads/downloads).
@@ -139,6 +143,163 @@ enum Command {
}, },
/// Permanently delete everything in the trash. This cannot be undone. /// Permanently delete everything in the trash. This cannot be undone.
EmptyTrash, EmptyTrash,
/// Manage the account's registered devices.
Devices {
#[command(subcommand)]
action: DeviceCmd,
},
/// Share a file or folder with Proton and/or external email addresses.
Share {
/// File/folder path, inside the mountpoint or relative to it.
path: PathBuf,
/// One or more invitee emails (Proton or external, auto-detected).
#[arg(required = true)]
emails: Vec<String>,
/// Role to grant: viewer, editor or admin.
#[arg(long, default_value = "viewer")]
role: String,
/// Optional message included in the invitation email.
#[arg(long)]
message: Option<String>,
},
/// List a node's members, pending invitations and public link.
Members {
/// File/folder path, inside the mountpoint or relative to it.
path: PathBuf,
},
/// Change the role of a member or pending invitation on a node's share.
ShareRole {
/// File/folder path, inside the mountpoint or relative to it.
path: PathBuf,
/// Entry id (membership or invitation id), from `pdfs members`.
id: String,
/// Entry kind: member, proton or external.
kind: ShareKindArg,
/// New role: viewer, editor or admin.
role: String,
},
/// Remove a member or pending invitation from a node's share.
Unshare {
/// File/folder path, inside the mountpoint or relative to it.
path: PathBuf,
/// Entry id (membership or invitation id), from `pdfs members`.
id: String,
/// Entry kind: member, proton or external.
kind: ShareKindArg,
},
/// Manage a node's public link.
PublicLink {
#[command(subcommand)]
action: PublicLinkCmd,
},
/// List the items I have shared with others (members, invites or a link).
Shared,
/// List nodes shared with me that I have accepted.
SharedWithMe,
/// Leave a shared node by its uid (from `pdfs shared-with-me`).
Leave {
/// Node uid in `volume~link` form.
uid: String,
},
/// Manage invitations addressed to me.
Invitations {
#[command(subcommand)]
action: InvitationCmd,
},
/// Manage saved public-link bookmarks.
Bookmarks {
#[command(subcommand)]
action: BookmarkCmd,
},
/// Show the daemon's recent activity, newest first.
Activity {
/// Maximum entries to show.
#[arg(short, long, default_value_t = 50)]
limit: usize,
},
}
/// Which collection a share entry lives in, for `share-role` / `unshare`.
#[derive(Clone, Copy, clap::ValueEnum)]
enum ShareKindArg {
/// An accepted member.
Member,
/// A pending invitation to a Proton user.
Proton,
/// A pending invitation to a non-Proton email.
External,
}
impl ShareKindArg {
fn to_kind(self) -> ShareEntryKind {
match self {
ShareKindArg::Member => ShareEntryKind::Member,
ShareKindArg::Proton => ShareEntryKind::ProtonInvite,
ShareKindArg::External => ShareEntryKind::ExternalInvite,
}
}
}
#[derive(Subcommand)]
enum DeviceCmd {
/// List registered devices.
List,
/// Rename a device by uid.
Rename { uid: String, name: String },
/// Delete (deregister) a device by uid.
Rm { uid: String },
}
#[derive(Subcommand)]
enum PublicLinkCmd {
/// Create a public link on a node.
Create {
/// File/folder path, inside the mountpoint or relative to it.
path: PathBuf,
/// Role: viewer or editor.
#[arg(long, default_value = "viewer")]
role: String,
/// Optional custom password protecting the link.
#[arg(long)]
password: Option<String>,
/// Optional expiry, Unix epoch seconds.
#[arg(long)]
expires: Option<i64>,
},
/// Remove a node's public link.
Remove {
/// File/folder path, inside the mountpoint or relative to it.
path: PathBuf,
/// Public-link id, from `pdfs members`.
id: String,
},
}
#[derive(Subcommand)]
enum InvitationCmd {
/// List invitations addressed to me.
List,
/// Accept an invitation by id.
Accept { id: String },
/// Reject an invitation by id.
Reject { id: String },
}
#[derive(Subcommand)]
enum BookmarkCmd {
/// List saved public-link bookmarks.
List,
/// Save a public link (optionally password-protected) as a bookmark.
Add {
/// Public link URL, including the `#password` fragment.
url: String,
/// Optional custom password for the link.
#[arg(long)]
password: Option<String>,
},
/// Remove a saved bookmark by its token.
Rm { token: String },
} }
fn main() -> Result<()> { fn main() -> Result<()> {
@@ -170,15 +331,335 @@ fn main() -> Result<()> {
Command::Move { path, new_parent } => cmd_move(path, new_parent), Command::Move { path, new_parent } => cmd_move(path, new_parent),
Command::Rm { path } => cmd_rm(path), Command::Rm { path } => cmd_rm(path),
Command::Mkdir { parent, name } => cmd_mkdir(parent, name), Command::Mkdir { parent, name } => cmd_mkdir(parent, name),
Command::Upload { file, parent } => cmd_upload(file, parent), Command::Upload { sources, parent } => cmd_upload(sources, parent),
Command::Transfers => cmd_transfers(), Command::Transfers => cmd_transfers(),
Command::Trash => cmd_trash(), Command::Trash => cmd_trash(),
Command::Restore { uids } => cmd_restore(uids), Command::Restore { uids } => cmd_restore(uids),
Command::DeleteForever { uids } => cmd_delete_forever(uids), Command::DeleteForever { uids } => cmd_delete_forever(uids),
Command::EmptyTrash => cmd_empty_trash(), Command::EmptyTrash => cmd_empty_trash(),
Command::Devices { action } => cmd_devices(action),
Command::Share {
path,
emails,
role,
message,
} => cmd_share(path, emails, role, message),
Command::Members { path } => cmd_members(path),
Command::ShareRole {
path,
id,
kind,
role,
} => cmd_share_role(path, id, kind, role),
Command::Unshare { path, id, kind } => cmd_unshare(path, id, kind),
Command::PublicLink { action } => cmd_public_link(action),
Command::Shared => cmd_shared(),
Command::SharedWithMe => cmd_shared_with_me(),
Command::Leave { uid } => cmd_leave(uid),
Command::Invitations { action } => cmd_invitations(action),
Command::Bookmarks { action } => cmd_bookmarks(action),
Command::Activity { limit } => cmd_activity(limit),
} }
} }
fn cmd_devices(action: DeviceCmd) -> Result<()> {
match action {
DeviceCmd::List => match control_request(CtlRequest::ListDevices)? {
CtlResponse::Devices { items } if items.is_empty() => println!("No devices."),
CtlResponse::Devices { items } => {
for d in items {
let sync = d
.last_sync
.map(|t| t.to_string())
.unwrap_or_else(|| "never".to_string());
println!(
"{} {} (synced: {sync}) [{}]",
d.device_type, d.name, d.uid
);
}
}
CtlResponse::Error { message } => bail!("{message}"),
other => bail!("unexpected response: {other:?}"),
},
DeviceCmd::Rename { uid, name } => {
ok_or_bail(control_request(CtlRequest::RenameDevice { uid, name })?)?
}
DeviceCmd::Rm { uid } => ok_or_bail(control_request(CtlRequest::DeleteDevice { uid })?)?,
}
Ok(())
}
fn cmd_share(
path: PathBuf,
emails: Vec<String>,
role: String,
message: Option<String>,
) -> Result<()> {
ok_or_bail(control_request(CtlRequest::ShareNode {
path: path_arg(&path)?,
emails,
role,
message,
})?)
}
fn cmd_members(path: PathBuf) -> Result<()> {
match control_request(CtlRequest::ListShare {
path: path_arg(&path)?,
})? {
CtlResponse::Share { entries, link } => {
if entries.is_empty() {
println!("No members or pending invitations.");
}
for e in entries {
let kind = match e.kind {
ShareEntryKind::Member => "member",
ShareEntryKind::ProtonInvite => "invited (proton)",
ShareEntryKind::ExternalInvite => "invited (external)",
};
println!("{:<18} {:<8} {} [{}]", kind, e.role, e.email, e.id);
}
match link {
Some(l) => {
let url = l.url.as_deref().unwrap_or("(url hidden)");
let pw = if l.has_password { " +password" } else { "" };
println!("public link ({}{pw}): {url} [{}]", l.role, l.id);
}
None => println!("public link: none"),
}
}
CtlResponse::Error { message } => bail!("{message}"),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
}
fn cmd_share_role(path: PathBuf, id: String, kind: ShareKindArg, role: String) -> Result<()> {
ok_or_bail(control_request(CtlRequest::UpdateShareRole {
path: path_arg(&path)?,
id,
kind: kind.to_kind(),
role,
})?)
}
fn cmd_unshare(path: PathBuf, id: String, kind: ShareKindArg) -> Result<()> {
ok_or_bail(control_request(CtlRequest::RemoveShareEntry {
path: path_arg(&path)?,
id,
kind: kind.to_kind(),
})?)
}
fn cmd_public_link(action: PublicLinkCmd) -> Result<()> {
match action {
PublicLinkCmd::Create {
path,
role,
password,
expires,
} => match control_request(CtlRequest::CreatePublicLink {
path: path_arg(&path)?,
role,
password,
expires,
})? {
CtlResponse::PublicLink { link } => {
println!("{}", link.url.as_deref().unwrap_or("(no url returned)"));
}
CtlResponse::Error { message } => bail!("{message}"),
other => bail!("unexpected response: {other:?}"),
},
PublicLinkCmd::Remove { path, id } => {
ok_or_bail(control_request(CtlRequest::RemovePublicLink {
path: path_arg(&path)?,
id,
})?)?
}
}
Ok(())
}
fn cmd_shared() -> Result<()> {
match control_request(CtlRequest::ListSharedByMe)? {
CtlResponse::SharedByMe { items } if items.is_empty() => {
println!("You haven't shared anything.")
}
CtlResponse::SharedByMe { items } => {
for it in items {
let kind = if it.is_dir { "d" } else { "-" };
let mut tags = Vec::new();
if it.member_count > 0 {
tags.push(format!("{} member(s)", it.member_count));
}
if it.invite_count > 0 {
tags.push(format!("{} pending", it.invite_count));
}
if let Some(link) = &it.link {
tags.push(match &link.url {
Some(url) => format!("link: {url}"),
None => "link".to_string(),
});
}
let tags = if tags.is_empty() {
String::new()
} else {
format!(" ({})", tags.join(", "))
};
println!("{kind} {}{tags} [{}]", it.name, it.uid);
}
}
CtlResponse::Error { message } => bail!("{message}"),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
}
fn cmd_shared_with_me() -> Result<()> {
match control_request(CtlRequest::ListSharedWithMe)? {
CtlResponse::Entries { entries } if entries.is_empty() => {
println!("Nothing shared with you.")
}
CtlResponse::Entries { entries } => {
for e in entries {
let kind = if e.is_dir { "d" } else { "-" };
println!("{kind} {:>12} {} [{}]", e.size, e.name, e.uid);
}
}
CtlResponse::Error { message } => bail!("{message}"),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
}
fn cmd_activity(limit: usize) -> Result<()> {
match control_request(CtlRequest::ListActivity { limit })? {
CtlResponse::Activity { items } if items.is_empty() => println!("No recent activity."),
CtlResponse::Activity { items } => {
for a in items {
let when = format_epoch(a.time);
let verb = activity_verb(a.kind);
let mark = if a.ok { " " } else { "!" };
let detail = if a.detail.is_empty() {
String::new()
} else {
format!(" {}", a.detail)
};
println!("{mark} {when} {verb:<8} {}{detail}", a.target);
}
}
CtlResponse::Error { message } => bail!("{message}"),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
}
/// A short verb for an activity kind, for the CLI's one-line-per-event feed.
fn activity_verb(kind: pdfs_core::control::ActivityKind) -> &'static str {
use pdfs_core::control::ActivityKind::*;
match kind {
Upload => "upload",
Rename => "rename",
Move => "move",
CreateFolder => "mkdir",
Trash => "trash",
Restore => "restore",
DeleteForever => "delete",
EmptyTrash => "empty",
Share => "share",
PublicLink => "link",
Unshare => "unshare",
}
}
/// Format an epoch-seconds timestamp as a compact local-ish `MM-DD HH:MM`.
/// Deliberately dependency-free: derived directly from the Unix time via UTC.
fn format_epoch(secs: i64) -> String {
// Minimal civil-time conversion (UTC) — good enough for an activity feed.
let days = secs.div_euclid(86_400);
let tod = secs.rem_euclid(86_400);
let (h, mi) = (tod / 3600, (tod % 3600) / 60);
// Days since 1970-01-01 → y/m/d (Howard Hinnant's civil_from_days).
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let _ = y;
format!("{m:02}-{d:02} {h:02}:{mi:02}")
}
fn cmd_leave(uid: String) -> Result<()> {
ok_or_bail(control_request(CtlRequest::LeaveShared { uid })?)
}
fn cmd_invitations(action: InvitationCmd) -> Result<()> {
match action {
InvitationCmd::List => match control_request(CtlRequest::ListInvitations)? {
CtlResponse::Invitations { items } if items.is_empty() => {
println!("No pending invitations.")
}
CtlResponse::Invitations { items } => {
for i in items {
let kind = if i.is_dir { "folder" } else { "file" };
let name = i.name.as_deref().unwrap_or("(name hidden)");
println!("{} shared {kind} \"{name}\" [{}]", i.inviter_email, i.id);
}
}
CtlResponse::Error { message } => bail!("{message}"),
other => bail!("unexpected response: {other:?}"),
},
InvitationCmd::Accept { id } => {
ok_or_bail(control_request(CtlRequest::AcceptInvitation { id })?)?
}
InvitationCmd::Reject { id } => {
ok_or_bail(control_request(CtlRequest::RejectInvitation { id })?)?
}
}
Ok(())
}
fn cmd_bookmarks(action: BookmarkCmd) -> Result<()> {
match action {
BookmarkCmd::List => match control_request(CtlRequest::ListBookmarks)? {
CtlResponse::Bookmarks { items } if items.is_empty() => println!("No bookmarks."),
CtlResponse::Bookmarks { items } => {
for b in items {
let name = b.name.as_deref().unwrap_or("(name hidden)");
println!("{name} {} [{}]", b.url, b.token);
}
}
CtlResponse::Error { message } => bail!("{message}"),
other => bail!("unexpected response: {other:?}"),
},
BookmarkCmd::Add { url, password } => {
ok_or_bail(control_request(CtlRequest::CreateBookmark {
url,
password,
})?)?
}
BookmarkCmd::Rm { token } => {
ok_or_bail(control_request(CtlRequest::DeleteBookmark { token })?)?
}
}
Ok(())
}
/// Print an `Ok` message or bail on an `Error`, for the many commands whose only
/// reply is one of those two.
fn ok_or_bail(resp: CtlResponse) -> Result<()> {
match resp {
CtlResponse::Ok { message } => println!("{message}"),
CtlResponse::Error { message } => bail!("{message}"),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
}
/// Read a line from stdin with a prompt (echoed). /// Read a line from stdin with a prompt (echoed).
fn prompt_line(label: &str) -> Result<String> { fn prompt_line(label: &str) -> Result<String> {
print!("{label}: "); print!("{label}: ");
@@ -595,17 +1076,21 @@ fn cmd_mkdir(parent: PathBuf, name: String) -> Result<()> {
Ok(()) Ok(())
} }
fn cmd_upload(file: PathBuf, parent: PathBuf) -> Result<()> { fn cmd_upload(sources: Vec<PathBuf>, parent: PathBuf) -> Result<()> {
let bytes = std::fs::read(&file).with_context(|| format!("read {}", file.display()))?; // The daemon reads the sources off its own (local) filesystem, so send it
let name = file // absolute, canonicalized paths.
.file_name() let mut abs = Vec::with_capacity(sources.len());
.and_then(|n| n.to_str()) for src in &sources {
.ok_or_else(|| anyhow!("source file has no valid name"))? let p = std::fs::canonicalize(src).with_context(|| format!("resolve {}", src.display()))?;
.to_owned(); abs.push(
match control_request(CtlRequest::UploadFile { p.to_str()
.ok_or_else(|| anyhow!("path is not valid UTF-8: {}", p.display()))?
.to_owned(),
);
}
match control_request(CtlRequest::UploadPaths {
parent: path_arg(&parent)?, parent: path_arg(&parent)?,
name, sources: abs,
bytes,
})? { })? {
CtlResponse::Ok { message } => println!("{message}"), CtlResponse::Ok { message } => println!("{message}"),
CtlResponse::Error { message } => bail!("{message}"), CtlResponse::Error { message } => bail!("{message}"),
+340
View File
@@ -92,6 +92,18 @@ pub enum Request {
name: String, name: String,
bytes: Vec<u8>, bytes: Vec<u8>,
}, },
/// Bulk-upload local files and/or directory trees into the mountpoint-relative
/// `parent` folder. `sources` are absolute paths on the daemon's own
/// filesystem (the daemon is local): each file is uploaded, each directory is
/// recreated remotely and walked recursively. The daemon acks immediately with
/// [`Response::Ok`] and does the work in the background — a big tree far
/// outlasts the socket read timeout — so progress is observed through
/// [`Request::GetQueueStatus`] and completion through the transfer count
/// falling to zero.
UploadPaths {
parent: String,
sources: Vec<String>,
},
/// Delete all unpinned cached blobs and on-demand blocks, keeping pinned /// Delete all unpinned cached blobs and on-demand blocks, keeping pinned
/// files intact. Replies with [`Response::Ok`] reporting the bytes freed. /// files intact. Replies with [`Response::Ok`] reporting the bytes freed.
PurgeCache, PurgeCache,
@@ -117,6 +129,243 @@ pub enum Request {
/// Permanently delete everything in the trash. Irreversible. /// Permanently delete everything in the trash. Irreversible.
/// Replies with [`Response::Ok`]. /// Replies with [`Response::Ok`].
EmptyTrash, EmptyTrash,
// ---- devices ----------------------------------------------------------
/// List the account's registered devices. Replies with [`Response::Devices`].
ListDevices,
/// Rename a device by its uid. Replies with [`Response::Ok`].
RenameDevice { uid: String, name: String },
/// Delete (deregister) a device by its uid. Replies with [`Response::Ok`].
DeleteDevice { uid: String },
// ---- sharing a node ---------------------------------------------------
/// Invite `emails` (Proton and/or external addresses, auto-detected) to the
/// node at mountpoint-relative `path` at `role` ("viewer"|"editor"|"admin"),
/// with an optional email `message`. Replies with [`Response::Ok`].
ShareNode {
path: String,
emails: Vec<String>,
role: String,
message: Option<String>,
},
/// List the members, pending invitations and public link of the node at
/// mountpoint-relative `path`. Replies with [`Response::Share`].
ListShare { path: String },
/// Change the role of a share entry (member or pending invitation) on the node
/// at `path`. `id` and `kind` identify the entry (from [`Response::Share`]).
/// Replies with [`Response::Ok`].
UpdateShareRole {
path: String,
id: String,
kind: ShareEntryKind,
role: String,
},
/// Remove a share entry (member, pending Proton invite, or external invite)
/// from the node at `path`. Replies with [`Response::Ok`].
RemoveShareEntry {
path: String,
id: String,
kind: ShareEntryKind,
},
/// Create a public link on the node at `path`. `role` is "viewer" or "editor";
/// `password` optionally adds a custom password; `expires` is an optional Unix
/// expiry. Replies with [`Response::PublicLink`] (carrying the shareable URL).
CreatePublicLink {
path: String,
role: String,
password: Option<String>,
expires: Option<i64>,
},
/// Remove the public link `id` from the node at `path`. Replies with
/// [`Response::Ok`].
RemovePublicLink { path: String, id: String },
// ---- shared by me -----------------------------------------------------
/// List the nodes I have shared with others — collaborative shares that still
/// have members, pending invitations or a public link. Replies with
/// [`Response::SharedByMe`]. Each item carries the share's state so the
/// front-end can render it without a follow-up per node.
ListSharedByMe,
// ---- shared with me ---------------------------------------------------
/// List nodes shared with me that I have accepted. Replies with
/// [`Response::Entries`] (each entry carries its `uid`; `path` is empty since
/// the item lives outside the mount tree).
ListSharedWithMe,
/// Leave a shared node by its `uid`, giving up my access. Replies with
/// [`Response::Ok`].
LeaveShared { uid: String },
// ---- incoming invitations ---------------------------------------------
/// List invitations addressed to me, pending accept or reject. Replies with
/// [`Response::Invitations`].
ListInvitations,
/// Accept the invitation `id`, gaining access to the shared node. Replies with
/// [`Response::Ok`].
AcceptInvitation { id: String },
/// Reject the invitation `id`, declining access. Replies with [`Response::Ok`].
RejectInvitation { id: String },
// ---- bookmarks --------------------------------------------------------
/// List public links saved to my account. Replies with [`Response::Bookmarks`].
ListBookmarks,
/// Save a public link `url` (optionally password-protected) as a bookmark.
/// Replies with [`Response::Ok`].
CreateBookmark {
url: String,
password: Option<String>,
},
/// Remove a saved bookmark by its `token`. Replies with [`Response::Ok`].
DeleteBookmark { token: String },
// ---- activity ---------------------------------------------------------
/// Fetch the daemon's recent activity log, newest first, capped at `limit`
/// entries. Replies with [`Response::Activity`]. The log lives in memory for
/// the life of the daemon: it records the mutations and transfers the daemon
/// performs (uploads, deletes, renames, shares, …), so a front-end can show a
/// running "what happened" feed without re-deriving it from anywhere.
ListActivity { limit: usize },
}
/// Which kind of share entry a [`ShareEntry`] is, and which collection an
/// [`Request::UpdateShareRole`]/[`Request::RemoveShareEntry`] targets.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShareEntryKind {
/// An accepted member (identified by its membership id).
Member,
/// A pending invitation to a Proton user (identified by its invitation id).
ProtonInvite,
/// A pending invitation to a non-Proton email (identified by its invitation id).
ExternalInvite,
}
/// A registered device in a [`Response::Devices`] listing.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct DeviceInfo {
/// Device uid — the handle for [`Request::RenameDevice`]/[`Request::DeleteDevice`].
pub uid: String,
/// Decrypted device name, or a placeholder when it could not be decrypted.
pub name: String,
/// Platform: "Windows", "MacOs" or "Linux".
pub device_type: String,
/// Last sync time, epoch seconds; `None` if it never synced.
pub last_sync: Option<i64>,
}
/// One member or pending invitation on a node's share (in [`Response::Share`]).
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ShareEntry {
/// Membership id (members) or invitation id (invitations) — the handle for
/// [`Request::UpdateShareRole`]/[`Request::RemoveShareEntry`].
pub id: String,
/// The member's / invitee's email address.
pub email: String,
/// Role: "viewer", "editor", "admin" or "inherited".
pub role: String,
/// Whether this is a member, a pending Proton invite, or an external invite.
pub kind: ShareEntryKind,
}
/// A node's public link (in [`Response::Share`] / [`Response::PublicLink`]).
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct PublicLinkInfo {
/// Public-link id — the handle for [`Request::RemovePublicLink`].
pub id: String,
/// The shareable URL including the `#password` fragment, when known (always on
/// creation; absent when only listed).
pub url: Option<String>,
/// Role granted to anyone with the link ("viewer" or "editor").
pub role: String,
/// Expiry, epoch seconds, if set.
pub expires: Option<i64>,
/// Whether a custom password additionally protects the link.
pub has_password: bool,
}
/// One invitation addressed to me (in [`Response::Invitations`]).
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct InvitationInfo {
/// Invitation id — the handle for [`Request::AcceptInvitation`]/[`Request::RejectInvitation`].
pub id: String,
/// The email of the user who shared the item.
pub inviter_email: String,
/// The shared item's decrypted name, when available.
pub name: Option<String>,
/// Whether the shared item is a folder.
pub is_dir: bool,
}
/// One saved public link (in [`Response::Bookmarks`]).
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct BookmarkInfo {
/// Bookmark token — the handle for [`Request::DeleteBookmark`].
pub token: String,
/// The full public URL including the `#password` fragment.
pub url: String,
/// The bookmarked item's decrypted name, when available.
pub name: Option<String>,
/// Whether the bookmarked item is a folder.
pub is_dir: bool,
}
/// One node I have shared with others (in [`Response::SharedByMe`]). Summarizes
/// the share's state so the "Shared" view renders in one pass: how many people
/// have access, how many invitations are still pending, and the public link if
/// the node has one.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct SharedItem {
/// Node uid in `volume~link` form — the handle for opening the node's share
/// (via its mount path) or its details.
pub uid: String,
/// The shared node's decrypted name.
pub name: String,
pub is_dir: bool,
/// Mountpoint-relative path, when the daemon can resolve it (the node lives in
/// my own tree). Empty when the path is unknown.
#[serde(default)]
pub path: String,
/// Accepted members (people who already have access), excluding me.
pub member_count: usize,
/// Pending invitations (Proton + external) not yet accepted.
pub invite_count: usize,
/// The node's public link, if it has one.
pub link: Option<PublicLinkInfo>,
}
/// What happened, in an [`ActivityEntry`]. Kept coarse: a front-end maps each to
/// an icon and a verb, and the human detail lives in the entry's fields.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActivityKind {
Upload,
Rename,
Move,
CreateFolder,
Trash,
Restore,
DeleteForever,
EmptyTrash,
Share,
PublicLink,
Unshare,
}
/// One line in the daemon's activity log (in [`Response::Activity`]). Newest
/// first. Records a mutation or transfer the daemon performed, with enough
/// context to read as a sentence: "Uploaded report.pdf to /docs".
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ActivityEntry {
/// When it happened, epoch seconds.
pub time: i64,
pub kind: ActivityKind,
/// The primary item's name (a file/folder name, or a count like "3 items").
pub target: String,
/// Extra context: a destination path, a new name, an invitee, an error
/// message. Empty when there is nothing to add.
#[serde(default)]
pub detail: String,
/// Whether the operation succeeded. A failed entry still appears, so the log
/// shows what was attempted.
pub ok: bool,
} }
/// Which way an active transfer is moving, in a [`TransferItem`]. /// Which way an active transfer is moving, in a [`TransferItem`].
@@ -285,6 +534,26 @@ pub enum Response {
LocalResults { hits: Vec<LocalHit>, indexing: bool }, LocalResults { hits: Vec<LocalHit>, indexing: bool },
/// A snapshot of in-flight transfers (reply to [`Request::GetQueueStatus`]). /// A snapshot of in-flight transfers (reply to [`Request::GetQueueStatus`]).
Transfers { items: Vec<TransferItem> }, Transfers { items: Vec<TransferItem> },
/// The account's devices (reply to [`Request::ListDevices`]).
Devices { items: Vec<DeviceInfo> },
/// A node's share: members + pending invitations, and its public link if any
/// (reply to [`Request::ListShare`]).
Share {
entries: Vec<ShareEntry>,
link: Option<PublicLinkInfo>,
},
/// A freshly created public link, carrying the shareable URL (reply to
/// [`Request::CreatePublicLink`]).
PublicLink { link: PublicLinkInfo },
/// Invitations addressed to me (reply to [`Request::ListInvitations`]).
Invitations { items: Vec<InvitationInfo> },
/// Saved public links (reply to [`Request::ListBookmarks`]).
Bookmarks { items: Vec<BookmarkInfo> },
/// Nodes I have shared with others (reply to [`Request::ListSharedByMe`]).
SharedByMe { items: Vec<SharedItem> },
/// The daemon's recent activity, newest first (reply to
/// [`Request::ListActivity`]).
Activity { items: Vec<ActivityEntry> },
/// The request failed. /// The request failed.
Error { message: String }, Error { message: String },
} }
@@ -336,6 +605,10 @@ mod tests {
name: "f.bin".into(), name: "f.bin".into(),
bytes: vec![0, 1, 2, 255], bytes: vec![0, 1, 2, 255],
}, },
Request::UploadPaths {
parent: "a".into(),
sources: vec!["/home/u/x.txt".into(), "/home/u/pics".into()],
},
]; ];
for req in reqs { for req in reqs {
let line = serde_json::to_string(&req).unwrap(); let line = serde_json::to_string(&req).unwrap();
@@ -368,4 +641,71 @@ mod tests {
assert_eq!(line, serde_json::to_string(&back).unwrap()); assert_eq!(line, serde_json::to_string(&back).unwrap());
} }
} }
/// The sharing and devices requests must survive the same line-delimited JSON
/// round-trip: a mangled id or role would touch the wrong share or member.
#[test]
fn sharing_requests_roundtrip() {
let reqs = [
Request::ListDevices,
Request::RenameDevice {
uid: "dev-1".into(),
name: "laptop".into(),
},
Request::DeleteDevice {
uid: "dev-1".into(),
},
Request::ShareNode {
path: "a/b".into(),
emails: vec!["x@proton.me".into(), "y@example.com".into()],
role: "editor".into(),
message: Some("hi".into()),
},
Request::ListShare { path: "a/b".into() },
Request::UpdateShareRole {
path: "a/b".into(),
id: "mid-1".into(),
kind: ShareEntryKind::Member,
role: "admin".into(),
},
Request::RemoveShareEntry {
path: "a/b".into(),
id: "iid-1".into(),
kind: ShareEntryKind::ExternalInvite,
},
Request::CreatePublicLink {
path: "a/b".into(),
role: "viewer".into(),
password: Some("pw".into()),
expires: Some(1_700_000_000),
},
Request::RemovePublicLink {
path: "a/b".into(),
id: "url-1".into(),
},
Request::ListSharedByMe,
Request::ListActivity { limit: 100 },
Request::ListSharedWithMe,
Request::LeaveShared {
uid: "vol~link".into(),
},
Request::ListInvitations,
Request::AcceptInvitation { id: "inv-1".into() },
Request::RejectInvitation { id: "inv-1".into() },
Request::ListBookmarks,
Request::CreateBookmark {
url: "https://drive.proton.me/urls/tok#pw".into(),
password: None,
},
Request::DeleteBookmark {
token: "tok".into(),
},
];
for req in reqs {
let line = serde_json::to_string(&req).unwrap();
assert!(!line.contains('\n'), "wire form must be a single line");
let back: Request = serde_json::from_str(&line).unwrap();
assert_eq!(line, serde_json::to_string(&back).unwrap());
}
}
} }
+12 -5
View File
@@ -381,9 +381,8 @@ impl Db {
let tx = conn.transaction()?; let tx = conn.transaction()?;
let learned: HashMap<String, (Option<f64>, i64)> = { let learned: HashMap<String, (Option<f64>, i64)> = {
let mut stmt = tx.prepare("SELECT uid, ratio, thumb_state FROM photos")?; let mut stmt = tx.prepare("SELECT uid, ratio, thumb_state FROM photos")?;
let rows = stmt.query_map([], |r| { let rows =
Ok((r.get::<_, String>(0)?, (r.get(1)?, r.get(2)?))) stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, (r.get(1)?, r.get(2)?))))?;
})?;
rows.collect::<rusqlite::Result<_>>()? rows.collect::<rusqlite::Result<_>>()?
}; };
@@ -394,8 +393,16 @@ impl Db {
VALUES (?1, ?2, ?3, ?4, ?5, ?6)", VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
)?; )?;
for (seq, (uid, capture_time, name)) in items.iter().enumerate() { for (seq, (uid, capture_time, name)) in items.iter().enumerate() {
let (ratio, thumb_state) = learned.get(uid).copied().unwrap_or((None, THUMB_UNKNOWN)); let (ratio, thumb_state) =
stmt.execute(params![uid, capture_time, name, ratio, thumb_state, seq as i64])?; learned.get(uid).copied().unwrap_or((None, THUMB_UNKNOWN));
stmt.execute(params![
uid,
capture_time,
name,
ratio,
thumb_state,
seq as i64
])?;
} }
} }
tx.commit()?; tx.commit()?;
File diff suppressed because it is too large. Load diff
+25
View File
@@ -148,6 +148,31 @@ impl<W: Write> Write for CountingWriter<'_, W> {
} }
} }
/// A [`Read`] that tallies bytes read and *owns* its [`TransferGuard`], so the
/// transfer stays registered exactly as long as the reader lives. Used by the
/// concurrent bulk uploader, where each task hands its reader to the SDK and has
/// nowhere separate to park the guard; the transfer deregisters when the SDK
/// drops the reader after sealing the revision. Owning (rather than borrowing)
/// also keeps each upload future `Send + 'static` for [`tokio::task::spawn`].
pub struct OwnedCountingReader<R> {
inner: R,
guard: TransferGuard,
}
impl<R: Read> OwnedCountingReader<R> {
pub fn new(inner: R, guard: TransferGuard) -> Self {
Self { inner, guard }
}
}
impl<R: Read> Read for OwnedCountingReader<R> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let n = self.inner.read(buf)?;
self.guard.add(n as u64);
Ok(n)
}
}
/// A [`Read`] that tallies bytes read through a [`TransferGuard`] (upload path). /// A [`Read`] that tallies bytes read through a [`TransferGuard`] (upload path).
pub struct CountingReader<'a, R> { pub struct CountingReader<'a, R> {
inner: R, inner: R,
+2002 -40
View File
File diff suppressed because it is too large. Load diff