Compare commits

...
182 Commits
Author SHA1 Message Date
nvrl 6cc8f72bab fix(core,fuse,gui,cli): read only the photos a refresh has not read
A timeline refresh resolved every photo's node to learn its name, media
type, favourite tag, content hash and photo relation — 63 round-trips for
a 12,445-photo library, minutes of work, repeated every time the timeline
went stale sixty seconds later. None of that metadata changes on its own,
and when it does change the event feed names the node it changed.

`photos.resolved_at` (schema v31) records that a photo's node was read.
The refresh resolves the photos without that mark: the ones it has never
read, and the ones a remote event marked stale. An existing timeline is
backfilled as resolved, so upgrading does not re-read the library once.

Remote changes now name what they changed instead of asking for
everything. `classify_photo_events` turns a `NodeUpdated` into
`photos_mark_unresolved(uid)`, so a favourite toggled on a phone costs one
node read rather than the whole library; a local toggle is claimed as our
own echo, because the feed replays it and a `NodeUpdated` carries nothing
that says what changed. A continuity loss and `refresh photos --full` drop
every mark, which is the way back to a whole-library read.

Along the way: `photos_replace` never kept a photo's name, so a photo the
SDK could not decrypt was renamed to nothing on every refresh — and the
tab split and the RAW+JPEG grouping are both derived from the name. It is
kept now, and a row that *was* resolved is authoritative instead, so a
photo unlinked from its main elsewhere finally loses its `main_uid`.

`TIMELINE_ENRICH_CHUNK` is 150, matching the SDK's own `MAX_BATCH_COUNT`:
a chunk of 200 was split there into a full request and a 50-uid tail.
2026-09-19 17:39:49 +02:00
nvrl 376f3edb28 fix(core,fuse,gui,cli): pair a Pixel raw with its JPEG, and stop refresh timing out
Three things the 12,445-photo library showed that the tests did not.

A Pixel raw capture is two files named after the role each plays —
`PXL_….RAW-01.COVER.jpg` and `PXL_….RAW-02.ORIGINAL.dng` — so their stems differ
and the pairing rule never saw one shot. The marker is now cut off before names
are compared, narrowly: digits, then one role word, so an ordinary name that
happens to contain `.raw-` keeps its stem. Fed the real library's first 40 rows
through the grouping, they collapse to 21 tiles.

A raw that has a JPEG beside it is now painted with that JPEG's thumbnail, which
the server usually already holds. The Raw tab costs no raw download and no raw
decode for those tiles. Raws are found in one batched read, so an ordinary page
pays nothing for the lookup.

Photos could not be moved to Trash at all: they live on the photo volume, which
the mount never holds, so no `nodes` row exists, every authority answered
`Unknown`, and the write check refused the whole library with "trash access: not
allowed". Membership of our own timeline is the authority that does exist for a
photo, and it now answers. An album photo on someone else's volume still fails
closed.

Refreshing the gallery then reported `io: Resource temporarily unavailable (os
error 11)` (B92). The daemon held the control reply until the whole timeline had
been re-read — 63 batches of 200 nodes for this library, minutes of work against
a 120 s read bound. The daemon now answers as soon as the refresh is running,
`PhotosRefreshStatus` reports when it is done, and both front ends follow it: the
gallery reloads the page when it clears, and `pdfs refresh photos` blocks so the
command still means what it says. Each refresh logs its photo count, batch count
and duration.

Released as 1.11.0.
2026-09-19 17:08:33 +02:00
nvrl 09c0684a5e fix(core): keep pre-v30 photos on the page until a refresh groups them
A database written before the grouping column opens with `group_key` NULL on
every row, and NULL matches nothing: the join dropped the whole timeline until
the first refresh ran. `COALESCE(group_key, uid)` is what makes such a row its
own group, which is how the gallery behaved before the column existed.

Covered by a migration test that rewinds a fixture to v29.
2026-09-19 15:41:24 +02:00
nvrl c1df3b6cc2 feat(gui): show a grouped shot as one tile, and reach its other files
The grouping landed in the daemon; this is what it looks like. A tile whose shot
was stored as more than one file badges that in the corner — "RAW" when one of
the members is a raw file, since that is the word the person is looking for, and
a plain count otherwise (a live photo, a burst). The lightbox gains a control
that steps through the group's files, which is the only way to reach the RAW now
that the grid shows the shot once.

Deleting says what it will do: the daemon takes the whole group, so the
confirmation names the file count when a selection stands for more files than
tiles.
2026-09-19 15:39:14 +02:00
nvrl afd71713d5 feat(core,fuse): group the RAW and the JPEG of one shot
A camera that writes both puts two files on the volume, and the timeline showed
two tiles of the same photo. They are one shot to the person who took it.

Schema v30 records what ties them together: the server's own duplicate hash and
main-photo relation, plus a `group_key` every member of a shot carries. The key
is computed when the timeline is replaced, in three steps — the server relation
first, because it knows about live photos and bursts and we do not have to guess
at them; then same capture day, same file-name stem and one member raw while the
other is not, which is the pair a camera writes; otherwise the photo is its own
group. The member the grid shows is the non-raw one, since a JPEG decodes in
milliseconds and is what the person expects to see.

The gallery therefore pages by group and carries `group_size` and `has_raw` on
each entry, and the date scrubber counts what the page shows. The Raw tab is the
exception and still lists every raw file, because that tab exists to answer
"which raws do I have". Album pages are not grouped at all: an album is a list
someone made, and it shows exactly what they put in it.

`TimelineRow` is a struct now. It had grown to five positional fields and was
about to grow to seven, at which point nobody can read a call site.
2026-09-19 15:34:17 +02:00
nvrl c6bdba024f feat(gui,fuse): delete photos from the gallery and notice remote deletions
The gallery could show photos and nothing else: a photo deleted on the phone
stayed on the page for minutes, and a photo you wanted gone had to be deleted
on another device. Both are now handled here.

Deleting needs a uid-addressed request, because `Request::Delete` takes a
mountpoint path and the photos volume is not in the FUSE mount. `TrashNodes`
trashes through the SDK and answers per-uid outcomes, so a partial failure is
reportable; the daemon then drops those rows with `Db::photos_delete`, album
membership included, rather than leaving the gallery to wait for the next
timeline refresh. The GUI removes the tiles immediately with an Undo toast and
puts back anything the server refused — the photos are in Proton's trash and
stay recoverable there either way. Selection is Ctrl/Shift+click, the Select
toggle or Delete in the lightbox.

Remote deletions arrive on their own now. The Drive volume and the photos
volume have separate event streams, so the photos volume gets its own poller
and its own cursor. Trash and delete events remove rows; anything else only
marks the timeline stale, because there is no inode space to converge on this
volume. With the timeline TTL down to 60 s and an explicit Refresh that waits
for the server instead of only clearing the freshness stamp, a photo deleted
elsewhere leaves the grid in about ten seconds.
2026-09-19 15:25:04 +02:00
nvrl 5a8e73657c feat(gui): justify the gallery rows and keep the thumbnails on screen
The Photos grid laid every photo out as a centre-cropped square. That is
why portraits showed black bars and why a panorama was unrecognisable at
tile size: the tile's shape had nothing to do with the photo's.

Rows are now justified. Photos are taken in capture order until they no
longer fit the target height, and the row is then scaled to land exactly
on the right margin, so each tile is its own photo's shape and nothing is
cropped or letterboxed. The last row of a day stays at the target height
rather than being stretched — a day holding two photos should look like
a short day, not like a layout bug. Ratios come from the daemon where it
has one; where it has not, the tile is laid out square and the day
re-flows once a decoded thumbnail proves the real shape, on the existing
debounce so a screenful of decodes costs one re-flow. Extreme ratios are
clamped, so one strip of film cannot decide what a row looks like.

Ctrl+scroll and Ctrl+± now move the target row height, and the grid is
tighter to match: 2 px gaps, smaller heading margins, a 6 px radius and
no card behind each tile, so the page reads as photographs rather than
as a deck of cards.

Thumbnails also stopped disappearing on scroll. The texture cache was
evicting in insertion order — which is precisely what a scroll back up
asks for again — so it is now least-recently-used and holds 1500 rather
than 600, which at the new density is several screens instead of under
two. A batch tops itself up with the photos just past the rows on
screen, in the direction of travel, and once the queue is short the
near-miss tiles are fetched rather than dropped.
2026-09-19 15:05:30 +02:00
nvrl cf0d495f8c fix(fuse): join the worker pool before the runtime goes away, and log deep queues
Teardown flagged the FUSE worker pool closed but never joined it, so the
process could drop the tokio runtime while a worker was still inside a
job — which is where 2026-09-18's "A Tokio 1.x context was found, but it
is being shutdown" panic came from. `Workers::stop_and_join` now waits
for the jobs in flight, bounded at 10 s so a worker wedged on the network
cannot hold the stop the user asked for; what is still stuck is named in
the log and shutdown continues around it.

The queues stay unbounded — the only caller that could feel backpressure
is fuser's dispatch loop, and blocking that is the stall the pool exists
to avoid — but a lane now logs its depth every 256 jobs, so a queue that
keeps climbing is visible before it is reported as a slow mount.

Documents the hang as B91, with the journal evidence, what made it
undiagnosable, and the candidates the evidence does not yet separate.
2026-09-19 14:49:31 +02:00
nvrl bee15ee7cf feat(fuse,packaging): watch the daemon for stalls and answer the systemd watchdog
A supervisor thread ticks every 15 s and does three things: it WARNs
about a worker that has held one job for two minutes, a control request
that never returned, or a lane that has not completed anything while its
queue is non-empty; it samples resident size and the pending-op count
into the log every five minutes; and it answers the systemd watchdog.

The watchdog ping is deliberately not a heartbeat from a thread that
does nothing else — that would keep the unit "healthy" through exactly
the hang it is meant to catch. It goes out only after a real round trip
over the control socket, so it means "a client can still talk to this
daemon", which is the property that failed on 2026-09-18.

`sd_notify` is spoken directly rather than through libsystemd (READY,
WATCHDOG, STOPPING), including the abstract-namespace address; off
systemd every call is a no-op.

The unit gains `NotifyAccess=all` (rather than `Type=notify`, which
would fail the unit on the start timeout when no login is stored yet),
`WatchdogSec=120`, `Restart=always` — a hang is not a failing exit code
— and `MemoryHigh`/`MemoryMax` as a backstop against the 5.2 G growth
the hung run showed.
2026-09-19 14:45:15 +02:00
nvrl f24ac8a429 feat(fuse,core,cli): report what the daemon is doing, for the next hang
On 2026-09-18 the daemon hung: 43 minutes of CPU over 20 hours, 5.2 GiB
resident, silent from 17:10 until a manual restart the next day, and no
panic. Nothing in the journal said what it had been waiting on, and
nothing in the daemon could be asked.

- The worker pool publishes per-thread state: whether the thread is busy,
  how long it has been in that state, and the label of the job in hand.
  Every FUSE handler that hands work to the pool now names its job
  (`read`, `lookup`, `rename`), so a stuck worker is named rather than
  merely counted. Queue depths and per-lane completion counts are
  mirrored outside the queue lock.
- `Request::Diagnostics` / `pdfs diagnostics` reports those, plus
  resident size, the control handler count against its limit, the
  in-flight control requests with their ages, and the pending-op count.
- Built to answer while the daemon is wedged: atomics and `try_lock`
  only, and the one field that needs the database reads it on a throwaway
  thread with a 500 ms deadline.
2026-09-19 14:40:26 +02:00
nvrl f78f663740 feat(core,fuse): restore the whole trashed tree, and bump the SDK to 0.6.5
The trash is a flat list of uids, but a delete has a shape: a folder, and
often items inside it that were trashed separately beforehand. Restoring
one of those in isolation is not what it means — a folder came back
without the contents deleted with it, and a file came back inside a
folder that is still trashed, which is to say nowhere the user can see.

- Schema 29: `trash.parent_uid`, filled by the trash refresh from the
  node's parent, plus an index for the walk.
- `expand_restore` takes the connected piece of the trashed tree around
  what was asked for (every trashed descendant, every trashed ancestor)
  and orders it into waves, shallowest first: the server cannot put a
  node back under a parent that is still trashed. A uid the listing does
  not know about is restored on its own, because the listing is
  materialised in chunks and can be stale.
- `Core::restore` restores wave by wave, still streaming per-node
  outcomes, so one node the server refuses leaves the rest restored.

The SDK bump brings the 6cbf2f44 port: permanent API errors are no
longer retried on block transfers, expired block URLs are re-minted on
the whole-file and public-link download paths, and a server-supplied
Retry-After is capped at 120 s.
2026-09-19 14:31:40 +02:00
nvrl 470948c191 chore(version) bumped to 1.10.2 2026-09-16 23:05:09 +02:00
nvrl b095b9f6b0 fix(core,fuse): never stat a foreign mount from the local-file indexer
The "This computer" indexer walks $HOME, and `default_excludes` only kept it
out of the Drive mountpoint and our own state/cache dirs. A home directory
that also holds third-party FUSE mounts (sshfs, rclone) got walked into.

`scan` does ask for `same_file_system(true)`, which looks sufficient and is
not: `ignore` implements it as `is_same_file_system` -> `device_num(path)` ->
`path.metadata()`, run after the directory is queued. Learning that a mount is
foreign requires stat'ing it first, and against a mount whose server is gone
that stat never returns.

The consequence is worse than a stalled scan. The blocked thread sits in
uninterruptible FUSE wait, so it ignores SIGKILL: the daemon can never exit,
systemd's stop times out, and the unreapable process keeps the cache.db lock
the next start needs, turning one dead mount into a permanent restart loop.
No userspace recovery exists once a thread is in that state, so the fix has to
be to never enter it.

Exclude every mountpoint strictly below the scan root, read by path from
/proc/self/mounts (octal-unescaped, so a path with a space still matches).
`filter_entry` runs before the device stat, so the walker never touches them.
Re-read per scan rather than cached at startup: a mount that appeared since
the daemon started is the likeliest to be half-alive.

This does not change what is indexed — a nested mount is a different device,
so it was already going to be skipped. Only the stat in front of that decision
goes away.

Refs: docs/BUGS.md B90
2026-09-16 23:05:06 +02:00
nvrl f4d6580cd3 chore(version) bumped to 1.10.1 2026-09-15 15:13:22 +02:00
nvrl b2f62f70e1 chore(deps,core,gui): upgrade dependencies to latest
- keyring 3 -> keyring-core 1 + dbus-secret-service-keyring-store 1
  (crypto-rust). Same libdbus Secret Service backend; with no target the
  store matches items keyring 3 wrote, so stored sessions survive.
- ksni 0.2 -> 0.3 via the blocking API, with assume_sni_available so a
  tray that starts before the SNI watcher waits instead of failing. Drops
  the clap 2 / atty chain (Dependabot atty advisory).
- gtk4 0.11, libadwaita 0.9, webkit6 0.6, rusqlite 0.40, fuser 0.18,
  directories 6, sha1/sha2 0.11, base64 0.23.
2026-09-15 15:07:00 +02:00
nvrl dcb2ab7fac chore(version) bumped to 1.10.0 2026-09-15 13:59:24 +02:00
nvrl eb56f4a1e0 feat(fuse,core,gui,cli): one-request move+rename drain, show who shared an item
- A queued rename that also changes folder now lands as one `move-multiple`
  request carrying the target name instead of rename-then-move, so there is
  no half-applied state and no second call for stale name requirements to
  hit (B46 follow-up). A destination name collision moves under a conflict
  name in one step; same-folder renames still use the rename endpoint.
- Shared with me shows the inviter and invite date (GUI subtitle, `pdfs
  shared-with-me`), with a warning when the invitation signature does not
  verify against the claimed inviter's keys. `DirEntry` gains `shared_by`,
  `shared_at` and `shared_by_unverified`, all serde-defaulted.
- Bump `proton-sdk` / `proton-drive-rs` to 0.6.3 (anonymous-node moves,
  `TrashTime` trashed detection).
2026-09-15 13:59:13 +02:00
nvrl c28803b037 chore(changelog) updated changelog for 1.9.2 and 1.9.3 2026-09-08 14:58:48 +02:00
nvrl cd8474954b chore(version) bumped to 1.9.3 2026-09-01 17:00:43 +02:00
nvrl d7e07d042d Merge pull request #22 from kacperpaczos/fix/login-poll-backoff
fix(cli): back off while waiting for a stored session
2026-09-01 16:38:29 +02:00
nvrl 45852082c0 fix(cli,core): wake the parked login-wait daemon on sign-in
The login backoff had no wake-up path. A unit started at boot sits in
wait_for_session and reaches the ceiling within ~90s; the user then signs
in and `enable --now` is a no-op on an already-active unit, so the daemon
kept sleeping and the mount appeared up to a full interval late.

- service::enable_start now runs `enable` + `restart` instead of
  `enable --now`, so the GUI login and the tray's Connect interrupt the
  sleep immediately and still start a unit that is down.
- `pdfs login` restarts the unit when it is already active. It stays out
  of `enable` on purpose, so a headless setup driving `pdfs daemon` by
  hand is left alone.
- Lower the backoff ceiling from 60s to 15s. It is still an 80% cut in
  keyring/D-Bus wakeups (28,800/day -> 5,760/day) and it bounds the
  worst-case latency for paths that never touch systemd.

Also corrects the flat-3s-poll figure in the doc comment: 86400/3 is
28,800 checks a day, not 48,000.
2026-09-01 16:28:25 +02:00
nvrl 99ad3838e1 fix(GALLERY) fixed takeout date conversion + repair command 2026-08-28 13:06:50 +02:00
Kacper Paczos 637b512f6a fix(cli): back off while waiting for a stored session
The daemon's wait-for-login loop slept a flat 3 seconds between checks and
never gave up, so an enabled service with no saved session polled the keyring
28,800 times a day. Each check is more than a sleep: auth::load() builds a
fresh keyring::Entry, so every iteration opens a new D-Bus connection to the
Secret Service and negotiates a session on it.

Back off from 3s to a 60s ceiling and say "not logged in" once at INFO rather
than on every iteration, which also stops the journal filling with tens of
thousands of identical lines.

The wait is extracted into wait_for_session() taking the load and sleep calls
as parameters, so the schedule is testable without a keyring and without real
time passing.

Refs #21
2026-08-25 13:24:46 +02:00
nvrl 16ad6548c8 chore(DOCS) updated changelog 2026-08-21 23:32:06 +02:00
nvrl 847ece17d7 version bumped to 1.9.1 2026-08-21 22:48:28 +02:00
nvrl 50d2acdaba Merge pull request #20 from emptyname-org/feature/files-thumbnails-statusbar
feat(gui): add file thumbnails and a browser status bar
2026-08-21 22:44:58 +02:00
emptyname 53caf63ad4 Fix thumbnail lifecycle failures 2026-08-21 12:32:31 +01:00
nvrl 5176dd91e1 fixed icon 2026-08-21 04:13:11 +02:00
nvrl e88903d5ff chore(branding) added branding, best practice to repo 2026-08-21 04:09:58 +02:00
emptyname b3e6af1ae5 docs: document thumbnail runtime support 2026-08-18 13:06:40 +01:00
emptyname b0f9513915 feat(gui): show file thumbnails and browser status bar 2026-08-18 13:06:36 +01:00
emptyname 91b0864fce feat(thumbnails): generate cached previews for Drive files 2026-08-18 13:06:27 +01:00
nvrl 8bd9481902 fix(ui) improved ux 2026-08-18 12:50:49 +02:00
nvrl b2d5870834 fix(ui) improved ux 2026-08-18 12:06:28 +02:00
nvrl 078774f6a1 release(1.8.2) 2026-08-17 00:49:24 +02:00
nvrl 326a9b4e92 chore(docs) updated changelog + bug states 2026-08-17 00:04:54 +02:00
nvrl 78dc72a34d release(1.8.1) 2026-08-16 23:33:43 +02:00
nvrl 438b2b463e chore(docs) updated changelog 2026-08-16 22:03:55 +02:00
nvrl 4cd49168c4 fix(drain) failed attempts 2026-08-16 22:02:27 +02:00
nvrl 481c3fe0c7 version bump + bug docs 2026-08-16 21:34:55 +02:00
nvrl 098da2e97a fix(log) log unreachable nodes 2026-08-16 21:34:12 +02:00
nvrl d03d3eac3f fix(drive) locks + 1.7.1 2026-08-16 20:41:03 +02:00
nvrl 433e206203 chore(changelog) 2026-08-16 00:54:55 +02:00
nvrl 35418eea3f fix(perf) read-only connection pool, States doesn't write to db, search optimized 2026-08-16 00:33:27 +02:00
nvrl e48c4bb4b2 fix(fs) block fetch, sequential prefetch, in memory LRU 2026-08-15 23:44:13 +02:00
nvrl 7ad9231858 fix(fs) locks, vacuum, recover fsynced writes 2026-08-15 19:17:56 +02:00
nvrl e396c6b4d6 fix(fs) performance, locks 2026-08-15 18:49:07 +02:00
nvrl b37ab78451 version bump 2026-08-12 16:16:16 +02:00
nvrl 6e3d2c335e improved search prompt 2026-08-12 16:15:27 +02:00
nvrl 14b6df7bce fix(prompt) cached file open instead of actualy files 2026-08-11 14:22:13 +02:00
nvrl 4d76462e62 chore(versio) bumped to 1.4.0 2026-08-10 15:35:59 +02:00
nvrl a5e03ad8da persistent entity cache, streaming batche 2026-08-10 15:22:04 +02:00
nvrl 2336ad9e87 added album to photos, renamed gallery view 2026-07-31 20:31:25 +02:00
nvrl d988236bfa wired new locations into gui, fuse acceptance with new permission based mounts 2026-07-31 19:37:12 +02:00
nvrl d78ddeab8e cargo fmt 2026-07-31 10:18:30 +02:00
nvrl b04cce3a23 fixed B78 2026-07-31 10:15:13 +02:00
nvrl 7e8403936b fixed permission error on on-demand mounts 2026-07-31 09:46:51 +02:00
nvrl 866a174c7a fixed rpm signing 2026-07-30 02:55:15 +02:00
nvrl 352117e806 fixed rpm signing 2026-07-30 02:23:49 +02:00
nvrl 4b5666eacb added .deb signing + version bump 2026-07-30 02:13:07 +02:00
nvrl 9bbd8eb982 fixed a migration bug where ProtonDrive becomse unwritable 2026-07-30 02:11:23 +02:00
nvrl 77198ed261 version bumped to 1.2 2026-07-29 21:36:24 +02:00
nvrl 0d79c46729 version bumped to 1.2 2026-07-29 21:30:10 +02:00
nvrl a8210ce8fd implemented shared with me + verification 2026-07-29 21:29:07 +02:00
nvrl 551e14b644 implemented shared id permissions 2026-07-29 19:26:57 +02:00
nvrl 7de667b386 implemented primary mount shared with me 2026-07-29 17:04:42 +02:00
nvrl 295bbeadac implemented share_access, POSIX read-only mode 2026-07-29 14:57:56 +02:00
nvrlandClaude Opus 5 32817870a5 chore(packaging): bump PKGBUILD pkgver to 1.1.1
Version bump in b93e41e updated Cargo.toml only, so the release
workflow's tag/manifest/PKGBUILD agreement check failed on v1.1.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BEWKS8smdPfLMZacgDXjAR
2026-07-27 03:25:56 +02:00
nvrl b93e41efc5 version bump to 1.1.1 2026-07-26 20:18:45 +02:00
nvrl 082ef8522a fixed refresh pull bug for trash 2026-07-26 20:17:59 +02:00
nvrl 2e95655244 Merge pull request #19 from narrrl/release/1.1.0
release 1.1.0
2026-07-26 19:03:00 +02:00
nvrl 942cb9f583 fixed concurrency bugs + performance 2026-07-26 19:01:48 +02:00
nvrl 8470662c54 fixed race conditions + extendet test suite 2026-07-26 15:56:58 +02:00
nvrl a091c2f34f implemented sweep mode and better conflict handling 2026-07-26 02:06:58 +02:00
nvrlandClaude Opus 5 bca0fe34bc fix(fuse,core): key conflict detection on revision id, stop transient-name seals (B69, B70)
B69 — spurious `(sync-conflict)` copies came from `(mtime, size)` being used as a
revision identity. The server re-stamps a sealed revision with its own commit
time, so the drain diverted a write into a conflict copy of its own base; and
`plan_file` classified a no-baseline (local, remote) pair as `Conflict`, forking
every pre-existing file on first reconcile.

- `Baseline` carries `revision_id` (from the SDK's new `active_revision_id`),
  captured at write-open and preserved across supersede/rebaseline.
  `revision_changed` trusts the id and only falls back to `(mtime, size)` for
  old sidecars.
- Planner gains `FilePlan::AdoptBaseline`: equal-size no-baseline pairs are
  recorded, not conflicted. Divergent sizes stay a real conflict.
- New `sweep.rs` (`pdfs-conflict-sweep`, 5-min cadence) removes conflict copies
  proven identical by size + `content_sha1` and surfaces divergent ones once as
  an `ActivityKind::Conflict` entry. Never removes what it cannot prove.

B70 — browsers downloading into an on-demand mount had their in-flight temps
sealed as canonical revisions, exiling the finished file to a conflict copy.
`syncignore::is_transient_name` now recognises download/scratch temps, and the
on-demand `release`/`rename` paths hold them (`set_create_hold`) instead of
sealing until the name is finalized.

Workspace deps bumped to proton-sdk/proton-drive-rs 0.2.2 (registry, no patch).

Live validation against a real account still pending for both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8P42asymU158CPnhjRwri
2026-07-25 15:24:31 +02:00
nvrl 6df757006a updated documentation + readme 2026-07-22 15:29:34 +02:00
nvrl 6f70eca78e Merge pull request #18 from narrrl/release/1.0.0
release/1.0.0
2026-07-22 15:18:00 +02:00
nvrl 0d8a474442 version bump 2026-07-22 15:11:33 +02:00
nvrl 8be0f04b61 fixed fuse bugs from BUGS.md
- Durable scratch/staging publication and preservation on failure.
      - Transactional pending-operation supersession.
      - Conflict copies cannot overwrite older copies or lose the source.
      - Single-entry wipe protection and incomplete-scan aborts.
      - Failed local deletion retains its baseline.
      - Downloads are fsynced before baseline settlement.
      - Future database schemas are refused.
      - Event cursors advance only after successful invalidation and persistence.
      - IPC frame, timeout, and concurrency limits.
      - Atomic config saves; malformed configs are preserved.
      - Sensitive directories require correct ownership, real directories, and 0700.
      - Debian package now includes service and tray autostart files.
      - Release workflow enforces versions, tests, lint, formatting, and offline acceptance.
      - CI now runs offline filesystem acceptance.
2026-07-22 14:49:06 +02:00
nvrl d5165b36a2 Merge pull request #17 from narrrl/feature/testing
Feature/testing
2026-07-22 14:29:39 +02:00
nvrl a76f36bdff fixed fs bugs + compleance test for fuse fs 2026-07-22 14:24:40 +02:00
nvrl e9009dd47c Merge pull request #16 from narrrl/fix/prompt
Fix/prompt
2026-07-22 11:51:43 +02:00
nvrl d0b49d3c8c fixed a truncate defect 2026-07-22 11:50:58 +02:00
nvrl 25f578161d added more comprehensive testing 2026-07-22 11:43:17 +02:00
nvrl ea56818a80 prompt refactor 2026-07-22 11:27:57 +02:00
nvrl 39ab4aae48 prompt refactor 2026-07-22 09:50:42 +02:00
nvrl 6cba77d3cb Merge pull request #15 from narrrl/fix/ioctl-handler
Fix/ioctl handler
2026-07-22 03:07:30 +02:00
nvrl 26375885ed updated workflow 2026-07-22 03:04:59 +02:00
nvrl 544d0913e8 added fuse compliance testing 2026-07-22 03:04:40 +02:00
nvrl 714b3048dc Merge pull request #14 from narrrl/fix/ioctl-handler
filesystem fixes, critical + feature implementations
2026-07-22 02:30:57 +02:00
nvrl bef39f75cf sync refactor 2026-07-22 02:22:40 +02:00
nvrl 0a1fa95eb4 refactor 2026-07-22 02:17:19 +02:00
nvrl 242a28bb8f refactor from monolithic structure 2026-07-22 02:02:26 +02:00
nvrl bd17d506f8 fixed rm before online del, mirror bug 2026-07-22 01:52:10 +02:00
nvrl 1cd6a26be3 fix(fuse,core): remediate audit bugs B23, B26-B29, B33 and update BUGS tracking
- B23: Generate synthetic StagedWrite sidecars in rescue_scratch when sidecar is corrupted
- B26: Add open_count and unlinked state fields to defer node removal and cache eviction on unlinked open files until release
- B27: Discard orphaned write revisions for local UIDs unlinked prior to upload
- B28: Add emergency_evict to ContentCache for LRU blob eviction upon ENOSPC
- B29: Implement FUSE forget handler and forget_lookup in state to prune unreferenced inodes
- B33: Quarantine failing drain ops on HTTP 40x / max retries to unblock offline sync queue
- docs: Track audit items B18-B36 in docs/BUGS.md with fix details and verification results
2026-07-22 01:18:35 +02:00
nvrl fe75998cea fix(filesystem): resolve critical data loss, POSIX non-compliance, and deadlock vulnerabilities
- Standardize POSIX unlink (EISDIR) and rmdir (ENOTDIR/ENOTEMPTY) checks
- Defer local directory eviction in mode switch until FUSE mount verification succeeds
- Enforce fsync before atomic renames in ContentCache store operations
- Add directory ancestor loop check in rename (EINVAL)
- Implement punch_hole interval tracking in fallocate
- Purge un-created local node write revisions on unlink
- Fix control socket permission race with octal umask 0o77
- Add unit tests for state, DB, and FUSE POSIX logic (208/208 tests passing)
2026-07-22 00:23:55 +02:00
nvrl 9d55ffb9f6 fixed io lock with alloced files from for example aria2c 2026-07-21 23:23:20 +02:00
nvrl 2275004f79 impplemented fallocate 2026-07-21 22:39:35 +02:00
nvrl 914ff2b5ae added ioctl handler 2026-07-21 16:42:41 +02:00
nvrl 01677f2670 updated documentation on bugs/features 2026-07-21 10:35:53 +02:00
nvrl 3f9d36607a updated readme 2026-07-21 02:12:50 +02:00
nvrl f1399aa661 Merge pull request #13 from narrrl/feature/selective-sync
Feature/selective sync + diagnostics
2026-07-20 23:09:47 +02:00
nvrl a3d9d05777 added settings for this device + quota in settings 2026-07-20 23:08:23 +02:00
nvrl bf625bf8d9 added functionality to open shared files with you + fixed bug where mid transfer the SIGTERM signal can corrupt the mount 2026-07-20 22:30:16 +02:00
nvrl e0f0c7e0b7 added json output 2026-07-20 20:44:37 +02:00
nvrlandClaude Opus 4.8 a6a925667a add --json output to the query commands
Scripts and health checks had to scrape formatted text, which changes whenever
the display does.

--json is global and applies to status, ls, pins, sync list, devices list,
transfers, activity, and cache inspect. Action commands keep their human
output; a script that needs to know whether one worked has the exit code.

Two decisions worth recording:

The daemon's own reply is serialized rather than a hand-built parallel
structure, so new fields reach the JSON without anyone remembering to add them
-- but the variant tag is stripped first. Serializing CtlResponse directly
yields {"SyncFolders": {"items": [...]}}, which would make every caller write
jq '.SyncFolders.items[]' and couple it to the name of an internal enum
variant. Callers get {"items": [...]}.

A daemon-side error prints its payload and then fails the command. The first
cut exited 0 after printing the error body, which was caught by running
`pdfs --json ls NoSuchFolder`: a script would have read a failed lookup as
success. The body is still emitted so `kind` is available, but the exit code
is what `set -e` and `if pdfs ...` actually test.

status composes its object by hand instead, being the one query that merges
the local keyring with the daemon -- including the logged-in-but-no-daemon
case, which has no daemon reply to pass through at all.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JFzNQyb4TWrCRWcnegqWxY
2026-07-20 20:38:48 +02:00
nvrlandClaude Opus 4.8 e259016a00 checkpoint the WAL after the vacuum, not before
Verified against a live install: vacuum reported "34.3 MiB freed (170.3 MiB
-> 136.0 MiB), 0 WAL frame(s) checkpointed" while the WAL grew from 67 MiB to
143 MiB. Net disk use barely moved, and the frame count was zero because the
checkpoint ran before the VACUUM, when there was nothing to fold.

Reordering is strictly better, but the commit message should not overstate it:
the live inflation happens because the daemon's other connections hold read
transactions and a TRUNCATE checkpoint waits on readers, so under a busy
daemon this can still move little. journal_size_limit stays the backstop --
the live WAL did fall back to its 64 MiB cap on its own afterwards.

The accompanying test deliberately does not assert on WAL size or frame count.
Measured both ways, a single-connection test reports frames=0 and a 0-byte WAL
under either ordering, because SQLite folds the log itself without a
concurrent reader to block it; an assertion there would pass on the bug and be
decoration. It asserts what is reliably true instead -- that a file-backed
vacuum reclaims space and leaves the database sound -- and the ordering
rationale plus the residual uncertainty are documented on vacuum() itself.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JFzNQyb4TWrCRWcnegqWxY
2026-07-20 20:32:18 +02:00
nvrlandClaude Opus 4.8 471f57b8e1 add cache inspect/vacuum and a diagnose self-check
A corrupt database or an oversized cache previously left the user with no
answer but "delete the state directory and re-sync", which for a large
account means re-downloading everything to fix a problem that may have been
a few stale rows.

pdfs diagnose reports on paths, keyring, daemon, and mountpoint, and exits
non-zero if any check fails so it works in a health check. Every check is
written to run with no daemon, because that is the situation it gets run in:
a stopped daemon is reported as a finding rather than aborting the report.
Writability is tested by writing, since permission bits say nothing about a
full disk or a read-only remount.

pdfs cache inspect reports database size, reclaimable space, per-table row
counts, and cache usage; --deep adds SQLite's integrity check, opt-in because
it reads every page. The report distinguishes "checked and clean" from "not
checked" rather than letting an empty problem list imply a clean bill of
health it never gave.

pdfs cache vacuum checkpoints the WAL and compacts the database.

Deliberately no orphaned-cache-row pruning: ContentCache::reconcile already
rebuilds the index from what is on disk at every open, so a second pass here
would duplicate it against a worse view of the truth. The WAL is likewise
left to journal_size_limit, which already caps it -- a periodic checkpoint
would paper over a problem that no longer exists.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JFzNQyb4TWrCRWcnegqWxY
2026-07-20 19:54:42 +02:00
nvrlandClaude Opus 4.8 3ade754ea2 add .pdfsignore selective sync for mirror folders
Mirror folders walked and uploaded every path under their root, so syncing a
project directory pushed node_modules/, target/, and .git/ to Drive -- quota
spent on files nobody wants there, and enough API traffic to invite rate
limiting.

Rules come from a .pdfsignore (or .protonignore) at the folder root, unioned
with an ignore_patterns list in config.json that defaults to the usual build,
VCS, and editor-leftover globs. Both are re-read at the start of every pass,
so edits apply without restarting the daemon.

The filter deliberately sits on the classification union in
classification_order(), not on walk_local alone. do_reconcile classifies
local | remote | baseline, so a file synced before a rule started matching it
is absent from local but present in the other two -- the "local deleted,
remote untouched" arm, which calls trash_nodes. Filtering only the walk would
have meant that adding a line to .pdfsignore silently trashed files on Drive
on the next pass. Ignored baseline rows are left in place so un-ignoring
re-adopts the existing remote file instead of re-uploading it.

guard_local_wipe takes a filtered baseline for the same reason: an ignored
path is absent from the walk by rule rather than by loss, and counting those
as missing would trip the guard on every pass once a rule covered the tree,
wedging that folder's sync permanently.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JFzNQyb4TWrCRWcnegqWxY
2026-07-20 19:40:40 +02:00
nvrl 661ae3cf7a fixed incorrect versioning for rpm build 2026-07-20 17:06:36 +02:00
nvrl f803bf3cd7 fixed github ci 2026-07-20 15:06:17 +02:00
nvrl 084740ad95 version bump to 0.6.2 2026-07-20 14:54:13 +02:00
nvrl 65eed0cc72 Merge pull request #12 from narrrl/release/0.6.2
release/0.6.2
2026-07-20 14:53:06 +02:00
nvrl 1e0e284d5f per-chunk wakeup, B13 + B14 2026-07-20 14:52:31 +02:00
nvrl f0fbd0775c fixed long getattr load times with ls -l 2026-07-20 13:37:59 +02:00
nvrl d25a573e8f fixed mount-kind correlation 2026-07-20 00:09:00 +02:00
nvrl eafe1307b8 Merge pull request #11 from narrrl/fix/return-prompt
Fix/return prompt
2026-07-19 23:53:04 +02:00
nvrl 056ae0463f Merge pull request #10 from narrrl/fix/capture #
added webgtk recaptcha
2026-07-19 23:52:39 +02:00
nvrl b2b0fe7b99 fixed return key not opening file 2026-07-19 23:39:24 +02:00
nvrl d13042b550 Merge branch 'main' into fix/capture 2026-07-19 23:25:10 +02:00
nvrl cb67d97b0f added webgtk recaptcha 2026-07-19 23:24:18 +02:00
nvrl 0323fdc44d Merge pull request #7 from kacperpaczos/fedora-packaging
Add Fedora packaging
2026-07-19 22:26:46 +02:00
nvrl 2b2a49c679 Merge branch 'main' into fedora-packaging 2026-07-19 22:26:28 +02:00
nvrl ed129f7c71 Merge pull request #9 from narrrl/feature/fedora-packeging
Feature/fedora packeging
2026-07-19 20:50:44 +02:00
nvrl b39d09fb9d version bump to 0.6.1 2026-07-19 20:49:50 +02:00
nvrl c8f57ed5c9 added fedora packeging 2026-07-19 20:49:02 +02:00
Kacper PaczosandCursor b90b7e4b9c Add Fedora packaging and fix crates.io SDK build.
Remove the local proton-sdk path patch so the workspace builds from published crates, and document Fedora 44 build/runtime plus a local RPM spec.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 19:46:56 +02:00
nvrl cd437dff9c removed local sdk ref 2026-07-19 19:42:18 +02:00
nvrl 681db981f5 Merge pull request #6 from narrrl/release/0.6.0
Release/0.6.0
2026-07-19 19:33:45 +02:00
nvrl 45bd48fa5f version bump 2026-07-19 19:32:30 +02:00
nvrl 4a2643163b added recovery instructions 2026-07-19 19:30:40 +02:00
nvrl 829f0ee643 updated architecture 2026-07-19 19:30:25 +02:00
nvrl 532628456e fix(fuse): keep inode stable across rename
relocate() called forget(), dropping the node's by_uid mapping, so
the re-enumeration assigned a fresh inode. The kernel had already
carried the renamed dentry onto the old one and holds it for the
entry TTL, so lookup/getattr/opendir resolved to an inode entries
no longer held and returned ENOENT — while readdir walked children
and reported the new inode. A renamed directory listed in its
parent but could not be entered until the TTL expired.

Rewrite the node in place and invalidate both parents' listings
instead. Also stops the moved node's DB row being deleted and
recreated on every move, which the B1 data-loss fix worked around
2026-07-19 19:30:05 +02:00
nvrl f9250211b8 fixed missing trash logging 2026-07-19 17:45:47 +02:00
nvrl e8f11f7c42 added docs 2026-07-19 17:42:59 +02:00
nvrl 717521195c fixed long file names 2026-07-19 15:57:36 +02:00
nvrl 190ea3e9cb fixed some conflict bugs + ui fix 2026-07-19 15:36:04 +02:00
nvrl 1d5fdfb6b9 some performance optimizations 2026-07-19 12:48:48 +02:00
nvrl a90cf83ff5 fixed some bugs 2026-07-18 19:24:11 +02:00
nvrl 7766628098 refactor, fixed monolithic 2026-07-18 18:33:14 +02:00
nvrl 232b3b5348 db.rs refactor 2026-07-18 14:20:37 +02:00
nvrl d58dc46708 added parking_lot mutex 2026-07-18 12:24:28 +02:00
nvrl 38aca3cd48 video streaming 2026-07-18 12:08:34 +02:00
nvrl c555315998 improved ui + prompt 2026-07-17 23:34:46 +02:00
nvrl 81e7133003 perf improvements 2026-07-17 22:30:08 +02:00
nvrl a293593f4a updated sdk dependencie 2026-07-17 16:00:52 +02:00
nvrl 1f8f1936a7 fixed stale revision bug 2026-07-17 15:31:21 +02:00
nvrl 4720b2a5fb concurrent reads 2026-07-17 13:49:16 +02:00
nvrl 50c96cbbf4 implemented real offline structure, with online drain 2026-07-17 12:39:03 +02:00
nvrl dcc62ba420 file pipeping 2026-07-17 11:13:23 +02:00
nvrl fb39594453 Merge pull request #1 from narrrl/feature/sharing-endpoints
Feature/sharing + devices
2026-07-17 11:12:13 +02:00
nvrl 68e07ed426 implemented offline scaffold 2026-07-17 10:23:24 +02:00
nvrl bb9804498c added new folder icon, fixed db size (max log size), progress bars 2026-07-16 18:13:48 +02:00
nvrl a38d0371ec implemented shared files, folder uploads, caching 2026-07-16 01:18:03 +02:00
nvrl cc744d56c7 added photos thumbnail caching + bigger photos 2026-07-15 01:00:43 +02:00
nvrl ae0d374ec0 updated readme screenshots 2026-07-14 19:36:11 +02:00
nvrl c52d829a43 added trash 2026-07-14 14:55:54 +02:00
nvrl 30039c43d7 updated photos gallery 2026-07-14 12:40:33 +02:00
nvrl 6c67226ab0 version bump 2026-07-13 02:44:59 +02:00
nvrl 6f7269b9b6 updated to use github repo for sdk 2026-07-13 02:44:20 +02:00
nvrl dacacf51bb added local file search to prompt 2026-07-13 02:33:18 +02:00
nvrl 8f28f5fd22 improvements to the photos gallery 2026-07-13 02:15:08 +02:00
nvrl 924e07e85f updated prompt design 2026-07-10 16:03:31 +02:00
nvrl e77ab0ffa9 updated readme + workflows 2026-07-01 00:50:20 +02:00
nvrl d131d3dca8 implemented proton drive prompt 2026-07-01 00:21:42 +02:00
nvrl 5766bc4ed1 transfer ui 2026-07-01 00:02:35 +02:00
nvrl 35bc957cd5 transfer progress api 2026-06-30 23:52:41 +02:00
nvrl f9000f8540 version bump 2026-06-30 21:42:09 +02:00
nvrl d77d7ed817 version bump for sdk 2026-06-30 21:11:21 +02:00
nvrl 56f2b35aca version bump to 0.2.2 2026-06-30 18:39:11 +02:00
nvrl 56abe3fe14 added settings + non blocking ui actions 2026-06-30 18:38:38 +02:00
nvrl 5e71148fec version bump 0.2.1 2026-06-30 17:42:38 +02:00
nvrl 2812e0c893 clippy + pkgbuild 2026-06-30 17:41:52 +02:00
nvrl 03dd8d8496 implemented database for search 2026-06-30 17:35:18 +02:00
nvrl 5e151bbfc5 updated readme 2026-06-30 06:31:51 +02:00
nvrl 172ec4b0b7 version 0.2.0 2026-06-30 06:23:36 +02:00
nvrl ec4f5ea32f fmt 2026-06-30 06:23:00 +02:00
nvrl 81a0ecce54 added block cache 2026-06-30 06:22:40 +02:00
nvrl cc2f370670 lazy 2fa 2026-06-30 06:01:36 +02:00
nvrl b3e8e8ab96 performance fixes 2026-06-30 05:56:21 +02:00
nvrl 8e8d42ad3b updated ux 2026-06-30 05:50:20 +02:00
nvrl 99e80a7206 implemented robust fuse mount 2026-06-30 05:43:14 +02:00
144 changed files with 82634 additions and 3837 deletions

No files matched your search

+76
View File
@@ -0,0 +1,76 @@
name: Bug report
description: Something in the client misbehaves
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Please check [`docs/BUGS.md`](../blob/main/docs/BUGS.md) first — the issue may already be
tracked there with context.
**Do not report a security vulnerability here.** See [SECURITY.md](../blob/main/SECURITY.md).
- type: textarea
id: what
attributes:
label: What happened
description: What you did, what you expected, and what happened instead.
validations:
required: true
- type: textarea
id: repro
attributes:
label: Steps to reproduce
placeholder: |
1. …
2. …
validations:
required: true
- type: input
id: version
attributes:
label: Version
description: Output of `pdfs --version`, and how you installed it (deb / rpm / AUR / source).
validations:
required: true
- type: input
id: environment
attributes:
label: Distribution and desktop
placeholder: "Fedora 44, GNOME 48 (Wayland)"
validations:
required: true
- type: dropdown
id: surface
attributes:
label: Where does it happen
multiple: true
options:
- FUSE mount / file operations
- Sync or backed-up folders (Computers)
- CLI (pdfs)
- Desktop app (pdfs-app)
- Tray (pdfs-tray)
- Search launcher (pdfs-prompt)
- Login / keyring
- Photos
- Packaging / installation
validations:
required: true
- type: textarea
id: diagnose
attributes:
label: "`pdfs diagnose` output"
description: Redact paths or the account address if you prefer.
render: console
- type: textarea
id: logs
attributes:
label: Daemon logs
description: "`journalctl --user -u proton-drive.service -n 200 --no-pager`"
render: console
- type: checkboxes
id: dataloss
attributes:
label: Data
options:
- label: This bug involved missing, truncated, or overwritten file content.
+11
View File
@@ -0,0 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: Security vulnerability
url: https://github.com/narrrl/proton-drive-linux/security/advisories/new
about: Report privately — never in a public issue.
- name: Known issues
url: https://github.com/narrrl/proton-drive-linux/blob/main/docs/BUGS.md
about: The authoritative ledger of tracked bugs and their status.
- name: Proton Drive service problems
url: https://proton.me/support
about: This is an unofficial client. Account or service issues go to Proton AG.
@@ -0,0 +1,37 @@
name: Feature request
description: Suggest a capability or an improvement
labels: ["enhancement"]
body:
- type: textarea
id: problem
attributes:
label: The problem
description: What are you trying to do that the client makes hard or impossible today?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: What you'd like to see
validations:
required: true
- type: dropdown
id: surface
attributes:
label: Which part of the client
multiple: true
options:
- FUSE mount
- Sync / Computers
- CLI (pdfs)
- Desktop app (pdfs-app)
- Tray (pdfs-tray)
- Search launcher (pdfs-prompt)
- Photos
- Packaging
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives you considered
+26
View File
@@ -0,0 +1,26 @@
## What this changes
<!-- The user-visible effect, in a sentence or two. -->
**Crates touched:**
**Related `docs/BUGS.md` entries:**
## Verification
```bash
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace --locked
```
<!-- Plus whatever else you ran: acceptance suite, a live mount, manual steps. -->
## Implications
- [ ] Adds a SQLite migration (new `MIGRATION_V*`, `SCHEMA_VERSION` bumped — no shipped migration edited)
- [ ] Touches recovery, staging, or anything that can be the only copy of user data
- [ ] Changes packaging, the systemd unit, or desktop entries
- [ ] Changes the control protocol (daemon and front ends updated together)
- [ ] None of the above
<!-- Screenshots for GTK changes. -->
+59
View File
@@ -0,0 +1,59 @@
name: CI
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
rust:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Install native dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
pkg-config \
libfuse3-dev \
libgtk-4-dev \
libadwaita-1-dev \
libwebkitgtk-6.0-dev \
libsecret-1-dev \
libdbus-1-dev
- name: Install minimum supported Rust
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: '1.96'
components: rustfmt, clippy
- uses: swatinem/rust-cache@v2
- name: Formatting
run: cargo fmt --all -- --check
- name: Lints
run: cargo clippy --workspace --all-targets -- -D warnings
- name: Unit tests
run: cargo test --workspace --locked
- name: Account-free filesystem acceptance
run: scripts/fuse-acceptance.sh --offline-only
- name: Acceptance runner safety guard
run: |
if scripts/fuse-acceptance.sh /tmp >acceptance.out 2>&1; then
echo "runner unexpectedly accepted a non-FUSE path"
exit 1
fi
grep -q "refusing primary non-FUSE path" acceptance.out
# Real Proton credentials and /dev/fuse deliberately do not run on pull
# requests. Follow docs/TESTING.md on a dedicated test-account runner.
+80 -8
View File
@@ -13,6 +13,7 @@ jobs:
build-release:
name: Build and Release
runs-on: ubuntu-24.04
environment: 'rpm signing'
steps:
- name: Checkout repository
@@ -26,18 +27,38 @@ jobs:
libfuse3-dev \
libgtk-4-dev \
libadwaita-1-dev \
libwebkitgtk-6.0-dev \
libsecret-1-dev \
libdbus-1-dev \
desktop-file-utils
desktop-file-utils \
rpm
- name: Set up Rust
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: stable
toolchain: '1.96'
components: rustfmt, clippy
- name: Cache Cargo target
uses: swatinem/rust-cache@v2
- name: Verify tag and manifest versions
if: startsWith(github.ref, 'refs/tags/')
shell: bash
run: |
tag_version="${GITHUB_REF_NAME#v}"
manifest_version="$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -n1)"
pkgbuild_version="$(sed -n 's/^pkgver=//p' packaging/PKGBUILD | head -n1)"
test "$tag_version" = "$manifest_version"
test "$tag_version" = "$pkgbuild_version"
- name: Release quality gates
run: |
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace --locked
scripts/fuse-acceptance.sh --offline-only
- name: Build release binaries
run: |
cargo build --release --locked
@@ -62,6 +83,7 @@ jobs:
cp target/release/pdfs dist/binaries/
cp target/release/pdfs-tray dist/binaries/
cp target/release/pdfs-app dist/binaries/
cp target/release/pdfs-prompt dist/binaries/
# Create tarball
tar -czf proton-drive-linux-${{ env.VERSION }}-x86_64.tar.gz -C dist/binaries .
@@ -73,15 +95,20 @@ jobs:
mkdir -p deb/usr/share/applications
mkdir -p deb/usr/share/icons/hicolor/scalable/apps
mkdir -p deb/usr/share/licenses/proton-drive-linux
mkdir -p deb/usr/lib/systemd/user
mkdir -p deb/etc/xdg/autostart
# Copy binaries
cp target/release/pdfs deb/usr/bin/
cp target/release/pdfs-tray deb/usr/bin/
cp target/release/pdfs-app deb/usr/bin/
cp target/release/pdfs-prompt deb/usr/bin/
# Copy desktop integration files
cp packaging/io.narl.proton-drive-linux.desktop deb/usr/share/applications/
cp packaging/io.narl.proton-drive-linux-tray.desktop deb/etc/xdg/autostart/
cp packaging/io.narl.proton-drive-linux.svg deb/usr/share/icons/hicolor/scalable/apps/
cp packaging/proton-drive.service deb/usr/lib/systemd/user/
cp LICENSE deb/usr/share/licenses/proton-drive-linux/
# Calculate Installed-Size in KB for the control file
@@ -95,18 +122,61 @@ jobs:
Priority: optional
Architecture: amd64
Maintainer: Nils Pukropp <contact@narl.io>
Depends: fuse3, libgtk-4-1 (>= 4.12.0), libadwaita-1-0 (>= 1.5.0), libsecret-1-0
Depends: fuse3, libgtk-4-1 (>= 4.12.0), libadwaita-1-0 (>= 1.5.0), libsecret-1-0, libwebkitgtk-6.0-4, libimage-exiftool-perl
Installed-Size: ${INSTALLED_SIZE}
Description: Proton Drive client for Linux
Proton Drive client for Linux featuring:
- FUSE files-on-demand mount
- Command-line interface (CLI)
- GTK4 system tray and desktop application
Homepage: https://github.com/narrrl/proton-drive-linux
Description: Unofficial Proton Drive client for Linux
Files-on-demand Proton Drive for the Linux desktop, unaffiliated with Proton AG:
- FUSE virtual mount with block-level caching
- Scriptable command-line interface (pdfs)
- GTK4 desktop application, system tray, and search launcher
EOF
# Build Debian package
dpkg-deb --build deb proton-drive-linux_${{ env.VERSION }}_amd64.deb
- name: Package Fedora package (.rpm)
run: |
# Sanitize version (replace hyphens with dots for RPM compatibility)
RPM_VERSION=$(echo "${{ env.VERSION }}" | tr '-' '.')
# Create RPM build directories
mkdir -p rpmbuild/{BUILD,RPMS,SOURCES,SPECS,SRPMS,BUILDROOT,db}
# Build RPM package
rpmbuild -bb \
--nodeps \
--define "_topdir $PWD/rpmbuild" \
--define "_sourcedir $PWD" \
--define "git_dir $PWD" \
--define "version ${RPM_VERSION}" \
--define "_dbpath $PWD/rpmbuild/db" \
packaging/proton-drive-linux.spec
# Copy built RPM to root directory
cp rpmbuild/RPMS/x86_64/*.rpm .
- name: Sign Fedora package (.rpm)
env:
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
GPG_KEY_NAME: ${{ secrets.GPG_KEY_NAME }}
GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
run: |
if [ -n "$GPG_PRIVATE_KEY" ] && [ -n "$GPG_KEY_NAME" ]; then
echo "$GPG_PRIVATE_KEY" | gpg --batch --pinentry-mode loopback --passphrase "$GPG_PASSPHRASE" --import
cat <<EOF > ~/.rpmmacros
%_signature gpg
%_gpg_name ${GPG_KEY_NAME}
%__gpg_sign_cmd %{__gpg} gpg --batch --verbose --no-armor --pinentry-mode loopback --passphrase %{_gpg_passphrase} --no-secmem-warning -u "%{_gpg_name}" -sbo %{__signature_filename} --digest-algo sha256 %{__plaintext_filename}
EOF
rpm --addsign --define "_gpg_passphrase $GPG_PASSPHRASE" *.rpm
else
echo "Skipping RPM signing: GPG_PRIVATE_KEY or GPG_KEY_NAME secret is not set."
fi
- name: Upload Workflow Artifacts
uses: actions/upload-artifact@v4
with:
@@ -114,6 +184,7 @@ jobs:
path: |
*.deb
*.tar.gz
*.rpm
if-no-files-found: error
- name: Create GitHub Release
@@ -123,6 +194,7 @@ jobs:
files: |
*.deb
*.tar.gz
*.rpm
draft: false
prerelease: false
env:
+1
View File
@@ -2,3 +2,4 @@
**/*.rs.bk
*.pdb
.env
/rpmbuild
+32
View File
@@ -0,0 +1,32 @@
# Code of Conduct
## Our pledge
We want participation in this project to be a harassment-free experience for everyone, regardless
of age, body size, disability, ethnicity, sex characteristics, gender identity and expression,
level of experience, education, socioeconomic status, nationality, personal appearance, race,
religion, or sexual identity and orientation.
## Standards
Examples of behavior that helps:
- being respectful of differing viewpoints and experience levels,
- giving and accepting constructive feedback gracefully,
- focusing on what is best for the project and its users.
Examples of unacceptable behavior:
- sexualized language or imagery, and unwelcome sexual attention,
- trolling, insulting or derogatory comments, and personal or political attacks,
- public or private harassment,
- publishing others' private information without explicit permission.
## Scope and enforcement
This applies in all project spaces — issues, pull requests, discussions — and when representing the
project in public. Report unacceptable behavior to <contact@narl.io>. Reports are handled
confidentially. Maintainers may edit, remove, or reject contributions that violate this document,
and may temporarily or permanently ban any contributor for behavior they deem inappropriate.
Adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.1.
+70
View File
@@ -0,0 +1,70 @@
# Contributing
Thanks for taking the time. This is an unofficial Proton Drive client that people trust with real
files, so the bar for changes that touch the filesystem, the sync engine, or the database is
deliberately high — everything else is ordinary Rust review.
## Before you start
- Read [`AGENTS.md`](AGENTS.md) for repository conventions and
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for how the daemon, front ends, and SDK fit
together.
- Check [`docs/BUGS.md`](docs/BUGS.md) — it is the authoritative issue ledger, and your problem may
already be tracked there with context.
- For anything larger than a fix, open an issue first so the design can be discussed before you
spend the time.
## Development setup
```bash
git clone https://github.com/narrrl/proton-drive-linux.git
cd proton-drive-linux
cargo build --workspace
```
GUI and FUSE targets need the GTK4, libadwaita, WebKitGTK 6, libsecret, D-Bus, and FUSE3
development packages listed in the [README](README.md#prerequisites). Minimum supported Rust
version is 1.96.
## Quality gates
CI runs exactly these, and they must pass with zero warnings:
```bash
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace --locked
scripts/fuse-acceptance.sh --offline-only
```
Changes to the FUSE layer, sync, or the pending-operation queue should also be exercised against a
real mount before review — see [`docs/TESTING.md`](docs/TESTING.md). Live runs are destructive:
use a dedicated test account, never one holding data you cannot lose.
## Making changes
- **Keep shared logic in `pdfs-core`.** The CLI and GUI never touch the database or the network;
they speak the control protocol to the daemon.
- **The SQLite schema is forward-only.** Never edit a shipped migration — add a new one and bump
`SCHEMA_VERSION` in `crates/pdfs-core/src/db/migrations.rs`.
- **Typed errors and `?` over panics** on runtime paths. Blocking filesystem, keyring, or
control-socket work stays off GTK's main thread.
- **`staging/` and `recovery/` may hold the only copy of user data.** Nothing may clear or purge
them opportunistically.
## Commits and pull requests
Commits follow scoped [Conventional Commits](https://www.conventionalcommits.org), e.g.
`fix(fuse,core): keep truncate composable with queued revisions`.
A pull request should state:
- the user-visible effect and which crates it touches,
- the `docs/BUGS.md` entries it fixes or affects,
- the commands you ran to verify it,
- screenshots for GTK changes,
- and, explicitly, any migration, recovery, data-loss, or packaging implications.
## Security
Do not open a public issue for a vulnerability — see [SECURITY.md](SECURITY.md).
Generated
+1708 -796
View File
File diff suppressed because it is too large. Load diff
+36 -8
View File
@@ -3,16 +3,20 @@ resolver = "2"
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
[workspace.package]
version = "0.1.2"
version = "1.11.0"
edition = "2024"
license = "MIT"
authors = ["Nils Pukropp <contact@narl.io>"]
rust-version = "1.96"
repository = "https://github.com/narrrl/proton-drive-linux"
homepage = "https://github.com/narrrl/proton-drive-linux"
readme = "README.md"
keywords = ["proton", "drive", "fuse", "sync", "linux"]
categories = ["filesystem", "command-line-utilities"]
[workspace.dependencies]
# Local Proton SDK crates (path deps until published; switch to versioned later).
proton-sdk = "0.1"
proton-drive-rs = "0.1"
proton-sdk = { version = "0.6.5" }
proton-drive-rs = { version = "0.6.5" }
# Internal crates.
pdfs-core = { path = "crates/pdfs-core" }
@@ -21,14 +25,38 @@ pdfs-fuse = { path = "crates/pdfs-fuse" }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "io-util", "time", "signal"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
base64 = "0.23"
thiserror = "2"
anyhow = "1"
# Non-poisoning mutexes. A handler that panics while holding a lock must not
# wedge every later acquisition of it: `Workers` catches the unwind to keep the
# pool alive, and `std::sync::Mutex` would defeat that by poisoning the state
# the recovered worker needs.
parking_lot = "0.12"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
directories = "5"
sha2 = "0.10"
keyring = { version = "3", features = ["sync-secret-service", "crypto-rust"] }
directories = "6"
ignore = "0.4"
sha2 = "0.11"
# Google Takeout exports are zip sets; the importer reads their central
# directories and streams entries out without extracting to disk first.
zip = { version = "8", default-features = false, features = ["deflate"] }
# Proton's photo duplicate detection is keyed on the plaintext SHA-1, so an
# import has to speak SHA-1 whatever its age.
sha1 = "0.11"
# Thumbnail generation for photos the server has none for. Decoders only for the
# formats a camera or phone actually produces — the rest of the crate's codecs
# would be dead weight in the daemon.
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "tiff", "bmp", "rayon"] }
rusqlite = { version = "0.40", features = ["bundled"] }
# keyring 4 split into `keyring-core` plus per-platform stores. The libdbus
# Secret Service store is the one keyring 3's `sync-secret-service` wrapped, and
# with no `target` it matches items v3 wrote, so existing logins survive.
keyring-core = "1"
dbus-secret-service-keyring-store = { version = "1", features = ["crypto-rust"] }
clap = { version = "4", features = ["derive"] }
rpassword = "7"
fuser = "0.17"
async-trait = "0.1"
futures = "0.3"
fuser = "0.18"
libc = "0.2"
+401 -21
View File
@@ -1,27 +1,361 @@
# Proton Drive client for Linux (unofficial)
<div align="center">
A fast, unofficial Proton Drive client for Linux. This client provides a FUSE files-on-demand virtual mount, a command-line interface (CLI), and a GTK4 desktop application with system tray integration.
<img src="images/logo.svg" alt="" width="112" height="112">
# Proton Drive for Linux
**Files-on-demand Proton Drive for the Linux desktop** — a FUSE virtual mount with block-level
caching, a scriptable CLI, and a native GTK4 app with tray and search launcher.
[![CI](https://github.com/narrrl/proton-drive-linux/actions/workflows/ci.yml/badge.svg)](https://github.com/narrrl/proton-drive-linux/actions/workflows/ci.yml)
[![Release](https://img.shields.io/github/v/release/narrrl/proton-drive-linux?sort=semver)](https://github.com/narrrl/proton-drive-linux/releases/latest)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![Rust 1.96+](https://img.shields.io/badge/rust-1.96%2B-orange.svg)](https://www.rust-lang.org)
[![Platform: Linux](https://img.shields.io/badge/platform-linux-lightgrey.svg)](#prerequisites)
<img src="images/files.png" alt="The GTK4 file browser" width="820">
</div>
> [!IMPORTANT]
> This is an **unofficial**, community-built client. It is not affiliated with, endorsed by, or
> supported by Proton AG. "Proton" and "Proton Drive" are trademarks of Proton AG, used here only
> to describe what the software talks to. Use a test account first, and read
> [`docs/RECOVERY.md`](docs/RECOVERY.md) before trusting it with data you cannot lose.
## Install
```bash
# Debian / Ubuntu
sudo apt install ./proton-drive-linux_*.deb
# Fedora
sudo dnf install ./proton-drive-linux-*.rpm
# Arch Linux
cd packaging && makepkg -fi
```
Packages for each release are attached to the [latest
release](https://github.com/narrrl/proton-drive-linux/releases/latest); see
[Installation & Packages](#installation--packages) for details and
[Building from Source](#building-from-source) to compile it yourself.
```bash
pdfs login # sign in (SRP + 2FA, credentials go to the system keyring)
pdfs status # mount state, queued writes, cache usage
pdfs-app # the desktop app
```
## Contents
- [Features](#features) · [Quick search](#quick-search-and-media-opening) · [Opening files](#choosing-how-files-open)
- [Selective sync](#selective-sync-pdfsignore) · [Diagnostics](#diagnostics--maintenance) · [Scripting](#scripting---json)
- [Performance](#performance--caching) · [Filesystem safety](#filesystem-safety) · [Screenshots](#screenshots)
- [Prerequisites](#prerequisites) · [Building](#building-from-source) · [Packages](#installation--packages) · [Releases](#automated-releases-cicd)
**Documentation:** [Architecture](docs/ARCHITECTURE.md) · [Testing](docs/TESTING.md) ·
[Recovery](docs/RECOVERY.md) · [Development](docs/DEVELOPMENT.md) ·
[Known issues](docs/BUGS.md) · [Changelog](docs/CHANGELOG.md) ·
[Contributing](CONTRIBUTING.md) · [Security policy](SECURITY.md)
## Features
- **Files-on-demand (FUSE)**: Mount your Proton Drive as a virtual filesystem where files are downloaded only when opened.
- **Files-on-Demand (FUSE)**: Mount your Proton Drive as a virtual filesystem where files are downloaded only when opened, utilizing smart block-level caching and disk-backed writes.
- **Command-Line Interface (CLI)**: Manage your drive, authenticate, and monitor sync status directly from the terminal.
- **GTK4 Desktop App**: Log in, browse files, and configure sync options through a modern, native Linux GUI.
- **Non-Blocking GTK4 Desktop App**: Browse files, manage pins, configure options, and monitor active transfers through a modern, native GUI with a fully non-blocking asynchronous main loop.
- **File Thumbnails**: Browse locally cached image previews in Files, Shared, and Trash, including embedded previews from camera RAW files. Build a whole folder tree from the Files toolbar and resize the grid from the status bar.
- **Shared Links & Invites**: Browse files shared with you by other users, and view/manage your own public shared links directly in the GUI.
- **Local Backup (Computers)**: Sync and back up local directories (like Downloads, Documents, Pictures, etc.) directly to your Proton Drive account.
- **Locations**: One page (and `pdfs locations`) listing every place Proton Drive occupies on this machine — the main mount plus each backed-up folder — with its mode, sync state, and whether it is read-only. Switching a folder between a full local copy and on-demand happens here.
- **System Tray Integration**: Background indicator for status monitoring, quick actions, and fast sync controls.
- **Secure Credential Storage**: Integrates with the system Secret Service (GNOME Keyring, KWallet, etc.) for safe token storage.
- **Unified Search Launcher (HUD)**: A resident Google Drive-style launcher (`pdfs-prompt`) that searches Proton Drive and local files together, ranks the best matches, tolerates abbreviations and typos, and is ideal for a system-wide hotkey. It can also present the same search in your own launcher (`--dmenu`) or, searching live as you type, in `fzf` (`--fzf`).
- **Secure Credential Storage**: Integrates with the system Secret Service (GNOME Keyring, KWallet, etc.) with smart in-memory credential caching to avoid UI thread blockages.
- **Proton Photos Support**: Access your Proton Photos timeline, view thumbnails, and download backed-up media natively (available in the GUI as a navigation tab and via the CLI).
- **File Version History**: Every revision Proton Drive still holds for a file, from the browser's details pane (**Versions**) or `pdfs versions list|restore|save|rm`. Restoring is server-side — no re-upload — and an old version can be written out to a local file without touching the live one.
- **Photo Favourites**: Star a photo in the lightbox, filter the gallery to favourites, or use `pdfs favorite <uid>` / `pdfs photos --favorites`.
- **Photo Albums**: Browse your albums — including the ones shared with you — from the Albums view of the Photos page, or with `pdfs albums` / `pdfs album <uid>`. Album contents open in the same gallery as the timeline.
- **Human Verification (CAPTCHA) Recovery**: Detects sign-in gates (VPN/new IP challenges) and launches an embedded `WebKitWebView` dialog to safely complete the challenge, transparently retrying authentication with the earned token.
- **Selective Sync (`.pdfsignore`)**: Keep build trees, dependency directories, and editor leftovers out of synced folders using gitignore-style rules.
- **Data-Safe Offline Writes**: Durable scratch/staging files and a transactional pending queue preserve acknowledged writes across network failures and restarts.
## Quick Search and Media Opening
`pdfs-prompt` searches Drive metadata and the local home-directory index in one request. Results are ranked together rather than split by source. Matching is case-insensitive and supports exact names, prefixes, parent paths, multiple terms, punctuation-separated words, ordered abbreviations, common typos, and adjacent transpositions.
The prompt stays resident after its first launch, so invoking the shortcut again reuses and resets the existing window. Drive folders open from the mount. Audio and video results also open through FUSE, allowing media players to seek and request ranges without downloading the entire file first; ordinary Drive files are materialized into the local cache before opening.
### Using your own launcher (fuzzel, rofi, wofi, …)
`pdfs-prompt --dmenu` runs the same search through an external dmenu-style
launcher instead of the built-in window, so the HUD matches the rest of a
tiling-WM desktop:
```bash
pdfs-prompt --dmenu # fuzzel/rofi/wofi/tofi/bemenu/dmenu, whichever is installed
pdfs-prompt --dmenu --menu 'fuzzel --dmenu --width 60'
pdfs-prompt --dmenu --query invoice # skip straight to a search
```
A launcher filters a fixed list — it cannot ask for a new one per keystroke — so
searching takes two steps, and the prompt says which step you are on:
- `Search Drive ›` lists your pinned files. Type anything that isn't one of them
and press Enter to search for it.
- `Drive: invoice ›` lists the results. Enter opens one; typing something else
and pressing Enter searches again. Escape closes.
`--query` skips the first step. fuzzel and rofi also get file-type icons.
To make an existing keybinding use it without re-binding, and to pin the
launcher command, set them in `config.json`:
```json
{
"prompt": {
"mode": "dmenu",
"menu": ["fuzzel", "--dmenu", "--width", "60"],
"menu_limit": 50
}
}
```
`--gtk` overrides `"mode": "dmenu"` for one invocation. A `{prompt}` token
anywhere in `menu` is replaced by the prompt text; without one, the launcher's
own prompt flag is appended.
### Live launcher search (fzf)
The two-step above exists because no dmenu-style launcher can call back for new
results per keystroke. `fzf` can, so `pdfs-prompt --fzf` searches as you type,
exactly like the built-in window:
```bash
pdfs-prompt --fzf # pinned files, then live results as you type
pdfs-prompt --fzf --query invoice # start on a search
```
Every keystroke re-queries the daemon (debounced, like the GTK prompt) and fzf's
own matcher is switched off, so the ordering you see is the daemon's ranking —
abbreviations and typos included, which a launcher filtering a fixed list cannot
do. Enter opens; Escape closes.
fzf is a terminal program, so when it is started without one — from a WM
keybinding — it spawns a terminal for itself: foot, ghostty, kitty, alacritty,
wezterm or xterm, whichever is installed. Each is launched with the app-id/class
`pdfs-prompt`, so one window rule floats it whichever terminal you have. In
Hyprland:
```
windowrulev2 = float, class:^(pdfs-prompt)$
windowrulev2 = size 900 500, class:^(pdfs-prompt)$
windowrulev2 = center, class:^(pdfs-prompt)$
bind = SUPER, space, exec, pdfs-prompt --fzf
```
Set it as the default front end, and pick the terminal explicitly, in
`config.json`:
```json
{
"prompt": {
"mode": "fzf",
"terminal": ["foot", "--app-id=pdfs-prompt", "--"],
"menu_limit": 50
}
}
```
A `{cmd}` token anywhere in `terminal` is replaced by the command to run;
without one it is appended, which is what every detected terminal expects.
## Choosing How Files Open
By default every result is handed to `xdg-open`. An `open_with` block in
`config.json` overrides that per file type — for instance opening text in
Neovim inside Alacritty, while everything else still goes to the desktop:
```json
{
"open_with": {
"terminal": ["alacritty", "-e"],
"rules": [
{ "match": ["@text"], "command": ["nvim"], "terminal": true },
{ "match": ["*.png", "*.jpg"], "command": ["imv"] }
]
}
}
```
- `match` takes file-name globs (`*.md`, `notes-*.txt`) or the classes `@dir`,
`@text`, `@document`, `@image`, `@media`, `@any`. First matching rule wins.
- `command` is argv, not a shell line. A `{}` token is replaced by the path;
without one the path is appended. `$VAR` tokens expand from the environment,
so `["$EDITOR"]` follows your editor.
- `"terminal": true` wraps the command in `terminal`. When that is unset, the
terminal comes from `$PDFS_TERMINAL`, then `$TERMINAL`, then the first known
emulator on `PATH`; a bare name like `TERMINAL=alacritty` gains the right
"run this" flag automatically.
- `"default": [...]` replaces `xdg-open` for everything unmatched.
The rules apply to the prompt (both front ends) and to the GTK browser, so a
file opens the same way wherever it was picked. Patterns are matched against the
Drive name — a downloaded file is stored in the cache under its content hash,
which says nothing about its type, so matching that would send everything to
`xdg-open`.
## Selective Sync (`.pdfsignore`)
Two-way synced folders skip paths matched by ignore rules, so syncing a project
directory does not upload `node_modules/`, `target/`, or `.git/`.
Rules come from two places, and both apply:
1. **Per folder** — a `.pdfsignore` file at the root of the synced folder
(`.protonignore` also works). Gitignore syntax, including negation:
```gitignore
# everything build-related
build/
*.log
# ...except this one
!important.log
```
2. **Globally** — an `ignore_patterns` list in `config.json`, applied to every
synced folder. When unset, sensible defaults apply: `.git/`, `.hg/`, `.svn/`,
`node_modules/`, `target/`, `.venv/`, `__pycache__/`, `*~`, `*.swp`, `*.tmp`,
`.DS_Store`, and `Thumbs.db`.
```json
{
"ignore_patterns": ["node_modules/", "target/", "*.iso"]
}
```
Set it to `[]` to opt out of the defaults entirely.
Rules are re-read at the start of every sync pass, so edits take effect on the
next pass without restarting the daemon.
**Ignoring is never destructive.** If a rule starts matching a file that was
already synced, its copy on Drive is left untouched — the file simply stops
being tracked. Removing the rule later picks the existing remote file back up
rather than re-uploading it.
## Diagnostics & Maintenance
When something looks wrong, `pdfs diagnose` checks the installation and prints a
report. It runs without a daemon on purpose — the state worth diagnosing is
usually the state where the daemon will not start:
```console
$ pdfs diagnose
Paths
[ok ] state dir: /home/you/.local/state/proton-drive-linux
[ok ] database: /home/you/.local/state/proton-drive-linux/cache.db (170.3 MiB)
Account
[ok ] keyring session: you@proton.me
Daemon
[ok ] daemon responding
[ok ] mounted at: /home/you/ProtonDrive
[ok ] queued writes: none
No problems found.
```
It exits non-zero if any check fails, so it works in a health-check script.
For the local metadata database and content cache:
| Command | What it does |
|---|---|
| `pdfs cache inspect` | Database size, reclaimable space, per-table row counts, cache usage against budget |
| `pdfs cache inspect --deep` | Also runs SQLite's integrity check — reads every page, so it is slow on a large database |
| `pdfs cache vacuum` | Checkpoints the write-ahead log and compacts the database |
| `pdfs cache clear` | Deletes cached file content, keeping pinned files |
`vacuum` takes a write lock for its duration and needs room for a second copy of
the database while it runs, so it is a deliberate operation rather than
something the daemon does on a timer.
Before a release, run the real-kernel acceptance suite against a dedicated test
account and mount. See [`docs/TESTING.md`](docs/TESTING.md); it includes optional
two-mount convergence checks and confines destructive operations to a fresh
test directory.
## Scripting (`--json`)
Query commands accept a global `--json` flag and emit machine-readable output:
```console
$ pdfs --json sync list | jq '.items[] | select(.state != "idle") | .local_path'
$ pdfs --json cache inspect | jq '.db_reclaimable_bytes'
$ pdfs --json status | jq -r '.mount.mountpoint'
```
Supported on `status`, `ls`, `pins`, `sync list`, `devices list`, `transfers`,
`activity`, and `cache inspect`. Commands that perform an action keep their
human output — a script that needs to know whether one worked has the exit code.
Two things worth relying on:
- **The payload is unwrapped.** Output is `{"items": […]}`, not the daemon's
internal `{"SyncFolders": {"items": […]}}`, so scripts never name an internal
variant.
- **Errors still fail.** A daemon-side error prints its JSON body (with a
machine-readable `kind`) on stdout *and* exits non-zero, so `set -e` and
`if pdfs …` behave as expected rather than treating a failed lookup as
success.
## Performance & Caching
The client includes several optimizations designed for high efficiency, a low memory footprint, and a responsive user experience:
- **On-Demand Block Cache**: Files are read in fixed 4 MiB blocks. For unpinned files, the client fetches and caches only the blocks containing the requested byte ranges. This enables fast sequential and sparse reads (e.g. media streaming or metadata scanning) without downloading entire files.
- **Disk-Backed Writes**: Large file writes are staged on disk in temporary scratch files (rather than fully buffered in RAM) and track modified byte intervals. Only the unedited remote segments are fetched at commit time, keeping memory usage minimal.
- **Non-Blocking GTK4 Loop**: To prevent UI freezes, all synchronous D-Bus credential checks, control socket requests, and cache usage calculations are offloaded to background worker threads or fetched asynchronously.
- **Flicker-Free UI Rendering**: The GTK4 application performs differential rendering of the pins list, only modifying list rows when the list content changes, preserving the user's scroll position and widget focus.
- **Durable Staging**: Scratch metadata and staged writes are synced and atomically published before an upload is acknowledged locally. Rapid revisions supersede pending work transactionally.
## Filesystem Safety
Version 1.0 strengthens the boundaries around local-only data and destructive reconciliation:
- Truncate composes with queued revisions, preserving the authoritative prefix and correct zero-filled growth.
- Combined move-and-rename operations are queued durably and tolerate partially completed remote state.
- Session tombstones prevent successfully unlinked entries from reappearing through eventually consistent listings.
- Names are valid UTF-8 and no longer than Linux's 255-byte component limit.
- Incomplete mirror scans are non-destructive, and total-wipe protection covers every non-empty baseline.
- Failed conflict preservation, local deletion, or staging publication does not settle a new baseline or discard the only local copy.
- State, cache, and config directories must be real, current-user-owned `0700` directories; control sockets are `0600`.
## Screenshots
### GUI Application
### GUI Application & Launcher
| Login Screen | Account Overview |
| :---: | :---: |
| ![Login Screen](images/login.png) | ![Account Overview](images/account.png) |
| Files (Grid View) | Files (List View) |
| :---: | :---: |
| ![Files Grid](images/files_grid.png) | ![Files List](images/files_list.png) |
<table>
<tr>
<td align="center" width="50%"><img src="images/login.png" alt="Login Screen" width="100%"><br><sub><b>Login Screen</b></sub></td>
<td align="center" width="50%"><img src="images/files.png" alt="Files Browser" width="100%"><br><sub><b>Files Browser</b></sub></td>
</tr>
<tr>
<td align="center" width="50%"><img src="images/shared.png" alt="Shared Links" width="100%"><br><sub><b>Shared Links</b></sub></td>
<td align="center" width="50%"><img src="images/shared_with_me.png" alt="Shared with me" width="100%"><br><sub><b>Shared with me</b></sub></td>
</tr>
<tr>
<td align="center" width="50%"><img src="images/computers.png" alt="Local Backups (Computers)" width="100%"><br><sub><b>Local Backups (Computers)</b></sub></td>
<td align="center" width="50%"><img src="images/photos.png" alt="Photos Timeline" width="100%"><br><sub><b>Photos Timeline</b></sub></td>
</tr>
<tr>
<td align="center" width="50%"><img src="images/prompt.png" alt="Search Launcher Prompt" width="100%"><br><sub><b>Search Launcher Prompt</b></sub></td>
<td align="center" width="50%"><img src="images/settings.png" alt="Settings" width="100%"><br><sub><b>Settings</b></sub></td>
</tr>
</table>
---
@@ -37,13 +371,32 @@ sudo apt-get install -y \
libfuse3-dev \
libgtk-4-dev \
libadwaita-1-dev \
libwebkitgtk-6.0-dev \
libsecret-1-dev \
libdbus-1-dev
libdbus-1-dev \
libimage-exiftool-perl
```
### Arch Linux
```bash
sudo pacman -S --needed pkgconf fuse3 gtk4 libadwaita libsecret dbus
sudo pacman -S --needed pkgconf fuse3 gtk4 libadwaita libsecret dbus webkitgtk-6.0 perl-image-exiftool
```
### Fedora (44+)
```bash
sudo dnf install -y \
pkgconf-pkg-config fuse3-devel gtk4-devel libadwaita-devel \
webkitgtk6.0-devel libsecret-devel dbus-devel glib2-devel \
perl-Image-ExifTool cargo rust
```
Runtime extras (pick your desktop):
```bash
# GNOME — keyring + tray (AppIndicator)
sudo dnf install -y gnome-keyring gnome-shell-extension-appindicator xdg-utils
# KDE Plasma — KWallet (Secret Service); tray works via built-in SNI
sudo dnf install -y kwallet xdg-utils
```
---
@@ -54,7 +407,7 @@ Ensure you have Rust and Cargo installed (minimum supported Rust version is 1.96
1. Clone the repository and navigate into the project directory:
```bash
git clone https://github.com/narl/proton-drive-linux.git
git clone https://github.com/narrrl/proton-drive-linux.git
cd proton-drive-linux
```
2. Build the workspace in release mode:
@@ -66,6 +419,7 @@ The compiled binaries will be available under `target/release/`:
- `pdfs`: The CLI utility.
- `pdfs-app`: The GTK4 application.
- `pdfs-tray`: The tray status notifier.
- `pdfs-prompt`: The launcher prompt for quick HUD search.
---
@@ -83,6 +437,20 @@ A local `PKGBUILD` is available under the `packaging/` directory. You can build
cd packaging && makepkg -fi
```
### 3. Fedora (local RPM)
A local `.spec` is available under `packaging/`. From the repository root:
```bash
sudo dnf install -y rpm-build
rpmbuild -bb packaging/proton-drive-linux.spec \
--define "git_dir $PWD" \
--define "_rpmdir $PWD/packaging/out" \
--define "_builddir $PWD/packaging/build" \
--define "_sourcedir $PWD" \
--define "_specdir $PWD/packaging" \
--define "_srcrpmdir $PWD/packaging/out"
sudo dnf install packaging/out/x86_64/proton-drive-linux-*.rpm
```
---
## Automated Releases (CI/CD)
@@ -93,19 +461,31 @@ This project has a GitHub Actions CI workflow configured under `.github/workflow
1. **Triggers**:
- Pushing a git tag matching `v*` (e.g. `git tag v0.1.0 && git push origin v0.1.0`).
- Manual runs via the **Actions** tab in GitHub (**workflow_dispatch**).
2. **Build Process**:
2. **Quality Gates and Build Process**:
- For tagged builds, verifies that the tag, workspace version, and `packaging/PKGBUILD` version agree.
- Runs `cargo fmt`, Clippy with warnings denied, the locked workspace test suite, and the account-free FUSE acceptance contract.
- Spawns an Ubuntu runner and installs GTK4, Libadwaita, FUSE3, and Secret Service packages.
- Sets up the Rust compiler and caches build targets to speed up runs.
- Compiles the workspace members in release mode.
3. **Artifact Packaging**:
- Generates a `.tar.gz` containing the raw binaries (`pdfs`, `pdfs-app`, `pdfs-tray`).
- Packs them into a Debian package (`.deb`).
- Generates a `.tar.gz` containing the raw binaries (`pdfs`, `pdfs-app`, `pdfs-tray`, `pdfs-prompt`).
- Packs them into Debian (`.deb`) and Fedora (`.rpm`) packages.
- Includes the systemd user service and tray autostart entry in the Debian package.
4. **Publishing**:
- Creates a GitHub Release matching the pushed tag and uploads the `.deb` and `.tar.gz` packages as release assets.
- Creates a GitHub Release matching the pushed tag and uploads the `.deb`, `.rpm`, and `.tar.gz` packages as release assets.
- For manual runs, compiles and exposes the packages as workflow run artifacts for testing.
---
## Contributing
Bug reports, packaging fixes, and patches are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for
the workflow and the quality gates CI enforces, and [SECURITY.md](SECURITY.md) for reporting a
vulnerability privately.
## License
This project is licensed under the [MIT License](LICENSE).
Released under the [MIT License](LICENSE).
Proton Drive is a service of Proton AG. This project is an independent client and carries no
affiliation with or endorsement from Proton AG.
+34
View File
@@ -0,0 +1,34 @@
# Security Policy
## Supported versions
Fixes land on the latest release. Please reproduce on the most recent version before reporting.
## Reporting a vulnerability
**Do not open a public issue.** Report privately through GitHub's
[private vulnerability reporting](https://github.com/narrrl/proton-drive-linux/security/advisories/new),
or by email to <contact@narl.io>.
Please include the version (`pdfs --version`), your distribution and desktop environment, what an
attacker gains, and a reproduction if you have one. You can expect an acknowledgement within a few
days; a fix ships in the next release, and you are credited in the advisory unless you ask
otherwise.
## Scope
This client holds Proton account credentials and decrypted file content on the local machine, so
the interesting boundaries are:
- **Credentials.** Sessions and the mailbox password live in the system Secret Service. Anything
that writes them elsewhere — logs, temp files, crash dumps — is a vulnerability.
- **The control socket.** Anything that can connect to `control.sock` has the daemon's
authenticated session. It is `0600`, and the state, cache, and config directories must be real,
current-user-owned `0700` directories; the daemon fails closed otherwise. Reports that defeat
those checks are in scope.
- **Local cache and staging.** Decrypted block content and staged writes on disk.
- **Cryptography.** Key derivation, signature handling, and anything that could publish plaintext
or the wrong key material to Proton.
Out of scope: vulnerabilities in Proton's own service (report those to Proton AG), and issues that
require an attacker who already has your user account on the machine.
+2
View File
@@ -5,6 +5,8 @@ edition.workspace = true
license.workspace = true
authors.workspace = true
rust-version.workspace = true
repository.workspace = true
homepage.workspace = true
description = "CLI for the Proton Drive Linux client"
[[bin]]
File diff suppressed because it is too large. Load diff
+11 -1
View File
@@ -5,6 +5,8 @@ edition.workspace = true
license.workspace = true
authors.workspace = true
rust-version.workspace = true
repository.workspace = true
homepage.workspace = true
description = "Core auth/session/config for the Proton Drive Linux client"
[dependencies]
@@ -16,5 +18,13 @@ serde_json.workspace = true
thiserror.workspace = true
tracing.workspace = true
directories.workspace = true
ignore.workspace = true
sha2.workspace = true
keyring.workspace = true
sha1.workspace = true
zip.workspace = true
rusqlite.workspace = true
keyring-core.workspace = true
dbus-secret-service-keyring-store.workspace = true
parking_lot.workspace = true
async-trait.workspace = true
libc.workspace = true
+108
View File
@@ -0,0 +1,108 @@
//! Effective access carried by mounted Drive nodes.
use proton_drive_rs::MemberRole;
/// Effective write access for a node in a mounted tree.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Access {
Owner,
Editor,
Viewer,
Unknown,
}
impl Access {
pub fn writable(self) -> bool {
matches!(self, Self::Owner | Self::Editor)
}
pub(crate) fn as_db_str(self) -> &'static str {
match self {
Self::Owner => "owner",
Self::Editor => "editor",
Self::Viewer => "viewer",
Self::Unknown => "unknown",
}
}
pub(crate) fn from_db_str(value: &str) -> Option<Self> {
match value {
"owner" => Some(Self::Owner),
"editor" => Some(Self::Editor),
"viewer" => Some(Self::Viewer),
"unknown" => Some(Self::Unknown),
_ => None,
}
}
}
/// Resolve a share root's effective access from its exact membership role.
///
/// Owned trees fail open. A share whose role is absent or unrecognised fails
/// closed. Descendants normally inherit directly at intern time; `Inherited`
/// exists for the explicit wire role and therefore returns `parent`.
pub fn access_for(role: Option<MemberRole>, parent: Access, under_share: bool) -> Access {
match role {
Some(MemberRole::Viewer) => Access::Viewer,
Some(MemberRole::Editor | MemberRole::Admin) => Access::Editor,
Some(MemberRole::Inherited) => parent,
None if under_share => Access::Viewer,
None => Access::Owner,
}
}
/// POSIX permission bits advertised for a mounted node.
pub fn perm_bits(is_dir: bool, access: Access) -> u16 {
match (is_dir, access.writable()) {
(true, true) => 0o755,
(false, true) => 0o644,
(true, false) => 0o555,
(false, false) => 0o444,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn access_for_covers_the_fail_open_and_fail_closed_table() {
for parent in [
Access::Owner,
Access::Editor,
Access::Viewer,
Access::Unknown,
] {
assert_eq!(access_for(None, parent, false), Access::Owner);
assert_eq!(access_for(None, parent, true), Access::Viewer);
assert_eq!(
access_for(Some(MemberRole::Viewer), parent, true),
Access::Viewer
);
assert_eq!(
access_for(Some(MemberRole::Editor), parent, true),
Access::Editor
);
assert_eq!(
access_for(Some(MemberRole::Admin), parent, true),
Access::Editor
);
assert_eq!(
access_for(Some(MemberRole::Inherited), parent, true),
parent
);
}
}
#[test]
fn permission_bits_follow_effective_access() {
for writable in [Access::Owner, Access::Editor] {
assert_eq!(perm_bits(true, writable), 0o755);
assert_eq!(perm_bits(false, writable), 0o644);
}
for read_only in [Access::Viewer, Access::Unknown] {
assert_eq!(perm_bits(true, read_only), 0o555);
assert_eq!(perm_bits(false, read_only), 0o444);
}
}
}
+236 -14
View File
@@ -6,10 +6,13 @@
//! interactive step; the refresh token auto-renews via the HTTP client's 401
//! path, so no fresh 2FA is required until the refresh token itself expires.
use keyring::Entry;
use proton_drive_rs::ProtonDriveClient;
use keyring_core::Entry;
use proton_drive_rs::{KeySalt, ProtonDriveClient};
use proton_sdk::api::{HumanVerification, HumanVerificationCredential};
use proton_sdk::cache::EncryptedCacheRepository;
use proton_sdk::config::ProtonClientConfiguration;
use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
use proton_sdk::telemetry::TracingTelemetry;
use serde::{Deserialize, Serialize};
use crate::config::{APP_VERSION, AppDirs, KEYRING_SERVICE, USER_AGENT};
@@ -32,6 +35,20 @@ pub struct StoredSession {
/// Mailbox (data) password — required by `ProtonDriveClient::new` to derive
/// the key chain. Lives only in the OS keyring, never on disk in cleartext.
pub mailbox_password: String,
/// The account's key salts, captured at login.
///
/// `core/v4/keys/salts` requires the `locked` scope, which only the access
/// token minted by a password login carries: once that token has been
/// rotated through `auth/v4/refresh` (as it is on every daemon start after
/// the first), the endpoint answers 403 and the key chain can no longer be
/// unlocked. Salts only change when the password changes, so store them
/// once and seed the Drive client with them on resume.
///
/// Empty for blobs written before this field existed; [`resume_client`]
/// then falls back to fetching (and, if the token is still scoped for it,
/// backfilling) them.
#[serde(default)]
pub key_salts: Vec<KeySalt>,
}
impl StoredSession {
@@ -64,32 +81,120 @@ fn client_config() -> ProtonClientConfiguration {
}
fn keyring_entry() -> Result<Entry> {
// Installed lazily rather than once at startup so a session bus that was not
// up yet on the first attempt is retried on the next one.
if keyring_core::get_default_store().is_none() {
keyring_core::set_default_store(dbus_secret_service_keyring_store::Store::new()?);
}
Ok(Entry::new(KEYRING_SERVICE, KEYRING_USER)?)
}
/// Run an interactive SRP + (optional) 2FA login and persist the session.
///
/// `get_totp` is only invoked when the account requires a second factor, so
/// callers can defer prompting until it is actually needed.
/// callers can defer prompting until it is actually needed. It may be called
/// more than once — [`login_interactive`] re-runs the whole login after human
/// verification, and a 2FA account needs a fresh code on that second attempt.
///
/// A gated login fails with [`Error::HumanVerificationRequired`]; a front-end
/// that can present the challenge should use [`login_interactive`] instead.
pub async fn login(
username: &str,
password: &str,
get_totp: impl FnOnce() -> Result<String>,
get_totp: impl Fn() -> Result<String>,
) -> Result<()> {
let mut session =
ProtonApiSession::begin(client_config(), username, password.as_bytes()).await?;
login_verified(username, password, None, get_totp).await
}
/// [`login`], able to answer a human-verification gate rather than fail on it.
///
/// `get_hv` is invoked only when the API actually gates the login, mirroring how
/// `get_totp` is only invoked for accounts with a second factor: neither costs a
/// prompt the user did not need. It receives the challenge and returns the token
/// the user earned by solving it.
///
/// The retry lives here rather than in the front-ends because the recovery is
/// not a UI concern: the gated attempt burned its SRP handshake, so the login
/// has to start over with the credential attached, and every front-end would
/// otherwise have to know that. Gating the *retry* is not handled — a second
/// challenge in a row means the token was rejected, and looping on it would trap
/// the user in a CAPTCHA that never clears.
pub async fn login_interactive(
username: &str,
password: &str,
get_totp: impl Fn() -> Result<String>,
get_hv: impl FnOnce(HumanVerification) -> Result<HumanVerificationCredential>,
) -> Result<()> {
match login_verified(username, password, None, &get_totp).await {
Err(Error::HumanVerificationRequired(hv)) => {
let credential = get_hv(*hv)?;
login_verified(username, password, Some(&credential), &get_totp).await
}
other => other,
}
}
/// [`login`], replaying a human-verification token the user has already earned.
///
/// A gated login fails with [`Error::HumanVerificationRequired`] carrying the
/// challenge. The front-end presents it, collects the solved token, and calls
/// this. The login starts over from the SRP handshake rather than resuming: the
/// gated attempt never got a session, and its `srp_session` is spent.
pub async fn login_verified(
username: &str,
password: &str,
verification: Option<&HumanVerificationCredential>,
get_totp: impl Fn() -> Result<String>,
) -> Result<()> {
let mut session = ProtonApiSession::begin_verified(
client_config(),
username,
password.as_bytes(),
verification,
)
.await
.map_err(classify_verification_gate)?;
if session.is_waiting_for_second_factor() {
let code = get_totp()?;
session.apply_second_factor_code(code.trim()).await?;
}
save(&session, password).await?;
// Grab the key salts while this access token still has the `locked` scope:
// after its first refresh it never will again, and without them no later
// resume can unlock the key chain. See `StoredSession::key_salts`.
let client = ProtonDriveClient::new(&session, password.as_bytes().to_vec());
let key_salts = client.account().key_salts().await?;
register_refresh_handler(&session, password.to_owned(), key_salts.clone());
save(&session, password, key_salts).await?;
Ok(())
}
/// Persist the session's current tokens + mailbox password to the keyring.
pub async fn save(session: &ProtonApiSession, mailbox_password: &str) -> Result<()> {
/// Lift a human-verification gate out of the generic API error into the typed
/// variant a front-end can act on.
///
/// Only a gate that names a solvable method is converted. A `9001` with no
/// `Details`, or one offering only `email`/`sms`, stays a plain API error:
/// promoting it would send the UI to open a verification page it cannot
/// complete, which reads to the user as a hang rather than a refusal.
fn classify_verification_gate(e: proton_sdk::error::ProtonError) -> Error {
let proton_sdk::error::ProtonError::Api(api) = &e else {
return e.into();
};
match api.human_verification() {
Some(hv) if hv.supports_captcha() => Error::HumanVerificationRequired(Box::new(hv)),
_ => e.into(),
}
}
/// Persist the session's current tokens, mailbox password and key salts.
pub async fn save(
session: &ProtonApiSession,
mailbox_password: &str,
key_salts: Vec<KeySalt>,
) -> Result<()> {
let tokens = session.current_tokens().await;
let stored = StoredSession {
session_id: session.session_id().as_str().to_owned(),
@@ -103,6 +208,7 @@ pub async fn save(session: &ProtonApiSession, mailbox_password: &str) -> Result<
PasswordMode::Dual => 2,
},
mailbox_password: mailbox_password.to_owned(),
key_salts,
};
let json = serde_json::to_string(&stored)?;
keyring_entry()?.set_password(&json)?;
@@ -114,15 +220,30 @@ pub fn load() -> Result<StoredSession> {
let entry = keyring_entry()?;
match entry.get_password() {
Ok(json) => Ok(serde_json::from_str(&json)?),
Err(keyring::Error::NoEntry) => Err(Error::NotLoggedIn),
Err(keyring_core::Error::NoEntry) => Err(Error::NotLoggedIn),
Err(e) => Err(e.into()),
}
}
/// Forget the persisted session (best-effort; absent entry is not an error).
pub fn logout() -> Result<()> {
// The persisted entity cache describes the account being logged out of. It
// is encrypted under that account's mailbox password, but leaving it behind
// would also mean the next account inherits a store it can only ever read as
// misses. Best-effort: failing to remove a cache must not block the logout.
if let Ok(dirs) = AppDirs::new() {
let path = dirs.state_dir().join("sdk_cache.db");
if let Some(cache) = crate::sdkcache::SdkCache::opened()
&& let Err(e) = cache.clear_now()
{
tracing::warn!(error = %e, "clearing the SDK entity cache on logout failed");
}
for suffix in ["", "-wal", "-shm"] {
let _ = std::fs::remove_file(format!("{}{suffix}", path.display()));
}
}
match keyring_entry()?.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(()),
Err(e) => Err(e.into()),
}
}
@@ -137,10 +258,73 @@ pub fn logout() -> Result<()> {
pub async fn resume_client() -> Result<(ProtonDriveClient, ProtonApiSession)> {
let stored = load()?;
let session = ProtonApiSession::resume(client_config(), stored.to_params())?;
let client = ProtonDriveClient::new(&session, stored.mailbox_password.clone().into_bytes());
let password = stored.mailbox_password.clone();
// Blobs written before key salts were persisted have none: fetch them once
// more and write them back, which only works while the stored access token
// still carries the `locked` scope. If it doesn't, the account must be
// logged into again — no refresh can restore that scope.
let key_salts = if stored.key_salts.is_empty() {
register_refresh_handler(&session, password.clone(), Vec::new());
let probe = ProtonDriveClient::new(&session, password.clone().into_bytes());
let salts = probe.account().key_salts().await.map_err(|e| match &e {
proton_sdk::error::ProtonError::Api(api) if api.is_insufficient_scope() => {
Error::ReloginRequired
}
_ => e.into(),
})?;
save(&session, &password, salts.clone()).await?;
salts
} else {
stored.key_salts
};
register_refresh_handler(&session, password.clone(), key_salts.clone());
let client = tune(
ProtonDriveClient::with_key_salts(&session, password.clone().into_bytes(), key_salts),
password.as_bytes(),
);
Ok((client, session))
}
/// The client settings every front-end wants, applied wherever a
/// [`ProtonDriveClient`] is built for real work.
///
/// - **Telemetry** into `tracing`, so the SDK's own spans (transfers, block
/// storage, per-request timings) land in the daemon's log next to the
/// daemon's, instead of being dropped by the default no-op sink.
/// - **Small-file uploads**: a file that fits in one block is uploaded as a
/// single atomic request instead of the multi-step draft/block/commit dance.
/// The SDK makes this opt-in because it has no remote feature-flag provider;
/// the drain queue writes plenty of small files, and the shorter path is one
/// failure point per write instead of three.
/// - **A persistent entity cache** ([`crate::sdkcache`]), so a restart does not
/// re-fetch and re-decrypt the tree the previous run already walked. It is
/// wrapped in the SDK's [`EncryptedCacheRepository`] keyed by the mailbox
/// password, so the decrypted node names it holds are not readable from the
/// file alone — and a password change simply reads as a cold cache, since the
/// SDK treats an undecryptable entry as a miss and clears the store.
///
/// Opening it is best-effort: a store this process cannot open costs cache
/// hits, not the session, so the client falls back to the SDK's in-memory
/// default.
fn tune(client: ProtonDriveClient, mailbox_password: &[u8]) -> ProtonDriveClient {
let client = client
.with_telemetry(TracingTelemetry::shared())
.with_small_file_upload(true);
match AppDirs::new().and_then(|dirs| crate::sdkcache::SdkCache::shared(&dirs)) {
Ok(cache) => client.with_entity_repository(EncryptedCacheRepository::shared(
cache,
mailbox_password.to_vec(),
)),
Err(e) => {
tracing::warn!(error = %e, "persistent SDK entity cache unavailable; using memory");
client
}
}
}
/// Write the session's current tokens back to the keyring.
///
/// The session does not carry the mailbox password (it is only needed to rebuild
@@ -148,6 +332,44 @@ pub async fn resume_client() -> Result<(ProtonDriveClient, ProtonApiSession)> {
/// preserved. Call whenever the session may have rotated its tokens through the
/// 401-refresh path so a later [`resume_client`] presents a live refresh token.
pub async fn persist(session: &ProtonApiSession) -> Result<()> {
let mailbox_password = load()?.mailbox_password;
save(session, &mailbox_password).await
let stored = load()?;
save(session, &stored.mailbox_password, stored.key_salts).await
}
fn register_refresh_handler(
session: &ProtonApiSession,
mailbox_password: String,
key_salts: Vec<KeySalt>,
) {
let session_id = session.session_id().as_str().to_owned();
let username = session.username().to_owned();
let user_id = session.user_id().as_str().to_owned();
let scopes = session.scopes().to_vec();
let password_mode = match session.password_mode() {
PasswordMode::Single => 1,
PasswordMode::Dual => 2,
};
session.http().set_on_tokens_refreshed(move |tokens| {
let stored = StoredSession {
session_id: session_id.clone(),
username: username.clone(),
user_id: user_id.clone(),
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
scopes: scopes.clone(),
password_mode,
mailbox_password: mailbox_password.clone(),
key_salts: key_salts.clone(),
};
if let Ok(json) = serde_json::to_string(&stored)
&& let Ok(entry) = keyring_entry()
{
if let Err(e) = entry.set_password(&json) {
tracing::warn!(error = %e, "failed to auto-persist refreshed tokens in keyring");
} else {
tracing::info!("successfully auto-persisted refreshed tokens in keyring");
}
}
});
}
+84
View File
@@ -0,0 +1,84 @@
//! Per-node outcomes of the SDK's batch node operations.
//!
//! `trash_nodes`, `restore_nodes`, `delete_nodes` and `move_nodes` report one
//! outcome per node instead of failing the whole call: a node the server
//! rejects leaves the rest of the batch applied. The daemon's callers fall into
//! two shapes, and both are here so neither has to re-derive the fold:
//!
//! - one node in, one answer out — [`into_unit`], which collapses the single
//! outcome back to a `Result`;
//! - a user-supplied list — [`split`], which keeps the successes and hands back
//! the failures to report.
use proton_sdk::error::ProtonError;
use proton_sdk::ids::NodeUid;
/// One node's outcome, as the SDK batch operations report it.
pub type Outcome = (NodeUid, Result<(), ProtonError>);
/// Collapse a batch result to the first per-node failure, for the callers that
/// pass exactly one node and want the old all-or-nothing answer.
///
/// An empty outcome list is `Ok`: the server accepted the request and reported
/// on nothing, which is not an error the caller can act on.
pub fn into_unit(outcomes: Vec<Outcome>) -> Result<(), ProtonError> {
for (_, outcome) in outcomes {
outcome?;
}
Ok(())
}
/// Split a batch result into the nodes that succeeded and the ones that failed.
pub fn split(outcomes: Vec<Outcome>) -> (Vec<NodeUid>, Vec<(NodeUid, ProtonError)>) {
let mut done = Vec::with_capacity(outcomes.len());
let mut failed = Vec::new();
for (uid, outcome) in outcomes {
match outcome {
Ok(()) => done.push(uid),
Err(e) => failed.push((uid, e)),
}
}
(done, failed)
}
#[cfg(test)]
mod tests {
use super::*;
use proton_sdk::ids::{LinkId, VolumeId};
fn uid(link: &str) -> NodeUid {
NodeUid::new(VolumeId::new("vol"), LinkId::new(link))
}
fn failure() -> ProtonError {
ProtonError::invalid_operation("nope")
}
#[test]
fn into_unit_is_ok_when_every_node_succeeded() {
assert!(into_unit(vec![(uid("a"), Ok(())), (uid("b"), Ok(()))]).is_ok());
assert!(
into_unit(Vec::new()).is_ok(),
"nothing reported is not a failure"
);
}
#[test]
fn into_unit_surfaces_a_failed_node() {
let outcomes = vec![(uid("a"), Ok(())), (uid("b"), Err(failure()))];
assert!(into_unit(outcomes).is_err());
}
#[test]
fn split_keeps_successes_and_failures_apart() {
let outcomes = vec![
(uid("a"), Ok(())),
(uid("b"), Err(failure())),
(uid("c"), Ok(())),
];
let (done, failed) = split(outcomes);
assert_eq!(done, vec![uid("a"), uid("c")]);
assert_eq!(failed.len(), 1);
assert_eq!(failed[0].0, uid("b"));
}
}
File diff suppressed because it is too large. Load diff
+440 -9
View File
@@ -1,11 +1,14 @@
//! Static client identity and per-user filesystem paths.
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
use crate::menu::PromptConfig;
use crate::opener::OpenWith;
use crate::syncignore::DEFAULT_IGNORE_PATTERNS;
/// Proton requires `external-drive-{name}@{semver}-{channel}` (channel ∈
/// stable/beta/alpha); a malformed value trips the 422 anti-abuse path.
@@ -19,11 +22,103 @@ pub const KEYRING_SERVICE: &str = "proton-drive-linux";
/// blobs back under this; pinned files are exempt. See [`crate::cache`].
pub const DEFAULT_CACHE_BUDGET_BYTES: u64 = 5 * 1024 * 1024 * 1024;
/// Environment override for [`AppConfig::resolved_conflict_sweep`]. Exists so a
/// support case can disable the sweep without editing the config file or
/// reinstalling — it wins over the stored setting.
pub const CONFLICT_SWEEP_ENV: &str = "PDFS_CONFLICT_SWEEP";
/// What the background conflict sweep (`pdfs-fuse`'s `pdfs-conflict-sweep`
/// thread) is allowed to do about a `(sync-conflict …)` copy it believes is a
/// redundant duplicate.
///
/// Defaults to [`SweepMode::Report`]: the sweep's whole reason to exist is that
/// the *previous* revision-identity check was wrong (B69), so the replacement is
/// not trusted with deletion until it has a release cycle of field evidence
/// behind it. See `docs/BUGS.md` B71.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum SweepMode {
/// Do not run the sweep at all — the thread is never spawned.
Off,
/// Run, and log what it *would* remove, but never trash anything. Divergent
/// and orphaned copies are still surfaced to the activity feed as usual.
#[default]
Report,
/// Trash copies proven identical to their live sibling.
Enforce,
}
impl SweepMode {
/// Parse an operator-supplied value ([`CONFLICT_SWEEP_ENV`] or a CLI flag).
/// `None` for anything unrecognised, so a typo falls back to the stored
/// setting rather than silently picking a mode the operator did not ask for.
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"off" | "no" | "false" | "0" | "disabled" => Some(Self::Off),
"report" | "dry-run" | "dryrun" | "observe" => Some(Self::Report),
"enforce" | "on" | "yes" | "true" | "1" => Some(Self::Enforce),
_ => None,
}
}
/// Whether this mode is allowed to trash a duplicate.
pub fn is_enforcing(self) -> bool {
matches!(self, Self::Enforce)
}
}
/// Precedence for [`AppConfig::resolved_conflict_sweep`], split out from the
/// environment read so it can be unit-tested without mutating process state.
fn resolve_sweep_mode(env: Option<&str>, stored: Option<SweepMode>) -> SweepMode {
env.and_then(SweepMode::parse)
.or(stored)
.unwrap_or_default()
}
/// Configuration structure allowing the user to customize client identification headers.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct AppConfig {
pub app_version: String,
pub user_agent: String,
/// Soft cap on the on-disk content cache, in bytes (`0` = unlimited).
/// `None` means "use [`DEFAULT_CACHE_BUDGET_BYTES`]"; the Settings page
/// writes an explicit value here. Defaulted for configs predating the field.
#[serde(default)]
pub cache_budget: Option<u64>,
/// Mountpoint the daemon mounts at. `None` means
/// [`AppDirs::default_mountpoint`]; the Settings page writes an explicit
/// path here. Defaulted for configs predating the field.
#[serde(default)]
pub mountpoint: Option<String>,
/// Ignore patterns (gitignore syntax) applied to every mirror sync folder,
/// on top of each folder's own `.pdfsignore`. `None` means "use
/// [`DEFAULT_IGNORE_PATTERNS`]"; an explicit empty list disables the
/// defaults. Defaulted for configs predating the field.
#[serde(default)]
pub ignore_patterns: Option<Vec<String>>,
/// Device uid this machine has been *adopted* into, overriding the hostname
/// heuristic in `ensure_device`. Lives here rather than in `cache.db` so it
/// survives a fresh install — on a new machine the DB is empty by
/// definition, which is exactly when the pin matters. `None` means "resolve
/// by hostname". Defaulted for configs predating the field.
#[serde(default)]
pub device_uid: Option<String>,
/// What the background conflict sweep may do. `None` means "use
/// [`SweepMode::default`]" (report-only). Defaulted for configs predating
/// the field, and overridable at runtime via [`CONFLICT_SWEEP_ENV`].
#[serde(default)]
pub conflict_sweep: Option<SweepMode>,
/// How files opened from the prompt/browser are launched. `None` means
/// "hand everything to `xdg-open`", which is what the client did before
/// this existed. See [`crate::opener`]. Defaulted for configs predating the
/// field.
#[serde(default)]
pub open_with: Option<OpenWith>,
/// `pdfs-prompt` behaviour, including the external launcher used by
/// `--dmenu`. See [`crate::menu`]. Defaulted for configs predating the
/// field.
#[serde(default)]
pub prompt: Option<PromptConfig>,
}
impl Default for AppConfig {
@@ -31,6 +126,61 @@ impl Default for AppConfig {
Self {
app_version: APP_VERSION.to_string(),
user_agent: USER_AGENT.to_string(),
cache_budget: None,
mountpoint: None,
ignore_patterns: None,
device_uid: None,
conflict_sweep: None,
open_with: None,
prompt: None,
}
}
}
impl AppConfig {
/// The effective cache budget in bytes: the user's explicit choice, or
/// [`DEFAULT_CACHE_BUDGET_BYTES`] when unset.
pub fn resolved_cache_budget(&self) -> u64 {
self.cache_budget.unwrap_or(DEFAULT_CACHE_BUDGET_BYTES)
}
/// The effective opener policy: the user's rules, or the all-`xdg-open`
/// default. Cloned rather than borrowed because callers hold it for the
/// life of a window, long after the config value is gone.
pub fn resolved_open_with(&self) -> OpenWith {
self.open_with.clone().unwrap_or_default()
}
/// The effective prompt settings, defaulted when the section is absent.
pub fn resolved_prompt(&self) -> PromptConfig {
self.prompt.clone().unwrap_or_default()
}
/// The effective conflict-sweep mode: the [`CONFLICT_SWEEP_ENV`] override if
/// it parses, then the user's stored choice, then [`SweepMode::default`].
///
/// The environment wins so a support case can stop a destructive background
/// loop without editing config or reinstalling (`docs/BUGS.md` B71). An
/// unparseable value is ignored rather than treated as "off", so a typo
/// cannot silently change behaviour in either direction.
pub fn resolved_conflict_sweep(&self) -> SweepMode {
resolve_sweep_mode(
std::env::var(CONFLICT_SWEEP_ENV).ok().as_deref(),
self.conflict_sweep,
)
}
/// The effective global sync ignore patterns: the user's explicit list, or
/// [`DEFAULT_IGNORE_PATTERNS`] when unset. An explicit empty list is
/// honoured as "no global patterns", which is why this is not a
/// `unwrap_or_default`.
pub fn resolved_ignore_patterns(&self) -> Vec<String> {
match &self.ignore_patterns {
Some(patterns) => patterns.clone(),
None => DEFAULT_IGNORE_PATTERNS
.iter()
.map(|s| (*s).to_string())
.collect(),
}
}
}
@@ -55,11 +205,22 @@ impl AppDirs {
/// Load config from disk, creating default if missing.
pub fn load_config(&self) -> AppConfig {
let path = self.config_path();
if path.exists()
&& let Ok(content) = std::fs::read_to_string(&path)
&& let Ok(config) = serde_json::from_str::<AppConfig>(&content) {
return config;
if path.exists() {
match std::fs::read_to_string(&path)
.map_err(Error::from)
.and_then(|content| {
serde_json::from_str::<AppConfig>(&content).map_err(Error::from)
}) {
Ok(config) => return config,
Err(e) => {
// Preserve the original bytes for diagnosis/recovery. In
// particular, never replace a truncated config with defaults
// and silently lose mount, device, or ignore settings.
tracing::error!(path = %path.display(), error = %e, "config is unreadable; using in-memory defaults without overwriting it");
return AppConfig::default();
}
}
}
let default_config = AppConfig::default();
if let Ok(content) = serde_json::to_string_pretty(&default_config) {
@@ -69,6 +230,34 @@ impl AppDirs {
default_config
}
/// Persist `config` to [`config_path`](Self::config_path), creating the
/// config dir if missing. The Settings page calls this after editing the
/// cache budget or mountpoint; the daemon re-reads the file on its next mount.
pub fn save_config(&self, config: &AppConfig) -> Result<()> {
let path = self.config_path();
let parent = path
.parent()
.ok_or_else(|| Error::Other("configuration path has no parent".into()))?;
std::fs::create_dir_all(parent)?;
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0);
let tmp = path.with_extension(format!("json.tmp-{}-{nonce}", std::process::id()));
use std::os::unix::fs::OpenOptionsExt as _;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&tmp)?;
use std::io::Write as _;
file.write_all(&serde_json::to_vec_pretty(config)?)?;
file.sync_all()?;
std::fs::rename(&tmp, &path)?;
std::fs::File::open(parent)?.sync_all()?;
Ok(())
}
/// Persistent state: inode map / cache index DB lives here.
pub fn state_dir(&self) -> PathBuf {
// `state_dir` is Linux-only in `directories`; fall back to data dir.
@@ -93,6 +282,12 @@ impl AppDirs {
self.state_dir().join("pins.json")
}
/// Unified SQLite metadata cache (inodes, FTS, cache LRU, pins). Lives in
/// persistent state next to `control.sock`; only the daemon writes it.
pub fn db_path(&self) -> PathBuf {
self.state_dir().join("cache.db")
}
/// Unix domain socket the mount daemon listens on for CLI control commands.
pub fn control_socket(&self) -> PathBuf {
self.state_dir().join("control.sock")
@@ -103,6 +298,12 @@ impl AppDirs {
self.state_dir().join("tray.sock")
}
/// The user's home directory — the root the daemon indexes for local
/// (non-Drive) file search. `None` if it cannot be resolved.
pub fn home_dir(&self) -> Option<PathBuf> {
directories::UserDirs::new().map(|u| u.home_dir().to_path_buf())
}
/// Default mountpoint when the user does not pass one explicitly.
pub fn default_mountpoint(&self) -> PathBuf {
directories::UserDirs::new()
@@ -110,24 +311,172 @@ impl AppDirs {
.unwrap_or_else(|| PathBuf::from("/tmp/ProtonDrive"))
}
/// Create state + cache dirs if missing.
/// The effective mountpoint: the config's explicit path, or
/// [`default_mountpoint`](Self::default_mountpoint) when unset.
pub fn resolved_mountpoint(&self, config: &AppConfig) -> PathBuf {
config
.mountpoint
.as_ref()
.map(PathBuf::from)
.unwrap_or_else(|| self.default_mountpoint())
}
/// Create state + cache dirs if missing, owner-only.
///
/// The mode is set explicitly rather than left to the umask. These
/// directories hold decrypted file content, a plaintext index of every node
/// name in the Drive, and the control socket — and `create_dir_all` under a
/// typical umask makes them `0755`. Until this was fixed, the only thing
/// keeping another local user out was `~/.cache` and `~/.local/state`
/// happening to be `0700`, which is a convention of the user's system and
/// not something this client established (bugs.md B6).
///
/// Applied on every start, not just at creation: a directory that already
/// exists with a permissive mode — restored from a backup that flattened
/// modes, or created by an older build — is tightened here.
pub fn ensure(&self) -> Result<()> {
std::fs::create_dir_all(self.state_dir())?;
std::fs::create_dir_all(self.cache_dir())?;
let _ = std::fs::create_dir_all(self.dirs.config_dir());
for dir in [self.state_dir(), self.cache_dir()] {
std::fs::create_dir_all(&dir)?;
restrict_dir(&dir)?;
}
let config_dir = self.dirs.config_dir().to_path_buf();
std::fs::create_dir_all(&config_dir)?;
restrict_dir(&config_dir)?;
Ok(())
}
}
/// Make `dir` owner-only (`0700`). These directories contain decrypted bytes,
/// plaintext names, and an authenticated control socket, so failure is fatal.
fn restrict_dir(dir: &Path) -> Result<()> {
use std::os::unix::fs::{MetadataExt as _, PermissionsExt};
let before = std::fs::symlink_metadata(dir)?;
if before.file_type().is_symlink() || !before.is_dir() {
return Err(Error::Other(format!(
"sensitive path is not a real directory: {}",
dir.display()
)));
}
if before.uid() != unsafe { libc::geteuid() } {
return Err(Error::Other(format!(
"sensitive directory is not owned by the current user: {}",
dir.display()
)));
}
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
let after = std::fs::symlink_metadata(dir)?;
if after.permissions().mode() & 0o777 != 0o700 {
return Err(Error::Other(format!(
"sensitive directory did not become owner-only: {}",
dir.display()
)));
}
Ok(())
}
/// Make a just-bound Unix socket owner-only (`0600`).
///
/// Connecting to the control socket drives the daemon with its authenticated
/// session — enumerate, read, upload, trash, share — without presenting any
/// credential, since the daemon already holds one. It is an authority boundary,
/// not merely private data, and `UnixListener::bind` applies the umask like any
/// other file (bugs.md B6).
///
/// There is a window between `bind` and this call during which the socket
/// carries the umask's mode. It is closed in practice by the containing
/// directory: [`Dirs::ensure`] has already made the state directory `0700`, so
/// nothing else can reach the socket to exploit the window.
pub fn restrict_socket(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
fn mode_of(p: &Path) -> u32 {
std::fs::metadata(p).unwrap().permissions().mode() & 0o777
}
/// bugs.md B6. A permissive mode here is not a cosmetic problem: these
/// directories hold decrypted content, a plaintext index of every node name,
/// and the control socket that commands the daemon's session.
#[test]
fn restrict_dir_makes_a_directory_owner_only() {
let dir = std::env::temp_dir().join(format!(
"pdfs-cfg-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
// Start deliberately world-readable, as `create_dir_all` under a typical
// umask would leave it.
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
restrict_dir(&dir).unwrap();
assert_eq!(mode_of(&dir), 0o700, "group and other must have no access");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn restrict_dir_refuses_a_symlink() {
let root = std::env::temp_dir().join(format!(
"pdfs-cfg-link-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let target = root.join("target");
let link = root.join("state");
std::fs::create_dir_all(&target).unwrap();
std::os::unix::fs::symlink(&target, &link).unwrap();
assert!(restrict_dir(&link).is_err());
let _ = std::fs::remove_dir_all(&root);
}
/// Connecting to the control socket confers the daemon's authenticated
/// session with no credential, so it is an authority boundary rather than
/// merely private data.
#[test]
fn restrict_socket_makes_a_socket_owner_only() {
let dir = std::env::temp_dir().join(format!(
"pdfs-sock-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let sock = dir.join("control.sock");
// chmod semantics are identical for a socket path and a regular file;
// use a file so this regression also runs in sandboxes that prohibit
// creating Unix sockets.
let listener = std::fs::File::create(&sock).unwrap();
restrict_socket(&sock).unwrap();
assert_eq!(mode_of(&sock), 0o600, "no other local user may connect");
drop(listener);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_app_config_serialization() {
let config = AppConfig {
app_version: "external-drive-test-client@1.0.0".to_string(),
user_agent: "test-agent/1.0".to_string(),
cache_budget: Some(1234),
mountpoint: Some("/tmp/x".to_string()),
ignore_patterns: Some(vec!["build/".to_string()]),
device_uid: Some("dev-uid".to_string()),
conflict_sweep: Some(SweepMode::Enforce),
open_with: None,
prompt: None,
};
let json = serde_json::to_string(&config).unwrap();
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
@@ -141,4 +490,86 @@ mod tests {
assert_eq!(default_config.app_version, APP_VERSION);
assert_eq!(default_config.user_agent, USER_AGENT);
}
#[test]
fn sweep_mode_defaults_to_report_only() {
// The sweep trashes user files, so an installed config that has never
// heard of the setting must not get the enforcing behaviour.
assert_eq!(SweepMode::default(), SweepMode::Report);
assert!(!SweepMode::default().is_enforcing());
assert_eq!(resolve_sweep_mode(None, None), SweepMode::Report);
}
#[test]
fn sweep_mode_parses_operator_spellings() {
for s in ["off", "OFF", " no ", "false", "0", "disabled"] {
assert_eq!(SweepMode::parse(s), Some(SweepMode::Off), "{s}");
}
for s in ["report", "dry-run", "dryrun", "observe"] {
assert_eq!(SweepMode::parse(s), Some(SweepMode::Report), "{s}");
}
for s in ["enforce", "on", "yes", "true", "1"] {
assert_eq!(SweepMode::parse(s), Some(SweepMode::Enforce), "{s}");
}
// A typo must not resolve to anything, so it falls through to the config.
assert_eq!(SweepMode::parse("offf"), None);
assert_eq!(SweepMode::parse(""), None);
}
#[test]
fn sweep_mode_env_overrides_config_but_a_typo_does_not() {
let stored = Some(SweepMode::Enforce);
// The point of the override: kill a destructive loop without editing config.
assert_eq!(resolve_sweep_mode(Some("off"), stored), SweepMode::Off);
// Unrecognised value falls back to the stored choice rather than guessing.
assert_eq!(resolve_sweep_mode(Some("nope"), stored), SweepMode::Enforce);
assert_eq!(resolve_sweep_mode(None, stored), SweepMode::Enforce);
// No env, no stored value: report-only.
assert_eq!(resolve_sweep_mode(Some("nope"), None), SweepMode::Report);
}
#[test]
fn a_config_predating_conflict_sweep_still_loads() {
let json = r#"{"app_version":"v","user_agent":"u"}"#;
let decoded: AppConfig = serde_json::from_str(json).unwrap();
assert!(decoded.conflict_sweep.is_none());
}
#[test]
fn sweep_mode_round_trips_through_the_config_file() {
let config = AppConfig {
conflict_sweep: Some(SweepMode::Enforce),
..Default::default()
};
let json = serde_json::to_string(&config).unwrap();
assert!(json.contains(r#""conflict_sweep":"enforce""#), "{json}");
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.conflict_sweep, Some(SweepMode::Enforce));
}
#[test]
fn a_config_predating_ignore_patterns_still_loads() {
// Every installed config on disk looks like this; the field must not be
// required or the daemon would fall back to a default config on upgrade.
let json = r#"{"app_version":"v","user_agent":"u"}"#;
let decoded: AppConfig = serde_json::from_str(json).unwrap();
assert!(decoded.ignore_patterns.is_none());
assert_eq!(
decoded.resolved_ignore_patterns().len(),
DEFAULT_IGNORE_PATTERNS.len(),
"an absent list means the defaults apply"
);
}
#[test]
fn an_explicit_empty_ignore_list_disables_the_defaults() {
let config = AppConfig {
ignore_patterns: Some(Vec::new()),
..AppConfig::default()
};
assert!(
config.resolved_ignore_patterns().is_empty(),
"opting out must be expressible, so this cannot be unwrap_or_default"
);
}
}
File diff suppressed because it is too large. Load diff
+66
View File
@@ -0,0 +1,66 @@
//! The activity feed: a bounded, newest-first log of what the daemon did.
use rusqlite::params;
use super::Db;
use crate::Result;
use crate::control::{ActivityEntry, ActivityKind};
/// How many activity rows to keep. Older rows are pruned on insert, so the feed
/// stays a bounded "recent history" rather than growing without limit.
pub(super) const ACTIVITY_KEEP: i64 = 2000;
impl Db {
pub fn activity_add(&self, entry: &ActivityEntry) -> Result<()> {
let kind = serde_json::to_string(&entry.kind)?;
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
tx.execute(
"INSERT INTO activity (time, kind, target, detail, ok) VALUES (?1, ?2, ?3, ?4, ?5)",
params![entry.time, kind, entry.target, entry.detail, entry.ok],
)?;
tx.execute(
"DELETE FROM activity WHERE id <= (
SELECT id FROM activity ORDER BY id DESC LIMIT 1 OFFSET ?1
)",
params![ACTIVITY_KEEP],
)?;
tx.commit()?;
Ok(())
}
/// The most recent activity, newest first, capped at `limit` entries. Rows
/// whose stored `kind` no longer parses (written by an older build) are
/// skipped rather than failing the whole read.
pub fn activity_list(&self, limit: usize) -> Result<Vec<ActivityEntry>> {
let conn = self.read();
let mut stmt = conn.prepare(
"SELECT time, kind, target, detail, ok FROM activity
ORDER BY id DESC LIMIT ?1",
)?;
let rows = stmt.query_map(params![limit as i64], |r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, String>(1)?,
r.get::<_, String>(2)?,
r.get::<_, String>(3)?,
r.get::<_, bool>(4)?,
))
})?;
let mut out = Vec::new();
for row in rows {
let (time, kind, target, detail, ok) = row?;
let Ok(kind) = serde_json::from_str::<ActivityKind>(&kind) else {
continue;
};
out.push(ActivityEntry {
time,
kind,
target,
detail,
ok,
});
}
Ok(out)
}
}
+267
View File
@@ -0,0 +1,267 @@
//! Photo albums: the album listing and each album's contents, persisted like
//! the timeline so the Albums view opens from disk instead of re-enumerating the
//! photos volume on every launch.
//!
//! An album's photos are stored per album rather than as a join against
//! `photos`: an album shared with us lives on the sharer's volume, so its photos
//! never appear in our own timeline and have nowhere else to record what a
//! thumbnail attempt learned about them.
use rusqlite::params;
use std::collections::HashMap;
use super::{Db, StoredPhoto, THUMB_UNKNOWN};
use crate::Result;
/// One album of the persisted listing, ordered newest-activity-first in `seq`.
#[derive(Clone, Debug, PartialEq)]
pub struct StoredAlbum {
pub uid: String,
pub name: String,
pub photo_count: usize,
/// The photo shown as the album's cover, when the server named one.
pub cover_uid: Option<String>,
/// Epoch seconds of the last change to the album's contents.
pub last_activity: Option<i64>,
/// True when the album lives on someone else's photos volume.
pub shared: bool,
}
impl Db {
/// Replace the album listing wholesale, in the given order.
pub fn albums_replace(&self, albums: &[StoredAlbum]) -> Result<()> {
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
// Contents of an album that no longer exists (unshared, deleted) go with
// it — otherwise they would linger forever, keyed by an album uid no
// listing mentions again.
{
let kept: std::collections::HashSet<&str> =
albums.iter().map(|a| a.uid.as_str()).collect();
let stale: Vec<String> = {
let mut stmt = tx.prepare("SELECT DISTINCT album_uid FROM album_photos")?;
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
rows.filter_map(|r| r.ok())
.filter(|uid| !kept.contains(uid.as_str()))
.collect()
};
let mut stmt = tx.prepare("DELETE FROM album_photos WHERE album_uid = ?1")?;
for uid in stale {
stmt.execute([uid])?;
}
}
tx.execute("DELETE FROM albums", [])?;
{
let mut stmt = tx.prepare(
"INSERT INTO albums
(uid, name, photo_count, cover_uid, last_activity, shared, seq)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
)?;
for (seq, album) in albums.iter().enumerate() {
stmt.execute(params![
album.uid,
album.name,
album.photo_count as i64,
album.cover_uid,
album.last_activity,
album.shared as i64,
seq as i64,
])?;
}
}
tx.commit()?;
Ok(())
}
/// The persisted album listing, in stored order.
pub fn albums_list(&self) -> Result<Vec<StoredAlbum>> {
let conn = self.read();
let mut stmt = conn.prepare(
"SELECT uid, name, photo_count, cover_uid, last_activity, shared
FROM albums ORDER BY seq",
)?;
let rows = stmt
.query_map([], |r| {
Ok(StoredAlbum {
uid: r.get(0)?,
name: r.get(1)?,
photo_count: r.get::<_, i64>(2)?.max(0) as usize,
cover_uid: r.get(3)?,
last_activity: r.get(4)?,
shared: r.get::<_, i64>(5)? != 0,
})
})?
.collect::<rusqlite::Result<_>>()?;
Ok(rows)
}
/// Number of albums in the persisted listing.
pub fn albums_count(&self) -> Result<usize> {
let conn = self.read();
let n: i64 = conn.query_row("SELECT COUNT(*) FROM albums", [], |r| r.get(0))?;
Ok(n.max(0) as usize)
}
/// Replace one album's contents, in the given (newest-capture-first) order.
/// Learned aspect ratios and thumbnail verdicts survive the replacement, the
/// same way [`Db::photos_replace`] keeps them for the timeline.
pub fn album_photos_replace(
&self,
album_uid: &str,
items: &[(String, i64, Option<String>, Option<String>)],
) -> Result<()> {
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
let learned: HashMap<String, (Option<f64>, i64, Option<String>)> = {
let mut stmt = tx.prepare(
"SELECT uid, ratio, thumb_state, media_type FROM album_photos
WHERE album_uid = ?1",
)?;
let rows = stmt.query_map([album_uid], |r| {
Ok((r.get::<_, String>(0)?, (r.get(1)?, r.get(2)?, r.get(3)?)))
})?;
rows.collect::<rusqlite::Result<_>>()?
};
tx.execute("DELETE FROM album_photos WHERE album_uid = ?1", [album_uid])?;
{
let mut stmt = tx.prepare(
"INSERT INTO album_photos
(album_uid, uid, capture_time, name, media_type, kind, ratio, thumb_state, seq)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
)?;
for (seq, (uid, capture_time, name, media_type)) in items.iter().enumerate() {
let (ratio, thumb_state, learned_media) =
learned
.get(uid)
.cloned()
.unwrap_or((None, THUMB_UNKNOWN, None));
let media_type = media_type.clone().or(learned_media);
let kind =
crate::control::PhotoKind::classify(name.as_deref(), media_type.as_deref());
stmt.execute(params![
album_uid,
uid,
capture_time,
name,
media_type,
kind.as_i64(),
ratio,
thumb_state,
seq as i64,
])?;
}
}
tx.commit()?;
Ok(())
}
/// One page of an album's photos, in stored order.
pub fn album_photos_page(
&self,
album_uid: &str,
offset: usize,
limit: usize,
) -> Result<Vec<StoredPhoto>> {
let conn = self.read();
let mut stmt = conn.prepare(
"SELECT uid, capture_time, name, ratio, thumb_state, kind FROM album_photos
WHERE album_uid = ?1 ORDER BY seq LIMIT ?2 OFFSET ?3",
)?;
let rows = stmt
.query_map(params![album_uid, limit as i64, offset as i64], |r| {
Ok(StoredPhoto {
uid: r.get(0)?,
capture_time: r.get(1)?,
name: r.get(2)?,
ratio: r.get(3)?,
thumb_state: r.get(4)?,
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
// Album rows have no favourite state of their own: an album
// photo on someone else's volume is not in our timeline, and
// favouriting one is not supported yet anyway.
favorite: false,
// Nor are album rows grouped: an album is a list the user
// made, and it shows exactly what they put in it.
group_size: 1,
has_raw: false,
})
})?
.collect::<rusqlite::Result<_>>()?;
Ok(rows)
}
/// Which albums hold `uid`, by album node uid. Read by the photo re-date,
/// which has to put the re-uploaded copy back into the albums the original
/// was in.
pub fn albums_of_photo(&self, uid: &str) -> Result<Vec<String>> {
let conn = self.read();
let mut stmt =
conn.prepare("SELECT DISTINCT album_uid FROM album_photos WHERE uid = ?1")?;
let rows = stmt
.query_map([uid], |r| r.get(0))?
.collect::<rusqlite::Result<_>>()?;
Ok(rows)
}
/// How many photos of `album_uid` are persisted.
pub fn album_photos_count(&self, album_uid: &str) -> Result<usize> {
let conn = self.read();
let n: i64 = conn.query_row(
"SELECT COUNT(*) FROM album_photos WHERE album_uid = ?1",
[album_uid],
|r| r.get(0),
)?;
Ok(n.max(0) as usize)
}
/// The album-held photos among `uids`, one row per uid (a photo in two albums
/// is returned once). Backs the thumbnail path for photos that are in an
/// album but not in our own timeline — everything in a shared album, and an
/// album cover whose photo the current timeline page hasn't reached.
pub fn album_photos_by_uid(&self, uids: &[String]) -> Result<Vec<StoredPhoto>> {
if uids.is_empty() {
return Ok(Vec::new());
}
let conn = self.read();
let placeholders = vec!["?"; uids.len()].join(",");
let mut stmt = conn.prepare(&format!(
"SELECT uid, capture_time, name, ratio, thumb_state, kind FROM album_photos
WHERE uid IN ({placeholders}) GROUP BY uid"
))?;
let rows = stmt
.query_map(rusqlite::params_from_iter(uids), |r| {
Ok(StoredPhoto {
uid: r.get(0)?,
capture_time: r.get(1)?,
name: r.get(2)?,
ratio: r.get(3)?,
thumb_state: r.get(4)?,
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
// Album rows have no favourite state of their own: an album
// photo on someone else's volume is not in our timeline, and
// favouriting one is not supported yet anyway.
favorite: false,
// Nor are album rows grouped: an album is a list the user
// made, and it shows exactly what they put in it.
group_size: 1,
has_raw: false,
})
})?
.collect::<rusqlite::Result<_>>()?;
Ok(rows)
}
/// Record a thumbnail verdict against every album row for `uid`, so what one
/// attempt learned is not re-learned per album the photo sits in. The
/// timeline's own copy is updated separately by [`Db::photo_set_thumb`].
pub fn album_photo_set_thumb(&self, uid: &str, state: i64, ratio: Option<f64>) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"UPDATE album_photos SET thumb_state = ?2, ratio = COALESCE(?3, ratio) WHERE uid = ?1",
params![uid, state, ratio],
)?;
Ok(())
}
}
+196
View File
@@ -0,0 +1,196 @@
//! The content-cache index: one row per cached blob, carrying the access stamp
//! the LRU eviction pass sorts on.
use rusqlite::params;
use super::Db;
use crate::Result;
pub struct CacheEntryInput<'a> {
/// On-disk filename of the blob or block.
pub key: &'a str,
/// `'blob'` or `'block'` — the two byte budgets are counted separately.
pub kind: &'a str,
pub size: u64,
/// Initial LRU key, in unix milliseconds.
pub last_accessed: i64,
}
impl Db {
pub fn cache_touch(&self, key: &str, kind: &str, size: u64, now: i64) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"INSERT INTO cache_entries (cache_key, kind, size_bytes, last_accessed)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(cache_key) DO UPDATE SET
kind = excluded.kind,
size_bytes = excluded.size_bytes,
last_accessed = excluded.last_accessed",
params![key, kind, size as i64, now],
)?;
Ok(())
}
/// Bump only `last_accessed` on a cache hit (LRU ordering). A missing row is
/// a no-op — best effort, mirroring the old best-effort mtime touch.
pub fn cache_accessed(&self, key: &str, now: i64) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"UPDATE cache_entries SET last_accessed = ?2 WHERE cache_key = ?1",
params![key, now],
)?;
Ok(())
}
/// Bump `last_accessed` for many keys in one transaction.
///
/// The read path touches a key per 128 KiB the kernel asks for — 32 times
/// per 4 MiB block — and each of those was its own write transaction under
/// the single connection mutex, contending with every `lookup` and
/// `readdir`. Callers accumulate touches in memory and flush them here.
pub fn cache_accessed_batch(&self, touches: &[(String, i64)]) -> Result<()> {
if touches.is_empty() {
return Ok(());
}
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
{
let mut stmt = tx.prepare_cached(
"UPDATE cache_entries SET last_accessed = ?2 WHERE cache_key = ?1",
)?;
for (key, at) in touches {
stmt.execute(params![key, at])?;
}
}
tx.commit()?;
Ok(())
}
/// Drop a single cache-entry row (one evicted blob or block).
pub fn cache_remove(&self, key: &str) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"DELETE FROM cache_entries WHERE cache_key = ?1",
params![key],
)?;
Ok(())
}
/// Drop the whole-file blob row for `key` plus every derived row for the
/// same uid — blocks (`<key>.b<idx>`) and thumbnails (`<key>.t<type>`).
/// Called by `ContentCache::evict`, which removes the blob, all of a uid's
/// cached blocks and all of its thumbnails at once; a row left behind here
/// would keep counting bytes that are no longer on disk.
pub fn cache_remove_all(&self, key: &str) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"DELETE FROM cache_entries
WHERE cache_key = ?1 OR cache_key LIKE ?2 OR cache_key LIKE ?3",
params![key, format!("{key}.b%"), format!("{key}.t%")],
)?;
Ok(())
}
/// Total bytes held by entries of `kind`.
///
/// The budget enforcers' first question, and almost always their last: the
/// common case is "under budget, evict nothing". Answering it with a `SUM`
/// costs one index range rather than materializing every row, which matters
/// because `enforce_block_budget` runs on every cached block — once per
/// 4 MiB of every cold read — while holding the shared connection.
pub fn cache_total_bytes(&self, kind: &str) -> Result<u64> {
let conn = self.read();
let total: i64 = conn.query_row(
"SELECT COALESCE(SUM(size_bytes), 0) FROM cache_entries WHERE kind = ?1",
params![kind],
|r| r.get(0),
)?;
Ok(total.max(0) as u64)
}
/// The `limit` least-recently-accessed entries of `kind`, oldest first.
///
/// Eviction wants a handful of victims, not the whole table. Bounded so a
/// pass over a large cache does not read every row to drop a few, and so the
/// `(kind, last_accessed)` index can answer it as a range scan with an early
/// stop.
pub fn cache_eviction_candidates(
&self,
kind: &str,
limit: usize,
) -> Result<Vec<(String, u64)>> {
let conn = self.read();
let mut stmt = conn.prepare_cached(
"SELECT cache_key, size_bytes FROM cache_entries
WHERE kind = ?1 ORDER BY last_accessed ASC LIMIT ?2",
)?;
let rows = stmt.query_map(params![kind, limit as i64], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.max(0) as u64))
})?;
let mut out = Vec::new();
for r in rows {
out.push(r?);
}
Ok(out)
}
/// Every cache entry of `kind`, ordered least-recently-accessed first, as
/// `(cache_key, size_bytes)`. Used by the whole-cache passes
/// (`clear_unpinned`); incremental eviction wants
/// [`cache_eviction_candidates`](Self::cache_eviction_candidates).
pub fn cache_entries_by_kind(&self, kind: &str) -> Result<Vec<(String, u64)>> {
let conn = self.read();
let mut stmt = conn.prepare(
"SELECT cache_key, size_bytes FROM cache_entries
WHERE kind = ?1 ORDER BY last_accessed ASC",
)?;
let rows = stmt.query_map(params![kind], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.max(0) as u64))
})?;
let mut out = Vec::new();
for r in rows {
out.push(r?);
}
Ok(out)
}
/// Replace the whole cache index with `entries`, as one transaction. The
/// daemon calls this on open, so a crash or an external file deletion can
/// never leave a phantom row inflating the budget total.
///
/// The clear and the refill are the same commit deliberately: they are one
/// operation — "the index now describes what is on disk" — and a crash
/// between them would otherwise leave an empty index against a full cache
/// directory, i.e. a budget total of zero and nothing eviction can see.
///
/// One commit also matters for speed. A mount is the only caller and it runs
/// on the open path, so a cache of thousands of blobs used to pay thousands
/// of autocommit fsyncs before the mountpoint appeared.
pub fn cache_rebuild(&self, entries: &[CacheEntryInput<'_>]) -> Result<()> {
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
tx.execute("DELETE FROM cache_entries", [])?;
{
let mut stmt = tx.prepare(
"INSERT INTO cache_entries (cache_key, kind, size_bytes, last_accessed)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(cache_key) DO UPDATE SET
kind = excluded.kind,
size_bytes = excluded.size_bytes,
last_accessed = excluded.last_accessed",
)?;
for e in entries {
stmt.execute(params![e.key, e.kind, e.size as i64, e.last_accessed])?;
}
}
tx.commit()?;
Ok(())
}
// --- Pins (P5) --------------------------------------------------------
//
// The pin registry, formerly `pins.json`. One row per directly-pinned node,
// keyed by uid display string. `recursive` marks a folder pin: its whole
// subtree counts as pinned, resolved against `nodes` via a CTE so a new
// descendant is covered the moment it lands in the node cache.
}
+47
View File
@@ -0,0 +1,47 @@
//! This machine as a registered Proton Drive device.
use rusqlite::{OptionalExtension, params};
use super::Db;
use crate::Result;
pub struct StoredDevice {
pub uid: String,
pub share_id: String,
pub root_uid: String,
pub name: String,
pub created: i64,
}
impl Db {
pub fn device_get(&self) -> Result<Option<StoredDevice>> {
let conn = self.read();
conn.query_row(
"SELECT uid, share_id, root_uid, name, created FROM device LIMIT 1",
[],
|r| {
Ok(StoredDevice {
uid: r.get(0)?,
share_id: r.get(1)?,
root_uid: r.get(2)?,
name: r.get(3)?,
created: r.get(4)?,
})
},
)
.optional()
.map_err(Into::into)
}
/// Persist (or replace) this machine's device. The table holds a single row.
pub fn device_set(&self, dev: &StoredDevice) -> Result<()> {
let conn = self.conn.lock();
conn.execute("DELETE FROM device", [])?;
conn.execute(
"INSERT INTO device (uid, share_id, root_uid, name, created)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![dev.uid, dev.share_id, dev.root_uid, dev.name, dev.created],
)?;
Ok(())
}
}
+255
View File
@@ -0,0 +1,255 @@
//! The local-file index: a generation-stamped scan of the mount, so search can
//! answer over files the daemon has on disk as well as the remote tree.
use rusqlite::{OptionalExtension, params};
use super::Db;
use crate::Result;
use crate::localindex::LocalEntry;
use super::nodes::candidate_trigrams;
use super::utils::{TRIGRAM_MIN, like_escape};
/// One hit from [`Db::search_local`]: an indexed file on the machine itself, not
/// in Drive. `path` is absolute.
pub struct LocalFileHit {
pub path: String,
pub name: String,
pub is_dir: bool,
pub size: i64,
pub mtime: i64,
}
impl Db {
pub fn local_begin_scan(&self) -> Result<i64> {
let conn = self.conn.lock();
let current: i64 = conn
.query_row(
"SELECT value FROM sync_state WHERE key = 'local_scan_gen'",
[],
|row| row.get::<_, String>(0),
)
.optional()?
.and_then(|v| v.parse().ok())
.unwrap_or(0);
let next = current + 1;
conn.execute(
"INSERT INTO sync_state (key, value) VALUES ('local_scan_gen', ?1)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
[next.to_string()],
)?;
Ok(next)
}
/// Write one batch of walked entries under scan generation `generation`,
/// keeping the FTS index in step with them.
///
/// The index is maintained incrementally rather than rebuilt at the end of
/// the scan, because a rebuild reads and re-tokenises every row in the table
/// — trigrams over a few hundred thousand paths — inside one transaction on
/// the single shared connection, which stalls the whole mount for as long as
/// it takes. Incremental maintenance is possible here for a reason specific
/// to this table: `local_fts` covers `name` and `path`, `path` is the
/// table's key, and `name` is derived from it, so an existing row's indexed
/// content can never change. Only insertions and deletions touch the index;
/// a re-scan that finds the same files does no index work at all.
///
/// Hence the update-then-insert shape below rather than an upsert: the
/// distinction between "already indexed" and "new row" is exactly what
/// decides whether the index needs a write.
pub fn local_upsert_batch(&self, generation: i64, entries: &[LocalEntry]) -> Result<()> {
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
{
let mut update = tx.prepare_cached(
"UPDATE local_files
SET name = ?2, is_dir = ?3, size = ?4, mtime = ?5, scan_gen = ?6
WHERE path = ?1",
)?;
let mut insert = tx.prepare_cached(
"INSERT INTO local_files (path, name, is_dir, size, mtime, scan_gen)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
)?;
let mut index =
tx.prepare_cached("INSERT INTO local_fts (rowid, name, path) VALUES (?1, ?2, ?3)")?;
for e in entries {
let row = params![e.path, e.name, e.is_dir as i64, e.size, e.mtime, generation];
if update.execute(row)? == 0 {
insert.execute(row)?;
index.execute(params![tx.last_insert_rowid(), e.name, e.path])?;
}
}
}
tx.commit()?;
Ok(())
}
/// Close scan generation `generation`: drop every row an older scan wrote
/// (those paths are gone from disk), drop their FTS entries, and stamp the
/// completion time. Returns the number of indexed entries.
///
/// The FTS deletes name the old values explicitly, which is how an
/// external-content FTS5 table is told to retract a row — see
/// [`local_upsert_batch`](Self::local_upsert_batch) for why this is done
/// per-row instead of by rebuilding the index.
pub fn local_finish_scan(&self, generation: i64, finished_at: i64) -> Result<i64> {
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
tx.execute(
"INSERT INTO local_fts (local_fts, rowid, name, path)
SELECT 'delete', id, name, path FROM local_files WHERE scan_gen != ?1",
params![generation],
)?;
tx.execute(
"DELETE FROM local_files WHERE scan_gen != ?1",
params![generation],
)?;
tx.execute(
"INSERT INTO sync_state (key, value) VALUES ('local_indexed_at', ?1)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
[finished_at.to_string()],
)?;
let count: i64 = tx.query_row("SELECT COUNT(*) FROM local_files", [], |r| r.get(0))?;
tx.commit()?;
Ok(count)
}
/// When the last local scan completed (epoch seconds), or `None` if the index
/// has never been built. The daemon uses this to decide whether a fresh mount
/// needs an immediate rescan or can serve the existing index.
pub fn local_indexed_at(&self) -> Result<Option<i64>> {
let conn = self.read();
Ok(conn
.query_row(
"SELECT value FROM sync_state WHERE key = 'local_indexed_at'",
[],
|row| row.get::<_, String>(0),
)
.optional()?
.and_then(|v| v.parse().ok()))
}
/// Substring search over indexed local file names, newest-modified first
/// within a relevance tier. Mirrors [`search`](Self::search): the trigram
/// index handles queries of at least [`TRIGRAM_MIN`] chars, shorter ones fall
/// back to a `LIKE` scan.
pub fn search_local(&self, query: &str, limit: usize) -> Result<Vec<LocalFileHit>> {
let query = query.trim();
if query.is_empty() {
return Ok(Vec::new());
}
let conn = self.read();
let mut stmt;
let rows = if query.chars().count() < TRIGRAM_MIN {
let pat = format!("%{}%", like_escape(query));
stmt = conn.prepare(
"SELECT path, name, is_dir, size, mtime FROM local_files
WHERE name LIKE ?1 ESCAPE '\\'
ORDER BY mtime DESC LIMIT ?2",
)?;
stmt.query_map(params![pat, limit as i64], local_hit)?
} else {
let phrase = query
.split_whitespace()
.map(|word| format!("\"{}\"", word.replace('"', "\"\"")))
.collect::<Vec<_>>()
.join(" AND ");
stmt = conn.prepare(
"SELECT f.path, f.name, f.is_dir, f.size, f.mtime
FROM local_fts x JOIN local_files f ON f.id = x.rowid
WHERE x.name MATCH ?1
ORDER BY x.rank LIMIT ?2",
)?;
stmt.query_map(params![phrase, limit as i64], local_hit)?
};
let mut out = Vec::new();
for r in rows {
out.push(r?);
}
Ok(out)
}
/// Bounded candidates for the fuzzy scorer, including matches in the full
/// local path. ORed trigrams admit misspellings while the FTS index prevents
/// this from becoming an unbounded filesystem-table scan.
pub fn search_local_candidates(&self, query: &str, limit: usize) -> Result<Vec<LocalFileHit>> {
let query = query.trim();
if query.is_empty() || limit == 0 {
return Ok(Vec::new());
}
let conn = self.read();
let terms = candidate_trigrams(query);
let lane_limit = limit.div_ceil(2);
let mut rows: Vec<LocalFileHit> = if terms.is_empty() {
let pat = format!("%{}%", like_escape(query));
let mut stmt = conn.prepare(
"SELECT path, name, is_dir, size, mtime FROM local_files
WHERE name LIKE ?1 ESCAPE '\\' OR path LIKE ?1 ESCAPE '\\'
ORDER BY mtime DESC LIMIT ?2",
)?;
stmt.query_map(params![pat, limit as i64], local_hit)?
.collect::<std::result::Result<_, _>>()?
} else {
let mut stmt = conn.prepare(
"SELECT f.path, f.name, f.is_dir, f.size, f.mtime
FROM local_fts x JOIN local_files f ON f.id = x.rowid
WHERE local_fts MATCH ?1
ORDER BY x.rank LIMIT ?2",
)?;
stmt.query_map(params![terms.join(" OR "), lane_limit as i64], local_hit)?
.collect::<std::result::Result<_, _>>()?
};
if !terms.is_empty()
&& let Some(first) = query.chars().next()
{
// Trigram rank says nothing about how well a name *starts* with the
// query, so on a home directory full of dependency trees the pool
// fills with vendored files before the obvious answer is reached
// (measured: 73% of the 500 best-ranked candidates for "test" came
// from one Go module cache). Give whole-query prefix matches their
// own lane, newest first, and keep the single-character lane —
// which recovers a typo that destroyed every trigram — behind it.
let mut lanes = vec![(
format!("{}%", like_escape(query)),
"ORDER BY mtime DESC LIMIT ?2",
)];
lanes.push((
format!("{}%", like_escape(&first.to_string())),
"ORDER BY name COLLATE NOCASE LIMIT ?2",
));
let mut extra = Vec::new();
for (pattern, order) in lanes {
let mut stmt = conn.prepare(&format!(
"SELECT path, name, is_dir, size, mtime FROM local_files
WHERE name COLLATE NOCASE LIKE ?1 ESCAPE '\\' {order}"
))?;
extra.extend(
stmt.query_map(params![pattern, lane_limit as i64], local_hit)?
.collect::<std::result::Result<Vec<_>, _>>()?,
);
}
// Prefix hits go in front: `rows` is truncated to `limit` below, and
// the lane exists precisely because trigram rank was burying them.
let trigram = std::mem::take(&mut rows);
for hit in extra.into_iter().chain(trigram) {
if !rows.iter().any(|existing| existing.path == hit.path) {
rows.push(hit);
}
}
rows.truncate(limit);
}
Ok(rows)
}
}
/// Row → [`LocalFileHit`]. Every local-search query selects the same
/// columns in the same order.
pub(super) fn local_hit(row: &rusqlite::Row) -> rusqlite::Result<LocalFileHit> {
Ok(LocalFileHit {
path: row.get(0)?,
name: row.get(1)?,
is_dir: row.get::<_, i64>(2)? != 0,
size: row.get(3)?,
mtime: row.get(4)?,
})
}
+337
View File
@@ -0,0 +1,337 @@
//! Database health reporting and repair (features.md 4.3).
//!
//! Everything here exists for the case where something has already gone wrong:
//! a crash mid-write, a full disk, a cache that has grown out of proportion to
//! what it holds. The daemon never calls these on its own — they are what
//! `pdfs cache inspect`, `pdfs cache vacuum`, and `pdfs diagnose` are built on,
//! so a user with a suspect install can get an answer without being told to
//! delete their state directory and re-sync from scratch.
use rusqlite::OptionalExtension;
use super::Db;
use crate::Result;
/// A point-in-time picture of the database's size and contents.
///
/// Sizes are computed from SQLite's own page accounting rather than by stat'ing
/// the file, so they stay right when the file is sparse or a checkpoint is
/// pending.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DbStats {
/// Schema version the file is currently at.
pub schema_version: i64,
/// Total pages allocated to the database.
pub page_count: i64,
/// Pages allocated but unused — what a `VACUUM` would reclaim.
pub free_pages: i64,
/// Bytes per page.
pub page_size: i64,
/// Row counts for the tables worth reporting, in display order.
pub tables: Vec<(String, i64)>,
}
impl DbStats {
/// Bytes the database file accounts for.
pub fn total_bytes(&self) -> u64 {
(self.page_count * self.page_size).max(0) as u64
}
/// Bytes a `VACUUM` could hand back to the filesystem.
pub fn reclaimable_bytes(&self) -> u64 {
(self.free_pages * self.page_size).max(0) as u64
}
}
/// What a maintenance run reclaimed.
///
/// Deliberately does not carry an orphaned-row count: the content cache already
/// rebuilds its index from what is actually on disk every time it opens (see
/// [`crate::cache::ContentCache`]'s `reconcile`), so a second pruning pass here
/// would be a duplicate of that logic with a worse view of the truth.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct VacuumOutcome {
/// Database bytes before the run.
pub before_bytes: u64,
/// Database bytes after it.
pub after_bytes: u64,
/// WAL frames written back into the database by the checkpoint.
pub wal_frames_checkpointed: i64,
}
impl VacuumOutcome {
/// Bytes handed back to the filesystem. Saturating: a database can legitimately
/// grow across a vacuum (a checkpoint folds WAL frames in), and reporting that
/// as a huge positive number via wraparound would be worse than reporting zero.
pub fn freed_bytes(&self) -> u64 {
self.before_bytes.saturating_sub(self.after_bytes)
}
}
/// The tables `inspect` reports on, in the order a reader wants them.
const REPORTED_TABLES: &[&str] = &[
"nodes",
"cache_entries",
"pins",
"photos",
"trash",
"device",
"sync_folder",
"mount",
"sync_entry",
"pending_op",
"activity",
"local_files",
"share_access",
];
impl Db {
/// SQLite's own consistency check.
///
/// Returns an empty vector when the database is sound; otherwise each entry
/// is one problem SQLite found. Read-only and safe against a live daemon,
/// though it reads every page, so it is not free on a large file.
pub fn integrity_check(&self) -> Result<Vec<String>> {
let conn = self.conn.lock();
let mut stmt = conn.prepare("PRAGMA integrity_check")?;
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
let mut problems = Vec::new();
for row in rows {
let row = row?;
// A sound database reports the single line "ok"; anything else is a
// finding.
if row != "ok" {
problems.push(row);
}
}
Ok(problems)
}
/// Size and row-count snapshot for `pdfs cache inspect`.
pub fn stats(&self) -> Result<DbStats> {
let conn = self.conn.lock();
let page_count: i64 = conn.query_row("PRAGMA page_count", [], |r| r.get(0))?;
let free_pages: i64 = conn.query_row("PRAGMA freelist_count", [], |r| r.get(0))?;
let page_size: i64 = conn.query_row("PRAGMA page_size", [], |r| r.get(0))?;
// The schema version lives in the `sync_state` key/value table, not in
// `PRAGMA user_version` — see `migrations`.
let schema_version: i64 = conn
.query_row(
"SELECT value FROM sync_state WHERE key = 'schema_version'",
[],
|r| r.get::<_, String>(0),
)
.optional()?
.and_then(|v| v.parse().ok())
.unwrap_or(0);
let mut tables = Vec::new();
for name in REPORTED_TABLES {
// A table listed here may not exist yet on an older schema, and a
// report is not worth failing over a missing one.
let exists: Option<String> = conn
.query_row(
"SELECT name FROM sqlite_master WHERE type='table' AND name=?1",
[name],
|r| r.get(0),
)
.optional()?;
if exists.is_none() {
continue;
}
let count: i64 =
conn.query_row(&format!("SELECT count(*) FROM \"{name}\""), [], |r| {
r.get(0)
})?;
tables.push(((*name).to_string(), count));
}
Ok(DbStats {
schema_version,
page_count,
free_pages,
page_size,
tables,
})
}
/// Fold the WAL back into the database and compact the file.
///
/// `VACUUM` rewrites the whole database into a fresh file, so it needs room
/// for a second copy and takes a write lock for the duration. That makes it
/// a deliberate, user-invoked operation rather than something the daemon
/// does on a timer.
///
/// The checkpoint runs *after* the vacuum. Checkpointing only beforehand
/// folds a log that the vacuum is about to rewrite anyway, and leaves the
/// rewrite itself sitting in the WAL: measured on a live install, a 170 MiB
/// database vacuumed to 136 MiB while its WAL grew 67 MiB → 143 MiB,
/// erasing the reclaim in net disk terms and reporting `0 frames`
/// checkpointed because it ran when there was nothing to fold.
///
/// Ordering it afterwards is strictly better but is *not* a proven fix for
/// that inflation. The live case inflates because the daemon's other
/// connections hold read transactions, and a `TRUNCATE` checkpoint waits on
/// readers — so under a busy daemon this can still return having moved
/// little. `journal_size_limit` remains the backstop that caps the file.
/// Confirming the fix needs a measurement against a live daemon, not a
/// single-connection test, which folds the WAL on its own either way.
pub fn vacuum(&self) -> Result<VacuumOutcome> {
let before = self.stats()?.total_bytes();
{
let conn = self.conn.lock();
conn.execute_batch("VACUUM")?;
}
let wal_frames_checkpointed = self.checkpoint_wal()?;
let after = self.stats()?.total_bytes();
Ok(VacuumOutcome {
before_bytes: before,
after_bytes: after,
wal_frames_checkpointed,
})
}
/// Checkpoint the WAL and truncate it, returning the number of frames
/// written back into the database.
///
/// `TRUNCATE` blocks on readers rather than giving up part-way, which is
/// what makes the result meaningful: a `PASSIVE` checkpoint against a busy
/// daemon can legitimately move nothing and report success.
pub fn checkpoint_wal(&self) -> Result<i64> {
let conn = self.conn.lock();
// Columns are (busy, log frames, frames checkpointed).
let frames: i64 = conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |r| r.get(2))?;
Ok(frames.max(0))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_fresh_database_passes_its_integrity_check() {
let db = Db::open_in_memory().unwrap();
assert!(db.integrity_check().unwrap().is_empty());
}
#[test]
fn stats_report_the_schema_and_the_known_tables() {
let db = Db::open_in_memory().unwrap();
let stats = db.stats().unwrap();
assert!(stats.schema_version > 0, "migrations set a user_version");
assert!(stats.page_size > 0);
assert!(
stats.tables.iter().any(|(name, _)| name == "nodes"),
"the nodes table should be reported; got {:?}",
stats.tables
);
// A fresh database has no rows in any of them.
assert!(stats.tables.iter().all(|(_, count)| *count == 0));
}
#[test]
fn total_and_reclaimable_bytes_derive_from_the_page_counts() {
let stats = DbStats {
schema_version: 1,
page_count: 10,
free_pages: 4,
page_size: 4096,
tables: Vec::new(),
};
assert_eq!(stats.total_bytes(), 40960);
assert_eq!(stats.reclaimable_bytes(), 16384);
}
/// A vacuum that leaves the file larger (a checkpoint folded WAL frames in)
/// must report nothing freed, not an underflowed enormous number.
#[test]
fn freed_bytes_does_not_underflow_when_the_file_grew() {
let outcome = VacuumOutcome {
before_bytes: 100,
after_bytes: 400,
..VacuumOutcome::default()
};
assert_eq!(outcome.freed_bytes(), 0);
}
#[test]
fn freed_bytes_reports_the_difference_when_the_file_shrank() {
let outcome = VacuumOutcome {
before_bytes: 4096,
after_bytes: 1024,
..VacuumOutcome::default()
};
assert_eq!(outcome.freed_bytes(), 3072);
}
#[test]
fn vacuuming_a_fresh_database_leaves_it_sound() {
let db = Db::open_in_memory().unwrap();
db.vacuum().unwrap();
assert!(db.integrity_check().unwrap().is_empty());
}
/// A unique temp directory removed on drop; avoids a dev-dependency, as in
/// [`crate::cache`]'s tests. Needed here because an in-memory database has
/// no WAL file to measure.
struct TempDir(std::path::PathBuf);
impl TempDir {
fn new() -> Self {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
let p = std::env::temp_dir().join(format!(
"pdfs-maint-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir_all(&p).unwrap();
TempDir(p)
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// A file-backed vacuum reclaims space and leaves the file sound.
///
/// Note what this does *not* pin: the WAL-inflation behaviour that motivated
/// the checkpoint ordering in [`Db::vacuum`]. Measured here, both orderings
/// report `frames=0` and leave a 0-byte WAL, because a single-connection
/// test lets SQLite fold the log itself. The live case inflated only because
/// the daemon holds concurrent readers that block a checkpoint, and
/// reproducing that needs a second connection parked in a read transaction.
/// An assertion on frame count or WAL size here would pass under both
/// orderings and so would be decoration — see the note in `vacuum`.
#[test]
fn a_file_backed_vacuum_reclaims_space_and_stays_sound() {
let dir = TempDir::new();
let path = dir.0.join("cache.db");
let db = Db::open(&path).unwrap();
for i in 0..2000 {
db.set_state_str(&format!("k{i}"), &"v".repeat(256))
.unwrap();
}
let full = db.stats().unwrap().total_bytes();
for i in 0..2000 {
db.set_state_str(&format!("k{i}"), "").unwrap();
}
let outcome = db.vacuum().unwrap();
assert!(
outcome.after_bytes < full,
"vacuum did not reclaim: {} → {} (peak {full})",
outcome.before_bytes,
outcome.after_bytes
);
assert!(db.integrity_check().unwrap().is_empty());
}
}
+954
View File
@@ -0,0 +1,954 @@
//! Forward-only schema migrations.
//!
//! Each `MIGRATION_V*` is the SQL that moves the schema from version N-1 to N.
//! They are applied in order and never edited once shipped: a released version
//! is already on disk somewhere, so a change here would silently diverge from
//! what an existing install actually has. Add a new one instead.
use super::Db;
use crate::Result;
/// Current schema version. Bump on every forward migration added below.
pub(super) const SCHEMA_VERSION: i64 = 31;
impl Db {
pub(super) fn migrate(&self) -> Result<()> {
let mut conn = self.conn.lock();
// `sync_state` is the key/value table holding `schema_version` and the
// event cursor (later). Create it first so we can read the version.
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS sync_state (key TEXT PRIMARY KEY, value TEXT);",
)?;
let current: i64 = conn
.query_row(
"SELECT value FROM sync_state WHERE key = 'schema_version'",
[],
|row| row.get::<_, String>(0),
)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(0);
if current > SCHEMA_VERSION {
return Err(crate::Error::Other(format!(
"database schema {current} is newer than this build supports ({SCHEMA_VERSION}); refusing to open it to avoid corrupting user data"
)));
}
if current == SCHEMA_VERSION {
return Ok(());
}
let tx = conn.transaction()?;
if current < 1 {
tx.execute_batch(MIGRATION_V1)?;
}
if current < 2 {
tx.execute_batch(MIGRATION_V2)?;
}
if current < 3 {
tx.execute_batch(MIGRATION_V3)?;
}
if current < 4 {
tx.execute_batch(MIGRATION_V4)?;
}
if current < 5 {
tx.execute_batch(MIGRATION_V5)?;
}
if current < 6 {
tx.execute_batch(MIGRATION_V6)?;
}
if current < 7 {
tx.execute_batch(MIGRATION_V7)?;
}
if current < 8 {
tx.execute_batch(MIGRATION_V8)?;
}
if current < 9 {
tx.execute_batch(MIGRATION_V9)?;
}
if current < 10 {
tx.execute_batch(MIGRATION_V10)?;
}
if current < 11 {
tx.execute_batch(MIGRATION_V11)?;
}
if current < 12 {
tx.execute_batch(MIGRATION_V12)?;
}
if current < 13 {
tx.execute_batch(MIGRATION_V13)?;
}
if current < 14 {
tx.execute_batch(MIGRATION_V14)?;
}
if current < 15 {
tx.execute_batch(MIGRATION_V15)?;
}
if current < 16 {
tx.execute_batch(MIGRATION_V16)?;
}
if current < 17 {
tx.execute_batch(MIGRATION_V17)?;
}
if current < 18 {
tx.execute_batch(MIGRATION_V18)?;
}
if current < 19 {
tx.execute_batch(MIGRATION_V19)?;
}
if current < 20 {
tx.execute_batch(MIGRATION_V20)?;
}
if current < 21 {
// A V17-and-older fixture may carry only the tables its own test
// cares about; there is nothing to reindex without `nodes`.
let has_nodes: bool = tx.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'nodes'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
if has_nodes {
tx.execute_batch(MIGRATION_V21)?;
}
}
if current < 22 {
// Older fixtures (and a database that never grew a table because
// its feature was never used) may be missing any of these, and an
// index on an absent table is a hard error rather than a no-op.
for (table, sql) in MIGRATION_V22 {
let present: bool = tx.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1",
[table],
|row| row.get::<_, i64>(0),
)? > 0;
if present {
tx.execute_batch(sql)?;
}
}
// `activity_time` indexes `time DESC` while both activity queries
// order by `id`: it has only ever cost writes.
tx.execute_batch("DROP INDEX IF EXISTS activity_time;")?;
}
if current < 23 {
// One last full rebuild of the local index, because from here on the
// scan maintains it incrementally (`local_upsert_batch`) and so can
// no longer repair a `local_fts` that drifted from `local_files`.
// Whatever it costs, it costs once, at startup, before the mount
// serves anything.
let present: bool = tx.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'local_fts'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
if present {
tx.execute_batch("INSERT INTO local_fts(local_fts) VALUES('rebuild');")?;
}
}
if current < 24 {
tx.execute_batch(MIGRATION_V24)?;
}
if current < 25 {
// Same guard as V21: an old fixture may not have `nodes` at all,
// and there is no path to materialise without it.
let has_nodes: bool = tx.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'nodes'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
if has_nodes {
tx.execute_batch(MIGRATION_V25)?;
}
}
if current < 26 {
// Same guard as V22: a fixture that never exercised the queue may
// not have `pending_op`, and altering an absent table is an error
// rather than a no-op.
// A fixture built from the current schema and then rewound to an
// older `schema_version` already has the column, and `ADD COLUMN`
// has no `IF NOT EXISTS`.
let has_column: bool = tx.query_row(
"SELECT COUNT(*) FROM pragma_table_info('pending_op') WHERE name = 'claimed_at'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
let has_ops: bool = tx.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'pending_op'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
if has_ops && !has_column {
tx.execute_batch(MIGRATION_V26)?;
}
}
if current < 27 {
// Same guards as V26, for the same two reasons: the table may not
// exist in an old fixture, and a fixture rewound from the current
// schema already has the column.
let has_column: bool = tx.query_row(
"SELECT COUNT(*) FROM pragma_table_info('pending_op') \
WHERE name = 'access_deferred_since'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
let has_ops: bool = tx.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'pending_op'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
if has_ops && !has_column {
tx.execute_batch(MIGRATION_V27)?;
}
}
if current < 28 {
// Same guards as V26/V27, for the same two reasons.
let has_column: bool = tx.query_row(
"SELECT COUNT(*) FROM pragma_table_info('sync_entry') \
WHERE name = 'local_mtime_ns'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
let has_entries: bool = tx.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'sync_entry'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
if has_entries && !has_column {
tx.execute_batch(MIGRATION_V28)?;
}
}
if current < 29 {
// Same guards as V26/V27/V28: an old fixture may not have `trash`,
// and a fixture rewound from the current schema already has the
// column.
let has_column: bool = tx.query_row(
"SELECT COUNT(*) FROM pragma_table_info('trash') WHERE name = 'parent_uid'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
let has_trash: bool = tx.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'trash'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
if has_trash && !has_column {
tx.execute_batch(MIGRATION_V29)?;
}
}
if current < 30 {
// Same guards as V26-V29: the column may already be there on a
// fixture rewound from the current schema, and a very old fixture
// may not have `photos` at all.
let has_column: bool = tx.query_row(
"SELECT COUNT(*) FROM pragma_table_info('photos') WHERE name = 'group_key'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
let has_photos: bool = tx.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'photos'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
if has_photos && !has_column {
tx.execute_batch(MIGRATION_V30)?;
}
}
if current < 31 {
// Same guards as V26-V30.
let has_column: bool = tx.query_row(
"SELECT COUNT(*) FROM pragma_table_info('photos') WHERE name = 'resolved_at'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
let has_photos: bool = tx.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'photos'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
if has_photos && !has_column {
tx.execute_batch(MIGRATION_V31)?;
}
}
tx.execute(
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
[SCHEMA_VERSION.to_string()],
)?;
tx.commit()?;
Ok(())
}
}
/// Schema v1: nodes + FTS5 trigram index + cache LRU. `sync_state` is created
/// in [`Db::migrate`] before this runs.
const MIGRATION_V1: &str = "
CREATE TABLE nodes (
uid TEXT PRIMARY KEY,
parent_uid TEXT,
name TEXT NOT NULL,
is_dir INTEGER NOT NULL,
size INTEGER,
mtime INTEGER NOT NULL,
revision_hash TEXT,
trashed INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_nodes_parent ON nodes(parent_uid);
CREATE VIRTUAL TABLE nodes_fts USING fts5(
name, path, content='', tokenize='trigram'
);
CREATE TABLE cache_entries (
cache_key TEXT PRIMARY KEY,
size_bytes INTEGER,
last_accessed INTEGER,
is_pinned INTEGER NOT NULL DEFAULT 0
);
";
/// Schema v2: node write-through (P1). `node_json` stores the full [`Node`] so
/// the hot-cache maps rehydrate losslessly; `listed` records whether a folder's
/// child listing was complete when persisted.
const MIGRATION_V2: &str = "
ALTER TABLE nodes ADD COLUMN node_json TEXT;
ALTER TABLE nodes ADD COLUMN listed INTEGER NOT NULL DEFAULT 0;
";
/// Schema v3: FTS search (P3). The v1 `nodes_fts` was contentless (`content=''`)
/// and never populated — drop it and recreate as a self-contained trigram index
/// carrying the `uid` (UNINDEXED, so retrievable but not tokenized) alongside the
/// indexed `name`. Backfill from the nodes already persisted by P1.
const MIGRATION_V3: &str = "
DROP TABLE IF EXISTS nodes_fts;
CREATE VIRTUAL TABLE nodes_fts USING fts5(
uid UNINDEXED, name, tokenize='trigram'
);
INSERT INTO nodes_fts (uid, name)
SELECT uid, name FROM nodes WHERE trashed = 0;
";
/// Schema v4: content-cache LRU index (P4). The v1 `cache_entries` keyed only by
/// `cache_key`; add a `kind` discriminator so the whole-file blob pool and the
/// block pool — which carry separate byte budgets — can be summed and evicted
/// independently. Existing rows (none in practice, the table was never written)
/// default to `'blob'`. The daemon rebuilds the index from disk on open, so no
/// backfill is needed here.
const MIGRATION_V4: &str = "
ALTER TABLE cache_entries ADD COLUMN kind TEXT NOT NULL DEFAULT 'blob';
";
/// Schema v5: pins move out of `pins.json` into the DB (P5). One row per
/// directly-pinned node, keyed by uid display string so a pin survives
/// renames/moves. `recursive` marks a folder pin whose whole subtree is kept;
/// descendants are resolved on demand against `nodes` via a CTE rather than
/// being expanded into rows here. `ContentCache::open` imports any legacy
/// `pins.json` into this table once, then deletes the file.
const MIGRATION_V5: &str = "
CREATE TABLE pins (
uid TEXT PRIMARY KEY,
path TEXT NOT NULL,
recursive INTEGER NOT NULL DEFAULT 0
);
";
/// Schema v6: the index of *local* (non-Drive) files, so the launcher prompt can
/// search the machine alongside Drive. `local_files` is keyed by absolute path;
/// `scan_gen` stamps the scan that last saw a row, so a rescan prunes vanished
/// files with one `DELETE` instead of diffing.
///
/// `local_fts` is an *external-content* FTS5 index over `local_files.name`: the
/// text lives once in the base table and the index is rebuilt in bulk at the end
/// of a scan (`INSERT INTO local_fts(local_fts) VALUES('rebuild')`). That is far
/// cheaper than the delete-then-insert-per-row dance `nodes_fts` needs, because
/// a scan rewrites most rows at once rather than trickling single updates.
const MIGRATION_V6: &str = "
CREATE TABLE local_files (
id INTEGER PRIMARY KEY,
path TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
is_dir INTEGER NOT NULL,
size INTEGER NOT NULL DEFAULT 0,
mtime INTEGER NOT NULL DEFAULT 0,
scan_gen INTEGER NOT NULL DEFAULT 0
);
CREATE VIRTUAL TABLE local_fts USING fts5(
name, content='local_files', content_rowid='id', tokenize='trigram'
);
";
/// Schema v7: the photos timeline and the trash listing become persistent, so
/// opening the app paints them from disk instead of re-fetching the world (both
/// were memory-only: the timeline behind a 60 s TTL, the trash not cached at all).
///
/// `photos.seq` preserves the server's newest-first order, which is the only order
/// the timeline has — `capture_time` ties are common (a burst of shots) and would
/// otherwise shuffle between refreshes. `ratio` and `thumb_state` are *locally
/// learned*: they cost a download to rediscover, so `photos_replace` carries them
/// across a refresh while capture times and order come from the server.
const MIGRATION_V7: &str = "
CREATE TABLE photos (
uid TEXT PRIMARY KEY,
capture_time INTEGER NOT NULL,
name TEXT,
ratio REAL,
thumb_state INTEGER NOT NULL DEFAULT 0,
seq INTEGER NOT NULL
);
CREATE INDEX idx_photos_seq ON photos(seq);
CREATE TABLE trash (
uid TEXT PRIMARY KEY,
name TEXT NOT NULL,
is_dir INTEGER NOT NULL,
size INTEGER NOT NULL DEFAULT 0,
mtime INTEGER NOT NULL DEFAULT 0
);
";
/// Schema v8: device sync (devices.md). This machine registers as one Proton Drive
/// **Device** (a share + root folder on the main volume); `device` is a singleton
/// row cached so we reuse the same device across restarts instead of creating a
/// new one each run. `sync_folder` is one row per local folder the user added,
/// each mapped to a folder under the device root; `mode` is `mirror` (full local
/// copy, two-way synced) or `ondemand` (FUSE mount at `local_path`, no local
/// storage). `sync_entry` is the per-file baseline for three-way merge — added in
/// this migration so Phase 2 has the table, though Phase 1 leaves it empty.
const MIGRATION_V8: &str = "
CREATE TABLE device (
uid TEXT PRIMARY KEY,
share_id TEXT NOT NULL,
root_uid TEXT NOT NULL,
name TEXT NOT NULL,
created INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE sync_folder (
id INTEGER PRIMARY KEY,
local_path TEXT NOT NULL UNIQUE,
remote_uid TEXT NOT NULL,
remote_share_id TEXT NOT NULL,
mode TEXT NOT NULL DEFAULT 'mirror',
state TEXT NOT NULL DEFAULT 'idle',
last_sync INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE sync_entry (
folder_id INTEGER NOT NULL,
rel_path TEXT NOT NULL,
remote_uid TEXT,
local_mtime INTEGER NOT NULL DEFAULT 0,
local_size INTEGER NOT NULL DEFAULT 0,
remote_hash TEXT,
remote_rev TEXT,
PRIMARY KEY (folder_id, rel_path)
);
";
const MIGRATION_V9: &str = "
CREATE TABLE activity (
id INTEGER PRIMARY KEY,
time INTEGER NOT NULL,
kind TEXT NOT NULL,
target TEXT NOT NULL,
detail TEXT NOT NULL DEFAULT '',
ok INTEGER NOT NULL DEFAULT 1
);
CREATE INDEX activity_time ON activity(time DESC);
";
/// Schema v10: a mode switch the user asked for is queued rather than rejected
/// when the folder is mid-pass or has un-uploaded changes, so `pending_mode`
/// records the intent until the engine can act on it. NULL is the resting state:
/// the folder is already where the user wants it.
const MIGRATION_V10: &str = "
ALTER TABLE sync_folder ADD COLUMN pending_mode TEXT;
";
/// Schema v11: writes no longer upload inside the FUSE handler. `release` stages
/// the bytes and records the intended upload here, and a drain worker performs it
/// (offline.md Phase 3). The row outlives the process, so a write survives both a
/// dead network and a restart.
///
/// `blob_path` points into the content cache's `staging/` dir and `meta_json` is
/// the [`StagedWrite`] sidecar describing which of its bytes are real — an
/// incomplete blob must be gap-filled from the remote base before it can be
/// uploaded. `next_attempt_at` is a ms deadline implementing retry backoff.
///
/// [`StagedWrite`]: crate::cache::StagedWrite
const MIGRATION_V11: &str = "
CREATE TABLE pending_op (
id INTEGER PRIMARY KEY,
kind TEXT NOT NULL,
uid TEXT NOT NULL,
blob_path TEXT,
meta_json TEXT,
created_at INTEGER NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
next_attempt_at INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX pending_op_uid ON pending_op(uid);
";
/// Schema v12: `pending_op` also carries mutations that *create* a node, which a
/// revision op never had to describe — it always addressed a node the server had
/// already minted a uid for (offline.md Phase 3b).
///
/// An offline `create`/`mkdir` cannot get a real uid, so the node is invented
/// locally under a `local~<uuid>` placeholder and the op records where it goes
/// (`parent_uid`) and what it is called (`name`). Both are nullable because a
/// revision op sets neither.
///
/// `parent_uid` is indexed: draining a folder rewrites every queued child that
/// points at its placeholder.
const MIGRATION_V12: &str = "
ALTER TABLE pending_op ADD COLUMN parent_uid TEXT;
ALTER TABLE pending_op ADD COLUMN name TEXT;
CREATE INDEX pending_op_parent ON pending_op(parent_uid);
";
/// Schema v13: photos record their media type and a derived `kind` so the
/// timeline can be split into Photos / Videos / Raw tabs. `media_type` is
/// nullable — it is filled once the daemon resolves a photo's node — while
/// `kind` (see [`crate::control::PhotoKind`]) defaults to `0` (still photo), the
/// classification anything unresolved falls back to. Existing rows are
/// reclassified for free on the next timeline refresh, so no backfill is needed.
const MIGRATION_V13: &str = "
ALTER TABLE photos ADD COLUMN media_type TEXT;
ALTER TABLE photos ADD COLUMN kind INTEGER NOT NULL DEFAULT 0;
";
/// Schema v14: index the content-cache LRU. `cache_entries` carried only its
/// `cache_key` primary key, so every budget check and every eviction pass was a
/// full scan plus a sort — on a path that runs once per cached 4 MiB block,
/// under the connection lock every FUSE metadata call also needs. `(kind,
/// last_accessed)` turns both into a range scan: the `SUM` reads one kind's
/// slice, and eviction reads its front and stops.
const MIGRATION_V14: &str = "
CREATE INDEX cache_entries_lru ON cache_entries(kind, last_accessed);
";
/// Schema v15: key the search index by rowid so a row can be replaced without
/// scanning the whole index (B12).
///
/// v3 built `nodes_fts` with the node's `uid` as an `UNINDEXED` column and
/// `upsert_nodes` refreshed a row with `DELETE FROM nodes_fts WHERE uid = ?`.
/// `UNINDEXED` means exactly what it says — the column is retrievable but not
/// searchable — so that predicate could only ever be a full scan of the index
/// (`EXPLAIN QUERY PLAN`: `SCAN nodes_fts VIRTUAL TABLE`). Every node written
/// scanned every node indexed: measured at 6.5 ms per node against a 17k-node
/// index, about half the cost of a cold listing, and *growing with the size of
/// the account* rather than the size of the listing.
///
/// FTS5 deletes by `rowid` efficiently, and `nodes` is an ordinary rowid table
/// (`uid` is its `TEXT PRIMARY KEY`, not `WITHOUT ROWID`), so its rowid is a
/// stable per-node key that `ON CONFLICT DO UPDATE` preserves. Rebuild the index
/// keyed by it and drop the `uid` column: searches now join on `rowid`, which is
/// also a cheaper join than the old one on a TEXT uid.
const MIGRATION_V15: &str = "
DROP TABLE IF EXISTS nodes_fts;
CREATE VIRTUAL TABLE nodes_fts USING fts5(
name, tokenize='trigram'
);
INSERT INTO nodes_fts (rowid, name)
SELECT rowid, name FROM nodes WHERE trashed = 0;
";
/// Schema v16: fuzzy search needs a bounded candidate set that includes parent
/// paths. Keeping the path in the trigram indexes lets SQLite narrow both name
/// and path candidates without walking either base table for every keystroke.
/// Node paths are initially backfilled below and subsequently maintained by the
/// node write path (including descendants after a folder move/rename).
const MIGRATION_V16: &str = "
DROP TABLE IF EXISTS nodes_fts;
CREATE VIRTUAL TABLE nodes_fts USING fts5(
name, path, tokenize='trigram'
);
CREATE INDEX IF NOT EXISTS idx_nodes_name_nocase ON nodes(name COLLATE NOCASE);
INSERT INTO nodes_fts (rowid, name, path)
WITH RECURSIVE paths(rowid, uid, path) AS (
SELECT n.rowid, n.uid, '' FROM nodes n
WHERE n.parent_uid IS NULL
OR NOT EXISTS (SELECT 1 FROM nodes p WHERE p.uid = n.parent_uid)
UNION ALL
SELECT n.rowid, n.uid,
CASE WHEN paths.path = '' THEN n.name ELSE paths.path || '/' || n.name END
FROM nodes n JOIN paths ON n.parent_uid = paths.uid
)
SELECT n.rowid, n.name, paths.path
FROM nodes n JOIN paths ON paths.rowid = n.rowid
WHERE n.trashed = 0;
DROP TABLE IF EXISTS local_fts;
CREATE VIRTUAL TABLE local_fts USING fts5(
name, path, content='local_files', content_rowid='id', tokenize='trigram'
);
CREATE INDEX IF NOT EXISTS idx_local_files_name_nocase
ON local_files(name COLLATE NOCASE);
INSERT INTO local_fts(local_fts) VALUES('rebuild');
";
/// Schema v17: effective access for roots shared with this account. The role is
/// persisted separately from `nodes.node_json` so a cold/offline mount can
/// enforce the last known permission even when the SDK cannot refresh metadata.
const MIGRATION_V17: &str = "
CREATE TABLE share_access (
root_uid TEXT PRIMARY KEY,
access TEXT NOT NULL
CHECK (access IN ('owner', 'editor', 'viewer', 'unknown'))
);
";
/// Schema v18: unified location presentation. Device rows carry only their
/// `sync_folder` identity; reads join all device state from that authoritative
/// table. The trigger projects future folders, while the foreign key removes a
/// presentation row with its sync folder.
const MIGRATION_V18: &str = "
CREATE TABLE mount (
id INTEGER PRIMARY KEY,
kind TEXT NOT NULL
CHECK (kind IN ('myfiles', 'device', 'shared')),
sync_folder_id INTEGER REFERENCES sync_folder(id) ON DELETE CASCADE,
share_root_uid TEXT,
local_path TEXT NOT NULL DEFAULT '',
root_uid TEXT NOT NULL DEFAULT '',
root_share_id TEXT NOT NULL DEFAULT '',
mode TEXT NOT NULL DEFAULT 'unknown',
access TEXT NOT NULL DEFAULT 'rw'
CHECK (access IN ('rw', 'ro')),
CHECK (
(kind = 'myfiles' AND sync_folder_id IS NULL AND share_root_uid IS NULL)
OR
(kind = 'device' AND sync_folder_id IS NOT NULL AND share_root_uid IS NULL)
OR
(kind = 'shared' AND sync_folder_id IS NULL AND share_root_uid IS NOT NULL)
)
);
CREATE UNIQUE INDEX mount_myfiles
ON mount(kind) WHERE kind = 'myfiles';
CREATE UNIQUE INDEX mount_device
ON mount(sync_folder_id) WHERE kind = 'device';
CREATE UNIQUE INDEX mount_shared
ON mount(share_root_uid) WHERE kind = 'shared';
INSERT INTO mount (kind, sync_folder_id)
SELECT 'device', id FROM sync_folder;
CREATE TRIGGER mount_sync_folder_insert
AFTER INSERT ON sync_folder
BEGIN
INSERT OR IGNORE INTO mount (kind, sync_folder_id)
VALUES ('device', NEW.id);
END;
";
/// Schema v19: photo albums and their contents. Both are projections of the
/// server's listings — like `photos`, they are replaced wholesale on refresh —
/// so the Albums view opens from disk instead of re-enumerating every launch.
///
/// `album_photos` carries its own `ratio` / `thumb_state` rather than joining
/// `photos`: an album shared with us lives on the sharer's volume, so its
/// photos are not in our timeline at all and would otherwise have nowhere to
/// remember what a thumbnail attempt learned.
const MIGRATION_V19: &str = "
CREATE TABLE albums (
uid TEXT PRIMARY KEY,
name TEXT NOT NULL,
photo_count INTEGER NOT NULL DEFAULT 0,
cover_uid TEXT,
last_activity INTEGER,
shared INTEGER NOT NULL DEFAULT 0,
seq INTEGER NOT NULL
);
CREATE TABLE album_photos (
album_uid TEXT NOT NULL,
uid TEXT NOT NULL,
capture_time INTEGER NOT NULL,
name TEXT,
media_type TEXT,
kind INTEGER NOT NULL DEFAULT 0,
ratio REAL,
thumb_state INTEGER NOT NULL DEFAULT 0,
seq INTEGER NOT NULL,
PRIMARY KEY (album_uid, uid)
);
CREATE INDEX idx_album_photos_seq ON album_photos(album_uid, seq);
CREATE INDEX idx_album_photos_uid ON album_photos(uid);
";
/// V20: whether a photo is a favourite.
///
/// A column rather than a tag table: `Favorite` is the only Proton photo tag a
/// user sets by hand (the rest — Video, Screenshot, Selfie, Raw… — are the
/// server's own classification, and the Photos page already derives its tabs
/// from the media type). Defaulting to 0 means an existing timeline stays
/// intact and each row learns its real value on the next refresh, the same way
/// `media_type` did.
const MIGRATION_V20: &str = "
ALTER TABLE photos ADD COLUMN favorite INTEGER NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_photos_favorite ON photos(favorite, seq);
";
/// V21: rebuild the node search index after fixing what counts as indexable.
///
/// The v16 backfill rooted its path walk at any node whose parent is null *or*
/// not cached, but the write path's `node_is_indexable_tx` demanded a
/// null-parent ancestor. Only the My Files root has one — a device folder's
/// root is stored in `device`/`sync_folder`, never in `nodes` — so every node
/// under a device folder was dropped from `nodes_fts` the first time it was
/// upserted after the backfill, and was unfindable from the prompt (2,705 of
/// 9,252 nodes on the account this was diagnosed against).
///
/// The rule now matches the backfill, so this replays it: same recursive walk
/// as v16, over the current `nodes`.
/// Two details the v16 backfill did not carry, both cheap here: a node under a
/// trashed folder stays out (the write path has always excluded it, and
/// `tombstone_subtree_tx` only marks a subtree it was told about), and the walk
/// is depth-capped so a parent cycle in a corrupted database cannot spin the
/// migration forever.
const MIGRATION_V21: &str = "
DROP TABLE IF EXISTS nodes_fts;
CREATE VIRTUAL TABLE nodes_fts USING fts5(
name, path, tokenize='trigram'
);
INSERT INTO nodes_fts (rowid, name, path)
WITH RECURSIVE paths(rowid, uid, path, trashed, depth) AS (
-- Only a true root (no parent at all) contributes nothing to the path: its
-- name is the mount. A row whose parent is merely uncached is an ordinary
-- folder and keeps its name, which is what `path_of` does at runtime.
SELECT n.rowid, n.uid,
CASE WHEN n.parent_uid IS NULL THEN '' ELSE n.name END, n.trashed, 0
FROM nodes n
WHERE n.parent_uid IS NULL
OR NOT EXISTS (SELECT 1 FROM nodes p WHERE p.uid = n.parent_uid)
UNION ALL
SELECT n.rowid, n.uid,
CASE WHEN paths.path = '' THEN n.name ELSE paths.path || '/' || n.name END,
MAX(paths.trashed, n.trashed), paths.depth + 1
FROM nodes n JOIN paths ON n.parent_uid = paths.uid
WHERE paths.depth < 256
)
SELECT n.rowid, n.name, paths.path
FROM nodes n JOIN paths ON paths.rowid = n.rowid
WHERE paths.trashed = 0;
";
/// V22: indexes for the lookups that were scanning, each paired with the table
/// it needs to exist for.
///
/// Each backs a query on a hot path that had no index to use:
///
/// - `sync_entry(remote_uid)` — the reconcile resolves a remote uid to its
/// local entry once per node, per pass.
/// - `local_files(scan_gen)` — the sweep that retires the previous generation
/// after a local index scan.
/// - `nodes(parent_uid) WHERE trashed = 0` — every listing, with the partial
/// index keeping it to the live rows a listing actually wants.
///
/// Deliberately *not* here: an index on `pending_op(next_attempt_at, id)`.
/// `next_due_op` orders by `id`, which is the rowid, so it already stops at the
/// first due row; offering the planner a `next_attempt_at` index makes it scan
/// that index and then sort the matches by `id` instead —
/// `next_due_op_does_not_scale_with_queue_length` measures the regression.
const MIGRATION_V22: [(&str, &str); 3] = [
(
"sync_entry",
"CREATE INDEX IF NOT EXISTS idx_sync_entry_remote_uid ON sync_entry(remote_uid);",
),
(
"local_files",
"CREATE INDEX IF NOT EXISTS idx_local_files_scan_gen ON local_files(scan_gen);",
),
(
"nodes",
"CREATE INDEX IF NOT EXISTS idx_nodes_parent_live ON nodes(parent_uid) WHERE trashed = 0;",
),
];
/// V24: the revisions this daemon sealed itself, so restarting does not reopen
/// the conflict hole B70 layer B closed.
///
/// `Core::own_sealed_revs` held these in memory: a queued write whose baseline
/// names an earlier revision is a conflict *unless* the remote sits at a
/// revision we sealed, in which case it is one writer stalling and resuming and
/// must supersede rather than fork a `(sync-conflict)` copy. Losing the map at
/// shutdown meant the first drain after a restart could not tell the two apart.
///
/// `sealed_at` is what bounds the table: entries older than the window in which
/// a stall→resume is plausible are pruned on write.
const MIGRATION_V24: &str = "
CREATE TABLE IF NOT EXISTS own_sealed_rev (
uid TEXT PRIMARY KEY,
revision_id TEXT NOT NULL,
sealed_at INTEGER NOT NULL
);
";
/// V25: `nodes.path` — a node's mountpoint-relative path, stored rather than
/// walked.
///
/// `path_of` was a recursive CTE per node, and search ran it once per candidate
/// (up to a thousand per keystroke) on top of the one the write path already ran
/// per descendant of every folder upsert. The value only changes when a node's
/// name or parent changes, which is exactly when the write path is already
/// walking the subtree — so it belongs in a column.
///
/// The backfill is the same walk V21 used for `nodes_fts`, minus the trashed
/// filter: a trashed node stays out of the index but still has a path. Roots get
/// `''` (their name is the mount), and a node whose parent is merely uncached
/// keeps its own name — both matching what `path_of` returned at runtime.
///
/// Materialised into a temp table rather than written by a correlated subquery,
/// so the recursive walk runs once instead of once per row.
const MIGRATION_V25: &str = "
ALTER TABLE nodes ADD COLUMN path TEXT;
CREATE TEMP TABLE node_paths (uid TEXT PRIMARY KEY, path TEXT NOT NULL);
INSERT INTO node_paths (uid, path)
WITH RECURSIVE paths(uid, path, depth) AS (
SELECT n.uid, CASE WHEN n.parent_uid IS NULL THEN '' ELSE n.name END, 0
FROM nodes n
WHERE n.parent_uid IS NULL
OR NOT EXISTS (SELECT 1 FROM nodes p WHERE p.uid = n.parent_uid)
UNION ALL
SELECT n.uid,
CASE WHEN paths.path = '' THEN n.name ELSE paths.path || '/' || n.name END,
paths.depth + 1
FROM nodes n JOIN paths ON n.parent_uid = paths.uid
WHERE paths.depth < 256
)
SELECT uid, path FROM paths;
UPDATE nodes
SET path = (SELECT p.path FROM node_paths p WHERE p.uid = nodes.uid)
WHERE EXISTS (SELECT 1 FROM node_paths p WHERE p.uid = nodes.uid);
DROP TABLE node_paths;
";
/// V26: `pending_op.claimed_at` — the drain's claim column.
///
/// The drain was one thread taking one op at a time, so a 10 GiB upload held
/// every queued rename and small write behind it for as long as it ran. Several
/// workers can share the queue only if a claim is visible to the others: this
/// column is what makes "already being drained by someone" a fact about the row
/// rather than about one thread's local variable.
///
/// `0` is unclaimed; anything else is the ms at which a worker took it. A
/// claim is process-local state that outlives nothing — the single-writer lock
/// means a non-zero value found at open belongs to a crashed run, and
/// [`Db::clear_op_claims`](super::Db::clear_op_claims) drops the lot.
///
/// The index is partial so it costs nothing on the ordinary row: only the
/// handful of claimed rows are in it, which is exactly the set the claim query
/// has to subtract to keep two workers off one node.
const MIGRATION_V26: &str = "
ALTER TABLE pending_op ADD COLUMN claimed_at INTEGER NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_pending_op_claimed
ON pending_op(uid) WHERE claimed_at <> 0;
";
/// Schema v27: when an op was first deferred by the local access check.
///
/// An access deferral consumes no attempt and records no error — the op has not
/// failed remotely, it has not been sent at all. That is right for a permission
/// change the user is about to undo, and wrong for a condition that never
/// clears: the row is re-deferred every
/// [`DRAIN_ACCESS_RECHECK`](../../../pdfs_fuse/drain/constant.DRAIN_ACCESS_RECHECK.html)
/// forever while `attempts`, `last_error`, `failing` and the logs all stay
/// empty, so a queue that can never drain is indistinguishable from a busy one.
/// Found in the field as 28 revision ops holding 18 GiB of accepted writes for
/// 30 days, none of them visible anywhere.
///
/// `0` means "not currently access-deferred"; anything else is the ms at which
/// the first consecutive deferral happened, which is what
/// [`Db::defer_op_for_access`](super::Db::defer_op_for_access) measures the
/// deferral against.
const MIGRATION_V27: &str = "
ALTER TABLE pending_op ADD COLUMN access_deferred_since INTEGER NOT NULL DEFAULT 0;
";
/// Schema v28: sub-second precision for a mirror folder's local baseline.
///
/// A reconcile pass decides whether the local side of a path changed by
/// comparing `(local_mtime, local_size)` against the baseline. Both sides of
/// that comparison were whole seconds, so an edit that landed in the same second
/// as the last sync and left the file the same length — a fixed-size record
/// rewritten, an image re-exported, a database page updated in place — read as
/// *unchanged* and was never uploaded. The remote copy silently stayed at the
/// older content until something else about the file moved (bugs.md B25).
///
/// Nullable rather than a converted `local_mtime * 1_000_000_000`: the
/// nanosecond part of an existing baseline is genuinely unknown, and inventing
/// zero for it would make every already-synced file compare as changed and
/// re-upload an entire mirror on first start. `NULL` means "this row predates
/// the column", which the comparison reads as "fall back to whole seconds" —
/// exactly the behaviour that row was written under. Each row gains a real value
/// the next time its path is synced.
const MIGRATION_V28: &str = "
ALTER TABLE sync_entry ADD COLUMN local_mtime_ns INTEGER;
";
/// Schema v29: the trash listing remembers each trashed node's parent, so a
/// restore can reason about the shape of what it is putting back.
///
/// Trash is a flat list of uids on the wire, but the user deletes a *tree*: a
/// folder and, often, items inside it that were trashed separately. Restoring
/// one of those in isolation is what the parent link fixes — a child restored
/// under a still-trashed parent lands somewhere invisible, and a folder restored
/// without its separately-trashed contents comes back empty.
///
/// `NULL` means "this row predates the column" (or the node is a volume root);
/// the next trash refresh fills it in, and until then the restore behaves as it
/// did before.
const MIGRATION_V29: &str = "
ALTER TABLE trash ADD COLUMN parent_uid TEXT;
CREATE INDEX IF NOT EXISTS idx_trash_parent ON trash(parent_uid);
";
/// Schema v30: a RAW and the JPEG of the same shot are one photo to the person
/// who took it, so the timeline records what ties them together.
///
/// `content_hash` and `main_uid` come from the server's own photo properties —
/// the duplicate-detection hash, and the main photo a related one belongs to.
/// `group_key` is what the gallery pages by: every member of one shot carries
/// the same key, and the member the grid shows is the one whose `main_uid` is
/// its own uid.
///
/// All three are `NULL` until the next timeline refresh resolves the photo's
/// node, and a row without a `group_key` is its own group — which is exactly how
/// the gallery behaved before this column existed.
const MIGRATION_V30: &str = "
ALTER TABLE photos ADD COLUMN content_hash TEXT;
ALTER TABLE photos ADD COLUMN main_uid TEXT;
ALTER TABLE photos ADD COLUMN group_key TEXT;
CREATE INDEX IF NOT EXISTS idx_photos_group ON photos(group_key);
";
/// Schema v31: which photos a refresh still has to read from the server.
///
/// A refresh used to resolve every photo's node to learn its name, media type,
/// favourite tag, content hash and photo relation — minutes of round-trips for a
/// library of any size, repeated every time the timeline went stale. `resolved_at`
/// records that the work was already done, so a refresh only reads the photos it
/// has never read and the ones a remote event marked stale (`resolved_at = NULL`).
///
/// The backfill treats an existing timeline as resolved, so an upgrade does not
/// re-read the whole library once. `WHERE name IS NOT NULL` keeps the photos
/// whose earlier resolve had failed eligible: a nameless row is one nothing was
/// ever learned about.
const MIGRATION_V31: &str = "
ALTER TABLE photos ADD COLUMN resolved_at INTEGER;
CREATE INDEX IF NOT EXISTS idx_photos_unresolved ON photos(resolved_at);
UPDATE photos SET resolved_at = strftime('%s','now') WHERE name IS NOT NULL;
";
+463
View File
@@ -0,0 +1,463 @@
//! Unified SQLite metadata cache — the single persistence layer behind FUSE
//! inode bookkeeping, full-text search, content-cache LRU tracking, and pins.
//!
//! Only the daemon (`pdfs-fuse`) opens this for writes; the GUI and CLI reach
//! the same data through the control socket. There is one write connection
//! behind a `Mutex` — SQLite allows exactly one writer, and the single-instance
//! `flock` makes sure it is ours — and a small pool of read-only connections
//! behind [`Db::read`], which every `SELECT`-only method uses.
//!
//! The pool is what makes WAL worth having. The original justification for a
//! single connection was that "FUSE callbacks already serialize behind the
//! `State` lock"; that stopped being true when reads moved onto an 11-thread
//! worker pool, and a lookup then queued behind whatever listing happened to be
//! committing. Under WAL a reader never blocks the writer and vice versa, so the
//! only thing left serialising them was this process's own mutex.
//!
//! This module is the P0 foundation: it opens the database, enables WAL, and
//! applies the forward-only schema migrations. Write-through of nodes, the
//! event cursor, FTS, and the cache index land in later phases on this schema.
//!
//! The surface is one `Db` type; its methods live in a submodule per table group
//! (`nodes`, `photos`, `pins`, …) as separate `impl Db` blocks. Everything those
//! modules define is re-exported here, so callers keep saying `db::StoredPhoto`
//! and never name a submodule.
use parking_lot::Mutex;
use rusqlite::Connection;
use crate::{Error, Result};
use std::fs::File;
use std::os::fd::AsRawFd;
use std::path::{Path, PathBuf};
mod activity;
mod albums;
mod cache;
mod devices;
mod local;
mod maintenance;
mod migrations;
mod mounts;
mod nodes;
mod ops;
mod photos;
mod pins;
mod share_access;
mod state;
mod sync;
mod trash;
mod utils;
pub use albums::StoredAlbum;
pub use cache::CacheEntryInput;
pub use devices::StoredDevice;
pub use local::LocalFileHit;
pub use maintenance::{DbStats, VacuumOutcome};
pub use nodes::{PublishedSharedRoot, SearchHit, StoredNode};
pub use ops::{
AttachedBlob, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_UNTIL,
PendingCounts, PendingOp, RenameMeta, op_supersedes,
};
pub use photos::{StoredPhoto, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN, TimelineRow};
pub use pins::PinRow;
pub use sync::{StoredSyncEntry, StoredSyncFolder};
pub use trash::StoredTrash;
/// Size the WAL is truncated back to after a checkpoint. Comfortably above the
/// steady-state working set (a few MB), so the truncation only claws back the
/// outliers rather than fighting the normal write path for disk.
const WAL_SIZE_LIMIT: i64 = 64 * 1024 * 1024;
/// How long a statement waits for a database lock before giving up.
///
/// Long enough to outlast any transaction this daemon writes (the largest, a
/// full-index FTS rebuild, is well under a second), short enough that a
/// genuinely stuck holder surfaces as an error rather than an apparent hang.
const BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
/// Page cache, in KiB (applied negated, which is how SQLite reads a size in KiB
/// rather than in pages). 32 MiB against a database that is typically tens of
/// MiB: large enough to hold the working set of a listing-heavy session, small
/// enough to be unremarkable in a desktop daemon's RSS.
const CACHE_SIZE_KIB: i64 = 32 * 1024;
/// Ceiling on the memory-mapped window over the database file. 256 MiB covers
/// any realistic index while keeping the mapping bounded.
const MMAP_SIZE: i64 = 256 * 1024 * 1024;
/// WAL pages between automatic checkpoints (default 1000, i.e. ~4 MiB). Halved
/// so the synchronous checkpoint a committing thread inherits stays short.
const WAL_AUTOCHECKPOINT_PAGES: i64 = 500;
/// Handle to the unified metadata database.
///
/// Cheap to wrap in an `Arc`; clone the `Arc`, not this. All access goes through
/// the inner `Mutex<Connection>`.
pub struct Db {
conn: Mutex<Connection>,
/// Read-only connections for `SELECT`-only methods. `None` for an in-memory
/// database, where a second connection would open a different, empty one —
/// those fall back to the write connection.
readers: Option<ReadPool>,
/// The single-writer lock, held open for as long as this handle lives.
///
/// Never read; dropping the `File` is what releases the `flock`, so it has
/// to be owned here rather than by `open`'s stack frame. `None` for
/// in-memory databases, which nothing else can reach.
_single_writer: Option<File>,
}
/// Idle read-only connections, opened on demand and kept for reuse.
///
/// Not a bounded pool: the number in flight is bounded by the number of threads
/// that can be inside a read at once (the FUSE worker lanes plus the control
/// handlers), and blocking one of those on a permit would reintroduce exactly
/// the queueing this exists to remove. Only the *idle* set is capped, so a burst
/// does not leave a connection per thread parked forever.
struct ReadPool {
path: PathBuf,
idle: Mutex<Vec<Connection>>,
}
/// How many idle read connections to keep. Enough that the steady state never
/// reopens, small enough to be unremarkable.
const MAX_IDLE_READERS: usize = 4;
impl ReadPool {
fn take(&self) -> Option<Connection> {
if let Some(conn) = self.idle.lock().pop() {
return Some(conn);
}
match open_read_only(&self.path) {
Ok(conn) => Some(conn),
Err(error) => {
// Falling back to the write connection is correct, just slower,
// so this is a warning and not a failed read.
tracing::warn!(%error, "could not open a read-only connection");
None
}
}
}
fn put(&self, conn: Connection) {
let mut idle = self.idle.lock();
if idle.len() < MAX_IDLE_READERS {
idle.push(conn);
}
}
}
/// A connection to read through: one borrowed from [`ReadPool`], or the write
/// connection when there is no pool (in-memory) or opening one failed.
///
/// Derefs to `Connection`, so a method converts by swapping `self.conn.lock()`
/// for `self.read()` and nothing else.
pub(super) struct Reader<'a> {
pooled: Option<Connection>,
writer: Option<parking_lot::MutexGuard<'a, Connection>>,
pool: Option<&'a ReadPool>,
}
impl std::ops::Deref for Reader<'_> {
type Target = Connection;
fn deref(&self) -> &Connection {
self.pooled
.as_ref()
.or(self.writer.as_deref())
.expect("a Reader always holds one of the two")
}
}
impl Drop for Reader<'_> {
fn drop(&mut self) {
if let (Some(conn), Some(pool)) = (self.pooled.take(), self.pool) {
pool.put(conn);
}
}
}
/// Open a second connection to the same file for reads only.
///
/// `SQLITE_OPEN_READ_ONLY` plus `query_only` is belt and braces: the flag stops
/// a routing mistake at the file, the pragma stops it at the statement. The rest
/// mirror [`Db::open_configured`] — a read connection has its own page cache,
/// its own temp store and its own busy timeout, and gets nothing from the
/// writer's.
///
/// `journal_mode` is deliberately absent: it is a property of the database file,
/// which the writer already set to WAL, and a read-only connection cannot change
/// it anyway.
fn open_read_only(path: &Path) -> Result<Connection> {
use rusqlite::OpenFlags;
let conn = Connection::open_with_flags(
path,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
)?;
conn.busy_timeout(BUSY_TIMEOUT)?;
conn.pragma_update(None, "cache_size", -CACHE_SIZE_KIB)?;
conn.pragma_update(None, "temp_store", "MEMORY")?;
conn.pragma_update(None, "mmap_size", MMAP_SIZE)?;
conn.pragma_update(None, "query_only", true)?;
Ok(conn)
}
impl Db {
/// Open (creating if absent) the database at `path`, enable WAL, and bring
/// the schema up to [`SCHEMA_VERSION`].
///
/// Takes the single-instance lock first (see
/// [`acquire_single_writer_lock`]), and rebuilds from empty if what is on
/// disk turns out not to be a readable database (see [`is_corrupt`]).
pub fn open(path: &Path) -> Result<Self> {
let single_writer = acquire_single_writer_lock(path)?;
let conn = match Self::open_configured(path) {
Ok(conn) => conn,
Err(e) if is_corrupt(&e) => {
// Every byte in here is derived from the account or from the
// content cache directory, so the recovery is to start over —
// loudly, and keeping the damaged file for a post-mortem rather
// than deleting evidence.
tracing::error!(
db = %path.display(),
error = %e,
"cache database is corrupt — moving it aside and rebuilding \
(metadata will be re-fetched; no user data is stored here)"
);
quarantine_corrupt_db(path)?;
Self::open_configured(path)?
}
Err(e) => return Err(e),
};
let db = Self {
conn: Mutex::new(conn),
// No pool for `:memory:`. A second connection to that name is a
// second, empty database rather than another view of this one, so
// every routed read would find a schema that does not exist.
readers: (!is_in_memory(path)).then(|| ReadPool {
path: path.to_path_buf(),
idle: Mutex::new(Vec::new()),
}),
_single_writer: single_writer,
};
// A drain claim names a worker thread in the process that took it, and
// the single-writer lock above says that process is not running. Any
// claim still on disk is therefore a crashed run's, and leaving it would
// hide those ops from every worker for good.
match db.clear_op_claims() {
Ok(0) => {}
Ok(n) => tracing::info!(ops = n, "released drain claims left by a previous run"),
Err(e) => tracing::warn!(error = %e, "clearing stale drain claims failed"),
}
Ok(db)
}
/// A connection for a `SELECT`-only method.
///
/// Reads see the last committed state, which is the same thing they saw
/// through the write connection: every method here commits before it
/// returns, and nothing holds a transaction open across calls.
pub(super) fn read(&self) -> Reader<'_> {
if let Some(pool) = &self.readers
&& let Some(conn) = pool.take()
{
return Reader {
pooled: Some(conn),
writer: None,
pool: Some(pool),
};
}
Reader {
pooled: None,
writer: Some(self.conn.lock()),
pool: None,
}
}
/// Open the file, apply every PRAGMA, and migrate. Split out of
/// [`open`](Self::open) so the corruption path can retry it verbatim.
fn open_configured(path: &Path) -> Result<Connection> {
let conn = Connection::open(path)?;
// WAL: readers never block the single writer. NORMAL sync is the
// standard durability/throughput tradeoff for WAL.
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "synchronous", "NORMAL")?;
conn.pragma_update(None, "foreign_keys", "ON")?;
// Hand the WAL's disk back after a checkpoint. Without a limit SQLite
// reuses the file in place but never shrinks it, so a single large
// transaction (a full-index FTS rebuild in `local_finish_scan` is the
// one that reaches this size) leaves the WAL at its high-water mark
// forever — a multi-GB file next to a database two orders of magnitude
// smaller. Checkpointing is unaffected; only the file is truncated back.
conn.pragma_update(None, "journal_size_limit", WAL_SIZE_LIMIT)?;
// Wait for a lock rather than failing instantly on SQLITE_BUSY.
//
// This is deliberately explicit and deliberately redundant: rusqlite
// already applies a five-second busy timeout of its own, so this changes
// nothing today. It is here so the value is *ours* — a behaviour the
// daemon depends on should not be an inherited default that a dependency
// bump can silently change. `open_bounds_the_wal_size` pins it.
//
// (Bare SQLite does default to 0, which is where the belief that this
// was unset came from. That default is not what we get.)
conn.busy_timeout(BUSY_TIMEOUT)?;
// Page cache. The default is 2 MiB, against a schema whose hot table
// stores a JSON blob per node — a listing of a few thousand files
// evicts itself while it is being read.
conn.pragma_update(None, "cache_size", -CACHE_SIZE_KIB)?;
// Sorts and recursive CTEs materialise their intermediate results.
// Every `ORDER BY … COLLATE NOCASE` listing and every ancestor walk
// spills those to a file in `/tmp` by default; they are small and
// short-lived, so memory is both faster and less surprising.
conn.pragma_update(None, "temp_store", "MEMORY")?;
// Read the database through the page cache of the OS rather than
// `pread` per page. Bounded rather than unlimited so the daemon's RSS
// does not track the database size.
conn.pragma_update(None, "mmap_size", MMAP_SIZE)?;
// Checkpointing is synchronous in whichever thread happens to commit
// the transaction that crosses the threshold — often a FUSE callback.
// A smaller autocheckpoint makes that debt smaller and more frequent
// rather than rare and multi-second.
conn.pragma_update(None, "wal_autocheckpoint", WAL_AUTOCHECKPOINT_PAGES)?;
let db = Self {
conn: Mutex::new(conn),
readers: None,
_single_writer: None,
};
db.migrate()?;
Ok(db.into_conn())
}
/// Unwrap the connection back out of a temporary handle. Only
/// [`open_configured`](Self::open_configured) uses it, to run `migrate`
/// (which is written against `&self`) before the real handle exists.
fn into_conn(self) -> Connection {
self.conn.into_inner()
}
/// Open an in-memory database. For tests.
#[cfg(test)]
pub fn open_in_memory() -> Result<Self> {
let conn = Connection::open_in_memory()?;
// Keep relational behavior identical to `open`: V18 relies on the
// `mount.sync_folder_id` cascade, and SQLite disables foreign keys per
// connection unless explicitly enabled.
conn.pragma_update(None, "foreign_keys", "ON")?;
let db = Self {
conn: Mutex::new(conn),
readers: None,
_single_writer: None,
};
db.migrate()?;
Ok(db)
}
/// Run a closure with the locked connection. Escape hatch for callers that
/// need a query no typed method covers yet.
pub fn with_conn<T>(&self, f: impl FnOnce(&Connection) -> Result<T>) -> Result<T> {
let conn = self.conn.lock();
f(&conn)
}
}
/// Whether `path` names the in-memory database rather than a file. Both
/// spellings SQLite accepts are checked, because neither has a lock file or a
/// corruption story.
fn is_in_memory(path: &Path) -> bool {
let p = path.as_os_str();
p == ":memory:" || p.is_empty()
}
/// Take the single-instance write lock for the database at `path`.
///
/// The daemon is the only writer by design — the front-ends go through the
/// control socket — but nothing stopped a hand-run `pdfs mount` from starting
/// alongside the systemd unit, at which point two processes own the same inode
/// space, the same content cache, and the same drain queue. SQLite's own
/// locking makes each *statement* safe and does nothing about that.
///
/// `flock` on a sibling `.lock` file rather than on the database itself:
/// SQLite uses POSIX record locks, so the two never interact, and a lock file
/// left behind carries no state — the lock lives in the kernel and dies with
/// the process, however it exits.
fn acquire_single_writer_lock(path: &Path) -> Result<Option<File>> {
if is_in_memory(path) {
return Ok(None);
}
let lock_path = lock_path_for(path);
let file = std::fs::OpenOptions::new()
.create(true)
.read(true)
.truncate(false)
.write(true)
.open(&lock_path)?;
// SAFETY: `file` owns the descriptor and outlives the call.
let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
if rc != 0 {
let e = std::io::Error::last_os_error();
if e.raw_os_error() == Some(libc::EWOULDBLOCK) {
return Err(Error::Other(format!(
"another Proton Drive daemon is already using {} — \
stop it first (`systemctl --user stop proton-drive.service`)",
path.display()
)));
}
return Err(Error::Io(e));
}
Ok(Some(file))
}
fn lock_path_for(path: &Path) -> PathBuf {
let mut name = path.as_os_str().to_owned();
name.push(".lock");
PathBuf::from(name)
}
/// Whether `e` says the file is not a usable database, as opposed to any other
/// failure. These are the only two conditions rebuilding fixes; a disk error, a
/// permission problem or a newer-than-supported schema must all still fail.
fn is_corrupt(e: &Error) -> bool {
matches!(
e,
Error::Db(rusqlite::Error::SqliteFailure(inner, _))
if inner.code == rusqlite::ErrorCode::DatabaseCorrupt
|| inner.code == rusqlite::ErrorCode::NotADatabase
)
}
/// Move a damaged database (and its WAL sidecars) out of the way so the next
/// open starts from empty.
///
/// Renamed rather than deleted: this is a cache, but it is also the only
/// evidence of what went wrong, and it costs one file the user can remove.
fn quarantine_corrupt_db(path: &Path) -> Result<()> {
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
for suffix in ["", "-wal", "-shm"] {
let mut from = path.as_os_str().to_owned();
from.push(suffix);
let from = PathBuf::from(from);
if !from.exists() {
continue;
}
let mut to = path.as_os_str().to_owned();
to.push(format!(".corrupt-{stamp}{suffix}"));
let to = PathBuf::from(to);
// A failure to rename the sidecars is survivable — SQLite discards a
// WAL whose database header no longer matches — but failing to move the
// database itself is not, because the retry would hit the same file.
match std::fs::rename(&from, &to) {
Ok(()) => tracing::warn!(from = %from.display(), to = %to.display(), "quarantined"),
Err(e) if suffix.is_empty() => return Err(Error::Io(e)),
Err(e) => tracing::warn!(file = %from.display(), error = %e, "could not quarantine"),
}
}
Ok(())
}
#[cfg(test)]
mod tests;
+178
View File
@@ -0,0 +1,178 @@
//! Unified mount/location presentation rows.
//!
//! A device row stores only its `sync_folder` identity. Its path, remote root,
//! mode, state, pending mode, and last-sync timestamp are selected from
//! `sync_folder`, which remains the authoritative sync-engine table.
use rusqlite::params;
use super::Db;
use crate::mounts::{MountAccess, MountKind, MountMode, MountSpec};
use crate::{Error, Result};
impl Db {
/// Project the configured primary mount into the presentation table.
///
/// `root_share_id` is optional because an offline startup may only have the
/// cached root node. A previously learned share id is retained only when it
/// belongs to the same root uid.
pub fn mount_upsert_my_files(
&self,
local_path: &str,
root_uid: &str,
root_share_id: Option<&str>,
) -> Result<i64> {
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
let updated = tx.execute(
"UPDATE mount
SET local_path = ?1,
root_share_id = CASE
WHEN root_uid = ?2
THEN COALESCE(NULLIF(?3, ''), root_share_id)
ELSE COALESCE(?3, '')
END,
root_uid = ?2,
mode = 'ondemand',
access = 'rw'
WHERE kind = 'myfiles'",
params![local_path, root_uid, root_share_id],
)?;
let id = if updated == 0 {
tx.execute(
"INSERT INTO mount
(kind, local_path, root_uid, root_share_id, mode, access)
VALUES ('myfiles', ?1, ?2, COALESCE(?3, ''), 'ondemand', 'rw')",
params![local_path, root_uid, root_share_id],
)?;
tx.last_insert_rowid()
} else {
tx.query_row("SELECT id FROM mount WHERE kind = 'myfiles'", [], |row| {
row.get(0)
})?
};
tx.commit()?;
Ok(id)
}
/// Store a share id learned asynchronously, but only if the projection still
/// names the root it was resolved for.
pub fn mount_repair_my_files_share_id(
&self,
root_uid: &str,
root_share_id: &str,
) -> Result<bool> {
let conn = self.conn.lock();
Ok(conn.execute(
"UPDATE mount
SET root_share_id = ?2
WHERE kind = 'myfiles'
AND root_uid = ?1",
params![root_uid, root_share_id],
)? > 0)
}
/// Every local location, with device fields joined from `sync_folder`.
pub fn mount_list(&self) -> Result<Vec<MountSpec>> {
let conn = self.read();
let mut stmt = conn.prepare(
"SELECT
m.id,
m.kind,
m.sync_folder_id,
m.share_root_uid,
CASE WHEN m.kind = 'device' THEN sf.local_path ELSE m.local_path END,
CASE WHEN m.kind = 'device' THEN sf.remote_uid ELSE m.root_uid END,
CASE WHEN m.kind = 'device' THEN sf.remote_share_id ELSE m.root_share_id END,
CASE WHEN m.kind = 'device' THEN sf.mode ELSE m.mode END,
m.access,
CASE WHEN m.kind = 'device' THEN sf.state ELSE 'idle' END,
CASE WHEN m.kind = 'device' THEN sf.last_sync ELSE 0 END,
CASE WHEN m.kind = 'device' THEN sf.pending_mode ELSE NULL END
FROM mount AS m
LEFT JOIN sync_folder AS sf ON sf.id = m.sync_folder_id
ORDER BY CASE m.kind
WHEN 'myfiles' THEN 0
WHEN 'device' THEN 1
ELSE 2
END,
m.id",
)?;
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Option<i64>>(2)?,
row.get::<_, Option<String>>(3)?,
row.get::<_, String>(4)?,
row.get::<_, String>(5)?,
row.get::<_, String>(6)?,
row.get::<_, String>(7)?,
row.get::<_, String>(8)?,
row.get::<_, String>(9)?,
row.get::<_, i64>(10)?,
row.get::<_, Option<String>>(11)?,
))
})?;
let mut mounts = Vec::new();
for row in rows {
let (
id,
kind,
sync_folder_id,
share_root_uid,
local_path,
root_uid,
root_share_id,
mode,
access,
state,
last_sync,
pending_mode,
) = row?;
let kind = match kind.as_str() {
"myfiles" => MountKind::MyFiles,
"device" => MountKind::Device {
sync_folder_id: sync_folder_id.ok_or_else(|| {
Error::Other(format!("mount row {id} has no sync_folder_id"))
})?,
},
"shared" => MountKind::Shared {
share_root_uid: share_root_uid.ok_or_else(|| {
Error::Other(format!("mount row {id} has no share_root_uid"))
})?,
},
other => {
return Err(Error::Other(format!(
"mount row {id} has unknown kind {other:?}"
)));
}
};
let access = match access.as_str() {
"rw" => MountAccess::Rw,
"ro" => MountAccess::Ro,
other => {
return Err(Error::Other(format!(
"mount row {id} has unknown access {other:?}"
)));
}
};
mounts.push(MountSpec {
id,
kind,
local_path,
root_uid,
root_share_id,
mode: MountMode::from(mode.as_str()),
access,
state,
last_sync,
pending_mode: pending_mode.as_deref().map(MountMode::from),
mounted: false,
progress: None,
});
}
Ok(mounts)
}
}
+869
View File
@@ -0,0 +1,869 @@
//! Node rows: the persisted mirror of the remote tree, plus the trigram
//! full-text index over names and paths that backs `Request::Search`.
use std::collections::HashSet;
use rusqlite::{OptionalExtension, Transaction, params};
use super::Db;
use crate::{Access, Result};
use proton_drive_rs::proton_sdk::ids::{LinkId, NodeUid, VolumeId};
use proton_drive_rs::{Node, NodeKind};
use super::utils::{
HitRow, TRIGRAM_MIN, collect_hits, hit_row, join_path, like_escape, path_of, walk_path_of,
};
pub struct StoredNode {
pub node: Node,
pub listed: bool,
}
/// One full-text search match: the stored [`Node`] plus its mountpoint-relative
/// path (`/`-joined, root excluded) so the front-end can navigate to or open it.
pub struct SearchHit {
pub node: Node,
pub path: String,
}
pub struct PublishedSharedRoot {
pub node: Node,
pub access: Access,
}
impl Db {
pub fn upsert_node(&self, node: &Node) -> Result<()> {
self.upsert_nodes(std::slice::from_ref(node))
}
/// Write-through a batch of nodes as one transaction — a whole directory
/// listing, typically. Otherwise identical to [`upsert_node`](Self::upsert_node),
/// which is the single-node case of it.
///
/// The commit count is the point: SQLite autocommits every statement that is
/// not in an explicit transaction, so interning a folder of a thousand
/// children row-by-row cost a thousand fsyncs, and `ls` waited for all of
/// them.
pub fn upsert_nodes(&self, nodes: &[Node]) -> Result<()> {
if nodes.is_empty() {
return Ok(());
}
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
for node in nodes {
upsert_node_tx(&tx, node)?;
}
tx.commit()?;
Ok(())
}
/// Return visible direct children without consulting the parent's `listed`
/// flag. Synthetic shared listings use this to serve the last completed
/// snapshot while offline, including after a live event expired its TTL.
pub fn visible_children(&self, parent: &NodeUid) -> Result<Vec<Node>> {
let conn = self.read();
let mut stmt = conn.prepare(
"SELECT node_json FROM nodes
WHERE parent_uid = ?1 AND trashed = 0 AND node_json IS NOT NULL
ORDER BY name, uid",
)?;
let rows = stmt.query_map([parent.to_string()], |row| row.get::<_, String>(0))?;
let mut nodes = Vec::new();
for row in rows {
nodes.push(serde_json::from_str(&row?)?);
}
Ok(nodes)
}
/// Atomically publish one completed `Shared with me` root listing.
///
/// Roots absent from `accepted` and every persisted descendant are
/// tombstoned, not deleted. Accepted roots omitted from materialization keep
/// their visible snapshot but are downgraded to Viewer until membership is
/// verified again. Both cases preserve queued operations and staged writes.
pub fn publish_shared_roots(
&self,
parent: &NodeUid,
accepted: &[NodeUid],
roots: &[PublishedSharedRoot],
) -> Result<Vec<NodeUid>> {
let parent = parent.to_string();
let present: HashSet<String> = accepted.iter().map(NodeUid::to_string).collect();
let materialized: HashSet<String> = roots
.iter()
.map(|published| published.node.uid.to_string())
.collect();
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
let existing = direct_child_uids_tx(&tx, &parent)?;
let removed: Vec<String> = existing
.into_iter()
.filter(|uid| !present.contains(uid))
.collect();
for published in roots {
if !present.contains(&published.node.uid.to_string()) {
continue;
}
upsert_node_tx(&tx, &published.node)?;
upsert_share_access_tx(&tx, &published.node.uid.to_string(), published.access)?;
}
for uid in present.difference(&materialized) {
upsert_share_access_tx(&tx, uid, Access::Viewer)?;
}
for root in &removed {
tombstone_subtree_tx(&tx, root)?;
upsert_share_access_tx(&tx, root, Access::Viewer)?;
}
tx.execute(
"UPDATE nodes SET listed = 1 WHERE uid = ?1",
params![parent],
)?;
tx.commit()?;
Ok(removed
.into_iter()
.filter_map(|uid| parse_node_uid(&uid))
.collect())
}
/// Atomically withdraw a deleted foreign subtree and deny stale handles.
///
/// Node rows and queued operations remain so retries and staged writes keep
/// their references. FTS and completed-listing state are withdrawn for the
/// whole subtree, and the deleted UID becomes a fail-closed authority.
pub fn tombstone_foreign_subtree(&self, uid: &NodeUid) -> Result<()> {
let uid = uid.to_string();
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
tombstone_subtree_tx(&tx, &uid)?;
upsert_share_access_tx(&tx, &uid, Access::Viewer)?;
tx.commit()?;
Ok(())
}
/// Publish a completed foreign-folder listing from its authoritative UID
/// list and the subset that materialized successfully. Accepted-but-omitted
/// children retain their previous snapshot; only absent UIDs are tombstoned.
pub fn publish_foreign_children(
&self,
parent: &NodeUid,
accepted: &[NodeUid],
materialized: &[Node],
) -> Result<Vec<NodeUid>> {
let parent = parent.to_string();
let present: HashSet<String> = accepted.iter().map(NodeUid::to_string).collect();
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
let removed: Vec<String> = direct_child_uids_tx(&tx, &parent)?
.into_iter()
.filter(|uid| !present.contains(uid))
.collect();
for node in materialized {
if present.contains(&node.uid.to_string()) {
upsert_node_tx(&tx, node)?;
}
}
for uid in &removed {
tombstone_subtree_tx(&tx, uid)?;
}
tx.execute(
"UPDATE nodes SET listed = 1 WHERE uid = ?1",
params![parent],
)?;
tx.commit()?;
Ok(removed
.into_iter()
.filter_map(|uid| parse_node_uid(&uid))
.collect())
}
/// Atomically publish the synthetic root's pinned name, node/FTS state, and
/// Viewer authority.
///
/// The pin is inserted last so any failure there rolls back the node and
/// its descendant FTS updates as well as the access row. Returning `false`
/// guarantees no write occurred, which keeps cached root lookups O(1).
pub fn publish_virtual_root(&self, pinned_name_key: &str, node: &Node) -> Result<bool> {
let uid = node.uid.to_string();
let desired_parent = node.parent_uid.as_ref().map(NodeUid::to_string);
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
let pinned_name: Option<String> = tx
.query_row(
"SELECT value FROM sync_state WHERE key = ?1",
params![pinned_name_key],
|row| row.get(0),
)
.optional()?;
let stored: Option<(Option<String>, String, i64, i64)> = tx
.query_row(
"SELECT parent_uid, name, is_dir, trashed FROM nodes WHERE uid = ?1",
params![uid],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
)
.optional()?;
let access: Option<String> = tx
.query_row(
"SELECT access FROM share_access WHERE root_uid = ?1",
params![uid],
|row| row.get(0),
)
.optional()?;
let unchanged = stored.is_some_and(|(parent, name, is_dir, trashed)| {
parent == desired_parent
&& name == node.name
&& is_dir == 1
&& trashed == node.trashed as i64
}) && access.as_deref() == Some(Access::Viewer.as_db_str())
&& pinned_name.is_some();
if unchanged {
tx.commit()?;
return Ok(false);
}
upsert_share_access_tx(&tx, &uid, Access::Viewer)?;
upsert_node_tx(&tx, node)?;
tx.execute(
"INSERT INTO sync_state (key, value) VALUES (?1, ?2)
ON CONFLICT(key) DO NOTHING",
params![pinned_name_key, node.name],
)?;
tx.commit()?;
Ok(true)
}
/// Drop a node row (delete or trash from the hot cache). Children rows are
/// not cascaded here; the daemon forgets a whole subtree node-by-node.
pub fn delete_node(&self, uid: &NodeUid) -> Result<()> {
let uid = uid.to_string();
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
// Read the rowid before dropping the node: it is the search index's key
// (B12), and it is gone once the row is. A node with no row leaves
// nothing to unindex — the index is keyed off `nodes`, so it cannot
// hold an entry the table never had.
let rowid: Option<i64> = tx
.query_row(
"SELECT rowid FROM nodes WHERE uid = ?1",
params![uid],
|row| row.get(0),
)
.optional()?;
tx.execute("DELETE FROM nodes WHERE uid = ?1", params![uid])?;
if let Some(rowid) = rowid {
tx.execute("DELETE FROM nodes_fts WHERE rowid = ?1", params![rowid])?;
}
tx.commit()?;
Ok(())
}
/// Atomically retire a landed trash operation and its retained authority.
///
/// Queued trash keeps the node row so drain-time permission checks can
/// resolve its shared-tree access. Removing the op first could resurrect
/// that row after a crash; removing the row first could strand a now
/// unauthorizable op. One transaction closes both windows.
pub fn complete_trash_op(&self, op_id: i64, uid: &NodeUid) -> Result<()> {
let uid = uid.to_string();
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
let rowid: Option<i64> = tx
.query_row(
"SELECT rowid FROM nodes WHERE uid = ?1",
params![uid],
|row| row.get(0),
)
.optional()?;
tx.execute("DELETE FROM nodes WHERE uid = ?1", params![uid])?;
if let Some(rowid) = rowid {
tx.execute("DELETE FROM nodes_fts WHERE rowid = ?1", params![rowid])?;
}
tx.execute("DELETE FROM pending_op WHERE id = ?1", params![op_id])?;
tx.commit()?;
Ok(())
}
/// Check if a folder node has any non-trashed children in the database.
pub fn has_children(&self, parent_uid: &NodeUid) -> Result<bool> {
let uid_str = parent_uid.to_string();
let conn = self.read();
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM nodes WHERE parent_uid = ?1 AND trashed = 0",
params![uid_str],
|row| row.get(0),
)?;
Ok(count > 0)
}
/// A node's mountpoint-relative path, or `None` when it is not cached.
///
/// One indexed read of the stored `path`. Callers that need to relate many
/// nodes to a handful of roots — search decorating each hit with its
/// mountpoint — resolve the roots once through this and do the rest as
/// string arithmetic, instead of a `path_relative_to` walk per pair.
pub fn node_path(&self, uid: &str) -> Result<Option<String>> {
let conn = self.read();
let exists: Option<i64> = conn
.query_row("SELECT 1 FROM nodes WHERE uid = ?1", params![uid], |r| {
r.get(0)
})
.optional()?;
if exists.is_none() {
return Ok(None);
}
Ok(Some(path_of(&conn, uid)?))
}
/// Resolve `uid` relative to an ancestor node.
///
/// This is used when a remote subtree has its own local sync mount: the
/// sync folder stores the ancestor UID, while search results identify the
/// selected descendant. Returning `None` for an unrelated or incomplete
/// chain keeps callers from accidentally joining a Drive-wide path onto the
/// wrong mountpoint. The ancestor itself resolves to the empty path.
pub fn path_relative_to(&self, ancestor_uid: &str, uid: &str) -> Result<Option<String>> {
let conn = self.read();
let mut stmt = conn.prepare(
"WITH RECURSIVE anc(uid, parent_uid, name, depth) AS (
SELECT uid, parent_uid, name, 0 FROM nodes WHERE uid = ?1
UNION ALL
SELECT n.uid, n.parent_uid, n.name, anc.depth + 1
FROM nodes n JOIN anc ON n.uid = anc.parent_uid
WHERE anc.depth < 1024
)
SELECT uid, name FROM anc ORDER BY depth",
)?;
let rows = stmt.query_map(params![uid], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?;
let mut parts = Vec::new();
for row in rows {
let (current_uid, name) = row?;
if current_uid == ancestor_uid {
parts.reverse();
return Ok(Some(parts.join("/")));
}
parts.push(name);
}
Ok(None)
}
/// Full-text search over node names, newest schema's trigram index giving
/// substring (not just prefix) matches. Returns up to `limit` non-trashed
/// hits, each with its mountpoint-relative path resolved. Queries shorter
/// than [`TRIGRAM_MIN`] fall back to a `LIKE` scan since trigram indexes
/// nothing below 3 chars.
pub fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchHit>> {
let query = query.trim();
if query.is_empty() {
return Ok(Vec::new());
}
let conn = self.read();
let rows: Vec<HitRow> = if query.chars().count() < TRIGRAM_MIN {
let pat = format!("%{}%", like_escape(query));
let mut stmt = conn.prepare(
"SELECT node_json, uid, path FROM nodes
WHERE name LIKE ?1 ESCAPE '\\' AND trashed = 0 AND node_json IS NOT NULL
ORDER BY name LIMIT ?2",
)?;
collect_hits(stmt.query_map(params![pat, limit as i64], hit_row)?)?
} else {
// Escape double quotes and quote each term, then combine with AND so
// all terms must match but can appear in any order or position.
let phrase = query
.split_whitespace()
.map(|word| format!("\"{}\"", word.replace('"', "\"\"")))
.collect::<Vec<_>>()
.join(" AND ");
let mut stmt = conn.prepare(
"SELECT n.node_json, n.uid, n.path
FROM nodes_fts f JOIN nodes n ON n.rowid = f.rowid
WHERE f.name MATCH ?1 AND n.trashed = 0 AND n.node_json IS NOT NULL
ORDER BY f.rank LIMIT ?2",
)?;
collect_hits(stmt.query_map(params![phrase, limit as i64], hit_row)?)?
};
let mut hits = Vec::with_capacity(rows.len());
for (json, uid, path) in rows {
let node: Node = serde_json::from_str(&json)?;
let path = match path {
Some(path) => path,
None => walk_path_of(&conn, &uid)?,
};
hits.push(SearchHit { node, path });
}
Ok(hits)
}
/// Return a bounded, deliberately broad candidate pool for fuzzy ranking.
/// Unlike [`search`](Self::search), trigram terms are ORed across both the
/// basename and parent path, so a typo can still share enough trigrams to
/// enter the pool. Final relevance ordering belongs to the caller.
pub fn search_candidates(&self, query: &str, limit: usize) -> Result<Vec<SearchHit>> {
let query = query.trim();
if query.is_empty() || limit == 0 {
return Ok(Vec::new());
}
let conn = self.read();
let terms = candidate_trigrams(query);
let lane_limit = limit.div_ceil(2);
let mut rows: Vec<HitRow> = if terms.is_empty() {
let pat = format!("%{}%", like_escape(query));
let mut stmt = conn.prepare(
"SELECT node_json, uid, path FROM nodes
WHERE name LIKE ?1 ESCAPE '\\' AND trashed = 0 AND node_json IS NOT NULL
ORDER BY name LIMIT ?2",
)?;
collect_hits(stmt.query_map(params![pat, limit as i64], hit_row)?)?
} else {
let expression = terms.join(" OR ");
let mut stmt = conn.prepare(
"SELECT n.node_json, n.uid, n.path
FROM nodes_fts f JOIN nodes n ON n.rowid = f.rowid
WHERE nodes_fts MATCH ?1 AND n.trashed = 0 AND n.node_json IS NOT NULL
ORDER BY f.rank LIMIT ?2",
)?;
collect_hits(stmt.query_map(params![expression, lane_limit as i64], hit_row)?)?
};
// Trigram rank does not know that a name *starts* with the query, and a
// short substitution can destroy every trigram outright (`vedio` vs
// `video`). Two indexed lanes cover both without scanning the node
// table: whole-query prefix first, then same-initial.
if !terms.is_empty()
&& let Some(first) = query.chars().next()
{
let mut stmt = conn.prepare(
"SELECT node_json, uid, path FROM nodes
WHERE name COLLATE NOCASE LIKE ?1 ESCAPE '\\'
AND trashed = 0 AND node_json IS NOT NULL
ORDER BY name COLLATE NOCASE LIMIT ?2",
)?;
let mut extra = Vec::new();
for pattern in [
format!("{}%", like_escape(query)),
format!("{}%", like_escape(&first.to_string())),
] {
extra.extend(collect_hits(
stmt.query_map(params![pattern, lane_limit as i64], hit_row)?,
)?);
}
let trigram = std::mem::take(&mut rows);
for row in extra.into_iter().chain(trigram) {
if !rows.iter().any(|(_, uid, _)| uid == &row.1) {
rows.push(row);
}
}
rows.truncate(limit);
}
rows.into_iter()
.map(|(json, uid, path)| {
Ok(SearchHit {
node: serde_json::from_str(&json)?,
path: match path {
Some(path) => path,
None => walk_path_of(&conn, &uid)?,
},
})
})
.collect()
}
/// Mark (or unmark) a folder's child listing as complete. A listed folder
/// rehydrates its `children` map on mount even when empty; an unlisted one
/// re-enumerates from the remote on next access.
pub fn set_listed(&self, uid: &NodeUid, listed: bool) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"UPDATE nodes SET listed = ?2 WHERE uid = ?1",
params![uid.to_string(), listed as i64],
)?;
Ok(())
}
/// Load every persisted node for cold-start hydration of the `State` maps.
pub fn load_all(&self) -> Result<Vec<StoredNode>> {
let conn = self.read();
let mut stmt =
conn.prepare("SELECT node_json, listed FROM nodes WHERE node_json IS NOT NULL")?;
let rows = stmt.query_map([], |row| {
let json: String = row.get(0)?;
let listed: i64 = row.get(1)?;
Ok((json, listed != 0))
})?;
let mut out = Vec::new();
for row in rows {
let (json, listed) = row?;
let node: Node = serde_json::from_str(&json)?;
out.push(StoredNode { node, listed });
}
Ok(out)
}
/// Load one persisted node back by uid. Used to recover the My Files root
/// when the API is unreachable, so the mount can still serve the cached tree
/// (offline.md Phase 1).
pub fn node_by_uid(&self, uid: &str) -> Result<Option<Node>> {
let conn = self.read();
let json: Option<String> = conn
.query_row(
"SELECT node_json FROM nodes WHERE uid = ?1 AND node_json IS NOT NULL",
params![uid],
|r| r.get(0),
)
.optional()?;
match json {
Some(json) => Ok(Some(serde_json::from_str(&json)?)),
None => Ok(None),
}
}
/// Read the persisted incremental-sync cursor (a `DriveEventId`), if any.
/// The daemon resumes from this on restart instead of reseeding to the
/// server head, so changes made while unmounted are still applied (P2).
pub fn children_if_listed(&self, parent: &NodeUid) -> Result<Option<Vec<Node>>> {
let conn = self.read();
let listed: Option<i64> = conn
.query_row(
"SELECT listed FROM nodes WHERE uid = ?1",
params![parent.to_string()],
|r| r.get(0),
)
.optional()?;
if listed != Some(1) {
return Ok(None);
}
let mut stmt = conn.prepare(
"SELECT node_json FROM nodes
WHERE parent_uid = ?1 AND node_json IS NOT NULL AND trashed = 0",
)?;
let rows = stmt.query_map(params![parent.to_string()], |r| r.get::<_, String>(0))?;
let mut out = Vec::new();
for json in rows {
out.push(serde_json::from_str(&json?)?);
}
Ok(Some(out))
}
// --- Content-cache LRU index (P4) -------------------------------------
//
// Replaces the per-eviction `read_dir` scans in `ContentCache`. Each cached
// blob/block carries one row keyed by its on-disk filename (`cache_key`),
// tagged with `kind` ('blob' | 'block') so the two byte budgets stay
// separate. `last_accessed` (unix seconds) is the LRU key. The daemon owns
// the on-disk cache and rebuilds this index from disk on open, then keeps it
// in sync on every store/read/evict, so it is authoritative for eviction.
}
/// What a node's row looked like before this upsert, for the fields that decide
/// whether the subtree below it has to be touched at all.
struct PriorRow {
parent_uid: Option<String>,
name: String,
path: Option<String>,
trashed: bool,
}
fn upsert_node_tx(tx: &Transaction<'_>, node: &Node) -> Result<()> {
let json = serde_json::to_string(node)?;
let uid = node.uid.to_string();
let parent_uid = node.parent_uid.as_ref().map(|u| u.to_string());
let prior: Option<PriorRow> = tx
.query_row(
"SELECT parent_uid, name, path, trashed FROM nodes WHERE uid = ?1",
params![uid],
|row| {
Ok(PriorRow {
parent_uid: row.get(0)?,
name: row.get(1)?,
path: row.get(2)?,
trashed: row.get::<_, i64>(3)? != 0,
})
},
)
.optional()?;
// A node's path is its parent's path plus its own name — one indexed lookup,
// where resolving it from scratch is a recursive walk to the root. A node
// whose parent is not cached (a device folder's root lives in `device`, never
// in `nodes`) starts a path of its own, which is what the walk did too.
let path = match &parent_uid {
None => String::new(),
Some(parent) => {
let parent_path: Option<Option<String>> = tx
.query_row(
"SELECT path FROM nodes WHERE uid = ?1",
params![parent],
|row| row.get(0),
)
.optional()?;
join_path(parent_path.flatten().as_deref().unwrap_or(""), &node.name)
}
};
tx.execute(
"INSERT INTO nodes
(uid, parent_uid, name, is_dir, size, mtime, trashed, node_json, path)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
ON CONFLICT(uid) DO UPDATE SET
parent_uid = excluded.parent_uid,
name = excluded.name,
is_dir = excluded.is_dir,
size = excluded.size,
mtime = excluded.mtime,
trashed = excluded.trashed,
node_json = excluded.node_json,
path = excluded.path",
params![
uid,
parent_uid,
node.name,
node.is_folder() as i64,
node_size(node),
node.modification_time,
node.trashed as i64,
json,
path,
],
)?;
let rowid: i64 = tx.query_row(
"SELECT rowid FROM nodes WHERE uid = ?1",
params![uid],
|row| row.get(0),
)?;
// FTS5 has no UPSERT, so the node's own row is always replaced.
let indexable = node_is_indexable_tx(tx, &uid)?;
tx.execute("DELETE FROM nodes_fts WHERE rowid = ?1", params![rowid])?;
if indexable {
tx.execute(
"INSERT INTO nodes_fts (rowid, name, path) VALUES (?1, ?2, ?3)",
params![rowid, node.name, path],
)?;
}
// Everything below only matters when the subtree's paths or reachability
// actually moved. Re-listing a folder upserts it unchanged, and paying for a
// full descendant walk on each of those was most of what a large tree spent
// its time on.
let subtree_moved = prior.is_none_or(|prior| {
prior.parent_uid != parent_uid
|| prior.name != node.name
|| prior.path.as_deref() != Some(path.as_str())
|| prior.trashed != node.trashed
});
if node.is_folder() && subtree_moved {
reindex_subtree_tx(tx, &uid, &path, indexable)?;
}
Ok(())
}
/// Rewrite every descendant's stored path and search-index row after `folder`
/// moved, was renamed, or changed reachability.
///
/// One recursive walk carries both the new path and whether anything on the way
/// down is trashed, so each descendant costs two trivial statements instead of a
/// `path_of` walk plus an ancestor walk of its own. `folder_indexable` is the
/// answer `node_is_indexable_tx` gave for the folder itself: what is above the
/// subtree is the same for every node in it.
///
/// The depth cap is a liveness guard, not a policy: `UNION ALL` over a parent
/// cycle (a's parent is b, b's parent is a — corrupt data, but the API can hand
/// it to us) never terminates, and this runs inside the write transaction that
/// holds the daemon's only SQLite connection. Real Drive trees are nowhere near
/// this deep.
fn reindex_subtree_tx(
tx: &Transaction<'_>,
folder: &str,
folder_path: &str,
folder_indexable: bool,
) -> Result<()> {
let descendants: Vec<(i64, String, bool)> = {
let mut stmt = tx.prepare(
"WITH RECURSIVE sub(rowid, uid, name, path, blocked, depth) AS (
SELECT n.rowid, n.uid, n.name,
CASE WHEN ?2 = '' THEN n.name ELSE ?2 || '/' || n.name END,
n.trashed, 0
FROM nodes n WHERE n.parent_uid = ?1
UNION ALL
SELECT n.rowid, n.uid, n.name,
sub.path || '/' || n.name,
MAX(sub.blocked, n.trashed), sub.depth + 1
FROM nodes n JOIN sub ON n.parent_uid = sub.uid
WHERE sub.depth < 256
)
SELECT rowid, path, blocked FROM sub",
)?;
let rows = stmt.query_map(params![folder, folder_path], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, i64>(2)? != 0,
))
})?;
let mut out = Vec::new();
for row in rows {
out.push(row?);
}
out
};
if descendants.is_empty() {
return Ok(());
}
let mut set_path = tx.prepare("UPDATE nodes SET path = ?2 WHERE rowid = ?1")?;
let mut unindex = tx.prepare("DELETE FROM nodes_fts WHERE rowid = ?1")?;
let mut index = tx.prepare(
"INSERT INTO nodes_fts (rowid, name, path)
SELECT rowid, name, ?2 FROM nodes WHERE rowid = ?1",
)?;
for (rowid, path, blocked) in descendants {
set_path.execute(params![rowid, path])?;
unindex.execute(params![rowid])?;
if folder_indexable && !blocked {
index.execute(params![rowid, path])?;
}
}
Ok(())
}
fn direct_child_uids_tx(tx: &Transaction<'_>, parent: &str) -> Result<Vec<String>> {
let mut stmt = tx.prepare("SELECT uid FROM nodes WHERE parent_uid = ?1")?;
let rows = stmt.query_map([parent], |row| row.get::<_, String>(0))?;
let mut uids = Vec::new();
for row in rows {
uids.push(row?);
}
Ok(uids)
}
/// Whether a node belongs in the search index: it exists, and nothing on the
/// way up to the top of its cached chain is trashed.
///
/// The chain does *not* have to reach a `parent_uid IS NULL` row. Only the My
/// Files root is stored that way; a device folder's root lives in `device` /
/// `sync_folder` and never gets a `nodes` row, so requiring a null-parent
/// ancestor silently excluded every device-folder subtree from search — 29% of
/// the nodes on the account this was found on. A subtree whose topmost cached
/// row has an uncached parent is ordinary, reachable data.
///
/// What is still excluded is a *cycle*: the walk's own guard stops it, but
/// [`path_of`] has no such guard, so an indexed cycle would hang a search. A
/// clean chain ends at a row whose parent is null or simply not cached; a cycle
/// ends at one whose parent is a row we have already visited.
fn node_is_indexable_tx(tx: &Transaction<'_>, uid: &str) -> Result<bool> {
let indexable: i64 = tx.query_row(
"WITH RECURSIVE ancestors(uid, parent_uid, trashed, depth, path) AS (
SELECT uid, parent_uid, trashed, 0, char(31) || uid || char(31)
FROM nodes WHERE uid = ?1
UNION ALL
SELECT n.uid, n.parent_uid, n.trashed, a.depth + 1,
a.path || n.uid || char(31)
FROM ancestors a JOIN nodes n ON n.uid = a.parent_uid
WHERE instr(a.path, char(31) || n.uid || char(31)) = 0
)
SELECT CASE
WHEN COUNT(*) = 0 THEN 0
WHEN MAX(trashed) != 0 THEN 0
WHEN EXISTS (
SELECT 1 FROM ancestors a
WHERE a.depth = (SELECT MAX(depth) FROM ancestors)
AND a.parent_uid IS NOT NULL
AND EXISTS (SELECT 1 FROM nodes p WHERE p.uid = a.parent_uid)
) THEN 0
ELSE 1
END
FROM ancestors",
[uid],
|row| row.get(0),
)?;
Ok(indexable == 1)
}
fn tombstone_subtree_tx(tx: &Transaction<'_>, root_uid: &str) -> Result<()> {
let rows = {
let mut stmt = tx.prepare(
"WITH RECURSIVE subtree(rowid, uid, node_json) AS (
SELECT rowid, uid, node_json FROM nodes WHERE uid = ?1
UNION
SELECT n.rowid, n.uid, n.node_json FROM nodes n
JOIN subtree s ON n.parent_uid = s.uid
)
SELECT rowid, uid, node_json FROM subtree",
)?;
let mapped = stmt.query_map([root_uid], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Option<String>>(2)?,
))
})?;
let mut rows = Vec::new();
for row in mapped {
rows.push(row?);
}
rows
};
for (rowid, uid, json) in rows {
let json = match json {
Some(json) => {
let mut node: Node = serde_json::from_str(&json)?;
node.trashed = true;
Some(serde_json::to_string(&node)?)
}
None => None,
};
tx.execute(
"UPDATE nodes SET trashed = 1, listed = 0, node_json = ?2 WHERE uid = ?1",
params![uid, json],
)?;
tx.execute("DELETE FROM nodes_fts WHERE rowid = ?1", [rowid])?;
}
Ok(())
}
fn upsert_share_access_tx(tx: &Transaction<'_>, uid: &str, access: Access) -> Result<()> {
tx.execute(
"INSERT INTO share_access (root_uid, access) VALUES (?1, ?2)
ON CONFLICT(root_uid) DO UPDATE SET access = excluded.access",
params![uid, access.as_db_str()],
)?;
Ok(())
}
fn parse_node_uid(value: &str) -> Option<NodeUid> {
let (volume, link) = value.split_once('~')?;
Some(NodeUid::new(VolumeId::from(volume), LinkId::from(link)))
}
/// Unique quoted character trigrams suitable for an FTS5 OR expression.
pub(super) fn candidate_trigrams(query: &str) -> Vec<String> {
let chars: Vec<char> = query.to_lowercase().chars().collect();
let mut terms = Vec::new();
for window in chars.windows(TRIGRAM_MIN) {
if window.iter().all(|c| c.is_whitespace()) {
continue;
}
let raw: String = window.iter().collect();
let quoted = format!("\"{}\"", raw.replace('"', "\"\""));
if !terms.contains(&quoted) {
terms.push(quoted);
}
}
terms
}
/// Effective plaintext size of a node for the indexed `size` column: the
/// claimed size when known, else the on-storage size; folders are 0.
pub(super) fn node_size(node: &Node) -> i64 {
match &node.kind {
NodeKind::Folder => 0,
NodeKind::File {
total_size_on_storage,
claimed_size,
..
} => claimed_size.unwrap_or(*total_size_on_storage).max(0),
}
}
+886
View File
@@ -0,0 +1,886 @@
//! The offline mutation queue (`pending_op`): writes the kernel has accepted but
//! the remote has not yet seen. Rebuilt into memory on mount and drained in row
//! order, so a child never drains before the parent that gives it a real uid.
use rusqlite::{OptionalExtension, params};
use serde::{Deserialize, Serialize};
use super::Db;
use crate::Result;
/// The `kind` of a [`PendingOp`] that uploads a staged file as a new revision.
pub const OP_REVISION: &str = "revision";
/// The `kind` of a [`PendingOp`] that creates a file that so far exists only
/// locally, under a `local:` placeholder uid (offline.md Phase 3b).
///
/// The written bytes ride along on the same row rather than as a follow-on
/// [`OP_REVISION`]: draining the create mints the node's real uid, which would
/// leave a separate revision op addressed to a uid that no longer exists.
pub const OP_CREATE: &str = "create";
/// The `kind` of a [`PendingOp`] that creates a folder that so far exists only
/// locally. Ordering matters: a child's op is queued after its parent's and
/// [`Db::pending_ops`] replays by row id, so the parent has a real uid by the
/// time the child drains.
pub const OP_MKDIR: &str = "mkdir";
/// The `kind` of a [`PendingOp`] that gives a node a new name, a new parent, or
/// both — the queued form of `mv` (offline.md Phase 3b).
///
/// `parent_uid` and `name` hold the node's *desired end state*, not a delta, so
/// a second rename simply replaces the row (see [`Db::enqueue_op`]) and the
/// drain can compare them against the remote and skip whichever half already
/// matches. The parent may be a `local~` placeholder — moving a file into a
/// folder that was itself created offline — in which case it is rewritten by
/// that folder's own drain, exactly as for [`OP_CREATE`].
pub const OP_RENAME: &str = "rename";
/// Drain-time authority retained by a queued rename.
///
/// The optimistic local move rewrites the node's persisted ancestry to the
/// destination immediately. Keeping the original parent here prevents that
/// rewrite from erasing the shared tree that admitted the move.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RenameMeta {
pub original_parent_uid: String,
}
/// The `kind` of a [`PendingOp`] that trashes a node the server knows about
/// (offline.md Phase 3b).
///
/// A node that only ever existed locally never gets one of these: there is
/// nothing to trash remotely, so deleting it just drops its queued ops
/// ([`Db::delete_ops_for_uid`]).
pub const OP_TRASH: &str = "trash";
/// Whether at most one op of this `kind` may be queued per node, so that a newer
/// one replaces the older rather than queueing behind it.
///
/// True exactly of the kinds that describe a node's desired *end state* rather
/// than a step towards it: the newest revision already contains every earlier
/// one's bytes, the newest name is the only name wanted, and a trash subsumes
/// anything queued before it. A `create`/`mkdir` is the opposite — it is the one
/// thing that will ever make the node exist, so it must never be replaced.
pub fn op_supersedes(kind: &str) -> bool {
matches!(kind, OP_REVISION | OP_RENAME | OP_TRASH)
}
/// The volume id given to a node that exists only on this machine, so far. A
/// real [`NodeUid`] is `{volume}~{link}`, so a placeholder is `local~<uuid>` and
/// round-trips through the same `Display`/parse path as any other uid.
///
/// Nothing bearing this volume may be handed to the API — it would 404. The
/// drain replaces it with the uid the server assigns.
pub const LOCAL_VOLUME: &str = "local";
/// A `next_attempt_at` far enough in the future that [`Db::next_due_op`] never
/// selects the op (ms since epoch, ≈ year 2223). Used to *park* a queued create
/// for a transient file — a browser's in-flight `*.crdownload`/`*.part`, an
/// editor's `*.swp` — so its bytes never upload while it wears that name. A
/// rename to the finished name un-parks it (see [`Db::set_create_hold`]), which
/// is the only moment the completed file is meant to reach Drive. Without the
/// park, every partial and every abandoned temp would upload and, on a
/// stall+resume, fork a `(sync-conflict)` copy (docs/BUGS.md B70).
pub const PARK_UNTIL: i64 = 8_000_000_000_000;
/// A mutation that has been accepted locally but not yet performed against the
/// API — the durable half of the write-back queue (offline.md Phase 3).
///
/// The daemon answers the FUSE call the moment this row and its staged blob are
/// on disk, so a `cp` into the mount runs at disk speed and the upload happens
/// behind it. That also makes an offline write succeed rather than EIO: the row
/// simply waits for the network.
#[derive(Debug, Clone)]
pub struct PendingOp {
/// Row id, `0` on a value being inserted.
pub id: i64,
/// See [`OP_REVISION`], [`OP_CREATE`], [`OP_MKDIR`].
pub kind: String,
/// Node this op targets. For [`OP_CREATE`]/[`OP_MKDIR`] this is the
/// `local~<uuid>` placeholder the node is known by until it drains.
pub uid: String,
/// Where the new node goes; only set for [`OP_CREATE`]/[`OP_MKDIR`]. May
/// itself be a placeholder when the parent folder is also still queued, in
/// which case it is rewritten when the parent drains.
pub parent_uid: Option<String>,
/// The new node's name; only set for [`OP_CREATE`]/[`OP_MKDIR`].
pub name: Option<String>,
/// Staged blob holding the bytes to upload.
pub blob_path: Option<String>,
/// Serialized [`StagedWrite`](crate::cache::StagedWrite).
pub meta_json: Option<String>,
/// When the op was queued (ms since epoch).
pub created_at: i64,
pub attempts: i64,
pub last_error: Option<String>,
/// Earliest ms at which to retry, for backoff.
pub next_attempt_at: i64,
}
/// How much work the queue owes the server, by whether it carries bytes.
#[derive(Debug, Clone, Default)]
pub struct PendingCounts {
/// Queued `create`/`revision` ops: files whose content is not on the remote.
pub uploads: i64,
/// Queued `mkdir`/`rename`/`trash` ops: metadata the remote has not been
/// told about.
pub changes: i64,
/// Ops parked at [`PARK_UNTIL`]: a transient file's bytes, held back until
/// the rename that gives it its finished name. They are queued but will
/// never drain on their own, so counting them as "waiting to upload" would
/// have the front-end promise progress that is not coming.
pub parked: i64,
/// Ops that have failed at least [`FAILING_ATTEMPTS`] times. A permanently
/// failing op never wedges the queue — the backoff sees to that — which is
/// exactly why it needs surfacing: it retries forever, invisibly.
pub failing: i64,
/// The most recent error text from a failing op, for the front-end to show
/// instead of the bare count.
pub last_error: Option<String>,
}
/// Failed attempts after which an op is reported as failing rather than merely
/// retrying. Six is where the backoff has reached its ceiling, so an op past it
/// is retrying at the slowest rate it ever will and has been for minutes.
pub const FAILING_ATTEMPTS: i64 = 6;
/// The outcome of folding freshly written bytes into a queued create.
#[derive(Debug, Clone)]
pub struct AttachedBlob {
/// Row id of the create the bytes were attached to.
pub id: i64,
/// Blob the create held before, now orphaned.
pub superseded: Option<String>,
}
impl Db {
/// Whether a specific desired-state operation is still queued for a node.
pub fn has_pending_op(&self, uid: &str, kind: &str) -> Result<bool> {
let conn = self.read();
conn.query_row(
"SELECT EXISTS(
SELECT 1 FROM pending_op WHERE uid = ?1 AND kind = ?2
)",
params![uid, kind],
|row| row.get(0),
)
.map_err(Into::into)
}
/// Read an existing operation's metadata before a superseding enqueue.
pub fn pending_op_meta(&self, uid: &str, kind: &str) -> Result<Option<String>> {
let conn = self.read();
conn.query_row(
"SELECT meta_json FROM pending_op WHERE uid = ?1 AND kind = ?2",
params![uid, kind],
|row| row.get(0),
)
.optional()
.map(|value| value.flatten())
.map_err(Into::into)
}
pub fn enqueue_op(&self, op: &PendingOp) -> Result<(i64, Option<String>)> {
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
let superseded: Option<String> = if op_supersedes(&op.kind) {
let blob: Option<String> = tx
.query_row(
"SELECT blob_path FROM pending_op WHERE uid = ?1 AND kind = ?2",
params![op.uid, op.kind],
|r| r.get(0),
)
.optional()?
.flatten();
tx.execute(
"DELETE FROM pending_op WHERE uid = ?1 AND kind = ?2",
params![op.uid, op.kind],
)?;
blob
} else {
None
};
tx.execute(
"INSERT INTO pending_op
(kind, uid, parent_uid, name, blob_path, meta_json, created_at, next_attempt_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![
op.kind,
op.uid,
op.parent_uid,
op.name,
op.blob_path,
op.meta_json,
op.created_at,
op.next_attempt_at
],
)?;
let id = tx.last_insert_rowid();
tx.commit()?;
Ok((id, superseded))
}
/// Atomically replace every queued operation for `uid` and its queued
/// descendants with one remote trash intent.
///
/// Staged blob paths are returned only after the transaction commits. A
/// failed trash insert therefore rolls the deletions back and leaves every
/// prior operation owning its bytes.
pub fn replace_ops_with_trash(
&self,
uid: &str,
name: &str,
created_at: i64,
) -> Result<(i64, Vec<String>)> {
const SUBTREE: &str = "
WITH RECURSIVE doomed(uid) AS (
SELECT ?1
UNION
SELECT p.uid FROM pending_op p JOIN doomed d ON p.parent_uid = d.uid
)";
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
let blobs: Vec<String> = {
let mut stmt = tx.prepare(&format!(
"{SUBTREE}
SELECT blob_path FROM pending_op
WHERE uid IN (SELECT uid FROM doomed) AND blob_path IS NOT NULL"
))?;
let rows = stmt.query_map(params![uid], |row| row.get::<_, String>(0))?;
rows.collect::<std::result::Result<Vec<_>, _>>()?
};
tx.execute(
&format!("{SUBTREE} DELETE FROM pending_op WHERE uid IN (SELECT uid FROM doomed)"),
params![uid],
)?;
tx.execute(
"INSERT INTO pending_op
(kind, uid, parent_uid, name, blob_path, meta_json, created_at, next_attempt_at)
VALUES (?1, ?2, NULL, ?3, NULL, NULL, ?4, 0)",
params![OP_TRASH, uid, name, created_at],
)?;
let id = tx.last_insert_rowid();
tx.commit()?;
Ok((id, blobs))
}
/// Point a queued create at the bytes that were just written to it, returning
/// any blob it previously held so the caller can discard it.
///
/// This is what `release` does for a file that only exists locally: the node
/// has no uid to hang a revision op on yet, so the bytes ride on the create.
/// Repeated writes before the drain simply replace the blob.
///
/// Returns `Ok(None)` and touches nothing if the create has already drained —
/// the caller must then queue an ordinary revision against the real uid.
pub fn attach_blob_to_create(
&self,
uid: &str,
blob_path: &str,
meta_json: &str,
) -> Result<Option<AttachedBlob>> {
let conn = self.conn.lock();
let existing: Option<(i64, Option<String>)> = conn
.query_row(
"SELECT id, blob_path FROM pending_op WHERE uid = ?1 AND kind = ?2",
params![uid, OP_CREATE],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()?;
let Some((id, superseded)) = existing else {
return Ok(None);
};
// Clearing the backoff lets the fresh bytes be tried promptly — except
// when the create is *parked* (a transient file, next_attempt_at at the
// PARK_UNTIL sentinel). Attaching a new revision to a still-growing
// download must not wake it; only the finalize rename does. So preserve a
// park and reset an ordinary backoff.
conn.execute(
"UPDATE pending_op
SET blob_path = ?2, meta_json = ?3, attempts = 0,
next_attempt_at = CASE WHEN next_attempt_at >= ?4
THEN next_attempt_at ELSE 0 END
WHERE id = ?1",
params![id, blob_path, meta_json, PARK_UNTIL],
)?;
Ok(Some(AttachedBlob { id, superseded }))
}
/// Replace the sidecar of a queued op, for a baseline that has moved under
/// it.
///
/// Exists for one caller: when *our own* upload seals a new revision while a
/// further write to the same node is already queued, that queued write's
/// [`StagedWrite::based_on`](crate::cache::StagedWrite::based_on) still
/// names the revision we just replaced. Left alone it would read as "another
/// device changed this file" and divert the write into a conflict copy over
/// nothing — a self-conflict. Restamping it is what keeps a chain of writes
/// to one file a chain rather than a pile of copies.
///
/// Deliberately narrow: only the sidecar changes, so the blob, the attempt
/// count and the backoff all survive. Returns false when no such op is
/// queued, which is the ordinary case.
pub fn update_op_meta(&self, uid: &str, kind: &str, meta_json: &str) -> Result<bool> {
let conn = self.conn.lock();
let n = conn.execute(
"UPDATE pending_op SET meta_json = ?3 WHERE uid = ?1 AND kind = ?2",
params![uid, kind, meta_json],
)?;
Ok(n > 0)
}
/// Point a queued create at a new parent and name, for a node renamed or
/// moved before it ever reached the server.
///
/// A `local~` uid means nothing to the API, so there is no rename call to
/// make — the node is still only a queued intent, and rewriting that intent
/// *is* the rename. Returns false when the create has already drained, in
/// which case the node has a real uid and the caller must rename it there
/// instead (offline.md Phase 3b).
pub fn rewrite_op_target(&self, uid: &str, parent_uid: &str, name: &str) -> Result<bool> {
let conn = self.conn.lock();
let n = conn.execute(
"UPDATE pending_op SET parent_uid = ?2, name = ?3
WHERE uid = ?1 AND kind IN (?4, ?5)",
params![uid, parent_uid, name, OP_CREATE, OP_MKDIR],
)?;
Ok(n > 0)
}
/// Park or un-park a queued create/mkdir for `uid`, returning whether a row
/// was touched.
///
/// Parking sets `next_attempt_at` to [`PARK_UNTIL`] so [`Db::next_due_op`]
/// skips it indefinitely; un-parking sets it due now. Used to keep a
/// transient file's bytes off Drive until it is renamed to its finished name
/// (docs/BUGS.md B70). Un-parking a create that was never parked is harmless:
/// it just makes the create due immediately, which for a normal local node it
/// already is.
pub fn set_create_hold(&self, uid: &str, held: bool) -> Result<bool> {
let conn = self.conn.lock();
let n = conn.execute(
"UPDATE pending_op SET next_attempt_at = ?2
WHERE uid = ?1 AND kind IN (?3, ?4)",
params![uid, if held { PARK_UNTIL } else { 0 }, OP_CREATE, OP_MKDIR],
)?;
Ok(n > 0)
}
/// Check if a queued create or mkdir op exists for `uid`.
pub fn has_create_op(&self, uid: &str) -> Result<bool> {
let conn = self.read();
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM pending_op WHERE uid = ?1 AND kind IN (?2, ?3)",
params![uid, OP_CREATE, OP_MKDIR],
|row| row.get(0),
)?;
Ok(count > 0)
}
/// Check whether *any* op of any kind is queued against `uid`.
///
/// Used as a safety interlock by callers that are about to remove a node
/// they only know about from a stale snapshot: a queued op means the user's
/// bytes are still owed an upload, and dropping it would discard the staged
/// blob that holds them (`docs/BUGS.md` B71).
pub fn has_any_op(&self, uid: &str) -> Result<bool> {
let conn = self.read();
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM pending_op WHERE uid = ?1",
params![uid],
|row| row.get(0),
)?;
Ok(count > 0)
}
/// Drop every op targeting a node **or anything queued beneath it**,
/// returning the staged blobs they held so the caller can delete them.
///
/// Used when a node that only ever existed locally is deleted: there is
/// nothing on the server to trash, so the queued work simply stops being
/// wanted.
///
/// The descent is what keeps the queue alive. Deleting a folder created
/// offline drops the `mkdir` that would have given it a real uid, and any
/// op still queued under that placeholder is then unreachable forever: it
/// can never be attempted (its parent is a `local~` uid, so `op_is_ready`
/// refuses it) and nothing is left to remap it. It would sit in the queue,
/// and in the user's pending count, for the life of the database.
///
/// Only `create`/`mkdir` ops carry a `parent_uid`, so for a file this
/// recursion finds nothing and costs one query.
pub fn delete_ops_for_uid(&self, uid: &str) -> Result<Vec<String>> {
const SUBTREE: &str = "
WITH RECURSIVE doomed(uid) AS (
SELECT ?1
UNION
SELECT p.uid FROM pending_op p JOIN doomed d ON p.parent_uid = d.uid
)";
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
let blobs: Vec<String> = {
let mut stmt = tx.prepare(&format!(
"{SUBTREE}
SELECT blob_path FROM pending_op
WHERE uid IN (SELECT uid FROM doomed) AND blob_path IS NOT NULL"
))?;
let rows = stmt.query_map(params![uid], |r| r.get::<_, String>(0))?;
rows.collect::<std::result::Result<Vec<_>, _>>()?
};
tx.execute(
&format!("{SUBTREE} DELETE FROM pending_op WHERE uid IN (SELECT uid FROM doomed)"),
params![uid],
)?;
tx.commit()?;
Ok(blobs)
}
/// Rewrite every queued op that points at a placeholder parent, once that
/// parent has drained and has a real uid. Also moves the node rows whose
/// parent column still names the placeholder, so listings keep resolving.
pub fn remap_local_uid(&self, local: &str, real: &str) -> Result<()> {
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
tx.execute(
"UPDATE pending_op SET parent_uid = ?2 WHERE parent_uid = ?1",
params![local, real],
)?;
tx.execute(
"UPDATE nodes SET parent_uid = ?2 WHERE parent_uid = ?1",
params![local, real],
)?;
tx.execute("DELETE FROM nodes WHERE uid = ?1", params![local])?;
tx.commit()?;
Ok(())
}
/// Every queued op, oldest first. The drain worker replays them in this order
/// so a file's writes land in the order they were made.
/// The oldest queued op that is due and not blocked, or `None`.
///
/// This is the drain loop's per-iteration query, and it is deliberately not
/// `pending_ops().find(...)`. That variant read every row — including each
/// op's full `meta_json` — sorted them, and threw all but one away, once per
/// drained op *and* once per retry, while holding the single connection
/// mutex that every FUSE `lookup` also needs. A queue of a few thousand ops
/// (an ordinary offline `cp -r`) made draining quadratic in the queue length.
///
/// Both filters are in SQL so the row is decided by the database rather than
/// materialized and rejected in Rust:
///
/// * `next_attempt_at <= now` — the backoff.
/// * a `local~` parent is skipped. A node created inside a folder that was
/// itself created offline cannot be sent anywhere until that folder is
/// real. Ops replay in queue order so the parent normally drains first;
/// this matters when the parent is backing off, where the child must wait
/// rather than burn its own retries.
///
/// `id` is `INTEGER PRIMARY KEY`, i.e. the rowid, so `ORDER BY id LIMIT 1`
/// walks the table in insertion order and stops at the first match — no
/// sort, and no separate index to maintain. The common case (something is
/// due) exits on the first row.
pub fn next_due_op(&self, now: i64) -> Result<Option<PendingOp>> {
let conn = self.read();
let mut stmt = conn.prepare_cached(&format!(
"SELECT id, kind, uid, parent_uid, name, blob_path, meta_json, created_at,
attempts, last_error, next_attempt_at
FROM pending_op
WHERE next_attempt_at <= ?1
AND (parent_uid IS NULL OR substr(parent_uid, 1, {n}) <> '{v}~')
ORDER BY id LIMIT 1",
v = LOCAL_VOLUME,
n = LOCAL_VOLUME.len() + 1,
))?;
let op = stmt
.query_row(params![now], |r| {
Ok(PendingOp {
id: r.get(0)?,
kind: r.get(1)?,
uid: r.get(2)?,
parent_uid: r.get(3)?,
name: r.get(4)?,
blob_path: r.get(5)?,
meta_json: r.get(6)?,
created_at: r.get(7)?,
attempts: r.get(8)?,
last_error: r.get(9)?,
next_attempt_at: r.get(10)?,
})
})
.optional()?;
Ok(op)
}
/// Take the oldest due op for this worker, marking it claimed so no other
/// drain worker takes it, or `None` when there is nothing to do.
///
/// The drain is several workers over one queue, which is only safe because
/// the claim is a column: a row is offered to exactly one worker, and the
/// worker that took it is the only one that may retire, defer or fail it.
///
/// Two exclusions on top of [`next_due_op`](Self::next_due_op)'s:
///
/// * `claimed_at = 0` — not already in flight somewhere else.
/// * no *other* claimed op shares this `uid`. Ordering only has to hold per
/// node, but there it has to hold absolutely: a queued rename and a queued
/// revision of one file must land in the order they were made, and two
/// workers on one node would also race over its staged blob and its
/// pending entry. `ORDER BY id` plus this exclusion gives per-uid serial
/// order for free — the older op is claimed first and the newer one is
/// invisible until it retires.
///
/// Select and mark are one transaction, so two workers cannot both see the
/// row as unclaimed.
pub fn claim_next_due_op(&self, now: i64) -> Result<Option<PendingOp>> {
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
let op = {
let mut stmt = tx.prepare_cached(&format!(
"SELECT id, kind, uid, parent_uid, name, blob_path, meta_json, created_at,
attempts, last_error, next_attempt_at
FROM pending_op
WHERE next_attempt_at <= ?1
AND claimed_at = 0
AND (parent_uid IS NULL OR substr(parent_uid, 1, {n}) <> '{v}~')
AND uid NOT IN (SELECT uid FROM pending_op WHERE claimed_at <> 0)
ORDER BY id LIMIT 1",
v = LOCAL_VOLUME,
n = LOCAL_VOLUME.len() + 1,
))?;
stmt.query_row(params![now], |r| {
Ok(PendingOp {
id: r.get(0)?,
kind: r.get(1)?,
uid: r.get(2)?,
parent_uid: r.get(3)?,
name: r.get(4)?,
blob_path: r.get(5)?,
meta_json: r.get(6)?,
created_at: r.get(7)?,
attempts: r.get(8)?,
last_error: r.get(9)?,
next_attempt_at: r.get(10)?,
})
})
.optional()?
};
if let Some(op) = op.as_ref() {
tx.execute(
"UPDATE pending_op SET claimed_at = ?2 WHERE id = ?1",
params![op.id, now.max(1)],
)?;
}
tx.commit()?;
Ok(op)
}
/// Give a claimed op back to the queue, whatever became of the attempt.
///
/// Called on every path out of a drain attempt rather than only the failing
/// ones: a handler that retires its own row leaves nothing to release (the
/// `UPDATE` matches nothing), and a handler that returns without retiring
/// *and* without failing would otherwise leave the row claimed by a worker
/// that has moved on — invisible to every worker, forever.
pub fn release_op_claim(&self, id: i64) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"UPDATE pending_op SET claimed_at = 0 WHERE id = ?1",
params![id],
)?;
Ok(())
}
/// Drop every claim in the table.
///
/// A claim describes a worker in *this* process, so one surviving on disk
/// means the previous run died holding it. The single-writer lock
/// ([`Db::open`]) is what makes that inference safe: nobody else can be
/// draining this queue while we are opening it.
pub fn clear_op_claims(&self) -> Result<usize> {
let conn = self.conn.lock();
Ok(conn.execute(
"UPDATE pending_op SET claimed_at = 0 WHERE claimed_at <> 0",
[],
)?)
}
/// The earliest `next_attempt_at` among all queued ops, or `None` if the
/// queue is empty. Used by the drain loop to sleep exactly until the next
/// debounced or backed-off op becomes eligible rather than waiting the full
/// idle-poll interval.
///
/// Claimed ops are excluded: another worker is already on them, so they are
/// not work this one is waiting for, and counting them would have an idle
/// worker spin on a row it cannot have.
pub fn earliest_due_at(&self) -> Result<Option<i64>> {
let conn = self.read();
let ts: Option<i64> = conn
.query_row(
&format!(
"SELECT MIN(next_attempt_at) FROM pending_op \
WHERE claimed_at = 0 \
AND (parent_uid IS NULL OR substr(parent_uid, 1, {n}) <> '{v}~')",
v = LOCAL_VOLUME,
n = LOCAL_VOLUME.len() + 1,
),
[],
|r| r.get(0),
)
.optional()?
.flatten();
Ok(ts)
}
/// Every queued op, oldest first. For status read-outs and the CLI — the
/// drain wants [`next_due_op`](Self::next_due_op) instead.
pub fn pending_ops(&self) -> Result<Vec<PendingOp>> {
let conn = self.read();
let mut stmt = conn.prepare(
"SELECT id, kind, uid, parent_uid, name, blob_path, meta_json, created_at,
attempts, last_error, next_attempt_at
FROM pending_op ORDER BY id",
)?;
let rows = stmt
.query_map([], |r| {
Ok(PendingOp {
id: r.get(0)?,
kind: r.get(1)?,
uid: r.get(2)?,
parent_uid: r.get(3)?,
name: r.get(4)?,
blob_path: r.get(5)?,
meta_json: r.get(6)?,
created_at: r.get(7)?,
attempts: r.get(8)?,
last_error: r.get(9)?,
next_attempt_at: r.get(10)?,
})
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(rows)
}
/// How many queued ops there are of each kind, for `Response::Status`.
///
/// Split because "3 uploads queued" has to mean three files whose bytes are
/// not on the remote yet. A queued `mkdir`/`rename`/`trash` is also work the
/// mount owes the server, but it carries no bytes and reporting it as an
/// upload is simply untrue.
pub fn pending_op_counts(&self) -> Result<PendingCounts> {
let conn = self.read();
let uploads = conn.query_row(
"SELECT COUNT(*) FROM pending_op WHERE kind IN (?1, ?2)",
params![OP_REVISION, OP_CREATE],
|r| r.get(0),
)?;
let changes = conn.query_row(
"SELECT COUNT(*) FROM pending_op WHERE kind NOT IN (?1, ?2)",
params![OP_REVISION, OP_CREATE],
|r| r.get(0),
)?;
let parked = conn.query_row(
"SELECT COUNT(*) FROM pending_op WHERE next_attempt_at >= ?1",
params![PARK_UNTIL],
|r| r.get(0),
)?;
let failing = conn.query_row(
"SELECT COUNT(*) FROM pending_op WHERE attempts >= ?1",
params![FAILING_ATTEMPTS],
|r| r.get(0),
)?;
let last_error = conn
.query_row(
"SELECT last_error FROM pending_op
WHERE attempts >= ?1 AND last_error IS NOT NULL
ORDER BY attempts DESC LIMIT 1",
params![FAILING_ATTEMPTS],
|r| r.get::<_, Option<String>>(0),
)
.optional()?
.flatten();
Ok(PendingCounts {
uploads,
changes,
parked,
failing,
last_error,
})
}
/// Nothing bearing this volume may be handed to the API — it would 404. The
/// drain replaces it with the uid the server assigns.
pub const LOCAL_VOLUME: &str = "local";
/// Drop a queued op, once its upload has actually landed.
pub fn delete_op(&self, id: i64) -> Result<()> {
let conn = self.conn.lock();
conn.execute("DELETE FROM pending_op WHERE id = ?1", params![id])?;
Ok(())
}
/// Record a failed attempt and when to next try. Leaves the row in place —
/// the staged bytes are still the only copy of the user's write.
///
/// Clears any access-deferral window: whatever the op was waiting for has
/// now been reported, so the next uncleared deferral starts its own window
/// rather than escalating again on the following recheck.
pub fn record_op_failure(&self, id: i64, error: &str, next_attempt_at: i64) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"UPDATE pending_op
SET attempts = attempts + 1, last_error = ?2, next_attempt_at = ?3,
access_deferred_since = 0
WHERE id = ?1",
params![id, error, next_attempt_at],
)?;
Ok(())
}
/// Defer a due operation without recording a failed remote attempt.
///
/// Access checks happen before the drain calls Proton, so a locally denied
/// operation has not failed remotely and must not consume a retry. Preserve
/// the long-lived transient-create sentinel if this is ever called for one.
pub fn defer_op_without_attempt(&self, id: i64, next_attempt_at: i64) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"UPDATE pending_op
SET next_attempt_at = CASE
WHEN next_attempt_at >= ?3 THEN next_attempt_at
ELSE ?2
END
WHERE id = ?1",
params![id, next_attempt_at, PARK_UNTIL],
)?;
Ok(())
}
/// Defer an op the local access check refused, and report how long it has
/// been refused for.
///
/// Identical to [`defer_op_without_attempt`](Self::defer_op_without_attempt)
/// on the retry clock, plus the one thing that was missing: the first
/// deferral of a run stamps `access_deferred_since`, and every deferral
/// returns that stamp. A caller can therefore tell a deferral that is one
/// recheck old from one that has been repeating unchanged for days, which is
/// the difference between a permission the user is mid-way through changing
/// and a queue that will never drain.
///
/// Returns the ms at which the current run of deferrals began — `now` on the
/// first one.
pub fn defer_op_for_access(&self, id: i64, now: i64, next_attempt_at: i64) -> Result<i64> {
let conn = self.conn.lock();
conn.execute(
"UPDATE pending_op
SET next_attempt_at = CASE
WHEN next_attempt_at >= ?4 THEN next_attempt_at
ELSE ?3
END,
access_deferred_since = CASE
WHEN access_deferred_since = 0 THEN ?2
ELSE access_deferred_since
END
WHERE id = ?1",
params![id, now, next_attempt_at, PARK_UNTIL],
)?;
let since = conn
.query_row(
"SELECT access_deferred_since FROM pending_op WHERE id = ?1",
params![id],
|r| r.get::<_, i64>(0),
)
.optional()?;
// A row that drained out from under this call leaves nothing to measure;
// reporting `now` says "this run just started", which is the reading that
// never escalates something that no longer exists.
Ok(since.filter(|since| *since != 0).unwrap_or(now))
}
/// Forget an op's access-deferral window, after an attempt the access check
/// let through. The op is no longer blocked, so a later deferral is the
/// start of a new run rather than the continuation of an old one.
pub fn clear_op_access_deferral(&self, id: i64) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"UPDATE pending_op SET access_deferred_since = 0
WHERE id = ?1 AND access_deferred_since <> 0",
params![id],
)?;
Ok(())
}
/// Every staged blob the queue still refers to.
///
/// The startup staging reconcile subtracts this from what is actually in
/// `staging/`: the difference is bytes the kernel was told were written and
/// that nothing is going to upload.
pub fn op_blob_paths(&self) -> Result<std::collections::HashSet<String>> {
let conn = self.read();
let mut stmt =
conn.prepare("SELECT blob_path FROM pending_op WHERE blob_path IS NOT NULL")?;
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
let mut out = std::collections::HashSet::new();
for row in rows {
out.insert(row?);
}
Ok(out)
}
/// Record that this daemon sealed `revision_id` on `uid`, and prune entries
/// past [`OWN_SEALED_TTL_MS`].
///
/// One row per node, overwritten as the node advances. See
/// [`own_sealed_rev`](Self::own_sealed_rev) for what reads it.
pub fn set_own_sealed_rev(&self, uid: &str, revision_id: &str, now_ms: i64) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"INSERT INTO own_sealed_rev (uid, revision_id, sealed_at)
VALUES (?1, ?2, ?3)
ON CONFLICT(uid) DO UPDATE SET
revision_id = excluded.revision_id,
sealed_at = excluded.sealed_at",
params![uid, revision_id, now_ms],
)?;
conn.execute(
"DELETE FROM own_sealed_rev WHERE sealed_at < ?1",
params![now_ms - OWN_SEALED_TTL_MS],
)?;
Ok(())
}
/// The last revision of `uid` this daemon sealed itself, if it is still
/// within [`OWN_SEALED_TTL_MS`].
///
/// The drain asks before treating a moved-on remote as a conflict: a
/// revision we sealed means one writer stalled and resumed, which
/// supersedes rather than forking a `(sync-conflict)` copy (docs/BUGS.md
/// B70 layer B).
pub fn own_sealed_rev(&self, uid: &str, now_ms: i64) -> Result<Option<String>> {
let conn = self.read();
Ok(conn
.query_row(
"SELECT revision_id FROM own_sealed_rev
WHERE uid = ?1 AND sealed_at >= ?2",
params![uid, now_ms - OWN_SEALED_TTL_MS],
|r| r.get(0),
)
.optional()?)
}
/// Forget what we sealed on `uid` — it has been trashed, so there is no
/// revision left to chain onto.
pub fn clear_own_sealed_rev(&self, uid: &str) -> Result<()> {
let conn = self.conn.lock();
conn.execute("DELETE FROM own_sealed_rev WHERE uid = ?1", params![uid])?;
Ok(())
}
}
/// How long a revision this daemon sealed stays recognisable as its own.
///
/// Bounds a table that would otherwise grow with every write for the life of
/// the install. A week is far longer than any stall→resume — a browser download
/// that pauses and continues, an editor that reopens its handle — and short
/// enough that the table tracks recent work rather than accumulating history.
pub const OWN_SEALED_TTL_MS: i64 = 7 * 24 * 60 * 60 * 1000;
+653
View File
@@ -0,0 +1,653 @@
//! The photos timeline: a flat, date-ordered projection of the photo share,
//! persisted so the gallery opens instantly instead of re-enumerating on launch.
use rusqlite::params;
use std::collections::HashMap;
use super::Db;
use crate::Result;
/// A photo whose thumbnail state is not known yet: it has never been asked for.
pub const THUMB_UNKNOWN: i64 = 0;
/// A thumbnail exists for this photo — served by the server, or generated locally
/// from the full file when the server had none.
pub const THUMB_HAVE: i64 = 1;
/// This photo can never be given a thumbnail: the server has none and the bytes
/// could not be decoded locally either. Never retried.
pub const THUMB_NONE: i64 = 2;
/// One photo of the persisted timeline. The timeline itself is server-ordered
/// (newest first) and stored with that order in `seq`; `ratio` and `thumb_state`
/// are locally learned and survive a refresh of the timeline.
#[derive(Clone, Debug, PartialEq)]
pub struct StoredPhoto {
pub uid: String,
pub capture_time: i64,
pub name: Option<String>,
/// Aspect ratio (w/h), known once a thumbnail has been decoded.
pub ratio: Option<f64>,
/// One of [`THUMB_UNKNOWN`] / [`THUMB_HAVE`] / [`THUMB_NONE`].
pub thumb_state: i64,
/// Which Photos-page tab this entry belongs to, derived from its name and
/// media type when the timeline was last replaced.
pub kind: crate::control::PhotoKind,
/// Whether the photo carries Proton's `Favorite` tag.
pub favorite: bool,
/// How many photos this photo's group holds — one shot taken as RAW+JPEG is
/// two files and one group. `1` for a photo that stands on its own.
pub group_size: usize,
/// Whether any member of the group is a camera raw file. The grid badges
/// that, because the JPEG it shows is not the whole of what is stored.
pub has_raw: bool,
}
/// One row of a timeline replacement. Everything but the uid and the capture
/// time is "what this refresh learned": a `None` keeps whatever is already
/// stored rather than clearing it, because the timeline DTO carries only the uid
/// and the capture time and resolving the node can fail.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct TimelineRow {
pub uid: String,
pub capture_time: i64,
pub name: Option<String>,
pub media_type: Option<String>,
pub favorite: Option<bool>,
/// The server's duplicate-detection hash for the plaintext.
pub content_hash: Option<String>,
/// The main photo this one is *related* to (a live-photo video, a burst
/// sibling), when the server says it belongs to one.
pub main_uid: Option<String>,
/// When this refresh resolved the photo's node, if it did at all. `Some`
/// makes every other field authoritative — including a `None` that means
/// "the server no longer reports one" — while `None` means the refresh
/// skipped this photo and whatever is stored must be kept.
pub resolved_at: Option<i64>,
}
impl TimelineRow {
/// A row carrying only what the timeline listing itself gives.
pub fn new(uid: impl Into<String>, capture_time: i64) -> Self {
Self {
uid: uid.into(),
capture_time,
..Self::default()
}
}
}
/// What a previous refresh learned about a photo and a new one must not lose.
#[derive(Clone, Debug, Default)]
struct Learned {
name: Option<String>,
ratio: Option<f64>,
thumb_state: i64,
media_type: Option<String>,
favorite: bool,
content_hash: Option<String>,
main_uid: Option<String>,
/// When this photo's node was last resolved, kept so a refresh that skips it
/// does not make it look unresolved again.
resolved_at: Option<i64>,
}
/// The local calendar day a capture time falls on, as a `YYYY-MM-DD` string.
/// Grouping by day rather than by exact second is deliberate: a camera writes
/// the RAW and the JPEG of one shot a moment apart, and two shots of the same
/// subject on different days are not one photo.
fn capture_day(conn: &rusqlite::Connection, capture_time: i64) -> Option<String> {
conn.query_row(
"SELECT strftime('%Y-%m-%d', ?1, 'unixepoch', 'localtime')",
params![capture_time],
|r| r.get::<_, Option<String>>(0),
)
.ok()
.flatten()
}
/// The file name without its extension, lowercased — `IMG_1234.CR2` and
/// `IMG_1234.JPG` share `img_1234` — with the camera's own part-of-a-shot
/// marker removed, so the two files a raw capture writes share a stem too.
fn name_stem(name: Option<&str>) -> Option<String> {
let name = name?;
let stem = match name.rsplit_once('.') {
Some((stem, _)) if !stem.is_empty() => stem,
_ => name,
};
let stem = stem.to_ascii_lowercase();
Some(strip_shot_marker(&stem).to_string())
}
/// Drop a `.RAW-NN.ROLE` marker from a stem.
///
/// Pixel stores one raw capture as `PXL_20260919_000625670.RAW-01.COVER.jpg`
/// and `PXL_20260919_000625670.RAW-02.ORIGINAL.dng`: one shot, two files, two
/// stems that differ in exactly the part naming which file of the shot this is.
/// Cutting the marker off is what lets rule 2 see the pair. The check is narrow
/// on purpose — digits, then a single role word — so an ordinary name that
/// happens to contain `.raw-` keeps its stem.
fn strip_shot_marker(stem: &str) -> &str {
let Some(at) = stem.rfind(".raw-") else {
return stem;
};
let rest = &stem[at + ".raw-".len()..];
let Some((index, role)) = rest.split_once('.') else {
return stem;
};
let marked = !index.is_empty()
&& index.bytes().all(|b| b.is_ascii_digit())
&& !role.is_empty()
&& role.bytes().all(|b| b.is_ascii_alphabetic());
if marked { &stem[..at] } else { stem }
}
/// One entry of the grouping pass: what rule 2 needs, plus what choosing a
/// representative needs.
struct Grouped {
uid: String,
kind: crate::control::PhotoKind,
main_uid: Option<String>,
day: Option<String>,
stem: Option<String>,
}
/// Which group each photo belongs to, and which member of it the gallery shows.
///
/// Precedence, as agreed:
///
/// 1. The server's own relation — a photo with a `main_photo_uid` joins that
/// photo's group. The server knows about live photos and bursts; we do not
/// have to guess at them.
/// 2. Otherwise, same capture day **and** same file-name stem **and** one of the
/// two is a camera raw while the other is not. That is the RAW+JPEG pair a
/// camera writes, and the differing-kind clause keeps two JPEGs that happen
/// to share a name apart.
/// 3. Otherwise the photo is its own group.
///
/// The representative is the non-raw member when the group has one — a JPEG
/// decodes in milliseconds and is what the person expects to see — and the
/// earliest member in server order otherwise.
fn group_photos(entries: &[Grouped]) -> Vec<String> {
let mut parent: Vec<usize> = (0..entries.len()).collect();
fn find(parent: &mut [usize], mut i: usize) -> usize {
while parent[i] != i {
parent[i] = parent[parent[i]];
i = parent[i];
}
i
}
let union = |parent: &mut [usize], a: usize, b: usize| {
let (a, b) = (find(parent, a), find(parent, b));
if a != b {
parent[b] = a;
}
};
let index: HashMap<&str, usize> = entries
.iter()
.enumerate()
.map(|(i, e)| (e.uid.as_str(), i))
.collect();
for (i, entry) in entries.iter().enumerate() {
if let Some(main) = entry.main_uid.as_deref()
&& let Some(&j) = index.get(main)
{
union(&mut parent, j, i);
}
}
// Rule 2 needs only one pass: everything sharing a day and a stem lands in
// the same bucket, and the raw / non-raw split is checked pairwise there.
let mut by_name: HashMap<(&str, &str), Vec<usize>> = HashMap::new();
for (i, entry) in entries.iter().enumerate() {
if let (Some(day), Some(stem)) = (entry.day.as_deref(), entry.stem.as_deref()) {
by_name.entry((day, stem)).or_default().push(i);
}
}
let is_raw = |i: usize| entries[i].kind == crate::control::PhotoKind::Raw;
for bucket in by_name.values() {
for (n, &i) in bucket.iter().enumerate() {
for &j in &bucket[n + 1..] {
if is_raw(i) != is_raw(j) {
union(&mut parent, i, j);
}
}
}
}
// Representative per root, then the key every member carries.
let mut representative: HashMap<usize, usize> = HashMap::new();
for i in 0..entries.len() {
let root = find(&mut parent, i);
match representative.get(&root).copied() {
Some(current) if !is_raw(current) || is_raw(i) => {}
_ => {
representative.insert(root, i);
}
}
}
(0..entries.len())
.map(|i| {
let root = find(&mut parent, i);
entries[representative[&root]].uid.clone()
})
.collect()
}
/// The columns every photo read selects, with the group aggregates joined in.
/// `g.n` is how many photos the group holds and `g.raw` whether any of them is a
/// camera raw file — one query rather than a second lookup per tile.
///
/// `COALESCE(group_key, uid)` is what makes a row written before schema v30 its
/// own group: the column is `NULL` until the next refresh computes it, and a
/// `NULL` would otherwise match nothing and drop the photo off the page.
const PHOTO_SELECT: &str = "SELECT p.uid, p.capture_time, p.name, p.ratio, p.thumb_state, \
p.kind, p.favorite, g.n, g.raw \
FROM photos p JOIN (SELECT COALESCE(group_key, uid) AS k, COUNT(*) AS n, \
MAX(kind = 2) AS raw FROM photos GROUP BY k) g ON g.k = COALESCE(p.group_key, p.uid)";
/// Read one row of [`PHOTO_SELECT`].
fn stored_photo(r: &rusqlite::Row<'_>) -> rusqlite::Result<StoredPhoto> {
Ok(StoredPhoto {
uid: r.get(0)?,
capture_time: r.get(1)?,
name: r.get(2)?,
ratio: r.get(3)?,
thumb_state: r.get(4)?,
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
favorite: r.get::<_, i64>(6)? != 0,
group_size: r.get::<_, i64>(7)?.max(1) as usize,
has_raw: r.get::<_, i64>(8)? != 0,
})
}
impl Db {
/// Replace the timeline wholesale. A row whose `resolved_at` is `Some` was
/// read from the server in this pass, so its fields are taken as they are —
/// a `None` there means the server no longer reports that value. A row whose
/// `resolved_at` is `None` was skipped or failed to resolve, and keeps
/// whatever is already stored rather than having it silently cleared.
pub fn photos_replace(&self, items: &[TimelineRow]) -> Result<()> {
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
// `name` and `media_type` are learned-and-kept like the ratio and thumb
// verdict: the timeline DTO carries only the uid and capture time, so the
// daemon may not know them when it replaces the timeline — because the
// refresh skipped an already-resolved photo, or because resolving it
// failed. Keep any previously learned value so the tab split and the
// RAW+JPEG grouping survive a refresh instead of collapsing to nothing:
// both are derived from the name. The photo relation is kept for the same
// reason — losing it would break a group up until the next resolve.
let learned: HashMap<String, Learned> = {
let mut stmt = tx.prepare(
"SELECT uid, name, ratio, thumb_state, media_type, favorite, content_hash, \
main_uid, resolved_at FROM photos",
)?;
let rows = stmt.query_map([], |r| {
Ok((
r.get::<_, String>(0)?,
Learned {
name: r.get(1)?,
ratio: r.get(2)?,
thumb_state: r.get(3)?,
media_type: r.get(4)?,
favorite: r.get::<_, i64>(5)? != 0,
content_hash: r.get(6)?,
main_uid: r.get(7)?,
resolved_at: r.get(8)?,
},
))
})?;
rows.collect::<rusqlite::Result<_>>()?
};
// Everything the insert and the grouping need, resolved once per photo.
let resolved: Vec<(&TimelineRow, Learned, crate::control::PhotoKind)> = items
.iter()
.map(|row| {
let mut carried = learned.get(&row.uid).cloned().unwrap_or(Learned {
thumb_state: THUMB_UNKNOWN,
..Learned::default()
});
if row.resolved_at.is_some() {
// This pass read the node itself, so what it says is the
// truth — including what it no longer says. A photo unlinked
// from its main on another device loses its `main_uid` here,
// which a carry could never express.
carried.name = row.name.clone();
carried.media_type = row.media_type.clone();
carried.favorite = row.favorite.unwrap_or(false);
carried.content_hash = row.content_hash.clone();
carried.main_uid = row.main_uid.clone();
} else {
carried.name = row.name.clone().or(carried.name);
carried.media_type = row.media_type.clone().or(carried.media_type);
carried.favorite = row.favorite.unwrap_or(carried.favorite);
carried.content_hash = row.content_hash.clone().or(carried.content_hash);
carried.main_uid = row.main_uid.clone().or(carried.main_uid);
}
carried.resolved_at = row.resolved_at.or(carried.resolved_at);
// The tab this photo lands in is derived here, once, so a page or
// count query is a plain indexed `WHERE kind = ?` rather than a
// reclassification of every row.
let kind = crate::control::PhotoKind::classify(
carried.name.as_deref(),
carried.media_type.as_deref(),
);
(row, carried, kind)
})
.collect();
let entries: Vec<Grouped> = resolved
.iter()
.map(|(row, carried, kind)| Grouped {
uid: row.uid.clone(),
kind: *kind,
main_uid: carried.main_uid.clone(),
day: capture_day(&tx, row.capture_time),
stem: name_stem(carried.name.as_deref()),
})
.collect();
let group_keys = group_photos(&entries);
tx.execute("DELETE FROM photos", [])?;
{
let mut stmt = tx.prepare(
"INSERT INTO photos
(uid, capture_time, name, ratio, thumb_state, seq, media_type, kind, favorite,
content_hash, main_uid, group_key, resolved_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
)?;
for (seq, ((row, carried, kind), group_key)) in
resolved.iter().zip(group_keys.iter()).enumerate()
{
stmt.execute(params![
row.uid,
row.capture_time,
carried.name,
carried.ratio,
carried.thumb_state,
seq as i64,
carried.media_type,
kind.as_i64(),
carried.favorite as i64,
carried.content_hash,
carried.main_uid,
group_key,
carried.resolved_at,
])?;
}
}
tx.commit()?;
Ok(())
}
/// The uids whose node metadata is already resolved, so a refresh can skip
/// reading their nodes again. Everything else — a photo never seen before, or
/// one a remote event marked stale — is resolved. That is what keeps a
/// 12k-photo library off 63 round-trips of `enumerate_nodes` every refresh.
pub fn photos_resolved(&self) -> Result<Vec<String>> {
let conn = self.read();
let mut stmt = conn.prepare("SELECT uid FROM photos WHERE resolved_at IS NOT NULL")?;
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
let mut uids = Vec::new();
for row in rows {
uids.push(row?);
}
Ok(uids)
}
/// Mark photos as needing a fresh metadata resolve, and report how many rows
/// that matched. This is how a remote change is recorded: the event feed says
/// *which* node changed but never *what* changed about it, so the uid is
/// written down and the next refresh reads the node.
pub fn photos_mark_unresolved(&self, uids: &[String]) -> Result<usize> {
if uids.is_empty() {
return Ok(0);
}
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
let mut marked = 0;
{
let mut stmt = tx.prepare("UPDATE photos SET resolved_at = NULL WHERE uid = ?1")?;
for uid in uids {
marked += stmt.execute(params![uid])?;
}
}
tx.commit()?;
Ok(marked)
}
/// The same for the whole timeline: every photo is resolved again on the next
/// refresh. For a continuity loss, where the daemon cannot know what it
/// missed, and for an explicit `refresh photos --full`.
pub fn photos_mark_all_unresolved(&self) -> Result<usize> {
let conn = self.conn.lock();
Ok(conn.execute("UPDATE photos SET resolved_at = NULL", [])?)
}
/// Forget photos that have been trashed, so the gallery does not show them
/// until the next timeline refresh would have caught up.
///
/// Album membership goes with them: a trashed photo is gone from every album
/// that held it, and leaving the rows behind would keep it on the album
/// pages and in the album covers.
pub fn photos_delete(&self, uids: &[String]) -> Result<usize> {
if uids.is_empty() {
return Ok(0);
}
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
let mut removed = 0;
{
let mut photos = tx.prepare("DELETE FROM photos WHERE uid = ?1")?;
let mut albums = tx.prepare("DELETE FROM album_photos WHERE uid = ?1")?;
for uid in uids {
removed += photos.execute([uid])?;
albums.execute([uid])?;
}
}
tx.commit()?;
Ok(removed)
}
/// One page of the persisted timeline, newest first. `kind`, when set,
/// restricts the page to one tab (Photos / Videos / Raw); `range`, when set,
/// restricts it to a `[from, to)` capture-time window (epoch seconds) — the
/// date scrubber's jump. `offset` is relative to whatever the filters leave.
/// `favorites` restricts the page to favourited photos.
///
/// The page holds one row per *group*: the RAW and the JPEG of one shot are
/// one tile. The Raw tab is the exception and lists every raw file, because
/// that tab exists to answer "which raws do I have".
pub fn photos_page(
&self,
offset: usize,
limit: usize,
kind: Option<crate::control::PhotoKind>,
range: Option<(i64, i64)>,
favorites: bool,
) -> Result<Vec<StoredPhoto>> {
let conn = self.read();
// Built up so any combination of the optional filters is one indexed
// query rather than a statement per case.
let mut sql = String::from(PHOTO_SELECT);
let mut binds: Vec<i64> = Vec::new();
let mut conds: Vec<String> = Vec::new();
if kind != Some(crate::control::PhotoKind::Raw) {
conds.push("p.uid = COALESCE(p.group_key, p.uid)".to_string());
}
if let Some(k) = kind {
binds.push(k.as_i64());
conds.push(format!("p.kind = ?{}", binds.len()));
}
if let Some((from, to)) = range {
binds.push(from);
conds.push(format!("p.capture_time >= ?{}", binds.len()));
binds.push(to);
conds.push(format!("p.capture_time < ?{}", binds.len()));
}
if favorites {
conds.push("p.favorite = 1".to_string());
}
if !conds.is_empty() {
sql.push_str(" WHERE ");
sql.push_str(&conds.join(" AND "));
}
binds.push(limit as i64);
let limit_pos = binds.len();
binds.push(offset as i64);
let offset_pos = binds.len();
sql.push_str(&format!(
" ORDER BY p.seq LIMIT ?{limit_pos} OFFSET ?{offset_pos}"
));
let mut stmt = conn.prepare(&sql)?;
let rows = stmt
.query_map(rusqlite::params_from_iter(binds), stored_photo)?
.collect::<rusqlite::Result<_>>()?;
Ok(rows)
}
/// Every photo of one photo's group, in server order — the RAW and the JPEG
/// of a shot, or just the photo itself when it stands alone. The lightbox
/// switches between these, and deleting a tile trashes all of them.
pub fn photos_group(&self, uid: &str) -> Result<Vec<StoredPhoto>> {
let conn = self.read();
let sql = format!(
"{PHOTO_SELECT} WHERE COALESCE(p.group_key, p.uid) = \
(SELECT COALESCE(group_key, uid) FROM photos WHERE uid = ?1) ORDER BY p.seq"
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt
.query_map(params![uid], stored_photo)?
.collect::<rusqlite::Result<_>>()?;
Ok(rows)
}
/// The whole persisted timeline, in stored order — every file, not one row
/// per group. Only for whole-library passes (the photo re-date); the gallery
/// pages with [`Db::photos_page`].
pub fn photos_all(&self) -> Result<Vec<StoredPhoto>> {
let conn = self.read();
let sql = format!("{PHOTO_SELECT} ORDER BY p.seq");
let mut stmt = conn.prepare(&sql)?;
let rows = stmt
.query_map([], stored_photo)?
.collect::<rusqlite::Result<_>>()?;
Ok(rows)
}
/// The months the timeline spans, newest first, each with how many photos it
/// holds — the data behind the date scrubber. Buckets are local-time
/// `(year, month)` so they line up with the day headings the gallery draws
/// and with the boundaries a front-end computes when it jumps to one. `kind`
/// scopes the counts to one tab when set.
pub fn photos_months(
&self,
kind: Option<crate::control::PhotoKind>,
) -> Result<Vec<(i32, i32, usize)>> {
let conn = self.read();
// The scrubber has to agree with the page it scrubs, so it counts what
// the page shows: groups everywhere except on the Raw tab.
let (filter, binds): (&str, Vec<i64>) = match kind {
Some(crate::control::PhotoKind::Raw) => (" WHERE kind = 2", Vec::new()),
Some(k) => (
" WHERE uid = COALESCE(group_key, uid) AND kind = ?1",
vec![k.as_i64()],
),
None => (" WHERE uid = COALESCE(group_key, uid)", Vec::new()),
};
let sql = format!(
"SELECT CAST(strftime('%Y', capture_time, 'unixepoch', 'localtime') AS INTEGER) AS y, \
CAST(strftime('%m', capture_time, 'unixepoch', 'localtime') AS INTEGER) AS m, \
COUNT(*) \
FROM photos{filter} GROUP BY y, m ORDER BY y DESC, m DESC"
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt
.query_map(rusqlite::params_from_iter(binds), |r| {
Ok((
r.get::<_, i32>(0)?,
r.get::<_, i32>(1)?,
r.get::<_, i64>(2)? as usize,
))
})?
.collect::<rusqlite::Result<_>>()?;
Ok(rows)
}
/// Per-tab counts for the Photos page subtitle: `(photos, videos, raw)`.
pub fn photos_counts(&self) -> Result<(usize, usize, usize)> {
use crate::control::PhotoKind;
let conn = self.read();
// Photos and Videos count groups — one shot is one entry, however many
// files it was stored as. Raw counts files, because the Raw tab lists
// them individually.
let mut stmt = conn.prepare(
"SELECT kind, COUNT(*) FROM photos \
WHERE uid = COALESCE(group_key, uid) OR kind = 2 GROUP BY kind",
)?;
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?;
let (mut photos, mut videos, mut raw) = (0usize, 0usize, 0usize);
for row in rows {
let (kind, n) = row?;
match PhotoKind::from_i64(kind) {
PhotoKind::Photo => photos = n as usize,
PhotoKind::Video => videos = n as usize,
PhotoKind::Raw => raw = n as usize,
}
}
Ok((photos, videos, raw))
}
/// The stored photos for `uids`, in no particular order. Used by the thumbnail
/// path, which needs each photo's capture time (the cache validity tag) and
/// its thumbnail verdict.
pub fn photos_by_uid(&self, uids: &[String]) -> Result<Vec<StoredPhoto>> {
if uids.is_empty() {
return Ok(Vec::new());
}
let conn = self.read();
let placeholders = vec!["?"; uids.len()].join(",");
let sql = format!("{PHOTO_SELECT} WHERE p.uid IN ({placeholders})");
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(uids), stored_photo)?;
let mut photos = Vec::new();
for row in rows {
photos.push(row?);
}
Ok(photos)
}
/// Record a photo's favourite flag locally, after the server accepted the
/// change. A uid the timeline does not hold is a no-op — an album photo on
/// someone else's volume is never in our own `photos` table.
pub fn photos_set_favorite(&self, uid: &str, favorite: bool) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"UPDATE photos SET favorite = ?2 WHERE uid = ?1",
params![uid, favorite as i64],
)?;
Ok(())
}
/// Record what a thumbnail attempt learned: whether the photo now has one
/// ([`THUMB_HAVE`] / [`THUMB_NONE`]), and its aspect ratio if the pixels were
/// seen. A `None` ratio leaves any previously learned one alone.
pub fn photo_set_thumb(&self, uid: &str, state: i64, ratio: Option<f64>) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"UPDATE photos SET thumb_state = ?2, ratio = COALESCE(?3, ratio) WHERE uid = ?1",
params![uid, state, ratio],
)?;
Ok(())
}
/// Number of photos in the persisted timeline.
pub fn photos_count(&self) -> Result<usize> {
let conn = self.read();
let n: i64 = conn.query_row("SELECT COUNT(*) FROM photos", [], |r| r.get(0))?;
Ok(n.max(0) as usize)
}
}
+150
View File
@@ -0,0 +1,150 @@
//! Pinned nodes: files and folders the user has asked to keep on disk, exempt
//! from LRU eviction. A recursive pin covers a whole subtree.
use rusqlite::{OptionalExtension, params};
use super::Db;
use super::utils::MAX_PATH_DEPTH;
use crate::Result;
/// One row of [`Db::pin_list`]. `recursive` is pin *policy* — whether the whole
/// subtree is kept — while `is_dir` is the node's kind as the node cache knows
/// it, or `None` for a pin whose node is not cached. Reading the former as the
/// latter is what made a non-recursively pinned folder render as a file.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PinRow {
pub uid: String,
pub path: String,
pub recursive: bool,
pub is_dir: Option<bool>,
}
impl Db {
pub fn pin_add(&self, uid: &str, path: &str, recursive: bool) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"INSERT INTO pins (uid, path, recursive) VALUES (?1, ?2, ?3)
ON CONFLICT(uid) DO UPDATE SET
path = excluded.path, recursive = excluded.recursive",
params![uid, path, recursive as i64],
)?;
Ok(())
}
/// Drop `uid`'s pin row. Returns whether a row existed.
pub fn pin_remove(&self, uid: &str) -> Result<bool> {
let conn = self.conn.lock();
let n = conn.execute("DELETE FROM pins WHERE uid = ?1", params![uid])?;
Ok(n > 0)
}
/// Every directly-pinned entry, ordered by uid.
pub fn pin_list(&self) -> Result<Vec<PinRow>> {
let conn = self.read();
// `recursive` is pin policy; the node's kind comes from `nodes`, and is
// NULL for a pin whose node was never cached.
let mut stmt = conn.prepare(
"SELECT p.uid, p.path, p.recursive, n.is_dir
FROM pins p LEFT JOIN nodes n ON n.uid = p.uid
ORDER BY p.uid",
)?;
let rows = stmt.query_map([], |r| {
Ok(PinRow {
uid: r.get(0)?,
path: r.get(1)?,
recursive: r.get::<_, i64>(2)? != 0,
is_dir: r.get::<_, Option<i64>>(3)?.map(|is_dir| is_dir != 0),
})
})?;
let mut out = Vec::new();
for r in rows {
out.push(r?);
}
Ok(out)
}
/// Whether `uid` is pinned — directly, or because a strict ancestor folder
/// is pinned recursively. The ancestor check walks `parent_uid` to the root
/// via a CTE; a direct pin is honoured even when the node has no `nodes` row
/// yet (e.g. a CLI that never hydrates the node cache).
///
/// The walk is depth-capped like [`super::utils::path_of`]: a `parent_uid`
/// cycle is corrupt data the API can hand us, and `UNION ALL` over one never
/// terminates — while holding the daemon's only SQLite connection, on a path
/// that runs per cached read.
pub fn is_pinned(&self, uid: &str) -> Result<bool> {
let conn = self.read();
let direct: Option<i64> = conn
.query_row("SELECT 1 FROM pins WHERE uid = ?1", params![uid], |r| {
r.get(0)
})
.optional()?;
if direct.is_some() {
return Ok(true);
}
let anc: Option<i64> = conn
.query_row(
&format!(
"WITH RECURSIVE anc(uid, parent_uid, depth) AS (
SELECT uid, parent_uid, 0 FROM nodes WHERE uid = ?1
UNION ALL
SELECT n.uid, n.parent_uid, anc.depth + 1
FROM nodes n JOIN anc ON n.uid = anc.parent_uid
WHERE anc.depth < {MAX_PATH_DEPTH}
)
SELECT 1 FROM anc a JOIN pins p ON p.uid = a.uid
WHERE a.uid != ?1 AND p.recursive = 1
LIMIT 1"
),
params![uid],
|r| r.get(0),
)
.optional()?;
Ok(anc.is_some())
}
/// Every uid that is pinned, directly or transitively: all direct pins plus
/// every descendant of a recursively-pinned folder. Used to build the
/// eviction-exempt set (the budget enforcer hashes these into cache keys).
pub fn pinned_uids(&self) -> Result<Vec<String>> {
let conn = self.read();
let mut stmt = conn.prepare(
"WITH RECURSIVE sub(uid) AS (
SELECT uid FROM pins WHERE recursive = 1
UNION
SELECT n.uid FROM nodes n JOIN sub ON n.parent_uid = sub.uid
)
SELECT uid FROM sub
UNION
SELECT uid FROM pins",
)?;
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
let mut out = Vec::new();
for r in rows {
out.push(r?);
}
Ok(out)
}
/// Every descendant uid of `folder` (all depths), via a `parent_uid` CTE.
/// Used to evict a recursively-pinned subtree's cached blobs on unpin.
pub fn descendants(&self, folder: &str) -> Result<Vec<String>> {
let conn = self.read();
let mut stmt = conn.prepare(
"WITH RECURSIVE sub(uid) AS (
SELECT uid FROM nodes WHERE parent_uid = ?1
UNION
SELECT n.uid FROM nodes n JOIN sub ON n.parent_uid = sub.uid
)
SELECT uid FROM sub",
)?;
let rows = stmt.query_map(params![folder], |r| r.get::<_, String>(0))?;
let mut out = Vec::new();
for r in rows {
out.push(r?);
}
Ok(out)
}
// ---- local (non-Drive) file index (schema v6) ----
}
+143
View File
@@ -0,0 +1,143 @@
//! Persisted effective access for roots shared with this account.
use std::collections::HashMap;
use proton_drive_rs::proton_sdk::ids::{LinkId, NodeUid, VolumeId};
use rusqlite::{OptionalExtension, params};
use super::Db;
use crate::{Access, Error, Result};
impl Db {
/// Load the complete, deliberately small shared-root authority map.
///
/// Mount state keeps this resident so ordinary node interning and subtree
/// propagation never turn into one SQLite query per inode. The database
/// remains the restart/offline authority and every explicit role change is
/// written through before this cache is updated.
pub fn all_share_access(&self) -> Result<HashMap<NodeUid, Access>> {
let conn = self.read();
let mut stmt = conn.prepare("SELECT root_uid, access FROM share_access")?;
let rows = stmt.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?;
let mut access = HashMap::new();
for row in rows {
let (uid, value) = row?;
let (volume, link) = uid.split_once('~').ok_or_else(|| {
Error::Other(format!(
"invalid shared-root uid {uid:?} stored in database"
))
})?;
let access_value = Access::from_db_str(&value).ok_or_else(|| {
Error::Other(format!("invalid share access {value:?} stored for {uid}"))
})?;
access.insert(
NodeUid::new(VolumeId::from(volume), LinkId::from(link)),
access_value,
);
}
Ok(access)
}
/// Read the effective access recorded for a shared-tree root.
pub fn share_access(&self, uid: &NodeUid) -> Result<Option<Access>> {
let conn = self.read();
let value = conn
.query_row(
"SELECT access FROM share_access WHERE root_uid = ?1",
[uid.to_string()],
|row| row.get::<_, String>(0),
)
.optional()?;
value
.map(|value| {
Access::from_db_str(&value).ok_or_else(|| {
Error::Other(format!("invalid share access {value:?} stored for {uid}"))
})
})
.transpose()
}
/// Insert or replace the effective access for a shared-tree root.
pub fn set_share_access(&self, uid: &NodeUid, access: Access) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"INSERT INTO share_access (root_uid, access) VALUES (?1, ?2)
ON CONFLICT(root_uid) DO UPDATE SET access = excluded.access",
params![uid.to_string(), access.as_db_str()],
)?;
Ok(())
}
/// Forget a shared-tree root's effective access.
pub fn delete_share_access(&self, uid: &NodeUid) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"DELETE FROM share_access WHERE root_uid = ?1",
[uid.to_string()],
)?;
Ok(())
}
/// Fail closed every persisted shared-tree authority.
///
/// Owned and device trees have no row in `share_access`, so this only
/// affects roots already identified as shared with this account.
pub fn downgrade_all_share_access(&self) -> Result<usize> {
let conn = self.conn.lock();
Ok(conn.execute(
"UPDATE share_access SET access = 'viewer' WHERE access != 'viewer'",
[],
)?)
}
/// Resolve a persisted node's effective access through its nearest recorded
/// share root. `None` means the node itself is not persisted, which callers
/// must not treat as owned: stale handles need to fail closed.
pub fn effective_node_access(&self, uid: &NodeUid) -> Result<Option<Access>> {
let conn = self.read();
let exists: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM nodes WHERE uid = ?1)",
[uid.to_string()],
|row| row.get(0),
)?;
if !exists {
return Ok(None);
}
let value = conn
.query_row(
"WITH RECURSIVE ancestors(uid, parent_uid, depth, path) AS (
SELECT uid, parent_uid, 0, char(31) || uid || char(31)
FROM nodes WHERE uid = ?1
UNION ALL
SELECT a.parent_uid, n.parent_uid, a.depth + 1,
a.path || a.parent_uid || char(31)
FROM ancestors a
LEFT JOIN nodes n ON n.uid = a.parent_uid
WHERE a.parent_uid IS NOT NULL
AND instr(
a.path,
char(31) || a.parent_uid || char(31)
) = 0
)
SELECT sa.access
FROM ancestors a JOIN share_access sa ON sa.root_uid = a.uid
ORDER BY a.depth ASC
LIMIT 1",
[uid.to_string()],
|row| row.get::<_, String>(0),
)
.optional()?;
value
.map(|value| {
Access::from_db_str(&value).ok_or_else(|| {
Error::Other(format!(
"invalid effective share access {value:?} for {uid}"
))
})
})
.transpose()
.map(|access| Some(access.unwrap_or(Access::Owner)))
}
}
+119
View File
@@ -0,0 +1,119 @@
//! The `state` key/value table: small scalars that outlive a mount — the volume
//! event cursor, listing freshness stamps, the local-index generation.
use rusqlite::{OptionalExtension, params};
use super::Db;
use crate::Result;
impl Db {
pub fn get_event_cursor(&self) -> Result<Option<String>> {
let conn = self.read();
let v = conn
.query_row(
"SELECT value FROM sync_state WHERE key = 'event_cursor'",
[],
|r| r.get::<_, String>(0),
)
.optional()?;
Ok(v)
}
/// Persist the incremental-sync cursor after a batch of events is applied.
pub fn set_event_cursor(&self, cursor: &str) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"INSERT INTO sync_state (key, value) VALUES ('event_cursor', ?1)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
params![cursor],
)?;
Ok(())
}
/// Read a `sync_state` value as a string.
pub fn state_str(&self, key: &str) -> Result<Option<String>> {
let conn = self.read();
let v = conn
.query_row(
"SELECT value FROM sync_state WHERE key = ?1",
params![key],
|r| r.get::<_, String>(0),
)
.optional()?;
Ok(v)
}
/// Write a `sync_state` string value.
pub fn set_state_str(&self, key: &str, value: &str) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"INSERT INTO sync_state (key, value) VALUES (?1, ?2)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
params![key, value],
)?;
Ok(())
}
/// Queue an op to be performed by the drain worker, returning its row id.
///
/// One *revision* op per node: a second write to the same file before the
/// first has drained replaces it, since the newer blob already contains
/// everything the older one did. The superseded blob's path is returned so
/// the caller can delete it.
///
/// Rows supersede only their own kind, and only the kinds
/// [`op_supersedes`] names. A create op for the same uid must survive —
/// dropping it would leave a file that exists nowhere but this machine with
/// nothing left to create it. (Writes to a node that is itself still queued
pub fn state_i64(&self, key: &str) -> Result<Option<i64>> {
let conn = self.read();
let v: Option<String> = conn
.query_row(
"SELECT value FROM sync_state WHERE key = ?1",
params![key],
|r| r.get(0),
)
.optional()?;
Ok(v.and_then(|v| v.parse().ok()))
}
/// Write a `sync_state` integer value.
pub fn set_state_i64(&self, key: &str, value: i64) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"INSERT INTO sync_state (key, value) VALUES (?1, ?2)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
params![key, value.to_string()],
)?;
Ok(())
}
/// Drop a `sync_state` key, so whatever it stamped counts as never fetched.
pub fn clear_state(&self, key: &str) -> Result<()> {
let conn = self.conn.lock();
conn.execute("DELETE FROM sync_state WHERE key = ?1", params![key])?;
Ok(())
}
/// Drop every `sync_state` key starting with `prefix` — the per-entity
/// stamps (one album, one shared folder) that are keyed by uid and so have
/// no fixed name to clear individually.
pub fn clear_state_prefix(&self, prefix: &str) -> Result<()> {
let conn = self.conn.lock();
// `\` escapes the LIKE wildcards, so a prefix containing `%` or `_`
// (a uid never does today, but this is not the place to rely on that)
// matches literally instead of sweeping unrelated keys.
let pattern = format!(
"{}%",
prefix
.replace('\\', "\\\\")
.replace('%', "\\%")
.replace('_', "\\_")
);
conn.execute(
"DELETE FROM sync_state WHERE key LIKE ?1 ESCAPE '\\'",
params![pattern],
)?;
Ok(())
}
}
+275
View File
@@ -0,0 +1,275 @@
//! Folder-sync bookkeeping: the folders this device mirrors, and the per-file
//! state each pass reconciles against.
use rusqlite::{OptionalExtension, params};
use super::Db;
use crate::Result;
use std::collections::HashMap;
pub struct StoredSyncFolder {
pub id: i64,
pub local_path: String,
pub remote_uid: String,
pub remote_share_id: String,
/// `mirror` (full local copy, two-way synced) or `ondemand` (FUSE mount).
pub mode: String,
/// A mode the user asked for that could not be applied on the spot — the
/// folder was mid-pass, or had un-uploaded changes. The engine applies it
/// once the folder is safe to switch, so the request is queued rather than
/// rejected. `None` when the folder is where the user wants it.
pub pending_mode: Option<String>,
/// `idle` | `syncing` | `error` | `conflict`.
pub state: String,
pub last_sync: i64,
}
/// One per-file sync baseline row: what a path looked like on both sides at the
/// last successful sync, so the next reconcile can tell which side changed
/// (devices.md Phase 2). `remote_rev`/`remote_hash` hold the remote signature —
/// its modification time and size as strings — since no cheap content hash is
/// exposed; change detection is `(mtime, size)` on each side.
#[derive(Clone, Debug, PartialEq)]
pub struct StoredSyncEntry {
pub rel_path: String,
pub remote_uid: Option<String>,
pub local_mtime: i64,
pub local_size: i64,
/// The same modification time to nanosecond precision, when it is known.
///
/// `local_mtime` alone cannot tell an edit that landed inside the same
/// second as the last sync — and left the file the same length — from no
/// edit at all, so those never uploaded (bugs.md B25). `None` on a row
/// written before schema 28, which the comparison reads as "compare whole
/// seconds", so upgrading does not make an entire mirror look changed.
pub local_mtime_ns: Option<i64>,
pub remote_rev: Option<String>,
pub remote_hash: Option<String>,
}
impl Db {
pub fn sync_folder_add(
&self,
local_path: &str,
remote_uid: &str,
remote_share_id: &str,
) -> Result<i64> {
let conn = self.conn.lock();
conn.execute(
"INSERT INTO sync_folder (local_path, remote_uid, remote_share_id)
VALUES (?1, ?2, ?3)",
params![local_path, remote_uid, remote_share_id],
)?;
Ok(conn.last_insert_rowid())
}
/// Every synced folder, oldest first.
pub fn sync_folder_list(&self) -> Result<Vec<StoredSyncFolder>> {
let conn = self.read();
let mut stmt = conn.prepare(
"SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync
FROM sync_folder ORDER BY id",
)?;
let rows = stmt.query_map([], |r| {
Ok(StoredSyncFolder {
id: r.get(0)?,
local_path: r.get(1)?,
remote_uid: r.get(2)?,
remote_share_id: r.get(3)?,
mode: r.get(4)?,
pending_mode: r.get(5)?,
state: r.get(6)?,
last_sync: r.get(7)?,
})
})?;
let mut items = Vec::new();
for row in rows {
items.push(row?);
}
Ok(items)
}
/// Look up one synced folder by id.
pub fn sync_folder_get(&self, id: i64) -> Result<Option<StoredSyncFolder>> {
let conn = self.read();
conn.query_row(
"SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync
FROM sync_folder WHERE id = ?1",
params![id],
|r| {
Ok(StoredSyncFolder {
id: r.get(0)?,
local_path: r.get(1)?,
remote_uid: r.get(2)?,
remote_share_id: r.get(3)?,
mode: r.get(4)?,
pending_mode: r.get(5)?,
state: r.get(6)?,
last_sync: r.get(7)?,
})
},
)
.optional()
.map_err(Into::into)
}
/// Remove a synced folder and its per-file baseline.
pub fn sync_folder_remove(&self, id: i64) -> Result<bool> {
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
tx.execute("DELETE FROM sync_entry WHERE folder_id = ?1", params![id])?;
let n = tx.execute("DELETE FROM sync_folder WHERE id = ?1", params![id])?;
tx.commit()?;
Ok(n > 0)
}
/// Update a synced folder's mode (`mirror`/`ondemand`). A queued request for
/// that mode is satisfied and cleared in the same write. An opposite pending
/// mode arrived after this transition started, so preserve it as the newer
/// last-request intent.
pub fn sync_folder_set_mode(&self, id: i64, mode: &str) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"UPDATE sync_folder
SET mode = ?2,
pending_mode = CASE WHEN pending_mode = ?2 THEN NULL ELSE pending_mode END
WHERE id = ?1",
params![id, mode],
)?;
Ok(())
}
/// Queue (or, with `None`, withdraw) a mode the folder should move to once it
/// is safe to switch.
pub fn sync_folder_set_pending_mode(&self, id: i64, mode: Option<&str>) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"UPDATE sync_folder SET pending_mode = ?2 WHERE id = ?1",
params![id, mode],
)?;
Ok(())
}
/// Withdraw a queued mode only if it is still the intent the caller
/// inspected. A newer request must survive an older settlement attempt.
pub fn sync_folder_clear_pending_mode_if(&self, id: i64, expected: &str) -> Result<bool> {
let conn = self.conn.lock();
let changed = conn.execute(
"UPDATE sync_folder SET pending_mode = NULL
WHERE id = ?1 AND pending_mode = ?2",
params![id, expected],
)?;
Ok(changed > 0)
}
/// Update a synced folder's state and stamp `last_sync` to now.
pub fn sync_folder_set_state(&self, id: i64, state: &str, last_sync: i64) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"UPDATE sync_folder SET state = ?2, last_sync = ?3 WHERE id = ?1",
params![id, state, last_sync],
)?;
Ok(())
}
/// The whole per-file sync baseline for a folder, keyed by relative path.
pub fn sync_entries(&self, folder_id: i64) -> Result<HashMap<String, StoredSyncEntry>> {
let conn = self.read();
let mut stmt = conn.prepare(
"SELECT rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash,
local_mtime_ns
FROM sync_entry WHERE folder_id = ?1",
)?;
let rows = stmt.query_map(params![folder_id], |r| {
Ok(StoredSyncEntry {
rel_path: r.get(0)?,
remote_uid: r.get(1)?,
local_mtime: r.get(2)?,
local_size: r.get(3)?,
remote_rev: r.get(4)?,
remote_hash: r.get(5)?,
local_mtime_ns: r.get(6)?,
})
})?;
let mut map = HashMap::new();
for row in rows {
let e = row?;
map.insert(e.rel_path.clone(), e);
}
Ok(map)
}
/// Whether `uid` is represented by a mirror location.
///
/// An on-demand location proves residency through its live inode state.
/// Mirror locations have no FUSE state, so their folder root comes from
/// `sync_folder` and descendants come from the last successful
/// `sync_entry` baseline.
pub fn mirror_contains_uid(&self, uid: &str) -> Result<bool> {
let conn = self.read();
conn.query_row(
"SELECT EXISTS(
SELECT 1
FROM sync_folder AS folder
LEFT JOIN sync_entry AS entry ON entry.folder_id = folder.id
WHERE folder.mode = 'mirror'
AND (folder.remote_uid = ?1 OR entry.remote_uid = ?1)
)",
params![uid],
|row| row.get(0),
)
.map_err(Into::into)
}
/// Insert or replace one baseline row.
pub fn sync_entry_upsert(&self, folder_id: i64, e: &StoredSyncEntry) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"INSERT INTO sync_entry
(folder_id, rel_path, remote_uid, local_mtime, local_size, remote_rev,
remote_hash, local_mtime_ns)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
ON CONFLICT(folder_id, rel_path) DO UPDATE SET
remote_uid = excluded.remote_uid,
local_mtime = excluded.local_mtime,
local_size = excluded.local_size,
remote_rev = excluded.remote_rev,
remote_hash = excluded.remote_hash,
local_mtime_ns = excluded.local_mtime_ns",
params![
folder_id,
e.rel_path,
e.remote_uid,
e.local_mtime,
e.local_size,
e.remote_rev,
e.remote_hash,
e.local_mtime_ns,
],
)?;
Ok(())
}
/// Drop one baseline row (a path that left the sync set on both sides).
pub fn sync_entry_remove(&self, folder_id: i64, rel_path: &str) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"DELETE FROM sync_entry WHERE folder_id = ?1 AND rel_path = ?2",
params![folder_id, rel_path],
)?;
Ok(())
}
/// Drop the entire baseline for a folder. Used when flipping ondemand→mirror:
/// the local tree was evicted, so the old baseline is stale and would make the
/// next reconcile mistake "locally deleted" for "must re-download". Clearing it
/// leaves an empty baseline + full remote = pure download (devices.md P3).
pub fn sync_entries_clear(&self, folder_id: i64) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"DELETE FROM sync_entry WHERE folder_id = ?1",
params![folder_id],
)?;
Ok(())
}
}
File diff suppressed because it is too large. Load diff
+85
View File
@@ -0,0 +1,85 @@
//! Trashed nodes, persisted so the trash view opens without a round-trip.
use rusqlite::params;
use super::Db;
use crate::Result;
pub struct StoredTrash {
pub uid: String,
pub name: String,
pub is_dir: bool,
pub size: i64,
pub mtime: i64,
/// The folder this node was trashed from, when it is known. `None` for a
/// row written before schema v29, and for a node whose parent the server
/// did not report.
pub parent_uid: Option<String>,
}
impl Db {
pub fn trash_replace(&self, items: &[StoredTrash]) -> Result<()> {
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
tx.execute("DELETE FROM trash", [])?;
{
let mut stmt = tx.prepare(
"INSERT INTO trash (uid, name, is_dir, size, mtime, parent_uid) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
)?;
for item in items {
stmt.execute(params![
item.uid,
item.name,
item.is_dir as i64,
item.size,
item.mtime,
item.parent_uid
])?;
}
}
tx.commit()?;
Ok(())
}
/// The persisted trash listing, folders first then by name — the order the
/// Trash page shows it in.
pub fn trash_list(&self) -> Result<Vec<StoredTrash>> {
let conn = self.read();
let mut stmt = conn.prepare(
"SELECT uid, name, is_dir, size, mtime, parent_uid FROM trash
ORDER BY is_dir DESC, name COLLATE NOCASE",
)?;
let rows = stmt.query_map([], |r| {
Ok(StoredTrash {
uid: r.get(0)?,
name: r.get(1)?,
is_dir: r.get::<_, i64>(2)? != 0,
size: r.get(3)?,
mtime: r.get(4)?,
parent_uid: r.get(5)?,
})
})?;
let mut items = Vec::new();
for row in rows {
items.push(row?);
}
Ok(items)
}
/// Every trashed node paired with the folder it was trashed from, for the
/// restore's tree walk. Kept separate from [`Db::trash_list`] because the
/// restore wants only the links, not the names, sizes or ordering.
pub fn trash_parents(&self) -> Result<Vec<(String, Option<String>)>> {
let conn = self.read();
let mut stmt = conn.prepare("SELECT uid, parent_uid FROM trash")?;
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?;
let mut pairs = Vec::new();
for row in rows {
pairs.push(row?);
}
Ok(pairs)
}
// ---- device sync (devices.md) -----------------------------------------
}
+103
View File
@@ -0,0 +1,103 @@
//! Small query helpers shared across the table modules.
use rusqlite::{Connection, OptionalExtension, params};
use crate::Result;
/// Below this length the trigram tokenizer indexes nothing (it needs 3-char
/// grams), so short queries fall back to a `LIKE` scan over `nodes.name`.
pub(super) const TRIGRAM_MIN: usize = 3;
/// One raw search row: the node's JSON, its uid, and its stored path — `None`
/// only for a row written before the `path` column existed, which
/// [`path_of`] repairs by walking.
pub(super) type HitRow = (String, String, Option<String>);
pub(super) fn hit_row(row: &rusqlite::Row) -> rusqlite::Result<HitRow> {
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
}
/// Drain a `query_map` of [`hit_row`] rows into a `Vec`, propagating row errors.
pub(super) fn collect_hits(
rows: impl Iterator<Item = rusqlite::Result<HitRow>>,
) -> Result<Vec<HitRow>> {
let mut out = Vec::new();
for r in rows {
out.push(r?);
}
Ok(out)
}
/// Escape `LIKE` wildcards in a user query so `%` and `_` match literally
/// (paired with `ESCAPE '\'` in the statement).
pub(super) fn like_escape(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('%', "\\%")
.replace('_', "\\_")
}
/// How deep the parent walk may go before it gives up. Real Drive trees are
/// nowhere near this; a parent *cycle* is unbounded, and this query is on the
/// path of every search result, including the `LIKE` lane that reads `nodes`
/// directly and so never passed through the index's cycle check.
pub(super) const MAX_PATH_DEPTH: usize = 256;
/// Append `name` to a parent's stored path. An empty parent path is the root,
/// whose name is the mount and so contributes nothing.
pub(super) fn join_path(parent_path: &str, name: &str) -> String {
if parent_path.is_empty() {
name.to_string()
} else {
format!("{parent_path}/{name}")
}
}
/// Resolve a node's mountpoint-relative path. The root (the node with no
/// parent) is excluded, so a top-level file `report.pdf` yields
/// `"report.pdf"`, not `"My Files/report.pdf"`.
///
/// Reads the stored `nodes.path`, which the write path maintains (see
/// `upsert_node_tx`). Falls back to [`walk_path_of`] for a row that predates the
/// column or was never written through — a search serving a keystroke used to
/// run that walk once per candidate, which is the reason the column exists.
pub(super) fn path_of(conn: &Connection, uid: &str) -> Result<String> {
let stored: Option<Option<String>> = conn
.query_row("SELECT path FROM nodes WHERE uid = ?1", params![uid], |r| {
r.get(0)
})
.optional()?;
match stored.flatten() {
Some(path) => Ok(path),
None => walk_path_of(conn, uid),
}
}
/// Resolve a path by walking `parent_uid` to the root via a recursive CTE.
///
/// A cycle in `parent_uid` is corrupt data the API can still hand us, and
/// `UNION ALL` over one never terminates — the walk is therefore depth-capped
/// and returns the truncated path rather than hanging the caller.
pub(super) fn walk_path_of(conn: &Connection, uid: &str) -> Result<String> {
let mut stmt = conn.prepare(&format!(
"WITH RECURSIVE anc(uid, parent_uid, name, depth) AS (
SELECT uid, parent_uid, name, 0 FROM nodes WHERE uid = ?1
UNION ALL
SELECT n.uid, n.parent_uid, n.name, anc.depth + 1
FROM nodes n JOIN anc ON n.uid = anc.parent_uid
WHERE anc.depth < {MAX_PATH_DEPTH}
)
SELECT name, parent_uid FROM anc ORDER BY depth DESC"
))?;
let rows = stmt.query_map(params![uid], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, Option<String>>(1)?))
})?;
let mut parts = Vec::new();
for r in rows {
let (name, parent_uid) = r?;
// Skip the root node (no parent); its name is the mount itself.
if parent_uid.is_some() {
parts.push(name);
}
}
Ok(parts.join("/"))
}
+297 -1
View File
@@ -1,14 +1,17 @@
//! Crate-wide error type.
use proton_sdk::api::HumanVerification;
use thiserror::Error;
use crate::control::ErrorKind;
#[derive(Debug, Error)]
pub enum Error {
#[error("proton api: {0}")]
Proton(#[from] proton_sdk::ProtonError),
#[error("keyring: {0}")]
Keyring(#[from] keyring::Error),
Keyring(#[from] keyring_core::Error),
#[error("serialization: {0}")]
Serde(#[from] serde_json::Error),
@@ -16,12 +19,305 @@ pub enum Error {
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("database: {0}")]
Db(#[from] rusqlite::Error),
/// No persisted session found — caller must run an interactive login.
#[error("not logged in (no saved session)")]
NotLoggedIn,
/// The stored session can no longer unlock the account key chain: reading
/// the key salts needs the `locked` scope, which a refreshed access token
/// does not have. Only a fresh password login restores it.
#[error("saved session can no longer unlock your keys — run `pdfs login` again")]
ReloginRequired,
/// The login was gated behind human verification. Not a failure the user can
/// fix by retrying: the challenge has to be solved and the login restarted
/// with the resulting token (see [`auth::login_verified`](crate::auth::login_verified)).
///
/// Carries the challenge rather than just a message so a front-end can
/// present it; a front-end that cannot (no webview) still has prose to show.
#[error("sign-in needs human verification — complete the CAPTCHA to continue")]
HumanVerificationRequired(Box<HumanVerification>),
#[error("{0}")]
Other(String),
}
impl Error {
/// Whether this is the local disk being full (`ENOSPC`), as opposed to any
/// other I/O failure.
///
/// Worth distinguishing because it is the one storage failure the user can
/// act on, and because the caller's response differs: a full disk is met by
/// evicting cached blobs and answering `ENOSPC`, where `EIO` would read as a
/// broken file. `std::io::ErrorKind::StorageFull` is still unstable, so this
/// compares the raw code.
pub fn is_disk_full(&self) -> bool {
match self {
Error::Io(e) => e.raw_os_error() == Some(libc::ENOSPC),
// SQLite reports it as its own code rather than surfacing the errno.
Error::Db(rusqlite::Error::SqliteFailure(e, _)) => {
e.code == rusqlite::ErrorCode::DiskFull
}
_ => false,
}
}
}
pub type Result<T> = std::result::Result<T, Error>;
/// A failure on its way to a front-end: prose for the user, plus the
/// [`ErrorKind`] the front-end actually branches on.
///
/// This is what the daemon's request-serving methods return, as opposed to
/// [`Error`] above, which is the plumbing error of the `pdfs-core` layer. The
/// two meet at the `From` impls below: a `rusqlite` or `io` failure carries no
/// information a user can act on, so it arrives here as
/// [`ErrorKind::Internal`] and keeps its text only for the log.
///
/// Classification happens where the failure is *understood* — at the API
/// boundary that knows a 404 from a 403, at the path resolver that knows an
/// `ENOENT` — never at the call site, which by then has only a string.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CoreError {
pub kind: ErrorKind,
pub message: String,
}
impl CoreError {
pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
}
}
/// The API is unreachable. Carries fixed wording because there is only ever
/// one thing to say about it.
pub fn offline() -> Self {
Self::new(ErrorKind::Offline, "the Proton Drive API is unreachable")
}
pub fn not_found(what: impl Into<String>) -> Self {
Self::new(ErrorKind::NotFound, what)
}
pub fn denied(what: impl Into<String>) -> Self {
Self::new(ErrorKind::Denied, what)
}
pub fn conflict(what: impl Into<String>) -> Self {
Self::new(ErrorKind::Conflict, what)
}
pub fn invalid(what: impl Into<String>) -> Self {
Self::new(ErrorKind::Invalid, what)
}
pub fn remote(what: impl Into<String>) -> Self {
Self::new(ErrorKind::Remote, what)
}
pub fn internal(what: impl Into<String>) -> Self {
Self::new(ErrorKind::Internal, what)
}
/// Classify a failure from an SDK call, prefixing `what` for the log.
///
/// Takes `&dyn Error` rather than `&ProtonError` so it reads the same
/// through the boxes the drain deals in, where the concrete type survives
/// but the static one does not.
///
/// A failure that is not an API response at all — a transport error, a
/// timeout, a TLS failure — is what being offline looks like from here, so
/// it classifies as [`ErrorKind::Offline`] rather than
/// [`ErrorKind::Remote`]: the request never reached Proton, and telling the
/// user their connection is down beats telling them the server refused.
pub fn from_api(e: &(dyn std::error::Error + 'static), what: &str) -> Self {
let kind = match e.downcast_ref::<proton_sdk::ProtonError>() {
Some(proton_sdk::ProtonError::Api(api)) => api_kind(api.code),
// A `ProtonError` that is not an `Api` variant never reached the
// API; so does anything that is not a `ProtonError` at all.
_ => ErrorKind::Offline,
};
Self::new(kind, format!("{what}: {e}"))
}
/// Prefix the message while keeping the classification.
///
/// For adding the caller's context to an already-classified failure — the
/// kind was decided by whoever understood the error and must survive being
/// passed up.
pub fn context(mut self, what: &str) -> Self {
self.message = format!("{what}: {}", self.message);
self
}
}
impl std::fmt::Display for CoreError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for CoreError {}
impl From<rusqlite::Error> for CoreError {
fn from(e: rusqlite::Error) -> Self {
Self::internal(format!("database: {e}"))
}
}
impl From<std::io::Error> for CoreError {
fn from(e: std::io::Error) -> Self {
Self::internal(format!("io: {e}"))
}
}
impl From<serde_json::Error> for CoreError {
fn from(e: serde_json::Error) -> Self {
Self::internal(format!("serialization: {e}"))
}
}
impl From<Error> for CoreError {
fn from(e: Error) -> Self {
let kind = match &e {
Error::NotLoggedIn | Error::ReloginRequired | Error::HumanVerificationRequired(_) => {
ErrorKind::Denied
}
Error::Proton(proton_sdk::ProtonError::Api(api)) => api_kind(api.code),
// A `ProtonError` that never reached the API is a transport failure.
Error::Proton(_) => ErrorKind::Offline,
_ => ErrorKind::Internal,
};
Self::new(kind, e.to_string())
}
}
/// What an API response code means to a front-end.
///
/// The grouping is by *what the user can do about it*, which is what
/// [`ErrorKind`] exists to express — not by HTTP-ish similarity. So a 403 and a
/// `NotEnoughPermissions` land together despite being different numbers, while
/// `AlreadyExists` and `DoesNotExist` stay apart despite both being 25xx.
fn api_kind(code: proton_sdk::api::ResponseCode) -> ErrorKind {
use proton_sdk::api::ResponseCode as C;
match code {
// The account may not do this, and repeating it will not help.
C::Unauthorized
| C::Forbidden
| C::NotEnoughPermissions
| C::NotEnoughPermissionsToGrantPermissions
| C::InsufficientScope
| C::IncorrectLoginCredentials
| C::InvalidRefreshToken
| C::AccountDeleted
| C::AccountDisabled
| C::NoActiveSubscription => ErrorKind::Denied,
C::DoesNotExist => ErrorKind::NotFound,
// The remote moved underneath the request; the caller has to decide.
C::AlreadyExists | C::IncompatibleState => ErrorKind::Conflict,
// The request was malformed or asks for something structurally
// impossible. A bug in the caller, not a condition to retry.
C::InvalidRequirements
| C::InvalidValue
| C::InvalidEncryptedIdFormat
| C::InvalidApp
| C::TooManyChildren
| C::NestingTooDeep => ErrorKind::Invalid,
C::InsufficientQuota | C::InsufficientSpace | C::InsufficientVolumeQuota => {
ErrorKind::Quota
}
// Reached Proton, but it could not answer *now*. Retrying is exactly
// the right response, so these are Offline rather than Remote — the
// front-end already words that as "not right now".
C::RequestTimeout
| C::Timeout
| C::TooManyRequests
| C::ServiceUnavailable
| C::Offline => ErrorKind::Offline,
// Reached Proton and it refused for a reason we have no specific
// handling for. Retryable, because an unchanged retry can legitimately
// succeed against a transient server-side condition.
_ => ErrorKind::Remote,
}
}
/// The result every request-serving method in the daemon returns.
pub type CoreResult<T> = std::result::Result<T, CoreError>;
#[cfg(test)]
mod tests {
use super::*;
use proton_sdk::api::ResponseCode as C;
use proton_sdk::{ProtonApiError, ProtonError};
fn api(code: C) -> ProtonError {
ProtonError::Api(ProtonApiError {
code,
http_status: 422,
message: "nope".into(),
details: None,
})
}
#[test]
fn api_codes_classify_by_what_the_user_can_do() {
assert_eq!(api_kind(C::Forbidden), ErrorKind::Denied);
assert_eq!(api_kind(C::NotEnoughPermissions), ErrorKind::Denied);
assert_eq!(api_kind(C::DoesNotExist), ErrorKind::NotFound);
assert_eq!(api_kind(C::AlreadyExists), ErrorKind::Conflict);
assert_eq!(api_kind(C::InvalidValue), ErrorKind::Invalid);
assert_eq!(api_kind(C::InsufficientSpace), ErrorKind::Quota);
assert_eq!(api_kind(C::TooManyRequests), ErrorKind::Offline);
// Nothing specific known about it, but Proton did answer.
assert_eq!(api_kind(C::ProtonDriveUnknown), ErrorKind::Remote);
}
/// The regression this whole pass exists to prevent: before it, every SDK
/// failure was hardcoded to `Remote`, so a 403 asked the user to retry a
/// request that could never succeed.
#[test]
fn a_forbidden_response_is_not_offered_as_retryable() {
let e = CoreError::from_api(&api(C::Forbidden), "rename");
assert_eq!(e.kind, ErrorKind::Denied);
assert!(!e.kind.retryable());
assert!(e.message.starts_with("rename: "));
}
/// Being full is not a transient condition, so retrying an upload that did
/// not fit must not be offered as a fix.
#[test]
fn running_out_of_space_is_not_retryable() {
assert!(
!CoreError::from_api(&api(C::InsufficientQuota), "upload")
.kind
.retryable()
);
}
/// A failure that never reached the API is the user's connection, not
/// Proton refusing — the front-end words those differently.
#[test]
fn a_transport_failure_reads_as_offline() {
let io = std::io::Error::other("connection reset");
assert_eq!(CoreError::from_api(&io, "list").kind, ErrorKind::Offline);
}
/// `Error` is the plumbing type; converting it must classify as sharply as
/// the direct path, or the two disagree about the same failure.
#[test]
fn converting_from_the_plumbing_error_classifies_the_api_code() {
let e: CoreError = Error::Proton(api(C::DoesNotExist)).into();
assert_eq!(e.kind, ErrorKind::NotFound);
}
}
+16 -1
View File
@@ -2,13 +2,28 @@
//! identity, XDG paths, and the login/session/keyring layer that every
//! front-end (CLI today, daemon + GUI later) shares.
pub mod access;
pub mod auth;
pub mod batch;
pub mod cache;
pub mod config;
pub mod control;
pub mod db;
pub mod error;
pub mod localindex;
pub mod menu;
pub mod mounts;
pub mod opener;
pub mod profile;
pub mod sdkcache;
pub mod search;
pub mod service;
pub mod shell;
pub mod syncignore;
pub mod takeout;
pub use error::{Error, Result};
pub use access::{Access, access_for, perm_bits};
pub use error::{CoreError, CoreResult, Error, Result};
// Re-export the SDK surface downstream crates need, so they depend on
// `pdfs-core` rather than pinning the SDK directly.
+372
View File
@@ -0,0 +1,372 @@
//! Filesystem walker behind the daemon's index of *local* (non-Drive) files.
//!
//! The launcher prompt searches Proton Drive and the machine's own files side by
//! side. Drive names come from the `nodes` FTS index; local names come from the
//! `local_files` index this module feeds. The daemon runs [`scan`] on a
//! background thread — never on a FUSE or control-socket thread — and streams
//! batches into [`crate::db::Db::local_upsert_batch`].
//!
//! Walking is deliberately shallow on cost: we stat each entry once (the walker
//! already has the `DirEntry` metadata) and skip the directories that dominate a
//! home directory's inode count without ever being interesting to search
//! (`node_modules`, `target`, caches, VCS internals). The Drive mountpoint is
//! always excluded — walking it would fault in every remote node through FUSE.
use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;
use ignore::{WalkBuilder, WalkState};
/// Directory names skipped anywhere in the tree. These hold build artefacts,
/// dependency trees, and caches: high inode count, no search value.
const SKIP_DIRS: &[&str] = &[
"node_modules",
"target",
"__pycache__",
"venv",
".venv",
"vendor",
"dist",
"dist-newstyle",
"build",
"Trash",
];
/// Directory *paths* skipped when they end with one of these, for caches whose
/// own name is too generic to blacklist (`mod`). The Go module cache was 63,399
/// of the 110,187 entries indexed on the machine this was measured on — 58% of
/// the index, crowding real files out of the bounded candidate pool the fuzzy
/// scorer draws from. The other big dependency caches (`.cargo`, `.rustup`,
/// `.gradle`, `.m2`) are already skipped by the hidden-file filter.
const SKIP_PATH_SUFFIXES: &[&str] = &["go/pkg/mod"];
/// Hard cap on indexed entries, so a pathological home directory cannot grow the
/// database without bound. Reaching it stops the walk early.
const MAX_ENTRIES: usize = 400_000;
/// Rows handed to the sink at a time. Large enough that the per-transaction cost
/// amortises, small enough that the writer lock is released often (FUSE
/// write-through shares the same connection).
const BATCH: usize = 2_000;
/// One indexed local file or directory.
#[derive(Debug, Clone)]
pub struct LocalEntry {
/// Absolute path.
pub path: String,
/// Final path component.
pub name: String,
pub is_dir: bool,
/// Size in bytes (0 for directories).
pub size: i64,
/// Modification time, epoch seconds.
pub mtime: i64,
}
/// Walk `roots` in parallel, calling `sink` with batches of entries.
///
/// Hidden files and anything under `excludes` (the Drive mountpoint, our own
/// state/cache dirs) are skipped, as are the [`SKIP_DIRS`] names at any depth.
/// Symlinks are not followed, so a link loop cannot hang the scan. Returns the
/// number of entries handed to `sink`.
///
/// `sink` is called from the walker's worker threads, serialized by an internal
/// mutex; it must not block for long.
pub fn scan(
roots: &[PathBuf],
excludes: &[PathBuf],
sink: impl FnMut(Vec<LocalEntry>) + Send,
) -> usize {
let Some((first, rest)) = roots.split_first() else {
return 0;
};
let mut builder = WalkBuilder::new(first);
for root in rest {
builder.add(root);
}
let excludes: Vec<PathBuf> = excludes.to_vec();
builder
// `standard_filters` would honour .gitignore/.ignore files: a source
// tree's ignored-but-present files are still files the user may want to
// find, so only the hidden filter stays on.
.standard_filters(false)
.hidden(true)
.follow_links(false)
.same_file_system(true)
.threads(std::thread::available_parallelism().map_or(4, |n| n.get().min(8)))
.filter_entry(move |entry| {
let path = entry.path();
if excludes.iter().any(|e| path.starts_with(e)) {
return false;
}
if matches!(entry.file_name().to_str(), Some(name) if SKIP_DIRS.contains(&name)) {
return false;
}
!is_skipped_path(path)
});
let state = parking_lot::Mutex::new(SinkState {
buf: Vec::with_capacity(BATCH),
total: 0,
sink,
});
builder.build_parallel().run(|| {
Box::new(|result| {
let Ok(entry) = result else {
return WalkState::Continue;
};
// Depth 0 is a root itself; indexing it adds nothing to search.
if entry.depth() == 0 {
return WalkState::Continue;
}
let Some(local) = to_entry(&entry) else {
return WalkState::Continue;
};
let mut state = state.lock();
state.push(local);
if state.total >= MAX_ENTRIES {
return WalkState::Quit;
}
WalkState::Continue
})
});
let mut state = state.into_inner();
state.flush();
state.total
}
/// Batching buffer shared by the walker's worker threads.
struct SinkState<F: FnMut(Vec<LocalEntry>)> {
buf: Vec<LocalEntry>,
total: usize,
sink: F,
}
impl<F: FnMut(Vec<LocalEntry>)> SinkState<F> {
fn push(&mut self, entry: LocalEntry) {
self.buf.push(entry);
self.total += 1;
if self.buf.len() >= BATCH {
self.flush();
}
}
fn flush(&mut self) {
if !self.buf.is_empty() {
let batch = std::mem::take(&mut self.buf);
(self.sink)(batch);
}
}
}
/// Whether `path` is (or is inside) one of the [`SKIP_PATH_SUFFIXES`] caches.
/// Matched on the path's own components so a directory merely *named* `mod`
/// keeps being indexed.
fn is_skipped_path(path: &Path) -> bool {
let path = path.to_string_lossy();
SKIP_PATH_SUFFIXES.iter().any(|suffix| {
path.strip_suffix(suffix)
.is_some_and(|head| head.is_empty() || head.ends_with('/'))
})
}
/// Convert a walker entry into a [`LocalEntry`], reusing the metadata the walker
/// already fetched. Entries with non-UTF-8 paths are dropped: the index (and the
/// JSON wire format) is UTF-8 only.
fn to_entry(entry: &ignore::DirEntry) -> Option<LocalEntry> {
let path = entry.path().to_str()?.to_string();
let name = entry.file_name().to_str()?.to_string();
let meta = entry.metadata().ok()?;
let is_dir = meta.is_dir();
let mtime = meta
.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map_or(0, |d| d.as_secs() as i64);
Some(LocalEntry {
path,
name,
is_dir,
size: if is_dir { 0 } else { meta.len() as i64 },
mtime,
})
}
/// The paths a scan should never descend into: the Drive mountpoint (walking it
/// would fault every remote node in through FUSE) plus our own state and cache
/// dirs, which hold blobs no user searches for by name.
///
/// Callers should extend this with [`nested_mount_points`] for each scan root —
/// see that function for why `same_file_system(true)` is not enough on its own.
pub fn default_excludes(mountpoint: &Path, state_dir: &Path, cache_dir: &Path) -> Vec<PathBuf> {
vec![
mountpoint.to_path_buf(),
state_dir.to_path_buf(),
cache_dir.to_path_buf(),
]
}
/// Every mountpoint strictly below `root`, read from `/proc/self/mounts`.
///
/// [`scan`] already asks the walker to stay on one filesystem, but that check is
/// a *stat of the mountpoint* (`ignore`'s `is_same_file_system`), performed after
/// the directory is queued — so the walker has to touch a foreign mount to learn
/// it should skip it. On a dead network FUSE mount (an sshfs whose server is
/// gone, an unresponsive rclone) that stat blocks in `request_wait_answer` with
/// nobody left to answer it, and blocks *uninterruptibly*: the walker thread then
/// ignores SIGKILL, so the daemon can never exit, systemd's stop times out, and
/// the half-dead process keeps the cache.db lock that the next start needs
/// (docs/BUGS.md B90).
///
/// Excluding the mountpoints by path avoids that stat entirely. It does not
/// change what ends up indexed: a nested mount is a different device, so
/// `same_file_system(true)` was already going to skip it.
pub fn nested_mount_points(root: &Path) -> Vec<PathBuf> {
let Ok(mounts) = std::fs::read_to_string("/proc/self/mounts") else {
return Vec::new();
};
parse_nested_mount_points(&mounts, root)
}
/// The mountpoint-field parse behind [`nested_mount_points`], split out so it can
/// be tested without a real `/proc`.
fn parse_nested_mount_points(mounts: &str, root: &Path) -> Vec<PathBuf> {
mounts
.lines()
.filter_map(|line| line.split_whitespace().nth(1))
.map(|field| PathBuf::from(unescape_mount_field(field)))
// Strictly below: `root` itself being a mountpoint must not exclude the
// whole scan.
.filter(|path| path.starts_with(root) && path != root)
.collect()
}
/// Undo the octal escaping `/proc/self/mounts` applies to space, tab, newline and
/// backslash in path fields. An un-unescaped `\040` would simply fail to match a
/// real path, silently putting a mount with a space in its name back in the walk.
fn unescape_mount_field(field: &str) -> String {
let mut out = String::with_capacity(field.len());
let mut rest = field;
while let Some(at) = rest.find('\\') {
out.push_str(&rest[..at]);
let escape = rest[at + 1..].get(..3);
match escape.and_then(|digits| u8::from_str_radix(digits, 8).ok()) {
Some(byte) => {
out.push(byte as char);
rest = &rest[at + 4..];
}
// Not an octal escape after all — keep the backslash verbatim.
None => {
out.push('\\');
rest = &rest[at + 1..];
}
}
}
out.push_str(rest);
out
}
#[cfg(test)]
mod tests {
use super::*;
/// A scan indexes plain files, skips the excluded subtree and the junk dirs,
/// and reports names/paths the search index can match on.
#[test]
fn scan_indexes_files_and_honours_excludes() {
let tmp = std::env::temp_dir().join(format!("pdfs-scan-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(tmp.join("docs")).unwrap();
std::fs::create_dir_all(tmp.join("node_modules/pkg")).unwrap();
std::fs::create_dir_all(tmp.join("mnt")).unwrap();
std::fs::write(tmp.join("docs/report.pdf"), b"x").unwrap();
std::fs::write(tmp.join("node_modules/pkg/index.js"), b"x").unwrap();
std::fs::write(tmp.join("mnt/remote.txt"), b"x").unwrap();
let mut got = Vec::new();
let n = scan(std::slice::from_ref(&tmp), &[tmp.join("mnt")], |batch| {
got.extend(batch)
});
let names: Vec<&str> = got.iter().map(|e| e.name.as_str()).collect();
assert_eq!(n, got.len());
assert!(names.contains(&"report.pdf"));
assert!(names.contains(&"docs"));
// Junk dir pruned, mountpoint-style exclude pruned.
assert!(!names.contains(&"index.js"));
assert!(!names.contains(&"node_modules"));
assert!(!names.contains(&"remote.txt"));
let report = got.iter().find(|e| e.name == "report.pdf").unwrap();
assert!(!report.is_dir);
assert_eq!(report.size, 1);
assert!(report.path.ends_with("docs/report.pdf"));
std::fs::remove_dir_all(&tmp).unwrap();
}
/// The Go module cache is the single largest source of index noise on a
/// developer's machine (58% of the entries on the one this was measured
/// on), and it cannot be pruned by directory name: `mod` is far too
/// common a name to blacklist outright.
#[test]
fn the_go_module_cache_is_pruned_but_a_plain_mod_dir_is_not() {
let tmp = std::env::temp_dir().join(format!("pdfs-scan-gomod-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(tmp.join("go/pkg/mod/example.com/lib")).unwrap();
std::fs::create_dir_all(tmp.join("kernel/mod")).unwrap();
std::fs::write(tmp.join("go/pkg/mod/example.com/lib/vendored.go"), b"x").unwrap();
std::fs::write(tmp.join("kernel/mod/mine.conf"), b"x").unwrap();
let mut got = Vec::new();
scan(std::slice::from_ref(&tmp), &[], |batch| got.extend(batch));
let names: Vec<&str> = got.iter().map(|e| e.name.as_str()).collect();
assert!(!names.contains(&"vendored.go"), "{names:?}");
assert!(names.contains(&"mine.conf"), "{names:?}");
std::fs::remove_dir_all(&tmp).unwrap();
}
/// A dead network FUSE mount under the scan root must be skipped by *path*,
/// before anything stats it — see `nested_mount_points`. The scan root itself
/// being a mountpoint must not exclude the entire walk.
#[test]
fn nested_mount_points_are_excluded_but_the_root_mount_is_not() {
let mounts = "\
/dev/sda2 /home ext4 rw 0 0
narl@narl.io:/opt /home/narl/remote/narl.io fuse.sshfs rw 0 0
google_drive: /home/narl/remote/gdrive fuse.rclone rw 0 0
tmpfs /run/user/1000 tmpfs rw 0 0
";
let nested = parse_nested_mount_points(mounts, Path::new("/home/narl"));
assert_eq!(
nested,
vec![
PathBuf::from("/home/narl/remote/narl.io"),
PathBuf::from("/home/narl/remote/gdrive"),
]
);
// `/home` is an ancestor, not a descendant: excluding it would empty the
// scan. `/run/user/1000` is unrelated.
assert!(parse_nested_mount_points(mounts, Path::new("/home")).len() == 2);
}
/// `/proc/self/mounts` octal-escapes a space; leaving it escaped would put a
/// mount whose path contains one back into the walk.
#[test]
fn mount_paths_are_unescaped() {
let mounts = "srv:/x /home/narl/My\\040Cloud fuse.sshfs rw 0 0\n";
assert_eq!(
parse_nested_mount_points(mounts, Path::new("/home/narl")),
vec![PathBuf::from("/home/narl/My Cloud")]
);
assert_eq!(unescape_mount_field("/a\\134b"), "/a\\b");
assert_eq!(unescape_mount_field("/plain/path"), "/plain/path");
}
}
+529
View File
@@ -0,0 +1,529 @@
//! Driving an external dmenu-style launcher (fuzzel, rofi, wofi, …).
//!
//! `pdfs-prompt` ships its own GTK HUD, but on a tiling-WM desktop the user
//! already has a launcher bound to a key, themed to match everything else. This
//! module lets the prompt borrow it: entries go out on the child's stdin, the
//! chosen line comes back on stdout.
//!
//! Two things vary between launchers and are the only reasons this is not four
//! lines of `Command`:
//!
//! * the flag that turns on filter mode and the one that sets the prompt text;
//! * icon support, which fuzzel and rofi both spell `text\0icon\x1fNAME` and
//! everything else would render as literal garbage.
//!
//! Selection is matched back by label rather than by index, because the one
//! feature no launcher agrees on is index output. A line that matches nothing is
//! the user's own typing — [`MenuChoice::Custom`] — which is what makes
//! type-then-search work at all.
use std::io::{BufRead, BufReader, Write};
use std::path::Path;
use std::process::{Command, Stdio};
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
/// Separates the label from its icon name in the fuzzel/rofi protocol.
const ICON_SEPARATOR: &str = "\0icon\x1f";
/// Launchers we know how to invoke, most-preferred first:
/// `(program, filter-mode args, prompt flag, icon protocol)`.
///
/// The prompt flag is written as it is passed: fuzzel wants `--prompt=TEXT` as
/// one argument, the rest take the text as a separate one.
const KNOWN_MENUS: [MenuFlavor; 6] = [
MenuFlavor {
program: "fuzzel",
mode: &["--dmenu"],
prompt: PromptFlag::Joined("--prompt="),
icons: true,
},
MenuFlavor {
program: "rofi",
mode: &["-dmenu"],
prompt: PromptFlag::Separate("-p"),
icons: true,
},
MenuFlavor {
program: "wofi",
mode: &["--dmenu"],
prompt: PromptFlag::Separate("--prompt"),
icons: false,
},
MenuFlavor {
program: "tofi",
mode: &[],
prompt: PromptFlag::Joined("--prompt-text="),
icons: false,
},
MenuFlavor {
program: "bemenu",
mode: &[],
prompt: PromptFlag::Separate("-p"),
icons: false,
},
MenuFlavor {
program: "dmenu",
mode: &[],
prompt: PromptFlag::Separate("-p"),
icons: false,
},
];
/// Terminals that can host `pdfs-prompt --fzf`, most-preferred first.
///
/// The fzf front end is a terminal program, so a keybinding that has no tty
/// needs one spawned for it. Each entry sets a stable app-id/class
/// ([`TERMINAL_APP_ID`]) so a tiling WM can be told to float exactly this
/// window, and ends with the flag after which the command to run follows.
const KNOWN_TERMINALS: [&[&str]; 6] = [
&["foot", "--app-id=pdfs-prompt", "--"],
&["ghostty", "--class=pdfs-prompt", "-e"],
&["kitty", "--class", "pdfs-prompt"],
&["alacritty", "--class", "pdfs-prompt", "-e"],
&["wezterm", "start", "--class", "pdfs-prompt", "--"],
&["xterm", "-class", "pdfs-prompt", "-e"],
];
/// The app-id/class every [`KNOWN_TERMINALS`] entry sets, so the window rule a
/// user writes for one terminal keeps working if they switch.
pub const TERMINAL_APP_ID: &str = "pdfs-prompt";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PromptFlag {
/// `--prompt=TEXT`
Joined(&'static str),
/// `-p TEXT`
Separate(&'static str),
}
#[derive(Debug, Clone, Copy)]
struct MenuFlavor {
program: &'static str,
mode: &'static [&'static str],
prompt: PromptFlag,
icons: bool,
}
/// Prompt behaviour from `config.json`, under `prompt`.
///
/// ```json
/// "prompt": { "menu": ["fuzzel", "--dmenu", "--width=60"], "menu_limit": 40 }
/// ```
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PromptConfig {
/// Which front end `pdfs-prompt` uses when invoked with no mode flag, so a
/// desktop shortcut does not have to be re-bound to switch. `None` is
/// [`PromptMode::Gtk`], the built-in HUD.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<PromptMode>,
/// Launcher argv for `pdfs-prompt --dmenu`. Unset means "find one" — see
/// [`resolve_menu`]. A `{prompt}` token anywhere in the argv is replaced by
/// the prompt text; without one the launcher's own prompt flag is appended.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub menu: Option<Vec<String>>,
/// Terminal argv for `pdfs-prompt --fzf` when it is started without a tty.
/// Unset means "find one" — see [`resolve_terminal`]. A `{cmd}` token
/// anywhere in the argv is replaced by the command to run; without one the
/// command is appended, which is what every [`KNOWN_TERMINALS`] entry
/// expects.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub terminal: Option<Vec<String>>,
/// How many results to feed the launcher. Unset means
/// [`DEFAULT_MENU_LIMIT`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub menu_limit: Option<usize>,
/// Show icons when the launcher understands them. Defaults to true; set
/// false for a launcher that advertises support but is themed without them.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub menu_icons: Option<bool>,
}
/// Which front end the prompt presents.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PromptMode {
/// The built-in GTK HUD.
#[default]
Gtk,
/// The user's own launcher, driven over stdin/stdout.
Dmenu,
/// `fzf` in a terminal, re-querying the daemon on every keystroke.
Fzf,
}
/// Rows an external launcher can show without becoming a scroll exercise. Larger
/// than the GTK prompt's cap because filtering happens launcher-side.
pub const DEFAULT_MENU_LIMIT: usize = 50;
impl PromptConfig {
pub fn resolved_mode(&self) -> PromptMode {
self.mode.unwrap_or_default()
}
pub fn resolved_menu_limit(&self) -> usize {
self.menu_limit.unwrap_or(DEFAULT_MENU_LIMIT)
}
pub fn resolved_menu_icons(&self) -> bool {
self.menu_icons.unwrap_or(true)
}
}
/// One line offered to the launcher.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MenuItem {
/// What the user sees, and what identifies the choice on the way back.
pub label: String,
/// Icon theme name, used only by launchers that support the protocol.
pub icon: Option<String>,
}
impl MenuItem {
pub fn new(label: impl Into<String>, icon: Option<String>) -> Self {
Self {
label: label.into(),
icon,
}
}
}
/// What came back from the launcher.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MenuChoice {
/// Index into the items that were passed in.
Item(usize),
/// Text the user typed that matched no item — treat it as a query.
Custom(String),
/// Escape, or an empty selection.
Cancelled,
}
/// The launcher to run: the configured one, or the first [`KNOWN_MENUS`] entry
/// on `PATH`. Returns the argv as it should be spawned, filter-mode flags
/// included.
pub fn resolve_menu(configured: Option<&Vec<String>>) -> Option<Vec<String>> {
if let Some(argv) = configured.filter(|argv| !argv.is_empty()) {
return Some(argv.clone());
}
let flavor = KNOWN_MENUS.iter().find(|f| on_path(f.program))?;
let mut argv = vec![flavor.program.to_string()];
argv.extend(flavor.mode.iter().map(|f| (*f).to_string()));
Some(argv)
}
/// The terminal to host `pdfs-prompt --fzf` in: the configured one, or the
/// first [`KNOWN_TERMINALS`] entry on `PATH`. Returns the argv up to and
/// including the "run this command" flag — the caller appends the command, or
/// substitutes it for a `{cmd}` token, via [`with_command`].
pub fn resolve_terminal(configured: Option<&Vec<String>>) -> Option<Vec<String>> {
if let Some(argv) = configured.filter(|argv| !argv.is_empty()) {
return Some(argv.clone());
}
let flavor = KNOWN_TERMINALS
.iter()
.find(|argv| argv.first().is_some_and(|program| on_path(program)))?;
Some(flavor.iter().map(|arg| (*arg).to_string()).collect())
}
/// Place `command` into a terminal argv: substituted for a `{cmd}` token if the
/// user wrote one, otherwise appended.
///
/// The token exists for terminals that want the command in the middle of their
/// own arguments; appending is right for every [`KNOWN_TERMINALS`] entry, whose
/// last element is the flag the command follows.
pub fn with_command(argv: &[String], command: &[String]) -> Vec<String> {
if !argv.iter().any(|arg| arg == "{cmd}") {
let mut argv = argv.to_vec();
argv.extend_from_slice(command);
return argv;
}
let mut out = Vec::with_capacity(argv.len() + command.len());
for arg in argv {
if arg == "{cmd}" {
out.extend_from_slice(command);
} else {
out.push(arg.clone());
}
}
out
}
/// Show `items` in the launcher named by `argv` and wait for a choice.
///
/// Blocking by design: the caller is a one-shot CLI invocation whose entire job
/// is this round-trip.
pub fn run(argv: &[String], prompt: &str, items: &[MenuItem]) -> Result<MenuChoice> {
let argv = with_prompt(argv, prompt);
let Some((program, args)) = argv.split_first() else {
return Err(Error::Other("no launcher command configured".into()));
};
let icons = supports_icons(program);
let mut child = Command::new(program)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.map_err(|e| Error::Other(format!("cannot run launcher {program}: {e}")))?;
// Write on a worker: a launcher that starts reading only after it maps its
// window would otherwise deadlock against a full pipe buffer.
let payload = render(items, icons);
let mut stdin = child
.stdin
.take()
.ok_or_else(|| Error::Other("launcher stdin unavailable".into()))?;
let writer = std::thread::spawn(move || {
let _ = stdin.write_all(payload.as_bytes());
let _ = stdin.flush();
});
let mut selected = String::new();
if let Some(stdout) = child.stdout.take() {
// Only the first line matters; multi-select is not a thing here, and a
// launcher that emits more must not leave us blocked on a full pipe.
let mut reader = BufReader::new(stdout);
let mut line = String::new();
while reader.read_line(&mut line).unwrap_or(0) > 0 {
if selected.is_empty() {
selected = line.trim_end_matches('\n').to_string();
}
line.clear();
}
}
let _ = child.wait();
let _ = writer.join();
Ok(interpret(&selected, items))
}
/// Map the launcher's output back to a choice. Exposed for tests; the ambiguity
/// it resolves (label vs. free text) is the whole contract with the launcher.
fn interpret(selected: &str, items: &[MenuItem]) -> MenuChoice {
// Some launchers echo the icon protocol back verbatim.
let selected = selected
.split(ICON_SEPARATOR)
.next()
.unwrap_or(selected)
.trim_end();
if selected.is_empty() {
return MenuChoice::Cancelled;
}
match items.iter().position(|item| item.label == selected) {
Some(index) => MenuChoice::Item(index),
None => MenuChoice::Custom(selected.to_string()),
}
}
/// The stdin payload. Labels are stripped of newlines rather than escaped —
/// there is no escape in this protocol, and a file name with a newline in it
/// would otherwise become two entries.
fn render(items: &[MenuItem], icons: bool) -> String {
let mut out = String::new();
for item in items {
let label = item.label.replace(['\n', '\r'], " ");
out.push_str(&label);
if icons && let Some(icon) = &item.icon {
out.push_str(ICON_SEPARATOR);
out.push_str(icon);
}
out.push('\n');
}
out
}
/// Add the prompt text: substituted for `{prompt}` if the user placed one,
/// otherwise appended using the launcher's own flag. An unrecognised launcher
/// gets no prompt rather than a guessed flag that might not parse.
fn with_prompt(argv: &[String], prompt: &str) -> Vec<String> {
if argv.iter().any(|arg| arg.contains("{prompt}")) {
return argv
.iter()
.map(|arg| arg.replace("{prompt}", prompt))
.collect();
}
let mut argv = argv.to_vec();
if prompt.is_empty() {
return argv;
}
if let Some(flavor) = argv.first().and_then(|program| flavor_of(program)) {
match flavor.prompt {
PromptFlag::Joined(flag) => argv.push(format!("{flag}{prompt}")),
PromptFlag::Separate(flag) => {
argv.push(flag.to_string());
argv.push(prompt.to_string());
}
}
}
argv
}
fn supports_icons(program: &str) -> bool {
flavor_of(program).is_some_and(|flavor| flavor.icons)
}
fn flavor_of(program: &str) -> Option<&'static MenuFlavor> {
let base = Path::new(program).file_name()?.to_str()?;
KNOWN_MENUS.iter().find(|flavor| flavor.program == base)
}
/// Whether `program` is an executable file on `PATH`.
pub fn on_path(program: &str) -> bool {
let Some(path) = std::env::var_os("PATH") else {
return false;
};
std::env::split_paths(&path).any(|dir| dir.join(program).is_file())
}
#[cfg(test)]
mod tests {
use super::*;
fn items() -> Vec<MenuItem> {
vec![
MenuItem::new(
"notes.md · My files / Docs",
Some("text-x-generic".into()),
),
MenuItem::new("photo.png · Home / Pictures", None),
]
}
#[test]
fn a_matching_line_is_an_item_and_anything_else_is_a_query() {
let items = items();
assert_eq!(
interpret("notes.md · My files / Docs", &items),
MenuChoice::Item(0)
);
assert_eq!(
interpret("invoice", &items),
MenuChoice::Custom("invoice".into())
);
assert_eq!(interpret("", &items), MenuChoice::Cancelled);
}
#[test]
fn an_echoed_icon_field_does_not_break_the_match() {
let items = items();
assert_eq!(
interpret(
"notes.md · My files / Docs\0icon\x1ftext-x-generic",
&items
),
MenuChoice::Item(0)
);
}
#[test]
fn icons_are_only_rendered_for_launchers_that_understand_them() {
let items = items();
assert_eq!(
render(&items, true),
"notes.md · My files / Docs\0icon\x1ftext-x-generic\nphoto.png · Home / Pictures\n"
);
assert_eq!(
render(&items, false),
"notes.md · My files / Docs\nphoto.png · Home / Pictures\n"
);
assert!(supports_icons("fuzzel"));
assert!(supports_icons("/usr/bin/rofi"));
assert!(!supports_icons("wofi"));
assert!(!supports_icons("mystery-menu"));
}
#[test]
fn a_label_with_a_newline_stays_one_entry() {
let sneaky = vec![MenuItem::new("two\nlines.txt", None)];
assert_eq!(render(&sneaky, false), "two lines.txt\n");
}
#[test]
fn the_prompt_uses_each_launchers_own_flag() {
assert_eq!(
with_prompt(&["fuzzel".into(), "--dmenu".into()], "Drive"),
vec!["fuzzel", "--dmenu", "--prompt=Drive"]
);
assert_eq!(
with_prompt(&["rofi".into(), "-dmenu".into()], "Drive"),
vec!["rofi", "-dmenu", "-p", "Drive"]
);
// Unknown launcher: no guessed flag.
assert_eq!(
with_prompt(&["mystery-menu".into()], "Drive"),
vec!["mystery-menu"]
);
}
#[test]
fn an_explicit_placeholder_wins_over_the_flag() {
assert_eq!(
with_prompt(
&[
"rofi".into(),
"-dmenu".into(),
"-p".into(),
"{prompt} >".into()
],
"Drive"
),
vec!["rofi", "-dmenu", "-p", "Drive >"]
);
}
#[test]
fn a_configured_terminal_is_used_verbatim() {
let configured = vec!["my-term".to_string(), "-e".to_string()];
assert_eq!(
resolve_terminal(Some(&configured)),
Some(configured.clone())
);
// An empty list is "not configured", not "run nothing".
assert_eq!(
resolve_terminal(Some(&Vec::new())),
resolve_terminal(None),
"an empty terminal list must fall back to detection"
);
}
#[test]
fn the_command_is_appended_unless_the_user_placed_it() {
let command = vec!["pdfs-prompt".to_string(), "--fzf".to_string()];
assert_eq!(
with_command(&["foot".into(), "--".into()], &command),
vec!["foot", "--", "pdfs-prompt", "--fzf"]
);
assert_eq!(
with_command(
&["term".into(), "-e".into(), "{cmd}".into(), "--hold".into()],
&command
),
vec!["term", "-e", "pdfs-prompt", "--fzf", "--hold"]
);
}
#[test]
fn every_known_terminal_sets_the_same_app_id() {
for argv in KNOWN_TERMINALS {
assert!(
argv.iter().any(|arg| arg.contains(TERMINAL_APP_ID)),
"{argv:?} must be floatable by one window rule"
);
}
}
#[test]
fn a_configured_launcher_is_used_verbatim() {
let configured = vec!["my-menu".to_string(), "--pick".to_string()];
assert_eq!(resolve_menu(Some(&configured)), Some(configured.clone()));
// An empty list is "not configured", not "run nothing".
assert_eq!(
resolve_menu(Some(&Vec::new())),
resolve_menu(None),
"an empty menu list must fall back to detection"
);
}
}
+146
View File
@@ -0,0 +1,146 @@
//! Unified presentation model for every local Proton Drive location.
//!
//! Device sync state remains owned by `sync_folder`; these types are the stable
//! shape exposed to front-ends after that state is joined with the `mount`
//! presentation table.
use std::fmt;
use serde::{Deserialize, Serialize};
use crate::control::SyncProgress;
/// How a location makes remote content available locally.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum MountMode {
/// A full local copy reconciled in both directions.
Mirror,
/// A FUSE session that fetches content as it is accessed.
OnDemand,
/// A mode written by a newer client.
#[default]
#[serde(other)]
Unknown,
}
impl MountMode {
pub const fn as_str(self) -> &'static str {
match self {
Self::Mirror => "mirror",
Self::OnDemand => "ondemand",
Self::Unknown => "unknown",
}
}
}
impl From<&str> for MountMode {
fn from(value: &str) -> Self {
match value {
"mirror" => Self::Mirror,
"ondemand" => Self::OnDemand,
_ => Self::Unknown,
}
}
}
impl fmt::Display for MountMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
/// Whether the location itself was declared writable.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum MountAccess {
Rw,
Ro,
}
impl MountAccess {
pub const fn as_str(self) -> &'static str {
match self {
Self::Rw => "rw",
Self::Ro => "ro",
}
}
}
impl fmt::Display for MountAccess {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
/// What remote root a local location presents.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum MountKind {
/// The account's primary My Files mount.
MyFiles,
/// A folder owned by this machine's Proton Drive device.
Device { sync_folder_id: i64 },
/// A future standalone mount rooted at a node shared with this account.
Shared { share_root_uid: String },
}
/// One local Proton Drive location as exposed over the control protocol.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct MountSpec {
/// Presentation-row id. Device actions use the `sync_folder_id` carried by
/// [`MountKind::Device`], not this id.
pub id: i64,
pub kind: MountKind,
pub local_path: String,
pub root_uid: String,
pub root_share_id: String,
pub mode: MountMode,
pub access: MountAccess,
/// `idle` | `syncing` | `error` | `conflict` for device locations.
pub state: String,
/// Last successful device sync, epoch seconds; `0` when not applicable.
pub last_sync: i64,
/// A device mode switch accepted but not yet applied.
#[serde(default)]
pub pending_mode: Option<MountMode>,
/// Whether a FUSE session currently owns `local_path`.
#[serde(default)]
pub mounted: bool,
/// Live device sync progress, absent when no pass is running.
#[serde(default)]
pub progress: Option<SyncProgress>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mount_mode_keeps_legacy_wire_strings() {
assert_eq!(
serde_json::to_string(&MountMode::Mirror).unwrap(),
r#""mirror""#
);
assert_eq!(
serde_json::to_string(&MountMode::OnDemand).unwrap(),
r#""ondemand""#
);
assert_eq!(
serde_json::from_str::<MountMode>(r#""mirror""#).unwrap(),
MountMode::Mirror
);
assert_eq!(
serde_json::from_str::<MountMode>(r#""ondemand""#).unwrap(),
MountMode::OnDemand
);
}
#[test]
fn mount_mode_tolerates_unknown_values() {
assert_eq!(
serde_json::from_str::<MountMode>(r#""from-the-future""#).unwrap(),
MountMode::Unknown
);
}
}
+618
View File
@@ -0,0 +1,618 @@
//! How a file chosen in the prompt (or the browser) is actually opened.
//!
//! The default is `xdg-open`, which routes through the desktop's MIME
//! associations. That is the right answer for a GNOME session and the wrong one
//! for a tiling-WM setup where "open a text file" means "run `nvim` inside my
//! terminal" — there is no desktop file for that, and pointing `xdg-open` at a
//! terminal editor globally breaks every graphical caller.
//!
//! So the rules live in `config.json` under `open_with`: an ordered list of
//! name/class patterns mapped to argv, each optionally wrapped in a terminal.
//! First match wins; anything unmatched falls through to `default` (itself
//! `xdg-open` unless overridden). Nothing here is required — an absent
//! `open_with` reproduces the previous behaviour exactly.
//!
//! ```json
//! "open_with": {
//! "terminal": ["alacritty", "-e"],
//! "rules": [
//! { "match": ["@text", "*.rs", "*.toml"], "command": ["nvim"], "terminal": true },
//! { "match": ["*.png"], "command": ["imv"] }
//! ]
//! }
//! ```
use std::ffi::OsStr;
use std::os::unix::process::CommandExt;
use std::path::Path;
use std::process::{Command, Stdio};
use serde::{Deserialize, Serialize};
/// Environment override for the terminal used by `"terminal": true` rules.
/// Checked before `$TERMINAL` so a support case can redirect it without editing
/// config.
pub const TERMINAL_ENV: &str = "PDFS_TERMINAL";
/// Terminals we know how to hand a command line to, most-preferred first, with
/// the argument that means "run this, don't parse it as options". `kitty` and
/// `wezterm start --` take the command positionally.
const KNOWN_TERMINALS: [(&str, &[&str]); 11] = [
("alacritty", &["-e"]),
("foot", &["-e"]),
("kitty", &[]),
("ghostty", &["-e"]),
("wezterm", &["start", "--"]),
("gnome-terminal", &["--"]),
("konsole", &["-e"]),
("xfce4-terminal", &["-x"]),
("urxvt", &["-e"]),
("st", &["-e"]),
("xterm", &["-e"]),
];
/// The opener policy as it appears in `config.json`.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct OpenWith {
/// Terminal argv used by rules with `"terminal": true`, e.g.
/// `["alacritty", "-e"]`. Unset means "resolve one" — see
/// [`resolve_terminal`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub terminal: Option<Vec<String>>,
/// What to run when no rule matches. Unset means `xdg-open`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default: Option<Vec<String>>,
/// Ordered; first match wins.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub rules: Vec<OpenRule>,
}
/// One `pattern set -> command` mapping.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct OpenRule {
/// Patterns this rule answers to. Either a shell-style glob against the file
/// name (`*.md`, `notes-*.txt`) or one of the classes `@dir`, `@text`,
/// `@document`, `@image`, `@media`, `@any`. Matching is case-insensitive.
#[serde(rename = "match", default)]
pub patterns: Vec<String>,
/// argv to run. A `{}` token is replaced by the path; without one the path
/// is appended. `$VAR`/`${VAR}` tokens expand from the environment, so
/// `["$EDITOR"]` follows the user's editor.
#[serde(default)]
pub command: Vec<String>,
/// Wrap [`command`](Self::command) in the terminal emulator.
#[serde(default)]
pub terminal: bool,
}
impl OpenWith {
/// The argv that opens `path`, terminal wrapping included. Never fails: an
/// unmatched path — or a rule whose terminal cannot be resolved — degrades
/// to the default opener rather than doing nothing.
pub fn command_for(&self, path: &Path, is_dir: bool) -> Vec<String> {
let name = path
.file_name()
.and_then(OsStr::to_str)
.unwrap_or_default()
.to_string();
self.command_for_named(path, &name, is_dir)
}
/// [`command_for`](Self::command_for) with the rules matched against `name`
/// instead of the last component of `path`.
///
/// A materialised Drive file lives in the content cache under its content
/// hash — no extension, no original name — so matching the on-disk path
/// classifies every download as "unknown" and hands it to `xdg-open`, which
/// sniffs the bytes and picks whatever claims `text/plain` (LibreOffice, for
/// a Markdown file). The rules are about *what the user opened*, so the
/// caller passes that name and the argv still gets the real path.
pub fn command_for_named(&self, path: &Path, name: &str, is_dir: bool) -> Vec<String> {
for rule in &self.rules {
if rule.command.is_empty() {
continue;
}
if !rule.patterns.iter().any(|p| matches(p, name, is_dir)) {
continue;
}
let argv = expand(&rule.command, path);
if !rule.terminal {
return argv;
}
match self.terminal_argv() {
Some(mut terminal) => {
terminal.extend(argv);
return terminal;
}
// A terminal rule with no terminal to run it in would silently
// launch a TUI with no tty. Fall through to the desktop instead.
None => {
tracing::warn!(
"no terminal found for a terminal open rule; using the default opener"
);
break;
}
}
}
let fallback = self
.default
.clone()
.unwrap_or_else(|| vec!["xdg-open".to_string()]);
expand(&fallback, path)
}
/// The configured terminal, or a resolved one.
fn terminal_argv(&self) -> Option<Vec<String>> {
match &self.terminal {
Some(argv) if !argv.is_empty() => Some(argv.clone()),
_ => resolve_terminal(),
}
}
}
/// Launch `path` under `policy`. Fire-and-forget: the child outlives us, and a
/// spawn failure is logged rather than propagated — every caller is a UI action
/// with nowhere useful to report to.
pub fn open(policy: &OpenWith, path: &Path, is_dir: bool) {
let argv = policy.command_for(path, is_dir);
spawn(&argv, path);
}
/// [`open`] for a path whose own file name says nothing about its type — see
/// [`OpenWith::command_for_named`].
pub fn open_named(policy: &OpenWith, path: &Path, name: &str, is_dir: bool) {
let argv = policy.command_for_named(path, name, is_dir);
spawn(&argv, path);
}
/// Launch `argv`, detached from whatever is opening it.
///
/// The child gets its own process group and no inherited stdio, because the
/// caller may be about to exit and take its terminal with it: `pdfs-prompt
/// --fzf` runs inside a terminal it spawned for itself, and that terminal
/// closes the moment the selection is made. A child left in the terminal's
/// foreground process group is sent `SIGHUP` when the pty hangs up, so
/// "open this folder in a terminal" would open a window that dies immediately
/// — which is exactly what it did. Nothing here needs the caller's stdio in the
/// first place: every `argv` is another application, and a terminal one is
/// wrapped in its own emulator by the `terminal` rule flag.
fn spawn(argv: &[String], path: &Path) {
let Some((program, args)) = argv.split_first() else {
return;
};
tracing::info!(path = %path.display(), command = ?argv, "opening");
let spawned = Command::new(program)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.process_group(0)
.spawn();
if let Err(e) = spawned {
tracing::error!(command = ?argv, "open failed: {e}");
}
}
/// [`open`] with the policy from the user's `config.json`, read once per
/// process. For callers that open the occasional path and have no reason to
/// carry a policy around; long-lived windows should hold their own copy.
pub fn open_default(path: &Path, is_dir: bool) {
open(default_policy(), path, is_dir);
}
/// [`open_default`] matching the rules against `name` — see
/// [`OpenWith::command_for_named`].
pub fn open_default_named(path: &Path, name: &str, is_dir: bool) {
open_named(default_policy(), path, name, is_dir);
}
fn default_policy() -> &'static OpenWith {
static POLICY: std::sync::OnceLock<OpenWith> = std::sync::OnceLock::new();
POLICY.get_or_init(|| {
crate::config::AppDirs::new()
.map(|dirs| dirs.load_config().resolved_open_with())
.unwrap_or_default()
})
}
/// `$PDFS_TERMINAL`, then `$TERMINAL`, then the first [`KNOWN_TERMINALS`] entry
/// on `PATH`. An env value may be a full command line (`"foot -e"`), in which
/// case it is used verbatim; a bare program name gains that terminal's "run
/// this" argument so `TERMINAL=alacritty` does the obvious thing.
pub fn resolve_terminal() -> Option<Vec<String>> {
for key in [TERMINAL_ENV, "TERMINAL"] {
let Ok(value) = std::env::var(key) else {
continue;
};
let argv: Vec<String> = value.split_whitespace().map(str::to_string).collect();
match argv.len() {
0 => continue,
1 => return Some(with_exec_flag(&argv[0])),
_ => return Some(argv),
}
}
KNOWN_TERMINALS
.iter()
.find(|(program, _)| on_path(program))
.map(|(program, _)| with_exec_flag(program))
}
/// A bare terminal name plus whatever it needs before a command.
fn with_exec_flag(program: &str) -> Vec<String> {
let base = Path::new(program)
.file_name()
.and_then(OsStr::to_str)
.unwrap_or(program);
let mut argv = vec![program.to_string()];
let flags = KNOWN_TERMINALS
.iter()
.find(|(known, _)| *known == base)
.map_or(&["-e"][..], |(_, flags)| *flags);
argv.extend(flags.iter().map(|f| (*f).to_string()));
argv
}
fn on_path(program: &str) -> bool {
let Some(path) = std::env::var_os("PATH") else {
return false;
};
std::env::split_paths(&path).any(|dir| dir.join(program).is_file())
}
/// Substitute `{}` (or append) the path, and expand `$VAR` tokens. Tokens that
/// expand to nothing are dropped, so `["$EDITOR"]` with no `EDITOR` set leaves
/// an empty argv and falls back at the call site.
fn expand(argv: &[String], path: &Path) -> Vec<String> {
let target = path.display().to_string();
let mut out = Vec::with_capacity(argv.len() + 1);
let mut substituted = false;
for token in argv {
if token.contains("{}") {
out.push(token.replace("{}", &target));
substituted = true;
continue;
}
match expand_env(token) {
Some(value) => out.push(value),
None => continue,
}
}
if out.is_empty() {
return vec!["xdg-open".to_string(), target];
}
if !substituted {
out.push(target);
}
out
}
/// `$VAR` / `${VAR}` for a whole token, `None` when the variable is unset or
/// empty so the token drops out. A token that is not a variable reference is
/// returned unchanged; this is deliberately not a shell — no word splitting, no
/// quoting rules.
fn expand_env(token: &str) -> Option<String> {
let Some(name) = token
.strip_prefix("${")
.and_then(|rest| rest.strip_suffix('}'))
.or_else(|| token.strip_prefix('$'))
else {
return Some(token.to_string());
};
if name.is_empty() {
return Some(token.to_string());
}
// Follow `$EDITOR`'s convention of preferring the graphical-capable one.
let value = if name == "EDITOR" {
std::env::var("VISUAL")
.ok()
.filter(|v| !v.is_empty())
.or_else(|| std::env::var("EDITOR").ok())
} else {
std::env::var(name).ok()
};
value.filter(|v| !v.is_empty())
}
/// Whether `pattern` — a glob or an `@class` — matches this name.
fn matches(pattern: &str, name: &str, is_dir: bool) -> bool {
let name_lower = name.to_lowercase();
match pattern.to_lowercase().as_str() {
"@any" => true,
"@dir" => is_dir,
"@text" => !is_dir && class_text(&name_lower),
"@document" => !is_dir && class_document(&name_lower),
"@image" => !is_dir && class_image(&name_lower),
"@media" => !is_dir && class_media(&name_lower),
other if other.starts_with('@') => false,
other => glob_match(other, &name_lower),
}
}
fn extension(name: &str) -> &str {
Path::new(name)
.extension()
.and_then(OsStr::to_str)
.unwrap_or_default()
}
/// Plain-text-ish files a terminal editor is a sensible default for. Extensions
/// only: the daemon's hits may name files that are not on this machine yet, so
/// there is nothing to sniff.
fn class_text(name: &str) -> bool {
matches!(
extension(name),
"txt"
| "md"
| "markdown"
| "rst"
| "org"
| "log"
| "conf"
| "cfg"
| "ini"
| "toml"
| "yaml"
| "yml"
| "json"
| "xml"
| "csv"
| "tsv"
| "env"
| "sh"
| "bash"
| "zsh"
| "fish"
| "rs"
| "py"
| "go"
| "c"
| "h"
| "cpp"
| "hpp"
| "js"
| "ts"
| "tsx"
| "jsx"
| "css"
| "html"
| "sql"
| "lua"
| "vim"
| "nix"
| "patch"
| "diff"
) || name == "makefile"
|| name == "dockerfile"
}
fn class_document(name: &str) -> bool {
matches!(
extension(name),
"pdf"
| "doc"
| "docx"
| "odt"
| "rtf"
| "xls"
| "xlsx"
| "ods"
| "ppt"
| "pptx"
| "odp"
| "epub"
) || class_text(name)
}
fn class_image(name: &str) -> bool {
matches!(
extension(name),
"png" | "jpg" | "jpeg" | "webp" | "gif" | "bmp" | "svg" | "avif" | "heic" | "tiff"
)
}
fn class_media(name: &str) -> bool {
matches!(
extension(name),
"mp4"
| "mkv"
| "webm"
| "mov"
| "avi"
| "mp3"
| "flac"
| "wav"
| "ogg"
| "opus"
| "m4a"
| "aac"
)
}
/// `*` and `?` globbing, no character classes and no path semantics — patterns
/// are matched against a bare file name. Iterative with backtracking so a
/// pathological pattern cannot blow the stack.
fn glob_match(pattern: &str, name: &str) -> bool {
let pattern: Vec<char> = pattern.chars().collect();
let name: Vec<char> = name.chars().collect();
let (mut p, mut n) = (0usize, 0usize);
// Where to resume if the current `*` turns out to have matched too little.
let (mut star, mut resume) = (None, 0usize);
while n < name.len() {
match pattern.get(p) {
Some('*') => {
star = Some(p);
resume = n;
p += 1;
}
Some('?') => {
p += 1;
n += 1;
}
Some(c) if *c == name[n] => {
p += 1;
n += 1;
}
_ => match star {
Some(s) => {
p = s + 1;
resume += 1;
n = resume;
}
None => return false,
},
}
}
pattern[p..].iter().all(|c| *c == '*')
}
#[cfg(test)]
mod tests {
use super::*;
fn rule(patterns: &[&str], command: &[&str], terminal: bool) -> OpenRule {
OpenRule {
patterns: patterns.iter().map(|p| (*p).to_string()).collect(),
command: command.iter().map(|c| (*c).to_string()).collect(),
terminal,
}
}
#[test]
fn a_cache_blob_is_classified_by_the_name_it_was_opened_as() {
let policy = OpenWith {
terminal: Some(vec!["alacritty".into(), "-e".into()]),
default: None,
rules: vec![rule(&["@text"], &["nvim"], true)],
};
// What the daemon hands back: content-hash name, no extension. Matched
// on its own it is "unknown" and would land in xdg-open's lap.
let blob = Path::new("/home/me/.cache/proton-drive-linux/content/364d1d8cc8e6");
assert_eq!(
policy.command_for(blob, false),
vec![
"xdg-open",
"/home/me/.cache/proton-drive-linux/content/364d1d8cc8e6"
]
);
assert_eq!(
policy.command_for_named(blob, "ONBOARDING.md", false),
vec![
"alacritty",
"-e",
"nvim",
"/home/me/.cache/proton-drive-linux/content/364d1d8cc8e6"
]
);
}
#[test]
fn an_empty_policy_is_xdg_open() {
let policy = OpenWith::default();
assert_eq!(
policy.command_for(Path::new("/tmp/a.md"), false),
vec!["xdg-open".to_string(), "/tmp/a.md".to_string()]
);
}
#[test]
fn a_terminal_rule_wraps_the_command() {
let policy = OpenWith {
terminal: Some(vec!["alacritty".into(), "-e".into()]),
default: None,
rules: vec![rule(&["@text"], &["nvim"], true)],
};
assert_eq!(
policy.command_for(Path::new("/tmp/notes.md"), false),
vec!["alacritty", "-e", "nvim", "/tmp/notes.md"]
);
}
#[test]
fn the_first_matching_rule_wins_and_globs_are_case_insensitive() {
let policy = OpenWith {
terminal: None,
default: None,
rules: vec![
rule(&["*.PNG"], &["imv"], false),
rule(&["@image"], &["eog"], false),
],
};
assert_eq!(
policy.command_for(Path::new("/tmp/shot.png"), false),
vec!["imv", "/tmp/shot.png"]
);
}
#[test]
fn a_placeholder_positions_the_path_instead_of_appending_it() {
let policy = OpenWith {
terminal: None,
default: None,
rules: vec![rule(&["@any"], &["sh", "-c", "cat {} | less"], false)],
};
assert_eq!(
policy.command_for(Path::new("/tmp/a.log"), false),
vec!["sh", "-c", "cat /tmp/a.log | less"]
);
}
#[test]
fn directories_only_match_the_dir_class() {
let policy = OpenWith {
terminal: None,
default: None,
rules: vec![
rule(&["@text"], &["nvim"], false),
rule(&["@dir"], &["nautilus"], false),
],
};
assert_eq!(
policy.command_for(Path::new("/tmp/notes.md"), true),
vec!["nautilus", "/tmp/notes.md"]
);
}
#[test]
fn a_rule_that_cannot_find_a_terminal_falls_back_to_the_default() {
// A terminal that is spelled out in config is always honoured; this is
// the other path — resolution must not invent one from the test host.
let policy = OpenWith {
terminal: Some(Vec::new()),
default: Some(vec!["gio".into(), "open".into()]),
rules: vec![rule(&["@text"], &["nvim"], true)],
};
let argv = policy.command_for(Path::new("/tmp/a.md"), false);
// Either a terminal exists on this machine (wrapped nvim) or it does
// not (the default). Both are correct; what must not happen is a bare
// `nvim` with no tty.
assert!(
argv == vec!["gio", "open", "/tmp/a.md"]
|| argv.ends_with(&["nvim".to_string(), "/tmp/a.md".to_string()])
);
}
#[test]
fn globs_backtrack() {
assert!(glob_match("*.md", "notes.md"));
assert!(glob_match("*notes*.md", "my-notes-2024.md"));
assert!(glob_match("a?c.txt", "abc.txt"));
assert!(!glob_match("*.md", "notes.mdx"));
assert!(!glob_match("a?c.txt", "ac.txt"));
assert!(glob_match("*", "anything"));
}
#[test]
fn a_bare_terminal_name_gains_its_exec_flag() {
assert_eq!(with_exec_flag("alacritty"), vec!["alacritty", "-e"]);
assert_eq!(with_exec_flag("kitty"), vec!["kitty"]);
assert_eq!(
with_exec_flag("/usr/bin/wezterm"),
vec!["/usr/bin/wezterm", "start", "--"]
);
// Unknown terminals get the near-universal `-e`.
assert_eq!(with_exec_flag("mystery-term"), vec!["mystery-term", "-e"]);
}
}
+229
View File
@@ -0,0 +1,229 @@
//! The machine profile that travels with a device (features.md 5.3).
//!
//! Sync folder mappings, pins, and the settings that shape them live in
//! `cache.db` and `config.json` — both on the local disk. When that disk dies,
//! the remote data survives but the *arrangement* of it does not: which folders
//! were synced, where they lived locally, which were on-demand, what was pinned.
//!
//! So the daemon mirrors that arrangement into a `profile.json` in its device's
//! root folder on Drive. It goes up the normal upload path, which means it is
//! end-to-end encrypted with the account keys like any other file — 5.3's
//! "encrypt with the user's Proton keys" is satisfied by *not* special-casing
//! it. It lives under the device rather than the Drive root because it describes
//! that device; adopting the device is what makes it relevant.
//!
//! This module is pure data. The upload/download and the restore flow live in
//! `pdfs-fuse`, next to the client that performs them.
use serde::{Deserialize, Serialize};
use crate::mounts::MountMode;
/// File name of the profile.
pub const PROFILE_FILE_NAME: &str = "profile.json";
/// Folder, directly under the device root, that holds the profile.
///
/// The API refuses `Cannot create file at the root of a device` (HTTP 422), so
/// the profile cannot live at the device root itself even though that is where
/// it logically belongs (docs/BUGS.md B68/B78). A dotted name keeps it out of
/// the way in the web UI, and the restore ignores it when listing the device's
/// restorable folders.
pub const PROFILE_DIR_NAME: &str = ".proton-drive-linux";
/// Format version of the profile document.
///
/// Bumped only for changes a *reader* cannot survive. Adding fields does not
/// qualify — unknown fields are ignored on the way in, so an older client reads
/// a newer profile's common subset. A newer `version` than this is refused
/// outright rather than half-applied: a restore that silently drops folders it
/// did not understand is worse than one that tells you to upgrade.
pub const PROFILE_VERSION: u32 = 1;
/// One synced folder, as it was arranged on the machine that wrote the profile.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct ProfileFolder {
/// Uid of the folder's remote root, under the device root. The stable
/// identity — the local path may not exist on the restoring machine, but
/// this does.
pub remote_uid: String,
/// Absolute local path it was synced to. A *suggestion* on restore: another
/// machine may have a different username, home, or mount layout.
pub local_path: String,
/// `mirror` or `ondemand`. Unknown values written by a newer client do not
/// invalidate the rest of the profile.
pub mode: MountMode,
}
/// One pinned node (P5 pins), as `pins` stores it.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct ProfilePin {
pub uid: String,
/// Mountpoint-relative path, kept for display: a pin whose node has been
/// deleted should still be nameable in a restore summary.
pub path: String,
pub recursive: bool,
}
/// The full arrangement of one machine.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct Profile {
pub version: u32,
/// The device this profile describes.
pub device_uid: String,
/// Hostname of the machine that wrote it, so a restore prompt can say
/// *"restore settings from laptop-xyz?"* rather than quoting a uid.
pub hostname: String,
/// When it was written, epoch seconds.
pub saved_at: i64,
#[serde(default)]
pub folders: Vec<ProfileFolder>,
#[serde(default)]
pub pins: Vec<ProfilePin>,
/// Global sync ignore patterns. Safe to restore as-is.
#[serde(default)]
pub ignore_patterns: Option<Vec<String>>,
/// Machine-shaped settings. Carried, but never applied without asking: a
/// laptop's 5 GB cache budget and `~/ProtonDrive` mountpoint are not
/// obviously right on the next machine.
#[serde(default)]
pub cache_budget: Option<u64>,
#[serde(default)]
pub mountpoint: Option<String>,
}
impl Profile {
/// Parse a profile document, refusing one written by a newer client.
pub fn parse(bytes: &[u8]) -> Result<Self, String> {
let profile: Profile =
serde_json::from_slice(bytes).map_err(|e| format!("malformed profile.json: {e}"))?;
if profile.version > PROFILE_VERSION {
return Err(format!(
"profile.json was written by a newer client (version {}, this client understands {PROFILE_VERSION}); upgrade to restore it",
profile.version
));
}
Ok(profile)
}
/// Serialize for upload. Pretty-printed: it is a small file a user may well
/// open in the web UI to see what their machine had.
pub fn to_bytes(&self) -> Result<Vec<u8>, String> {
serde_json::to_vec_pretty(self).map_err(|e| format!("serialize profile: {e}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> Profile {
Profile {
version: PROFILE_VERSION,
device_uid: "dev-1".to_string(),
hostname: "laptop-xyz".to_string(),
saved_at: 1_700_000_000,
folders: vec![ProfileFolder {
remote_uid: "vol~link".to_string(),
local_path: "/home/nils/Documents".to_string(),
mode: MountMode::Mirror,
}],
pins: vec![ProfilePin {
uid: "vol~pin".to_string(),
path: "Photos/2024".to_string(),
recursive: true,
}],
ignore_patterns: Some(vec!["target/".to_string()]),
cache_budget: Some(5_000_000_000),
mountpoint: Some("/home/nils/ProtonDrive".to_string()),
}
}
#[test]
fn round_trips() {
let p = sample();
assert_eq!(Profile::parse(&p.to_bytes().unwrap()).unwrap(), p);
}
#[test]
fn profile_keeps_legacy_mode_bytes_and_parses_old_documents() {
let bytes = sample().to_bytes().unwrap();
let json = String::from_utf8(bytes).unwrap();
assert!(json.contains(r#""mode": "mirror""#));
let old = br#"{
"version": 1,
"device_uid": "dev-1",
"hostname": "old-client",
"saved_at": 1,
"folders": [
{
"remote_uid": "vol~mirror",
"local_path": "/home/me/Mirror",
"mode": "mirror"
},
{
"remote_uid": "vol~ondemand",
"local_path": "/home/me/Cloud",
"mode": "ondemand"
}
]
}"#;
let profile = Profile::parse(old).unwrap();
assert_eq!(profile.folders[0].mode, MountMode::Mirror);
assert_eq!(profile.folders[1].mode, MountMode::OnDemand);
}
#[test]
fn unknown_folder_mode_does_not_reject_profile() {
let json = br#"{
"version": 1,
"device_uid": "dev-1",
"hostname": "future-client",
"saved_at": 1,
"folders": [{
"remote_uid": "vol~future",
"local_path": "/home/me/Future",
"mode": "streaming"
}]
}"#;
let profile = Profile::parse(json).unwrap();
assert_eq!(profile.folders[0].mode, MountMode::Unknown);
}
/// A field added by a later client must not break this one's restore: the
/// common subset is still usable, which is the whole reason `version` is
/// reserved for breaking changes only.
#[test]
fn unknown_fields_are_ignored() {
let json = br#"{
"version": 1,
"device_uid": "dev-1",
"hostname": "laptop-xyz",
"saved_at": 1,
"folders": [],
"something_from_the_future": {"a": 1}
}"#;
let p = Profile::parse(json).unwrap();
assert_eq!(p.device_uid, "dev-1");
assert!(p.folders.is_empty());
}
/// Missing optional sections read as empty rather than failing: a profile
/// written before pins existed is still a valid folder mapping.
#[test]
fn absent_sections_default() {
let json = br#"{"version":1,"device_uid":"d","hostname":"h","saved_at":0}"#;
let p = Profile::parse(json).unwrap();
assert!(p.pins.is_empty());
assert!(p.ignore_patterns.is_none());
}
#[test]
fn future_version_is_refused() {
let mut p = sample();
p.version = PROFILE_VERSION + 1;
let err = Profile::parse(&p.to_bytes().unwrap()).unwrap_err();
assert!(err.contains("newer client"), "{err}");
}
}
+283
View File
@@ -0,0 +1,283 @@
//! A persistent [`CacheRepository`] for the SDK's Drive entity cache.
//!
//! The SDK caches decrypted node metadata (name, size, parent, the share whose
//! membership signs operations on it) so navigating a tree does not re-fetch and
//! re-decrypt what it already resolved. That cache defaults to memory, which
//! means every daemon restart pays for the whole walk again. This backs it with
//! SQLite so it survives one.
//!
//! **Its own database file, not `cache.db`.** The daemon's `Db` is a single
//! `Mutex<Connection>` shared by every FUSE thread and the control socket; SDK
//! cache traffic is frequent, small, and entirely reconstructible, so putting it
//! on that mutex would add contention to the daemon's hottest lock (and to a
//! write-ahead log whose growth is already sensitive to how long that lock is
//! held) for no durability benefit. A separate file also means no schema
//! migration: this store can be deleted and rebuilt at any time.
//!
//! **What it holds.** Decrypted metadata, never key material — the SDK keeps
//! node keys and hash keys in a separate in-memory secret cache that nothing
//! here can reach. The same names and sizes are already persisted in `cache.db`
//! (`nodes`), so this file widens no exposure that the state directory did not
//! already have; `AppDirs::ensure` is what keeps that directory private.
//!
//! **Staleness.** A persisted cache outlives the process, so an entry can
//! describe a node another client has since changed. The daemon's event loop is
//! what closes that: it resumes from a persisted cursor and calls the SDK's
//! `invalidate_caches_for_event` for every event it replays, including the ones
//! that happened while the daemon was down. The one case that leaves no trail is
//! a *seeded* cursor — a first-ever mount, or a cursor the daemon lost — where
//! nothing says what changed, so the caller [`clear`](SdkCache::clear)s this
//! store instead of trusting it.
use std::path::Path;
use std::sync::{Arc, OnceLock};
use async_trait::async_trait;
use parking_lot::Mutex;
use proton_sdk::cache::CacheRepository;
use proton_sdk::error::{ProtonError, Result as SdkResult};
use rusqlite::{Connection, params};
use crate::config::AppDirs;
use crate::error::Result;
/// Process-wide handle, so the daemon's event loop can clear the same store the
/// client is writing through without threading it down every call.
static SHARED: OnceLock<Arc<SdkCache>> = OnceLock::new();
/// SQLite-backed entity cache for the SDK.
pub struct SdkCache {
conn: Mutex<Connection>,
}
impl SdkCache {
/// Open (creating if needed) the entity cache under the app's state
/// directory. The first call wins; later calls return the same handle, which
/// is what lets the event loop clear the store the Drive client is using.
pub fn shared(dirs: &AppDirs) -> Result<Arc<Self>> {
if let Some(existing) = SHARED.get() {
return Ok(existing.clone());
}
let cache = Arc::new(Self::open(&dirs.state_dir().join("sdk_cache.db"))?);
Ok(SHARED.get_or_init(|| cache).clone())
}
/// The already-open shared cache, if one was opened in this process.
pub fn opened() -> Option<Arc<Self>> {
SHARED.get().cloned()
}
/// Open the store at `path`, creating its schema.
pub fn open(path: &Path) -> Result<Self> {
let conn = Connection::open(path)?;
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "synchronous", "NORMAL")?;
conn.pragma_update(None, "foreign_keys", "ON")?;
// Tags cascade with their entry, so a `set` that replaces a value cannot
// leave the old value's tags behind pointing at the new one.
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS entry (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS tag (
key TEXT NOT NULL REFERENCES entry(key) ON DELETE CASCADE,
tag TEXT NOT NULL,
PRIMARY KEY (key, tag)
);
CREATE INDEX IF NOT EXISTS idx_tag_tag ON tag(tag);",
)?;
Ok(Self {
conn: Mutex::new(conn),
})
}
/// Drop every entry. Used when the daemon cannot prove the cache is current
/// — a seeded event cursor — and on logout.
pub fn clear_now(&self) -> Result<()> {
let conn = self.conn.lock();
conn.execute_batch("DELETE FROM tag; DELETE FROM entry;")?;
Ok(())
}
fn set_now(&self, key: &str, value: &str, tags: &[String]) -> Result<()> {
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
tx.execute(
"INSERT INTO entry (key, value) VALUES (?1, ?2)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
params![key, value],
)?;
tx.execute("DELETE FROM tag WHERE key = ?1", params![key])?;
{
let mut stmt = tx.prepare("INSERT INTO tag (key, tag) VALUES (?1, ?2)")?;
for tag in tags {
stmt.execute(params![key, tag])?;
}
}
tx.commit()?;
Ok(())
}
fn get_now(&self, key: &str) -> Result<Option<String>> {
let conn = self.conn.lock();
let mut stmt = conn.prepare("SELECT value FROM entry WHERE key = ?1")?;
let mut rows = stmt.query(params![key])?;
match rows.next()? {
Some(row) => Ok(Some(row.get(0)?)),
None => Ok(None),
}
}
fn remove_now(&self, key: &str) -> Result<()> {
let conn = self.conn.lock();
conn.execute("DELETE FROM entry WHERE key = ?1", params![key])?;
Ok(())
}
fn remove_by_tag_now(&self, tag: &str) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"DELETE FROM entry WHERE key IN (SELECT key FROM tag WHERE tag = ?1)",
params![tag],
)?;
Ok(())
}
/// Entries carrying **all** of `tags` — the set intersection the SDK's
/// `get_by_tags` contract specifies, not a union.
fn get_by_tags_now(&self, tags: &[String]) -> Result<Vec<(String, String)>> {
if tags.is_empty() {
return Ok(Vec::new());
}
let conn = self.conn.lock();
let placeholders = vec!["?"; tags.len()].join(",");
let sql = format!(
"SELECT e.key, e.value FROM entry e
JOIN tag t ON t.key = e.key
WHERE t.tag IN ({placeholders})
GROUP BY e.key
HAVING COUNT(DISTINCT t.tag) = {}",
tags.len()
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(tags), |r| {
Ok((r.get(0)?, r.get(1)?))
})?;
let mut out = Vec::new();
for row in rows {
out.push(row?);
}
Ok(out)
}
}
/// A cache failure is never fatal to the operation that triggered it — the SDK
/// treats this repository as best-effort — but it still has to come back as the
/// SDK's error type.
fn sdk_error(e: crate::error::Error) -> ProtonError {
ProtonError::invalid_operation(format!("sdk entity cache: {e}"))
}
// Every method is a local SQLite statement on its own connection: microseconds,
// no network, no shared lock with the daemon's database. Running them inline on
// the caller's task is cheaper than the thread hop that moving them off would
// cost.
#[async_trait]
impl CacheRepository for SdkCache {
async fn set(&self, key: &str, value: &str, tags: &[String]) -> SdkResult<()> {
self.set_now(key, value, tags).map_err(sdk_error)
}
async fn get(&self, key: &str) -> SdkResult<Option<String>> {
self.get_now(key).map_err(sdk_error)
}
async fn remove(&self, key: &str) -> SdkResult<()> {
self.remove_now(key).map_err(sdk_error)
}
async fn remove_by_tag(&self, tag: &str) -> SdkResult<()> {
self.remove_by_tag_now(tag).map_err(sdk_error)
}
async fn clear(&self) -> SdkResult<()> {
self.clear_now().map_err(sdk_error)
}
async fn get_by_tags(&self, tags: &[String]) -> SdkResult<Vec<(String, String)>> {
self.get_by_tags_now(tags).map_err(sdk_error)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cache() -> SdkCache {
let conn = Connection::open_in_memory().unwrap();
conn.pragma_update(None, "foreign_keys", "ON").unwrap();
conn.execute_batch(
"CREATE TABLE entry (key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE tag (
key TEXT NOT NULL REFERENCES entry(key) ON DELETE CASCADE,
tag TEXT NOT NULL,
PRIMARY KEY (key, tag)
);",
)
.unwrap();
SdkCache {
conn: Mutex::new(conn),
}
}
fn tags(values: &[&str]) -> Vec<String> {
values.iter().map(|t| t.to_string()).collect()
}
#[test]
fn set_replaces_the_value_and_its_tags() {
let cache = cache();
cache.set_now("k", "one", &tags(&["a", "b"])).unwrap();
cache.set_now("k", "two", &tags(&["b"])).unwrap();
assert_eq!(cache.get_now("k").unwrap().as_deref(), Some("two"));
// The tag dropped by the second write must not still select the entry —
// that is how a stale invalidation would miss it.
assert!(cache.get_by_tags_now(&tags(&["a"])).unwrap().is_empty());
assert_eq!(cache.get_by_tags_now(&tags(&["b"])).unwrap().len(), 1);
}
#[test]
fn get_by_tags_intersects_rather_than_unions() {
let cache = cache();
cache.set_now("both", "1", &tags(&["x", "y"])).unwrap();
cache.set_now("one", "2", &tags(&["x"])).unwrap();
let hits = cache.get_by_tags_now(&tags(&["x", "y"])).unwrap();
assert_eq!(hits, vec![("both".to_string(), "1".to_string())]);
// An empty tag list selects nothing, matching the SDK contract.
assert!(cache.get_by_tags_now(&[]).unwrap().is_empty());
}
#[test]
fn removing_by_tag_takes_the_entry_and_its_tags() {
let cache = cache();
cache.set_now("k", "v", &tags(&["gone"])).unwrap();
cache.remove_by_tag_now("gone").unwrap();
assert!(cache.get_now("k").unwrap().is_none());
assert!(cache.get_by_tags_now(&tags(&["gone"])).unwrap().is_empty());
}
#[test]
fn clear_empties_the_store() {
let cache = cache();
cache.set_now("a", "1", &tags(&["t"])).unwrap();
cache.set_now("b", "2", &[]).unwrap();
cache.clear_now().unwrap();
assert!(cache.get_now("a").unwrap().is_none());
assert!(cache.get_now("b").unwrap().is_none());
}
}
+214
View File
@@ -0,0 +1,214 @@
//! Shared, deterministic search-result relevance scoring.
//!
//! The scorer deliberately operates on a small candidate set rather than
//! replacing an index. Callers should use SQLite (or another index) to find
//! candidates, then use this module to put local and remote results on the
//! same relevance scale.
/// Scores a candidate against a user query.
///
/// Every query term must match either `basename` or `parent_path`. Basename
/// matches are weighted twice as strongly as path matches. The returned score
/// is stable and only meaningful when compared with scores produced by this
/// function.
#[must_use]
pub fn relevance_score(query: &str, basename: &str, parent_path: &str) -> Option<i64> {
let query = normalize(query);
if query.is_empty() {
return Some(0);
}
let name = normalize(basename);
let path = normalize(parent_path);
let query_terms: Vec<_> = query.split_whitespace().collect();
let name_words: Vec<_> = name.split_whitespace().collect();
let path_words: Vec<_> = path.split_whitespace().collect();
let mut total = 0_i64;
for term in query_terms {
let name_score = field_term_score(term, &name, &name_words).map(|score| score * 2);
let path_score = field_term_score(term, &path, &path_words);
total += name_score.into_iter().chain(path_score).max()?;
}
// Whole-field bonuses make ordering intuitive without changing whether a
// candidate matches. They dominate small differences between term scores.
if name == query {
total += 20_000;
} else if name.starts_with(&query) {
total += 10_000;
} else if name.contains(&query) {
total += 4_000;
}
Some(total)
}
fn normalize(value: &str) -> String {
let mut normalized = String::with_capacity(value.len());
let mut last_was_space = true;
for character in value.chars().flat_map(char::to_lowercase) {
if character.is_alphanumeric() {
normalized.push(character);
last_was_space = false;
} else if !last_was_space {
normalized.push(' ');
last_was_space = true;
}
}
if last_was_space {
normalized.pop();
}
normalized
}
fn field_term_score(term: &str, field: &str, words: &[&str]) -> Option<i64> {
if field == term {
return Some(10_000);
}
if field.starts_with(term) {
return Some(8_500);
}
if words.contains(&term) {
return Some(8_000);
}
if words.iter().any(|word| word.starts_with(term)) {
return Some(7_000);
}
if field.contains(term) {
return Some(6_000);
}
// Subsequence matching supports useful abbreviations such as "qtrrpt".
let subsequence = std::iter::once(field)
.chain(words.iter().copied())
.filter_map(|word| subsequence_gap(term, word))
.min();
if let Some(gap) = subsequence {
return Some(4_500 - i64::try_from(gap.min(500)).unwrap_or(500));
}
// Typo matching is deliberately restricted to substantial terms to avoid
// noisy matches for one- and two-letter searches.
if term.chars().count() >= 4
&& words
.iter()
.any(|word| bounded_damerau_levenshtein(term, word, typo_limit(term)))
{
return Some(3_500);
}
None
}
fn subsequence_gap(needle: &str, haystack: &str) -> Option<usize> {
let needle_length = needle.chars().count();
let mut needle = needle.chars();
let mut wanted = needle.next()?;
let mut first = None;
for (index, character) in haystack.chars().enumerate() {
if character == wanted {
first.get_or_insert(index);
match needle.next() {
Some(next) => wanted = next,
None => return Some(index + 1 - first.unwrap_or(index) - needle_length),
}
}
}
None
}
fn typo_limit(term: &str) -> usize {
usize::from(term.chars().count() >= 5) + 1
}
/// A bounded optimal-string-alignment distance check. This treats a single
/// adjacent transposition (for example, `vidoe`) as one edit.
fn bounded_damerau_levenshtein(left: &str, right: &str, limit: usize) -> bool {
let left: Vec<_> = left.chars().collect();
let right: Vec<_> = right.chars().collect();
if left.len().abs_diff(right.len()) > limit || left.len().max(right.len()) > 64 {
return false;
}
let mut distances = vec![vec![0_usize; right.len() + 1]; left.len() + 1];
for (index, row) in distances.iter_mut().enumerate() {
row[0] = index;
}
for (index, distance) in distances[0].iter_mut().enumerate() {
*distance = index;
}
for i in 1..=left.len() {
for j in 1..=right.len() {
let cost = usize::from(left[i - 1] != right[j - 1]);
distances[i][j] = (distances[i - 1][j] + 1)
.min(distances[i][j - 1] + 1)
.min(distances[i - 1][j - 1] + cost);
if i > 1 && j > 1 && left[i - 1] == right[j - 2] && left[i - 2] == right[j - 1] {
distances[i][j] = distances[i][j].min(distances[i - 2][j - 2] + 1);
}
}
}
distances[left.len()][right.len()] <= limit
}
#[cfg(test)]
mod tests {
use super::relevance_score;
#[test]
fn exact_name_beats_prefix_and_substring() {
let exact = relevance_score("report", "report", "work").unwrap();
let prefix = relevance_score("report", "report final", "work").unwrap();
let substring = relevance_score("report", "annual report", "work").unwrap();
assert!(exact > prefix);
assert!(prefix > substring);
}
#[test]
fn basename_is_weighted_above_parent_path() {
let name = relevance_score("budget", "budget notes", "archive").unwrap();
let path = relevance_score("budget", "notes", "work/budget").unwrap();
assert!(name > path);
}
#[test]
fn matches_typos_and_adjacent_transpositions() {
assert!(relevance_score("vedio", "video.mp4", "media").is_some());
assert!(relevance_score("vidoe", "video.mp4", "media").is_some());
assert!(relevance_score("presentaton", "presentation.pdf", "work").is_some());
}
#[test]
fn separators_form_searchable_word_boundaries() {
assert!(relevance_score("final draft", "Final_Draft-v2.pdf", "work").is_some());
assert!(relevance_score("quarter report", "quarterly-report.pdf", "work").is_some());
}
#[test]
fn unicode_case_folding_preserves_accented_matches() {
assert!(relevance_score("CAFÉ", "Café Photos", "Trips").is_some());
assert!(relevance_score("ångström", "ÅNGSTRÖM notes", "Research").is_some());
}
#[test]
fn matches_terms_across_name_and_path() {
assert!(relevance_score("tax invoice", "invoice-1042.pdf", "archive/tax/2025").is_some());
}
#[test]
fn ordered_subsequence_supports_abbreviations() {
assert!(relevance_score("qtrrpt", "quarter-report.pdf", "finance").is_some());
}
#[test]
fn rejects_unrelated_candidates_and_short_typos() {
assert_eq!(relevance_score("video", "holiday.jpg", "photos"), None);
assert_eq!(relevance_score("xy", "xz.txt", "misc"), None);
}
}
+114
View File
@@ -0,0 +1,114 @@
//! Thin wrappers over `systemctl --user` for the auto-mount daemon.
//!
//! The mount is owned by a systemd *user* service (`proton-drive.service`, see
//! `packaging/`). Front-ends never spawn `pdfs mount` directly anymore: logging
//! in enables+starts the unit, logging out disables+stops it, and the tray's
//! "Disconnect" just stops it. systemd then handles restart-on-failure and a
//! clean SIGTERM stop (which the daemon turns into a lazy unmount).
//!
//! Every call is best-effort: a failure is logged but not fatal, since the user
//! can always drive `systemctl --user` by hand.
use std::process::{Command, Stdio};
/// The systemd user unit that runs `pdfs daemon`.
pub const SERVICE: &str = "proton-drive.service";
/// Run one `systemctl --user <args> proton-drive.service`, returning whether it
/// succeeded. Output is suppressed; failures are logged.
fn systemctl(args: &[&str]) -> bool {
let ok = Command::new("systemctl")
.arg("--user")
.args(args)
.arg(SERVICE)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
if !ok {
tracing::warn!(args = ?args, "systemctl --user {SERVICE} failed");
}
ok
}
/// Like [`systemctl`] but treats a non-zero exit as a plain `false` answer
/// rather than a failure to log. For query verbs (`is-active`, `is-failed`)
/// where "no" is the normal, expected result.
fn systemctl_query(args: &[&str]) -> bool {
Command::new("systemctl")
.arg("--user")
.args(args)
.arg(SERVICE)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
/// Enable the service so it survives reboots/logins, and bring it up now.
/// Called on successful login.
///
/// `restart` rather than `enable --now`: the unit may already be active but
/// parked in the daemon's login-wait backoff (`pdfs daemon` sleeps between
/// keyring checks). `--now` is a no-op on an active unit, so the daemon would
/// keep sleeping and the mount would not appear until the next poll fired.
/// Restarting makes the new session take effect immediately, and starts the
/// unit when it is not running at all.
pub fn enable_start() -> bool {
let enabled = systemctl(&["enable"]);
systemctl(&["restart"]) && enabled
}
/// Stop the running mount without disabling it. Called by the tray's
/// "Disconnect": the next login (or reboot) brings it back.
pub fn stop() -> bool {
systemctl(&["stop"])
}
/// Restart the unit now. Used by a front-end "Retry" action when the mount has
/// failed and the user wants to bring it back without re-logging-in.
pub fn restart() -> bool {
systemctl(&["restart"])
}
/// Whether the unit currently reports `active` (running, or starting up).
/// `systemctl is-active` exits 0 only while the unit is active, so a front-end
/// can use this to tell "service still coming up" from "service is down".
pub fn is_active() -> bool {
systemctl_query(&["is-active"])
}
/// Whether the unit is in the `failed` state. `systemctl is-failed` exits 0
/// only when the unit has failed, letting a front-end show an actionable error
/// (with Retry) rather than an indefinite "connecting…" spinner.
pub fn is_failed() -> bool {
systemctl_query(&["is-failed"])
}
/// Whether the unit is enabled to start on login/boot. `systemctl is-enabled`
/// exits 0 only for an enabled unit, so the Settings page can render the
/// "Start on login" switch in its true state.
pub fn is_enabled() -> bool {
systemctl_query(&["is-enabled"])
}
/// Enable the service so it auto-starts on future logins, without necessarily
/// starting it now beyond what `enable` does. Used by the Settings "Start on
/// login" switch; pairs with [`disable`](disable).
pub fn enable() -> bool {
systemctl(&["enable"])
}
/// Disable auto-start without stopping the running mount. Used by the Settings
/// "Start on login" switch turning off; the current session stays mounted.
pub fn disable() -> bool {
systemctl(&["disable"])
}
/// Disable the service so it no longer auto-starts, and stop it now. Called on
/// logout.
pub fn disable_stop() -> bool {
systemctl(&["disable", "--now"])
}
+97
View File
@@ -0,0 +1,97 @@
//! File-manager integration: right-click actions inside the mounted Drive.
//!
//! Nautilus (and Nemo/Caja, which read the same directories) runs any executable
//! in its scripts folder with the selected paths in the environment, and shows it
//! under a "Scripts" submenu. That gives us pin/unpin from the file manager
//! without shipping a Python extension or a GNOME-version-specific plugin.
//!
//! Installation is idempotent and best-effort: the scripts are (re)written on
//! every app start so an upgraded `pdfs` never leaves a stale script behind, and
//! any failure is logged rather than surfaced — a missing file manager is not an
//! error the user needs to act on.
use std::io;
use std::path::PathBuf;
/// Scripts shipped into the file manager: `(file name, `pdfs` subcommand)`.
const SCRIPTS: [(&str, &str); 2] = [
("Keep offline (Proton Drive)", "pin"),
("Remove offline copy (Proton Drive)", "unpin"),
];
/// The script body. Nautilus passes the selection as newline-separated paths in
/// `NAUTILUS_SCRIPT_SELECTED_FILE_PATHS`; the positional args carry the same
/// selection when the script runs on a local (non-URI) folder, so we prefer the
/// environment and fall back to `"$@"` for the Nemo/Caja variants.
fn script_body(subcommand: &str) -> String {
format!(
"#!/bin/sh\n\
# Installed by proton-drive-linux. Regenerated on every app start.\n\
set -eu\n\
\n\
paths=\"${{NAUTILUS_SCRIPT_SELECTED_FILE_PATHS:-}}\"\n\
if [ -z \"$paths\" ]; then\n\
\x20 paths=\"${{NEMO_SCRIPT_SELECTED_FILE_PATHS:-}}\"\n\
fi\n\
if [ -z \"$paths\" ]; then\n\
\x20 for arg in \"$@\"; do\n\
\x20 paths=\"$paths$arg\\n\"\n\
\x20 done\n\
fi\n\
\n\
printf '%s' \"$paths\" | while IFS= read -r path; do\n\
\x20 [ -n \"$path\" ] || continue\n\
\x20 pdfs {subcommand} \"$path\" || exit 1\n\
done\n"
)
}
/// The scripts directories to populate, for whichever file managers are present.
fn script_dirs() -> Vec<PathBuf> {
let Some(data) = dirs_data_home() else {
return Vec::new();
};
["nautilus/scripts", "nemo/scripts"]
.iter()
.map(|rel| data.join(rel))
.collect()
}
/// `$XDG_DATA_HOME`, or `~/.local/share`.
fn dirs_data_home() -> Option<PathBuf> {
if let Some(dir) = std::env::var_os("XDG_DATA_HOME").filter(|d| !d.is_empty()) {
return Some(PathBuf::from(dir));
}
std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share"))
}
/// Install (or refresh) the pin/unpin scripts. Best-effort: logs and moves on.
pub fn install_file_manager_scripts() {
for dir in script_dirs() {
// Only populate a file manager the user actually has: creating
// `~/.local/share/nemo` on a GNOME box would be litter.
if !dir.parent().is_some_and(|p| p.exists()) {
continue;
}
if let Err(e) = write_scripts(&dir) {
tracing::warn!("couldn't install file-manager scripts in {dir:?}: {e}");
}
}
}
/// Write both scripts into `dir`, creating it if needed, and make them
/// executable — a script without the exec bit is silently ignored by Nautilus.
fn write_scripts(dir: &PathBuf) -> io::Result<()> {
std::fs::create_dir_all(dir)?;
for (name, subcommand) in SCRIPTS {
let path = dir.join(name);
std::fs::write(&path, script_body(subcommand))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))?;
}
}
tracing::debug!("installed file-manager scripts in {dir:?}");
Ok(())
}
+347
View File
@@ -0,0 +1,347 @@
//! Selective-sync ignore rules for mirror folders (features.md 1.1).
//!
//! A mirror folder otherwise uploads every path beneath its root, which for a
//! project directory means `node_modules/`, `.git/`, and `target/` — quota
//! spent on files nobody wants on Drive, and enough API traffic to invite rate
//! limiting.
//!
//! Rules come from two places, unioned:
//!
//! * `.pdfsignore` (or `.protonignore`) at the root of the synced folder,
//! gitignore syntax.
//! * A global pattern list from the config, applied to every synced folder.
//!
//! Both are read fresh at the start of each reconcile pass, so editing the file
//! takes effect on the next pass without restarting the daemon.
//!
//! # Ignoring is not deleting
//!
//! A path that becomes ignored after it was already synced keeps its remote
//! copy and its baseline row. The sync engine drops ignored paths from
//! classification entirely rather than treating them as locally deleted —
//! see the union filter in `pdfs-fuse`'s `do_reconcile`. Editing a text file
//! must never trash data on Drive.
use std::path::Path;
use ignore::gitignore::{Gitignore, GitignoreBuilder};
/// Patterns applied to every synced folder when the config does not override
/// them. Version-control metadata, dependency and build trees, editor
/// leftovers, and OS turds — none of which belong on Drive, all of which are
/// large or rewritten constantly.
pub const DEFAULT_IGNORE_PATTERNS: &[&str] = &[
".git/",
".hg/",
".svn/",
"node_modules/",
"target/",
".venv/",
"__pycache__/",
"*~",
"*.swp",
"*.tmp",
".DS_Store",
"Thumbs.db",
];
/// Suffixes of files that are, by convention, incomplete or throwaway while they
/// wear the name: a download in flight, an editor's swap or backup, an atomic
/// write's scratch copy. On an on-demand mount these should never seal a remote
/// revision — the app renames the finished file to its real name, and only that
/// belongs on Drive. See [`is_transient_name`] and docs/BUGS.md B70.
const TRANSIENT_SUFFIXES: &[&str] = &[
".crdownload", // Chromium/Brave/Edge
".part", // Firefox, wget, aria2c
".partial", // some downloaders / older IE
".download", // Safari / macOS
".tmp", // generic scratch
".temp", // generic scratch
".swp", // vim swap
".swx", // vim swap (secondary)
];
/// Whether `name` is a transient scratch/in-flight file whose bytes should not
/// be uploaded on an on-demand mount until it is renamed to its finished name.
///
/// Recognises browser download temps, editor swap/backup files, and lock files.
/// Case-insensitive on the suffix so `FOO.CRDOWNLOAD` counts too. This is the
/// on-demand-write counterpart to [`DEFAULT_IGNORE_PATTERNS`] (which only covers
/// mirror-folder reconcile); keep the two conceptually in step.
pub fn is_transient_name(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
if TRANSIENT_SUFFIXES.iter().any(|s| lower.ends_with(s)) {
return true;
}
// Emacs/gedit/kate backup copies.
if name.ends_with('~') {
return true;
}
// LibreOffice lock files (`.~lock.<doc>#`) and MS Office temps (`~$doc`).
if name.starts_with(".~lock.") || name.starts_with("~$") {
return true;
}
false
}
/// Filenames accepted as a folder's ignore file, in precedence order.
const IGNORE_FILE_NAMES: &[&str] = &[".pdfsignore", ".protonignore"];
/// Compiled ignore rules for one synced folder.
pub struct IgnoreRules {
matcher: Option<Gitignore>,
}
impl IgnoreRules {
/// Build the rules for a folder rooted at `root`: `globals` first, then any
/// ignore file found at the root, so a folder-local rule can negate a
/// global one with `!pattern`.
///
/// Malformed patterns are skipped individually rather than failing the
/// load — a typo in `.pdfsignore` must not take the folder's sync down.
pub fn load(root: &Path, globals: &[String]) -> Self {
let mut builder = GitignoreBuilder::new(root);
let mut any = false;
for pattern in globals {
if builder.add_line(None, pattern).is_ok() {
any = true;
}
}
for name in IGNORE_FILE_NAMES {
let path = root.join(name);
if !path.is_file() {
continue;
}
// `add` returns the first parse error but still takes the lines it
// could read, so a bad line costs that line and nothing more.
let _ = builder.add(&path);
any = true;
break;
}
if !any {
return Self { matcher: None };
}
Self {
matcher: builder.build().ok(),
}
}
/// Rules matching nothing, for callers with no folder context.
pub fn empty() -> Self {
Self { matcher: None }
}
/// Whether any rule is loaded. When false, [`is_ignored`](Self::is_ignored)
/// is always false and callers can skip filtering entirely.
pub fn is_empty(&self) -> bool {
self.matcher.is_none()
}
/// Whether `rel` — a `/`-joined path relative to the folder root — is
/// ignored.
///
/// A path under an ignored directory is ignored too. `Gitignore` matches
/// one path against one rule set and does not walk parents itself, so each
/// ancestor is tested as a directory. This is what makes filtering the
/// classification union sound: the engine can ask about any path in
/// isolation, in any order, without having walked its parents first.
pub fn is_ignored(&self, rel: &str, is_dir: bool) -> bool {
let Some(matcher) = &self.matcher else {
return false;
};
if rel.is_empty() {
return false;
}
let mut prefix = String::new();
let mut parts = rel.split('/').peekable();
while let Some(part) = parts.next() {
if !prefix.is_empty() {
prefix.push('/');
}
prefix.push_str(part);
let last = parts.peek().is_none();
// Every ancestor is a directory; only the final component's kind
// is the caller's to say.
let as_dir = if last { is_dir } else { true };
if matcher.matched(&prefix, as_dir).is_ignore() {
return true;
}
}
false
}
}
impl std::fmt::Debug for IgnoreRules {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("IgnoreRules")
.field("active", &!self.is_empty())
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A unique temp directory removed on drop; avoids a dev-dependency, as in
/// [`crate::cache`]'s tests.
struct TempDir(std::path::PathBuf);
impl TempDir {
fn new() -> Self {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
let p = std::env::temp_dir().join(format!(
"pdfs-ignore-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir_all(&p).unwrap();
TempDir(p)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn globals() -> Vec<String> {
DEFAULT_IGNORE_PATTERNS
.iter()
.map(|s| (*s).to_string())
.collect()
}
fn write(root: &Path, name: &str, body: &str) {
std::fs::write(root.join(name), body).unwrap();
}
#[test]
fn no_rules_at_all_ignores_nothing() {
let dir = TempDir::new();
let rules = IgnoreRules::load(dir.path(), &[]);
assert!(rules.is_empty());
assert!(!rules.is_ignored("anything", false));
assert!(!rules.is_ignored("deep/nested/path.rs", false));
}
#[test]
fn global_defaults_match_without_an_ignore_file() {
let dir = TempDir::new();
let rules = IgnoreRules::load(dir.path(), &globals());
assert!(rules.is_ignored("node_modules", true));
assert!(rules.is_ignored("target", true));
assert!(rules.is_ignored("notes.txt~", false));
assert!(!rules.is_ignored("src/main.rs", false));
}
#[test]
fn a_path_under_an_ignored_directory_is_ignored() {
let dir = TempDir::new();
let rules = IgnoreRules::load(dir.path(), &globals());
// The engine asks about deep paths in isolation, so each must answer
// correctly without its parents having been tested first.
assert!(rules.is_ignored("node_modules/left-pad/index.js", false));
assert!(rules.is_ignored("node_modules/left-pad", true));
assert!(rules.is_ignored("target/debug/build/x/out", false));
}
#[test]
fn a_folder_ignore_file_adds_to_the_globals() {
let dir = TempDir::new();
write(dir.path(), ".pdfsignore", "secrets/\n*.log\n");
let rules = IgnoreRules::load(dir.path(), &globals());
assert!(rules.is_ignored("secrets/key.pem", false));
assert!(rules.is_ignored("run.log", false));
// Globals still apply alongside the file.
assert!(rules.is_ignored("node_modules", true));
assert!(!rules.is_ignored("run.txt", false));
}
#[test]
fn a_folder_rule_can_negate_a_global_one() {
let dir = TempDir::new();
write(dir.path(), ".pdfsignore", "!keep.tmp\n");
let rules = IgnoreRules::load(dir.path(), &globals());
assert!(rules.is_ignored("scratch.tmp", false));
assert!(!rules.is_ignored("keep.tmp", false));
}
#[test]
fn protonignore_is_accepted_as_an_alias() {
let dir = TempDir::new();
write(dir.path(), ".protonignore", "build/\n");
let rules = IgnoreRules::load(dir.path(), &[]);
assert!(rules.is_ignored("build/out.o", false));
}
#[test]
fn pdfsignore_wins_when_both_files_exist() {
let dir = TempDir::new();
write(dir.path(), ".pdfsignore", "from-pdfs/\n");
write(dir.path(), ".protonignore", "from-proton/\n");
let rules = IgnoreRules::load(dir.path(), &[]);
assert!(rules.is_ignored("from-pdfs/x", false));
assert!(!rules.is_ignored("from-proton/x", false));
}
#[test]
fn a_malformed_pattern_does_not_take_the_rest_down() {
let dir = TempDir::new();
// An unclosed character class is not a valid glob.
write(dir.path(), ".pdfsignore", "[unclosed\nvalid/\n");
let rules = IgnoreRules::load(dir.path(), &globals());
assert!(rules.is_ignored("valid/x", false));
assert!(rules.is_ignored("node_modules", true));
}
#[test]
fn the_root_itself_is_never_ignored() {
let dir = TempDir::new();
write(dir.path(), ".pdfsignore", "*\n");
let rules = IgnoreRules::load(dir.path(), &[]);
// A blanket rule must not classify the folder root, or the pass would
// have nothing to reconcile against.
assert!(!rules.is_ignored("", true));
}
#[test]
fn transient_download_and_editor_names_are_recognised() {
for name in [
"Unconfirmed 540477.crdownload",
"movie.mkv.part",
"big.iso.partial",
"photo.jpg.download",
"export.xml.tmp",
"notes.TEMP",
".notes.txt.swp",
"doc.txt~",
".~lock.report.odt#",
"~$budget.xlsx",
"FOO.CRDOWNLOAD",
] {
assert!(is_transient_name(name), "{name} should be transient");
}
}
#[test]
fn finished_names_are_not_transient() {
for name in [
"teamspeak.tar.gz",
"nils.zip",
"report.xml",
"archive.partly.zip", // ".partly" is not ".part"
"template.dotx",
"readme",
] {
assert!(!is_transient_name(name), "{name} should not be transient");
}
}
}
File diff suppressed because it is too large. Load diff
+9
View File
@@ -5,13 +5,22 @@ edition.workspace = true
license.workspace = true
authors.workspace = true
rust-version.workspace = true
repository.workspace = true
homepage.workspace = true
description = "FUSE on-demand filesystem for Proton Drive"
[dependencies]
pdfs-core.workspace = true
zip.workspace = true
sha1.workspace = true
proton-drive-rs.workspace = true
futures.workspace = true
tokio.workspace = true
tracing.workspace = true
fuser.workspace = true
libc.workspace = true
serde_json.workspace = true
base64.workspace = true
image.workspace = true
notify = "8"
parking_lot.workspace = true
+245
View File
@@ -0,0 +1,245 @@
//! Photo albums: the album listing, and one album's contents.
//!
//! Both are persisted projections of what the photos volume reports, refreshed
//! on the same stale-while-serving rule as the timeline
//! ([`crate::photos`]): a listing already on disk is served immediately and
//! refreshed behind the caller, and only an empty one waits on the network.
//!
//! An album is a folder node carrying album properties, so it has a name like
//! any other node; the properties supply the photo count and the cover. Albums
//! other people share with us come from a second listing and live on *their*
//! photos volume — which is why an album's photos are persisted per album rather
//! than looked up in our own timeline, where a shared album's photos never
//! appear.
use std::collections::{HashMap, HashSet};
use std::sync::atomic::Ordering;
use pdfs_core::control::{AlbumInfo, PhotoItem};
use pdfs_core::db::StoredAlbum;
use pdfs_core::{CoreError, CoreResult};
use proton_drive_rs::NodeKind;
use proton_drive_rs::proton_sdk::ids::NodeUid;
use tracing::warn;
use super::{
ALBUM_SYNCED_PREFIX, ALBUMS_SYNCED_MS, Core, PHOTOS_AVAILABLE, PHOTOS_SYNCED_MS,
TIMELINE_ENRICH_CHUNK, TIMELINE_TTL, now_ms, parse_uid,
};
impl Core {
/// The album listing, served from disk and refreshed behind the caller when
/// stale. `None` means the account has no photos volume — the same signal
/// [`Core::photos_timeline`] gives.
pub(crate) fn albums(&self) -> CoreResult<Option<Vec<AlbumInfo>>> {
let count = self.db.albums_count().map_err(CoreError::from)?;
if count == 0 {
// Nothing on disk, so this one request waits for the fetch — unless
// the account is already known to have no photos volume.
let known_empty = self.db.state_i64(PHOTOS_AVAILABLE).ok().flatten() == Some(0);
if known_empty && !self.listing_stale(ALBUMS_SYNCED_MS, TIMELINE_TTL) {
return Ok(None);
}
if !self.rt.block_on(self.refresh_albums())? {
return Ok(None);
}
} else if self.listing_stale(ALBUMS_SYNCED_MS, TIMELINE_TTL) {
self.spawn_albums_refresh();
}
let albums = self.db.albums_list().map_err(CoreError::from)?;
Ok(Some(albums.into_iter().map(album_info).collect()))
}
/// Re-enumerate the albums — ours and the ones shared with us — and replace
/// the persisted listing. Returns false when the account has no photos
/// volume.
pub(crate) async fn refresh_albums(&self) -> CoreResult<bool> {
let photos = self.photos();
if photos
.get_photos_root()
.await
.map_err(|e| CoreError::from_api(&e, "photos root"))?
.is_none()
{
let _ = self.db.set_state_i64(PHOTOS_AVAILABLE, 0);
let _ = self.db.set_state_i64(PHOTOS_SYNCED_MS, now_ms());
let _ = self.db.set_state_i64(ALBUMS_SYNCED_MS, now_ms());
return Ok(false);
}
let mut uids = photos
.enumerate_album_node_uids()
.await
.map_err(|e| CoreError::from_api(&e, "albums"))?;
// Shared-with-me albums are a separate listing on the sharer's volume.
// Best-effort: an account whose sharing listing fails still gets its own
// albums rather than an error page.
let shared: HashSet<String> = match photos.enumerate_shared_with_me_album_uids().await {
Ok(shared) => {
let keys = shared.iter().map(|u| u.to_string()).collect();
uids.extend(shared);
keys
}
Err(e) => {
warn!(error = %e, "enumerating shared-with-me albums failed");
HashSet::new()
}
};
// The listings carry uids only; the name, photo count and cover come from
// the album nodes themselves. A chunk that fails to resolve drops those
// albums from this refresh rather than failing the whole listing.
let mut albums: Vec<StoredAlbum> = Vec::with_capacity(uids.len());
for chunk in uids.chunks(TIMELINE_ENRICH_CHUNK) {
match photos.enumerate_nodes(chunk).await {
Ok(nodes) => {
for node in nodes {
let key = node.uid.to_string();
let props = node.album.as_ref();
albums.push(StoredAlbum {
shared: shared.contains(&key),
uid: key,
name: node.name,
photo_count: props.map_or(0, |a| a.photo_count.max(0) as usize),
cover_uid: props
.and_then(|a| a.cover_photo_uid.as_ref())
.map(|u| u.to_string()),
last_activity: props.and_then(|a| a.last_activity_time),
});
}
}
Err(e) => warn!(error = %e, "resolving an album chunk failed"),
}
}
// Newest activity first, and albums the server gave no activity time for
// last — an album nobody has touched belongs below the ones in use. Ties
// fall back to the name so the order is stable between refreshes.
albums.sort_by(|a, b| {
b.last_activity
.cmp(&a.last_activity)
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
self.db.albums_replace(&albums).map_err(CoreError::from)?;
let _ = self.db.set_state_i64(PHOTOS_AVAILABLE, 1);
let _ = self.db.set_state_i64(ALBUMS_SYNCED_MS, now_ms());
Ok(true)
}
/// Refresh the album listing off the request path. At most one runs at a time.
pub(crate) fn spawn_albums_refresh(&self) {
if self.albums_refreshing.swap(true, Ordering::SeqCst) {
return;
}
let core = self.clone();
self.rt.spawn(async move {
if let Err(e) = core.refresh_albums().await {
warn!(error = %e, "background album refresh failed");
}
core.albums_refreshing.store(false, Ordering::SeqCst);
});
}
/// A page of one album's photos, newest capture first. The album's contents
/// are fetched on first open and refreshed behind the caller afterwards, so
/// paging never waits on the network twice.
pub(crate) fn album_photos(
&self,
album: &NodeUid,
offset: usize,
limit: usize,
) -> CoreResult<Vec<PhotoItem>> {
let key = album.to_string();
let stamp = format!("{ALBUM_SYNCED_PREFIX}{key}");
let count = self.db.album_photos_count(&key).map_err(CoreError::from)?;
if count == 0 {
self.rt.block_on(self.refresh_album(album))?;
} else if self.listing_stale(&stamp, TIMELINE_TTL) {
let core = self.clone();
let album = album.clone();
self.rt.spawn(async move {
if let Err(e) = core.refresh_album(&album).await {
warn!(%album, error = %e, "background album refresh failed");
}
});
}
let page = self
.db
.album_photos_page(&key, offset, limit)
.map_err(CoreError::from)?;
Ok(page.into_iter().map(|p| self.photo_item(p)).collect())
}
/// Re-enumerate one album's photos and replace its persisted contents.
pub(crate) async fn refresh_album(&self, album: &NodeUid) -> CoreResult<()> {
let photos = self.photos();
let items = photos
.enumerate_album(album)
.await
.map_err(|e| CoreError::from_api(&e, "album"))?;
// Same enrichment as the timeline: the listing carries a uid and a
// capture time, and the gallery needs a name and media type to classify
// each entry. Best-effort per chunk.
let uids: Vec<NodeUid> = items.iter().map(|it| it.uid.clone()).collect();
let mut meta: HashMap<String, (Option<String>, Option<String>)> = HashMap::new();
for chunk in uids.chunks(TIMELINE_ENRICH_CHUNK) {
match photos.enumerate_nodes(chunk).await {
Ok(nodes) => {
for node in nodes {
let media_type = match &node.kind {
NodeKind::File { media_type, .. } => Some(media_type.clone()),
NodeKind::Folder => None,
};
meta.insert(node.uid.to_string(), (Some(node.name), media_type));
}
}
Err(e) => warn!(error = %e, "resolving album photo metadata failed"),
}
}
let rows: Vec<(String, i64, Option<String>, Option<String>)> = items
.iter()
.map(|it| {
let key = it.uid.to_string();
let (name, media_type) = meta.get(&key).cloned().unwrap_or((None, None));
(key, it.capture_time, name, media_type)
})
.collect();
let key = album.to_string();
self.db
.album_photos_replace(&key, &rows)
.map_err(CoreError::from)?;
let _ = self
.db
.set_state_i64(&format!("{ALBUM_SYNCED_PREFIX}{key}"), now_ms());
Ok(())
}
/// Drop the album listing's and every album's freshness stamp, so the next
/// request re-enumerates. Paired with [`Core::invalidate_photos`] — a change
/// to the timeline can just as easily be a change to an album.
pub(crate) fn invalidate_albums(&self) {
let _ = self.db.clear_state(ALBUMS_SYNCED_MS);
let _ = self.db.clear_state_prefix(ALBUM_SYNCED_PREFIX);
}
}
/// Project a persisted album into the wire item a front-end paints.
fn album_info(album: StoredAlbum) -> AlbumInfo {
AlbumInfo {
uid: album.uid,
name: album.name,
photo_count: album.photo_count,
// A cover uid the daemon cannot parse is no cover: the front-end would
// only ask for a thumbnail that can never be resolved.
cover_uid: album.cover_uid.filter(|uid| parse_uid(uid).is_some()),
last_activity: album.last_activity,
shared: album.shared,
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+212
View File
@@ -0,0 +1,212 @@
//! What the daemon is doing right now, for the case where it has stopped doing
//! anything at all.
//!
//! On 2026-09-18 the daemon hung: it consumed 43 minutes of CPU over 20 hours,
//! peaked at 5.2 GiB resident, went silent at 17:10, and only came back after a
//! manual `systemctl --user restart`. There was no panic, so nothing in the
//! journal said what it had been waiting on — and nothing in the daemon could be
//! asked, either. This module is the answer to "what was it stuck on?".
//!
//! Everything here is built to work *while* the daemon is wedged, which rules
//! out the obvious implementations: no lock that a stuck job might hold, no
//! database query on the calling thread, no waiting on the worker pool. A
//! diagnostic that blocks on the hang it is diagnosing reports nothing at
//! exactly the moment it matters.
use std::sync::LazyLock;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Instant;
use parking_lot::Mutex;
use pdfs_core::control::{Diagnostics, InflightRequest, WorkerState};
use super::Core;
/// When this process started. Read at the first touch, which
/// [`super::mount::mount`] makes happen during startup so the uptime is the
/// daemon's, not the first diagnostics request's.
static STARTED: LazyLock<Instant> = LazyLock::new(Instant::now);
/// Control requests currently being served, keyed by a monotonic ticket.
///
/// A `Vec` rather than a map because it holds at most `MAX_CONTROL_HANDLERS`
/// entries, and the only operations are push, remove-by-ticket and read-all.
static INFLIGHT: Mutex<Vec<(u64, String, Instant)>> = Mutex::new(Vec::new());
static NEXT_TICKET: AtomicU64 = AtomicU64::new(0);
/// Start the daemon's uptime clock. Called once, from the mount path.
pub(crate) fn start_clock() {
LazyLock::force(&STARTED);
}
/// Registers one in-flight control request for as long as it is held.
pub(crate) struct InflightGuard(u64);
impl InflightGuard {
pub(crate) fn new(kind: String) -> Self {
let ticket = NEXT_TICKET.fetch_add(1, Ordering::Relaxed);
INFLIGHT.lock().push((ticket, kind, Instant::now()));
Self(ticket)
}
}
impl Drop for InflightGuard {
fn drop(&mut self) {
INFLIGHT.lock().retain(|(ticket, _, _)| *ticket != self.0);
}
}
/// The variant name of a serialised [`pdfs_core::control::Request`], without
/// parsing it into one.
///
/// serde's external tagging makes this a property of the text: a unit variant is
/// the bare string `"Status"`, anything else is an object whose single key is
/// the variant name. Reading the name off the line rather than matching on 80
/// variants keeps this cheap, and keeps it correct when a variant is added.
pub(crate) fn request_kind(line: &str) -> String {
let line = line.trim();
let name = if let Some(rest) = line.strip_prefix('{') {
rest.trim_start()
.strip_prefix('"')
.and_then(|rest| rest.split('"').next())
} else {
line.strip_prefix('"')
.and_then(|rest| rest.split('"').next())
};
name.unwrap_or("unknown").to_string()
}
/// Resident set size in bytes, or `0` when `/proc` did not answer.
///
/// From `VmRSS` in `/proc/self/status`, which is already in kB — `statm` would
/// need the page size, and this needs no dependency at all.
pub(crate) fn rss_bytes() -> u64 {
let Ok(status) = std::fs::read_to_string("/proc/self/status") else {
return 0;
};
parse_vm_rss(&status)
}
/// The `VmRSS` line of a `/proc/<pid>/status`, in bytes.
fn parse_vm_rss(status: &str) -> u64 {
status
.lines()
.find_map(|line| line.strip_prefix("VmRSS:"))
.and_then(|rest| rest.split_whitespace().next())
.and_then(|kb| kb.parse::<u64>().ok())
.map_or(0, |kb| kb.saturating_mul(1024))
}
impl Core {
/// Assemble a [`Diagnostics`] report.
///
/// The pending-op count is the one field that needs the database, so it is
/// read on a throwaway thread with a short deadline: a daemon wedged *on*
/// the database must still be able to report everything else about itself.
pub(crate) fn diagnostics(&self) -> Diagnostics {
let pool = self.workers.snapshot();
let now = Instant::now();
let mut inflight: Vec<InflightRequest> = INFLIGHT
.try_lock()
.map(|entries| {
entries
.iter()
.map(|(_, kind, started)| InflightRequest {
kind: kind.clone(),
age_secs: now.duration_since(*started).as_secs(),
})
.collect()
})
.unwrap_or_default();
inflight.sort_by_key(|request| std::cmp::Reverse(request.age_secs));
Diagnostics {
uptime_secs: STARTED.elapsed().as_secs(),
rss_bytes: rss_bytes(),
workers: pool
.workers
.into_iter()
.map(|worker| WorkerState {
name: worker.name,
busy: worker.busy,
label: worker.label,
age_secs: worker.age_ms / 1000,
})
.collect(),
meta_queued: pool.meta_queued,
transfer_queued: pool.transfer_queued,
meta_completed: pool.meta_completed,
transfer_completed: pool.transfer_completed,
inflight,
control_handlers: super::control::active_handlers(),
control_handler_limit: super::control::handler_limit(),
pending_ops: self.pending_ops_quickly(),
}
}
/// Queued mutations, or `0` if the database did not answer within
/// [`PENDING_COUNT_DEADLINE`].
fn pending_ops_quickly(&self) -> u64 {
let db = self.db.clone();
let (tx, rx) = std::sync::mpsc::channel();
if std::thread::Builder::new()
.name("pdfs-diagnostics".into())
.spawn(move || {
let counts = db.pending_op_counts().unwrap_or_default();
let total = counts.uploads.max(0) as u64 + counts.changes.max(0) as u64;
let _ = tx.send(total);
})
.is_err()
{
return 0;
}
rx.recv_timeout(PENDING_COUNT_DEADLINE).unwrap_or(0)
}
}
/// How long the report waits for the database before reporting without it.
const PENDING_COUNT_DEADLINE: std::time::Duration = std::time::Duration::from_millis(500);
#[cfg(test)]
mod tests {
use super::*;
/// The request name is read off the wire format, so it must match both
/// shapes serde produces.
#[test]
fn the_request_kind_comes_off_both_wire_shapes() {
assert_eq!(request_kind("\"Status\""), "Status");
assert_eq!(request_kind("{\"Pin\":{\"path\":\"a\"}}"), "Pin");
assert_eq!(request_kind(" { \"Restore\" : {} } "), "Restore");
assert_eq!(request_kind("garbage"), "unknown");
}
/// A guard registers for its lifetime and nothing longer: a handler that
/// returns must not leave a permanent "in flight" entry behind.
#[test]
fn a_guard_registers_only_while_it_lives() {
let before = INFLIGHT.lock().len();
{
let _guard = InflightGuard::new("OpenFile".into());
assert_eq!(INFLIGHT.lock().len(), before + 1);
}
assert_eq!(INFLIGHT.lock().len(), before);
}
/// Whatever else it reports, the resident size has to be a real reading.
#[test]
fn rss_is_readable() {
assert!(rss_bytes() > 0);
}
/// `VmRSS` is in kB, and a status without one must read as unknown rather
/// than as a daemon using no memory.
#[test]
fn vm_rss_is_kilobytes_and_absence_is_zero() {
assert_eq!(
parse_vm_rss("VmPeak:\t 100 kB\nVmRSS:\t 2048 kB\n"),
2048 * 1024
);
assert_eq!(parse_vm_rss("VmPeak:\t 100 kB\n"), 0);
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+7572 -1371
View File
File diff suppressed because it is too large. Load diff
+773
View File
@@ -0,0 +1,773 @@
//! Mount lifecycle and background-service orchestration.
use super::*;
/// Why a [`mount`] call returned. Lets the daemon decide whether to exit (clean
/// shutdown) or remount (the mount went away under it).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MountOutcome {
/// The daemon was asked to stop (SIGTERM/SIGINT) and we lazily unmounted
/// ourselves. The caller should exit cleanly.
Shutdown,
/// The kernel mount ended on its own (e.g. an external `fusermount -u`).
/// The caller may want to remount.
Unmounted,
}
/// Whether `path` is a mountpoint whose FUSE connection is dead — the state a
/// daemon killed before it could unmount leaves behind.
///
/// The kernel answers every operation on such a path with `ENOTCONN`, which the
/// ordinary existence checks (`is_dir`, `exists`) report as plain `false`: the
/// path looks *absent* rather than broken. Callers that would otherwise treat
/// that as "nothing to mount here" use this to tell the two apart.
pub(crate) fn is_stale_mount(path: &Path) -> bool {
matches!(
std::fs::metadata(path).map_err(|e| e.raw_os_error()),
Err(Some(libc::ENOTCONN))
)
}
/// A secondary (on-demand sync folder) FUSE session and the exact liveness flag
/// published with its forked inode state.
///
/// Keeping these together makes every teardown mark the fork unroutable before
/// unmounting, including paths that can block on an in-flight FUSE request.
pub(super) struct SecondaryMount {
session: BackgroundSession,
conn: Option<u32>,
session_live: Arc<AtomicBool>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum SecondaryInsertRejection {
Closed,
Duplicate,
}
/// Secondary sessions and the daemon-wide gate controlling whether newly
/// spawned sessions may become visible. The gate and map share one lock so
/// shutdown cannot race an insertion between checking and publishing.
pub(super) struct SecondaryMountRegistry<T> {
accepting: bool,
entries: HashMap<i64, T>,
}
impl<T> Default for SecondaryMountRegistry<T> {
fn default() -> Self {
Self {
accepting: false,
entries: HashMap::new(),
}
}
}
impl<T> SecondaryMountRegistry<T> {
pub(super) fn open(&mut self) {
self.accepting = true;
}
pub(super) fn is_accepting(&self) -> bool {
self.accepting
}
pub(super) fn close(&mut self) {
self.accepting = false;
}
pub(super) fn insert(
&mut self,
id: i64,
mount: T,
) -> Result<(), (SecondaryInsertRejection, T)> {
if !self.accepting {
return Err((SecondaryInsertRejection::Closed, mount));
}
if self.entries.contains_key(&id) {
return Err((SecondaryInsertRejection::Duplicate, mount));
}
self.entries.insert(id, mount);
Ok(())
}
pub(super) fn remove(&mut self, id: &i64) -> Option<T> {
self.entries.remove(id)
}
pub(super) fn contains_key(&self, id: &i64) -> bool {
self.entries.contains_key(id)
}
pub(super) fn close_and_drain(&mut self) -> Vec<(i64, T)> {
self.close();
self.entries.drain().collect()
}
}
impl SecondaryMount {
pub(super) fn new(
session: BackgroundSession,
conn: Option<u32>,
session_live: Arc<AtomicBool>,
) -> Self {
Self {
session,
conn,
session_live,
}
}
pub(super) fn teardown(self) -> std::io::Result<()> {
teardown_session(&self.session_live, || {
umount_session_unblocked(self.session, self.conn)
})
}
}
/// The kernel's id for the FUSE connection backing the mount at `mountpoint` —
/// the directory name under `/sys/fs/fuse/connections`, which is the minor
/// number of the mountpoint's device. Must be read *while still mounted*; after
/// unmount the path resolves to the underlying directory on another device.
/// `None` when the path can't be stat'd.
pub(super) fn fuse_connection_id(mountpoint: &Path) -> Option<u32> {
use std::os::unix::fs::MetadataExt;
let dev = std::fs::metadata(mountpoint).ok()?.dev();
Some(libc::minor(dev))
}
/// Force the kernel to abort FUSE connection `id`, erroring every in-flight
/// request.
///
/// On a stop signal we unmount lazily (`MNT_DETACH`) so the call succeeds even
/// mid-transfer — but detach only removes the mountpoint; it does *not* end the
/// connection while a request is still in flight. fuser's session loop then
/// blocks on `/dev/fuse` waiting for a next request that never comes, so `join`
/// hangs — long enough during a transfer that systemd's stop timeout SIGKILLs
/// the daemon mid-unmount, stranding the on-demand mounts as dead endpoints.
/// Writing the connection's `abort` file makes the pending reads fail with
/// `ENODEV`, so the loop returns and `join` completes at once. Best-effort:
/// there is nothing more to do on the shutdown path if it fails.
pub(super) fn abort_fuse_connection(id: u32) {
let path = format!("/sys/fs/fuse/connections/{id}/abort");
if std::fs::write(&path, b"1").is_ok() {
info!(id, "aborted FUSE connection to unblock unmount");
}
}
/// Unmount a background session that may be mid-transfer without wedging: abort
/// its connection first (so the session loop exits promptly), then lazily
/// unmount and join. `conn` is the id captured at mount time; `None` skips the
/// abort and just unmounts (a healthy idle mount joins on its own `Destroy`).
pub(super) fn umount_session_unblocked(
session: BackgroundSession,
conn: Option<u32>,
) -> std::io::Result<()> {
if let Some(id) = conn {
abort_fuse_connection(id);
}
session.umount_and_join()
}
/// Best-effort teardown of a stale mount left behind by a crashed daemon. A
/// previous run that died without unmounting leaves the kernel mount in place,
/// so the fresh `Session::new` below would fail with EBUSY ("Device or resource
/// busy"). `fusermount3 -u -z` is the lazy (detach) unmount, which succeeds even
/// when the old mount is still busy. Swallow all output/errors: if there is no
/// stale mount this is simply a no-op.
pub(super) fn clear_stale_mount(mountpoint: &Path) {
for bin in ["fusermount3", "fusermount"] {
let ok = std::process::Command::new(bin)
.arg("-u")
.arg("-z")
.arg(mountpoint)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
if ok {
info!(mountpoint = %mountpoint.display(), "cleared stale mount before remount");
return;
}
}
}
/// `sync_state` key holding the uid of the My Files root, so a later run can
/// recover it from `nodes` without the API (offline.md Phase 1).
const ROOT_UID_KEY: &str = "root_uid";
/// The My Files root, and whether we got it from the API (`true`) or from the
/// cache because the API was unreachable (`false`).
///
/// A successful fetch also records the root's uid, which is what makes the
/// fallback possible on a later run: `nodes` is keyed by uid, so without this we
/// would have the root's row on disk and no way to tell which one it is.
///
/// Failing to fetch is only fatal on a genuinely cold start — no cached root
/// means an empty tree, and mounting that would show the user an empty Drive
/// rather than an honest error.
fn fetch_or_recall_root(
client: &ProtonDriveClient,
rt: &tokio::runtime::Handle,
db: &Db,
) -> std::io::Result<(Node, bool)> {
let err = match rt.block_on(client.get_my_files_folder()) {
Ok(root) => {
if let Err(e) = db.set_state_str(ROOT_UID_KEY, &root.uid.to_string()) {
warn!(error = %e, "persist root uid failed");
}
return Ok((root, true));
}
Err(e) => e,
};
let cached = db
.state_str(ROOT_UID_KEY)
.ok()
.flatten()
.and_then(|uid| db.node_by_uid(&uid).ok().flatten());
match cached {
Some(root) => {
warn!(error = %err, "fetch My Files root failed; mounting from cache (offline)");
Ok((root, false))
}
None => Err(std::io::Error::other(format!("fetch My Files root: {err}"))),
}
}
/// Per-mount settings resolved from [`pdfs_core::config::AppConfig`] by the
/// caller, so `mount` itself never reads config or the environment.
pub struct MountOptions {
/// Proton account the session belongs to, for display and per-user paths.
pub username: String,
/// What the background conflict sweep may do. See `docs/BUGS.md` B71.
pub sweep_mode: SweepMode,
}
/// Spawn one FUSE session rooted at an arbitrary remote node.
///
/// Primary My Files and secondary on-demand device locations use this same
/// construction path so mount options, stale-mount recovery, registry
/// liveness, and notifier ownership cannot drift apart. Each caller owns its
/// state/path registration and performs it exactly once before calling here.
pub(super) fn spawn_session(
core: &Core,
mountpoint: &Path,
root: Node,
) -> std::io::Result<BackgroundSession> {
let mut config = Config::default();
// `n_threads` stays at fuser's default of 1 (with `clone_fd` off) on
// purpose, for now. A concurrent dispatch loop is worth having, but
// `release` currently relies on being serialised against the `open`+`read`
// an application issues right after `close(2)` — see the comment on
// `Filesystem::release`. Raising it before that ordering is made explicit
// (a per-node staging-in-flight barrier) reintroduces the acceptance
// suite's "concurrent file 1 mismatch". Kernel-side caps that do *not*
// depend on that ordering are negotiated in `ProtonFs::init`.
config.mount_options = vec![
MountOption::FSName("protondrive".to_string()),
MountOption::Subtype("protondrive".to_string()),
MountOption::DefaultPermissions,
];
clear_stale_mount(mountpoint);
info!(mountpoint = %mountpoint.display(), "mounting Proton Drive location");
let fs = ProtonFs::new(core.clone(), root);
if core.primary {
// Only My Files rehydrates the daemon-wide node cache. A secondary
// inode space is rooted at one device subtree and must not import every
// unrelated persisted node.
core.hydrate();
}
let session = Session::new(fs, mountpoint, &config)?.spawn()?;
let _ = core.notifier.set(session.notifier());
core.session_live.store(true, Ordering::Release);
Ok(session)
}
/// Make a session unroutable before beginning any teardown that may block.
fn teardown_session<T>(session_live: &AtomicBool, teardown: impl FnOnce() -> T) -> T {
session_live.store(false, Ordering::Release);
teardown()
}
impl Core {
/// Resolve the primary share id away from startup and publish it only if the
/// projected root has not changed while the request was in flight.
pub(crate) fn repair_primary_share_id(&self, root_uid: &NodeUid) {
match self.rt.block_on(self.client.context_share_id(root_uid)) {
Ok(share_id) => {
match self
.db
.mount_repair_my_files_share_id(&root_uid.to_string(), &share_id.to_string())
{
Ok(true) => debug!(%root_uid, "repaired My Files share id"),
Ok(false) => {
debug!(%root_uid, "discarded stale My Files share-id result");
}
Err(error) => {
warn!(%root_uid, error = %error, "persist My Files share id failed");
}
}
}
Err(error) => {
debug!(%root_uid, error = %error, "resolve My Files share id failed");
}
}
}
}
/// Mount the filesystem at `mountpoint` and block until it is unmounted or the
/// daemon is asked to stop.
///
/// Resolves the My Files root up front — from the API, or from the cached tree
/// when the API is unreachable (`fetch_or_recall_root`) — then spawns the
/// Phase 2 event-sync task, the
/// Phase 4 control socket, and runs the FUSE session on its own thread while
/// this thread waits for either a stop signal (SIGTERM/SIGINT) or the kernel
/// mount ending on its own. On a stop signal we lazily unmount ourselves
/// (`umount_and_join`, the MNT_DETACH path that succeeds even while downloads
/// are in flight), so `systemctl --user stop` is always a clean teardown.
/// `rt` must be a handle to a *running* multi-threaded runtime.
pub fn mount(
client: ProtonDriveClient,
rt: tokio::runtime::Handle,
mountpoint: &Path,
cache: ContentCache,
control_socket: &Path,
db: Arc<Db>,
options: MountOptions,
) -> std::io::Result<MountOutcome> {
let MountOptions {
username,
sweep_mode,
} = options;
// Start the uptime clock here rather than at the first `pdfs diagnostics`,
// so the age it reports is the daemon's own.
super::diagnostics::start_clock();
let (root, online) = fetch_or_recall_root(&client, &rt, &db)?;
let scope = root.tree_event_scope_id();
db.mount_upsert_my_files(&mountpoint.to_string_lossy(), &root.uid.to_string(), None)
.map_err(|error| std::io::Error::other(format!("project My Files mount: {error}")))?;
let share_access = db
.all_share_access()
.map_err(|error| std::io::Error::other(format!("load shared access: {error}")))?;
// The folder-sync engine (devices.md Phase 2) runs on its own thread and is
// nudged over this channel; the sender lives in Core so control-socket
// handlers can trigger reconciles.
let (sync_tx, sync_rx) = std::sync::mpsc::channel::<sync::SyncMsg>();
let core = Core {
client: client.clone(),
rt: rt.clone(),
maintenance: Arc::new(Mutex::new(Default::default())),
primary_root_uid: root.uid.clone(),
primary: true,
state: Arc::new(Mutex::new(State::new(db.clone(), share_access, 2))),
cache: Arc::new(cache),
readers: Arc::new(Mutex::new(HashMap::new())),
block_ring: Arc::new(Mutex::new(BlockRing::default())),
block_flight: Arc::new(BlockFlight::default()),
content_repairs: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
prefetch: Arc::new(Prefetch::default()),
prefetch_budget: Arc::new(tokio::sync::Semaphore::new(PREFETCH_BUDGET)),
workers: Arc::new(Workers::new(FUSE_WORKERS)?),
db,
shared_publication: Arc::new(Mutex::new(())),
shared_generation: Arc::new(AtomicU64::new(0)),
shared_refresh_deadlines: Arc::new(Mutex::new(SharedRefreshDeadlines::default())),
online: Arc::new(AtomicBool::new(online)),
pending: Arc::new(Mutex::new(HashMap::new())),
hidden: Arc::new(Mutex::new(HashSet::new())),
drain_wake: Arc::new((Mutex::new(false), Condvar::new())),
shutdown: Arc::new(crate::shutdown::Shutdown::default()),
upload_times: Arc::new(Mutex::new(HashMap::new())),
upload_cancel: Arc::new(Mutex::new(HashMap::new())),
timeline_refreshing: Arc::new(AtomicBool::new(false)),
albums_refreshing: Arc::new(AtomicBool::new(false)),
trash_refreshing: Arc::new(AtomicBool::new(false)),
trash_progress: Arc::new(tokio::sync::Notify::new()),
conflict_notified: Arc::new(Mutex::new(HashSet::new())),
sweep_mode,
own_sealed_revs: Arc::new(Mutex::new(HashMap::new())),
self_changes: Arc::new(Mutex::new(HashMap::new())),
thumb_gen: Arc::new(Mutex::new(HashSet::new())),
thumb_gen_budget: Arc::new(tokio::sync::Semaphore::new(
super::photos::THUMB_GEN_CONCURRENCY,
)),
file_thumb_generation: Arc::new(AtomicU64::new(0)),
file_thumb_cancel: Arc::new(tokio::sync::Notify::new()),
thumbnail_build: Arc::new(Mutex::new(Default::default())),
thumbnail_build_cancelled: Arc::new(AtomicBool::new(false)),
thumbnail_build_cancel: Arc::new(tokio::sync::Notify::new()),
thumbnail_misses: Arc::new(Mutex::new(Default::default())),
quota: Arc::new(Mutex::new(None)),
size_upgrades: Arc::new(Mutex::new(HashMap::new())),
size_waiters: Arc::new(Default::default()),
notifier: Arc::new(OnceLock::new()),
session_live: Arc::new(AtomicBool::new(false)),
transfers: TransferRegistry::new(),
indexing: Arc::new(AtomicBool::new(false)),
sync_progress: Arc::new(Mutex::new(HashMap::new())),
sync_tx,
mounts: Arc::new(Mutex::new(SecondaryMountRegistry::default())),
sync_locks: Arc::new(Mutex::new(HashMap::new())),
states: Arc::new(StateRegistry::default()),
};
// Before anything can queue work against it: the drain thread below reaches
// every mount's inode space through this registry, not through `core.state`.
core.register_state(mountpoint);
// Writes queued by a previous run (or left behind by a crash) are still owed
// an upload, and reads must be served from their staged blobs until they land.
core.hydrate_pending();
// Then the writes that were fsynced but never closed, which the cache moved
// aside at open. After `hydrate_pending`, so a recovered partial write can
// see an already-queued write to the same node.
core.recover_fsynced_writes();
// And finally the staged blobs neither of those accounts for: bytes a
// failed release left behind with nothing pointing at them.
core.reconcile_staging();
// Several drain workers over one queue. One thread meant a single large
// upload held every queued rename, trash and small write behind it for as
// long as it ran; the claim column (`Db::claim_next_due_op`) keeps the
// workers off each other's nodes, which is the only ordering the queue
// actually needs. Worker 0 additionally runs the queue's idle chores.
//
// Every long-lived thread started from here on is kept in `workers` and
// joined at the end of this function. Leaving them running turned an
// in-process remount into a second generation of drain workers, sync engines
// and sweeps all operating on a mount that no longer exists (bugs.md B44).
let mut workers: Vec<std::thread::JoinHandle<()>> = Vec::new();
for worker in 0..DRAIN_WORKERS {
let core = core.clone();
workers.push(
std::thread::Builder::new()
.name(format!("pdfs-drain-{worker}"))
.spawn(move || core.run_pending_drain(worker == 0))?,
);
}
// Start the folder-sync engine. It watches every mirror folder, polls the
// remotes, and reconciles on its own thread — never in front of a FUSE call.
workers.extend(sync::spawn(core.clone(), sync_rx));
// Reconcile leftover `(sync-conflict …)` copies: drop the ones that turned
// out identical to the live file, surface the ones that genuinely diverge.
// Its own thread — it enumerates and trashes, and must never block a FUSE call.
// `Off` skips the thread entirely rather than starting an idle one, so the
// setting is verifiable from the outside (`ls /proc/<pid>/task/*/comm`).
if sweep_mode == SweepMode::Off {
info!("conflict sweep disabled by configuration");
} else {
info!(mode = ?sweep_mode, "conflict sweep enabled");
let core = core.clone();
workers.push(
std::thread::Builder::new()
.name("pdfs-conflict-sweep".into())
.spawn(move || core.run_conflict_sweep_loop())?,
);
}
// Mounted from the cache: watch for the network coming back so the mount can
// stop being read-only-ish without the user restarting the daemon.
if !online {
let core = core.clone();
workers.push(
std::thread::Builder::new()
.name("pdfs-online-probe".into())
.spawn(move || core.run_online_probe())?,
);
}
// Keep the launcher prompt's "This computer" index fresh. Its own thread:
// the walk is I/O-heavy and must never sit in front of a FUSE callback.
{
let db = core.db.clone();
let indexing = core.indexing.clone();
let transfers = core.transfers.clone();
let mountpoint = mountpoint.to_path_buf();
let shutdown = core.shutdown.clone();
workers.push(
std::thread::Builder::new()
.name("pdfs-localindex".into())
.spawn(move || run_local_index(db, indexing, transfers, mountpoint, shutdown))?,
);
}
// Bind the control socket before the FUSE session takes over the thread. A
// stale socket file from a previous run would block the bind, so clear it.
let _ = std::fs::remove_file(control_socket);
let old_umask = unsafe { libc::umask(0o77) };
let listener_res = UnixListener::bind(control_socket);
unsafe { libc::umask(old_umask) };
let listener = listener_res?;
// Owner-only before anything can connect: a peer on this socket commands the
// daemon's authenticated session without a credential of its own (B6).
if let Err(e) = pdfs_core::config::restrict_socket(control_socket) {
error!(error = %e, "cannot restrict control socket permissions; refusing to serve");
let _ = std::fs::remove_file(control_socket);
return Err(std::io::Error::other(format!(
"control socket permissions: {e}"
)));
}
// Do not accept control requests until `spawn_session` has transitioned the
// primary registration to live. The listener may queue a connection during
// this short window, but its handler cannot observe `mounted = false`.
let bg = match spawn_session(&core, mountpoint, root) {
Ok(session) => session,
Err(error) => {
let _ = std::fs::remove_file(control_socket);
return Err(error);
}
};
core.mounts.lock().open();
// The connection id, captured now while the mount is live, so a stop signal
// mid-transfer can abort it rather than block `join` (see `abort_fuse_connection`).
let main_conn = fuse_connection_id(mountpoint);
{
let control_core = core.clone();
let username = username.clone();
let mountpoint = mountpoint.to_path_buf();
match std::thread::Builder::new()
.name("pdfs-control".into())
.spawn(move || run_control_socket(control_core, username, mountpoint, listener))
{
Ok(handle) => workers.push(handle),
Err(error) => {
// A failure here abandons the mount, so the background threads
// started above have to be told to stop with it.
stop_workers(&core, control_socket, workers);
let secondaries = core.mounts.lock().close_and_drain();
for (id, mount) in secondaries {
if let Err(unmount_error) = mount.teardown() {
warn!(id, error = %unmount_error, "secondary teardown after control startup failure failed");
}
}
if let Err(unmount_error) = teardown_session(&core.session_live, || {
umount_session_unblocked(bg, main_conn)
}) {
warn!(error = %unmount_error, "unmount after control startup failure failed");
}
let _ = std::fs::remove_file(control_socket);
return Err(error);
}
}
}
// The supervisor watches the pool and answers the systemd watchdog. Started
// after the listener, because its liveness probe is a round trip over that
// socket.
{
let supervisor_core = core.clone();
let socket = control_socket.to_path_buf();
match std::thread::Builder::new()
.name("pdfs-supervisor".into())
.spawn(move || supervisor::run(supervisor_core, socket))
{
Ok(handle) => workers.push(handle),
// Not fatal: the mount works without a supervisor, it just goes back
// to being undiagnosable if it wedges.
Err(error) => warn!(error = %error, "could not start the daemon supervisor"),
}
}
// The mount is live and the control socket answers: this is the moment the
// unit is actually usable, which is what `Type=notify` reports.
systemd::ready();
// Re-establish on-demand mounts only after the primary session is live.
// Keep the worker so shutdown can close publication, wait for any in-flight
// fetch to observe that closure, and then drain a stable registry.
let restore_worker = {
let restore_core = core.clone();
match std::thread::Builder::new()
.name("pdfs-restore-ondemand".into())
.spawn(move || restore_core.restore_ondemand_mounts())
{
Ok(worker) => Some(worker),
Err(error) => {
warn!(%error, "start on-demand restore worker failed");
None
}
}
};
rt.spawn(run_event_sync(client, scope, core.clone()));
// The photos volume has its own event stream; without it the gallery only
// learns about a phone-side deletion when the timeline goes stale.
rt.spawn(run_photos_event_sync(core.clone()));
if online {
let repair_core = core.clone();
let root_uid = core.primary_root_uid.clone();
if let Err(error) = std::thread::Builder::new()
.name("pdfs-share-id".into())
.spawn(move || repair_core.repair_primary_share_id(&root_uid))
{
warn!(%error, "start My Files share-id repair failed");
}
}
// Stop signals (SIGTERM from `systemctl --user stop`, SIGINT from Ctrl-C)
// are delivered onto the async runtime; bridge them onto a sync channel so
// the loop below can react without blocking a worker thread. A bounded
// channel of 1 is enough — we only need to know that *a* stop arrived.
let (sig_tx, sig_rx) = std::sync::mpsc::sync_channel::<()>(1);
rt.spawn(async move {
let mut sigterm =
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
Ok(s) => s,
Err(e) => {
warn!(error = %e, "install SIGTERM handler failed");
return;
}
};
let mut sigint =
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt()) {
Ok(s) => s,
Err(e) => {
warn!(error = %e, "install SIGINT handler failed");
return;
}
};
tokio::select! {
_ = sigterm.recv() => info!("received SIGTERM"),
_ = sigint.recv() => info!("received SIGINT"),
}
let _ = sig_tx.try_send(());
});
// Wait for whichever happens first: a stop signal (→ we unmount ourselves
// via the lazy MNT_DETACH path, clean even mid-download), or the kernel
// mount ending on its own (→ the session thread finishes). Poll instead of
// blocking on `join` so we can also notice the signal.
let outcome = loop {
match sig_rx.recv_timeout(Duration::from_millis(500)) {
Ok(()) => {
info!("stop requested; unmounting");
core.mounts.lock().close();
if let Err(e) = teardown_session(&core.session_live, || {
umount_session_unblocked(bg, main_conn)
}) {
warn!(error = %e, "umount_and_join failed");
}
break MountOutcome::Shutdown;
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
if bg.guard.is_finished() {
info!("mount ended externally");
core.mounts.lock().close();
if let Err(e) = teardown_session(&core.session_live, || bg.join()) {
warn!(error = %e, "session join failed");
}
break MountOutcome::Unmounted;
}
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
// Signal task gone (failed to install); fall back to join.
core.mounts.lock().close();
let _ = teardown_session(&core.session_live, || bg.join());
break MountOutcome::Unmounted;
}
}
};
// Unmount every on-demand sync folder too, or the kernel mounts linger as
// stale and the next start fails with EBUSY (devices.md Phase 3).
if let Some(worker) = restore_worker
&& worker.join().is_err()
{
warn!("on-demand restore worker panicked");
}
let secondaries = core.mounts.lock().close_and_drain();
for (id, mount) in secondaries {
if let Err(e) = mount.teardown() {
warn!(id, error = %e, "unmount on-demand folder failed");
}
}
// Last, because the workers may legitimately be finishing an upload, and
// nothing they can still do is harmful once the mounts are down.
stop_workers(&core, control_socket, workers);
Ok(outcome)
}
/// How long teardown waits for the FUSE worker pool before proceeding without
/// it. Long enough for an in-flight block to finish, short enough that
/// `systemctl --user stop` still returns inside `TimeoutStopSec`.
const WORKER_JOIN_DEADLINE: Duration = Duration::from_secs(10);
/// Signal every background loop, wake the ones that are blocked, and join them.
///
/// The mount used to return with its drain workers, sync engine, sweep, online
/// probe, indexer and control listener all still running on `Core` clones. The
/// process usually exited straight afterwards and hid it; an in-process remount
/// did not, and started a second full set on top of the first (bugs.md B44).
///
/// Two of the waits need waking rather than merely flagging: the drain workers
/// block on their own condvar (`wake_drain`), and the control listener blocks
/// inside `accept`, which only returns when a connection arrives — so it gets
/// one, from us, after the flag is already set. A join that takes a while is
/// expected and correct: a worker part-way through an upload finishes it rather
/// than abandoning the user's bytes mid-flight.
fn stop_workers(core: &Core, control_socket: &Path, workers: Vec<std::thread::JoinHandle<()>>) {
// Say so before the joins: a teardown that takes a while must not be read as
// a missed watchdog ping and turned into a kill.
systemd::stopping();
core.shutdown.stop();
core.wake_drain();
let _ = core.sync_tx.send(sync::SyncMsg::Stop);
// Connect while the socket still exists — that is the whole point of the
// poke — then unlink it so nothing else can arrive behind us. The listener
// re-checks the flag after every accept, so even a connection that races the
// unlink cannot put it back to sleep.
let _ = std::os::unix::net::UnixStream::connect(control_socket);
let _ = std::fs::remove_file(control_socket);
for worker in workers {
if worker.join().is_err() {
warn!("a background worker panicked before shutdown");
}
}
// The FUSE worker pool last, and *before* the caller drops the tokio
// runtime: a worker still inside a job when the runtime goes away panics on
// its next timer with "A Tokio 1.x context was found, but it is being
// shutdown" (seen 2026-09-18 09:58:35). Bounded, so a worker wedged on the
// network cannot hold the stop the user asked for.
core.workers.stop_and_join(WORKER_JOIN_DEADLINE);
debug!("background workers stopped");
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicBool, Ordering};
use super::{SecondaryInsertRejection, SecondaryMountRegistry, teardown_session};
#[test]
fn session_is_unroutable_before_teardown_starts() {
let live = AtomicBool::new(true);
teardown_session(&live, || {
assert!(
!live.load(Ordering::Acquire),
"teardown must never run while the registry still reports mounted"
);
});
}
#[test]
fn secondary_registry_rejects_closed_and_duplicate_insertions() {
let mut mounts = SecondaryMountRegistry::default();
assert_eq!(
mounts.insert(7, "closed"),
Err((SecondaryInsertRejection::Closed, "closed"))
);
mounts.open();
assert_eq!(mounts.insert(7, "first"), Ok(()));
assert_eq!(
mounts.insert(7, "duplicate"),
Err((SecondaryInsertRejection::Duplicate, "duplicate"))
);
assert!(mounts.contains_key(&7));
assert_eq!(mounts.close_and_drain(), vec![(7, "first")]);
assert!(!mounts.is_accepting());
}
}
File diff suppressed because it is too large. Load diff
+537
View File
@@ -0,0 +1,537 @@
//! Reading and writing this machine's `profile.json` in its device root, and
//! the restore that rebuilds a machine from one (features.md 5.2 and 5.3).
//!
//! The document itself is defined in [`pdfs_core::profile`]; this is the half
//! that talks to Drive. Saving is best-effort and never blocks a user action:
//! the profile is a convenience for a *future* machine, so failing to write it
//! must not fail the folder add that triggered it.
use std::io::Cursor;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use std::sync::atomic::{AtomicBool, Ordering};
use pdfs_core::control::RestorableFolder;
use pdfs_core::mounts::MountMode;
use pdfs_core::profile::{
PROFILE_DIR_NAME, PROFILE_FILE_NAME, PROFILE_VERSION, Profile, ProfileFolder, ProfilePin,
};
use pdfs_core::{CoreError, CoreResult};
use proton_drive_rs::proton_sdk::ids::NodeUid;
use tracing::{info, warn};
use super::sync;
use super::{Core, now_secs, parse_uid, this_hostname};
/// Coalescing flags for background profile saves.
///
/// A single burst of user actions (adding three folders, pinning a tree) should
/// cost one upload, not one per action. `SAVING` admits one worker at a time and
/// `DIRTY` records that something changed while it was busy, so the worker takes
/// exactly one more lap afterwards.
///
/// Process-global rather than a [`Core`] field because on-demand mounts fork
/// their own `Core` (`Core::fork_state`) and all of them describe the same
/// machine — there is one profile per daemon, so there is one flag pair.
static SAVE_STATE: OnceLock<(AtomicBool, AtomicBool)> = OnceLock::new();
fn save_state() -> &'static (AtomicBool, AtomicBool) {
SAVE_STATE.get_or_init(|| (AtomicBool::new(false), AtomicBool::new(false)))
}
impl Core {
/// Note that the arrangement this machine's profile describes has changed,
/// and upload a fresh profile in the background.
///
/// Fire-and-forget by design: callers are mutation paths (add/remove a sync
/// folder, switch a mode, pin a node, save settings) whose success does not
/// depend on the backup succeeding.
pub(crate) fn touch_profile(&self) {
let (saving, dirty) = save_state();
dirty.store(true, Ordering::SeqCst);
// Someone is already uploading; they will see DIRTY and take another lap.
if saving.swap(true, Ordering::SeqCst) {
return;
}
let core = self.clone();
std::thread::spawn(move || {
let (saving, dirty) = save_state();
while dirty.swap(false, Ordering::SeqCst) {
if let Err(e) = core.save_profile() {
warn!(error = %e.message, "profile backup failed");
break;
}
}
saving.store(false, Ordering::SeqCst);
});
}
/// Serialize this machine's arrangement and upload it to `profile.json`
/// inside the device's `.proton-drive-linux` folder, replacing any existing
/// one.
///
/// Does nothing when no device is registered: a machine that has never
/// synced a folder has nothing to describe, and registering a device just to
/// hold an empty profile would put a stray computer in the user's Drive.
pub(crate) fn save_profile(&self) -> CoreResult<()> {
let Some(device) = self
.db
.device_get()
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
else {
return Ok(());
};
let root_uid = parse_uid(&device.root_uid).ok_or_else(|| {
CoreError::internal(format!("bad device root uid: {}", device.root_uid))
})?;
let profile = self.build_profile(&device.uid)?;
let bytes = profile.to_bytes().map_err(CoreError::internal)?;
let len = bytes.len() as i64;
// The device root itself rejects file creation (B78), so the profile
// lives one level down, in a folder created on first save.
let dir_uid = self.ensure_profile_dir(&root_uid)?;
// Replace the existing document rather than adding a second file of the
// same name: the profile is a single mutable record, and a folder with
// `profile.json` and `profile (1).json` in it teaches the restore
// nothing about which to trust.
let existing = self.find_device_child_file(&dir_uid, PROFILE_FILE_NAME)?;
match existing {
Some(uid) => self
.rt
.block_on(self.client.upload_new_revision_from(
&uid,
Cursor::new(bytes),
len,
Vec::new(),
Some(now_secs()),
))
.map(|_| ())
.map_err(|e| CoreError::from_api(&e, "upload profile revision"))?,
None => {
self.rt
.block_on(self.client.upload_file_replacing_draft_from(
&dir_uid,
PROFILE_FILE_NAME,
"application/json",
Cursor::new(bytes),
len,
Vec::new(),
Some(now_secs()),
false,
))
.map_err(|e| CoreError::from_api(&e, "upload profile"))?;
}
}
info!(folders = profile.folders.len(), "profile backed up");
Ok(())
}
/// Snapshot the local arrangement into a [`Profile`].
fn build_profile(&self, device_uid: &str) -> CoreResult<Profile> {
let folders = self
.db
.sync_folder_list()
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
.into_iter()
.map(|f| ProfileFolder {
remote_uid: f.remote_uid,
local_path: f.local_path,
// A queued switch is where the user asked the folder to be, so
// that is what the next machine should set up.
mode: MountMode::from(f.pending_mode.unwrap_or(f.mode).as_str()),
})
.collect();
let pins = self
.db
.pin_list()
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
.into_iter()
.map(|row| ProfilePin {
uid: row.uid,
path: row.path,
recursive: row.recursive,
})
.collect();
let cfg = pdfs_core::config::AppDirs::new()
.map(|d| d.load_config())
.unwrap_or_default();
Ok(Profile {
version: PROFILE_VERSION,
device_uid: device_uid.to_string(),
hostname: this_hostname(),
saved_at: now_secs(),
folders,
pins,
ignore_patterns: cfg.ignore_patterns,
cache_budget: cfg.cache_budget,
mountpoint: cfg.mountpoint,
})
}
/// An untrashed *file* named `name` directly under `parent_uid`.
///
/// The sibling of `find_device_child_folder`; kept separate because a folder
/// named `profile.json` must not be mistaken for the profile.
fn find_device_child_file(
&self,
parent_uid: &NodeUid,
name: &str,
) -> CoreResult<Option<NodeUid>> {
let uids = self
.rt
.block_on(self.client.enumerate_folder_children_node_uids(parent_uid))
.map_err(|e| CoreError::from_api(&e, "list device root"))?;
if uids.is_empty() {
return Ok(None);
}
let nodes = self
.rt
.block_on(self.client.enumerate_nodes(&uids))
.map_err(|e| CoreError::from_api(&e, "resolve device root children"))?;
Ok(nodes
.into_iter()
.find(|n| !n.is_folder() && !n.trashed && n.name == name)
.map(|n| n.uid))
}
/// The device's `.proton-drive-linux` folder, creating it if this is the
/// first save on this device.
///
/// Reused by stable identity rather than recreated: a second folder of the
/// same name would split the profile across two documents, and the restore
/// would have no way to tell which is current.
fn ensure_profile_dir(&self, root_uid: &NodeUid) -> CoreResult<NodeUid> {
if let Some(uid) = self.find_device_child_folder(root_uid, PROFILE_DIR_NAME)? {
return Ok(uid);
}
let uid = self
.rt
.block_on(
self.client
.create_folder(root_uid, PROFILE_DIR_NAME, Some(now_secs())),
)
.map_err(|e| CoreError::from_api(&e, "create profile folder"))?;
info!(name = PROFILE_DIR_NAME, "created device profile folder");
Ok(uid)
}
/// Download and parse this device's profile, if there is one.
///
/// Looks in `.proton-drive-linux/` first and falls back to the device root,
/// where clients before the B78 fix wrote it. The fallback is read-only: the
/// next save writes to the folder, and the legacy document is left alone
/// rather than trashed, so an older client on another machine keeps working.
fn load_profile(&self, root_uid: &NodeUid) -> CoreResult<Option<Profile>> {
let current = match self.find_device_child_folder(root_uid, PROFILE_DIR_NAME)? {
Some(dir_uid) => self.find_device_child_file(&dir_uid, PROFILE_FILE_NAME)?,
None => None,
};
let uid = match current {
Some(uid) => uid,
None => match self.find_device_child_file(root_uid, PROFILE_FILE_NAME)? {
Some(uid) => {
info!("reading legacy profile from the device root");
uid
}
None => return Ok(None),
},
};
let mut buf: Vec<u8> = Vec::new();
self.rt
.block_on(self.client.download_file_to(&uid, &mut buf))
.map_err(|e| CoreError::from_api(&e, "download profile"))?;
Profile::parse(&buf).map(Some).map_err(CoreError::invalid)
}
// ---- restore (features.md 5.2) ----------------------------------------
/// The folders under this machine's device that could be synced here, each
/// carrying a proposed local path.
///
/// The proposal comes from the profile when one exists and its path is
/// usable on this machine, and from `~/<name>` otherwise — a restored
/// `/home/other-user/Documents` would be wrong on a machine where that user
/// does not exist, and silently creating it would be worse.
pub(crate) fn list_restorable_folders(&self) -> CoreResult<Vec<RestorableFolder>> {
let device = self.ensure_device()?;
let root_uid = parse_uid(&device.root_uid).ok_or_else(|| {
CoreError::internal(format!("bad device root uid: {}", device.root_uid))
})?;
let profile = match self.load_profile(&root_uid) {
Ok(p) => p,
// A missing or unreadable profile is not fatal: the remote tree
// alone is enough to offer a restore, just with plainer defaults.
Err(e) => {
warn!(error = %e.message, "could not read profile; proposing defaults");
None
}
};
let uids = self
.rt
.block_on(self.client.enumerate_folder_children_node_uids(&root_uid))
.map_err(|e| CoreError::from_api(&e, "list device root"))?;
let nodes = if uids.is_empty() {
Vec::new()
} else {
self.rt
.block_on(self.client.enumerate_nodes(&uids))
.map_err(|e| CoreError::from_api(&e, "resolve device root children"))?
};
// Folders already synced here are not restorable — they are already
// restored — but they are still listed, flagged, so the picker can show
// the whole device rather than a confusing subset.
let existing: Vec<String> = self
.db
.sync_folder_list()
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
.into_iter()
.map(|f| f.remote_uid)
.collect();
Ok(nodes
.into_iter()
// `.proton-drive-linux` holds the profile itself, not user data:
// offering it as a restorable folder would sync the bookkeeping
// back onto the machine it describes.
.filter(|n| n.is_folder() && !n.trashed && n.name != PROFILE_DIR_NAME)
.map(|n| {
let uid = n.uid.to_string();
let saved = profile
.as_ref()
.and_then(|p| p.folders.iter().find(|f| f.remote_uid == uid));
let proposed = saved
.map(|f| PathBuf::from(&f.local_path))
.filter(|p| path_is_usable_here(p))
.unwrap_or_else(|| default_local_path(&n.name));
RestorableFolder {
already_synced: existing.iter().any(|e| e == &uid),
remote_uid: uid,
name: n.name,
local_path: proposed.to_string_lossy().to_string(),
mode: saved
.map(|f| f.mode.to_string())
.unwrap_or_else(|| "mirror".into()),
}
})
.collect())
}
/// Attach remote device folders to local paths and let the sync engine fill
/// them in.
///
/// The download is not written here: a new sync-folder row starts with an
/// empty baseline, and an empty baseline against an empty local directory
/// and a populated remote already classifies as "download everything". The
/// restore is therefore just bookkeeping plus a `Reconcile`, and it reuses
/// the engine's conflict handling for the case where the local directory
/// turns out not to be empty after all.
pub(crate) fn restore_sync_folders(
&self,
items: &[pdfs_core::control::RestoreItem],
) -> CoreResult<String> {
if items.is_empty() {
return Err(CoreError::invalid("nothing to restore"));
}
let device = self.ensure_device()?;
let existing = self
.db
.sync_folder_list()
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
let mut restored = 0usize;
let mut skipped: Vec<String> = Vec::new();
for item in items {
let local = PathBuf::from(&item.local_path);
if !local.is_absolute() {
skipped.push(format!("{}: local path must be absolute", item.local_path));
continue;
}
if existing.iter().any(|f| f.remote_uid == item.remote_uid) {
skipped.push(format!("{}: already synced", item.local_path));
continue;
}
if existing
.iter()
.any(|f| Path::new(&f.local_path) == local.as_path())
{
skipped.push(format!("{}: path already in use", item.local_path));
continue;
}
if let Err(e) = std::fs::create_dir_all(&local) {
skipped.push(format!("{}: {e}", item.local_path));
continue;
}
let mode = match restore_mode(&item.mode) {
Some(mode) => mode,
None => {
skipped.push(format!("{}: unknown mode {}", item.local_path, item.mode));
continue;
}
};
let id = self
.db
.sync_folder_add(&local.to_string_lossy(), &item.remote_uid, &device.share_id)
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
// `ondemand` is applied through the normal request path, which
// mounts the FUSE session and handles the folder being busy — the
// restore does not get its own copy of that logic.
if mode == MountMode::OnDemand {
let _ = self.request_sync_folder_mode(id, "ondemand");
}
let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id));
restored += 1;
}
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
let mut message = format!("restored {restored} folder(s)");
if !skipped.is_empty() {
message.push_str(&format!("; skipped: {}", skipped.join(", ")));
}
info!(restored, skipped = skipped.len(), "restore complete");
Ok(message)
}
}
/// Accept only modes this build can safely restore. `MountMode` itself is
/// tolerant so a future value does not reject the whole profile; the folder
/// carrying that value is skipped here with the message assembled above.
fn restore_mode(value: &str) -> Option<MountMode> {
match MountMode::from(value) {
MountMode::Mirror => Some(MountMode::Mirror),
MountMode::OnDemand => Some(MountMode::OnDemand),
MountMode::Unknown => None,
}
}
/// Whether a path recorded on another machine can be reused verbatim here.
///
/// Requires the *parent* to exist: `~/Documents` on a machine with the same
/// username is a fine proposal even though the directory itself is gone (the
/// restore creates it), but `/home/someone-else/Documents` is not.
fn path_is_usable_here(path: &Path) -> bool {
path.is_absolute() && path.parent().is_some_and(|p| p.is_dir())
}
/// `~/<name>`, or `/<name>` if the home directory cannot be determined.
fn default_local_path(name: &str) -> PathBuf {
match pdfs_core::config::AppDirs::new()
.ok()
.and_then(|d| d.home_dir())
{
Some(home) => home.join(name),
None => PathBuf::from("/").join(name),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn foreign_home_is_not_reused() {
assert!(!path_is_usable_here(Path::new(
"/home/definitely-not-a-user-here/Documents"
)));
assert!(!path_is_usable_here(Path::new("relative/path")));
}
#[test]
fn unknown_profile_mode_is_skipped_without_rejecting_known_modes() {
assert_eq!(restore_mode("mirror"), Some(MountMode::Mirror));
assert_eq!(restore_mode("ondemand"), Some(MountMode::OnDemand));
assert_eq!(restore_mode("streaming"), None);
assert_eq!(restore_mode("unknown"), None);
}
/// The device root refuses file creation (B78), so every upload in
/// `save_profile` must be addressed to the profile folder. Checked against
/// the source because both branches are pure network calls.
#[test]
fn the_profile_is_never_uploaded_to_the_device_root() {
let source = include_str!("profile.rs");
let save = function_source(source, "fn save_profile(");
assert!(
save.contains("ensure_profile_dir(&root_uid)"),
"save_profile resolves the profile folder"
);
for upload in [
"upload_new_revision_from",
"upload_file_replacing_draft_from",
] {
let call = save.find(upload).expect("upload call exists");
let args = &save[call..];
let root = args.find("&root_uid").unwrap_or(usize::MAX);
let dir = args.find("&dir_uid").unwrap_or(usize::MAX);
assert!(dir < root, "{upload} must target the profile folder");
}
}
/// A read has to keep finding profiles written before the fix, which sat in
/// the device root.
#[test]
fn loading_falls_back_to_a_legacy_device_root_profile() {
let source = include_str!("profile.rs");
let load = function_source(source, "fn load_profile(");
assert!(load.contains("PROFILE_DIR_NAME"), "prefers the folder");
assert!(
load.find("PROFILE_DIR_NAME").unwrap()
< load.find("legacy profile").expect("legacy fallback exists"),
"the folder is consulted before the device root"
);
}
/// The profile folder is the client's own bookkeeping: neither the restore
/// picker nor `add_sync_folder` may treat it as a user folder.
#[test]
fn the_profile_folder_is_not_offered_as_user_data() {
let restorable = function_source(include_str!("profile.rs"), "fn list_restorable_folders(");
assert!(
restorable.contains("n.name != PROFILE_DIR_NAME"),
"restorable folders exclude the profile folder"
);
let add = function_source(include_str!("devices.rs"), "fn add_sync_folder(");
assert_before(add, "PROFILE_DIR_NAME", "ensure_device()");
}
fn function_source<'a>(source: &'a str, signature: &str) -> &'a str {
let start = source.find(signature).expect("function signature exists");
let body = &source[start..];
let open = body.find('{').expect("function body starts");
let mut depth = 0usize;
for (offset, byte) in body.as_bytes()[open..].iter().enumerate() {
match byte {
b'{' => depth += 1,
b'}' => {
depth -= 1;
if depth == 0 {
return &body[..open + offset + 1];
}
}
_ => {}
}
}
panic!("function body closes")
}
fn assert_before(source: &str, first: &str, second: &str) {
let first_at = source.find(first).expect("first operation exists");
let second_at = source.find(second).expect("second operation exists");
assert!(first_at < second_at, "{first} must precede {second}");
}
/// A path whose parent exists but which does not itself is exactly the
/// restore case — the directory is about to be created.
#[test]
fn missing_leaf_under_existing_parent_is_reused() {
let dir = std::env::temp_dir().join("pdfs-restore-probe");
std::fs::create_dir_all(&dir).unwrap();
assert!(path_is_usable_here(&dir.join("Documents")));
let _ = std::fs::remove_dir_all(&dir);
}
}
File diff suppressed because it is too large. Load diff
+332
View File
@@ -0,0 +1,332 @@
//! Repairing photos whose capture time is when they were *imported* rather than
//! when they were taken.
//!
//! A Google Photos Takeout whose metadata sidecars did not match — a localized
//! `.supplemental-metadata` spelling, say — imports with no capture time at all,
//! and the SDK then seals "now" into every revision. A decade of photos lands on
//! one day.
//!
//! Proton has no API for editing a sealed capture time, so the repair is a
//! rewrite: download the photo, upload it again with the capture time its file
//! name implies, put the new copy back into the albums and favourites the old
//! one was in, then **trash** (not delete) the original. Trash rather than
//! delete so a bad run is recoverable from Proton's own trash for 30 days.
//!
//! A photo is a candidate only when its name carries a date that disagrees with
//! its stored capture time by more than [`DRIFT`]. That is deliberately narrow:
//! a photo whose name says nothing about its date is left alone, because there
//! is nothing better to set it to.
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use pdfs_core::control::{RedateSummary, TransferDirection};
use pdfs_core::takeout::{capture_time_from_name, media_type_of};
use pdfs_core::{CoreError, CoreResult};
use proton_drive_rs::proton_sdk::ids::NodeUid;
use proton_drive_rs::{PhotoTag, PhotoTagsUpdate, PhotoUploadMetadata};
use tracing::{info, warn};
use super::Core;
use crate::parse_uid as parse_node_uid;
use crate::transfers::CountingReader;
/// How far a stored capture time may sit from the one the file name implies
/// before the photo counts as mis-dated. A day, because a name usually carries
/// only the date and the stored time carries a clock — and because a timezone
/// can legitimately move a photo by hours.
const DRIFT: i64 = 86_400;
/// How many examples a report carries.
const SAMPLES: usize = 10;
/// The one re-date this daemon may be running, and how the last one ended.
/// Process-global for the same reasons as the Takeout import's state: one run
/// per daemon however many `Core` clones exist, and a front-end asking after it
/// finished must still find the report.
static REDATE: std::sync::LazyLock<parking_lot::Mutex<RedateState>> =
std::sync::LazyLock::new(|| parking_lot::Mutex::new(RedateState::default()));
#[derive(Default)]
struct RedateState {
running: bool,
cancel: Option<Arc<AtomicBool>>,
last: Option<RedateSummary>,
}
/// Whether a re-date is running, and the last one's report.
pub(crate) fn redate_status() -> (bool, Option<RedateSummary>) {
let state = REDATE.lock();
(state.running, state.last.clone())
}
/// Ask the running re-date to stop. False when none is running.
pub(crate) fn cancel_redate() -> bool {
match &REDATE.lock().cancel {
Some(flag) => {
flag.store(true, Ordering::Relaxed);
true
}
None => false,
}
}
/// One photo to repair.
struct Candidate {
uid: NodeUid,
name: String,
/// The capture time it carries now.
stored: i64,
/// The capture time its name implies.
wanted: i64,
favorite: bool,
/// Album node uids the original is filed under.
albums: Vec<String>,
}
impl Core {
/// Re-date mis-dated photos. Blocking; run on its own thread.
///
/// `cancel` stops the run at the next photo boundary. A photo is only ever
/// trashed after its replacement has uploaded, so a cancel — or a crash —
/// can leave a duplicate, never a hole.
pub(crate) fn redate_photos(
&self,
dry_run: bool,
limit: Option<usize>,
range: Option<(i64, i64)>,
cancel: Arc<AtomicBool>,
) -> CoreResult<RedateSummary> {
let job = self.transfers.begin_job("Fixing photo dates");
job.detail("looking for mis-dated photos");
let stored = self
.db
.photos_all()
.map_err(|e| CoreError::internal(format!("read timeline: {e}")))?;
let mut summary = RedateSummary {
examined: stored.len(),
dry_run,
..Default::default()
};
let mut candidates = Vec::new();
for photo in stored {
if let Some((from, to)) = range
&& !(from..to).contains(&photo.capture_time)
{
continue;
}
let Some(name) = photo.name.filter(|n| !n.is_empty()) else {
continue;
};
let Some(wanted) = capture_time_from_name(&name) else {
continue;
};
if (wanted - photo.capture_time).abs() <= DRIFT {
continue;
}
let Some(uid) = parse_node_uid(&photo.uid) else {
continue;
};
candidates.push(Candidate {
uid,
name,
stored: photo.capture_time,
wanted,
favorite: photo.favorite,
albums: self.db.albums_of_photo(&photo.uid).unwrap_or_default(),
});
}
if let Some(limit) = limit {
candidates.truncate(limit);
}
summary.candidates = candidates.len();
// Sampled after the limit, so the examples are of photos the run would
// actually touch.
summary.samples = candidates
.iter()
.take(SAMPLES)
.map(|c| (c.name.clone(), c.stored, c.wanted))
.collect();
job.set_total(candidates.len() as u64);
info!(
examined = summary.examined,
candidates = summary.candidates,
dry_run,
"photo re-date scan complete"
);
if dry_run || candidates.is_empty() {
return Ok(summary);
}
for candidate in candidates {
if cancel.load(Ordering::Relaxed) || self.shutdown.is_stopping() {
break;
}
job.detail(candidate.name.clone());
if let Err(e) = self.redate_one(&candidate, &mut summary) {
summary.failed += 1;
warn!(name = candidate.name, error = %e, "cannot re-date photo");
}
job.step();
}
self.spawn_timeline_refresh();
summary.cancelled = cancel.load(Ordering::Relaxed);
Ok(summary)
}
/// Rewrite one photo at the capture time its name implies.
///
/// Order is load-bearing: upload first, file the new copy, and only then
/// trash the original. Every earlier step failing leaves the account exactly
/// as it was.
fn redate_one(&self, candidate: &Candidate, summary: &mut RedateSummary) -> CoreResult<()> {
let photos = self.photos();
let path = self.open_photo(&candidate.uid)?;
let size = std::fs::metadata(&path)
.map_err(|e| CoreError::internal(format!("stat {}: {e}", path.display())))?
.len();
let media_type = media_type_of(&candidate.name).unwrap_or("application/octet-stream");
let guard =
self.transfers
.begin(candidate.name.clone(), "", TransferDirection::Upload, size);
let file = std::fs::File::open(&path)
.map_err(|e| CoreError::internal(format!("open {}: {e}", path.display())))?;
let reader = CountingReader::new(std::io::BufReader::new(file), &guard);
let metadata = PhotoUploadMetadata {
capture_time: Some(candidate.wanted),
..Default::default()
};
let uploaded = self.rt.block_on(photos.upload_photo_from(
&candidate.name,
media_type,
reader,
size as i64,
Vec::new(),
metadata,
false,
));
drop(guard);
let new_uid = uploaded.map_err(|e| CoreError::from_api(&e, "re-upload photo"))?;
// Favourite and albums before the trash: a failure here leaves both
// copies on the account, which is recoverable by hand. Losing the
// original first would not be.
if candidate.favorite {
let update = PhotoTagsUpdate {
node_uid: new_uid.clone(),
tags_to_add: vec![PhotoTag::Favorite],
tags_to_remove: Vec::new(),
};
match self.rt.block_on(photos.update_photos(&[update])) {
// As in `set_photo_favorite`: claim our own echo, so the tag we
// just set does not come back as a remote change.
Ok(_) => self.note_self_change(&new_uid),
Err(e) => {
warn!(name = candidate.name, error = %e, "cannot re-favourite re-dated photo")
}
}
}
for album in &candidate.albums {
let Some(album_uid) = parse_node_uid(album) else {
continue;
};
match self
.rt
.block_on(photos.add_photos_to_album(&album_uid, std::slice::from_ref(&new_uid)))
{
Ok(_) => summary.album_links += 1,
Err(e) => {
warn!(name = candidate.name, error = %e, "cannot re-file re-dated photo")
}
}
}
let outcomes = self
.rt
.block_on(
self.client
.trash_nodes(std::slice::from_ref(&candidate.uid)),
)
.map_err(|e| CoreError::from_api(&e, "trash the mis-dated original"))?;
if let Some((_, Err(e))) = outcomes.into_iter().next() {
return Err(CoreError::from_api(&e, "trash the mis-dated original"));
}
summary.redated += 1;
summary.bytes += size;
info!(
name = candidate.name,
from = candidate.stored,
to = candidate.wanted,
"photo re-dated"
);
Ok(())
}
/// Start a re-date on its own thread. Errors when one is already running.
pub(crate) fn spawn_redate(
&self,
dry_run: bool,
limit: Option<usize>,
range: Option<(i64, i64)>,
) -> CoreResult<()> {
let cancel = Arc::new(AtomicBool::new(false));
{
let mut state = REDATE.lock();
if state.running {
return Err(CoreError::conflict(
"a photo re-date is already running; wait for it to finish or cancel it",
));
}
state.running = true;
state.cancel = Some(cancel.clone());
state.last = None;
}
let core = self.clone();
std::thread::spawn(move || {
let outcome = core.redate_photos(dry_run, limit, range, cancel);
let mut state = REDATE.lock();
state.running = false;
state.cancel = None;
state.last = outcome.as_ref().ok().cloned();
match &outcome {
Ok(summary) => info!(
candidates = summary.candidates,
redated = summary.redated,
failed = summary.failed,
"photo re-date finished"
),
Err(e) => warn!(error = %e, "photo re-date failed"),
}
});
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_uid_without_both_halves_is_not_a_node() {
assert!(parse_node_uid("vol~link").is_some());
assert!(parse_node_uid("vol~").is_none());
assert!(parse_node_uid("~link").is_none());
assert!(parse_node_uid("nothing").is_none());
}
#[test]
fn only_a_disagreement_larger_than_a_day_counts() {
// The name says 2023-02-19; a stored time inside that day is fine.
let wanted = capture_time_from_name("IMG-20230219-WA0001.jpg").unwrap();
assert!((wanted - (wanted + 3_600)).abs() <= DRIFT);
// An import three years later is not.
assert!((wanted - (wanted + 3 * 365 * 86_400)).abs() > DRIFT);
}
}
+264
View File
@@ -0,0 +1,264 @@
//! File version history: listing a file's revisions, restoring one, deleting
//! one, and writing an old one out to a local path.
//!
//! Proton Drive keeps every revision a client ever committed, and until now the
//! daemon only ever addressed the active one — a file that a sync pass or a
//! `(sync-conflict)` fork overwrote could only be recovered from whatever the
//! local `recovery/` directory happened to hold. This is the remote equivalent,
//! and it is the reason a *restore* here is not a download-then-upload: the
//! server re-points the file at an existing revision, so no content crosses the
//! wire and nothing lands in the drain queue.
//!
//! Two things about the SDK surface leak into every method here:
//!
//! - **A restore is asynchronous.** The server answers 202 and swaps the active
//! revision in the background, so the daemon drops its cached content for the
//! file rather than trying to describe the new one. The next read repopulates
//! from whatever is live by then.
//! - **The active revision cannot be deleted**, and the server — not this code —
//! is what enforces that. [`Core::delete_revision_for_uid`] refuses locally
//! too, so the user gets a sentence instead of an API code.
use std::path::Path;
use pdfs_core::control::{ActivityKind, RevisionInfo};
use pdfs_core::{CoreError, CoreResult};
use proton_drive_rs::Revision;
use proton_drive_rs::proton_sdk::ids::NodeUid;
use tracing::info;
use super::Core;
/// A [`Revision`] as the control protocol reports it.
fn revision_info(revision: Revision) -> RevisionInfo {
RevisionInfo {
is_active: revision.is_active(),
id: revision.revision_id,
created: revision.creation_time,
size_on_storage: revision.size_on_storage,
claimed_size: revision.claimed_size,
claimed_modified: revision.claimed_modification_time,
// An empty signer means the node key signed it anonymously, which is not
// an address a front-end can show.
signed_by: revision.signature_email.filter(|email| !email.is_empty()),
has_thumbnails: revision.has_thumbnails,
}
}
impl Core {
/// The version history of the file at mountpoint-relative `rel`.
pub(crate) fn list_revisions(&self, rel: &Path) -> CoreResult<Vec<RevisionInfo>> {
let (_ino, uid) = self.resolve(rel)?;
self.list_revisions_for_uid(&uid)
}
/// [`Core::list_revisions`] for a node addressed through any daemon location.
pub(crate) fn list_revisions_by_uid(&self, uid: &str) -> CoreResult<Vec<RevisionInfo>> {
let uid = self.resolve_anywhere(uid)?;
self.list_revisions_for_uid(&uid)
}
fn list_revisions_for_uid(&self, uid: &NodeUid) -> CoreResult<Vec<RevisionInfo>> {
let revisions = self
.rt
.block_on(self.client.enumerate_revisions(uid))
.map_err(|e| CoreError::from_api(&e, "list revisions"))?;
Ok(revisions.into_iter().map(revision_info).collect())
}
/// Make `revision_id` the current content of the file at `rel`.
pub(crate) fn restore_revision(&self, rel: &Path, revision_id: &str) -> CoreResult<()> {
let (_ino, uid) = self.resolve(rel)?;
let name = rel
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| uid.to_string());
self.restore_revision_for_uid(&uid, revision_id, &name)?;
// The size and mtime a listing shows describe the revision that was
// active a moment ago.
self.invalidate_parent_listing(rel);
Ok(())
}
/// [`Core::restore_revision`] for a node addressed through any daemon
/// location.
pub(crate) fn restore_revision_by_uid(&self, uid: &str, revision_id: &str) -> CoreResult<()> {
let uid = self.resolve_anywhere(uid)?;
let name = uid.to_string();
self.restore_revision_for_uid(&uid, revision_id, &name)
}
fn restore_revision_for_uid(
&self,
uid: &NodeUid,
revision_id: &str,
name: &str,
) -> CoreResult<()> {
// Restoring rewrites the file's content: the same authority a write
// needs, not the one a read needs.
self.require_uid_writable(uid)
.map_err(|error| self.errno_error(error, "restore revision"))?;
if let Err(e) = self
.rt
.block_on(self.client.restore_revision(uid, revision_id))
{
let error = CoreError::from_api(&e, "restore revision");
self.log_activity(ActivityKind::Restore, name, &error, false);
return Err(error);
}
// The active revision is changing behind our back — anything describing
// the old one (cached blocks, an open reader, the size in a listing) is
// stale from here on, and the server may not have applied the swap yet.
self.cache.evict(uid);
self.evict_reader(uid);
self.log_activity(
ActivityKind::Restore,
name,
format!("restored version {revision_id}"),
true,
);
info!(%uid, revision_id, name, "restored an earlier revision");
Ok(())
}
/// Permanently delete `revision_id` from the history of the file at `rel`.
pub(crate) fn delete_revision(&self, rel: &Path, revision_id: &str) -> CoreResult<()> {
let (_ino, uid) = self.resolve(rel)?;
let name = rel
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| uid.to_string());
self.delete_revision_for_uid(&uid, revision_id, &name)
}
/// [`Core::delete_revision`] for a node addressed through any daemon
/// location.
pub(crate) fn delete_revision_by_uid(&self, uid: &str, revision_id: &str) -> CoreResult<()> {
let uid = self.resolve_anywhere(uid)?;
let name = uid.to_string();
self.delete_revision_for_uid(&uid, revision_id, &name)
}
fn delete_revision_for_uid(
&self,
uid: &NodeUid,
revision_id: &str,
name: &str,
) -> CoreResult<()> {
self.require_uid_writable(uid)
.map_err(|error| self.errno_error(error, "delete revision"))?;
// The server rejects deleting the active revision; saying so here is a
// sentence the user can act on instead of an API code, and it costs one
// listing the front-end already made to show the version list.
let revisions = self
.rt
.block_on(self.client.enumerate_revisions(uid))
.map_err(|e| CoreError::from_api(&e, "list revisions"))?;
let target = revisions
.iter()
.find(|r| r.revision_id == revision_id)
.ok_or_else(|| CoreError::not_found(format!("no version {revision_id} on {name}")))?;
if target.is_active() {
return Err(CoreError::invalid(format!(
"version {revision_id} is the current content of {name}; restore another version first"
)));
}
self.rt
.block_on(self.client.delete_revision(uid, revision_id))
.map_err(|e| CoreError::from_api(&e, "delete revision"))?;
self.log_activity(
ActivityKind::DeleteForever,
name,
format!("deleted version {revision_id}"),
true,
);
info!(%uid, revision_id, name, "deleted a revision");
Ok(())
}
/// Write `revision_id` of the file at `rel` to the absolute local path
/// `dest`, leaving the file itself untouched.
pub(crate) fn save_revision_as(
&self,
rel: &Path,
revision_id: &str,
dest: &Path,
) -> CoreResult<u64> {
let (_ino, uid) = self.resolve(rel)?;
let name = rel
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| uid.to_string());
self.save_revision_for_uid(&uid, revision_id, &name, dest)
}
/// [`Core::save_revision_as`] for a node addressed through any daemon
/// location.
pub(crate) fn save_revision_as_by_uid(
&self,
uid: &str,
revision_id: &str,
dest: &Path,
) -> CoreResult<u64> {
let uid = self.resolve_anywhere(uid)?;
let name = uid.to_string();
self.save_revision_for_uid(&uid, revision_id, &name, dest)
}
fn save_revision_for_uid(
&self,
uid: &NodeUid,
revision_id: &str,
name: &str,
dest: &Path,
) -> CoreResult<u64> {
if !dest.is_absolute() {
return Err(CoreError::invalid(format!(
"destination must be an absolute path: {}",
dest.display()
)));
}
// Never silently replace something the user already has: an old version
// written over the wrong file is exactly the loss this feature exists to
// undo.
if dest.exists() {
return Err(CoreError::invalid(format!(
"{} already exists",
dest.display()
)));
}
let file = std::fs::File::create(dest)
.map_err(|e| CoreError::internal(format!("create {}: {e}", dest.display())))?;
let mut out = std::io::BufWriter::new(file);
let written = match self
.rt
.block_on(self.client.download_revision_to(uid, revision_id, &mut out))
.map_err(|e| CoreError::from_api(&e, "download revision"))
.and_then(|()| {
use std::io::Write as _;
out.flush()
.map_err(|e| CoreError::internal(format!("write {}: {e}", dest.display())))?;
let written = std::fs::metadata(dest)
.map(|m| m.len())
.map_err(|e| CoreError::internal(format!("stat {}: {e}", dest.display())))?;
Ok(written)
}) {
Ok(written) => written,
Err(error) => {
// A half-written file looks like a successful export to every
// tool that opens it afterwards.
let _ = std::fs::remove_file(dest);
self.log_activity(ActivityKind::Download, name, &error, false);
return Err(error);
}
};
self.log_activity(
ActivityKind::Download,
name,
format!("saved version {revision_id} to {}", dest.display()),
true,
);
info!(%uid, revision_id, dest = %dest.display(), "wrote an earlier revision to disk");
Ok(written)
}
}
+673
View File
@@ -0,0 +1,673 @@
//! Sharing: per-node share membership, public links, the invitations this
//! account has received, saved public-link bookmarks, and the reverse view of
//! everything this account shares out.
//!
//! All of it is remote-only state — nothing here touches the inode maps or the
//! content cache, so every method is a round-trip wrapped in the crate error
//! shape the control socket expects.
use std::path::Path;
use pdfs_core::control::{
BookmarkInfo, DirEntry, InvitationInfo, PublicLinkInfo, ShareEntry, ShareEntryKind, SharedItem,
};
use pdfs_core::{CoreError, CoreResult};
use tracing::debug;
use crate::QUOTA_TTL;
use proton_drive_rs::proton_sdk::crypto::VerificationStatus;
use proton_drive_rs::proton_sdk::ids::NodeUid;
use proton_drive_rs::{MemberRole, NodeKind};
use proton_drive_rs::Node;
use super::{Core, ROOT_INO, node_size, parse_uid, public_link_info, role_from_str, role_to_str};
/// A node from a *shared* listing as a [`DirEntry`].
///
/// Still uid-addressed — that is the only handle a foreign-volume node is
/// guaranteed to have — but `path` is filled in when the node is resident under
/// the synthetic `Shared with me/` directory, which is what lets a front-end
/// offer path-keyed actions on it. The local-state flags (`pinned`, `cached`)
/// stay false: they describe content this account owns a local copy policy for,
/// which a share on someone else's volume is not.
fn shared_entry_of(core: &Core, n: Node) -> DirEntry {
let is_dir = n.is_folder();
let size = match &n.kind {
NodeKind::File {
claimed_size,
total_size_on_storage,
..
} => claimed_size.unwrap_or(*total_size_on_storage).max(0) as u64,
NodeKind::Folder => 0,
};
let role = n
.membership
.as_ref()
.and_then(|m| m.role_exact())
.map(role_to_str)
.unwrap_or_default()
.to_string();
let invitation = n.membership.as_ref();
let shared_by = invitation
.and_then(|m| m.inviter_email.clone())
.unwrap_or_default();
let shared_at = invitation.and_then(|m| m.invite_time).unwrap_or(0);
// Only a signature that is present and wrong is a warning. An invitation
// that could not be checked — unsigned, or no keys for the claimed inviter —
// is not evidence of forgery, and flagging every such share would teach the
// user to ignore the flag.
let shared_by_unverified = invitation
.and_then(|m| m.inviter_verification)
.is_some_and(|status| status == VerificationStatus::Failed);
DirEntry {
name: n.name,
is_dir,
size,
modified: n.modification_time,
pinned: false,
cached: false,
path: core.rel_path_for_uid(&n.uid).unwrap_or_default(),
uid: n.uid.to_string(),
role,
shared_by,
shared_at,
shared_by_unverified,
}
}
impl Core {
// ---- sharing a node ---------------------------------------------------
/// Invite Proton and/or external emails to the node at `rel` at `role`.
/// Returns `(proton_invited, external_invited)`.
pub(crate) fn share_node(
&self,
rel: &Path,
emails: &[String],
role: &str,
message: Option<&str>,
) -> CoreResult<(usize, usize)> {
let (_ino, uid) = self.resolve(rel)?;
self.share_node_for_uid(&uid, emails, role, message)
}
/// [`Core::share_node`] for a node addressed through any daemon location.
pub(crate) fn share_node_by_uid(
&self,
uid: &str,
emails: &[String],
role: &str,
message: Option<&str>,
) -> CoreResult<(usize, usize)> {
let uid = self.resolve_anywhere(uid)?;
self.share_node_for_uid(&uid, emails, role, message)
}
fn share_node_for_uid(
&self,
uid: &NodeUid,
emails: &[String],
role: &str,
message: Option<&str>,
) -> CoreResult<(usize, usize)> {
let role = role_from_str(role)?;
let invitees: Vec<(String, MemberRole)> =
emails.iter().map(|e| (e.clone(), role)).collect();
self.rt
.block_on(self.client.invite_users(uid, &invitees, message))
.map_err(|e| CoreError::from_api(&e, "share"))
}
/// List the members, pending invitations and public link of the node at `rel`.
pub(crate) fn list_share(
&self,
rel: &Path,
) -> CoreResult<(Vec<ShareEntry>, Option<PublicLinkInfo>)> {
let (_ino, uid) = self.resolve(rel)?;
self.list_share_for_uid(&uid)
}
/// [`Core::list_share`] for a node addressed through any daemon location.
pub(crate) fn list_share_by_uid(
&self,
uid: &str,
) -> CoreResult<(Vec<ShareEntry>, Option<PublicLinkInfo>)> {
let uid = self.resolve_anywhere(uid)?;
self.list_share_for_uid(&uid)
}
fn list_share_for_uid(
&self,
uid: &NodeUid,
) -> CoreResult<(Vec<ShareEntry>, Option<PublicLinkInfo>)> {
let mut entries = Vec::new();
for m in self
.rt
.block_on(self.client.list_share_members(uid))
.map_err(|e| CoreError::from_api(&e, "list members"))?
{
entries.push(ShareEntry {
id: m.membership_id.to_string(),
email: m.email,
role: role_to_str(m.role).to_string(),
kind: ShareEntryKind::Member,
});
}
for inv in self
.rt
.block_on(self.client.list_share_invitations(uid))
.map_err(|e| CoreError::from_api(&e, "list invitations"))?
{
entries.push(ShareEntry {
id: inv.invitation_id,
email: inv.invitee_email,
role: role_to_str(inv.role).to_string(),
kind: ShareEntryKind::ProtonInvite,
});
}
for ext in self
.rt
.block_on(self.client.list_external_invitations(uid))
.map_err(|e| CoreError::from_api(&e, "list external invitations"))?
{
entries.push(ShareEntry {
id: ext.invitation_id,
email: ext.invitee_email,
role: role_to_str(ext.role).to_string(),
kind: ShareEntryKind::ExternalInvite,
});
}
let link = self
.rt
.block_on(self.client.get_public_link(uid))
.map_err(|e| CoreError::from_api(&e, "get public link"))?
.map(public_link_info);
Ok((entries, link))
}
/// Change the role of a member or pending Proton invitation on the node at
/// `rel`. External invitations have no role-update endpoint.
pub(crate) fn update_share_role(
&self,
rel: &Path,
id: &str,
kind: ShareEntryKind,
role: &str,
) -> CoreResult<()> {
let (_ino, uid) = self.resolve(rel)?;
self.update_share_role_for_uid(&uid, id, kind, role)
}
/// [`Core::update_share_role`] for a node addressed through any daemon
/// location.
pub(crate) fn update_share_role_by_uid(
&self,
uid: &str,
id: &str,
kind: ShareEntryKind,
role: &str,
) -> CoreResult<()> {
let uid = self.resolve_anywhere(uid)?;
self.update_share_role_for_uid(&uid, id, kind, role)
}
fn update_share_role_for_uid(
&self,
uid: &NodeUid,
id: &str,
kind: ShareEntryKind,
role: &str,
) -> CoreResult<()> {
let role = role_from_str(role)?;
match kind {
ShareEntryKind::Member => {
let member = self
.rt
.block_on(self.client.list_share_members(uid))
.map_err(|e| CoreError::from_api(&e, "list members"))?
.into_iter()
.find(|m| m.membership_id.to_string() == id)
.ok_or_else(|| CoreError::not_found("member not found"))?;
self.rt
.block_on(self.client.update_member_role(&member, role))
.map_err(|e| CoreError::from_api(&e, "update role"))
}
ShareEntryKind::ProtonInvite => {
let inv = self
.rt
.block_on(self.client.list_share_invitations(uid))
.map_err(|e| CoreError::from_api(&e, "list invitations"))?
.into_iter()
.find(|i| i.invitation_id == id)
.ok_or_else(|| CoreError::not_found("invitation not found"))?;
self.rt
.block_on(self.client.update_invitation_role(&inv, role))
.map_err(|e| CoreError::from_api(&e, "update role"))
}
ShareEntryKind::ExternalInvite => Err(CoreError::invalid(
"an external invitation's role cannot be changed",
)),
}
}
/// Remove a member, pending Proton invite, or external invite from the node
/// at `rel`.
pub(crate) fn remove_share_entry(
&self,
rel: &Path,
id: &str,
kind: ShareEntryKind,
) -> CoreResult<()> {
let (_ino, uid) = self.resolve(rel)?;
self.remove_share_entry_for_uid(&uid, id, kind)
}
/// [`Core::remove_share_entry`] for a node addressed through any daemon
/// location.
pub(crate) fn remove_share_entry_by_uid(
&self,
uid: &str,
id: &str,
kind: ShareEntryKind,
) -> CoreResult<()> {
let uid = self.resolve_anywhere(uid)?;
self.remove_share_entry_for_uid(&uid, id, kind)
}
fn remove_share_entry_for_uid(
&self,
uid: &NodeUid,
id: &str,
kind: ShareEntryKind,
) -> CoreResult<()> {
match kind {
ShareEntryKind::Member => {
let member = self
.rt
.block_on(self.client.list_share_members(uid))
.map_err(|e| CoreError::from_api(&e, "list members"))?
.into_iter()
.find(|m| m.membership_id.to_string() == id)
.ok_or_else(|| CoreError::not_found("member not found"))?;
self.rt
.block_on(self.client.remove_member(&member))
.map_err(|e| CoreError::from_api(&e, "remove member"))
}
ShareEntryKind::ProtonInvite => {
let inv = self
.rt
.block_on(self.client.list_share_invitations(uid))
.map_err(|e| CoreError::from_api(&e, "list invitations"))?
.into_iter()
.find(|i| i.invitation_id == id)
.ok_or_else(|| CoreError::not_found("invitation not found"))?;
self.rt
.block_on(self.client.delete_invitation(&inv))
.map_err(|e| CoreError::from_api(&e, "revoke invitation"))
}
ShareEntryKind::ExternalInvite => {
let ext = self
.rt
.block_on(self.client.list_external_invitations(uid))
.map_err(|e| CoreError::from_api(&e, "list external invitations"))?
.into_iter()
.find(|i| i.invitation_id == id)
.ok_or_else(|| CoreError::not_found("external invitation not found"))?;
self.rt
.block_on(self.client.delete_external_invitation(&ext))
.map_err(|e| CoreError::from_api(&e, "revoke external invitation"))
}
}
}
/// Create a public link on the node at `rel`, returning its info (with URL).
pub(crate) fn create_public_link(
&self,
rel: &Path,
role: &str,
password: Option<&str>,
expires: Option<i64>,
) -> CoreResult<PublicLinkInfo> {
let (_ino, uid) = self.resolve(rel)?;
self.create_public_link_for_uid(&uid, role, password, expires)
}
/// [`Core::create_public_link`] for a node addressed through any daemon
/// location.
pub(crate) fn create_public_link_by_uid(
&self,
uid: &str,
role: &str,
password: Option<&str>,
expires: Option<i64>,
) -> CoreResult<PublicLinkInfo> {
let uid = self.resolve_anywhere(uid)?;
self.create_public_link_for_uid(&uid, role, password, expires)
}
fn create_public_link_for_uid(
&self,
uid: &NodeUid,
role: &str,
password: Option<&str>,
expires: Option<i64>,
) -> CoreResult<PublicLinkInfo> {
let role = role_from_str(role)?;
let link = self
.rt
.block_on(self.client.create_public_link(uid, role, password, expires))
.map_err(|e| CoreError::from_api(&e, "create public link"))?;
Ok(public_link_info(link))
}
/// Remove the public link `id` from the node at `rel`.
pub(crate) fn remove_public_link(&self, rel: &Path, id: &str) -> CoreResult<()> {
let (_ino, uid) = self.resolve(rel)?;
self.remove_public_link_for_uid(&uid, id)
}
/// [`Core::remove_public_link`] for a node addressed through any daemon
/// location.
pub(crate) fn remove_public_link_by_uid(&self, uid: &str, id: &str) -> CoreResult<()> {
let uid = self.resolve_anywhere(uid)?;
self.remove_public_link_for_uid(&uid, id)
}
fn remove_public_link_for_uid(&self, uid: &NodeUid, id: &str) -> CoreResult<()> {
let link = self
.rt
.block_on(self.client.get_public_link(uid))
.map_err(|e| CoreError::from_api(&e, "get public link"))?
.filter(|l| l.public_link_id == id)
.ok_or_else(|| CoreError::not_found("public link not found"))?;
self.rt
.block_on(self.client.remove_public_link(&link))
.map_err(|e| CoreError::from_api(&e, "remove public link"))
}
// ---- shared with me ---------------------------------------------------
/// List nodes shared with me that I have accepted.
pub(crate) fn list_shared_with_me(&self) -> CoreResult<Vec<DirEntry>> {
let uids = self
.rt
.block_on(self.client.enumerate_shared_with_me_node_uids())
.map_err(|e| CoreError::from_api(&e, "enumerate shared"))?;
if uids.is_empty() {
return Ok(Vec::new());
}
let nodes = self
.rt
.block_on(self.client.enumerate_nodes(&uids))
.map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?;
Ok(nodes
.into_iter()
.map(|node| shared_entry_of(self, node))
.collect())
}
/// List the children of a folder shared with me, addressed by uid.
///
/// A shared subtree has no path in this account's mount — the FUSE tree only
/// spans my own volume — so browsing into one is uid-addressed the whole way
/// down, each listing handing the front-end the uids it descends with next.
pub(crate) fn list_shared_folder(&self, uid: &str) -> CoreResult<Vec<DirEntry>> {
let uid =
parse_uid(uid).ok_or_else(|| CoreError::invalid(format!("invalid uid: {uid}")))?;
let child_uids = self
.rt
.block_on(self.client.enumerate_folder_children_node_uids(&uid))
.map_err(|e| CoreError::from_api(&e, "enumerate shared children"))?;
if child_uids.is_empty() {
return Ok(Vec::new());
}
let nodes = self
.rt
.block_on(self.client.enumerate_nodes(&child_uids))
.map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?;
Ok(nodes
.into_iter()
.map(|node| shared_entry_of(self, node))
.collect())
}
/// Download a file shared with me into the content cache, returning its
/// on-disk path (served from cache when a fresh blob already exists).
///
/// The by-uid twin of [`Core::open_file`]: same cache and same tracked
/// download, but the node is fetched from the server rather than resolved
/// through the inode tree, which does not cover other people's volumes.
///
/// [`Core::open_file`]: super::Core::open_file
pub(crate) fn open_shared_file(&self, uid: &str) -> CoreResult<std::path::PathBuf> {
let uid =
parse_uid(uid).ok_or_else(|| CoreError::invalid(format!("invalid uid: {uid}")))?;
let node = self
.rt
.block_on(self.client.get_node(&uid))
.map_err(|e| CoreError::from_api(&e, "get shared node"))?
.ok_or_else(|| CoreError::not_found("shared node not found"))?;
if !node.is_file() {
return Err(CoreError::invalid("not a regular file"));
}
let size = node_size(&node);
let mtime = node.modification_time;
if let Some(p) = self.cache.cached_content_path(&uid, mtime, size) {
return Ok(p);
}
let bytes = self
.download_file_tracked(&uid, &node.name, size)
.map_err(|e| CoreError::from_api(&e, "download"))?;
self.cache
.store(&uid, mtime, size, &bytes)
.map_err(|e| CoreError::internal(format!("cache store: {e}")))?;
Ok(self.cache.content_path(&uid))
}
/// Leave a shared node by its uid.
pub(crate) fn leave_shared(&self, uid: &str) -> CoreResult<()> {
let uid =
parse_uid(uid).ok_or_else(|| CoreError::invalid(format!("invalid uid: {uid}")))?;
self.rt
.block_on(self.client.leave_shared_node(&uid))
.map_err(|e| CoreError::from_api(&e, "leave shared"))
}
// ---- incoming invitations ---------------------------------------------
/// List invitations addressed to me, pending accept or reject.
pub(crate) fn list_invitations(&self) -> CoreResult<Vec<InvitationInfo>> {
let invitations = self
.rt
.block_on(self.client.list_incoming_invitations())
.map_err(|e| CoreError::from_api(&e, "list invitations"))?;
Ok(invitations
.into_iter()
.map(|i| InvitationInfo {
id: i.invitation_id,
inviter_email: i.inviter_email,
name: i.node_name,
is_dir: i.is_folder,
})
.collect())
}
/// Accept an invitation addressed to me by its id.
pub(crate) fn accept_invitation(&self, id: &str) -> CoreResult<()> {
self.rt
.block_on(self.client.accept_invitation(id))
.map_err(|e| CoreError::from_api(&e, "accept invitation"))
}
/// Reject an invitation addressed to me by its id.
pub(crate) fn reject_invitation(&self, id: &str) -> CoreResult<()> {
self.rt
.block_on(self.client.reject_invitation(id))
.map_err(|e| CoreError::from_api(&e, "reject invitation"))
}
// ---- bookmarks --------------------------------------------------------
/// List public links saved to my account.
pub(crate) fn list_bookmarks(&self) -> CoreResult<Vec<BookmarkInfo>> {
let bookmarks = self
.rt
.block_on(self.client.list_bookmarks())
.map_err(|e| CoreError::from_api(&e, "list bookmarks"))?;
Ok(bookmarks
.into_iter()
.map(|b| BookmarkInfo {
token: b.token,
url: b.url,
name: b.node_name,
is_dir: b.is_folder,
})
.collect())
}
/// Save a public link (optionally password-protected) as a bookmark.
pub(crate) fn create_bookmark(&self, url: &str, password: Option<&str>) -> CoreResult<()> {
self.rt
.block_on(self.client.create_bookmark(url, password))
.map_err(|e| CoreError::from_api(&e, "create bookmark"))
}
/// Remove a saved bookmark by its token.
pub(crate) fn delete_bookmark(&self, token: &str) -> CoreResult<()> {
self.rt
.block_on(self.client.delete_bookmark(token))
.map_err(|e| CoreError::from_api(&e, "delete bookmark"))
}
// ---- account ----------------------------------------------------------
/// Total account storage usage `(max_space, used_space)` in bytes, across all
/// Proton products (not Drive-only). A remote round-trip; nothing here is
/// cached.
pub(crate) fn account_quota(&self) -> CoreResult<(i64, i64)> {
let q = self
.rt
.block_on(self.client.quota())
.map_err(|e| CoreError::from_api(&e, "account quota"))?;
*self.quota.lock() = Some((std::time::Instant::now(), q.max_space, q.used_space));
Ok((q.max_space, q.used_space))
}
/// The account quota as `statfs(2)` needs it: cached for [`QUOTA_TTL`],
/// refetched after that, and `None` when there is no usable answer at all —
/// offline with nothing cached.
///
/// Never blocks on the network for a *fresh* value when a recent one exists,
/// because the callers are `df` and every "is there room?" preflight, and a
/// stale byte count is worth more to them than a stalled syscall. Offline,
/// the last known figures keep being served rather than degrading to zero.
pub(crate) fn account_quota_cached(&self) -> Option<(i64, i64)> {
if let Some((at, max_space, used_space)) = *self.quota.lock()
&& at.elapsed() < QUOTA_TTL
{
return Some((max_space, used_space));
}
match self.account_quota() {
Ok(q) => Some(q),
Err(error) => {
debug!(%error, "account quota refresh failed; serving the last known figures");
self.quota.lock().map(|(_, max, used)| (max, used))
}
}
}
// ---- shared by me -----------------------------------------------------
/// List the nodes I have shared with others, each with a summary of its share
/// state (members, pending invitations, public link). One list call enumerates
/// the shared uids; the per-node detail is then gathered best-effort — a single
/// node racing with an unshare drops from the list rather than failing the whole
/// request.
pub(crate) fn list_shared_by_me(&self) -> CoreResult<Vec<SharedItem>> {
let uids = self
.rt
.block_on(self.client.enumerate_shared_by_me_node_uids())
.map_err(|e| CoreError::from_api(&e, "enumerate shared-by-me"))?;
if uids.is_empty() {
return Ok(Vec::new());
}
let nodes = self
.rt
.block_on(self.client.enumerate_nodes(&uids))
.map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?;
let mut items = Vec::with_capacity(nodes.len());
for n in nodes {
let uid = n.uid.clone();
let members = self
.rt
.block_on(self.client.list_share_members(&uid))
.map(|m| m.len())
.unwrap_or(0);
let proton_invites = self
.rt
.block_on(self.client.list_share_invitations(&uid))
.map(|i| i.len())
.unwrap_or(0);
let external_invites = self
.rt
.block_on(self.client.list_external_invitations(&uid))
.map(|i| i.len())
.unwrap_or(0);
let link = self
.rt
.block_on(self.client.get_public_link(&uid))
.ok()
.flatten()
.map(public_link_info);
items.push(SharedItem {
uid: uid.to_string(),
is_dir: n.is_folder(),
modified: n.modification_time,
name: n.name,
path: self.rel_path_for_uid(&uid).unwrap_or_default(),
member_count: members,
invite_count: proton_invites + external_invites,
link,
});
}
Ok(items)
}
/// Best-effort mountpoint-relative path for a node already interned in the live
/// tree, by walking parent inodes to the root. `None` when the node has never
/// been seen through the mount (e.g. shared but not browsed to this session) —
/// the caller then leaves the path empty.
pub(crate) fn rel_path_for_uid(&self, uid: &NodeUid) -> Option<String> {
// Searched across every mount: a node browsed through a sync folder is
// interned in that fork's inode space, so the primary state alone had no
// entry for it and the caller silently showed an empty path
// (`docs/BUGS.md` B74). The path is relative to whichever mount holds
// it, which is the one the user reached it through.
let mut path = None;
self.for_each_state(|st| {
if path.is_some() {
return;
}
path = walk_to_root(st, uid);
});
path
}
}
/// Walk one inode space from `uid` up to its root, building the path. `None` if
/// that mount has never interned the node, or if the chain breaks partway —
/// a broken chain is a missing path, never a partial one.
fn walk_to_root(st: &super::State, uid: &NodeUid) -> Option<String> {
let mut ino = *st.by_uid.get(uid)?;
let mut parts = Vec::new();
while ino != ROOT_INO {
let entry = st.entries.get(&ino)?;
parts.push(entry.node.name.clone());
ino = entry.parent;
}
parts.reverse();
Some(parts.join("/"))
}
+96
View File
@@ -0,0 +1,96 @@
//! One shutdown signal shared by every long-lived worker in the daemon.
//!
//! The mount used to end by unmounting and returning, leaving the drain
//! workers, the sync engine, the conflict sweep, the online probe and the local
//! indexer running on `Core` clones. In a process that exits immediately that is
//! invisible; in an in-process remount — which is how the daemon recovers from a
//! mount going away underneath it — it leaks a whole generation of threads that
//! keep writing to the database and calling the API on behalf of a mount that no
//! longer exists (bugs.md B44).
//!
//! The primitive is deliberately small: a flag plus a condvar. Every loop that
//! would otherwise call `thread::sleep` calls [`Shutdown::sleep`] instead, which
//! returns early — and reports `false` — the moment [`Shutdown::stop`] is
//! called, so teardown does not have to wait out a five-minute sweep interval to
//! join a thread.
use std::time::Duration;
use parking_lot::{Condvar, Mutex};
/// The daemon's stop signal. Set once, never cleared: a `Core` that has been
/// told to stop belongs to a mount that is going away.
#[derive(Default)]
pub(crate) struct Shutdown {
stopping: Mutex<bool>,
changed: Condvar,
}
impl Shutdown {
/// Whether teardown has begun. Loops check this where they would otherwise
/// start another unit of work.
pub(crate) fn is_stopping(&self) -> bool {
*self.stopping.lock()
}
/// Begin teardown and wake everything waiting in [`sleep`](Self::sleep).
/// Idempotent, because more than one path can reach teardown (a signal, the
/// kernel mount ending, a failed startup).
pub(crate) fn stop(&self) {
let mut stopping = self.stopping.lock();
*stopping = true;
self.changed.notify_all();
}
/// Wait up to `duration`, or until teardown starts.
///
/// Returns `false` if the daemon is stopping — including when it was already
/// stopping on entry, so `while shutdown.sleep(interval)` is a complete and
/// correct loop condition and no separate check is needed at the top.
pub(crate) fn sleep(&self, duration: Duration) -> bool {
let mut stopping = self.stopping.lock();
if !*stopping {
self.changed.wait_for(&mut stopping, duration);
}
!*stopping
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use std::time::Instant;
#[test]
fn sleeping_returns_early_once_stopped() {
let shutdown = Arc::new(Shutdown::default());
assert!(!shutdown.is_stopping());
let waker = shutdown.clone();
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(20));
waker.stop();
});
let started = Instant::now();
// An hour's worth of interval, cut short by the stop.
assert!(!shutdown.sleep(Duration::from_secs(3600)));
assert!(
started.elapsed() < Duration::from_secs(5),
"stop must interrupt the wait, not wait it out"
);
assert!(shutdown.is_stopping());
// Already stopping: the next wait does not sleep at all.
let started = Instant::now();
assert!(!shutdown.sleep(Duration::from_secs(3600)));
assert!(started.elapsed() < Duration::from_secs(5));
}
#[test]
fn sleeping_runs_to_the_timeout_while_running() {
let shutdown = Shutdown::default();
assert!(shutdown.sleep(Duration::from_millis(10)));
assert!(!shutdown.is_stopping());
}
}
File diff suppressed because it is too large. Load diff
+229
View File
@@ -0,0 +1,229 @@
//! The thread that watches the daemon for the thing the daemon cannot report
//! about itself: that it has stopped making progress.
//!
//! Three jobs, all cheap, all on one timer:
//!
//! 1. **Say so in the journal.** A worker that has held one job for minutes, a
//! queue that is not draining, a control request that never returned — each
//! gets a WARN naming it. The 2026-09-18 hang left nothing in the journal at
//! all; this is what turns the next one into a one-line diagnosis.
//! 2. **Ping the systemd watchdog**, but only after a real round trip over the
//! control socket. Pinging unconditionally from a thread that does nothing
//! else would keep the unit "healthy" through exactly the hang it is meant to
//! catch: the probe is what makes the ping mean "a client can still talk to
//! this daemon".
//! 3. **Sample memory.** That hang peaked at 5.2 GiB resident, and nobody knew
//! until it was over.
use std::io::{BufRead, BufReader, Write as _};
use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use pdfs_core::control::Request as CtlRequest;
use tracing::{info, warn};
use super::Core;
use super::systemd;
/// How often the supervisor looks. Fast enough that a stall is named while the
/// user is still watching it, slow enough to cost nothing.
const TICK: Duration = Duration::from_secs(15);
/// How long one job may hold a worker, or one control request may run, before it
/// is called out. Generous: a large upload chunk or a cold folder listing on a
/// slow link is minutes of legitimate work, and a false WARN every few minutes
/// would train everyone to ignore the real one.
const STALL_AFTER: Duration = Duration::from_secs(120);
/// How often resident size goes into the log.
const RSS_EVERY: Duration = Duration::from_secs(300);
/// How long the liveness probe waits for the daemon to answer its own control
/// socket. Well under the watchdog period, so a probe that times out still
/// leaves time for the manager to hear nothing and act.
const PROBE_TIMEOUT: Duration = Duration::from_secs(10);
/// Run until shutdown. Spawned once per mount, from [`super::mount::mount`].
pub(crate) fn run(core: Core, control_socket: PathBuf) {
let watchdog = systemd::watchdog_period();
if let Some(period) = watchdog {
info!(period_secs = period.as_secs(), "systemd watchdog enabled");
}
let mut last_rss = Instant::now();
let mut last_ping = Instant::now();
// Per-lane completion counts from the previous tick, to tell a queue that is
// being worked through from one that is not moving at all.
let mut last_completed = (0_u64, 0_u64);
while core.shutdown.sleep(TICK) {
let report = core.diagnostics();
let stalled = report_stalls(&report, last_completed);
last_completed = (report.meta_completed, report.transfer_completed);
if last_rss.elapsed() >= RSS_EVERY {
info!(
rss_bytes = report.rss_bytes,
pending_ops = report.pending_ops,
meta_queued = report.meta_queued,
transfer_queued = report.transfer_queued,
stalled,
"daemon resource sample"
);
last_rss = Instant::now();
}
// A stalled lane is deliberately *not* a reason to withhold the ping:
// the mount may still be answering everything else, and a restart would
// throw away in-flight uploads. The probe decides that; a stall only
// gets said out loud.
if let Some(period) = watchdog
&& last_ping.elapsed() >= period
{
if probe(&control_socket) {
systemd::watchdog_ping();
} else {
warn!(
"daemon did not answer its own control socket; withholding the watchdog ping"
);
}
last_ping = Instant::now();
}
}
}
/// WARN about anything that has not moved for [`STALL_AFTER`]. Returns whether
/// anything was reported, so the caller can weigh it.
fn report_stalls(report: &pdfs_core::control::Diagnostics, last_completed: (u64, u64)) -> bool {
let threshold = STALL_AFTER.as_secs();
let mut stalled = false;
for worker in &report.workers {
if worker.busy && worker.age_secs >= threshold {
stalled = true;
warn!(
worker = %worker.name,
job = %worker.label,
age_secs = worker.age_secs,
"a fuse worker has held the same job for a long time"
);
}
}
if let Some(oldest) = report.inflight.first()
&& oldest.age_secs >= threshold
{
stalled = true;
warn!(
request = %oldest.kind,
age_secs = oldest.age_secs,
in_flight = report.inflight.len(),
"a control request has been running for a long time"
);
}
// A backed-up lane is normal; a backed-up lane that finished nothing since
// the last tick is not.
if report.meta_queued > 0 && report.meta_completed == last_completed.0 {
stalled = true;
warn!(
queued = report.meta_queued,
"the metadata lane has not completed a job since the last check"
);
}
if report.transfer_queued > 0 && report.transfer_completed == last_completed.1 {
stalled = true;
warn!(
queued = report.transfer_queued,
"the transfer lane has not completed a job since the last check"
);
}
stalled
}
/// One round trip over the control socket: connect, ask for the cheapest
/// request there is, read the answer.
///
/// This is the liveness the watchdog ping stands for. It covers the accept loop,
/// the handler pool and the reply path — everything a `pdfs` command or the GUI
/// depends on — without touching the network or the database.
fn probe(control_socket: &Path) -> bool {
let Ok(stream) = UnixStream::connect(control_socket) else {
return false;
};
if stream.set_read_timeout(Some(PROBE_TIMEOUT)).is_err()
|| stream.set_write_timeout(Some(PROBE_TIMEOUT)).is_err()
{
return false;
}
let Ok(request) = serde_json::to_string(&CtlRequest::Diagnostics) else {
return false;
};
let mut stream = stream;
if stream.write_all(format!("{request}\n").as_bytes()).is_err() {
return false;
}
let mut line = String::new();
BufReader::new(stream).read_line(&mut line).is_ok() && !line.trim().is_empty()
}
#[cfg(test)]
mod tests {
use super::*;
use pdfs_core::control::{Diagnostics, InflightRequest, WorkerState};
fn busy_worker(age_secs: u64) -> WorkerState {
WorkerState {
name: "pdfs-fuse-3".into(),
busy: true,
label: "read".into(),
age_secs,
}
}
/// A worker doing normal work must not be reported: a WARN every tick is a
/// WARN nobody reads.
#[test]
fn ordinary_work_is_not_a_stall() {
let report = Diagnostics {
workers: vec![busy_worker(5)],
..Default::default()
};
assert!(!report_stalls(&report, (0, 0)));
}
/// A worker on the same job past the threshold is the shape of the hang.
#[test]
fn a_long_held_job_is_a_stall() {
let report = Diagnostics {
workers: vec![busy_worker(STALL_AFTER.as_secs() + 1)],
..Default::default()
};
assert!(report_stalls(&report, (0, 0)));
}
/// So is a control request that never returned.
#[test]
fn a_long_running_request_is_a_stall() {
let report = Diagnostics {
inflight: vec![InflightRequest {
kind: "OpenFile".into(),
age_secs: STALL_AFTER.as_secs() + 1,
}],
..Default::default()
};
assert!(report_stalls(&report, (0, 0)));
}
/// A queue that is draining is busy, not stalled — that is what comparing
/// the completion counts is for.
#[test]
fn a_draining_queue_is_not_a_stall() {
let report = Diagnostics {
transfer_queued: 12,
transfer_completed: 40,
..Default::default()
};
assert!(!report_stalls(&report, (0, 30)));
assert!(report_stalls(&report, (0, 40)));
}
}
+484
View File
@@ -0,0 +1,484 @@
//! Background conflict sweep.
//!
//! A `(sync-conflict <ts>)` copy is only ever a fallback: the drain and the sync
//! engine cut one when they cannot prove a queued write is safe to apply, so the
//! user never loses bytes. Most of those copies turn out to be byte-identical to
//! the live file — a spurious conflict from a re-stamped revision (B16/B25) or a
//! first-sync of an already-identical file. This sweep reconciles them after the
//! fact:
//!
//! - **Identical** to the live sibling (same size *and* same content SHA-1): the
//! copy is redundant, so it is trashed and logged as a [`ActivityKind::Trash`].
//! - **Divergent** (different bytes) or **orphaned** (no live sibling): left in
//! place and surfaced once as an [`ActivityKind::Conflict`], because only the
//! user can decide which version wins.
//!
//! Identity is proved without downloading anything: the SHA-1 comes from the
//! revision's decrypted extended attributes, which enumeration already reads. A
//! copy whose sibling has no recorded SHA-1 is treated as divergent — the sweep
//! never removes a file it cannot prove is a duplicate.
use std::collections::HashMap;
use std::time::Duration;
use pdfs_core::batch;
use pdfs_core::config::SweepMode;
use pdfs_core::control::ActivityKind;
use proton_drive_rs::proton_sdk::ids::NodeUid;
use tracing::{debug, info, warn};
use super::{Core, node_content_sha1, node_revision_id, node_size};
/// Time between sweeps. Conflicts are rare and non-urgent; a slow cadence keeps
/// the sweep off the hot path while still cleaning up within a few minutes.
const CONFLICT_SWEEP_INTERVAL: Duration = Duration::from_secs(300);
/// Delay before the first sweep, so it does not compete with the burst of work a
/// fresh mount does at startup (hydrate, recover, initial enumeration).
const CONFLICT_SWEEP_WARMUP: Duration = Duration::from_secs(30);
/// Strip a ` (sync-conflict <stamp>)` (optionally `-<n>`) marker from `name`,
/// returning the original file name it was a copy of. `None` when `name` is not a
/// conflict copy. Inverse of the `conflict_name` / `conflict_path_with_suffix`
/// formats, so the two halves of the product agree on what a conflict is.
pub(crate) fn conflict_base_name(name: &str) -> Option<String> {
const MARKER: &str = " (sync-conflict ";
let start = name.find(MARKER)?;
let after = start + MARKER.len();
let close_rel = name[after..].find(')')?;
let close = after + close_rel;
let inner = &name[after..close];
// The token is `<digits>` or `<digits>-<digits>`; reject anything else so a
// file that merely happens to contain the phrase is not mistaken for a copy.
let mut parts = inner.splitn(2, '-');
let stamp = parts.next().unwrap_or_default();
let suffix = parts.next();
let is_digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit());
if !is_digits(stamp) || suffix.is_some_and(|s| !is_digits(s)) {
return None;
}
let mut base = String::with_capacity(start + name.len() - close);
base.push_str(&name[..start]);
base.push_str(&name[close + 1..]);
Some(base)
}
impl Core {
/// Sweep for stale conflict copies forever, on its own thread. See the module
/// docs for the identical/divergent policy.
pub(crate) fn run_conflict_sweep_loop(&self) {
// Both waits are interruptible, so teardown does not have to sit out a
// five-minute interval to join this thread (bugs.md B44).
if !self.shutdown.sleep(CONFLICT_SWEEP_WARMUP) {
return;
}
loop {
self.sweep_conflicts_once();
if !self.shutdown.sleep(CONFLICT_SWEEP_INTERVAL) {
return;
}
}
}
/// One sweep pass over every node the daemon currently knows.
fn sweep_conflicts_once(&self) {
let stored = match self.db.load_all() {
Ok(s) => s,
Err(e) => {
warn!(error = ?e, "conflict sweep: load nodes failed");
return;
}
};
let nodes: Vec<_> = stored
.into_iter()
.map(|stored| stored.node)
.filter(|node| self.is_own_or_virtual(&node.uid))
.collect();
// Index live (non-trashed) files by (parent, name) for sibling lookup.
let mut by_parent_name: HashMap<(String, &str), &_> = HashMap::new();
for node in &nodes {
if node.trashed || node.is_folder() {
continue;
}
let parent = node
.parent_uid
.as_ref()
.map(|u| u.to_string())
.unwrap_or_default();
by_parent_name.insert((parent, node.name.as_str()), node);
}
let online = self.online.load(std::sync::atomic::Ordering::Relaxed);
for node in &nodes {
if node.trashed || node.is_folder() {
continue;
}
let Some(base) = conflict_base_name(&node.name) else {
continue;
};
let parent = node
.parent_uid
.as_ref()
.map(|u| u.to_string())
.unwrap_or_default();
let sibling = by_parent_name.get(&(parent, base.as_str())).copied();
match decide(node, sibling, &base, self.sweep_mode) {
SweepAction::Remove => {
// Only while online — the remote trash is the point. Offline,
// it waits for a later pass.
if online {
self.remove_conflict_copy(node, &base);
}
}
SweepAction::Flag(detail) => self.flag_conflict(&node.uid, &node.name, detail),
}
}
}
/// Trash a conflict copy proven identical to its live sibling, and unhook it
/// locally — the same sequence the interactive unlink path uses after a trash.
///
/// `snapshot` is the node as the pass saw it, which by now is stale: the pass
/// enumerated every node up front and has been doing network I/O since. Before
/// touching anything remote this re-reads the node and refuses to act unless
/// it is *still* the same revision, still untrashed, still unwritten and still
/// unopened (`docs/BUGS.md` B71). Nothing on this path discards a queued write
/// or its staged blob: a write that arrives after that check is left for the
/// drain to resolve against the now-trashed node.
fn remove_conflict_copy(&self, snapshot: &proton_drive_rs::Node, base: &str) {
let uid = &snapshot.uid;
let name = snapshot.name.as_str();
if !self.duplicate_still_removable(snapshot) {
return;
}
match self
.rt
.block_on(self.client.trash_nodes(std::slice::from_ref(uid)))
.and_then(batch::into_unit)
{
Ok(()) => {}
Err(e) if super::is_gone(&e) => {}
Err(e) => {
warn!(%uid, name, error = %e, "conflict sweep: trashing duplicate failed");
return;
}
}
// Every mount, not just the primary: a conflict copy inside a sync
// folder lives in that fork's inode space, and leaving it hooked up
// there keeps a node we have just trashed remotely visible and
// readable there (`docs/BUGS.md` B74).
self.for_each_state(|st| {
st.forget_or_unlink(uid);
});
// Deliberately *not* `discard_queued_ops`: `duplicate_still_removable`
// proved the queue empty before the trash above, but that proof is stale
// the moment `trash_nodes` goes to the network. A `close(2)` landing in
// that window stages bytes, and discarding here would unlink the staged
// blob holding the only copy of them. Leaving the op queued is the
// non-destructive outcome: the drain re-reads the node, sees it trashed,
// and `revision_conflict` routes it to `keep_as_conflict_copy`.
self.cache.evict(uid);
self.evict_reader(uid);
if let Err(e) = self.db.delete_node(uid) {
warn!(%uid, error = ?e, "conflict sweep: db delete after trash failed");
}
self.invalidate_trash();
self.conflict_notified.lock().remove(uid);
self.log_activity(
ActivityKind::Trash,
name,
format!("auto-removed duplicate of {base}"),
true,
);
info!(%uid, name, base, "conflict sweep: removed identical conflict copy");
}
/// Re-check, immediately before the destructive call, everything the pass
/// decided from its stale snapshot. Any doubt means "leave it alone": a
/// conflict copy that survives to the next pass costs nothing, a wrongly
/// removed one costs the user data.
fn duplicate_still_removable(&self, snapshot: &proton_drive_rs::Node) -> bool {
let uid = &snapshot.uid;
let name = snapshot.name.as_str();
// A queued op means bytes are still owed an upload; trashing the node
// under them turns an ordinary upload into a conflict fork.
match self.db.has_any_op(&uid.to_string()) {
Ok(false) => {}
Ok(true) => {
debug!(%uid, name, "conflict sweep: skipping duplicate with queued ops");
return false;
}
// Cannot prove the queue is empty — assume it is not.
Err(e) => {
warn!(%uid, name, error = ?e, "conflict sweep: op check failed; skipping");
return false;
}
}
// Someone is writing to it, or holds it open. Trashing now would pull the
// node out from under a live handle.
if self.is_busy(uid) {
debug!(%uid, name, "conflict sweep: skipping duplicate that is open or being written");
return false;
}
// Finally, the node itself must not have moved on since the snapshot.
match self.db.node_by_uid(&uid.to_string()) {
Ok(Some(fresh)) => {
if fresh.trashed {
return false;
}
if fresh.name != snapshot.name || fresh.parent_uid != snapshot.parent_uid {
debug!(%uid, name, "conflict sweep: duplicate was renamed or moved; skipping");
return false;
}
if node_revision_id(&fresh) != node_revision_id(snapshot)
|| node_size(&fresh) != node_size(snapshot)
|| node_content_sha1(&fresh) != node_content_sha1(snapshot)
{
debug!(%uid, name, "conflict sweep: duplicate changed since the pass began");
return false;
}
true
}
// Already gone locally, or unreadable — either way, not ours to remove.
Ok(None) => false,
Err(e) => {
warn!(%uid, name, error = ?e, "conflict sweep: re-read failed; skipping");
false
}
}
}
/// Whether a node has a write in flight or a file handle open against it.
/// Read-only opens use fh 0 and are not tracked, so this is specifically the
/// writer/holder check.
/// Asked across **every** mount. The sweep runs on the primary Core but
/// trashes nodes anywhere in the tree, and a file open inside a sync folder
/// is tracked in that fork's `State` — so consulting `self.state` alone
/// answered "idle" for every such file and let the sweep delete one with a
/// writer attached (`docs/BUGS.md` B74). Busy anywhere means busy.
fn is_busy(&self, uid: &NodeUid) -> bool {
let mut busy = false;
self.for_each_state(|st| {
let Some(&ino) = st.by_uid.get(uid) else {
return;
};
busy |=
st.active_writes.contains_key(&ino) || st.handles.values().any(|&held| held == ino);
});
busy
}
/// Surface a conflict copy that needs the user's attention, at most once per
/// daemon run so the activity feed is not spammed each pass.
fn flag_conflict(&self, uid: &NodeUid, name: &str, detail: String) {
if !self.conflict_notified.lock().insert(uid.clone()) {
return;
}
debug!(%uid, name, detail, "conflict sweep: flagged divergent conflict copy");
self.log_activity(ActivityKind::Conflict, name, detail, false);
}
}
/// What the sweep concluded about one `(sync-conflict …)` copy.
#[derive(Debug, Clone, PartialEq, Eq)]
enum SweepAction {
/// Provably redundant: trash it, subject to the runtime interlocks in
/// [`Core::duplicate_still_removable`].
Remove,
/// Only the user can resolve this one; surface it with this detail.
Flag(String),
}
/// Decide what to do about conflict copy `node` given its live sibling (if any)
/// and the configured `mode`.
///
/// Pure, so the policy that governs deletion is testable without a mount, a
/// runtime, or an account — the same split used for `revision_changed` and
/// `is_own_self_supersede`. Everything that can fail or race lives in the caller.
fn decide(
node: &proton_drive_rs::Node,
sibling: Option<&proton_drive_rs::Node>,
base: &str,
mode: SweepMode,
) -> SweepAction {
match sibling {
Some(sib) if is_identical(node, sib) => {
if mode.is_enforcing() {
SweepAction::Remove
} else {
// Report-only: say what would happen, remove nothing. The point is
// to accumulate field evidence that the identity check is right
// before it is trusted with deletion (docs/BUGS.md B71).
SweepAction::Flag(format!(
"identical to {base}; would be removed (sweep is report-only)"
))
}
}
Some(_) => SweepAction::Flag(format!("differs from {base}")),
None => SweepAction::Flag(format!("no original {base} to reconcile against")),
}
}
/// Whether two file nodes provably hold the same bytes: equal size and equal,
/// present content SHA-1. A missing digest on either side is not proof, so the
/// sweep treats it as divergent rather than risk removing a real difference.
fn is_identical(a: &proton_drive_rs::Node, b: &proton_drive_rs::Node) -> bool {
if node_size(a) != node_size(b) {
return false;
}
match (node_content_sha1(a), node_content_sha1(b)) {
(Some(x), Some(y)) => x == y,
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use proton_drive_rs::NodeKind;
use proton_drive_rs::proton_sdk::ids::{LinkId, VolumeId};
/// A file node with the two fields the sweep's identity check reads.
fn file(name: &str, size: i64, sha1: Option<&str>) -> proton_drive_rs::Node {
proton_drive_rs::Node {
uid: NodeUid::new(VolumeId::from("v"), LinkId::from(name)),
parent_uid: None,
kind: NodeKind::File {
media_type: "text/plain".into(),
total_size_on_storage: size,
active_revision_state: None,
active_revision_id: Some("rev".into()),
claimed_size: Some(size),
claimed_modification_time: None,
content_sha1: sha1.map(String::from),
},
name: name.into(),
creation_time: 0,
modification_time: 0,
trashed: false,
is_shared: false,
is_shared_publicly: false,
signature_email: None,
membership: None,
photo: None,
album: None,
verification: Default::default(),
direct_role: None,
share_id: None,
}
}
/// The only input shape that may ever produce a removal: same size, same
/// present digest, live sibling, enforcing mode.
#[test]
fn removes_only_a_provably_identical_copy_when_enforcing() {
let copy = file("f (sync-conflict 1).txt", 100, Some("aaa"));
let live = file("f.txt", 100, Some("aaa"));
assert_eq!(
decide(&copy, Some(&live), "f.txt", SweepMode::Enforce),
SweepAction::Remove
);
}
#[test]
fn report_only_never_removes_but_still_says_what_it_would_do() {
let copy = file("f (sync-conflict 1).txt", 100, Some("aaa"));
let live = file("f.txt", 100, Some("aaa"));
// Same inputs as the removal case above; only the mode differs.
let SweepAction::Flag(detail) = decide(&copy, Some(&live), "f.txt", SweepMode::Report)
else {
panic!("report mode must never remove");
};
assert!(detail.contains("would be removed"), "{detail}");
assert!(detail.contains("report-only"), "{detail}");
}
#[test]
fn a_differing_size_is_a_real_conflict() {
let copy = file("f (sync-conflict 1).txt", 101, Some("aaa"));
let live = file("f.txt", 100, Some("aaa"));
assert_eq!(
decide(&copy, Some(&live), "f.txt", SweepMode::Enforce),
SweepAction::Flag("differs from f.txt".into())
);
}
#[test]
fn a_missing_digest_is_never_proof_of_identity() {
// Equal sizes are not enough: two different 100-byte files are common.
// A copy the sweep cannot fingerprint must survive, in either direction.
for (a, b) in [(None, Some("aaa")), (Some("aaa"), None), (None, None)] {
let copy = file("f (sync-conflict 1).txt", 100, a);
let live = file("f.txt", 100, b);
assert_eq!(
decide(&copy, Some(&live), "f.txt", SweepMode::Enforce),
SweepAction::Flag("differs from f.txt".into()),
"{a:?} vs {b:?}"
);
}
}
#[test]
fn a_differing_digest_at_equal_size_is_a_real_conflict() {
let copy = file("f (sync-conflict 1).txt", 100, Some("aaa"));
let live = file("f.txt", 100, Some("bbb"));
assert_eq!(
decide(&copy, Some(&live), "f.txt", SweepMode::Enforce),
SweepAction::Flag("differs from f.txt".into())
);
}
#[test]
fn an_orphaned_copy_is_surfaced_not_removed() {
// No live original left to reconcile against — the copy may be the only
// surviving version of the file, so it is never the sweep's to remove.
let copy = file("f (sync-conflict 1).txt", 100, Some("aaa"));
assert_eq!(
decide(&copy, None, "f.txt", SweepMode::Enforce),
SweepAction::Flag("no original f.txt to reconcile against".into())
);
}
#[test]
fn identity_needs_both_size_and_digest_to_match() {
let a = file("a", 100, Some("aaa"));
assert!(is_identical(&a, &file("b", 100, Some("aaa"))));
assert!(!is_identical(&a, &file("b", 101, Some("aaa"))));
assert!(!is_identical(&a, &file("b", 100, Some("bbb"))));
assert!(!is_identical(&a, &file("b", 100, None)));
}
#[test]
fn parses_conflict_names_and_leaves_others_alone() {
assert_eq!(
conflict_base_name("test_export (sync-conflict 1784898786).xml").as_deref(),
Some("test_export.xml")
);
assert_eq!(
conflict_base_name("README (sync-conflict 42)").as_deref(),
Some("README")
);
// The sync engine's de-duplicated suffix form.
assert_eq!(
conflict_base_name("notes (sync-conflict 1700-3).txt").as_deref(),
Some("notes.txt")
);
// Not a conflict copy.
assert_eq!(conflict_base_name("report.xml"), None);
// Looks close but the token is not a stamp.
assert_eq!(conflict_base_name("x (sync-conflict abc).txt"), None);
}
#[test]
fn a_dotted_stem_keeps_its_inner_dots() {
assert_eq!(
conflict_base_name("archive.tar (sync-conflict 7).gz").as_deref(),
Some("archive.tar.gz")
);
}
}
File diff suppressed because it is too large. Load diff
+287
View File
@@ -0,0 +1,287 @@
//! Filesystem watcher, polling, and debounce control plane.
use super::*;
/// How long the watcher must go quiet before a burst counts as finished.
///
/// Measured from the *last* event, not the first: applications do not write a
/// file once. An editor saves by writing a temp file and renaming it over the
/// target; an export or a database dump writes continuously for as long as it
/// takes. Settling a fixed interval after the first event walks the tree while
/// the file is still growing, and uploads a torn snapshot as a real revision —
/// corrected on the next pass, but only after spending the encrypt-and-upload
/// on a file that was never in that state.
const DEBOUNCE: Duration = Duration::from_secs(2);
/// Ceiling on the settle wait, so a directory under sustained change still
/// syncs. Without it, copying a large tree in — events arriving forever —
/// would postpone the reconcile for as long as the copy ran.
const MAX_SETTLE: Duration = Duration::from_secs(30);
/// How often to re-walk remotes so changes made on other clients are pulled in.
const POLL_INTERVAL: Duration = Duration::from_secs(120);
/// How many uploads/downloads/folder-creations a reconcile runs at once. The
/// per-item work is a network round-trip, so a folder with thousands of files is
/// unusable done one-at-a-time; this bounds concurrency like the bulk-upload
/// engine does.
pub(super) const SYNC_CONCURRENCY: usize = 8;
/// A message to the sync engine's control thread.
pub(crate) enum SyncMsg {
/// Reconcile one folder by id (a local change, or a targeted `SyncNow`).
Reconcile(i64),
/// Reconcile every mirror folder (periodic poll, or startup).
ReconcileAll,
/// Re-read the folder set and adjust the filesystem watches (after add/remove).
Rewatch,
/// The daemon is tearing down: finish the current pass and end the engine
/// thread, so the mount can join it (bugs.md B44).
Stop,
}
/// Start the sync engine: a control thread that owns the filesystem watcher and
/// serialises reconcile passes, plus a periodic poll thread. The engine receives
/// on `rx`; senders live in [`Core::sync_tx`].
pub(crate) fn spawn(core: Core, rx: Receiver<SyncMsg>) -> Option<std::thread::JoinHandle<()>> {
match std::thread::Builder::new()
.name("pdfs-sync".into())
.spawn(move || engine_loop(core, rx))
{
Ok(handle) => Some(handle),
Err(e) => {
warn!(error = %e, "failed to start sync engine");
None
}
}
}
/// The engine control loop. Single-threaded so reconcile passes never overlap.
fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
// Paths the watcher currently covers, mapped to their folder id, so an event
// path can be resolved back to the folder it belongs to.
let watched: Mutex<Vec<(PathBuf, i64)>> = Mutex::new(Vec::new());
let tx_events = core.sync_tx.clone();
let mut watcher =
match notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
let Ok(event) = res else { return };
// Ignore pure metadata/access noise; content changes are what matter.
if matches!(event.kind, notify::EventKind::Access(_)) {
return;
}
let _ = tx_events.send(SyncMsg::ReconcileAll);
}) {
Ok(w) => w,
Err(e) => {
warn!(error = %e, "filesystem watcher unavailable; sync is poll-only");
// Still run the loop so polling and explicit SyncNow work.
poll_only_loop(&core, rx);
return;
}
};
// Periodic remote poll.
{
let tx = core.sync_tx.clone();
let shutdown = core.shutdown.clone();
std::thread::Builder::new()
.name("pdfs-sync-poll".into())
.spawn(move || {
while shutdown.sleep(POLL_INTERVAL) {
if tx.send(SyncMsg::ReconcileAll).is_err() {
break;
}
}
})
.ok();
}
rewatch(&core, &mut watcher, &watched);
reconcile_all(&core);
while let Ok(msg) = rx.recv() {
let mut ids: HashSet<i64> = HashSet::new();
let mut all = false;
let mut do_rewatch = false;
if !classify(msg, &mut ids, &mut all, &mut do_rewatch)
|| !settle(&rx, &mut ids, &mut all, &mut do_rewatch)
{
info!("sync engine stopping");
return;
}
if do_rewatch {
rewatch(&core, &mut watcher, &watched);
}
if all {
reconcile_all(&core);
} else {
for id in ids {
if let Ok(Some(folder)) = core.db.sync_folder_get(id) {
core.reconcile_folder(&folder);
}
}
}
}
}
/// Fallback loop when no filesystem watcher could be created: poll + SyncNow only.
fn poll_only_loop(core: &Core, rx: Receiver<SyncMsg>) {
{
let tx = core.sync_tx.clone();
let shutdown = core.shutdown.clone();
std::thread::spawn(move || {
while shutdown.sleep(POLL_INTERVAL) {
if tx.send(SyncMsg::ReconcileAll).is_err() {
break;
}
}
});
}
reconcile_all(core);
while let Ok(msg) = rx.recv() {
let mut ids: HashSet<i64> = HashSet::new();
let mut all = false;
let mut do_rewatch = false;
if !classify(msg, &mut ids, &mut all, &mut do_rewatch)
|| !settle(&rx, &mut ids, &mut all, &mut do_rewatch)
{
info!("sync engine stopping");
return;
}
if all {
reconcile_all(core);
} else {
for id in ids {
if let Ok(Some(folder)) = core.db.sync_folder_get(id) {
core.reconcile_folder(&folder);
}
}
}
}
}
/// Absorb the rest of an event burst, returning once the watcher has been quiet
/// for `quiet` or `cap` has elapsed since the first event — whichever comes
/// first. The caller has already classified the event that opened the burst.
///
/// Split out with its timings as parameters so the settling behaviour can be
/// tested in milliseconds rather than in the tens of seconds the real constants
/// describe.
pub(super) fn settle_with(
rx: &Receiver<SyncMsg>,
ids: &mut HashSet<i64>,
all: &mut bool,
do_rewatch: &mut bool,
quiet: Duration,
cap: Duration,
) -> bool {
let deadline = Instant::now() + cap;
loop {
let now = Instant::now();
if now >= deadline {
break;
}
// Never wait past the cap, however quiet the watcher goes.
let wait = quiet.min(deadline - now);
match rx.recv_timeout(wait) {
// Another event: the burst is still going, so the quiet window
// starts again from here.
// A stop lands mid-burst: give up the rest of the burst rather
// than settling for it, and let the caller end the loop.
Ok(m) => {
if !classify(m, ids, all, do_rewatch) {
return false;
}
}
// Quiet for a full window: whatever was being written has stopped.
Err(RecvTimeoutError::Timeout) => break,
// Every sender is gone; the caller's loop is about to end anyway.
Err(RecvTimeoutError::Disconnected) => break,
}
}
true
}
/// [`settle_with`] at the real timings.
fn settle(
rx: &Receiver<SyncMsg>,
ids: &mut HashSet<i64>,
all: &mut bool,
do_rewatch: &mut bool,
) -> bool {
settle_with(rx, ids, all, do_rewatch, DEBOUNCE, MAX_SETTLE)
}
/// Fold one message into the pending work of the current burst. Returns `false`
/// for [`SyncMsg::Stop`], which is not work but an instruction to end the loop.
pub(super) fn classify(
msg: SyncMsg,
ids: &mut HashSet<i64>,
all: &mut bool,
rewatch: &mut bool,
) -> bool {
match msg {
SyncMsg::Reconcile(id) => {
ids.insert(id);
}
SyncMsg::ReconcileAll => *all = true,
SyncMsg::Rewatch => *rewatch = true,
SyncMsg::Stop => return false,
}
true
}
/// Reconcile every mirror folder in turn.
fn reconcile_all(core: &Core) {
let folders = match core.db.sync_folder_list() {
Ok(f) => f,
Err(e) => {
warn!(error = ?e, "sync: cannot list folders");
return;
}
};
for folder in folders {
core.reconcile_folder(&folder);
}
}
/// Bring the filesystem watches in line with the current mirror-folder set:
/// watch newly-added folders, drop removed ones.
fn rewatch(
core: &Core,
watcher: &mut notify::RecommendedWatcher,
watched: &Mutex<Vec<(PathBuf, i64)>>,
) {
let want: Vec<(PathBuf, i64)> = match core.db.sync_folder_list() {
Ok(f) => f
.into_iter()
.filter(|f| f.mode == "mirror")
.map(|f| (PathBuf::from(f.local_path), f.id))
.collect(),
Err(e) => {
warn!(error = ?e, "sync: cannot list folders to watch");
return;
}
};
let mut have = watched.lock();
// Drop watches no longer wanted.
have.retain(|(path, _)| {
if want.iter().any(|(p, _)| p == path) {
true
} else {
let _ = watcher.unwatch(path);
false
}
});
// Add newly wanted watches.
for (path, id) in &want {
if have.iter().any(|(p, _)| p == path) {
continue;
}
match watcher.watch(path, RecursiveMode::Recursive) {
Ok(()) => have.push((path.clone(), *id)),
Err(e) => warn!(path = %path.display(), error = %e, "sync: watch failed"),
}
}
}
// ---- reconcile ------------------------------------------------------------
+81
View File
@@ -0,0 +1,81 @@
//! Local open-for-write detection used to defer torn uploads.
use super::*;
// ---- open-for-write detection ---------------------------------------------
/// Scan `/proc/*/fd` once and return the set of **canonical** paths under
/// `root` that any process currently holds open for writing (`O_WRONLY` or
/// `O_RDWR`). The scan is best-effort: unreadable `/proc/*/fd` directories
/// (other users' processes, or kernel threads) are silently skipped.
///
/// Cost: one `readdir` of `/proc`, then for every process one `readdir` of its
/// `/proc/<pid>/fd/`, and one `readlink` + one `open` of `/proc/<pid>/fdinfo/<n>`
/// per open fd that resolves under `root`. For a typical desktop with a few
/// hundred processes and a handful of files inside the sync root, this
/// completes in low single-digit milliseconds.
pub(super) fn open_for_write_set(root: &Path) -> HashSet<PathBuf> {
let mut result = HashSet::new();
let Ok(root_canonical) = std::fs::canonicalize(root) else {
return result;
};
let Ok(proc_entries) = std::fs::read_dir("/proc") else {
return result;
};
for proc_entry in proc_entries.filter_map(|e| e.ok()) {
let pid_name = proc_entry.file_name();
// Skip non-numeric entries (kernel threads, /proc/self, etc.)
if !pid_name
.to_str()
.is_some_and(|s| s.bytes().all(|b| b.is_ascii_digit()))
{
continue;
}
let fd_dir = PathBuf::from("/proc").join(&pid_name).join("fd");
let Ok(fds) = std::fs::read_dir(&fd_dir) else {
continue;
};
for fd_entry in fds.filter_map(|e| e.ok()) {
// readlink on /proc/<pid>/fd/<n> gives the target path.
let link = fd_dir.join(fd_entry.file_name());
let Ok(target) = std::fs::read_link(&link) else {
continue;
};
// Quick prefix check before the more expensive fdinfo read.
if !target.starts_with(&root_canonical) {
continue;
}
// Read the fd's flags from /proc/<pid>/fdinfo/<n> to determine
// the access mode. The first line is "pos:\t<offset>", the second
// is "flags:\t<octal>". We only need the low two bits of flags:
// 0 = O_RDONLY, 1 = O_WRONLY, 2 = O_RDWR
let fdinfo_path = PathBuf::from("/proc")
.join(&pid_name)
.join("fdinfo")
.join(fd_entry.file_name());
if let Ok(contents) = std::fs::read_to_string(&fdinfo_path)
&& is_write_mode(&contents)
{
result.insert(target);
}
}
}
result
}
/// Parse the `flags:` line out of a `/proc/<pid>/fdinfo/<n>` file and return
/// true if the low two bits indicate write access (`O_WRONLY = 1` or
/// `O_RDWR = 2`).
pub(super) fn is_write_mode(fdinfo: &str) -> bool {
for line in fdinfo.lines() {
if let Some(rest) = line.strip_prefix("flags:\t") {
if let Ok(flags) = u32::from_str_radix(rest.trim_start_matches('0'), 8) {
let access = flags & 0o3; // O_ACCMODE
return access == 1 || access == 2; // O_WRONLY or O_RDWR
}
// An empty octal string ("0" stripped to "") means flags == 0 == O_RDONLY.
return false;
}
}
false
}
+235
View File
@@ -0,0 +1,235 @@
//! Reconciliation snapshots, queued operations, and pass outcomes.
use super::*;
/// What a reconcile pass remembers about the local side of one path: enough to
/// recognise the same content again, and no more.
///
/// `mtime_ns` is the whole reason this is a struct rather than a pair — see
/// [`StoredSyncEntry::local_mtime_ns`](pdfs_core::db::StoredSyncEntry).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct LocalSig {
pub(super) mtime: i64,
pub(super) mtime_ns: Option<i64>,
pub(super) size: i64,
}
impl LocalSig {
/// Whether these two observations describe the same local content.
///
/// Nanoseconds decide it when both sides have them; otherwise the comparison
/// falls back to whole seconds, which is what a baseline row written before
/// schema 28 — or a filesystem that reports no sub-second time — can support
/// (bugs.md B25).
pub(super) fn same_content(&self, other: &Self) -> bool {
if self.size != other.size {
return false;
}
match (self.mtime_ns, other.mtime_ns) {
(Some(a), Some(b)) => a == b,
_ => self.mtime == other.mtime,
}
}
}
impl From<&LocalItem> for LocalSig {
fn from(item: &LocalItem) -> Self {
Self {
mtime: item.mtime,
mtime_ns: item.mtime_ns,
size: item.size,
}
}
}
impl From<&std::fs::Metadata> for LocalSig {
fn from(meta: &std::fs::Metadata) -> Self {
Self {
mtime: system_mtime(meta),
mtime_ns: system_mtime_ns(meta),
size: meta.len() as i64,
}
}
}
/// The baseline's record of the local side, as [`LocalSig`] compares it.
impl From<&StoredSyncEntry> for LocalSig {
fn from(entry: &StoredSyncEntry) -> Self {
Self {
mtime: entry.local_mtime,
mtime_ns: entry.local_mtime_ns,
size: entry.local_size,
}
}
}
pub(super) struct LocalItem {
pub(super) is_dir: bool,
pub(super) mtime: i64,
/// [`LocalItem::mtime`] to nanosecond precision, when the filesystem
/// reports one.
pub(super) mtime_ns: Option<i64>,
pub(super) size: i64,
/// True when another process holds this file open for writing (detected via
/// `/proc/*/fd`). An open-for-write file is kept in the map — so it is not
/// misread as a deletion — but treated as unchanged for upload purposes,
/// deferring the upload until the writer closes the file.
pub(super) open_for_write: bool,
}
/// One item found while walking a remote tree.
pub(super) struct RemoteItem {
pub(super) uid: NodeUid,
pub(super) is_dir: bool,
pub(super) mtime: i64,
pub(super) size: i64,
}
/// The result of a reconcile pass: what it moved, how many paths were kept as
/// conflict copies, and how many failed to apply (and so still need another
/// pass). The counts drive both the folder's state and its activity summary.
#[derive(Default)]
pub(super) struct Outcome {
pub(super) uploaded: usize,
pub(super) downloaded: usize,
pub(super) created: usize,
pub(super) deleted: usize,
pub(super) conflicts: usize,
pub(super) errors: usize,
/// Files skipped because another process held them open for writing.
pub(super) deferred: usize,
}
impl Outcome {
/// Fold in one applied op.
pub(super) fn record(&mut self, applied: &Applied) {
match applied {
Applied::Dir(..) => self.created += 1,
Applied::Uploaded => self.uploaded += 1,
Applied::Downloaded => self.downloaded += 1,
Applied::Conflict => self.conflicts += 1,
}
}
/// Whether the pass moved nothing at all — the common case on a poll of an
/// unchanged folder, which should not add a line to the activity feed.
pub(super) fn is_empty(&self) -> bool {
self.uploaded == 0
&& self.downloaded == 0
&& self.created == 0
&& self.deleted == 0
&& self.conflicts == 0
&& self.errors == 0
&& self.deferred == 0
}
/// A human summary of the pass: "3 uploaded, 1 downloaded, 2 failed".
pub(super) fn summary(&self) -> String {
let mut parts = Vec::new();
for (n, label) in [
(self.uploaded, "uploaded"),
(self.downloaded, "downloaded"),
(self.created, "folder(s) created"),
(self.deleted, "deleted"),
(self.conflicts, "conflicted"),
(self.deferred, "deferred (open for write)"),
(self.errors, "failed"),
] {
if n > 0 {
parts.push(format!("{n} {label}"));
}
}
parts.join(", ")
}
}
/// A network operation queued during classification and run concurrently in a
/// per-depth batch. Parent uids are resolved up front (the parent folder is one
/// depth shallower and already created), so tasks share nothing mutable.
pub(super) enum Pending {
/// Create a new remote folder under `parent`.
CreateDir { rel: String, parent: NodeUid },
/// Upload a brand-new local file into `parent`.
UploadNew { rel: String, parent: NodeUid },
/// Upload a changed local file as a new revision of `uid`.
UploadRevision { rel: String, uid: NodeUid },
/// Download remote `uid` to the local path, stamping `mtime`. `size` is the
/// remote's reported size, used as the transfer's expected total.
///
/// `local` is what the planning walk saw at this path, or `None` if nothing
/// was there. A download can run for minutes, so the apply step revalidates
/// against it immediately before it renames over the destination: a writer
/// that finished an edit in the meantime must not be overwritten without a
/// conflict copy (bugs.md B39).
Download {
rel: String,
uid: NodeUid,
mtime: i64,
size: i64,
local: Option<LocalSig>,
},
/// Both sides changed: set the local copy aside, then download remote `uid`.
Conflict {
rel: String,
uid: NodeUid,
mtime: i64,
size: i64,
},
/// Both sides changed during a [`push pass`](Core::push_pass): upload the local
/// copy into `parent` under a conflict name, leaving the remote file as it is.
PushConflict { rel: String, parent: NodeUid },
}
impl Pending {
/// The path this op acts on, relative to the folder root.
pub(super) fn rel(&self) -> &str {
match self {
Pending::CreateDir { rel, .. }
| Pending::UploadNew { rel, .. }
| Pending::UploadRevision { rel, .. }
| Pending::Download { rel, .. }
| Pending::Conflict { rel, .. }
| Pending::PushConflict { rel, .. } => rel,
}
}
/// How this op reads in the activity feed.
pub(super) fn kind(&self) -> ActivityKind {
match self {
Pending::CreateDir { .. } => ActivityKind::CreateFolder,
Pending::UploadNew { .. }
| Pending::UploadRevision { .. }
| Pending::PushConflict { .. } => ActivityKind::Upload,
Pending::Download { .. } | Pending::Conflict { .. } => ActivityKind::Download,
}
}
/// The activity line's detail, which distinguishes ops that share a kind.
pub(super) fn detail(&self) -> &'static str {
match self {
Pending::CreateDir { .. } => "on Drive",
Pending::UploadNew { .. } => "new file",
Pending::UploadRevision { .. } => "new version",
Pending::Download { .. } => "from Drive",
Pending::Conflict { .. } => "local changes kept as a conflict copy",
Pending::PushConflict { .. } => "local changes uploaded as a conflict copy",
}
}
}
/// Why a full reconcile pass stopped without a result.
pub(super) enum PassAbort {
/// An on-demand switch was queued while the pass was running. Everything left
/// to do is work on a local copy about to be evicted, so the pass gives up its
/// remaining work to a [`push pass`](Core::push_pass) instead. Not a failure —
/// nothing is left half-applied, because each step is applied whole.
Interrupted,
/// The pass could not establish its diff (a walk or the baseline load failed).
Failed(String),
}
impl From<String> for PassAbort {
fn from(e: String) -> Self {
PassAbort::Failed(e)
}
}
+443
View File
@@ -0,0 +1,443 @@
//! Pure reconciliation ordering, safety guards, signatures, and path rules.
use super::*;
/// The paths a reconcile pass will classify: the union of the local, remote,
/// and baseline states, minus anything the ignore rules exclude, shallowest
/// first so a parent folder is created before its children are placed in it.
///
/// The ignore filter belongs *here*, on the union, rather than on the local walk
/// alone — filtering the walk alone would be actively destructive. A file synced
/// before it became ignored is absent from `local` but present in `remote` and
/// `baseline`, which is precisely the "local deleted, remote untouched" shape
/// `do_reconcile` responds to by trashing the remote copy. Adding a line to
/// `.pdfsignore` must never delete anything from Drive.
///
/// Ignored baseline rows are deliberately left in the database rather than
/// removed: with the row intact, un-ignoring the path later re-adopts the
/// existing remote file instead of reading as a brand-new local one.
pub(super) fn classification_order<L, R, B>(
local: &HashMap<String, L>,
remote: &HashMap<String, R>,
baseline: &HashMap<String, B>,
rules: &IgnoreRules,
) -> Vec<String>
where
L: HasKind,
R: HasKind,
{
let mut paths: HashSet<String> = HashSet::new();
paths.extend(local.keys().cloned());
paths.extend(remote.keys().cloned());
paths.extend(baseline.keys().cloned());
let mut order: Vec<String> = paths.into_iter().collect();
if !rules.is_empty() {
order.retain(|rel| {
// A baseline-only path has no kind recorded, so it is tested as
// both; see `filter_baseline` for why erring towards "ignored" is
// the safe direction here.
let kind = local
.get(rel)
.map(HasKind::is_dir)
.or_else(|| remote.get(rel).map(HasKind::is_dir));
match kind {
Some(is_dir) => !rules.is_ignored(rel, is_dir),
None => !rules.is_ignored(rel, false) && !rules.is_ignored(rel, true),
}
});
}
order.sort_by_key(|p| p.matches('/').count());
order
}
/// Lets [`classification_order`] ask either walk's item whether it is a
/// directory without knowing which walk it came from.
pub(super) trait HasKind {
fn is_dir(&self) -> bool;
}
impl HasKind for LocalItem {
fn is_dir(&self) -> bool {
self.is_dir
}
}
impl HasKind for RemoteItem {
fn is_dir(&self) -> bool {
self.is_dir
}
}
/// The baseline minus its ignored paths, for [`guard_local_wipe`].
///
/// The guard asks "did every synced path vanish locally?", and an ignored path
/// is absent from the local walk by rule rather than by loss. Left in, a rule
/// covering the whole tree would trip the guard on every pass and wedge that
/// folder's sync for good.
///
/// A baseline row does not record whether it was a file or a folder, so a path
/// counts as ignored if it matches as either. Erring towards "ignored" only ever
/// shrinks the set the guard checks, which weakens a safety net rather than
/// causing a deletion — the wrong direction to be wrong in is the other one.
pub(super) fn filter_baseline<'a, B>(
baseline: &'a HashMap<String, B>,
rules: &IgnoreRules,
) -> HashMap<String, &'a B> {
baseline
.iter()
.filter(|(rel, _)| !rules.is_ignored(rel, false) && !rules.is_ignored(rel, true))
.map(|(rel, entry)| (rel.clone(), entry))
.collect()
}
/// Refuse to run a pass whose local side has vanished in its entirety.
///
/// When every baseline path is absent locally, the likely cause is an unavailable
/// mount or unreadable folder rather than a deliberate whole-tree deletion.
/// One surviving path is enough to clear the guard.
pub(super) fn guard_local_wipe<B, L>(
baseline: &HashMap<String, B>,
local: &HashMap<String, L>,
) -> Result<(), String> {
if !baseline.is_empty() && baseline.keys().all(|rel| !local.contains_key(rel)) {
return Err(format!(
"every one of the {} synced paths is missing locally; refusing to trash \
them on Drive. Check that the folder is mounted and readable.",
baseline.len()
));
}
Ok(())
}
/// The size the baseline recorded for `rel`, if its remote signature's mtime
/// still matches `mtime`.
pub(super) fn unchanged_remote_size(
baseline: &HashMap<String, StoredSyncEntry>,
rel: &str,
mtime: i64,
) -> Option<i64> {
match baseline.get(rel).and_then(remote_sig) {
Some((recorded, size)) if recorded == mtime => Some(size),
_ => None,
}
}
/// Join a child `name` onto a walk's `prefix`, giving a rel path.
pub(super) fn join_rel(prefix: &str, name: &str) -> String {
if prefix.is_empty() {
name.to_string()
} else {
format!("{prefix}/{name}")
}
}
/// The stored remote signature of a baseline row, if it has one.
pub(super) fn remote_sig(e: &StoredSyncEntry) -> Option<(i64, i64)> {
match (&e.remote_rev, &e.remote_hash) {
(Some(m), Some(s)) => Some((m.parse().ok()?, s.parse().ok()?)),
_ => None,
}
}
/// The parent of a `/`-joined relative path (`""` for a top-level entry).
pub(super) fn parent_rel(rel: &str) -> &str {
match rel.rfind('/') {
Some(i) => &rel[..i],
None => "",
}
}
/// The final component of a `/`-joined relative path.
pub(crate) fn base_name(rel: &str) -> &str {
match rel.rfind('/') {
Some(i) => &rel[i + 1..],
None => rel,
}
}
/// Turn a `/`-joined relative path into an OS path (`/` is already the separator
/// on Linux, this keeps the intent explicit).
pub(super) fn rel_to_path(rel: &str) -> PathBuf {
rel.split('/').collect()
}
/// The name for a conflict copy of `path`, e.g. `notes (sync-conflict 1700000000).txt`.
#[cfg(test)]
pub(super) fn conflict_path(path: &Path, stamp: i64) -> PathBuf {
conflict_path_with_suffix(path, stamp, 0)
}
/// As [`conflict_path`], with a deterministic suffix used when that name already
/// exists. This keeps conflict preservation from ever replacing an older copy.
pub(super) fn conflict_path_with_suffix(path: &Path, stamp: i64, suffix: u32) -> PathBuf {
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("file");
let ext = path.extension().and_then(|s| s.to_str());
let suffix = if suffix == 0 {
String::new()
} else {
format!("-{suffix}")
};
let name = match ext {
Some(ext) => format!("{stem} (sync-conflict {stamp}{suffix}).{ext}"),
None => format!("{stem} (sync-conflict {stamp}{suffix})"),
};
match path.parent() {
Some(dir) => dir.join(name),
None => PathBuf::from(name),
}
}
/// A pure decision for one file-shaped path. Transfer identities and filesystem
/// effects are attached by the executor after this classification.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum FilePlan {
Unchanged,
Deferred,
UploadNew,
UploadRevision,
Download,
Conflict,
DeleteLocal,
DeleteRemote,
ForgetBaseline,
/// First sync of a file that already exists on both sides with identical
/// content: record the baseline without transferring anything. Without this,
/// a folder synced for the first time cuts a `(sync-conflict)` copy of every
/// pre-existing file, because a missing baseline makes both sides read as
/// "changed" (see the mass first-sync conflict storm in docs/BUGS.md).
AdoptBaseline,
}
pub(super) fn plan_file(
local: Option<&LocalItem>,
remote: Option<&RemoteItem>,
baseline: Option<&StoredSyncEntry>,
) -> FilePlan {
if local.is_some_and(|item| item.open_for_write) {
return FilePlan::Deferred;
}
let local_changed = local.is_some_and(|item| {
// Nanoseconds where both sides have them (bugs.md B25): whole seconds
// could not see an edit made in the same second as the last sync that
// left the file the same length, so it was never uploaded.
baseline.is_none_or(|base| !LocalSig::from(item).same_content(&LocalSig::from(base)))
});
let remote_changed = remote.is_some_and(|item| {
baseline.is_none_or(|base| remote_sig(base) != Some((item.mtime, item.size)))
});
match (local, remote) {
(Some(l), Some(r)) => match (local_changed, remote_changed) {
(false, false) => FilePlan::Unchanged,
(true, false) => FilePlan::UploadRevision,
(false, true) => FilePlan::Download,
// With a baseline, both-changed is a genuine concurrent edit and must
// be preserved as a conflict. Without one, "both changed" only means
// this is the first sync and there was nothing to compare to: adopt
// the baseline when the sizes already match rather than conflict-copy
// an already-identical file. Divergent sizes stay a real conflict.
(true, true) if baseline.is_none() && l.size == r.size => FilePlan::AdoptBaseline,
(true, true) => FilePlan::Conflict,
},
(Some(_), None) if baseline.is_none() || local_changed => FilePlan::UploadNew,
(Some(_), None) => FilePlan::DeleteLocal,
(None, Some(_)) if baseline.is_none() || remote_changed => FilePlan::Download,
(None, Some(_)) => FilePlan::DeleteRemote,
(None, None) => FilePlan::ForgetBaseline,
}
}
#[cfg(test)]
mod file_plan_tests {
use super::*;
use proton_drive_rs::proton_sdk::ids::{LinkId, VolumeId};
fn local(mtime: i64, size: i64) -> LocalItem {
LocalItem {
mtime_ns: None,
is_dir: false,
mtime,
size,
open_for_write: false,
}
}
fn remote(mtime: i64, size: i64) -> RemoteItem {
RemoteItem {
uid: NodeUid::new(VolumeId::from("v"), LinkId::from("l")),
is_dir: false,
mtime,
size,
}
}
fn baseline(
local_mtime: i64,
local_size: i64,
remote_mtime: i64,
remote_size: i64,
) -> StoredSyncEntry {
StoredSyncEntry {
rel_path: "file".into(),
remote_uid: Some("v~l".into()),
local_mtime,
local_mtime_ns: None,
local_size,
remote_rev: Some(remote_mtime.to_string()),
remote_hash: Some(remote_size.to_string()),
}
}
/// Whole seconds cannot see an edit made inside the same second that leaves the
/// file the same length, so a mirror silently stopped uploading it. With a
/// nanosecond time on both sides it is an ordinary local change (bugs.md B25).
#[test]
fn a_same_second_same_size_edit_is_still_a_local_change() {
let mut base = baseline(10, 20, 30, 40);
base.local_mtime_ns = Some(10_000_000_000);
let edited = LocalItem {
mtime_ns: Some(10_400_000_000),
..local(10, 20)
};
let remote = remote(30, 40);
assert_eq!(
plan_file(Some(&edited), Some(&remote), Some(&base)),
FilePlan::UploadRevision
);
// The same file untouched still reads as unchanged.
let same = LocalItem {
mtime_ns: Some(10_000_000_000),
..local(10, 20)
};
assert_eq!(
plan_file(Some(&same), Some(&remote), Some(&base)),
FilePlan::Unchanged
);
// And a row from before the column exists compares on seconds, exactly
// as it did when it was written — no mass re-upload on upgrade.
let old = baseline(10, 20, 30, 40);
assert_eq!(
plan_file(Some(&edited), Some(&remote), Some(&old)),
FilePlan::Unchanged
);
}
#[test]
fn file_plan_covers_every_presence_and_change_shape() {
let same_local = local(10, 20);
let changed_local = local(11, 20);
let same_remote = remote(30, 40);
let changed_remote = remote(31, 40);
let base = baseline(10, 20, 30, 40);
let cases = [
(None, None, None, FilePlan::ForgetBaseline),
(None, None, Some(&base), FilePlan::ForgetBaseline),
(Some(&same_local), None, None, FilePlan::UploadNew),
(Some(&same_local), None, Some(&base), FilePlan::DeleteLocal),
(Some(&changed_local), None, Some(&base), FilePlan::UploadNew),
(None, Some(&same_remote), None, FilePlan::Download),
(
None,
Some(&same_remote),
Some(&base),
FilePlan::DeleteRemote,
),
(None, Some(&changed_remote), Some(&base), FilePlan::Download),
(
Some(&same_local),
Some(&same_remote),
None,
FilePlan::Conflict,
),
(
Some(&same_local),
Some(&same_remote),
Some(&base),
FilePlan::Unchanged,
),
(
Some(&changed_local),
Some(&same_remote),
Some(&base),
FilePlan::UploadRevision,
),
(
Some(&same_local),
Some(&changed_remote),
Some(&base),
FilePlan::Download,
),
(
Some(&changed_local),
Some(&changed_remote),
Some(&base),
FilePlan::Conflict,
),
];
for (local, remote, baseline, expected) in cases {
assert_eq!(plan_file(local, remote, baseline), expected);
}
}
#[test]
fn an_open_writer_overrides_every_other_file_decision() {
let mut local = local(11, 20);
local.open_for_write = true;
let remote = remote(31, 40);
let base = baseline(10, 20, 30, 40);
for (remote, baseline) in [
(None, None),
(None, Some(&base)),
(Some(&remote), None),
(Some(&remote), Some(&base)),
] {
assert_eq!(
plan_file(Some(&local), remote, baseline),
FilePlan::Deferred
);
}
}
#[test]
fn first_sync_of_an_identical_file_adopts_the_baseline_not_a_conflict() {
// No baseline yet (first sync). Same size on both sides is a pre-existing
// identical file, not a concurrent edit: adopt it rather than cut a
// conflict copy of every file in the folder.
let local = local(10, 40);
let remote = remote(30, 40);
assert_eq!(
plan_file(Some(&local), Some(&remote), None),
FilePlan::AdoptBaseline
);
}
#[test]
fn first_sync_of_differing_sizes_is_still_a_conflict() {
// No baseline and the sizes disagree: a genuine both-sides divergence,
// preserved non-destructively as a conflict.
let local = local(10, 20);
let remote = remote(30, 40);
assert_eq!(
plan_file(Some(&local), Some(&remote), None),
FilePlan::Conflict
);
}
#[test]
fn a_missing_remote_signature_is_treated_as_changed() {
let local = local(10, 20);
let remote = remote(30, 40);
let mut base = baseline(10, 20, 30, 40);
base.remote_hash = None;
assert_eq!(
plan_file(Some(&local), Some(&remote), Some(&base)),
FilePlan::Download
);
}
}
+99
View File
@@ -0,0 +1,99 @@
//! The `sd_notify` half of the service contract, without linking libsystemd.
//!
//! The protocol is one datagram of `KEY=VALUE` lines to the socket in
//! `$NOTIFY_SOCKET`. That is little enough to write directly, and writing it
//! directly keeps the daemon buildable on a machine without systemd headers —
//! everything here degrades to "no socket, do nothing" off systemd.
use std::os::unix::net::UnixDatagram;
use std::time::Duration;
use tracing::debug;
/// Send one notification. `false` when there is no manager to send it to, or the
/// send failed — a caller has nothing useful to do about either.
pub(crate) fn notify(message: &str) -> bool {
let Ok(socket_path) = std::env::var("NOTIFY_SOCKET") else {
return false;
};
if socket_path.is_empty() {
return false;
}
let Ok(socket) = UnixDatagram::unbound() else {
return false;
};
if let Some(name) = socket_path.strip_prefix('@') {
// Abstract namespace: the address is a NUL byte followed by the name,
// which `std`'s path-based API cannot express.
send_abstract(&socket, name, message)
} else {
socket.send_to(message.as_bytes(), &socket_path).is_ok()
}
}
/// `sendto` on an abstract-namespace address, which needs a hand-built
/// `sockaddr_un` because the first byte of the name is NUL.
fn send_abstract(socket: &UnixDatagram, name: &str, message: &str) -> bool {
use std::os::fd::AsRawFd;
let mut addr: libc::sockaddr_un = unsafe { std::mem::zeroed() };
addr.sun_family = libc::AF_UNIX as libc::sa_family_t;
let bytes = name.as_bytes();
// One byte for the leading NUL, and the path must still be addressable.
if bytes.len() + 1 > addr.sun_path.len() {
return false;
}
for (slot, byte) in addr.sun_path.iter_mut().skip(1).zip(bytes) {
*slot = *byte as libc::c_char;
}
let len = std::mem::size_of::<libc::sa_family_t>() + 1 + bytes.len();
let sent = unsafe {
libc::sendto(
socket.as_raw_fd(),
message.as_ptr().cast(),
message.len(),
0,
std::ptr::from_ref(&addr).cast(),
len as libc::socklen_t,
)
};
sent >= 0
}
/// Tell the manager the daemon is up and serving. Called once the mount is live,
/// so `systemctl --user start` returns when the mount actually works rather than
/// when the process exists.
pub(crate) fn ready() {
if notify("READY=1\n") {
debug!("notified systemd: ready");
}
}
/// Tell the manager the daemon is still healthy.
pub(crate) fn watchdog_ping() {
let _ = notify("WATCHDOG=1\n");
}
/// Tell the manager the daemon is stopping, so a slow teardown is not mistaken
/// for a hang by the watchdog it is racing.
pub(crate) fn stopping() {
let _ = notify("STOPPING=1\n");
}
/// How often the manager expects a ping, from `$WATCHDOG_USEC`, or `None` when
/// the watchdog is off or belongs to another process.
///
/// Reports *half* the configured interval, the interval systemd documents for
/// pinging: a ping period equal to the deadline would restart the daemon on the
/// first late scheduling.
pub(crate) fn watchdog_period() -> Option<Duration> {
// `WATCHDOG_PID` is set when the manager wants a specific process to answer;
// in that case a ping from anyone else is not the one it is waiting for.
if let Ok(pid) = std::env::var("WATCHDOG_PID")
&& pid.parse::<u32>().ok() != Some(std::process::id())
{
return None;
}
let usec: u64 = std::env::var("WATCHDOG_USEC").ok()?.parse().ok()?;
(usec > 0).then(|| Duration::from_micros(usec / 2))
}
+632
View File
@@ -0,0 +1,632 @@
//! Importing a Google Photos Takeout export into Proton Photos.
//!
//! [`pdfs_core::takeout`] says what is in the archives; this is the part that
//! talks to Proton. One import runs on its own thread and moves through four
//! phases, reporting into the transfer registry as a job so the GUI's transfer
//! list shows it without a bespoke widget:
//!
//! 1. **Scan** — read the archives' central directories and sidecars.
//! 2. **Deduplicate against the account** — ask the photos volume which of the
//! names already exist ([`ProtonPhotosClient::name_collisions`]), hash only
//! those files, then confirm with the content digest. A library that is
//! already half-synced by Proton Photos on a phone therefore costs one cheap
//! request per 150 names and reads no media at all for the misses.
//! 3. **Upload** the survivors, streaming each entry straight out of its zip —
//! no extraction to disk, so the import needs no scratch space.
//! 4. **Albums** — create the albums the export names that the account lacks,
//! then add every photo of each album, the ones that were already on the
//! account included.
//!
//! Re-running an interrupted import is the resume story: phase 2 finds
//! everything the previous run uploaded and skips it. Nothing is persisted
//! between runs.
use std::collections::{BTreeMap, HashMap};
use std::io::Read;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use pdfs_core::control::{ActivityKind, ImportSummary, TransferDirection};
use pdfs_core::takeout::{Bucket, TakeoutPhoto, TakeoutScan, scan_archives};
use pdfs_core::{CoreError, CoreResult};
use proton_drive_rs::proton_sdk::ids::NodeUid;
use proton_drive_rs::{PhotoTag, PhotoTagsUpdate, PhotoUploadMetadata};
use sha1::{Digest, Sha1};
use tracing::{info, warn};
use super::Core;
use crate::transfers::CountingReader;
/// The one import this daemon may be running, and how the last one ended.
///
/// Process-global rather than a [`Core`] field because it must be *one* import
/// per daemon however many `Core` clones exist (the forked on-demand mounts each
/// hold one), and because a front-end asking for the result after the import
/// finished must still find it.
static IMPORT: std::sync::LazyLock<parking_lot::Mutex<ImportState>> =
std::sync::LazyLock::new(|| parking_lot::Mutex::new(ImportState::default()));
#[derive(Default)]
struct ImportState {
running: bool,
cancel: Option<Arc<AtomicBool>>,
last: Option<ImportSummary>,
}
/// Whether an import is running, and the last one's report.
pub(crate) fn import_status() -> (bool, Option<ImportSummary>) {
let state = IMPORT.lock();
(state.running, state.last.clone())
}
/// Ask the running import to stop. False when none is running.
pub(crate) fn cancel_import() -> bool {
match &IMPORT.lock().cancel {
Some(flag) => {
flag.store(true, Ordering::Relaxed);
true
}
None => false,
}
}
/// One photo to import, after the copies Google keeps per album are folded into
/// a single upload that joins several albums.
struct Planned {
photo: TakeoutPhoto,
/// Every album this photo belongs to, by export title.
albums: Vec<String>,
/// Filled in phase 2/3: where the photo ended up on the account.
uid: Option<NodeUid>,
}
/// Fold the scan into one entry per distinct photo.
///
/// Google stores a full copy of a photo in every album folder it appears in, so
/// the same file is in the export two or three times over. They are folded on
/// `(name, size)`: a byte-identical copy under the same name is the same photo,
/// and the album memberships of the copies are unioned onto the survivor. Two
/// genuinely different photos sharing both a name and an exact size would be
/// merged — vanishingly unlikely, and the cost of being wrong is one photo
/// filed under an extra album rather than lost data.
fn plan(scan: &TakeoutScan) -> Vec<Planned> {
let mut order: Vec<(String, u64)> = Vec::new();
let mut by_key: HashMap<(String, u64), Planned> = HashMap::new();
for photo in &scan.photos {
let key = (photo.name.clone(), photo.size);
let entry = by_key.entry(key.clone()).or_insert_with(|| {
order.push(key.clone());
Planned {
photo: photo.clone(),
albums: Vec::new(),
uid: None,
}
});
if let Bucket::Album(title) = &photo.bucket
&& !entry.albums.iter().any(|existing| existing == title)
{
entry.albums.push(title.clone());
}
// A copy that carries a capture time wins over one that does not: the
// album copy and the year-folder copy do not always both have a sidecar.
if entry.photo.capture_time.is_none() && photo.capture_time.is_some() {
entry.photo.capture_time = photo.capture_time;
}
entry.photo.favorite |= photo.favorite;
}
order
.into_iter()
.filter_map(|key| by_key.remove(&key))
.collect()
}
/// Open one archive and stream `entry` through `sink`.
fn read_entry<R>(
archive: &mut zip::ZipArchive<std::io::BufReader<std::fs::File>>,
entry: &str,
sink: impl FnOnce(&mut (dyn Read + Send)) -> R,
) -> CoreResult<R> {
let mut file = archive
.by_name(entry)
.map_err(|e| CoreError::invalid(format!("{entry}: {e}")))?;
Ok(sink(&mut file))
}
impl Core {
/// Import Google Photos Takeout archives. Blocking; run on its own thread.
///
/// `cancel` stops the run at the next photo boundary — an upload already on
/// the wire is allowed to finish, so a cancelled import never leaves a
/// half-written revision behind.
pub(crate) fn import_takeout(
&self,
archives: Vec<PathBuf>,
dry_run: bool,
cancel: Arc<AtomicBool>,
) -> CoreResult<ImportSummary> {
let job = self.transfers.begin_job("Importing Google Photos");
job.detail("reading archives");
let scan = scan_archives(&archives)?;
let mut planned = plan(&scan);
let mut summary = ImportSummary {
found: planned.len(),
skipped_trashed: scan.skipped_trashed,
..Default::default()
};
job.set_total(planned.len() as u64);
info!(
archives = archives.len(),
photos = summary.found,
albums = scan.albums.len(),
trashed = summary.skipped_trashed,
"takeout scan complete"
);
let photos = self.photos();
if self
.rt
.block_on(photos.get_photos_root())
.map_err(|e| CoreError::from_api(&e, "resolve photos library"))?
.is_none()
{
return Err(CoreError::invalid(
"this account has no Proton Photos library yet; open Proton Photos once to create it, then import again",
));
}
// Phase 2: which of these are already on the account.
job.detail("checking for photos you already have");
self.mark_existing(&mut planned, &archives, &mut summary, &cancel)?;
if dry_run {
summary.duplicates = planned.iter().filter(|p| p.uid.is_some()).count();
return Ok(summary);
}
// Phase 3: upload what is left, one archive at a time so each zip's
// central directory is parsed once.
self.upload_planned(&mut planned, &archives, &mut summary, &cancel, &job)?;
// Phase 4: albums. Attempted even after a cancel — the photos that did
// upload should still land in their albums.
if !scan.albums.is_empty() {
job.detail("sorting photos into albums");
if let Err(e) = self.link_albums(&planned, &scan.albums, &mut summary) {
warn!(error = %e, "takeout album linking failed");
}
}
self.spawn_timeline_refresh();
summary.cancelled = cancel.load(Ordering::Relaxed);
Ok(summary)
}
/// Fill in `uid` for every planned photo the account already holds.
///
/// Two passes on purpose: the name-only query is one request per 150 photos
/// and needs no file contents, so the expensive SHA-1 pass only runs for the
/// handful of names that actually collide.
fn mark_existing(
&self,
planned: &mut [Planned],
archives: &[PathBuf],
summary: &mut ImportSummary,
cancel: &AtomicBool,
) -> CoreResult<()> {
let photos = self.photos();
let names: Vec<String> = planned.iter().map(|p| p.photo.name.clone()).collect();
let collisions = self
.rt
.block_on(photos.name_collisions(&names))
.map_err(|e| CoreError::from_api(&e, "check for existing photos"))?;
// Hash the colliding files, grouped by archive so each zip opens once.
let mut by_archive: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
for (index, planned) in planned.iter().enumerate() {
if collisions.get(index).copied().unwrap_or(false) {
by_archive
.entry(planned.photo.archive)
.or_default()
.push(index);
}
}
// Kept as a pair list, not a map: the duplicate lookup answers in input
// order, so the order these were built in is what maps answers back to
// planned photos.
let mut digests: Vec<(usize, String)> = Vec::new();
for (archive_index, indices) in by_archive {
if cancel.load(Ordering::Relaxed) {
break;
}
let mut archive = open_archive(archives, archive_index)?;
for index in indices {
let entry = planned[index].photo.entry.clone();
match read_entry(&mut archive, &entry, sha1_of) {
Ok(Ok(digest)) => digests.push((index, digest)),
Ok(Err(e)) | Err(e) => {
warn!(entry, error = %e, "cannot hash takeout entry");
}
}
}
}
if digests.is_empty() {
return Ok(());
}
let items: Vec<(String, String)> = digests
.iter()
.map(|(index, digest)| (planned[*index].photo.name.clone(), digest.clone()))
.collect();
let matches = self
.rt
.block_on(photos.find_duplicates_many(&items))
.map_err(|e| CoreError::from_api(&e, "check for existing photos"))?;
for (slot, (index, _)) in digests.iter().enumerate() {
if let Some(uid) = matches.get(slot).and_then(|uids| uids.first()) {
planned[*index].uid = Some(uid.clone());
summary.duplicates += 1;
}
}
Ok(())
}
/// Upload every planned photo that is not already on the account.
fn upload_planned(
&self,
planned: &mut [Planned],
archives: &[PathBuf],
summary: &mut ImportSummary,
cancel: &AtomicBool,
job: &crate::transfers::JobGuard,
) -> CoreResult<()> {
let photos = self.photos();
let mut favorites: Vec<NodeUid> = Vec::new();
let mut by_archive: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
for (index, item) in planned.iter().enumerate() {
if item.uid.is_none() {
by_archive
.entry(item.photo.archive)
.or_default()
.push(index);
}
}
for (archive_index, indices) in by_archive {
if cancel.load(Ordering::Relaxed) || self.shutdown.is_stopping() {
break;
}
let mut archive = open_archive(archives, archive_index)?;
for index in indices {
if cancel.load(Ordering::Relaxed) || self.shutdown.is_stopping() {
break;
}
let photo = planned[index].photo.clone();
job.detail(photo.name.clone());
let guard = self.transfers.begin(
photo.name.clone(),
"",
TransferDirection::Upload,
photo.size,
);
let metadata = PhotoUploadMetadata {
capture_time: photo.capture_time,
..Default::default()
};
let uploaded = read_entry(&mut archive, &photo.entry, |reader| {
let counting = CountingReader::new(reader, &guard);
self.rt.block_on(photos.upload_photo_from(
&photo.name,
&photo.media_type,
counting,
photo.size as i64,
Vec::new(),
metadata,
false,
))
});
drop(guard);
job.step();
match uploaded {
Ok(Ok(uid)) => {
summary.uploaded += 1;
summary.bytes += photo.size;
if photo.favorite {
favorites.push(uid.clone());
}
planned[index].uid = Some(uid);
}
Ok(Err(e)) => {
summary.failed += 1;
warn!(name = photo.name, error = %e, "takeout photo upload failed");
}
Err(e) => {
summary.failed += 1;
warn!(name = photo.name, error = %e, "cannot read takeout entry");
}
}
}
}
// Favourites are a separate call: the upload seal takes classification
// tags, but `Favorite` is not one of them — it goes through the photos
// volume's dedicated favourite endpoint.
if !favorites.is_empty() {
let updates: Vec<PhotoTagsUpdate> = favorites
.into_iter()
.map(|node_uid| PhotoTagsUpdate {
node_uid,
tags_to_add: vec![PhotoTag::Favorite],
tags_to_remove: Vec::new(),
})
.collect();
if let Err(e) = self.rt.block_on(photos.update_photos(&updates)) {
warn!(error = %e, "marking imported favourites failed");
}
}
Ok(())
}
/// Create the albums the export names that the account lacks, then add each
/// album's photos to it.
fn link_albums(
&self,
planned: &[Planned],
albums: &[String],
summary: &mut ImportSummary,
) -> CoreResult<()> {
let photos = self.photos();
// Existing albums by decrypted name, so a second import of the same
// export adds to the album it made the first time instead of a twin.
let mut existing: HashMap<String, NodeUid> = HashMap::new();
let uids = self
.rt
.block_on(photos.enumerate_album_node_uids())
.map_err(|e| CoreError::from_api(&e, "list albums"))?;
if !uids.is_empty() {
let albums_nodes = self
.rt
.block_on(photos.enumerate_nodes(&uids))
.map_err(|e| CoreError::from_api(&e, "read albums"))?;
for node in albums_nodes {
existing.entry(node.name.clone()).or_insert(node.uid);
}
}
for title in albums {
let members: Vec<NodeUid> = planned
.iter()
.filter(|item| item.albums.iter().any(|album| album == title))
.filter_map(|item| item.uid.clone())
.collect();
if members.is_empty() {
continue;
}
let album_uid = match existing.get(title) {
Some(uid) => uid.clone(),
None => match self.rt.block_on(photos.create_album(title)) {
Ok(uid) => {
summary.albums_created += 1;
existing.insert(title.clone(), uid.clone());
uid
}
Err(e) => {
warn!(album = title, error = %e, "cannot create album");
continue;
}
},
};
match self
.rt
.block_on(photos.add_photos_to_album(&album_uid, &members))
{
Ok(outcomes) => {
for (uid, outcome) in outcomes {
match outcome {
Ok(()) => summary.album_links += 1,
// A photo already in the album is the expected
// outcome of a re-run, not a failure worth counting.
Err(e) => {
warn!(album = title, %uid, error = %e, "cannot add photo to album")
}
}
}
}
Err(e) => warn!(album = title, error = %e, "album addition failed"),
}
}
Ok(())
}
/// Run an import on a background thread, logging its outcome to the activity
/// feed. Returns immediately.
///
/// Refuses while one is already running: two imports of the same export
/// would race each other's duplicate detection and upload everything twice.
pub(crate) fn spawn_takeout_import(
&self,
archives: Vec<PathBuf>,
dry_run: bool,
) -> CoreResult<()> {
let cancel = Arc::new(AtomicBool::new(false));
{
let mut state = IMPORT.lock();
if state.running {
return Err(CoreError::conflict(
"an import is already running; wait for it to finish or cancel it",
));
}
state.running = true;
state.cancel = Some(cancel.clone());
state.last = None;
}
let core = self.clone();
std::thread::spawn(move || {
let outcome = core.import_takeout(archives, dry_run, cancel);
{
let mut state = IMPORT.lock();
state.running = false;
state.cancel = None;
state.last = outcome.as_ref().ok().cloned();
}
match outcome {
Ok(summary) => {
let mut parts = vec![format!("{} uploaded", summary.uploaded)];
if summary.duplicates > 0 {
parts.push(format!("{} already there", summary.duplicates));
}
if summary.albums_created > 0 {
parts.push(format!("{} albums", summary.albums_created));
}
if summary.failed > 0 {
parts.push(format!("{} failed", summary.failed));
}
if summary.cancelled {
parts.push("cancelled".to_string());
}
core.log_activity(
ActivityKind::Upload,
"Google Photos import",
parts.join(" · "),
summary.failed == 0 && !summary.cancelled,
);
}
Err(e) => {
warn!(error = %e, "takeout import failed");
core.log_activity(ActivityKind::Upload, "Google Photos import", &e, false);
}
}
});
Ok(())
}
}
/// Open one of the import's archives by index.
fn open_archive(
archives: &[PathBuf],
index: usize,
) -> CoreResult<zip::ZipArchive<std::io::BufReader<std::fs::File>>> {
let path = archives
.get(index)
.ok_or_else(|| CoreError::internal(format!("archive {index} is not in the import")))?;
let file = std::fs::File::open(path)
.map_err(|e| CoreError::invalid(format!("cannot open {}: {e}", path.display())))?;
zip::ZipArchive::new(std::io::BufReader::new(file))
.map_err(|e| CoreError::invalid(format!("{} is not a readable zip: {e}", path.display())))
}
/// The lowercase-hex SHA-1 of everything `reader` yields — the digest Proton's
/// photo duplicate detection is keyed on.
fn sha1_of(reader: &mut (dyn Read + Send)) -> CoreResult<String> {
let mut hasher = Sha1::new();
let mut buffer = vec![0u8; 256 * 1024];
loop {
let read = reader
.read(&mut buffer)
.map_err(|e| CoreError::invalid(format!("read failed: {e}")))?;
if read == 0 {
break;
}
hasher.update(&buffer[..read]);
}
Ok(hex_lower(&hasher.finalize()))
}
fn hex_lower(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push_str(&format!("{byte:02x}"));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use pdfs_core::takeout::TakeoutPhoto;
fn photo(name: &str, size: u64, bucket: Bucket) -> TakeoutPhoto {
TakeoutPhoto {
archive: 0,
entry: format!("Takeout/Google Photos/x/{name}"),
name: name.to_string(),
size,
media_type: "image/jpeg".to_string(),
capture_time: None,
favorite: false,
bucket,
}
}
#[test]
fn album_copies_of_one_photo_fold_into_a_single_upload() {
let scan = TakeoutScan {
photos: vec![
photo("IMG_1.jpg", 100, Bucket::Timeline),
photo("IMG_1.jpg", 100, Bucket::Album("Iceland".into())),
photo("IMG_1.jpg", 100, Bucket::Album("Best of".into())),
photo("IMG_2.jpg", 200, Bucket::Timeline),
],
albums: vec!["Iceland".into(), "Best of".into()],
..Default::default()
};
let planned = plan(&scan);
assert_eq!(planned.len(), 2, "the three copies are one upload");
assert_eq!(planned[0].albums, vec!["Iceland", "Best of"]);
assert!(planned[1].albums.is_empty());
}
#[test]
fn same_name_different_size_stays_two_photos() {
let scan = TakeoutScan {
photos: vec![
photo("IMG_1.jpg", 100, Bucket::Timeline),
photo("IMG_1.jpg", 101, Bucket::Album("Iceland".into())),
],
..Default::default()
};
assert_eq!(plan(&scan).len(), 2);
}
#[test]
fn metadata_from_any_copy_survives_the_fold() {
// Only the album copy carries a sidecar; the fold must not lose it.
let mut bare = photo("IMG_1.jpg", 100, Bucket::Timeline);
bare.capture_time = None;
let mut rich = photo("IMG_1.jpg", 100, Bucket::Album("Iceland".into()));
rich.capture_time = Some(1_560_000_000);
rich.favorite = true;
let planned = plan(&TakeoutScan {
photos: vec![bare, rich],
..Default::default()
});
assert_eq!(planned[0].photo.capture_time, Some(1_560_000_000));
assert!(planned[0].photo.favorite);
}
#[test]
fn sha1_matches_the_known_digest_of_abc() {
let mut reader: &[u8] = b"abc";
assert_eq!(
sha1_of(&mut reader).unwrap(),
"a9993e364706816aba3e25717850c26c9cd0d89d"
);
}
}
+323
View File
@@ -0,0 +1,323 @@
//! In-memory registry of in-flight work, behind [`Request::GetQueueStatus`].
//!
//! Two kinds of work live here. **Transfers** move bytes: the daemon downloads
//! and uploads whole files through the SDK's streaming [`Read`]/[`Write`]
//! variants, and wrapping that reader/writer in a
//! [`CountingReader`]/[`CountingWriter`] lets each block of bytes tick a
//! per-transfer counter without the SDK knowing anything about progress.
//! **Jobs** ([`JobGuard`]) cover the long stretches around them that move no
//! bytes — walking a local tree, creating the remote folder skeleton, indexing
//! `$HOME` — which otherwise look to a front-end exactly like an idle daemon.
//!
//! Both register on creation and deregister on guard drop, so the registry
//! always reflects exactly what is in flight — even if the work fails partway
//! and unwinds.
//!
//! [`Request::GetQueueStatus`]: pdfs_core::control::Request::GetQueueStatus
use parking_lot::Mutex;
use std::collections::HashMap;
use std::io::{Read, Write};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::Instant;
use pdfs_core::control::{JobItem, TransferDirection, TransferItem};
/// One registered transfer. `done` is bumped from the I/O wrapper without the
/// registry lock; the rest is immutable for the transfer's lifetime.
struct Entry {
name: String,
uid: String,
direction: TransferDirection,
total: u64,
done: AtomicU64,
started: Instant,
}
/// One registered job. `detail`/`done`/`total` all move as the job runs; `total`
/// may grow when more work is discovered mid-job.
struct JobEntry {
title: String,
detail: Mutex<String>,
done: AtomicU64,
total: AtomicU64,
}
/// The set of transfers and jobs currently in flight. Cloned `Arc`-style across
/// the FUSE session and the control-socket task (both share one registry via
/// [`Core`]).
#[derive(Default)]
pub struct TransferRegistry {
inner: Mutex<HashMap<u64, Arc<Entry>>>,
jobs: Mutex<HashMap<u64, Arc<JobEntry>>>,
next: AtomicU64,
}
impl TransferRegistry {
pub fn new() -> Arc<Self> {
Arc::new(Self::default())
}
/// Register a new transfer of `total` bytes (`0` = unknown), returning a
/// guard that tracks progress and deregisters the transfer when dropped.
pub fn begin(
self: &Arc<Self>,
name: impl Into<String>,
uid: impl Into<String>,
direction: TransferDirection,
total: u64,
) -> TransferGuard {
let id = self.next.fetch_add(1, Ordering::Relaxed);
let entry = Arc::new(Entry {
name: name.into(),
uid: uid.into(),
direction,
total,
done: AtomicU64::new(0),
started: Instant::now(),
});
self.inner.lock().insert(id, entry.clone());
TransferGuard {
reg: self.clone(),
id,
entry,
}
}
/// Register a long-running non-transfer job titled `title`, returning a guard
/// that carries its progress and deregisters the job when dropped. The job
/// starts indeterminate; give it a `total` once one is known.
pub fn begin_job(self: &Arc<Self>, title: impl Into<String>) -> JobGuard {
let id = self.next.fetch_add(1, Ordering::Relaxed);
let entry = Arc::new(JobEntry {
title: title.into(),
detail: Mutex::new(String::new()),
done: AtomicU64::new(0),
total: AtomicU64::new(0),
});
self.jobs.lock().insert(id, entry.clone());
JobGuard {
reg: self.clone(),
id,
entry,
}
}
/// Snapshot every in-flight job for [`Response::Transfers`], oldest first, so
/// a front-end's rows keep their order across polls.
///
/// [`Response::Transfers`]: pdfs_core::control::Response::Transfers
pub fn jobs_snapshot(&self) -> Vec<JobItem> {
let map = self.jobs.lock();
let mut ids: Vec<_> = map.keys().copied().collect();
ids.sort_unstable();
ids.iter()
.map(|id| {
let e = &map[id];
JobItem {
title: e.title.clone(),
detail: e.detail.lock().clone(),
done: e.done.load(Ordering::Relaxed),
total: e.total.load(Ordering::Relaxed),
}
})
.collect()
}
/// Snapshot every in-flight transfer for [`Response::Transfers`], oldest
/// first so a front-end's rows keep their order across polls. Speed is the
/// running average since the transfer began — simple, and stable enough for a
/// progress widget without per-tick sampling state.
///
/// [`Response::Transfers`]: pdfs_core::control::Response::Transfers
pub fn snapshot(&self) -> Vec<TransferItem> {
let map = self.inner.lock();
let mut ids: Vec<_> = map.keys().copied().collect();
ids.sort_unstable();
ids.iter()
.map(|id| &map[id])
.map(|e| {
let done = e.done.load(Ordering::Relaxed);
let secs = e.started.elapsed().as_secs_f64();
let speed = if secs > 0.0 {
(done as f64 / secs) as u64
} else {
0
};
TransferItem {
uid: e.uid.clone(),
name: e.name.clone(),
direction: e.direction,
bytes_completed: done,
bytes_total: e.total,
speed_bytes_sec: speed,
}
})
.collect()
}
}
/// Lifetime handle for a registered transfer: tick progress with [`add`], and
/// the transfer leaves the registry when this drops.
///
/// [`add`]: TransferGuard::add
pub struct TransferGuard {
reg: Arc<TransferRegistry>,
id: u64,
entry: Arc<Entry>,
}
impl TransferGuard {
/// Record `n` more bytes moved.
pub fn add(&self, n: u64) {
self.entry.done.fetch_add(n, Ordering::Relaxed);
}
}
impl Drop for TransferGuard {
fn drop(&mut self) {
self.reg.inner.lock().remove(&self.id);
}
}
/// Lifetime handle for a registered job: describe what it is doing with
/// [`detail`], size it with [`set_total`], advance it with [`step`], and the job
/// leaves the registry when this drops. Shared across the tasks of one batch as
/// an `Arc`, so a concurrent phase can report a single "N of M" line.
///
/// [`detail`]: JobGuard::detail
/// [`set_total`]: JobGuard::set_total
/// [`step`]: JobGuard::step
pub struct JobGuard {
reg: Arc<TransferRegistry>,
id: u64,
entry: Arc<JobEntry>,
}
impl JobGuard {
/// Say what the job is working on right now; empty clears it.
pub fn detail(&self, what: impl Into<String>) {
*self.entry.detail.lock() = what.into();
}
/// Set how many steps the job now knows it has. Safe to raise mid-job as more
/// work is discovered.
pub fn set_total(&self, total: u64) {
self.entry.total.store(total, Ordering::Relaxed);
}
/// Mark one more step done.
pub fn step(&self) {
self.entry.done.fetch_add(1, Ordering::Relaxed);
}
}
impl Drop for JobGuard {
fn drop(&mut self) {
self.reg.jobs.lock().remove(&self.id);
}
}
/// A [`Write`] that tallies bytes written to a [`TransferGuard`] (download path).
pub struct CountingWriter<'a, W> {
inner: W,
guard: &'a TransferGuard,
}
impl<'a, W: Write> CountingWriter<'a, W> {
pub fn new(inner: W, guard: &'a TransferGuard) -> Self {
Self { inner, guard }
}
pub fn into_inner(self) -> W {
self.inner
}
}
impl<W: Write> Write for CountingWriter<'_, W> {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let n = self.inner.write(buf)?;
self.guard.add(n as u64);
Ok(n)
}
fn flush(&mut self) -> std::io::Result<()> {
self.inner.flush()
}
}
/// 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),
/// and can be told to stop.
pub struct CountingReader<'a, R> {
inner: R,
guard: &'a TransferGuard,
cancel: Option<Arc<AtomicBool>>,
}
impl<'a, R: Read> CountingReader<'a, R> {
pub fn new(inner: R, guard: &'a TransferGuard) -> Self {
Self {
inner,
guard,
cancel: None,
}
}
/// Abandon the upload as soon as the SDK asks for its next bytes when
/// `cancel` is set.
///
/// This is the only cancellation point an upload has: the SDK takes a plain
/// blocking [`Read`] and pulls from it a block at a time, so refusing to
/// hand over the next block is what stops it. Used when a newer revision of
/// the same file is queued — the bytes on the wire have already been
/// superseded, and finishing them only spends uplink on a revision the next
/// op replaces.
pub fn with_cancel(mut self, cancel: Arc<AtomicBool>) -> Self {
self.cancel = Some(cancel);
self
}
}
impl<R: Read> Read for CountingReader<'_, R> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
if let Some(cancel) = &self.cancel
&& cancel.load(Ordering::Relaxed)
{
// Not `Ok(0)`: a short read is how this reader says "end of file",
// and the SDK would seal a truncated revision from it.
return Err(std::io::Error::new(
std::io::ErrorKind::Interrupted,
"upload superseded by a newer write",
));
}
let n = self.inner.read(buf)?;
self.guard.add(n as u64);
Ok(n)
}
}
+317
View File
@@ -0,0 +1,317 @@
//! Bulk local-to-Drive upload workflow.
use super::*;
/// How many files the bulk uploader ships at once. Overlaps the per-file network
/// round-trips without letting an unbounded number of block buffers pile up.
const UPLOAD_CONCURRENCY: usize = 4;
/// One file queued for bulk upload, resolved during the directory walk so the
/// concurrent phase carries everything it needs (no shared state, no `block_on`).
struct UploadTask {
/// Inode of the (already-created) remote parent folder, for interning the
/// uploaded node afterwards.
parent_ino: u64,
parent_uid: NodeUid,
name: String,
/// Local filesystem path to stream from.
path: PathBuf,
size: u64,
}
/// Tally of a completed [`Core::upload_paths`] batch, for the daemon log.
#[derive(Default)]
pub(super) struct UploadStats {
pub(super) uploaded: usize,
pub(super) failed: usize,
/// Total plaintext bytes of the files that uploaded successfully.
pub(super) bytes: u64,
/// Folders created (or reused) to mirror the local tree.
pub(super) folders: usize,
}
/// Upload every [`UploadTask`] with at most `limit` in flight at once, each
/// streamed straight from disk and ticking its own transfer-registry guard.
/// Returns, per file, either `(parent_ino, new_uid)` for the caller to intern or
/// `(name, error)` to log — one failure never sinks the batch.
///
/// `job` counts files finished (either way: a failure is still one file the batch
/// no longer waits on), so a front-end can show "12 of 40" over the per-file bars.
async fn run_uploads(
core: Core,
tasks: Vec<UploadTask>,
limit: usize,
job: Arc<JobGuard>,
) -> Vec<Result<(u64, NodeUid, u64), (String, String)>> {
let sem = Arc::new(tokio::sync::Semaphore::new(limit));
let mut set = tokio::task::JoinSet::new();
for t in tasks {
let core = core.clone();
let sem = sem.clone();
set.spawn(async move {
let _permit = sem.acquire_owned().await.expect("semaphore not closed");
let file = match std::fs::File::open(&t.path) {
Ok(f) => f,
Err(e) => return Err((t.name, format!("open {}: {e}", t.path.display()))),
};
let mtime = std::fs::metadata(&t.path)
.and_then(|m| m.modified())
.ok()
.and_then(|mt| mt.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64);
let guard = core
.transfers
.begin(&t.name, "", TransferDirection::Upload, t.size);
let reader = OwnedCountingReader::new(file, guard);
if let Err(error) = core.require_uid_writable(&t.parent_uid) {
return Err((
t.name,
format!("destination access denied: errno {}", error.code()),
));
}
match core
.client
.upload_file_from(
&t.parent_uid,
&t.name,
media_type_for(&t.name),
reader,
t.size as i64,
Vec::new(),
mtime,
false,
)
.await
{
Ok(uid) => Ok((t.parent_ino, uid, t.size)),
Err(e) => Err((t.name, format!("upload: {e}"))),
}
});
}
let mut out = Vec::new();
while let Some(joined) = set.join_next().await {
job.step();
match joined {
Ok(result) => out.push(result),
Err(e) => warn!(error = %e, "upload task panicked"),
}
}
out
}
impl Core {
/// Bulk-upload local files and directory trees under `sources` into the
/// mountpoint-relative `parent_rel` folder. Directories are recreated (or
/// merged into an existing same-named folder) and walked; the resulting flat
/// set of files is uploaded with bounded concurrency, each ticking the
/// transfer registry so a front-end sees live progress. Runs on a background
/// thread — a large tree far outlasts the control socket's read timeout — so
/// it reports only a summary for the log. Individual failures are counted and
/// logged rather than aborting the whole batch.
pub(super) fn upload_paths(
&self,
parent_rel: &Path,
sources: &[PathBuf],
) -> CoreResult<UploadStats> {
let (pino, parent_uid) = self.resolve(parent_rel)?;
self.ensure_children(pino)
.map_err(|e| self.errno_error(e, "enumerate"))?;
self.require_uid_writable(&parent_uid)
.map_err(|error| self.errno_error(error, "upload destination"))?;
// Phase 1 (sequential): build the remote folder skeleton and collect the
// flat list of files to upload. Folders must exist before their children,
// so this can't be parallelised. On a deep tree this is a folder-creation
// round-trip per directory before a single byte moves — long enough that
// it needs a job of its own, or the daemon looks idle for minutes.
let mut tasks = Vec::new();
let mut folders = 0usize;
{
let job = self.transfers.begin_job("Preparing upload");
for src in sources {
if let Err(e) =
self.collect_uploads(pino, &parent_uid, src, &mut tasks, &mut folders, &job)
{
warn!(source = %src.display(), error = %e, "skipping source");
}
}
}
// Phase 2 (concurrent): upload the files, up to UPLOAD_CONCURRENCY at once.
// Each file reports its own bytes; this job is the batch's "N of M files",
// which is the number a user actually waits on.
let job = Arc::new(self.transfers.begin_job(match tasks.len() {
1 => "Uploading 1 file".to_string(),
n => format!("Uploading {n} files"),
}));
job.set_total(tasks.len() as u64);
let outcomes = self.rt.block_on(run_uploads(
self.clone(),
tasks,
UPLOAD_CONCURRENCY,
job.clone(),
));
drop(job);
// Phase 3 (sequential): intern each uploaded node so it shows up in the
// listing without a re-enumeration. fetch_node uses `block_on`, so it must
// run here rather than inside the async batch — and it is a round-trip per
// file, so it too gets a job rather than a silent tail.
let mut stats = UploadStats {
folders,
..UploadStats::default()
};
let job = self.transfers.begin_job("Finishing upload");
job.set_total(outcomes.len() as u64);
for outcome in outcomes {
job.step();
match outcome {
Ok((parent_ino, uid, size)) => {
stats.uploaded += 1;
stats.bytes += size;
match self.fetch_node(&uid) {
Ok(node) => {
let mut st = self.state();
let ino = st.intern(parent_ino, node);
if let Some(kids) = st.children.get_mut(&parent_ino)
&& !kids.contains(&ino)
{
kids.push(ino);
}
drop(st);
self.flush_access_changes();
}
// Uploaded fine but the metadata refresh failed; it will
// appear on the next directory enumeration regardless.
Err(e) => warn!(%uid, error = ?e, "uploaded node metadata refresh failed"),
}
}
Err((name, msg)) => {
stats.failed += 1;
warn!(name, error = %msg, "file upload failed");
}
}
}
info!(
uploaded = stats.uploaded,
failed = stats.failed,
"bulk upload finished"
);
Ok(stats)
}
/// Resolve a remote child folder named `name` under `pino`, creating it if it
/// doesn't exist, and return its `(inode, uid)`. Reusing an existing same-named
/// folder makes re-uploading a directory merge into it rather than fail on a
/// duplicate name.
fn ensure_remote_folder(
&self,
pino: u64,
parent_uid: &NodeUid,
name: &str,
) -> CoreResult<(u64, NodeUid)> {
if name.is_empty() || name.contains('/') {
return Err(CoreError::invalid(format!("invalid folder name: {name:?}")));
}
self.ensure_children(pino)
.map_err(|e| self.errno_error(e, "enumerate"))?;
let existing = {
let st = self.state();
st.children.get(&pino).and_then(|kids| {
kids.iter().find_map(|ino| {
st.entries
.get(ino)
.filter(|entry| entry.node.is_folder() && entry.node.name == name)
.map(|entry| (*ino, entry.uid.clone()))
})
})
};
if let Some((ino, uid)) = existing {
self.require_uid_writable(&uid)
.map_err(|error| self.errno_error(error, "upload destination"))?;
return Ok((ino, uid));
}
self.require_uid_writable(parent_uid)
.map_err(|error| self.errno_error(error, "upload destination"))?;
let new_uid = self
.rt
.block_on(
self.client
.create_folder(parent_uid, name, Some(now_secs())),
)
.map_err(|e| CoreError::from_api(&e, &format!("create folder {name}")))?;
let node = self
.fetch_node(&new_uid)
.map_err(|e| self.errno_error(e, "fetch node"))?;
let mut st = self.state();
let ino = st.intern(pino, node);
if let Some(kids) = st.children.get_mut(&pino)
&& !kids.contains(&ino)
{
kids.push(ino);
}
drop(st);
self.flush_access_changes();
self.require_uid_writable(&new_uid)
.map_err(|error| self.errno_error(error, "created upload destination"))?;
Ok((ino, new_uid))
}
/// Walk one local source path, appending its files to `tasks`. A file becomes
/// one task; a directory is recreated remotely and recursed into (children
/// sorted for a stable order). Symlinks and other special files are skipped.
///
/// `job` narrates the walk with the folder currently being mirrored. It stays
/// indeterminate: nothing knows the size of the tree until the walk has ended.
fn collect_uploads(
&self,
pino: u64,
parent_uid: &NodeUid,
src: &Path,
tasks: &mut Vec<UploadTask>,
folders: &mut usize,
job: &JobGuard,
) -> CoreResult<()> {
let meta = std::fs::symlink_metadata(src)
.map_err(|e| CoreError::internal(format!("stat {}: {e}", src.display())))?;
let name = src
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| CoreError::invalid(format!("unusable name: {}", src.display())))?
.to_string();
if meta.is_dir() {
job.detail(&name);
let (child_ino, child_uid) = self.ensure_remote_folder(pino, parent_uid, &name)?;
*folders += 1;
let mut entries: Vec<PathBuf> = std::fs::read_dir(src)
.map_err(|e| CoreError::internal(format!("read dir {}: {e}", src.display())))?
.filter_map(|e| e.ok())
.map(|e| e.path())
.collect();
entries.sort();
for entry in entries {
if let Err(e) =
self.collect_uploads(child_ino, &child_uid, &entry, tasks, folders, job)
{
warn!(source = %entry.display(), error = %e, "skipping entry");
}
}
// Deeper folders have retitled the job by now; put this one back so the
// line tracks the walk's position rather than its deepest leaf.
job.detail(&name);
} else if meta.is_file() {
if name.contains('/') {
return Err(CoreError::invalid(format!("invalid file name: {name:?}")));
}
tasks.push(UploadTask {
parent_ino: pino,
parent_uid: parent_uid.clone(),
name,
path: src.to_path_buf(),
size: meta.len(),
});
}
Ok(())
}
}
+323
View File
@@ -0,0 +1,323 @@
//! Synthetic nodes and naming rules for the mounted shared tree.
use std::collections::{HashMap, HashSet};
use std::time::{Duration, Instant};
use proton_drive_rs::proton_sdk::ids::{LinkId, NodeUid, VolumeId};
use proton_drive_rs::{Node, NodeKind};
pub(crate) const VIRTUAL_VOLUME: &str = "virtual";
pub(crate) const SHARED_WITH_ME_LINK: &str = "sharedwithme";
pub(crate) const SHARED_WITH_ME_BASE: &str = "Shared with me";
const MAX_COMPONENT_BYTES: usize = 255;
pub(crate) fn shared_with_me_uid() -> NodeUid {
NodeUid::new(
VolumeId::from(VIRTUAL_VOLUME),
LinkId::from(SHARED_WITH_ME_LINK),
)
}
pub(crate) fn is_virtual_uid(uid: &NodeUid) -> bool {
uid.volume_id.as_str() == VIRTUAL_VOLUME
}
pub(crate) fn is_own_or_virtual_uid(uid: &NodeUid, own_volume: &VolumeId) -> bool {
uid.volume_id == *own_volume || is_virtual_uid(uid)
}
pub(crate) fn is_primary_root_listing(
primary: bool,
folder_uid: &NodeUid,
primary_root_uid: &NodeUid,
) -> bool {
primary && folder_uid == primary_root_uid
}
pub(crate) fn virtual_node(parent_uid: NodeUid, name: String, timestamp: i64) -> Node {
Node {
uid: shared_with_me_uid(),
parent_uid: Some(parent_uid),
name,
kind: NodeKind::Folder,
creation_time: timestamp,
modification_time: timestamp,
trashed: false,
is_shared: false,
is_shared_publicly: false,
signature_email: None,
membership: None,
// A synthetic folder is neither a photo nor an album.
photo: None,
album: None,
verification: Default::default(),
direct_role: None,
share_id: None,
}
}
/// Choose the initial synthetic-root name. Once persisted, `pinned` always wins;
/// a later real collision only controls whether the synthetic dentry is exposed.
pub(crate) fn virtual_root_name(
real_names: &HashSet<String>,
pinned: Option<&str>,
) -> (String, bool) {
if let Some(pinned) = pinned {
return (pinned.to_string(), !real_names.contains(pinned));
}
for n in 0usize.. {
let candidate = match n {
0 => SHARED_WITH_ME_BASE.to_string(),
1 => format!("{SHARED_WITH_ME_BASE} (Proton)"),
_ => format!("{SHARED_WITH_ME_BASE} (Proton {n})"),
};
if !real_names.contains(&candidate) {
return (candidate, true);
}
}
unreachable!("the numbered virtual-root namespace is unbounded")
}
/// Give every shared root a unique Linux component name, independent of API
/// ordering. Literal suffix-shaped names reserve their basename before duplicate
/// names are numbered, so `Photos (2)` is never stolen by a second `Photos`.
pub(crate) fn disambiguate_shared_names(nodes: &mut [Node]) {
nodes.sort_by(|a, b| {
a.name
.cmp(&b.name)
.then_with(|| a.uid.to_string().cmp(&b.uid.to_string()))
});
let reserved: HashSet<String> = nodes
.iter()
.map(|node| component_with_suffix(&node.name, ""))
.collect();
let mut used = HashSet::with_capacity(nodes.len());
for node in nodes {
let original = node.name.clone();
let basename = component_with_suffix(&original, "");
let chosen = if used.insert(basename.clone()) {
basename
} else {
let mut n = 2usize;
loop {
let suffix = format!(" ({n})");
let candidate = component_with_suffix(&original, &suffix);
if !reserved.contains(&candidate) && used.insert(candidate.clone()) {
break candidate;
}
n += 1;
}
};
node.name = chosen;
}
}
pub(crate) fn listing_needs_refresh(online: bool, stale: bool) -> bool {
online && stale
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SharedListingPlan {
Resident,
Persisted,
Refresh,
}
pub(crate) fn shared_listing_plan(resident: bool, online: bool, stale: bool) -> SharedListingPlan {
if listing_needs_refresh(online, stale) {
SharedListingPlan::Refresh
} else if resident {
SharedListingPlan::Resident
} else {
SharedListingPlan::Persisted
}
}
pub(crate) fn refresh_generation_is_current(started: u64, current: u64) -> bool {
started == current
}
#[derive(Default)]
pub(crate) struct SharedRefreshDeadlines {
deadlines: HashMap<String, Instant>,
}
impl SharedRefreshDeadlines {
pub(crate) fn is_fresh(&mut self, key: &str, now: Instant) -> bool {
self.deadlines.retain(|_, deadline| *deadline > now);
self.deadlines
.get(key)
.is_some_and(|deadline| *deadline > now)
}
pub(crate) fn mark(&mut self, key: &str, now: Instant, ttl: Duration) {
self.deadlines.insert(key.to_string(), now + ttl);
}
pub(crate) fn clear(&mut self) {
self.deadlines.clear();
}
}
fn component_with_suffix(name: &str, suffix: &str) -> String {
let keep = MAX_COMPONENT_BYTES.saturating_sub(suffix.len());
let mut boundary = name.len().min(keep);
while !name.is_char_boundary(boundary) {
boundary -= 1;
}
let mut out = String::with_capacity(boundary + suffix.len());
out.push_str(&name[..boundary]);
out.push_str(suffix);
out
}
#[cfg(test)]
mod tests {
use super::*;
fn folder(volume: &str, link: &str, name: &str) -> Node {
let mut node = virtual_node(
NodeUid::new(VolumeId::from("virtual"), LinkId::from("parent")),
name.to_string(),
1,
);
node.uid = NodeUid::new(VolumeId::from(volume), LinkId::from(link));
node
}
#[test]
fn virtual_uid_and_node_round_trip() {
let parent = NodeUid::new(VolumeId::from("own"), LinkId::from("root"));
let node = virtual_node(parent.clone(), SHARED_WITH_ME_BASE.into(), 42);
assert_eq!(node.uid.to_string(), "virtual~sharedwithme");
assert!(is_virtual_uid(&node.uid));
assert_eq!(node.parent_uid.as_ref(), Some(&parent));
assert!(node.is_folder());
let json = serde_json::to_string(&node).unwrap();
let restored: Node = serde_json::from_str(&json).unwrap();
assert_eq!(restored.uid, node.uid);
assert_eq!(restored.name, SHARED_WITH_ME_BASE);
}
#[test]
fn root_collision_ladder_and_pinned_collision_policy() {
let names = HashSet::from([
SHARED_WITH_ME_BASE.to_string(),
format!("{SHARED_WITH_ME_BASE} (Proton)"),
format!("{SHARED_WITH_ME_BASE} (Proton 2)"),
]);
assert_eq!(
virtual_root_name(&names, None),
(format!("{SHARED_WITH_ME_BASE} (Proton 3)"), true)
);
let (name, visible) = virtual_root_name(&names, Some(SHARED_WITH_ME_BASE));
assert_eq!(name, SHARED_WITH_ME_BASE);
assert!(!visible, "a real later collision suppresses the dentry");
let cleared = HashSet::new();
assert_eq!(
virtual_root_name(&cleared, Some(SHARED_WITH_ME_BASE)),
(SHARED_WITH_ME_BASE.to_string(), true),
"the pinned name returns when the collision clears"
);
}
#[test]
fn duplicate_names_are_stable_under_reordering_and_reserve_suffixes() {
let input = vec![
folder("b", "2", "Photos"),
folder("c", "3", "Photos (2)"),
folder("a", "1", "Photos"),
];
let mut forward = input.clone();
let mut reverse = input;
reverse.reverse();
disambiguate_shared_names(&mut forward);
disambiguate_shared_names(&mut reverse);
let summarize = |nodes: &[Node]| {
nodes
.iter()
.map(|node| (node.uid.to_string(), node.name.clone()))
.collect::<Vec<_>>()
};
assert_eq!(summarize(&forward), summarize(&reverse));
assert_eq!(
forward
.iter()
.map(|node| node.name.as_str())
.collect::<Vec<_>>(),
["Photos", "Photos (3)", "Photos (2)"]
);
}
#[test]
fn disambiguation_respects_utf8_component_limit() {
let long = "é".repeat(200);
let mut nodes = vec![folder("a", "1", &long), folder("b", "2", &long)];
disambiguate_shared_names(&mut nodes);
assert!(nodes.iter().all(|node| node.name.len() <= 255));
assert!(
nodes
.iter()
.all(|node| node.name.is_char_boundary(node.name.len()))
);
assert_ne!(nodes[0].name, nodes[1].name);
assert!(nodes[1].name.ends_with(" (2)"));
}
#[test]
fn ttl_refresh_only_happens_online() {
assert!(!listing_needs_refresh(false, true));
assert!(!listing_needs_refresh(true, false));
assert!(listing_needs_refresh(true, true));
}
#[test]
fn offline_snapshot_suppresses_the_api_and_memory_deadline_suppresses_retries() {
assert_eq!(
shared_listing_plan(false, false, true),
SharedListingPlan::Persisted
);
assert_eq!(
shared_listing_plan(true, true, false),
SharedListingPlan::Resident
);
let now = Instant::now();
let mut deadlines = SharedRefreshDeadlines::default();
deadlines.mark("shared", now, Duration::from_secs(60));
assert!(deadlines.is_fresh("shared", now + Duration::from_secs(30)));
deadlines.clear();
assert!(!deadlines.is_fresh("shared", now + Duration::from_secs(30)));
}
#[test]
fn generation_change_rejects_an_inflight_response() {
assert!(refresh_generation_is_current(7, 7));
assert!(!refresh_generation_is_current(7, 8));
}
#[test]
fn only_the_primary_inode_space_installs_the_synthetic_root() {
let root = NodeUid::new(VolumeId::from("own"), LinkId::from("root"));
assert!(is_primary_root_listing(true, &root, &root));
assert!(!is_primary_root_listing(false, &root, &root));
assert!(!is_primary_root_listing(
true,
&NodeUid::new(VolumeId::from("own"), LinkId::from("device")),
&root
));
}
#[test]
fn ownership_predicate_excludes_foreign_share_volumes() {
let own = VolumeId::from("own");
let own_uid = NodeUid::new(own.clone(), LinkId::from("file"));
let foreign = NodeUid::new(VolumeId::from("foreign"), LinkId::from("share"));
assert!(is_own_or_virtual_uid(&own_uid, &own));
assert!(is_own_or_virtual_uid(&shared_with_me_uid(), &own));
assert!(!is_own_or_virtual_uid(&foreign, &own));
}
}
+597
View File
@@ -0,0 +1,597 @@
//! The thread pool that serves the FUSE handlers which touch the network.
//!
//! fuser drives `Filesystem` from a single dispatch loop, so a cold read that
//! goes to the wire would block every cheap metadata call behind it. Those
//! handlers hand their work here instead and reply from the worker.
//!
//! The pool is split into two lanes because moving the work off the dispatch
//! loop is not by itself enough: with one shared queue, eight concurrent block
//! downloads occupy every worker, and a `lookup` that needs one network round
//! trip waits behind megabytes of transfer (audit A6). Some threads are
//! therefore reserved for metadata and never accept a transfer.
use std::collections::VecDeque;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::time::Instant;
use parking_lot::{Condvar, Mutex};
use tracing::warn;
/// How many FUSE handlers may run off the dispatch loop at once.
///
/// `fuser`'s session loop is non-concurrent: it reads one request, calls the
/// handler, and only then reads the next. A handler that touches the network
/// therefore stalls every `getattr`/`lookup` on the mount behind it. The slow
/// handlers hand their `Reply` to this pool instead and answer from a worker,
/// which frees the loop immediately.
///
/// Bounded on purpose: one worker can hold a 4 MiB block in flight, and an
/// unbounded pool would let read-ahead on a big file spawn threads without
/// limit. Sized so the SDK's in-flight block semaphore, not thread count, is
/// what bounds download memory.
///
/// Counted as `META_WORKERS` reserved threads *plus* the eight that transfers
/// had before the lanes were split. Taking the reservation out of the original
/// eight instead would have cut concurrent reads to five — a throughput
/// regression smuggled in with a latency fix. Threads are the cheap resource
/// here; block buffers are the expensive one, and those are capped in the SDK.
pub(crate) const FUSE_WORKERS: usize = 11;
/// How many of [`FUSE_WORKERS`] serve metadata *only*.
///
/// This is the whole guarantee: these threads never accept a [`Lane::Transfer`]
/// job, so no number of concurrent downloads can leave a `lookup` or `readdir`
/// without a thread to run on. Three is enough because metadata jobs are short
/// — one round trip, no block fetch — so they queue behind each other briefly
/// rather than for the length of a transfer.
///
/// The remaining workers are general: they prefer transfers and fall back to
/// metadata when there is no transfer waiting. That direction is safe (a
/// general thread picking up a cheap job frees itself again quickly) while the
/// reverse — metadata threads helping with transfers — would reintroduce
/// exactly the blocking this split exists to prevent.
const META_WORKERS: usize = 3;
/// Queue depth that gets a line in the log, and the step at which it repeats.
/// Every job holds a `Reply` and a few fields, so depth is cheap — but a lane
/// this far behind is worth knowing about before the user reports a slow mount.
const QUEUE_WARN_STEP: usize = 256;
/// Whether a queue reaching `depth` is one of the depths worth logging.
fn crosses_warn_step(depth: usize) -> bool {
depth >= QUEUE_WARN_STEP && depth.is_multiple_of(QUEUE_WARN_STEP)
}
type Job = Box<dyn FnOnce() + Send + 'static>;
/// A queued job and the name its caller gave it, so a job that never finishes
/// can be named in the diagnostics instead of showing up as "a worker is busy".
struct Queued {
label: &'static str,
job: Job,
}
/// Which lane a job belongs in.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum Lane {
/// Short, network-bound metadata work: `lookup`, `readdir`. Guaranteed a
/// thread by the reserved workers.
Meta,
/// Bulk data movement: block reads. May occupy every general worker.
Transfer,
}
#[derive(Default)]
struct Queues {
meta: VecDeque<Queued>,
transfer: VecDeque<Queued>,
/// Set when the pool is dropped, to wake and retire every worker.
closed: bool,
}
/// What one worker thread is doing, published for [`Workers::snapshot`].
///
/// Every field is an atomic (or a lock the worker only ever holds for the length
/// of a pointer write) because the whole point of the snapshot is to answer
/// *while the daemon is wedged*: a diagnostic that waits on the same lock the
/// stuck job holds reports nothing at exactly the moment it is needed.
struct Slot {
name: String,
/// True between picking a job up and finishing it.
busy: AtomicBool,
/// When the current state (busy or idle) began, as milliseconds since the
/// pool started. Monotonic, so an age is always a real duration.
since_ms: AtomicU64,
/// The label of the job in hand. Behind a mutex because a `&'static str` is
/// two words and there is no atomic for that; the worker holds it only to
/// swap the pointer, and the snapshot never blocks on it (see
/// [`Workers::snapshot`]).
label: Mutex<&'static str>,
}
impl Slot {
fn new(name: String, now_ms: u64) -> Self {
Self {
name,
busy: AtomicBool::new(false),
since_ms: AtomicU64::new(now_ms),
label: Mutex::new(""),
}
}
}
/// One worker's state at the moment [`Workers::snapshot`] read it.
pub(crate) struct WorkerSnapshot {
pub(crate) name: String,
pub(crate) busy: bool,
/// How long the worker has been in that state.
pub(crate) age_ms: u64,
/// The job in hand, or `""` when idle. `"?"` when the label could not be
/// read without blocking.
pub(crate) label: String,
}
/// The pool's state at the moment [`Workers::snapshot`] read it.
pub(crate) struct PoolSnapshot {
pub(crate) workers: Vec<WorkerSnapshot>,
pub(crate) meta_queued: usize,
pub(crate) transfer_queued: usize,
pub(crate) meta_completed: u64,
pub(crate) transfer_completed: u64,
}
/// The queues and the two wait sets over them.
///
/// Two condvars, not one, because the workers are not interchangeable: a
/// meta-only thread cannot take a transfer. With a single wait set,
/// `notify_one` for a transfer could wake a meta-only thread, which would find
/// nothing it is allowed to run and go back to sleep — leaving the transfer
/// unclaimed while a general worker sat idle. Waking *every* thread instead
/// would work but costs a thundering herd per job. So each lane notifies only
/// threads that can actually serve it.
struct Pool {
queues: Mutex<Queues>,
/// Waited on by the reserved metadata workers.
meta_cv: Condvar,
/// Waited on by the general workers.
general_cv: Condvar,
/// Queue depths, mirrored outside the lock so a snapshot can read them
/// without waiting on whatever is holding it.
meta_queued: AtomicUsize,
transfer_queued: AtomicUsize,
/// Jobs finished per lane. A depth that stays high while this stays still is
/// a stalled lane rather than a busy one.
meta_completed: AtomicU64,
transfer_completed: AtomicU64,
slots: Vec<Slot>,
started: Instant,
}
impl Pool {
fn now_ms(&self) -> u64 {
self.started.elapsed().as_millis() as u64
}
}
/// Bounded thread pool behind the network-touching FUSE handlers.
///
/// Shared by every session forked off one [`Core`] (the main mount plus each
/// on-demand sync folder), so the bound is per daemon rather than per mount.
pub(crate) struct Workers {
inner: Arc<Pool>,
/// Kept so teardown can *join* the workers, not merely flag them. A worker
/// still inside a job when the process drops its tokio runtime panics with
/// "A Tokio 1.x context was found, but it is being shutdown" (seen
/// 2026-09-18 09:58:35), which is a teardown ordering bug rather than a real
/// failure.
handles: Mutex<Vec<std::thread::JoinHandle<()>>>,
}
impl Workers {
pub(crate) fn new(n: usize) -> std::io::Result<Self> {
// Never leave the pool without a general worker, however small `n` is.
let meta_workers = META_WORKERS.min(n.saturating_sub(1));
let names: Vec<String> = (0..n)
.map(|i| {
format!(
"pdfs-fuse-{}{i}",
if i < meta_workers { "meta-" } else { "" }
)
})
.collect();
let inner = Arc::new(Pool {
queues: Mutex::new(Queues::default()),
meta_cv: Condvar::new(),
general_cv: Condvar::new(),
meta_queued: AtomicUsize::new(0),
transfer_queued: AtomicUsize::new(0),
meta_completed: AtomicU64::new(0),
transfer_completed: AtomicU64::new(0),
slots: names
.iter()
.map(|name| Slot::new(name.clone(), 0))
.collect(),
started: Instant::now(),
});
let mut handles = Vec::with_capacity(n);
for (i, name) in names.iter().enumerate() {
let inner = inner.clone();
let meta_only = i < meta_workers;
let handle = std::thread::Builder::new()
.name(name.clone())
.spawn(move || {
let cv = if meta_only {
&inner.meta_cv
} else {
&inner.general_cv
};
loop {
let mut q = inner.queues.lock();
let picked = loop {
// A general worker takes transfers first: metadata
// already has threads of its own, so draining the
// bulk queue is the useful thing for it to do.
let taken = if meta_only {
q.meta.pop_front().map(|job| (Lane::Meta, job))
} else {
q.transfer
.pop_front()
.map(|job| (Lane::Transfer, job))
.or_else(|| q.meta.pop_front().map(|job| (Lane::Meta, job)))
};
if let Some(taken) = taken {
break Some(taken);
}
if q.closed {
break None;
}
cv.wait(&mut q);
};
drop(q);
let Some((lane, queued)) = picked else { break };
match lane {
Lane::Meta => inner.meta_queued.fetch_sub(1, Ordering::Relaxed),
Lane::Transfer => inner.transfer_queued.fetch_sub(1, Ordering::Relaxed),
};
let slot = &inner.slots[i];
*slot.label.lock() = queued.label;
slot.since_ms.store(inner.now_ms(), Ordering::Relaxed);
slot.busy.store(true, Ordering::Relaxed);
// A panicking handler must not cost the pool a worker
// for the rest of the run. The dropped `Reply` answers
// EIO on its own, so the caller of the failed op is
// told; the next job is unaffected.
//
// This only holds while no shared state a job touches
// sits behind a *poisoning* lock. A `std::sync::Mutex`
// held at the point of the panic comes back poisoned,
// and the worker we just rescued dies on its next
// acquisition — as does every other thread. The rescue
// would then convert one recoverable EIO into a
// permanently broken daemon: strictly worse than not
// catching at all.
//
// This crate's state is behind `parking_lot`, which
// does not poison. **The requirement crosses the crate
// boundary**, though: a job runs deep into
// `proton-sdk`, whose entity cache is a `std` mutex. It
// recovers the guard rather than unwrapping (see
// `InMemoryCacheRepository::state`); its session and
// HTTP state are `tokio::sync::Mutex`, which has no
// poisoning at all. Anything new reached from a job owes
// the same check — an SDK-side `.lock().unwrap()`
// silently invalidates this comment.
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(queued.job));
slot.busy.store(false, Ordering::Relaxed);
slot.since_ms.store(inner.now_ms(), Ordering::Relaxed);
*slot.label.lock() = "";
match lane {
Lane::Meta => inner.meta_completed.fetch_add(1, Ordering::Relaxed),
Lane::Transfer => {
inner.transfer_completed.fetch_add(1, Ordering::Relaxed)
}
};
}
})?;
handles.push(handle);
}
Ok(Self {
inner,
handles: Mutex::new(handles),
})
}
/// Stop accepting work, wake every worker, and wait up to `deadline` for the
/// jobs in flight to finish.
///
/// Bounded because a join is only worth what it costs: a worker wedged on a
/// network call would otherwise hold the whole teardown, and the user asked
/// for the daemon to stop. What is stuck gets named in the log, and the
/// process continues shutting down around it.
pub(crate) fn stop_and_join(&self, deadline: std::time::Duration) {
self.close();
let handles: Vec<_> = std::mem::take(&mut *self.handles.lock());
if handles.is_empty() {
return;
}
let (tx, rx) = std::sync::mpsc::channel();
let joiner = std::thread::Builder::new()
.name("pdfs-fuse-join".into())
.spawn(move || {
for handle in handles {
let _ = handle.join();
}
let _ = tx.send(());
});
if joiner.is_err() {
return;
}
if rx.recv_timeout(deadline).is_err() {
let busy: Vec<String> = self
.snapshot()
.workers
.into_iter()
.filter(|worker| worker.busy)
.map(|worker| format!("{}={}", worker.name, worker.label))
.collect();
warn!(
stuck = busy.join(","),
"fuse workers did not finish in time; shutting down around them"
);
}
}
fn close(&self) {
self.inner.queues.lock().closed = true;
self.inner.meta_cv.notify_all();
self.inner.general_cv.notify_all();
}
/// Queue `job` in `lane`.
///
/// Neither queue is bounded, and deliberately so: the only thing that could
/// apply backpressure here is the fuser dispatch loop, and blocking *that*
/// is the stall this module exists to avoid. Queue depth is cheap anyway —
/// a pending job holds a `Reply` and a few fields, while the 4 MiB block
/// buffer is allocated inside the job once it runs, and the SDK's in-flight
/// semaphore is what bounds how many of those exist at once.
/// The queue is *not* bounded, and the depth warning above is deliberately
/// all this does about it: the only caller able to feel backpressure here is
/// fuser's dispatch loop, and blocking that is the stall the whole module
/// exists to avoid. A depth that keeps climbing is a symptom to read in the
/// log, not something to fix by stopping the mount.
///
/// `label` names the work for the diagnostics (`pdfs diagnostics`) and for
/// the stall warnings: it is what a hung daemon reports instead of "busy".
pub(crate) fn run(&self, lane: Lane, label: &'static str, job: impl FnOnce() + Send + 'static) {
let mut q = self.inner.queues.lock();
if q.closed {
// Pre-pool behaviour: a shut-down pool degrades to a slow mount
// rather than a mount that answers every read with EIO.
drop(q);
warn!("fuse worker pool is gone; serving inline");
job();
return;
}
let queued = Queued {
label,
job: Box::new(job),
};
let depth = match lane {
Lane::Meta => {
q.meta.push_back(queued);
self.inner.meta_queued.fetch_add(1, Ordering::Relaxed) + 1
}
Lane::Transfer => {
q.transfer.push_back(queued);
self.inner.transfer_queued.fetch_add(1, Ordering::Relaxed) + 1
}
};
drop(q);
if crosses_warn_step(depth) {
warn!(
lane = if matches!(lane, Lane::Meta) {
"meta"
} else {
"transfer"
},
depth, label, "fuse worker queue is deep"
);
}
match lane {
// Either class can serve metadata, and only one of them needs to:
// whichever wakes first takes it, and the other finds the queue
// empty and waits again.
Lane::Meta => {
self.inner.meta_cv.notify_one();
self.inner.general_cv.notify_one();
}
// Only general workers may take a transfer, so waking a reserved
// one would be the lost wakeup this split is careful to avoid.
Lane::Transfer => {
self.inner.general_cv.notify_one();
}
}
}
/// What every worker is doing right now, without taking the queue lock.
///
/// A diagnostic is worth having exactly when the daemon is stuck, so this
/// reads atomics only, and `try_lock`s each label rather than waiting for
/// it: a label is held for one pointer write, so failing to get it means a
/// racing worker, not a wedged one, and `"?"` is a better answer than a
/// diagnostics request that hangs too.
pub(crate) fn snapshot(&self) -> PoolSnapshot {
let now = self.inner.now_ms();
let workers = self
.inner
.slots
.iter()
.map(|slot| {
let label = slot
.label
.try_lock()
.map_or_else(|| "?".to_string(), |label| (*label).to_string());
WorkerSnapshot {
name: slot.name.clone(),
busy: slot.busy.load(Ordering::Relaxed),
age_ms: now.saturating_sub(slot.since_ms.load(Ordering::Relaxed)),
label,
}
})
.collect();
PoolSnapshot {
workers,
meta_queued: self.inner.meta_queued.load(Ordering::Relaxed),
transfer_queued: self.inner.transfer_queued.load(Ordering::Relaxed),
meta_completed: self.inner.meta_completed.load(Ordering::Relaxed),
transfer_completed: self.inner.transfer_completed.load(Ordering::Relaxed),
}
}
}
impl Drop for Workers {
/// A backstop for the paths that never call
/// [`stop_and_join`](Workers::stop_and_join) — tests, and a mount that fails
/// before teardown is wired up. Flag and wake only: a `Drop` that blocks on
/// a network call is worse than a worker outliving its pool by a moment.
fn drop(&mut self) {
self.close();
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::mpsc;
use std::time::Duration;
/// The depth warning fires on the step, and only on the step: a lane that
/// oscillates around the threshold must not log on every single job.
#[test]
fn queue_depth_warns_on_each_step() {
assert!(!crosses_warn_step(1));
assert!(!crosses_warn_step(QUEUE_WARN_STEP - 1));
assert!(crosses_warn_step(QUEUE_WARN_STEP));
assert!(!crosses_warn_step(QUEUE_WARN_STEP + 1));
assert!(crosses_warn_step(QUEUE_WARN_STEP * 3));
}
/// Teardown must return even while a job is running, and must not leave the
/// pool accepting work.
#[test]
fn stopping_is_bounded_and_closes_the_pool() {
let pool = Workers::new(2).unwrap();
let (tx, rx) = mpsc::channel();
pool.run(Lane::Meta, "test", move || tx.send(()).unwrap());
rx.recv_timeout(Duration::from_secs(5)).expect("it runs");
pool.stop_and_join(Duration::from_secs(5));
// A job queued after the stop runs inline rather than disappearing.
let (tx, rx) = mpsc::channel();
pool.run(Lane::Meta, "test", move || tx.send(()).unwrap());
rx.recv_timeout(Duration::from_secs(5))
.expect("a closed pool serves inline");
}
/// A diagnostic is only useful if it names the work. A running job must show
/// up under its label, and the queue behind it must show up as depth.
#[test]
fn a_snapshot_names_the_running_job_and_the_queue_behind_it() {
let pool = Workers::new(2).unwrap();
let (release_tx, release_rx) = mpsc::channel::<()>();
let release_rx = Arc::new(Mutex::new(release_rx));
let (started_tx, started_rx) = mpsc::channel();
{
let release_rx = release_rx.clone();
pool.run(Lane::Transfer, "read", move || {
started_tx.send(()).unwrap();
let _ = release_rx.lock().recv();
});
}
started_rx
.recv_timeout(Duration::from_secs(5))
.expect("the job starts");
let snapshot = pool.snapshot();
assert!(
snapshot.workers.iter().any(|w| w.busy && w.label == "read"),
"the running job is named"
);
assert_eq!(snapshot.transfer_completed, 0);
let _ = release_tx.send(());
}
/// Audit A6. Transfers filling every general worker must not delay a
/// metadata job: that is what the reserved threads are for.
#[test]
fn saturated_transfers_do_not_block_metadata() {
let pool = Workers::new(FUSE_WORKERS).unwrap();
let general = FUSE_WORKERS - META_WORKERS;
// Occupy every general worker with a transfer that will not finish
// until we say so, and wait until they are all actually running.
let (release_tx, release_rx) = mpsc::channel::<()>();
let release_rx = Arc::new(Mutex::new(release_rx));
let (started_tx, started_rx) = mpsc::channel();
for _ in 0..general {
let release_rx = release_rx.clone();
let started_tx = started_tx.clone();
pool.run(Lane::Transfer, "test", move || {
started_tx.send(()).unwrap();
let _ = release_rx.lock().recv();
});
}
for _ in 0..general {
started_rx
.recv_timeout(Duration::from_secs(5))
.expect("every general worker picks up a transfer");
}
// Queue more transfers than there are threads, so the lane is backed up
// in the way that used to starve metadata.
for _ in 0..16 {
let release_rx = release_rx.clone();
pool.run(Lane::Transfer, "test", move || {
let _ = release_rx.lock().recv();
});
}
let (meta_tx, meta_rx) = mpsc::channel();
pool.run(Lane::Meta, "test", move || meta_tx.send(()).unwrap());
meta_rx
.recv_timeout(Duration::from_secs(5))
.expect("a metadata job runs while every transfer thread is stuck");
for _ in 0..(general + 16) {
let _ = release_tx.send(());
}
}
/// The reverse direction is allowed and matters for throughput: with no
/// transfers in flight, a general worker serves metadata rather than idling.
#[test]
fn general_workers_fall_back_to_metadata() {
// One general worker and no reserved ones, so anything that completes
// must have been served by the general worker.
let pool = Workers::new(1).unwrap();
let (tx, rx) = mpsc::channel();
pool.run(Lane::Meta, "test", move || tx.send(()).unwrap());
rx.recv_timeout(Duration::from_secs(5))
.expect("a general worker takes metadata when no transfer is waiting");
}
/// A panicking job costs its caller an EIO, never the worker.
#[test]
fn a_panicking_job_does_not_retire_its_worker() {
let pool = Workers::new(1).unwrap();
pool.run(Lane::Meta, "test", || panic!("boom"));
let (tx, rx) = mpsc::channel();
pool.run(Lane::Meta, "test", move || tx.send(()).unwrap());
rx.recv_timeout(Duration::from_secs(5))
.expect("the pool still serves work after a panic");
}
}
+15 -4
View File
@@ -5,6 +5,9 @@ edition.workspace = true
license.workspace = true
authors.workspace = true
rust-version.workspace = true
repository.workspace = true
homepage.workspace = true
description = "GTK4 desktop app, tray, and search launcher for the Proton Drive Linux client"
[[bin]]
name = "pdfs-tray"
@@ -12,7 +15,11 @@ path = "src/main.rs"
[[bin]]
name = "pdfs-app"
path = "src/app.rs"
path = "src/app/main.rs"
[[bin]]
name = "pdfs-prompt"
path = "src/prompt.rs"
[dependencies]
pdfs-core.workspace = true
@@ -20,7 +27,11 @@ tracing.workspace = true
tracing-subscriber.workspace = true
anyhow.workspace = true
tokio.workspace = true
ksni = "0.2"
gtk4 = { version = "0.9", features = ["v4_12"] }
adw = { package = "libadwaita", version = "0.7", features = ["v1_5"] }
serde.workspace = true
serde_json.workspace = true
ksni = { version = "0.3", features = ["blocking"] }
gtk4 = { version = "0.11", features = ["v4_12"] }
adw = { package = "libadwaita", version = "0.9", features = ["v1_5"] }
async-channel = "2"
exif = { version = "0.6", package = "kamadak-exif" }
webkit6 = "0.6"
+31
View File
@@ -0,0 +1,31 @@
use std::env;
use std::process::Command;
fn main() {
let out_dir = env::var("OUT_DIR").unwrap();
let status = Command::new("glib-compile-resources")
.args([
"--target",
&format!("{}/pdfs.gresource", out_dir),
"--sourcedir",
"resources/icons",
"resources/pdfs.gresource.xml",
])
.status()
.expect("Failed to execute glib-compile-resources");
if !status.success() {
panic!(
"glib-compile-resources failed with exit code: {:?}",
status.code()
);
}
println!("cargo:rerun-if-changed=resources/pdfs.gresource.xml");
println!(
"cargo:rerun-if-changed=resources/icons/scalable/actions/pdfs-build-thumbnails-symbolic.svg"
);
println!(
"cargo:rerun-if-changed=resources/icons/scalable/actions/pdfs-folder-upload-symbolic.svg"
);
}
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<!-- Generate-thumbnails action: a picture frame with a sparkle. A single
symbolic fill lets GTK recolour it with the surrounding control. -->
<path fill="#222222" d="M 2,3 C 1.45,3 1,3.45 1,4 V 14 C 1,14.55 1.45,15 2,15 H 14 C 14.55,15 15,14.55 15,14 V 7 H 13 V 13 H 3 V 5 H 9 V 3 Z M 5,6 C 4.45,6 4,6.45 4,7 C 4,7.55 4.45,8 5,8 C 5.55,8 6,7.55 6,7 C 6,6.45 5.55,6 5,6 Z M 10,7 L 7.5,10 L 6,9 L 4,11.5 V 12 H 12 V 9 Z M 13,0 L 12.4,2.4 L 10,3 L 12.4,3.6 L 13,6 L 13.6,3.6 L 16,3 L 13.6,2.4 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 598 B

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<!-- Upload-a-folder action: a folder silhouette with an up arrow knocked out of
it (one path, fill-rule evenodd, so symbolic recolouring keeps working).
Matches the Papirus folder shape (width 14, height 12) with a pixel-aligned,
proportionate up arrow cutout for size consistency. -->
<path fill="#222222" fill-rule="evenodd" d="M 1,3 V 14 C 1,14 1,15 2,15 H 14 C 14,15 15,15 15,14 V 5 C 15,4 14,4 14,4 H 9 L 7,2 H 2 C 2,2 1,2 1,3 Z M 7,12 V 8 H 5 L 8,5 L 11,8 H 9 V 12 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 593 B

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<gresources>
<!-- Icons we ship ourselves because no icon theme has them. Registered as an
icon-theme resource path at startup, so they resolve by name (and recolour
with the theme) exactly like a stock symbolic icon. -->
<gresource prefix="/de/nils/protondrivelinux/icons">
<file preprocess="xml-stripblanks">scalable/actions/pdfs-build-thumbnails-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-folder-upload-symbolic.svg</file>
</gresource>
</gresources>
+137
View File
@@ -0,0 +1,137 @@
//! Shared Drive-result activation policy for the GTK browser and quick prompt.
//!
//! Media should be handed to its mounted path so players can issue range reads
//! through FUSE. Everything else opens through the mount too whenever the mount
//! exposes it, and only falls back to materialize-then-open when it does not —
//! see [`mounted_target`].
//!
//! Both binaries `#[path]`-include this file, so each compiles its own copy and
//! anything only one of them calls looks dead in the other. That is what the
//! `dead_code` allowances below are for — not code nobody uses.
use std::path::{Path, PathBuf};
use pdfs_core::control::{PhotoKind, SearchHit};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DriveActivation {
Folder,
MountedMedia,
Materialize,
}
pub(crate) fn drive_activation(name: &str, is_dir: bool) -> DriveActivation {
if is_dir {
DriveActivation::Folder
} else if is_streamable_media(name) {
DriveActivation::MountedMedia
} else {
DriveActivation::Materialize
}
}
pub(crate) fn mounted_path(mountpoint: &Path, relative_path: &str) -> PathBuf {
mountpoint.join(relative_path)
}
/// Where a Drive hit should be opened from, when a mount exposes it.
///
/// Materialised content is a *copy*: it lives in the cache under its content
/// hash, so an editor saving into it writes somewhere Drive will never look,
/// and the cache is free to evict it afterwards. The mounted path is the real
/// file — reads pull 4 MiB blocks on demand and a write becomes a new revision
/// — so it wins for every kind of file, not just streamable media.
///
/// [`Request::OpenFile`](pdfs_core::control::Request::OpenFile) stays the
/// fallback for hits no mount exposes: the search index also covers nodes
/// outside the mounted tree (a mirror folder's contents, a stale row), and
/// those can still be read, just not edited in place.
///
/// The `stat` is why this returns an `Option` rather than a path: it is the
/// only way to tell those two cases apart, and it costs one syscall on a path
/// the caller is about to hand to an application anyway.
#[allow(dead_code)] // prompt only
pub(crate) fn mounted_target(mountpoint: &Path, hit: &SearchHit) -> Option<PathBuf> {
present(mounted_or_relative(mountpoint, hit))
}
/// [`mounted_target`] for callers holding a mountpoint-relative path rather
/// than a search hit — the browser's listing entries, which are by definition
/// paths in the primary mount's tree.
#[allow(dead_code)] // browser only
pub(crate) fn mounted_target_rel(mountpoint: &Path, relative_path: &str) -> Option<PathBuf> {
present(mounted_path(mountpoint, relative_path))
}
fn present(path: PathBuf) -> Option<PathBuf> {
path.symlink_metadata().is_ok().then_some(path)
}
/// The mount path for a hit, whether or not anything is there — for callers
/// that have no fallback (a folder cannot be materialised) or already know the
/// entry came from a mount listing.
#[allow(dead_code)] // prompt only
pub(crate) fn mounted_or_relative(mountpoint: &Path, hit: &SearchHit) -> PathBuf {
hit.mounted_path
.as_deref()
.map_or_else(|| mounted_path(mountpoint, &hit.path), PathBuf::from)
}
fn is_streamable_media(name: &str) -> bool {
if PhotoKind::classify(Some(name), None) == PhotoKind::Video {
return true;
}
let extension = Path::new(name)
.extension()
.and_then(|value| value.to_str())
.unwrap_or_default();
matches!(
extension.to_ascii_lowercase().as_str(),
"mp3" | "flac" | "wav" | "ogg" | "opus" | "m4a" | "aac"
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn folders_use_the_mount_regardless_of_extension() {
assert_eq!(
drive_activation("recordings.mp4", true),
DriveActivation::Folder
);
}
#[test]
fn media_extensions_use_fuse_path_case_insensitively() {
for name in ["movie.MKV", "voice.opus", "song.FLAC", "clip.m4v"] {
assert_eq!(
drive_activation(name, false),
DriveActivation::MountedMedia,
"{name}"
);
}
}
#[test]
fn ordinary_files_are_materialized() {
for name in ["report.pdf", "notes.txt", "archive.zip", "no-extension"] {
assert_eq!(
drive_activation(name, false),
DriveActivation::Materialize,
"{name}"
);
}
}
#[test]
fn mounted_paths_preserve_relative_components() {
assert_eq!(
mounted_path(Path::new("/mnt/drive"), "Videos/a movie.mkv"),
Path::new("/mnt/drive/Videos/a movie.mkv")
);
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+313
View File
@@ -0,0 +1,313 @@
use crate::*;
pub(crate) struct ActivityState {
// Activity page: one section, newest-first, rebuilt wholesale on each load.
pub(crate) content: gtk4::Stack,
pub(crate) status: adw::StatusPage,
pub(crate) retry: gtk4::Button,
pub(crate) group: adw::PreferencesGroup,
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
pub(crate) inflight: Cell<bool>,
/// Fingerprint of the feed currently on screen (see [`activity_key`]). The
/// page polls every couple of seconds and usually gets back exactly what it
/// is already showing; rebuilding all 200 rows for that would throw away the
/// user's scroll position several times a minute.
pub(crate) key: RefCell<Option<String>>,
}
/// Widgets the Activity page's load/repaint touch.
pub(crate) struct ActivityWidgets {
pub(crate) content: gtk4::Stack,
pub(crate) status: adw::StatusPage,
pub(crate) group: adw::PreferencesGroup,
pub(crate) retry: gtk4::Button,
pub(crate) refresh: gtk4::Button,
}
/// The Activity page: a newest-first feed of the mutations and transfers the
/// daemon performed this session (uploads, deletes, shares, …).
pub(crate) fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) {
let title = gtk4::Label::builder()
.label("Activity")
.halign(gtk4::Align::Start)
.hexpand(true)
.build();
title.add_css_class("title-2");
let refresh = refresh_button();
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
header.set_hexpand(true);
header.append(&title);
header.append(&refresh);
let group = adw::PreferencesGroup::new();
let clamp = adw::Clamp::builder().child(&group).build();
let scroll = gtk4::ScrolledWindow::builder()
.vexpand(true)
.child(&clamp)
.build();
let retry = gtk4::Button::builder()
.label("Retry")
.halign(gtk4::Align::Center)
.build();
retry.add_css_class("pill");
retry.add_css_class("suggested-action");
retry.set_visible(false);
let status = adw::StatusPage::builder()
.icon_name("document-open-recent-symbolic")
.vexpand(true)
.child(&retry)
.build();
status.add_css_class("compact");
let content = gtk4::Stack::new();
content.set_vexpand(true);
content.set_transition_type(gtk4::StackTransitionType::Crossfade);
content.add_named(&scroll, Some("list"));
content.add_named(&status, Some("status"));
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
inner.set_margin_top(12);
inner.set_margin_bottom(12);
inner.set_margin_start(12);
inner.set_margin_end(12);
inner.append(&header);
inner.append(&content);
(
inner.upcast(),
ActivityWidgets {
content,
status,
group,
retry,
refresh,
},
)
}
/// Install the Activity page's retry button.
pub(crate) fn wire_activity(ui: &Rc<Ui>, retry: &gtk4::Button) {
let ui_retry = ui.clone();
retry.connect_clicked(move |_| {
service::restart();
load_activity(&ui_retry);
});
}
/// Show a status page in place of the Activity list.
pub(crate) fn activity_status(
ui: &Rc<Ui>,
icon: &str,
title: &str,
description: &str,
retry: bool,
) {
ui.activity.status.set_icon_name(Some(icon));
ui.activity.status.set_title(title);
ui.activity.status.set_description(Some(description));
ui.activity.retry.set_visible(retry);
ui.activity.content.set_visible_child_name("status");
// The list is no longer what is on screen, so the next repaint must not skip
// itself as a no-op and leave this status view up.
*ui.activity.key.borrow_mut() = None;
}
/// Refresh the Activity feed in place, with no status flash and no spinner.
/// Driven by the periodic tick while the page is on screen, so a running sync
/// pass fills the feed as it works rather than only once it is done. Anything
/// other than a good answer leaves the rows alone until the next tick.
pub(crate) fn refresh_activity(ui: &Rc<Ui>) {
if ui.activity.inflight.get() {
return;
}
ui.activity.inflight.set(true);
let rx = spawn_request(
ui.dirs.control_socket(),
Request::ListActivity { limit: 200 },
);
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.activity.inflight.set(false);
// The page may have been navigated away from while the request was in
// flight.
if let Ok(Ok(Response::Activity { items })) = result
&& ui.stack.visible_child_name().as_deref() == Some("activity")
{
repaint_activity(&ui, &items);
}
});
}
pub(crate) fn load_activity(ui: &Rc<Ui>) {
if ui.activity.inflight.get() {
return;
}
ui.activity.inflight.set(true);
activity_status(
ui,
"document-open-recent-symbolic",
"Loading…",
"Reading recent activity.",
false,
);
ui.busy_begin();
let rx = spawn_request(
ui.dirs.control_socket(),
Request::ListActivity { limit: 200 },
);
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.busy_end();
ui.activity.inflight.set(false);
match result {
Ok(Ok(Response::Activity { items })) => repaint_activity(&ui, &items),
Ok(Ok(Response::Error { message, .. })) => activity_status(
&ui,
"dialog-warning-symbolic",
"Couldn't read activity",
&message,
false,
),
Ok(Ok(_)) => activity_status(
&ui,
"dialog-warning-symbolic",
"Couldn't read activity",
"Unexpected reply from the mount service.",
false,
),
Ok(Err(_)) | Err(_) => activity_unreachable(&ui),
}
});
}
/// The daemon didn't answer the Activity page.
pub(crate) fn activity_unreachable(ui: &Rc<Ui>) {
if service::is_failed() || !service::is_active() {
activity_status(
ui,
"network-offline-symbolic",
"Not connected",
"The Proton Drive mount service isn't running.",
true,
);
return;
}
activity_status(
ui,
"folder-remote-symbolic",
"Connecting…",
"Waiting for the Proton Drive mount service to come up.",
false,
);
let ui = ui.clone();
glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || {
if ui.stack.visible_child_name().as_deref() == Some("activity") {
load_activity(&ui);
}
});
}
/// A cheap fingerprint of a feed: how many entries, and what the newest one is.
/// The log is append-only and newest-first, so a feed with the same length and
/// the same newest entry is the same feed.
pub(crate) fn activity_key(items: &[ActivityEntry]) -> String {
match items.first() {
Some(a) => format!("{}:{}:{}:{}", items.len(), a.time, a.target, a.detail),
None => String::new(),
}
}
/// Rebuild the Activity section from a fresh log, unless it is already showing
/// exactly this feed.
pub(crate) fn repaint_activity(ui: &Rc<Ui>, items: &[ActivityEntry]) {
let key = activity_key(items);
if ui.activity.key.borrow().as_deref() == Some(key.as_str()) {
return;
}
*ui.activity.key.borrow_mut() = Some(key);
for row in ui.activity.rows.borrow_mut().drain(..) {
ui.activity.group.remove(&row);
}
if items.is_empty() {
activity_status(
ui,
"document-open-recent-symbolic",
"Nothing yet",
"Uploads, moves, shares and other changes appear here as they happen.",
false,
);
return;
}
ui.activity.content.set_visible_child_name("list");
let mut rows: Vec<gtk4::Widget> = Vec::new();
for a in items {
let mut subtitle = activity_time(a.time);
if !a.detail.is_empty() {
subtitle = format!("{subtitle} · {}", a.detail);
}
let row = adw::ActionRow::builder()
.title(format!("{} {}", activity_verb(a.kind), a.target))
.subtitle(subtitle)
.build();
let icon = if a.ok {
activity_icon(a.kind)
} else {
"dialog-warning-symbolic"
};
row.add_prefix(&gtk4::Image::from_icon_name(icon));
row.set_activatable(false);
ui.activity.group.add(&row);
rows.push(row.upcast());
}
*ui.activity.rows.borrow_mut() = rows;
}
/// A human verb for an activity kind, used as the row title's lead word.
pub(crate) fn activity_verb(kind: ActivityKind) -> &'static str {
match kind {
ActivityKind::Upload => "Uploaded",
ActivityKind::Download => "Downloaded",
ActivityKind::Sync => "Synced",
ActivityKind::Rename => "Renamed",
ActivityKind::Move => "Moved",
ActivityKind::CreateFolder => "Created folder",
ActivityKind::Trash => "Trashed",
ActivityKind::Restore => "Restored",
ActivityKind::DeleteForever => "Deleted",
ActivityKind::EmptyTrash => "Emptied trash —",
ActivityKind::Share => "Shared",
ActivityKind::PublicLink => "Linked",
ActivityKind::Unshare => "Unshared",
ActivityKind::Conflict => "Conflict",
}
}
/// A themed icon for an activity kind.
pub(crate) fn activity_icon(kind: ActivityKind) -> &'static str {
match kind {
ActivityKind::Upload => "document-send-symbolic",
ActivityKind::Download => "document-save-symbolic",
ActivityKind::Sync => "emblem-synchronizing-symbolic",
ActivityKind::Rename => "document-edit-symbolic",
ActivityKind::Move => "go-jump-symbolic",
ActivityKind::CreateFolder => "folder-new-symbolic",
ActivityKind::Trash | ActivityKind::DeleteForever | ActivityKind::EmptyTrash => {
"user-trash-symbolic"
}
ActivityKind::Restore => "edit-undo-symbolic",
ActivityKind::Share | ActivityKind::PublicLink => "emblem-shared-symbolic",
ActivityKind::Unshare => "action-unavailable-symbolic",
ActivityKind::Conflict => "dialog-warning-symbolic",
}
}
/// Format an epoch-seconds timestamp for the Activity feed, in local time.
pub(crate) fn activity_time(secs: i64) -> String {
glib::DateTime::from_unix_local(secs)
.and_then(|dt| dt.format("%b %-d, %H:%M"))
.map(|s| s.to_string())
.unwrap_or_default()
}
+265
View File
@@ -0,0 +1,265 @@
//! The Albums view of the Photos page: a grid of album covers, and the album
//! that opens when one is clicked.
//!
//! An open album reuses the gallery wholesale — same square grid, same
//! on-demand thumbnails, same lightbox — by parking the album in
//! [`GalleryState::album`] and letting [`load_gallery`] page
//! [`Request::AlbumPhotos`] instead of the timeline. Only the header and the
//! filter bar differ, because an album is served whole rather than filtered.
use crate::*;
/// Edge length in px of an album cover in the grid. Bigger than a timeline tile:
/// there are far fewer albums than photos, and the cover is the only thing
/// identifying one at a glance.
const COVER_EDGE: i32 = 200;
/// Show the Albums grid and (re)load it. Called when the Albums toggle goes on.
pub(crate) fn show_albums(ui: &Rc<Ui>) {
close_album(ui);
ui.gallery.content.set_visible_child_name("albums");
load_albums(ui);
}
/// Ask the daemon for the album listing and rebuild the grid.
pub(crate) fn load_albums(ui: &Rc<Ui>) {
if ui.gallery.albums_loading.get() {
return;
}
ui.gallery.albums_loading.set(true);
albums_status(
ui,
"view-grid-symbolic",
"Loading albums…",
"Reading your Proton Drive albums.",
);
let rx = spawn_request(ui.dirs.control_socket(), Request::PhotoAlbums);
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.gallery.albums_loading.set(false);
match result {
Ok(Ok(Response::Albums {
available: false, ..
})) => albums_status(
&ui,
"image-missing-symbolic",
"No photo library",
"This Proton account doesn't have Photos enabled.",
),
Ok(Ok(Response::Albums { items, .. })) if items.is_empty() => albums_status(
&ui,
"view-grid-symbolic",
"No albums",
"Albums you create in Proton Photos appear here.",
),
Ok(Ok(Response::Albums { items, .. })) => {
fill_albums(&ui, &items);
ui.gallery.albums_stack.set_visible_child_name("grid");
}
Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, "Couldn't load albums", &message, kind);
albums_status(
&ui,
"dialog-warning-symbolic",
"Couldn't load albums",
&message,
);
}
Ok(Ok(_)) | Ok(Err(_)) | Err(_) => albums_status(
&ui,
"network-offline-symbolic",
"Not connected",
"The Proton Drive mount service didn't respond.",
),
}
});
}
/// Replace the grid's cards with `albums`, in the order the daemon gave them
/// (newest activity first).
fn fill_albums(ui: &Rc<Ui>, albums: &[AlbumInfo]) {
while let Some(child) = ui.gallery.albums.first_child() {
ui.gallery.albums.remove(&child);
}
for album in albums {
ui.gallery.albums.append(&album_card(ui, album));
}
// The covers are ordinary photos as far as the daemon is concerned, so the
// batch that fills them is the gallery's own.
schedule_thumbs(ui);
}
/// One album as a clickable card: its cover, its name, and how many photos it
/// holds. An album shared with us says so — it lives on someone else's volume,
/// and that is worth knowing before opening it.
fn album_card(ui: &Rc<Ui>, album: &AlbumInfo) -> gtk4::Button {
let picture = gtk4::Picture::builder()
// Fills and crops the cover square, like a timeline tile.
.content_fit(gtk4::ContentFit::Cover)
.can_shrink(true)
.hexpand(true)
.vexpand(true)
.build();
picture.add_css_class("photo-thumb");
let placeholder = gtk4::Image::builder()
.icon_name("view-grid-symbolic")
.pixel_size(32)
.halign(gtk4::Align::Center)
.valign(gtk4::Align::Center)
.build();
placeholder.add_css_class("photo-placeholder");
let cover = gtk4::Overlay::new();
cover.set_child(Some(&placeholder));
cover.add_overlay(&picture);
cover.set_size_request(COVER_EDGE, COVER_EDGE);
cover.set_overflow(gtk4::Overflow::Hidden);
cover.add_css_class("album-cover");
let name = gtk4::Label::builder()
.label(&album.name)
.halign(gtk4::Align::Start)
.xalign(0.0)
.ellipsize(gtk4::pango::EllipsizeMode::End)
.build();
name.add_css_class("heading");
let count = gtk4::Label::builder()
.label(album_subtitle(album))
.halign(gtk4::Align::Start)
.xalign(0.0)
.ellipsize(gtk4::pango::EllipsizeMode::End)
.build();
count.add_css_class("dim-label");
count.add_css_class("caption");
let card = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
card.append(&cover);
card.append(&name);
card.append(&count);
let button = gtk4::Button::builder().child(&card).build();
button.add_css_class("flat");
button.add_css_class("album-card");
button.set_tooltip_text(Some(&album.name));
if let Some(uid) = album.cover_uid.clone() {
want_cover(ui, uid, &picture);
}
let ui_open = ui.clone();
let album = album.clone();
button.connect_clicked(move |_| open_album(&ui_open, album.clone()));
button
}
/// "12 photos", plus where the album came from when it isn't ours.
fn album_subtitle(album: &AlbumInfo) -> String {
let photos = match album.photo_count {
1 => "1 photo".to_string(),
n => format!("{n} photos"),
};
if album.shared {
format!("{photos} · shared with me")
} else {
photos
}
}
/// Queue an album cover for the gallery's own thumbnail machinery: same batching,
/// same texture cache, same decode pacing as a timeline tile.
fn want_cover(ui: &Rc<Ui>, uid: String, picture: &gtk4::Picture) {
if let Some(texture) = ui.gallery.photo_tex.borrow().get(&uid) {
picture.set_paintable(Some(texture));
return;
}
if ui.gallery.photo_nothumb.borrow().contains(&uid) {
return;
}
ui.gallery
.thumb_wanted
.borrow_mut()
.insert(uid.clone(), picture.clone());
let mut queue = ui.gallery.thumb_queue.borrow_mut();
if !queue.contains(&uid) {
queue.push_back(uid);
}
}
/// Open one album in the gallery: the timeline view, paged from the album
/// instead, with the filters that don't apply to it out of the way.
pub(crate) fn open_album(ui: &Rc<Ui>, album: AlbumInfo) {
ui.gallery.title.set_label(&album.name);
ui.gallery.subtitle.set_label(&album_subtitle(&album));
ui.gallery.subtitle.set_visible(true);
*ui.gallery.album.borrow_mut() = Some(album);
// An album page carries no kind or date filter, and Upload targets the
// timeline rather than an album — hide those rather than offer controls that
// would silently do something else. The Photos/Albums switcher goes too: an
// album is a level below it, and back is the way out.
ui.gallery.filters.set_visible(false);
ui.gallery.view_switch.set_visible(false);
ui.gallery.upload.set_visible(false);
ui.gallery.back.set_visible(true);
// A stale kind/date filter would otherwise be sent with the album request.
ui.gallery.kind.set(None);
ui.gallery.range.set(None);
ui.gallery.content.set_visible_child_name("timeline");
load_gallery(ui, false);
}
/// Leave an open album, restoring the timeline's header and filters. A no-op
/// when no album is open, so callers can use it as "make sure we're not in one".
pub(crate) fn close_album(ui: &Rc<Ui>) {
if ui.gallery.album.borrow_mut().take().is_none() {
return;
}
ui.gallery.title.set_label("Gallery");
ui.gallery.filters.set_visible(true);
ui.gallery.view_switch.set_visible(true);
ui.gallery.upload.set_visible(true);
ui.gallery.back.set_visible(false);
}
/// Show the album grid's own status page (loading, empty, or an error).
fn albums_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str) {
ui.gallery.albums_status.set_icon_name(Some(icon));
ui.gallery.albums_status.set_title(title);
ui.gallery.albums_status.set_description(Some(description));
ui.gallery.albums_stack.set_visible_child_name("status");
}
/// Wire the Photos/Albums switcher and the back button. The switcher swaps the
/// whole content area; back leaves an open album for the grid it came from.
pub(crate) fn wire_albums(ui: &Rc<Ui>) {
let ui_albums = ui.clone();
ui.gallery.albums_btn.clone().connect_toggled(move |btn| {
if btn.is_active() {
show_albums(&ui_albums);
}
});
// Only the button being switched *on* acts: the group fires `toggled` for
// the one going off too, and acting on both would load twice.
let ui_photos = ui.clone();
ui.gallery.photos_btn.clone().connect_toggled(move |btn| {
if !btn.is_active() {
return;
}
// The timeline is reloaded rather than restored, because an open album
// left the model holding its own photos.
close_album(&ui_photos);
ui_photos.gallery.content.set_visible_child_name("timeline");
load_gallery(&ui_photos, false);
});
let ui_back = ui.clone();
ui.gallery.back.clone().connect_clicked(move |_| {
show_albums(&ui_back);
});
}
File diff suppressed because it is too large. Load diff
+860
View File
@@ -0,0 +1,860 @@
use crate::*;
pub(crate) struct DevicesState {
// Devices page: two sections (synced folders + other devices), rebuilt
// wholesale on each load.
pub(crate) content: gtk4::Stack,
pub(crate) status: adw::StatusPage,
pub(crate) retry: gtk4::Button,
pub(crate) group: adw::PreferencesGroup,
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
pub(crate) sync_group: adw::PreferencesGroup,
/// The single "This computer" summary row. The per-folder rows moved to the
/// Locations page, which owns every local path.
pub(crate) sync_rows: RefCell<Vec<gtk4::Widget>>,
/// "Rename" action in the "This computer" header. Insensitive until the
/// device list identifies this machine's own device.
pub(crate) rename_this: gtk4::Button,
/// This machine's own device `(uid, name)`, from the last device list. The
/// current device is filtered out of the "Other computers" rows, so this is
/// where its rename target lives. `None` until identified.
pub(crate) this_device: RefCell<Option<(String, String)>>,
pub(crate) inflight: Cell<bool>,
pub(crate) loaded_at: Cell<Option<Instant>>,
}
/// Widgets the Devices page's load/repaint touch.
pub(crate) struct DevicesWidgets {
pub(crate) content: gtk4::Stack,
pub(crate) status: adw::StatusPage,
/// "This computer" — this machine's device identity and a pointer to its
/// folders on the Locations page.
pub(crate) sync_group: adw::PreferencesGroup,
/// "Rename" in the "This computer" header — renames this machine's device.
pub(crate) rename_this: gtk4::Button,
/// "Other computers" — the account's *other* registered devices. This
/// machine's own device is deliberately not among them; see
/// [`repaint_devices`].
pub(crate) group: adw::PreferencesGroup,
/// "Restore Folders" — re-attach this device's remote folders to local
/// directories after adopting it on a new machine.
pub(crate) restore: gtk4::Button,
pub(crate) retry: gtk4::Button,
pub(crate) refresh: gtk4::Button,
}
/// The Computers page: a "This computer" section listing the local folders synced
/// to this machine's device (each row offering a mode toggle and Remove), plus an
/// "Other devices" section listing the account's other registered devices.
pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
let title = gtk4::Label::builder()
.label("Computers")
.halign(gtk4::Align::Start)
.build();
title.add_css_class("title-2");
let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
titles.set_hexpand(true);
titles.append(&title);
let restore = gtk4::Button::builder()
.label("Restore Folders")
.tooltip_text("Sync this computer's Drive folders back to local directories")
.valign(gtk4::Align::Center)
.build();
restore.add_css_class("flat");
let refresh = refresh_button();
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
header.append(&titles);
header.append(&refresh);
header.append(&restore);
// This page is about device *identity* — which computer this is, which other
// computers back up to the account, and how to adopt one. The folders
// themselves are local paths, so they live on the Locations page; keeping a
// second copy of that list here would be two places to change the same mode.
let sync_group = adw::PreferencesGroup::builder()
.title("This computer")
.description("The device this machine backs up to.")
.build();
// Rename control for *this* machine's device, in the section header. The
// current device is filtered out of "Other computers", so this is the only
// place it can be renamed; insensitive until the device list identifies it.
let rename_this = gtk4::Button::builder()
.icon_name("document-edit-symbolic")
.tooltip_text("Rename this computer")
.valign(gtk4::Align::Center)
.sensitive(false)
.build();
rename_this.add_css_class("flat");
sync_group.set_header_suffix(Some(&rename_this));
let group = adw::PreferencesGroup::builder()
.title("Other computers")
.description("Other devices backing up to this account.")
.build();
let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
groups.append(&sync_group);
groups.append(&group);
let clamp = adw::Clamp::builder().child(&groups).build();
let scroll = gtk4::ScrolledWindow::builder()
.vexpand(true)
.child(&clamp)
.build();
let retry = gtk4::Button::builder()
.label("Retry")
.halign(gtk4::Align::Center)
.build();
retry.add_css_class("pill");
retry.add_css_class("suggested-action");
retry.set_visible(false);
let status = adw::StatusPage::builder()
.icon_name("computer-symbolic")
.vexpand(true)
.child(&retry)
.build();
status.add_css_class("compact");
let content = gtk4::Stack::new();
content.set_vexpand(true);
content.set_transition_type(gtk4::StackTransitionType::Crossfade);
content.add_named(&scroll, Some("list"));
content.add_named(&status, Some("status"));
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
inner.set_margin_top(12);
inner.set_margin_bottom(12);
inner.set_margin_start(12);
inner.set_margin_end(12);
inner.append(&header);
inner.append(&content);
(
inner.upcast(),
DevicesWidgets {
content,
status,
sync_group,
rename_this,
group,
restore,
retry,
refresh,
},
)
}
/// Install the Devices page's retry button and the "Restore Folders" action.
pub(crate) fn wire_devices(ui: &Rc<Ui>, retry: &gtk4::Button, restore: &gtk4::Button) {
let ui_retry = ui.clone();
retry.connect_clicked(move |_| {
service::restart();
load_devices(&ui_retry);
});
let ui_restore = ui.clone();
restore.connect_clicked(move |_| prompt_restore_folders(&ui_restore));
let ui_ren = ui.clone();
ui.devices.rename_this.connect_clicked(move |_| {
if let Some((uid, name)) = ui_ren.devices.this_device.borrow().clone() {
prompt_rename_device(&ui_ren, &uid, &name);
}
});
}
/// Show a status page in place of the Devices list.
pub(crate) fn devices_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str, retry: bool) {
ui.devices.status.set_icon_name(Some(icon));
ui.devices.status.set_title(title);
ui.devices.status.set_description(Some(description));
ui.devices.retry.set_visible(retry);
ui.devices.content.set_visible_child_name("status");
}
/// Fetch this machine's synced folders and the account's other devices, then
/// repaint both sections. The two requests are chained so a single unreachable
/// daemon collapses the whole page to a status view.
pub(crate) fn load_devices(ui: &Rc<Ui>) {
if ui.devices.inflight.get() {
return;
}
ui.devices.inflight.set(true);
devices_status(
ui,
"computer-symbolic",
"Loading…",
"Reading your devices.",
false,
);
ui.busy_begin();
// The two lists are independent and the daemon serves requests concurrently,
// so fire both up front and collect them, rather than paying two round trips
// back to back.
let rx = spawn_request(ui.dirs.control_socket(), Request::ListSyncFolders);
let rx2 = spawn_request(ui.dirs.control_socket(), Request::ListDevices);
let ui = ui.clone();
glib::spawn_future_local(async move {
let sync = rx.recv().await;
let devices_reply = rx2.recv().await;
let folders = match sync {
Ok(Ok(Response::SyncFolders { items })) => items,
// The daemon answered but not as expected — treat as empty and carry
// on to the devices list rather than failing the whole page.
Ok(Ok(_)) => Vec::new(),
Ok(Err(_)) | Err(_) => {
ui.busy_end();
ui.devices.inflight.set(false);
ui.devices.loaded_at.set(None);
devices_unreachable(&ui);
return;
}
};
// Daemon reachable: show the list and paint the "This computer" section.
ui.devices.content.set_visible_child_name("list");
repaint_this_computer(&ui, &folders);
let devices = match devices_reply {
Ok(Ok(Response::Devices { items })) => items,
_ => Vec::new(),
};
ui.busy_end();
ui.devices.inflight.set(false);
repaint_devices(&ui, &devices);
ui.devices.loaded_at.set(Some(Instant::now()));
});
}
/// The daemon didn't answer the Devices page.
pub(crate) fn devices_unreachable(ui: &Rc<Ui>) {
if service::is_failed() || !service::is_active() {
devices_status(
ui,
"network-offline-symbolic",
"Not connected",
"The Proton Drive mount service isn't running.",
true,
);
return;
}
devices_status(
ui,
"folder-remote-symbolic",
"Connecting…",
"Waiting for the Proton Drive mount service to come up.",
false,
);
let ui = ui.clone();
glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || {
if ui.stack.visible_child_name().as_deref() == Some("devices") {
load_devices(&ui);
}
});
}
/// Rebuild the "Other computers" section from a fresh listing. Empty is a normal
/// state (this machine may be the only device), so it shows a placeholder row
/// rather than collapsing the page.
///
/// This machine's own device is filtered out. Deleting a device deletes its root
/// folder — every file backed up from it — and for *this* machine that would also
/// pull the ground out from under the synced folders listed directly above, so it
/// is not offered as a peer of "remove some other laptop". Removing this
/// computer's backup means removing its folders, each of which asks about its
/// cloud copy on its own terms.
pub(crate) fn repaint_devices(ui: &Rc<Ui>, devices: &[DeviceInfo]) {
for row in ui.devices.rows.borrow_mut().drain(..) {
ui.devices.group.remove(&row);
}
// Identify this machine's own device so the "This computer" header's Rename
// action has a target. It never appears in the rows below, so this is the
// only place its name/uid is captured.
match devices.iter().find(|d| d.this_device) {
Some(me) => {
*ui.devices.this_device.borrow_mut() = Some((me.uid.clone(), me.name.clone()));
ui.devices.rename_this.set_sensitive(true);
ui.devices
.rename_this
.set_tooltip_text(Some(&format!("Rename this computer ({})", me.name)));
}
None => {
*ui.devices.this_device.borrow_mut() = None;
ui.devices.rename_this.set_sensitive(false);
}
}
let others: Vec<&DeviceInfo> = devices.iter().filter(|d| !d.this_device).collect();
if others.is_empty() {
let row = adw::ActionRow::builder()
.title("No other computers")
.subtitle("Desktop apps syncing to this account appear here.")
.build();
row.add_prefix(&gtk4::Image::from_icon_name("computer-symbolic"));
ui.devices.group.add(&row);
*ui.devices.rows.borrow_mut() = vec![row.upcast()];
return;
}
let mut rows: Vec<gtk4::Widget> = Vec::new();
for dev in others {
let row = adw::ActionRow::builder()
.title(&dev.name)
.subtitle(device_subtitle(dev))
.build();
row.add_prefix(&gtk4::Image::from_icon_name("computer-symbolic"));
let remove = gtk4::Button::builder()
.icon_name("user-trash-symbolic")
.tooltip_text("Remove this computer and everything it backed up")
.valign(gtk4::Align::Center)
.build();
remove.add_css_class("flat");
let rename = gtk4::Button::builder()
.icon_name("document-edit-symbolic")
.tooltip_text("Rename computer")
.valign(gtk4::Align::Center)
.build();
rename.add_css_class("flat");
// Adoption is how a reinstalled or renamed machine re-attaches to the
// device it used to be, instead of registering a duplicate. It only
// makes sense on *another* device's row, which is the only place this
// loop paints.
let adopt = gtk4::Button::builder()
.icon_name("insert-object-symbolic")
.tooltip_text("Use this computer's identity for this machine")
.valign(gtk4::Align::Center)
.build();
adopt.add_css_class("flat");
let ui_ad = ui.clone();
let uid_ad = dev.uid.clone();
let name_ad = dev.name.clone();
adopt.connect_clicked(move |_| prompt_adopt_device(&ui_ad, &uid_ad, &name_ad));
row.add_suffix(&adopt);
let ui_ren = ui.clone();
let uid_ren = dev.uid.clone();
let name_ren = dev.name.clone();
rename.connect_clicked(move |_| prompt_rename_device(&ui_ren, &uid_ren, &name_ren));
let ui_rm = ui.clone();
let uid_rm = dev.uid.clone();
let name_rm = dev.name.clone();
remove.connect_clicked(move |_| prompt_remove_device(&ui_rm, &uid_rm, &name_rm));
row.add_suffix(&rename);
row.add_suffix(&remove);
ui.devices.group.add(&row);
rows.push(row.upcast());
}
*ui.devices.rows.borrow_mut() = rows;
}
/// A device row's subtitle: its platform, and when it last synced. A device that
/// has never synced says so — an unexplained missing date reads as a bug, and
/// "never" is the fact that tells the user this computer isn't backing anything up.
pub(crate) fn device_subtitle(dev: &DeviceInfo) -> String {
match dev.last_sync {
Some(secs) if secs > 0 => {
format!("{} · last synced {}", dev.device_type, activity_time(secs))
}
_ => format!("{} · never synced", dev.device_type),
}
}
/// Rebuild the "This computer" section: one row naming the device this machine
/// backs up to, and how many folders it carries.
///
/// The per-folder rows (mode switch, sync now, remove) live on the Locations
/// page. They are local paths, and Locations is the one page that owns those;
/// two lists of the same folders would be two places to flip the same switch.
pub(crate) fn repaint_this_computer(ui: &Rc<Ui>, folders: &[SyncFolderInfo]) {
for row in ui.devices.sync_rows.borrow_mut().drain(..) {
ui.devices.sync_group.remove(&row);
}
let row = adw::ActionRow::builder()
.title("This computer")
.subtitle(this_computer_subtitle(folders))
.build();
row.add_prefix(&gtk4::Image::from_icon_name("computer-symbolic"));
let manage = gtk4::Button::builder()
.label("Locations")
.tooltip_text("Manage this computer's folders and mountpoint")
.valign(gtk4::Align::Center)
.build();
manage.add_css_class("flat");
let ui_go = ui.clone();
manage.connect_clicked(move |_| ui_go.stack.set_visible_child_name("locations"));
row.add_suffix(&manage);
ui.devices.sync_group.add(&row);
*ui.devices.sync_rows.borrow_mut() = vec![row.upcast()];
}
/// How many folders this machine backs up, and whether any of them needs
/// attention — the one fact worth surfacing away from the folder list itself.
pub(crate) fn this_computer_subtitle(folders: &[SyncFolderInfo]) -> String {
if folders.is_empty() {
return "No folders backed up yet — add one under Locations.".to_string();
}
let count = match folders.len() {
1 => "1 folder backed up".to_string(),
n => format!("{n} folders backed up"),
};
let attention = folders
.iter()
.filter(|f| f.state == "error" || f.state == "conflict")
.count();
match attention {
0 => format!("{count} · manage them under Locations"),
1 => format!("{count} · 1 needs attention"),
n => format!("{count} · {n} need attention"),
}
}
/// Human label for a synced folder's `state` column.
pub(crate) fn sync_state_label(state: &str) -> &str {
match state {
"syncing" => "syncing…",
"error" => "sync error",
"conflict" => "needs attention",
_ => "up to date",
}
}
/// Human label for a sync pass in flight: what it is doing, to which file, and
/// how far along it is. Neither phase's total is exact — the scan's is an estimate
/// from the last pass, and the applying total grows as deeper paths are classified
/// — so both read "12 of 40" rather than a percentage that could go backwards.
pub(crate) fn sync_progress_label(p: &SyncProgress) -> String {
match p.phase {
// Before the first pass finishes there is no estimate, so the count would
// be "checked 12 of 12" — worse than saying nothing.
SyncPhase::Scanning if p.total == 0 => "checking for changes…".to_string(),
SyncPhase::Scanning => format!(
"checking for changes — {} of {}",
p.done,
p.total.max(p.done)
),
SyncPhase::Applying => {
let count = format!("{} of {}", p.done + 1, p.total.max(p.done + 1));
if p.current.is_empty() {
format!("syncing {count}")
} else {
format!("syncing {} — {count}", p.current)
}
}
}
}
/// Pick a local folder and hand it to the daemon to sync to this device.
pub(crate) fn prompt_add_sync_folder(ui: &Rc<Ui>) {
let win = ui_window(ui);
let dialog = gtk4::FileDialog::builder()
.title("Add Folder to Sync")
.build();
let ui = ui.clone();
dialog.select_folder(win.as_ref(), gio::Cancellable::NONE, move |res| {
let Ok(folder) = res else { return };
let Some(local_path) = folder.path().and_then(|p| p.to_str().map(str::to_string)) else {
return;
};
let rx = spawn_request(
ui.dirs.control_socket(),
Request::AddSyncFolder {
local_path: local_path.clone(),
},
);
let ui = ui.clone();
glib::spawn_future_local(async move {
match rx.recv().await {
Ok(Ok(Response::Ok { .. })) => {
// The daemon acks before the row exists — registering the
// device and creating the remote folder is off-socket network
// work. A fixed delay here either fires too early (empty list)
// or too late (row flashes in). Instead let the periodic
// `refresh_locations` tick pick the row up whenever it
// actually lands, which is what keeps a running pass live too.
toast(&ui, "Syncing folder…");
}
Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, "Couldn't add folder", &message, kind)
}
_ => toast_error(
&ui,
"Couldn't add folder",
"The mount service didn't respond.",
),
}
});
});
}
/// Ask the daemon what this machine's device holds, then show a picker mapping
/// each remote folder onto a local directory.
///
/// The daemon's paths are proposals — from the device's `profile.json` when it
/// makes sense here, else `~/<name>` — so every one of them is editable and
/// nothing is restored without being ticked.
pub(crate) fn prompt_restore_folders(ui: &Rc<Ui>) {
ui.busy_begin();
let rx = spawn_request(ui.dirs.control_socket(), Request::ListRestorableFolders);
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.busy_end();
match result {
Ok(Ok(Response::RestorableFolders { items })) => show_restore_picker(&ui, items),
Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, "Couldn't list folders to restore", &message, kind)
}
_ => toast_error(
&ui,
"Couldn't list folders to restore",
"The mount service didn't respond.",
),
}
});
}
/// The restore picker itself: one editable row per restorable folder.
fn show_restore_picker(ui: &Rc<Ui>, items: Vec<RestorableFolder>) {
let win = ui_window(ui);
let candidates: Vec<RestorableFolder> =
items.into_iter().filter(|f| !f.already_synced).collect();
if candidates.is_empty() {
toast(
ui,
"Nothing to restore — this computer's folders are all synced here.",
);
return;
}
let group = adw::PreferencesGroup::builder()
.description(
"Tick the folders to sync to this machine, and adjust where each one should live.",
)
.build();
// Held so the response handler can read back what the user ticked and typed.
let mut controls: Vec<(String, String, gtk4::CheckButton, adw::EntryRow)> = Vec::new();
for f in candidates {
let check = gtk4::CheckButton::builder()
.active(true)
.valign(gtk4::Align::Center)
.build();
let row = adw::EntryRow::builder().title(&f.name).build();
row.set_text(&f.local_path);
row.add_prefix(&check);
group.add(&row);
controls.push((f.remote_uid, f.mode, check, row));
}
let scroll = gtk4::ScrolledWindow::builder()
.propagate_natural_height(true)
.max_content_height(420)
.child(&group)
.build();
let dialog = adw::AlertDialog::builder()
.heading("Restore folders")
.body("These folders are backed up under this computer in Proton Drive.")
.extra_child(&scroll)
.build();
dialog.add_response("cancel", "Cancel");
dialog.add_response("restore", "Restore");
dialog.set_response_appearance("restore", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("restore"));
dialog.set_close_response("cancel");
let ui = ui.clone();
dialog.connect_response(None, move |_, resp| {
if resp != "restore" {
return;
}
let items: Vec<RestoreItem> = controls
.iter()
.filter(|(_, _, check, _)| check.is_active())
.map(|(uid, mode, _, row)| RestoreItem {
remote_uid: uid.clone(),
local_path: row.text().to_string(),
mode: mode.clone(),
})
.collect();
if items.is_empty() {
return;
}
// The daemon acks before the downloads finish, like AddSyncFolder — the
// rows appear as the periodic refresh picks them up.
run_devices_mutation(
&ui,
Request::RestoreSyncFolders { items },
"Restoring folders…",
"Couldn't restore folders",
);
});
dialog.present(win.as_ref());
}
/// Flip a synced folder between `mirror` and `ondemand`. Reloads after so the
/// row's subtitle and switch reflect the daemon's real state (the request may be
/// rejected, e.g. switching to on-demand while a folder is mid-sync).
pub(crate) fn set_sync_folder_mode(ui: &Rc<Ui>, id: i64, mode: &str) {
let rx = spawn_request(
ui.dirs.control_socket(),
Request::SetSyncFolderMode {
id,
mode: mode.to_string(),
},
);
let ui = ui.clone();
glib::spawn_future_local(async move {
match rx.recv().await {
Ok(Ok(Response::Ok { message })) => toast(&ui, &message),
Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, "Couldn't change mode", &message, kind)
}
_ => toast_error(
&ui,
"Couldn't change mode",
"The mount service didn't respond.",
),
}
if ui.stack.visible_child_name().as_deref() == Some("devices") {
load_devices(&ui);
}
});
}
/// Confirm, then stop syncing a folder. Offers to also delete the cloud copy.
pub(crate) fn prompt_remove_sync_folder(ui: &Rc<Ui>, id: i64, path: &str, ondemand: bool) {
let win = ui_window(ui);
// The two modes leave the user in opposite places, so they can't share a
// sentence. A mirror folder's files are already on this disk and stay there.
// An on-demand folder's are not: the path is a mount over content that lives
// in Proton Drive, so unmounting it leaves an empty directory — which is a
// nasty surprise if the dialog claimed the local files were safe, and is
// recoverable only by turning On-demand off *first* and letting it download.
let body = if ondemand {
format!(
"Stop syncing “{path}”?\n\nThis folder is on-demand: its files live in Proton \
Drive, not on this disk, so the folder will be empty once it is unmounted. To \
keep a local copy, cancel, turn off On-demand, and wait for the download to \
finish before removing it."
)
} else {
format!(
"Stop syncing “{path}”?\n\nThe local files stay on this disk and simply stop \
being synced. Choose whether to also delete the copy in Proton Drive."
)
};
let dialog = adw::AlertDialog::builder()
.heading("Stop syncing folder")
.body(body)
.build();
let group = adw::PreferencesGroup::new();
let delete_remote = adw::SwitchRow::builder()
.title("Also delete from Proton Drive")
.subtitle(if ondemand {
"Deletes the only copy of these files."
} else {
"The local copy is unaffected."
})
.active(false)
.build();
group.add(&delete_remote);
dialog.set_extra_child(Some(&group));
dialog.add_response("cancel", "Cancel");
dialog.add_response("remove", "Stop Syncing");
dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
let ui = ui.clone();
dialog.connect_response(None, move |_, resp| {
if resp == "remove" {
run_devices_mutation(
&ui,
Request::RemoveSyncFolder {
id,
delete_remote: delete_remote.is_active(),
},
"Stopped syncing folder",
"Couldn't stop syncing the folder",
);
}
});
dialog.present(win.as_ref());
}
/// Prompt for a new device name and rename it.
pub(crate) fn prompt_rename_device(ui: &Rc<Ui>, uid: &str, current: &str) {
let win = ui_window(ui);
let dialog = adw::AlertDialog::builder()
.heading("Rename device")
.body(format!("Rename “{current}”."))
.build();
let group = adw::PreferencesGroup::new();
let row = adw::EntryRow::builder()
.title("New name")
.activates_default(true)
.build();
row.set_text(current);
group.add(&row);
dialog.set_extra_child(Some(&group));
dialog.add_response("cancel", "Cancel");
dialog.add_response("rename", "Rename");
dialog.set_response_appearance("rename", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("rename"));
dialog.set_close_response("cancel");
let ui = ui.clone();
let uid = uid.to_string();
dialog.connect_response(None, move |_, resp| {
if resp != "rename" {
return;
}
let name = row.text().trim().to_string();
if name.is_empty() {
toast_error(&ui, "Couldn't rename device", "A name is required.");
return;
}
run_devices_mutation(
&ui,
Request::RenameDevice {
uid: uid.clone(),
name,
},
"Device renamed",
"Couldn't rename the device",
);
});
dialog.present(win.as_ref());
}
/// Confirm, then remove (deregister) a device.
pub(crate) fn prompt_remove_device(ui: &Rc<Ui>, uid: &str, name: &str) {
let win = ui_window(ui);
let dialog = adw::AlertDialog::builder()
.heading("Remove computer")
.body(format!(
"Remove “{name}” from this account?\n\nEverything it backed up to Proton Drive is \
deleted along with it. The files on that computer itself are not touched — but \
this cannot be undone from here."
))
.build();
dialog.add_response("cancel", "Cancel");
dialog.add_response("remove", "Remove");
dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
let ui = ui.clone();
let uid = uid.to_string();
dialog.connect_response(None, move |_, resp| {
if resp == "remove" {
run_devices_mutation(
&ui,
Request::DeleteDevice { uid: uid.clone() },
"Device removed",
"Couldn't remove the device",
);
}
});
dialog.present(win.as_ref());
}
/// Confirm, then adopt another device as this machine's identity.
///
/// Worth a confirmation rather than a plain click: adoption re-points this
/// machine's syncing at another computer's device folder, and the folders
/// already synced here keep pointing at the old one until they are removed. The
/// dialog says so, because the alternative is a user discovering it by watching
/// their folders diverge.
pub(crate) fn prompt_adopt_device(ui: &Rc<Ui>, uid: &str, name: &str) {
let win = ui_window(ui);
let dialog = adw::AlertDialog::builder()
.heading("Use this computer's identity")
.body(format!(
"Treat this machine as “{name}”?\n\nNew synced folders are created under that \
computer in Proton Drive, and this machine keeps that identity even if its hostname \
changes. Folders already synced here are not moved.\n\nUse “Restore folders” \
afterwards to bring back what “{name}” was syncing."
))
.build();
dialog.add_response("cancel", "Cancel");
dialog.add_response("adopt", "Use identity");
dialog.set_response_appearance("adopt", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
let ui = ui.clone();
let uid = uid.to_string();
dialog.connect_response(None, move |_, resp| {
if resp == "adopt" {
run_devices_mutation(
&ui,
Request::AdoptDevice {
uid: Some(uid.clone()),
},
"Identity adopted",
"Couldn't adopt that computer",
);
}
});
dialog.present(win.as_ref());
}
/// Run a mutation raised from the Devices page and reload the page on success.
pub(crate) fn run_devices_mutation(
ui: &Rc<Ui>,
req: Request,
done: &'static str,
failed: &'static str,
) {
ui.busy_begin();
let rx = spawn_request(ui.dirs.control_socket(), req);
let ui = ui.clone();
let done = done.to_string();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.busy_end();
match result {
Ok(Ok(Response::Ok { .. })) => {
load_devices(&ui);
toast(&ui, &done);
}
Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, failed, &message, kind),
_ => toast_error(&ui, failed, "The mount service didn't respond."),
}
});
}
#[cfg(test)]
mod tests {
use super::*;
fn folder(state: &str) -> SyncFolderInfo {
SyncFolderInfo {
id: 1,
local_path: "/home/u/Documents".into(),
remote_uid: "vol~link".into(),
mode: "mirror".into(),
state: state.into(),
last_sync: 0,
pending_mode: None,
progress: None,
}
}
#[test]
fn a_machine_with_no_folders_is_told_where_to_add_one() {
assert_eq!(
this_computer_subtitle(&[]),
"No folders backed up yet — add one under Locations."
);
}
#[test]
fn folders_needing_attention_are_counted_not_hidden() {
// The per-folder rows moved to Locations, so this row is the only place
// the Computers page can surface that something is wrong.
assert_eq!(
this_computer_subtitle(&[folder("idle"), folder("idle")]),
"2 folders backed up · manage them under Locations"
);
assert_eq!(
this_computer_subtitle(&[folder("idle"), folder("error")]),
"2 folders backed up · 1 needs attention"
);
assert_eq!(
this_computer_subtitle(&[folder("conflict"), folder("error")]),
"2 folders backed up · 2 need attention"
);
}
}
+536
View File
@@ -0,0 +1,536 @@
//! The Locations page: every local place Proton Drive occupies on this machine.
//!
//! One row per [`MountSpec`] from [`Request::ListLocations`] — the primary
//! `~/ProtonDrive` mount, plus each folder this computer backs up, whether it is
//! a mirrored local directory or an on-demand FUSE session. The page is called
//! *Locations* rather than *Mounts* because a mirror folder is a plain directory
//! with no FUSE session behind it (mount-architecture.md §4).
//!
//! The device rows drive the same control requests the Computers page used to:
//! mode switch, sync now, remove. What is new here is that the primary mount is
//! listed alongside them, with the mountpoint chooser that used to live in
//! Settings.
use crate::*;
pub(crate) struct LocationsState {
pub(crate) content: gtk4::Stack,
pub(crate) status: adw::StatusPage,
pub(crate) retry: gtk4::Button,
pub(crate) group: adw::PreferencesGroup,
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
pub(crate) inflight: Cell<bool>,
pub(crate) loaded_at: Cell<Option<Instant>>,
}
/// Widgets the Locations page's load/repaint touch.
pub(crate) struct LocationsWidgets {
pub(crate) content: gtk4::Stack,
pub(crate) status: adw::StatusPage,
pub(crate) group: adw::PreferencesGroup,
pub(crate) retry: gtk4::Button,
pub(crate) refresh: gtk4::Button,
pub(crate) add_folder: gtk4::Button,
}
pub(crate) fn build_locations_page() -> (gtk4::Widget, LocationsWidgets) {
let title = gtk4::Label::builder()
.label("Locations")
.halign(gtk4::Align::Start)
.build();
title.add_css_class("title-2");
let add_folder = gtk4::Button::builder()
.label("Add Folder")
.tooltip_text("Back a local folder up to this computer's Proton Drive device")
.valign(gtk4::Align::Center)
.build();
add_folder.add_css_class("flat");
let refresh = refresh_button();
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
titles.set_hexpand(true);
titles.append(&title);
header.append(&titles);
header.append(&refresh);
header.append(&add_folder);
// Clamped to the same width as the rows below, or the page title floats off
// to the left of the list it names.
let header_clamp = adw::Clamp::builder().child(&header).build();
// Same warning the Computers page carried, for the same reason: the
// on-demand switch removes the local copy, which is not a thing to discover
// after the fact.
let group = adw::PreferencesGroup::builder()
.title("On this computer")
.description(
"Where Proton Drive lives on this machine. Synced folders keep a full copy on \
this disk; on-demand folders keep the files in Proton Drive only and fetch \
them as you open them.",
)
.build();
let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
groups.append(&group);
let clamp = adw::Clamp::builder().child(&groups).build();
// Never scroll sideways: a location's title is a full path, and letting the
// row grow to fit one pushes the controls at its end off screen. Constrained,
// the path ellipsizes and the switch/remove/open stay reachable.
let scroll = gtk4::ScrolledWindow::builder()
.vexpand(true)
.hscrollbar_policy(gtk4::PolicyType::Never)
.child(&clamp)
.build();
let retry = gtk4::Button::builder()
.label("Retry")
.halign(gtk4::Align::Center)
.build();
retry.add_css_class("pill");
retry.add_css_class("suggested-action");
retry.set_visible(false);
let status = adw::StatusPage::builder()
.icon_name("drive-harddisk-symbolic")
.title("Loading…")
.child(&retry)
.build();
// A bare `StatusPage` asks for more width than a half-screen window has, and
// a `Stack` is as wide as its widest child — so an unwrapped status page
// silently widens the *list* page too, pushing each row's controls off the
// right edge. Scrolling it (never horizontally) caps that demand.
let status_scroll = gtk4::ScrolledWindow::builder()
.vexpand(true)
.hscrollbar_policy(gtk4::PolicyType::Never)
.child(&status)
.build();
let content = gtk4::Stack::new();
content.add_named(&scroll, Some("list"));
content.add_named(&status_scroll, Some("status"));
let page = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
page.set_margin_top(18);
page.set_margin_bottom(18);
page.set_margin_start(18);
page.set_margin_end(18);
page.append(&header_clamp);
page.append(&content);
let widgets = LocationsWidgets {
content: content.clone(),
status: status.clone(),
group: group.clone(),
retry: retry.clone(),
refresh,
add_folder,
};
(page.upcast(), widgets)
}
pub(crate) fn wire_locations(ui: &Rc<Ui>, retry: &gtk4::Button, add_folder: &gtk4::Button) {
let ui_retry = ui.clone();
retry.connect_clicked(move |_| {
ui_retry.locations.loaded_at.set(None);
load_locations(&ui_retry);
});
let ui_add = ui.clone();
add_folder.connect_clicked(move |_| prompt_add_sync_folder(&ui_add));
}
/// Show a status page in place of the locations list.
pub(crate) fn locations_status(
ui: &Rc<Ui>,
icon: &str,
title: &str,
description: &str,
retry: bool,
) {
ui.locations.status.set_icon_name(Some(icon));
ui.locations.status.set_title(title);
ui.locations.status.set_description(Some(description));
ui.locations.retry.set_visible(retry);
ui.locations.content.set_visible_child_name("status");
}
/// Fetch every local location and repaint the list.
pub(crate) fn load_locations(ui: &Rc<Ui>) {
if ui.locations.inflight.get() {
return;
}
ui.locations.inflight.set(true);
locations_status(
ui,
"drive-harddisk-symbolic",
"Loading…",
"Reading this computer's Proton Drive locations.",
false,
);
ui.busy_begin();
let rx = spawn_request(ui.dirs.control_socket(), Request::ListLocations);
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.busy_end();
ui.locations.inflight.set(false);
match result {
Ok(Ok(Response::Locations { items })) => {
ui.locations.content.set_visible_child_name("list");
repaint_locations(&ui, &items);
ui.locations.loaded_at.set(Some(Instant::now()));
}
Ok(Ok(Response::Error { message, .. })) => {
ui.locations.loaded_at.set(None);
locations_status(
&ui,
"dialog-warning-symbolic",
"Unavailable",
&message,
true,
);
}
_ => {
ui.locations.loaded_at.set(None);
locations_unreachable(&ui);
}
}
});
}
/// Refresh the rows with no status flash and no spinner, from the periodic tick:
/// a sync pass's progress is only live if something re-reads it.
pub(crate) fn refresh_locations(ui: &Rc<Ui>) {
if ui.locations.inflight.get() {
return;
}
ui.locations.inflight.set(true);
let rx = spawn_request(ui.dirs.control_socket(), Request::ListLocations);
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.locations.inflight.set(false);
let Ok(Ok(Response::Locations { items })) = result else {
return;
};
// The page may have been navigated away from, or collapsed to a status
// view, while the request was in flight.
if ui.stack.visible_child_name().as_deref() == Some("locations")
&& ui.locations.content.visible_child_name().as_deref() == Some("list")
{
repaint_locations(&ui, &items);
}
});
}
/// The daemon didn't answer the Locations page.
pub(crate) fn locations_unreachable(ui: &Rc<Ui>) {
if service::is_failed() || !service::is_active() {
locations_status(
ui,
"network-offline-symbolic",
"Not connected",
"The Proton Drive mount service isn't running.",
true,
);
return;
}
locations_status(
ui,
"folder-remote-symbolic",
"Connecting…",
"Waiting for the Proton Drive mount service to come up.",
false,
);
let ui = ui.clone();
glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || {
if ui.stack.visible_child_name().as_deref() == Some("locations") {
load_locations(&ui);
}
});
}
pub(crate) fn repaint_locations(ui: &Rc<Ui>, items: &[MountSpec]) {
for row in ui.locations.rows.borrow_mut().drain(..) {
ui.locations.group.remove(&row);
}
if items.is_empty() {
let row = adw::ActionRow::builder()
.title("No locations")
.subtitle("The mount service hasn't reported a mountpoint yet.")
.build();
row.add_prefix(&gtk4::Image::from_icon_name("drive-harddisk-symbolic"));
ui.locations.group.add(&row);
*ui.locations.rows.borrow_mut() = vec![row.upcast()];
return;
}
let mut rows: Vec<gtk4::Widget> = Vec::new();
for spec in items {
let row = adw::ActionRow::builder()
.title(&spec.local_path)
.subtitle(location_subtitle(spec))
// One line each: a wrapped path would reflow the whole list every
// time a sync state changed under it.
.title_lines(1)
.subtitle_lines(1)
.build();
row.add_prefix(&gtk4::Image::from_icon_name(location_icon(&spec.kind)));
// A read-only location cannot be written through even where the files are
// visible; saying so on the row is cheaper than letting the user find out
// from a save dialog.
if spec.access == MountAccess::Ro {
let badge = gtk4::Label::new(Some("Read-only"));
badge.add_css_class("dim-label");
badge.add_css_class("caption");
badge.set_valign(gtk4::Align::Center);
row.add_suffix(&badge);
}
// Same rule as the Computers page: a first pass has no estimate to draw
// against, so the bar appears only once real counts exist.
if let Some(p) = &spec.progress
&& p.total > 0
{
let bar = gtk4::ProgressBar::builder()
.fraction((p.done as f64 / p.total.max(p.done) as f64).min(1.0))
.valign(gtk4::Align::Center)
.width_request(120)
.build();
row.add_suffix(&bar);
}
match &spec.kind {
MountKind::MyFiles => add_my_files_controls(ui, &row),
MountKind::Device { sync_folder_id } => {
add_device_controls(ui, &row, spec, *sync_folder_id)
}
// A standalone shared mount has no local mode to switch and is not
// this device's to remove.
MountKind::Shared { .. } => {}
}
let open = gtk4::Button::builder()
.icon_name("folder-open-symbolic")
.tooltip_text("Open this folder")
.valign(gtk4::Align::Center)
.build();
open.add_css_class("flat");
let path = spec.local_path.clone();
open.connect_clicked(move |_| open_path(&path));
row.add_suffix(&open);
ui.locations.group.add(&row);
rows.push(row.upcast());
}
*ui.locations.rows.borrow_mut() = rows;
}
/// The primary mount's only control: where it lives. Changing it rewrites config
/// and offers a service restart, which is why it stays a single prompt shared
/// with the (now removed) Settings row.
fn add_my_files_controls(ui: &Rc<Ui>, row: &adw::ActionRow) {
let change = gtk4::Button::builder()
.label("Change")
.tooltip_text("Choose a different folder for the Proton Drive mount")
.valign(gtk4::Align::Center)
.build();
change.add_css_class("flat");
let ui_mp = ui.clone();
change.connect_clicked(move |_| prompt_mountpoint(&ui_mp));
row.add_suffix(&change);
}
/// Sync now (mirror only), the on-demand switch, and Remove — the same three
/// controls, and the same handlers, the Computers page used to carry.
fn add_device_controls(ui: &Rc<Ui>, row: &adw::ActionRow, spec: &MountSpec, id: i64) {
if spec.mode != MountMode::OnDemand {
let sync_now = gtk4::Button::builder()
.icon_name("view-refresh-symbolic")
.tooltip_text("Sync this folder now")
.valign(gtk4::Align::Center)
.build();
sync_now.add_css_class("flat");
let ui_sync = ui.clone();
sync_now.connect_clicked(move |_| sync_folder_now(&ui_sync, id));
row.add_suffix(&sync_now);
}
// A queued switch paints as already flipped: the daemon accepted it and will
// act on it, so snapping back would read as "it didn't take". The state is
// set before the handler is wired so painting the current mode doesn't fire
// a spurious request.
let target = spec.pending_mode.unwrap_or(spec.mode);
let ondemand = gtk4::Switch::builder()
.tooltip_text(
"On-demand: free this disk by keeping the files in Proton Drive only, \
fetching each as you open it. Turn off to download them back and keep a \
full local copy.",
)
.valign(gtk4::Align::Center)
.active(target == MountMode::OnDemand)
.build();
let ui_mode = ui.clone();
ondemand.connect_state_set(move |_, on| {
set_sync_folder_mode(&ui_mode, id, if on { "ondemand" } else { "mirror" });
glib::Propagation::Proceed
});
row.add_suffix(&ondemand);
let remove = gtk4::Button::builder()
.icon_name("user-trash-symbolic")
.tooltip_text("Stop syncing this folder")
.valign(gtk4::Align::Center)
.build();
remove.add_css_class("flat");
let ui_rm = ui.clone();
let path = spec.local_path.clone();
// The folder's *current* mode, not a queued one: a switch that hasn't landed
// yet has not moved the files anywhere.
let is_ondemand = spec.mode == MountMode::OnDemand;
remove.connect_clicked(move |_| prompt_remove_sync_folder(&ui_rm, id, &path, is_ondemand));
row.add_suffix(&remove);
}
/// Ask the daemon for an immediate pass over one folder.
fn sync_folder_now(ui: &Rc<Ui>, id: i64) {
let rx = spawn_request(ui.dirs.control_socket(), Request::SyncNow { id: Some(id) });
let ui = ui.clone();
glib::spawn_future_local(async move {
match rx.recv().await {
Ok(Ok(Response::Ok { .. })) => toast(&ui, "Syncing folder…"),
Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, "Couldn't sync", &message, kind)
}
_ => toast_error(&ui, "Couldn't sync", "The mount service didn't respond."),
}
});
}
pub(crate) fn location_icon(kind: &MountKind) -> &'static str {
match kind {
MountKind::MyFiles => "folder-remote-symbolic",
MountKind::Device { .. } => "folder-symbolic",
MountKind::Shared { .. } => "system-users-symbolic",
}
}
/// One line describing what a location *is* and what it is doing.
///
/// Ordered by how likely it is to be what the user came to check: a queued mode
/// switch leads (they just asked for it), then the resting mode, then the sync
/// state, then — only when it is surprising — the fact that no session owns the
/// path. A mirror folder is a plain directory with no FUSE session, so "not
/// mounted" is its normal state and saying it would be noise.
pub(crate) fn location_subtitle(spec: &MountSpec) -> String {
let mut parts: Vec<String> = Vec::new();
match &spec.kind {
MountKind::MyFiles => parts.push("My files".to_string()),
MountKind::Shared { .. } => parts.push("Shared folder".to_string()),
MountKind::Device { .. } => match (spec.pending_mode, spec.mode) {
(Some(MountMode::OnDemand), _) => parts.push("Going on-demand".to_string()),
(Some(MountMode::Mirror), _) => parts.push("Switching to synced".to_string()),
(Some(MountMode::Unknown) | None, MountMode::OnDemand) => {
parts.push("On-demand".to_string())
}
(Some(MountMode::Unknown) | None, _) => parts.push("Synced".to_string()),
},
}
if matches!(spec.kind, MountKind::Device { .. }) {
parts.push(match &spec.progress {
Some(p) => sync_progress_label(p),
None => sync_state_label(&spec.state).to_string(),
});
}
// Only the locations that are supposed to have a session report its absence:
// a mirror folder never has one.
let expects_session = !matches!(spec.kind, MountKind::Device { .. })
|| spec.mode == MountMode::OnDemand
|| spec.pending_mode == Some(MountMode::OnDemand);
if expects_session && !spec.mounted {
parts.push("not mounted".to_string());
}
parts.join(" · ")
}
#[cfg(test)]
mod tests {
use super::*;
fn spec(kind: MountKind, mode: MountMode, mounted: bool) -> MountSpec {
MountSpec {
id: 1,
kind,
local_path: "/home/u/ProtonDrive".into(),
root_uid: "vol~link".into(),
root_share_id: "share".into(),
mode,
access: MountAccess::Rw,
state: "idle".into(),
last_sync: 0,
pending_mode: None,
mounted,
progress: None,
}
}
#[test]
fn a_mirror_folder_is_never_reported_as_unmounted() {
// It is a plain local directory: no FUSE session is expected, so saying
// "not mounted" would describe every healthy mirror folder as broken.
let spec = spec(
MountKind::Device { sync_folder_id: 7 },
MountMode::Mirror,
false,
);
assert_eq!(location_subtitle(&spec), "Synced · up to date");
}
#[test]
fn an_on_demand_folder_without_a_session_says_so() {
let spec = spec(
MountKind::Device { sync_folder_id: 7 },
MountMode::OnDemand,
false,
);
assert_eq!(
location_subtitle(&spec),
"On-demand · up to date · not mounted"
);
}
#[test]
fn a_queued_switch_leads_the_subtitle() {
let mut spec = spec(
MountKind::Device { sync_folder_id: 7 },
MountMode::Mirror,
true,
);
spec.pending_mode = Some(MountMode::OnDemand);
assert!(location_subtitle(&spec).starts_with("Going on-demand"));
spec.mode = MountMode::OnDemand;
spec.pending_mode = Some(MountMode::Mirror);
assert!(location_subtitle(&spec).starts_with("Switching to synced"));
}
/// An unrecognised persisted mode must not silently read as "Synced" *and*
/// must not lose the rest of the line.
#[test]
fn the_primary_mount_reports_only_its_own_facts() {
let mut spec = spec(MountKind::MyFiles, MountMode::OnDemand, true);
assert_eq!(location_subtitle(&spec), "My files");
spec.mounted = false;
assert_eq!(location_subtitle(&spec), "My files · not mounted");
}
#[test]
fn sync_state_reaches_the_subtitle() {
let mut spec = spec(
MountKind::Device { sync_folder_id: 2 },
MountMode::Mirror,
true,
);
spec.state = "conflict".into();
assert_eq!(location_subtitle(&spec), "Synced · needs attention");
}
}
+284
View File
@@ -0,0 +1,284 @@
use crate::*;
use pdfs_core::proton_sdk::api::HumanVerificationCredential;
pub(crate) struct LoginState {
// Login page.
pub(crate) email: adw::EntryRow,
pub(crate) password: adw::PasswordEntryRow,
pub(crate) login_button: gtk4::Button,
pub(crate) login_status: gtk4::Label,
}
/// The login page: an email row, password row, a primary "Sign in" button and a
/// status label, centred in a clamp. The 2FA code is prompted lazily in a
/// dialog (see [`prompt_2fa`]) only when the account actually requires it.
#[allow(clippy::type_complexity)]
pub(crate) fn build_login_page() -> (
gtk4::Widget,
(
adw::EntryRow,
adw::PasswordEntryRow,
gtk4::Button,
gtk4::Label,
),
) {
let group = adw::PreferencesGroup::builder()
.title("Sign in to Proton")
.description("Use your Proton account to connect Drive.")
.build();
let email = adw::EntryRow::builder().title("Email or username").build();
let password = adw::PasswordEntryRow::builder().title("Password").build();
group.add(&email);
group.add(&password);
let login_button = gtk4::Button::builder()
.label("Sign in")
.halign(gtk4::Align::Center)
.build();
login_button.add_css_class("suggested-action");
login_button.add_css_class("pill");
let login_status = gtk4::Label::builder()
.wrap(true)
.justify(gtk4::Justification::Center)
.build();
login_status.add_css_class("dim-label");
let header = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
let logo = gtk4::Image::from_icon_name("folder-remote-symbolic");
logo.set_pixel_size(64);
logo.add_css_class("brand-icon");
let title = gtk4::Label::new(Some("Proton Drive"));
title.add_css_class("brand-title");
header.append(&logo);
header.append(&title);
header.set_margin_bottom(12);
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 16);
inner.set_margin_top(32);
inner.set_margin_bottom(32);
inner.set_margin_start(12);
inner.set_margin_end(12);
inner.append(&header);
inner.append(&group);
inner.append(&login_button);
inner.append(&login_status);
let clamp = adw::Clamp::builder()
.maximum_size(420)
.child(&inner)
.build();
let scroll = gtk4::ScrolledWindow::builder().child(&clamp).build();
(
scroll.upcast(),
(email, password, login_button, login_status),
)
}
/// Connect the sign-in button: read the fields, run [`auth::login`] on a worker
/// thread, and report the outcome back on the main loop.
pub(crate) fn wire_login(ui: &Rc<Ui>) {
// Pressing Enter in either field submits the form, so signing in never needs
// a reach for the mouse. Both fields route to the same button.
let btn = ui.login.login_button.clone();
ui.login
.email
.connect_entry_activated(move |_| btn.emit_clicked());
let btn = ui.login.login_button.clone();
ui.login
.password
.connect_entry_activated(move |_| btn.emit_clicked());
let ui = ui.clone();
let button = ui.login.login_button.clone();
button.connect_clicked(move |_| {
let username = ui.login.email.text().to_string();
let password = ui.login.password.text().to_string();
if username.is_empty() || password.is_empty() {
ui.login
.login_status
.set_text("Enter your email and password.");
return;
}
ui.login.login_button.set_sensitive(false);
ui.login.login_status.set_text("Signing in…");
let (rx, totp_req_rx, hv_req_rx) = spawn_login(username, password);
// Surface the 2FA dialog only if the SDK actually asks for a code (i.e.
// the account has 2FA enabled). The worker blocks until the dialog feeds
// back a code (or is cancelled, dropping the sender).
//
// Loops rather than awaiting once: a login gated behind a CAPTCHA is
// restarted after verification, so a 2FA account is asked for a second,
// unexpired code on the retry.
let ui_2fa = ui.clone();
glib::spawn_future_local(async move {
while let Ok(code_tx) = totp_req_rx.recv().await {
prompt_2fa(&ui_2fa, code_tx);
}
});
// Same shape for human verification: only fires when the API gates the
// sign-in, and the worker is blocked on the token until it does.
let ui_hv = ui.clone();
glib::spawn_future_local(async move {
if let Ok((url, token_tx)) = hv_req_rx.recv().await {
ui_hv
.login
.login_status
.set_text("Complete the verification to continue…");
prompt_human_verification(&ui_hv, &url, token_tx);
}
});
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx
.recv()
.await
.unwrap_or_else(|_| Err("login cancelled".into()));
ui.login.login_button.set_sensitive(true);
match result {
Ok(()) => {
ui.login.login_status.set_text("");
ui.login.password.set_text("");
// Cache the new identity so `refresh` never hits the keyring.
*ui.session.borrow_mut() = auth::load().ok();
// Enable+start the mount service now that we have a session.
service::enable_start();
refresh(&ui);
}
Err(e) => ui
.login
.login_status
.set_text(&format!("Sign-in failed: {e}")),
}
});
});
}
/// Run the async SRP + optional 2FA login on a dedicated current-thread Tokio
/// runtime. Returns two channels: the first yields the final login result once;
/// the second fires *only if* the account needs a 2FA code, carrying a
/// [`std::sync::mpsc::Sender`] the UI uses to feed the code back. The login
/// closure blocks the worker on that sender until the dialog answers, so the
/// code is requested lazily and can't expire before the password proof.
#[allow(clippy::type_complexity)]
pub(crate) fn spawn_login(
username: String,
password: String,
) -> (
async_channel::Receiver<Result<(), String>>,
async_channel::Receiver<std::sync::mpsc::Sender<String>>,
async_channel::Receiver<(String, std::sync::mpsc::Sender<String>)>,
) {
let (tx, rx) = async_channel::bounded(1);
// Two, not one: a CAPTCHA-gated login restarts, so a 2FA account is asked
// for a code on each attempt and a single slot would deadlock the worker.
let (totp_req_tx, totp_req_rx) = async_channel::bounded(2);
let (hv_req_tx, hv_req_rx) = async_channel::bounded(1);
std::thread::spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
let _ = tx.send_blocking(Err(e.to_string()));
return;
}
};
let result = rt.block_on(async move {
auth::login_interactive(
&username,
&password,
|| {
// 2FA required: hand a one-shot sender to the UI and block
// until the dialog supplies the code. A dropped sender
// (cancelled dialog) surfaces as a cancelled login.
let (code_tx, code_rx) = std::sync::mpsc::channel::<String>();
totp_req_tx
.send_blocking(code_tx)
.map_err(|_| pdfs_core::Error::Other("two-factor prompt closed".into()))?;
code_rx
.recv()
.map_err(|_| pdfs_core::Error::Other("two-factor entry cancelled".into()))
},
|hv| {
// Gated: hand the UI the page to show, block on the token the
// user earns by solving it.
let (token_tx, token_rx) = std::sync::mpsc::channel::<String>();
hv_req_tx
.send_blocking((hv.verification_url(), token_tx))
.map_err(|_| {
pdfs_core::Error::Other("verification prompt closed".into())
})?;
let token = token_rx
.recv()
.map_err(|_| pdfs_core::Error::Other("verification cancelled".into()))?;
Ok(HumanVerificationCredential::captcha(token))
},
)
.await
.map_err(|e| e.to_string())
});
let _ = tx.send_blocking(result);
});
(rx, totp_req_rx, hv_req_rx)
}
/// Show the lazy two-factor dialog and feed the entered code back to the waiting
/// login worker via `code_tx`. Cancelling (or closing) drops the sender, which
/// the worker reads as a cancelled login.
pub(crate) fn prompt_2fa(ui: &Rc<Ui>, code_tx: std::sync::mpsc::Sender<String>) {
let dialog = adw::AlertDialog::builder()
.heading("Two-factor authentication")
.body("Enter the code from your authenticator app.")
.build();
let group = adw::PreferencesGroup::new();
let entry = adw::EntryRow::builder()
.title("Authentication code")
.activates_default(true)
.build();
group.add(&entry);
dialog.set_extra_child(Some(&group));
dialog.add_response("cancel", "Cancel");
dialog.add_response("confirm", "Confirm");
dialog.set_response_appearance("confirm", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("confirm"));
dialog.set_close_response("cancel");
let code_tx = RefCell::new(Some(code_tx));
dialog.connect_response(None, move |_, resp| {
// On cancel/close we take + drop `tx` without sending, so the worker's
// recv errors out and the login is reported as cancelled.
if let Some(tx) = code_tx.borrow_mut().take()
&& resp == "confirm"
{
let _ = tx.send(entry.text().trim().to_string());
}
});
let parent = ui.login.login_button.root().and_downcast::<gtk4::Window>();
dialog.present(parent.as_ref());
}
/// Connect the sign-out button: disable+stop the mount service (so the daemon
/// isn't left running without credentials), forget the stored session, and drop
/// back to the login page.
pub(crate) fn wire_logout(ui: &Rc<Ui>, button: &gtk4::Button) {
let ui = ui.clone();
button.connect_clicked(move |_| {
service::disable_stop();
if let Err(e) = auth::logout() {
tracing::error!("logout failed: {e}");
}
*ui.session.borrow_mut() = None;
refresh(&ui);
});
}
+14
View File
@@ -0,0 +1,14 @@
pub(crate) mod activity;
pub(crate) mod albums;
pub(crate) mod browser;
pub(crate) mod devices;
pub(crate) mod locations;
pub(crate) mod login;
pub(crate) mod photo_viewer;
pub(crate) mod photos;
pub(crate) mod shared;
pub(crate) mod shared_by_me;
pub(crate) mod status;
pub(crate) mod takeout;
pub(crate) mod trash;
pub(crate) mod verify;
Loaded 100 of 144 files, more files were not shown because too many files have changed in this diff. Show more