Compare commits

..
442 Commits
Author SHA1 Message Date
nvrl 40aa8016ea Merge branch 'fix/sim-flakes-and-open-race' into chore/sdk-0.10.1
# Conflicts:
#	docs/BUGS.md
2026-10-31 19:26:47 +01:00
nvrl d1b7535696 Merge branch 'fix/restore-outcome-and-walk-gaps' into fix/sim-flakes-and-open-race
# Conflicts:
#	docs/BUGS.md
2026-10-31 19:26:44 +01:00
nvrl ba414c4446 Merge branch 'fix/diagnose-coverage' into fix/restore-outcome-and-walk-gaps
# Conflicts:
#	docs/BUGS.md
2026-10-31 19:26:41 +01:00
nvrl 5b7f17bdaf Merge branch 'fix/db-errors-not-offline' into fix/diagnose-coverage
# Conflicts:
#	docs/BUGS.md
2026-10-31 19:26:38 +01:00
nvrl 73068f5868 Merge branch 'fix/mount-failure-surfaced' into fix/db-errors-not-offline
# Conflicts:
#	docs/BUGS.md
2026-10-31 19:26:36 +01:00
nvrl b093010b64 Merge branch 'fix/never-synced-label' into fix/mount-failure-surfaced
# Conflicts:
#	docs/BUGS.md
#	po/de.po
#	po/en_GB.po
#	po/es.po
#	po/fr.po
#	po/it.po
#	po/nl.po
#	po/pdfs.pot
#	po/pl.po
#	po/pt_BR.po
2026-10-31 19:26:34 +01:00
nvrl ab8fe0e61d Merge branch 'fix/sync-scheduling-watches' into fix/never-synced-label
# Conflicts:
#	docs/BUGS.md
2026-10-31 19:26:28 +01:00
nvrl b8295373f7 Merge remote-tracking branch 'origin/docs/bugs-issue-audit' into fix/sync-scheduling-watches
# Conflicts:
#	docs/BUGS.md
2026-10-31 19:21:21 +01:00
nvrl 5bc0b5c58a Merge pull request #56 from narrrl/fix/sync-engine-containment
fix(fuse,core,cli): keep a panicking sync pass from ending the engine
2026-10-31 19:20:04 +01:00
nvrl 73af39ee65 Merge remote-tracking branch 'origin/docs/bugs-issue-audit' into fix/sync-engine-containment
# Conflicts:
#	docs/BUGS.md
2026-10-31 19:11:41 +01:00
nvrl 48aaad23ce Merge pull request #55 from narrrl/fix/journald-priority
fix(cli): give journald each log line's priority
2026-10-31 19:09:37 +01:00
nvrl 7957e1ed59 chore(deps): bump proton-sdk and proton-drive-rs to 0.10.1
0.10.1 decrypts address key tokens with every account key that opens,
not only the active ones, and leaves out an address whose keys do not
open instead of failing the account. That is the likely cause of issue
#40, a mount that retried "missing key" forever (B265).
2026-10-31 13:48:01 +01:00
nvrl 53543c91f9 docs(bugs): record the slow-link conflict copy seen in CI (B275) 2026-10-31 13:16:47 +01:00
nvrl fccb3d9561 fix(fuse): retry a write open whose queued revision landed under the copy
A write open copies the queued revision's blob into its scratch file.
When the upload landed between the sample and the copy, the drain had
already deleted the blob, and the open failed with EIO (B274, seen once
in CI). The drain drops the pending entry before the blob, so a failed
copy whose entry moved on now goes around again, as the re-check after
the copy already does.

Two simulations raced the daemon rather than it:
- a_file_deleted_after_a_restart_leaves_drive_with_what_its_create_sent
  let the create's reply through before the kernel's release attached
  the second bytes. That attach resets the attempts, so the retry
  landed before the test could see the failure. It now waits for the
  bytes to be attached.
- a_file_deleted_offline_while_open_still_reads_and_takes_writes read
  the cache as the queue emptied, which is a moment before the
  eviction. It now waits for the cache to empty.
2026-10-31 12:58:11 +01:00
nvrl 6f2b4bb80a fix(fuse,cli): keep a pass going past a failed listing, report skipped restores
A listing that failed anywhere in the remote walk ended the whole pass,
so a first sync of a large folder on a flaky link walked the tree again
and again and never downloaded anything (B271, issue #46). A failed
listing below the root now records that folder as a gap: nothing under
it is deleted, the rest of the pass applies, and the report names it.

A restore that skipped every folder was logged as a successful activity
(B269). The daemon now gets the restored and skipped folders back, logs
any skip as a failed activity with the reasons, and sends the Rewatch
for folders already restored when the database insert fails partway.
`pdfs devices join` points to `pdfs activity` for the outcome.
2026-10-31 11:11:01 +01:00
nvrl a617b2b410 fix(cli): have diagnose report failing writes, a pause and folders in error
diagnose dropped most of the Status reply and never looked at the synced
folders, so it said "No problems found" while a folder would not sync
(B268, issue #53). It now warns about writes that keep failing, a write
a day old, a pause, folders in error or conflict, and a control socket
nothing listens on.
2026-10-31 10:57:34 +01:00
nvrl bdbfc0bfd4 fix(core): read database errors handed to from_api as internal, not offline
from_api took every error that was not a ProtonError for a transport
failure. Pin, unpin, the searches and node loads hand it pdfs_core
errors from the cache and the database, so a failure there said
"You're offline" (B266, the rest of B205). The source chain now
decides, and only an error with neither type reads as offline.
2026-10-31 10:53:25 +01:00
nvrl e9577f953f fix(cli,core,fuse,gui): say why the mount keeps failing, and back off
A mount that failed on every try retried every 5 s forever, and the
reason was only in the journal: the app and the tray said Connecting...
for days (B263, issue #40). The retry now doubles to a 2-minute ceiling,
and the last failure is kept in mount-failure.json for the app, the
tray, pdfs diagnose and systemctl status.

The early returns in mount left the workers they had started running
beside the next attempt (B264). They are now stopped on every error
path, without telling systemd the daemon is stopping.
2026-10-31 10:49:01 +01:00
nvrl 437d14b0f5 fix(gui): say a folder is waiting for its first sync, not up to date
A new sync folder row starts with state idle, so the app called a folder
up to date before any of it reached Drive (B261, issue #53). A mirror
folder that has never synced now reads "waiting for first sync", and
the This computer row counts such folders. All catalogs translated.
2026-10-31 10:30:52 +01:00
nvrl b40ca797d9 fix(fuse): run a burst's named folders first and retry failed watches
A poll in the same burst as a new folder's Reconcile(id) took its place,
so the new folder waited behind a pass over every other one (B260).

A recursive watch that failed partway left its watches registered, was
never retried, and its events ran passes over every folder (B262). It
is now unwatched and retried every 10 minutes, symlinks are not followed,
and watcher errors are logged. A folder removed and added again in one
burst keeps its watch under the new id (B272).
2026-10-31 10:24:32 +01:00
nvrl c64bb23e3c fix(fuse,core,cli): keep a panicking sync pass from ending the engine
One thread runs every synced folder's passes. A panic in a pass ended
it, and every later add, sync-now, file event and poll went nowhere
without a word until a restart, which fits issue #53 (B259).

Each pass now runs under catch_unwind; a panic sets the folder to error
and the next poll tries again. Diagnostics reports the engine's state,
the supervisor warns when it has ended or a pass has gone quiet, and
first passes log at info.
2026-10-31 10:14:23 +01:00
nvrl 8d4a005aa2 fix(cli): give journald each log line's priority
journald stored every daemon line at info, so journalctl -p warning,
which TROUBLESHOOTING.md asks reporters for, was always empty (B258).
When fd 1 is the journal stream, prefix lines with <N> and drop colours.
2026-10-31 09:54:07 +01:00
nvrl 9460c93b50 docs(bugs): record the audit of issues #53, #46, #40 and #38
B258 to B273: why a stuck sync, a failing mount and a silent restore
are invisible, plus the sync, mount and restore bugs behind them.
B165, B202, B205 and B210 point at the entries that finish them.
2026-10-31 09:47:54 +01:00
Nils Pukropp bd50ba5959 chore(aur): update packages to 3.6.0 2026-10-27 13:33:37 +00:00
nvrl 27201914ee Merge pull request #51 from narrrl/fix/emptied-folder-direct-switch
fix(fuse): let an emptied mirror folder switch to on-demand at once
2026-10-27 12:05:50 +01:00
nvrl 2775f0681d test(acceptance): count a purge of a listed trash entry that then vanishes
The reaper found an abandoned root in the trash and sent the delete. The
reply did not confirm it, and the next listing no longer had the node,
but the retry loop only counted deletes it sent itself. It waited for a
node that was already gone, reported it as never trashed, and the
leftover check stopped the run before any case ran.

Callers that just found the node in the trash now say so, and the error
of the first delete is kept for the warning.
2026-10-27 01:22:07 +01:00
nvrl 97277d41dd fix(fuse): let an emptied mirror folder switch to on-demand at once
A switch asked for before any pass saw the folder empty found it still
idle, so the check for what only this computer has ran at once and
refused it for good: every synced path read as "deleted here but not on
Proton Drive yet". The account run hit it in the B255 case.

A local copy gone in full is the wipe guard's case, which the push pass
of a queued switch settles without trashing anything on Drive. The
switch now returns NotNow for it, so the request is queued and that pass
runs first. A partial deletion is still refused as before.
2026-10-27 01:22:07 +01:00
nvrl d49738bf0b Merge pull request #50 from narrrl/test/size-batch-clock-before-spawn
test(fuse): start the size batch clock before the worker
2026-10-27 01:03:49 +01:00
nvrl 48945b9c97 Merge pull request #49 from narrrl/test/b256-case-own-folder
test(acceptance): give the B256 case a copy folder of its own
2026-10-27 01:03:40 +01:00
nvrl b860ac3b49 test(fuse): start the size batch clock before the worker
The worker thread's sleeps could begin before the test read the clock, so the elapsed time came out a few milliseconds under the 90 ms bound on a busy runner. Reading the clock first makes the bound hold.
2026-10-27 01:02:29 +01:00
nvrl 42f3a18177 test(acceptance): give the B256 case a copy folder of its own
Every case of a run shares one root, and the pdfs copy case before it
had already made "copies" there, so the B256 case failed on its mkdir
with EEXIST before it checked anything.
2026-10-27 00:56:35 +01:00
nvrl 6f504d2e83 Merge pull request #48 from narrrl/test/acceptance-new-features
3.6.0: acceptance cases for the new features, --json for members/du/public-link
2026-10-27 00:44:30 +01:00
nvrl 8c1c37a928 test(acceptance): cover copy, folder size, link updates and sync recovery
New cases for what 3.6.0 adds:

- copy_file_range over several calls, into the middle of a file, past
  its end, at the source's end, and from a file whose writes are not
  released yet; on a live mount also read back from Drive.
- pdfs copy of a tree and of a file beside itself, read back as listed
  and after a refresh, with nothing queued; refused while a file has
  writes Drive lacks, and never the version before a save (B256).
- pdfs du counts every item below a folder, trash included.
- pdfs public-link update keeps the URL and the options it was not
  asked to change, and refuses a link id that is not the folder's.
- In a sync folder of their own: Sync From Scratch changes nothing that
  already matched, and an emptied mirror folder still switches to
  on-demand with every file intact on Drive (B255).

docs/TESTING.md gains a checklist for the new screens in the app. The
CHANGELOG moves these into 3.6.0, which is not tagged yet.
2026-10-27 00:42:44 +01:00
nvrl e4d2340bbf feat(cli): --json for members, du and public-link create/update
Scripts, the acceptance suite among them, can now read a link's id,
role, password flag and expiry, and a folder's size and item count,
instead of parsing the human-readable lines.
2026-10-27 00:42:44 +01:00
nvrl c1b72e455e test(fuse): time the short-outage reconnect from the mount coming back
The probe that sees the link return runs 2 s after the drop, then 6 s.
A loaded run could turn the link back on after the first probe, and the
next one came after the test's 3 s were up. The test now checks that the
drain takes the mount offline within 2 s, which is the B189 fix itself,
and counts the 3 s from the mount reporting it is back online.
2026-10-27 00:32:49 +01:00
nvrl 2e3b7748e6 Merge pull request #47 from narrrl/feat/server-copy
Release 3.6.0: server copy, link settings, folder size, sync fixes (#46)
2026-10-26 22:47:47 +01:00
nvrl 7f4f11ef18 chore(release): 3.6.0 2026-10-26 22:45:36 +01:00
nvrl cc9f7c6d8a Merge remote-tracking branch 'origin/main' into feat/server-copy
# Conflicts:
#	Cargo.lock
#	Cargo.toml
2026-10-26 22:41:40 +01:00
nvrl 9add185c1f Merge pull request #43 from narrrl/feat/diagnose-keys
feat(cli,core): trace the keys that open My Files in pdfs diagnose
2026-10-26 22:35:35 +01:00
nvrl 0e7ffe1261 fix(gui,cli): count what a folder's first scan has checked (#46)
The first pass over a synced folder has no baseline to estimate from, so
its scan showed only "checking for changes…" in the app and "scanning…"
in `pdfs sync list`. For a large folder that scan reads every item on
Proton Drive before anything downloads, which takes many minutes and
looked stuck. Both now show how many items the scan has checked so far.
2026-10-26 22:28:46 +01:00
nvrl a5acae41b3 fix(sync,gui,cli): get a synced folder unstuck when its local copy is gone (#46)
A mirror folder whose files were all gone from this computer stayed in
error for good. The wipe guard rightly refuses to trash the whole folder
on Drive, but nothing got the folder out of that state: a queued switch
to on-demand waits for a pass that ends idle, and its push pass hit the
same guard. Only removing the folder from sync cleared the baseline that
made the empty folder read as "delete everything".

- The push pass for a switch to on-demand forgets the baseline rows of a
  local copy that is gone in full instead of trashing them, so the
  switch goes ahead and the mount shows what Drive has.
- New Request::ResetSyncFolder, `pdfs sync reset ID` and "Sync From
  Scratch…" in a synced folder's menu clear a mirror folder's baseline
  (and a queued switch to on-demand, whose push pass would upload a
  duplicate of every file). The next pass compares both sides as on the
  first sync: one-sided files are copied across, files that differ keep
  both versions, nothing is deleted.
- The wipe guards' messages name the reset; restore logs why it skipped
  a folder.

Two simulations cover it. The on-demand one failed in exactly the
reported state (mirror, pending ondemand, error) before the fix.
All 8 catalogs translated.
2026-10-26 17:45:22 +01:00
nvrl ea10a43216 fix(fuse): a copy right after a save waits for the edit (B256)
close(2) returns before the kernel sends release, and release takes the
write handle out of the open writes a few milliseconds before the
revision is in the queue. A `pdfs copy` checked in that gap found the
edit nowhere and copied the version Drive had. A folder copy looked only
at the queue, so it also missed files below it still open with unsaved
writes.

release now keeps the inode in State::releasing until the revision is
queued. A copy refuses a file or folder with unsaved writes, or a
release still queueing, at it or below it, and a move checks the same
set.
2026-10-26 17:45:14 +01:00
nvrl 917e7268f9 build(deps): proton-sdk and proton-drive-rs 0.10.0
0.10.0 adds copy_node, get_folder_size, update_public_link and a link's custom password.
2026-10-26 17:03:33 +01:00
nvrl 9089c8d13c feat(fuse): answer copy_file_range from bytes already on this machine
A copy inside the mount went through the kernel's read/write loop, one
FUSE round trip per page, because copy_file_range answered ENOSYS. The
daemon now copies a range whose source bytes are local (an open write
handle, a queued write, the cache) into the destination's scratch file,
the same way write stages them. A range it would have to download gets
EOPNOTSUPP, which makes the kernel copy that one call itself and keeps
the operation enabled. The copy still uploads: Drive cannot copy into a
file cp has already created.

In the simulation, a warm cp of a 256 MiB file went from about
100 MiB/s to about 450 MiB/s (debug build).
2026-10-26 16:57:06 +01:00
nvrl 071a11ef65 Merge remote-tracking branch 'origin/main' into feat/diagnose-keys
# Conflicts:
#	docs/CHANGELOG.md
#	docs/TROUBLESHOOTING.md
2026-10-26 16:45:36 +01:00
nvrl 4edcff3f87 feat(sharing,gui,cli): change public links and show folder sizes
The share dialog gets Link settings under an existing link: role, password
and expiry apply at once and keep the URL. The daemon fills in the options a
change keeps from the link as it stands, and refuses to keep a password it
cannot read rather than dropping it. pdfs public-link update does the same
from a terminal, and pdfs members shows the expiry.

Details shows a folder's size and item count as Proton Drive counts it,
trash included, and pdfs du prints it. Both are refused offline and for a
folder that is not on Drive yet.

Needs proton-drive-rs with update_public_link, get_folder_size and
PublicLink::custom_password.
2026-10-25 22:18:11 +01:00
nvrl ee59e338b6 feat(gui): make a copy and copy to another folder in My files
The entry menu gains Make a Copy, which copies an item next to itself, and
Copy To…, which reuses the move folder picker limited to My files; Copy
To… is also in the selection menu. Both send Request::Copy, so the copy is
made on Proton Drive.

The move and copy reports share one request loop. Docs describe the new
actions, and the changelog notes that saved public links now show their
names once the SDK decrypts them.
2026-10-25 21:41:21 +01:00
nvrl c1d9538669 feat(cli,fuse,core): copy files and folders on Proton Drive
`pdfs copy PATH... NEW_PARENT` sends a new control request, Copy, which
copies a file with its content or a folder with its subtree on Proton
Drive through proton-drive-rs's copy_node. Nothing is downloaded or
uploaded. A name already taken in the destination gets the next free
" (n)" suffix through get_available_name.

A copy cannot wait in the queue, because Drive copies what it holds.
So it is refused offline, and while the mount still owes Drive part of
the node: a local placeholder, a queued op, unsaved writes, or, for a
folder, any queued op at all. Local-first does not change this.

DriveApi gains copy_node and get_available_name, and the fake drive
copies subtrees, so a FUSE sim test covers the copy, the suffix, the
folder subtree and both refusals. The activity feed logs a new Copy
kind, translated in every catalog.

Needs the proton-drive-rs release that adds copy_node; until then, build
with a [patch.crates-io] pointing at proton-sdk-rs.
2026-10-25 21:10:50 +01:00
nvrl a73ed34421 Merge pull request #45 from narrrl/docs/wiki
docs: publish docs/ to the GitHub wiki and reorganise the user guide
2026-10-17 14:06:46 +02:00
nvrl 9c30ff8276 docs: publish docs/ to the GitHub wiki and reorganise the user guide
- scripts/publish-wiki.sh publishes every page listed in SECTIONS, copies
  images, writes a sectioned sidebar and footer, and removes stale pages.
  --check fails on links to missing files or headings and on docs pages
  that are neither published nor excluded.
- New wiki.yml runs the check on pull requests and pushes to main, and
  publishes when release.yml calls it or when started by hand.
- Split USER_GUIDE.md into topic pages (files, synced folders, photos,
  sharing, computers, launcher and tray); add Getting started and FAQ.
- Make the COPR repository the main Fedora path, with update and
  uninstall steps for every package type.
- Fix the stale CAPTCHA note in CLI.md, document pdfs unlock and lock,
  and drop the broken AGENTS.md link from CONTRIBUTING.md.
2026-10-17 13:57:55 +02:00
nvrl d1d4d23dd5 Merge pull request #44 from narrrl/automate-updates
feat: COPR repository and opt-in update check
2026-10-17 13:13:49 +02:00
nvrl f717a692ff fix(packaging): COPR project belongs to narl-rs
The COPR project lives under the FAS account narl-rs, not narrrl.

Refs #42
2026-10-17 12:38:09 +02:00
nvrl e065586d7e build(deps): proton-sdk and proton-drive-rs 0.9.2
0.9.2 adds the key report pdfs diagnose uses.
2026-10-17 12:23:19 +02:00
nvrl fd80c34152 feat(cli,core): trace the keys that open My Files in pdfs diagnose
When the session is stored and unlocked but the daemon does not answer,
pdfs diagnose asks Proton for a key report and prints a Keys section:
each account key, each address key and the account key its token is
for, and the address key the My Files share is encrypted to. It holds
key IDs only, so it can be posted in an issue.

Refs #40
2026-10-17 12:13:27 +02:00
nvrl e66308498b feat(gui,core): opt-in check for new releases
Preferences gets a switch that asks GitHub once a day for the latest
release and shows a banner over the sidebar when it is newer. Off by
default. Packages whose package manager brings updates install the
marker share/proton-drive-linux/managed-updates, which hides the switch.

Refs #42
2026-10-17 11:45:09 +02:00
nvrl 29b87ece72 feat(packaging): COPR repository for Fedora
The spec builds from the tag archive and a vendored crates tarball when
git_dir is not defined, so COPR can build it without network access.
release.yml publishes the vendor tarball and submits the build to COPR
after the release is published.

Refs #42
2026-10-17 11:44:33 +02:00
Nils Pukropp fac8a144e5 chore(aur): update packages to 3.5.0 2026-10-16 17:29:08 +00:00
nvrl eb25c16fe6 Merge pull request #41 from narrrl/release/3.5.0
Release/3.5.0
2026-10-16 19:18:13 +02:00
nvrl 8f771fdcc4 fix(fuse): read the size again for a baseline row without a revision id
A mirror upload that Drive did not list yet when it was read back left a
baseline row with no revision id. The next walk reused the recorded size
whenever the modification time matched, and a row without an id never
counts as moved. So a revision another computer saved in the same second
looked unchanged and never arrived (B254). The B250 simulation hit this
about one run in five on CI.

A row without an id now reuses its size only while Drive gives no id
either. Otherwise the walk reads the size, and an unchanged file gets
Drive's revision id written into its row, so each row costs one extra
read. Rows from before schema 44 are read once after upgrading.
2026-10-16 18:44:51 +02:00
nvrl c1911ea1de docs: window size, keyring banner and the mailbox password per session
Add the window size and the narrow layout to the 3.5.0 changelog with
B253, document the `window` config field, and explain in the
troubleshooting guide why a two-password account asks for its mailbox
password after every reboot, and how to keep it unlocked.
2026-10-16 17:21:46 +02:00
nvrl 179b6555c4 fix(gui): keep a saved sign-in when the keyring can't be read
The app read the session with `auth::load().ok()`, so a locked keyring, a
Secret Service that was not up yet or any D-Bus error showed the sign-in
form as if nobody were signed in. The session was still saved (B253).

Keyring errors are now kept apart from "not signed in". The login page
shows a banner saying the saved sign-in could not be read, with a Retry
button. The app tries again after 2, 5, 10 and 30 seconds and opens on
Files once the keyring answers. The login page's header also loses the
back button the collapsed split gave it.

Translated into every shipped language.
2026-10-16 17:21:46 +02:00
nvrl e6971dd702 fix(gui): let the main window shrink to fit small screens
While signed in, the sidebar never collapsed. The window could not get
narrower than the sidebar plus the widest page, about 950 pixels, so on a
small or scaled screen the minimize, maximize and close buttons ended up
off-screen.

- The page stack is no longer homogeneous, so only the page on screen sets
  the minimum width.
- The selection bars of My files and Photos scroll sideways instead of
  holding the window at their full width, even while hidden.
- Below 760sp the sidebar and the page take turns, with a back button,
  and the My files search entry narrows. Picking a sidebar row opens the
  page.

Every page, in English and in German, now fits in a window 480 pixels
wide with its window buttons visible.
2026-10-16 17:21:46 +02:00
nvrl 770d4fda38 feat(core,gui): remember the main window's size
The app always opened at 980x680. It now keeps the unmaximized size and
whether the window was maximized in config.json under `window`, written
when the window closes and when the app quits with it still open. A
missing field, or a saved size below 480x400, opens at the old default.
2026-10-16 17:21:46 +02:00
nvrl 11d03091a7 docs: syncing several computers into one device; changelog for 3.5.0
Add B248 to B252 to the bug ledger, the shared-device feature and schema 44
to the 3.5.0 changelog, and describe the join flow, per-machine profiles,
conflict copy names and the mirror's multi-writer rules in the user guide,
recovery guide, CLI and configuration references and the architecture notes.
2026-10-16 16:18:14 +02:00
nvrl 7a0f1ebd6f feat(cli,gui): sync with another computer's device
Two or more computers can now sync the same folders through one device in
Proton Drive. `pdfs devices join UID` and "Sync With This Computer…" on
another computer's row adopt that device as this machine's and then offer its
folders for syncing here, like a restore. Unlike "Continue This Backup Here",
the other computer goes on syncing, and the confirmation says that changes
and deletions on one reach the other.

Translated into every shipped language.
2026-10-16 16:04:26 +02:00
nvrl 950056b48d feat(core,fuse): one profile per machine on a shared device
Every computer that synced into one device wrote the same profile.json, so
each save replaced the other computers' folders with its own (B252). Each
machine now writes profile-<machine id>.json, where the id is random, made on
first use and kept in machine-id next to config.json. No host identifier goes
to Drive.

Restore reads every profile in .proton-drive-linux, including the shared one
older clients wrote. A folder's proposed path and mode come from this
computer's own profile, otherwise from the newest one that has the folder.
The legacy file is left as it is, so a client before 3.5.0 still finds it.
2026-10-16 14:55:13 +02:00
nvrl 05b84bf796 fix(fuse,core): keep a mirrored folder safe when another computer syncs it
A mirrored folder assumed it was the only client writing to its folder on
Drive. With a second computer syncing the same folder, edits could be lost:

- The first sync took a same-size file at the same path for the same file.
  It now records the baseline only if the local file's SHA-1 matches Drive's
  digest, and otherwise keeps a conflict copy (B248).
- An upload replaced a revision another client had committed since the pass
  looked. The upload now checks Drive's active revision first, and an edit
  that read back with someone else's digest is left for the next pass to keep
  as a conflict copy (B249).
- A download recorded a revision committed while it ran as synced, so that
  edit never arrived. The baseline now records the revision the pass planned
  to download (B250).
- Conflict copies now carry the computer's hostname, so two computers do not
  make the same name on Drive (B251).

The baseline gains the revision id it was synced at (schema 44), and a new
revision counts as a remote change even at the same mtime and size.
2026-10-16 14:48:32 +02:00
nvrl 7cd516a164 chore(release): 3.5.0 2026-10-16 12:58:06 +02:00
nvrl c3c536892e docs(changelog): add B245 to B247 2026-10-16 12:31:51 +02:00
nvrl cc9db51b78 fix(gui): translate the tray's transfer status lines (B247) 2026-10-16 12:31:40 +02:00
nvrl 7384d8e97d fix(gui): read the new session off the main thread after sign-in (B246) 2026-10-16 12:31:40 +02:00
nvrl 4bf176a631 fix(packaging): accept watchdog pings from the daemon only (B245) 2026-10-16 12:17:53 +02:00
nvrl aa0c60f5a2 docs(changelog): list the second round of 3.5.0 fixes 2026-10-16 12:14:17 +02:00
nvrl 47934b7ddb fix(core,fuse,gui): date photos by their EXIF when nothing else does
A Takeout photo with no sidecar and no date in its name was filed under
the day of the import, and the app dated uploads by modification time.
A new `exiftime` module reads DateTimeOriginal (then DateTimeDigitized)
with its offset. Takeout uses it for undated photos it copies out for a
thumbnail, and the daemon uses it for app uploads before falling back to
the modification time (B244).
2026-10-16 12:12:41 +02:00
nvrl 48c8910e7b fix(gui,core): ask the mount off the main thread when opening, reap children
Opening a file or a location stat'ed the mount on the GTK main thread, so
a hung mount hung the app. The browser now uses the launcher's bounded
worker check (`present_within`, 2 s), and opening a location runs on a
thread. Every child the GUI starts is also waited for on a thread, so it
no longer stays a zombie (B243).
2026-10-16 12:07:48 +02:00
nvrl 6ec8043934 fix(gui): decode viewer photos and read their EXIF off the main thread
The viewer decoded each full-size photo, read its EXIF and stat'ed it on
the GTK main thread, freezing the app for a large photo. That now runs in
gio::spawn_blocking, and a result for a photo the user has left is
dropped (B242).
2026-10-16 12:04:05 +02:00
nvrl 99707c48fa fix(gui): free the photo viewer and the share dialog when they close
The viewer's handlers held strong references to the viewer and its
window, which own them, so every closed viewer stayed in memory with its
full-size photo. The share dialog's row handlers had the same cycle with
the dialog state. The handlers now hold weak references, and the viewer's
one strong handle is dropped on close with the photo (B241).
2026-10-16 12:03:02 +02:00
nvrl 5557aa66d5 ci: run the simulations on pull requests and lint with --locked 2026-10-16 11:58:56 +02:00
nvrl 6456f503f1 fix(cli,core): keep secret-holding commands out of core dumps
Only `pdfs daemon` marked itself non-dumpable, though `mount`, `login`,
`unlock` and `logout` hold the same passphrases or tokens. All five now
do. The file-manager script also passes each path after `--`, so a file
named like an option stays a path (B240).
2026-10-16 11:58:28 +02:00
nvrl 4836c5a84a fix(cli,fuse,gui): make --wait fail when an import or re-date did
`import-google-photos --wait` and `redate-photos --wait` exited 0 however
the run ended, and a run that failed as a whole left no summary, so the
CLI said no import had run and the app said it finished. Failed runs now
leave a summary with the reason, `--wait` exits 1 on failure, cancel or
failed photos, and the app shows the reason (B239).
2026-10-16 11:56:22 +02:00
nvrl 937a0a6824 perf(core): index queued ops by kind and name (schema 43)
The name checks the drain makes before each create, rename and trash,
and the wake of ops waiting for a name, read the whole queue, which made
a large offline batch quadratic to drain. Schema 43 adds an index on
pending_op(kind, name) (B238).
2026-10-16 11:50:56 +02:00
nvrl fcbb15cb4b fix(core,fuse): back failed uploads off properly and spread their retries
A reconnect made every failed op due at once, quota and access refusals
included, and ops that failed together retried in the same second. The
doubling also stopped at 128 s, short of the 300 s maximum. Reconnects
now move up only unrefused ops, and the backoff reaches its maximum with
a per-op spread of up to a fifth (B237).
2026-10-16 11:48:15 +02:00
nvrl 8aca7836f9 fix(fuse): upload thumbnails with imported, re-dated and app photos
Takeout imports, re-dates and the app's photo upload all sent an empty
thumbnail list, so the photos showed blank in Proton's apps. Re-dates and
app uploads now make thumbnails from their file. A Takeout photo that can
have one is copied out of the archive to the private staging folder,
thumbnailed and uploaded from the copy (B236).
2026-10-16 11:46:17 +02:00
nvrl 60a5a98174 perf(core): find Takeout sidecars by prefix lookup, not by ranking all
Each photo was ranked against every sidecar in its folder, so a folder
of 10,000 photos took about 30 s to scan. The sidecars are now indexed
by stem and each photo is ranked only against the stems a few range
lookups return: 0.1 s for the same folder, with the same matches (B235).
2026-10-16 11:41:02 +02:00
nvrl 51800c77d7 fix(core,fuse): refuse sync roots that overlap home, the mount or app data
Nothing stopped `/`, the home folder, the Proton Drive mount, the app's
own state or cache, or a folder nested in another root from becoming a
sync root, and a restore took a path from another machine's profile as
it was. One validator, `syncroot::problem`, now runs where roots are
added and restored, on paths resolved through symlinks with `..`
refused (B234).
2026-10-16 11:30:40 +02:00
nvrl 518763c5c9 fix(core,cli): end the session on Proton when signing out
Logout only deleted the keyring entry, so the session's tokens stayed
valid on Proton's side. It now ends the session first (DELETE auth/v4,
bounded to 10 s) and signs out locally either way. `pdfs logout` says
when Proton could not be reached (B233).
2026-10-16 11:26:29 +02:00
nvrl ef0f046939 fix(gui): show remote names as text, not Pango markup
Rows, toasts and status page descriptions parse their text as markup by
default. Shared item names, inviter addresses, device names, link URLs,
paths, EXIF values and daemon errors went in unescaped, so a name with
`&` showed as an empty row and other names were drawn as markup. Those
rows and toasts now use plain text, and status page descriptions are
escaped (B232).
2026-10-16 11:24:43 +02:00
nvrl 519ba0591a fix(fuse): give uploads under a low cap the time their share needs
The upload deadline assumed at least 32 KiB/s. Under an upload cap shared
by every drain worker and sync task, a large file ran out of time on every
attempt, took the mount offline and restarted from byte 0. The deadline
now counts the upload's share of the cap as the slowest rate when that
share is lower (B231).
2026-10-16 11:21:57 +02:00
nvrl 42b2f69fa0 fix(core,fuse): keep queued writes durable and their retries their own
- A gap-filled revision blob is recorded as complete once filled, so a
  retry after a lost upload answer recognises its own bytes on Drive
  instead of forking a conflict copy (B228).
- Commits that accept a change into the queue run at synchronous=FULL,
  so an op `close()` reported as done survives a power cut (B229).
- A queued create's adoption window starts at its first send, not when
  it was queued, so a create queued offline no longer takes another
  device's empty node of the same name. A send record that does not
  parse is kept instead of overwritten (B230).
2026-10-16 11:21:48 +02:00
nvrl 0204f69e9e docs(changelog): list the 3.5.0 fixes under Unreleased 2026-10-16 11:07:23 +02:00
nvrl d4ef9116c8 fix(fuse): download the base of a file open for writing on a worker
A read on a write handle was answered on the dispatch loop to keep its
order with the handle's writes, and when the bytes it asked for were
neither written nor cached it downloaded them there, holding every request
of the mount for up to the read deadline. The loop now answers from the
scratch file and the local caches only, and hands a read that needs a
download to a transfer worker with the handle as it was when the read came
in (B227).
2026-10-16 10:47:58 +02:00
nvrl f8c60c9753 build: add a release profile that strips the binaries
No package ships a separate debug package, and the unstripped binaries were
43, 24 and 12 MB. LTO stays off, as the packaging comments already said the
release profile kept it, and unwinding stays on for the workers that catch
panics.
2026-10-16 10:32:05 +02:00
nvrl 607972cf1e fix(fuse): turn generated thumbnails the way their EXIF orientation says
Thumbnails were decoded without the EXIF orientation, so a portrait photo
stored as landscape pixels plus a tag got a sideways tile, and the drain
uploaded that thumbnail to Drive for every client to show (B226).
2026-10-16 10:32:05 +02:00
nvrl 96b70600f0 fix(core): match a Takeout sidecar by prefix only where Google cut its name
The prefix rule for sidecars whose names Google cut at 51 characters also
matched names that were never cut, so IMG_1.jpg's sidecar gave its title
and date to an IMG_12.jpg that had none. It now applies only to a sidecar
name at the cap. Edited copies, which have no sidecar of their own, take
their original's date by a rule of their own and keep their own name
(B225).
2026-10-16 10:32:05 +02:00
nvrl b0ff6ee441 fix(core): never write defaults over a config file that does not parse
load_config gives defaults for a file it cannot read, and every setter
loads, changes one field and saves. The first settings change after the file
was damaged therefore wrote defaults over the mountpoint, the adopted device
and the ignore rules. save_config now refuses while the file on disk does
not parse, and the first-run default is written atomically with mode 0600
(B223).
2026-10-16 10:32:05 +02:00
nvrl bca22056b0 fix(fuse): report write errors at close, and take fsync and fallocate as POSIX does
flush answered ok at once and release synced the scratch file, but close(2)
only returns what flush answers, so a full disk at close was reported to
nobody. flush now syncs a handle with writes on a worker and returns ENOSPC,
EDQUOT or EIO; release then finds the pages on disk instead of writing a
whole file back on the dispatch loop. fsync on a descriptor opened for
reading no longer fails with EBADF (B222).

fallocate passed any mode through and tracked only a punched hole or
growth: a zeroed range came back from the remote base at commit, and a
collapse or insert corrupted the file. Unsupported modes now get EOPNOTSUPP,
zeroed ranges count as written, the real errno is returned, and the state
lock is not held across the syscall (B224).
2026-10-16 10:32:05 +02:00
nvrl 830cfd9faa fix(fuse): allocate a listing's inodes before taking the state lock
A cold listing allocated the local id of every new child in a write
transaction while it held the global state lock, then marked the folder
listed, still under it. Every inline handler of every mount waited on that
transaction, and the transaction on the write connection the drain shares.
The ids are now allocated before the lock is taken, and the folder is marked
listed through the outbox after its children's rows, so a crash in between
no longer leaves it recorded as complete and empty (B221).
2026-10-16 10:05:58 +02:00
nvrl 77dac97afe fix(fuse): keep slow remote calls off the dispatch loop and the metadata workers
A shared folder's listing is fetched again once it is a minute old, and that
fetch ran on the dispatch loop whenever the old listing was still resident,
so every request on the mount waited for Drive (B219).

Thumbnail downloads, size upgrades and the Shared with me listing had no
deadline of their own. On a stalled link each held a mount thread for the
SDK's retry budget, and a folder of photos filled the three metadata workers
that every cold lookup and rename needs. Offline, an uncached thumbnail is
now answered as absent, and these calls give up after 20 or 60 seconds
(B220).
2026-10-16 10:02:35 +02:00
nvrl 2bea1f7672 fix(core): check and count the database on a read connection
PRAGMA integrity_check and the row counts behind pdfs cache inspect read
the whole file, 34 s on a 567 MiB database, and they held the write
connection while they did. Every write in the daemon waited behind them.
They now read a WAL snapshot on a pooled read connection (B218).
2026-10-16 09:56:25 +02:00
nvrl 0f279c7536 fix(core,fuse): compact the search indexes and stop pruning after partial scans
FTS5 keeps deleted entries until their segments are merged, which with
these indexes is close to never. On one machine local_fts held 317 MiB for
40,000 paths, against 20 MiB rebuilt, and search ran four times slower. The
indexes are now compacted once a day after a local scan. A scan that hit the
entry cap or lost a batch no longer prunes the paths it never reached, which
the next scan used to add back (B217).
2026-10-16 09:56:13 +02:00
nvrl d6bc921a4b fix(core): keep the upload queue when a damaged database is rebuilt
A database SQLite reported as corrupt at open was moved aside and replaced
by an empty one, on the grounds that everything in it is fetched again. The
queue of writes Drive has not received, the mirror baselines and the pins
are not, so they were lost. They are now copied out of the damaged file,
read only and table by table, into the new one (B216).
2026-10-16 09:50:53 +02:00
nvrl 3221e4aa97 fix(fuse): mirror sync no longer deletes what Drive or this disk never had
Five ways a mirror pass could lose files:

- A folder deleted on Drive was removed here with everything in it, including
  new files and edits whose uploads had just failed for want of a parent, and
  ignored content such as .git. Such a folder now stays and is created on
  Drive again (B211).
- A node the SDK could not decrypt was missing from the remote walk and so
  read as deleted on Drive. A short listing or an unreadable size now leaves
  the affected paths out of the pass, and a pass in which no synced path is
  left on Drive is refused (B212).
- A file Drive deleted was removed here even when it had been edited since
  the scan. It is now checked again first, and kept (B213).
- A folder deleted here was trashed on Drive although another device had just
  put new files in it. It now stays on Drive (B214).
- A decrypted name holding / or .. became a path outside the sync folder, and
  failed the whole listing in the mount. Such names are left out (B215).
2026-10-16 09:47:08 +02:00
Nils Pukropp 3d96cdbab1 chore(aur): update packages to 3.4.1 2026-10-15 23:47:24 +00:00
nvrl 18cc2ea498 chore(release): 3.4.1 2026-10-16 01:32:59 +02:00
nvrl a9cb389f04 docs: a locked volume after a password reset (B210)
Issue #40: after a password reset the daemon failed every mount with
"failed to decrypt: missing key", and the app waited on "Connecting…"
because control.sock was never created. proton-drive-rs 0.9.1 now
creates a new My Files volume when no active regular volume is left,
as the web client does.

- BUGS.md: B210 with cause, fix and the SDK tests that cover it
- TROUBLESHOOTING.md: what the log line means and how to restore the
  files of the locked volume from the web
2026-10-16 01:32:59 +02:00
nvrl 923c47acb8 chore(deps) updated sdk to 0.9.1
0.9.1 replaces a locked My Files volume, the state a password reset leaves behind, instead of failing every mount with "missing key" (#40).
2026-10-15 23:02:36 +02:00
nvrl 478a0e6cdd fix(core,gui,cli): a blocked sign-in no longer asks to update the app (B209)
Proton answers a sign-in it blocks for abuse or rate limits with code
2028 and the text "This version of the app is no longer supported". The
SDK read 2028 as Unknown, so the app passed that text through and told
users to update a current release (issues #28, #39).

- deps: proton-sdk and proton-drive-rs 0.8.3, which name 2028 Blocked
- core: error::sign_in_blocked detects it
- gui: the login page says Proton is refusing sign-in for now and that
  waiting helps, translated in every catalog
- cli: pdfs login adds the same hint to the error
- cli: pdfs diagnose warns instead of failing when cache.db does not
  exist yet; the daemon creates it after the first sign-in
- docs: BUGS.md B209, changelog, troubleshooting entry
2026-10-13 20:29:23 +02:00
Nils Pukropp 2633b4ed72 chore(aur): update packages to 3.4.0 2026-10-12 16:21:56 +00:00
nvrl b46abfc07c chore(release): 3.4.0 2026-10-12 18:12:00 +02:00
nvrl fd78f088ec test(fuse): let a held create carry its bytes in the simulation
FUSE delivers `release` after `close()` returns, and the release is what
attaches the written bytes to the queued create. A drain worker that is
free can claim the create first and send the file empty, with the bytes
following as a revision. Tests that hold a create, its reply, or lose
its reply then act on the empty create, and their assertions about the
file's content race the revision. CI saw this as an occasional failure
of a_listing_taken_before_a_create_commits_keeps_the_landed_file.

Add Daemon::write_new, which pauses sync, writes the file, waits until
the pending create carries the bytes, and resumes. Use it for every
write that directly follows a create hold.
2026-10-12 18:05:45 +02:00
nvrl 005f7ded1f fix(fuse): a file landing while its folder is pinned stays available offline (B208)
A folder pin reads each file's modification time and size before it
downloads the file. If the file's create landed during the download, the
landing set Drive's time, and the pin then stored the blob under the old
time. The file showed as kept offline but read as stale.

After each download, the pin now compares the file's time and size with
the tree's. If they changed, it downloads the file again under the new
values, at most three times.

The simulation gains FakeClient::hold_next_download to force that order.
2026-10-12 15:40:56 +02:00
nvrl 3e4886f965 fix(fuse): refuse a move between locations under a just-opened file (B207)
The account run of 3.3.0 failed "move refuses a file still being
written" on the on-demand to mirror pairing: pdfs move moved the folder
while open.txt in it was open for writing, and the write landed after
the move.

retire_create re-keys a landed create's nodes row to the real uid first
and repoints the tree and the open write handles after. A file opened in
between holds a handle with the placeholder uid. has_pending_work_under
placed each open handle by looking its uid up in the nodes table, which
no longer has the placeholder, so it saw nothing open under the folder,
and with the queue already empty the move went ahead. Open handles are
now placed in the in-memory tree by inode, which a landing create does
not change.

The simulation test holds the drain at that point with the new
FakeClient::hold_next_landing_of_any, and fails without the fix.
2026-10-12 00:57:10 +02:00
nvrl aac53e3139 test(fuse): let the simulation sync local folders
A simulated daemon could not add a synced folder, so nothing that moves
between a mount, an on-demand folder and a mirror folder could be tested
without a real account.

Listing and registering devices now go through DriveApi, and the fake
Drive keeps the account's computers, each with a root folder outside My
files. The simulated daemons also read their config.json from a scratch
XDG directory instead of the developer's own: an adopted device of the
real account made every sync folder fail to register against the fake
Drive.
2026-10-12 00:57:04 +02:00
nvrl c2bf87596f test(acceptance): remove the local-only symlink the mirror-copy case leaves
"move never loses a mirror copy Drive lacks" puts a symlink in a mirror
folder to prove a move does not lose what sync never uploads, and left it
there. The next mode pairing switches that folder to on-demand, which the
daemon refuses because the link exists only on this computer, so the
account run stopped after pairing 2 of 4. The case now removes the link
wherever the move left it, whether it passes or fails.
2026-10-12 00:24:40 +02:00
nvrl 69d4feaa07 docs: add a security model page for users
Readers asked whether the client can be trusted with their files. The
threat model in ARCHITECTURE.md is written for contributors, so
docs/SECURITY_MODEL.md summarises it for users: encryption comes from
the SDK and rPGP, sign-in uses SRP, what is stored where and how it is
protected, who can reach the daemon, every network connection, what the
client does not protect against, and how each package is built and
signed. It says that there has been no independent audit.

It is linked from the README, the documentation index and SECURITY.md.
2026-10-12 00:07:18 +02:00
nvrl d481dcc15c feat(fuse,core,cli,gui): make folders available offline from the app
The daemon has kept recursive folder pins for a long time, but the app
offered "Available offline" for files only. The context menu, the
details pane and the selection bar now offer it for folders too, and a
pinned folder carries the offline badge in the browser and the launcher.

A folder pin used to download the whole subtree inside the control
request, so a large folder outlasted the client's 120 s read timeout,
and every pinned file was read into memory before it was cached. The
daemon now records the pin, answers at once and downloads on a
background thread as a job the tray and the app show, stopping when the
folder is unpinned or the daemon stops. Pins of files and folders both
go through fetch_content, which streams to disk and skips fresh blobs.
A folder pin is refused while offline instead of looking kept.

A file kept only because a folder above it is pinned has no pin of its
own, and unpinning it silently did nothing. ListDir now reports
pinned_by_folder; the app shows such an entry as available offline with
the toggle greyed out, and the daemon refuses the unpin with a message
naming the folder. Unpinning a folder no longer evicts files that are
pinned on their own.

Requested on Reddit.
2026-10-12 00:07:18 +02:00
nvrl e0442c56b9 fix(packaging): stop the service before the package is removed
Removing the package left the user service running from the deleted
binary, with ~/ProtonDrive still mounted. A user who then deleted the
folder to clean up had every file in it moved to the Proton Drive trash,
as any delete through the mount is.

The .deb (prerm on remove), the .rpm (%preun on erase) and the Arch
packages (pre_remove) now stop proton-drive.service for every user with
a running user manager, through runuser with that user's
XDG_RUNTIME_DIR, so no systemd-machined is needed. Upgrades are left
alone. Queued writes stay in the state directory for a later install.

The pacman install script lives once in packaging/ and the AUR
directories link it; push.sh copies its content into each AUR
repository. The uninstall guide now warns not to delete the mount point
while the service runs.

Reported on Reddit by a user who restored the files from the trash.
2026-10-12 00:07:07 +02:00
nvrl ee76f4ac60 fix(packaging): recommend each keyring only on its own desktop
The RPM recommended both gnome-keyring and kwallet unconditionally, and
dnf installs weak dependencies by default, so a GNOME install on Fedora
pulled in KWallet and KDE Frameworks with it. The recommendations are
now rich dependencies: KWallet only with plasma-workspace, GNOME Keyring
only without it, and the AppIndicator extension only with gnome-shell.
RPM does not accept `unless` in a weak dependency, so the keyring uses
the equivalent `(gnome-keyring or plasma-workspace)`.

Reported on Reddit by a Fedora GNOME user.
2026-10-12 00:06:51 +02:00
nvrl f6c651b06b ci(nix): push Nix builds to a Cachix binary cache
Every Nix build of main and of each release tag now goes to
proton-drive-linux.cachix.org, so installing the flake downloads the
client instead of compiling it. The flake names the cache in nixConfig
for nix run and nix shell, and the install guide shows the nix.settings
a NixOS system build needs, since it does not read an input's
nixConfig. A fork's pull request has no token and only pulls.
2026-10-11 23:02:22 +02:00
Nils Pukropp 8b7d7ae87f chore(aur): update packages to 3.3.0 2026-10-11 20:08:31 +00:00
nvrl 160626b29d chore(release): 3.3.0 2026-10-11 21:53:52 +02:00
nvrl f1f1f8ad4d ci(release): build the Nix flake at every release tag
A flake input pinned to vX.Y.Z is the Nix release, so the release
workflow now builds the flake at the tag and checks its version before
the GitHub release is published. The install guide shows how to pin the
input to a release tag, and the release checklist names the files that
carry the version.
2026-10-11 21:53:52 +02:00
nvrl bb28a18f49 Merge branch 'feat/issue-37-nix' 2026-10-11 21:48:28 +02:00
nvrl a3c0c9ee9b ci(release): take the release notes from the changelog
The release workflow created each GitHub release without a body, so 3.1.2,
3.2.0 and 3.2.1 shipped with empty notes. It now copies the version's
section of docs/CHANGELOG.md into the release, and fails when the section
is missing.
2026-10-11 21:24:39 +02:00
nvrl 91fa1614ed fix(core,fuse,cli,gui): say when an upgrade left the old daemon running
A package upgrade replaces the binaries but does not restart the user
service, so the daemon keeps the old release and its bugs until the next
login. On 3.2.0 the reporter of #33 still saw "Couldn't sync Documents:
read .../snapshots/37: Permission denied", which only a daemon older than
3.2.0 can produce: since then the scan leaves an unreadable subfolder out
and only an unreadable sync root fails the pass (B206).

The daemon now reports its release in Status; one from before the field
omits it, which reads as older. When the daemon is older than the
front-end, the tray says "Update not finished - restart Proton Drive" and
offers a restart, the sync card shows "Restart to finish updating" with a
Restart button, and pdfs status and pdfs diagnose print the systemctl
command. A newer daemon is not flagged, since then the front-end is the
stale one.

Refs #33
2026-10-11 21:20:51 +02:00
nvrl 87361a4573 fix(core,fuse,gui): a photo upload the SDK refuses no longer says offline
Every SDK error that was not an API reply was classified as Offline.
Only a transport failure is. An upload on an account without a photo
library fails in the SDK before any request, and the app said "You're
offline" while every other call worked (issue #38).

Classify by variant: Transport is Offline, InvalidOperation is Invalid,
Serialization is Remote, Crypto and KeysLocked are Internal. The daemon
logs a failed photo upload, and the Photos page greys out Upload when
the account has no photo library.

docs/BUGS.md B205.
2026-10-11 19:26:53 +02:00
Nils Pukropp 6d0b6ad553 chore(aur): update packages to 3.2.1 2026-10-11 17:24:37 +00:00
nvrl 170c3f01ff chore(release): 3.2.1 2026-10-11 19:19:36 +02:00
nvrl 976ad2aa04 docs(changelog): list the issue #35 fixes under Unreleased
The CPU spin while offline, the expired session shown as offline, the
lost refresh tokens and the keyring-refused rotation were fixed without
a changelog entry.
2026-10-11 18:56:55 +02:00
nvrl b73cd087aa test(core): time the B12 index write as the best of five runs
A single timed run lost the CPU to the rest of the suite running
alongside it and read as the full-scan regression (171 ms against
701 ms). The fastest of five runs still catches the 6x scaling of the
old path.
2026-10-11 18:56:55 +02:00
nvrl e402981708 fix(packaging): ship the Adwaita icons with the Nix package
GTK ships only a few stock icons itself. On a NixOS session without
Adwaita installed, such as Hyprland, the Photos and Computers sidebar
entries and about twenty other icons showed as missing (#37).
adwaita-icon-theme in buildInputs puts it on the wrapper's XDG_DATA_DIRS.
2026-10-11 18:55:00 +02:00
Claude 453c439b74 fix(packaging): give the Nix package TLS for WebKitGTK
On NixOS the CAPTCHA window failed with "TLS support is not available":
WebKitGTK's GIO had no TLS backend. With glib-networking in buildInputs,
wrapGAppsHook4 puts its GIO modules on GIO_EXTRA_MODULES in every
wrapper.

Refs #37

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EuyxByBpK3fSWtyPkugeHz
2026-10-10 08:23:21 +00:00
Claude ceba0f5849 feat(packaging): add a Nix flake and NixOS module
Builds the four binaries with rustPlatform.buildRustPackage from
Cargo.lock, installs the desktop entries, icon, user service and
translations as the other packages do, and wraps the binaries with
exiftool and ffmpeg on PATH. The NixOS module installs the package,
pulls proton-drive.service into the graphical session with the
fusermount3 wrapper on its PATH, and enables FUSE and GNOME Keyring.

A Nix workflow builds the flake when its recipe, the lock files or the
code change.

Refs #37

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EuyxByBpK3fSWtyPkugeHz
2026-10-09 22:07:20 +00:00
Claude 3e0db8cf46 fix(fuse): fill an incomplete queued revision's gaps on a write open
A partial write whose base was not in the content cache queued an
incomplete revision, and every write open over it was refused with EIO
until the drain uploaded it. CI's one-client-lan simulation hit this on
seed 1: a create over a file whose partial edit had not drained yet.

The write open now copies the blob and fills its gaps from the remote
base with Core::fill_gaps, as the drain does, and fails only when that
fill fails (B204).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EuyxByBpK3fSWtyPkugeHz
2026-10-09 11:36:46 +00:00
nvrl 2a86830e72 Merge branch 'fix/issue-35-dead-session'
Issue #35: a refused session showed as offline forever, the drain
workers spun while offline, and refreshed tokens could be lost or
overwritten (B200-B203, B61). Needs proton-sdk 0.8.2.
2026-10-09 12:34:38 +02:00
nvrl 43031ecf99 chore(deps) updated sdk to 0.8.2 2026-10-09 12:34:02 +02:00
nvrl 2b6c37f543 fix(fuse,core,cli,gui): say when the keyring refuses the refreshed sign-in
A rotation the keyring refused was retried and kept in the kernel
keyring, but only the log said so, and a reboot before the keyring took
it still signed the user out.

`Response::Status` carries `tokens_unsaved` while one waits. The tray
says "Sign-in not saved — unlock your keyring" and needs attention, the
app's status says the same, `pdfs status` prints a Keyring line and
`pdfs diagnose` warns. New strings are translated in every catalog
(B61).
2026-10-09 12:22:02 +02:00
nvrl 3458e2e72d fix(core): keep refused token rotations in the kernel keyring
A rotation the Secret Service refused lived only in the daemon's
memory, so a crash or SIGKILL before the retry landed lost the only live
refresh token.

Keep it in the kernel keyring as well until it is written. The next
resume, by the daemon or `pdfs unlock`, puts it back in when the stored
blob still holds the refresh token it spent, and writes it; one left
over from an older login is dropped. `kernelkey` now holds named keys,
the passphrases and the unsaved tokens (B61).
2026-10-09 12:14:25 +02:00
nvrl 0ac5928138 fix(fuse,core,cli): resume a session another process refreshed
`pdfs unlock` resumes the daemon's session and may refresh it. The
daemon then holds a spent refresh token, and since B202 a refusal of it
read as an expired session although the keyring held a live one.

Before calling a refused session expired, compare the keyring with what
this process last stored. When it holds a newer rotation or another
login, end the mount with `MountOutcome::ResumeStored` and mount again
from the keyring. A rotation another process already wrote counts as
this session's, so the daemon keeps writing its later ones (B203).
2026-10-09 12:08:17 +02:00
nvrl beb3b2e4e4 fix(core): retry rotated tokens the keyring refused
A failed keyring write after a refresh was only a warning, and the one
live refresh token then lived in memory only (B61). The sink now keeps
the rotation as unsaved and retries it in the background after 1 s, 5 s,
30 s and then every 5 minutes, until it is written or the blob has moved
on. The refusal is logged as an error naming `pdfs login`.

docs/BUGS.md B61.
2026-10-09 11:29:31 +02:00
nvrl bf43476e88 fix(fuse,core,cli,gui): show a refused session as expired, not offline
A refresh token Proton refused, or a deleted or disabled account, was
treated as a lost connection: the mount came up "offline", the probe
retried forever and logged only at debug, and nothing told the user that
only signing in again helps (issue #35).

session_revoked() names those errors. The root fetch, probe, drain,
event poll, event seed and photos root now set session_expired, log one
warning naming `pdfs login`, and stop retrying. Cached files stay
readable and changes stay queued.

Response::Status carries session_expired (serde default false). The
tray shows "Session expired — sign in again" with a Sign In Again…
item that opens the app's sign-in page; the app's status and Sync card
say the same with a button. `pdfs status` and `pdfs diagnose` report it.
New strings are translated in every catalog.

docs/BUGS.md B202.
2026-10-09 11:27:09 +02:00
nvrl 6363125fec fix(core,cli): write rotated tokens only into their own session's blob
Proton refresh tokens are single-use. store_tokens replaced the tokens of
whatever blob was stored, so after `pdfs login` restarted the daemon, the
old daemon's shutdown persist wrote its spent tokens into the new
session's blob, and the new daemon failed with InvalidRefreshToken
(issue #35). `pdfs unlock` and the legacy migration had the same shape.

Each session now writes through a TokenSink that only touches a blob
with its session id that still holds the refresh token it last left
there. Shutdown only flushes a rotation the keyring refused. unlock and
migrate_legacy re-read the blob after verifying.

docs/BUGS.md B201.
2026-10-09 11:08:40 +02:00
nvrl 51d114a8da fix(fuse,core): idle drain workers no longer spin while offline
Offline or paused, a drain worker claims nothing and then slept until the
earliest queued op fell due. Every queued op was already due and nothing
attempted it, so the wait was zero and all 16 workers spun on the queue
for the whole outage (issue #35: 17 min of CPU in 2 min 16 s).

A worker that claims nothing now sleeps the full idle poll; a reconnect
or resume wakes it. earliest_due_at also uses the claim's exclusions, so
an op held behind a claimed op on the same node no longer counts as due.

docs/BUGS.md B200.
2026-10-09 11:05:12 +02:00
Nils Pukropp 31be27f30e chore(aur): update packages to 3.2.0 2026-10-09 01:14:52 +00:00
nvrl 4c498d559c Merge pull request #32 from narrrl/fix/captcha-in-browser
fix(gui,cli,core): offer the CAPTCHA in the browser when the web view…
2026-10-09 03:04:10 +02:00
nvrl 7fd3439b60 Merge remote-tracking branch 'origin/main' into pr32-merge
# Conflicts:
#	docs/CHANGELOG.md
#	po/de.po
#	po/en_GB.po
#	po/es.po
#	po/fr.po
#	po/it.po
#	po/nl.po
#	po/pdfs.pot
#	po/pl.po
#	po/pt_BR.po
2026-10-09 03:03:31 +02:00
nvrl c4847c4d52 chore(release): 3.2.0 2026-10-09 02:56:26 +02:00
nvrl ad49e32de9 fix(gui): context menus size to fit instead of scrolling
A popover is sized and placed again when its parent's layout manager
allocates it. Context menus were parented to the outermost list view,
and the keyboard menu to the ScrolledWindow; neither has a layout
manager, so a menu kept the size it was first given and often opened
too short, with a scrollbar.

The host is now the first widget from the list view up that has a
layout manager, which still keeps menus out of list items and the theme
rules that style their insides.
2026-10-09 02:44:00 +02:00
nvrl 19854b2d76 fix(gui): tidy the Share dialog
Send Invitations and Create Public Link sat alone in boxed-list rows,
which read as broken settings. They now sit under their section at its
right edge. The role picker moves into the address row it applies to,
people get avatars, a member needs no subtitle, and invitations say they
are pending. The People and Public link sections show a loading row
until the share is read, and one line instead of nothing when it can't
be. The page is an AdwPreferencesPage, for the standard spacing.

Translated in every catalog.
2026-10-09 02:41:07 +02:00
nvrl 085335ca14 feat(gui): one main menu, on the sidebar's account row
The sidebar header had a hamburger beside the brand, and the account row
in the footer had its own ⋮ menu; both offered Preferences. The header
button sat next to the page's own tools and read as one of them.

The account row is now the main menu's button, as in Proton's own apps:
Preferences, Keyboard Shortcuts and About, then Sign Out. The whole row
opens it, upwards, and F10 still does as the primary menu.
2026-10-09 02:37:25 +02:00
nvrl 9fb7e84d41 fix(fuse,gui): a stopped share leaves Shared by me at once (B199)
Stop Sharing removes the link, invitations and members but cannot delete
the share itself, and Drive keeps listing the empty share until its
backend removes it, minutes later.

The daemon now leaves a share with no member, invitation or link out of
the listing (unless a lookup failed, so a live share is never hidden),
and the app drops stopped items from the page as soon as the requests
succeed instead of waiting for the slow reload.
2026-10-09 02:34:03 +02:00
nvrl 7ebe8e5210 fix(fuse,core,gui): routine mirror scans no longer show as syncing
Every mirror pass, the two-minute poll included, reported a "Checking
{folder}" job, and the tray took any job for syncing, so its icon spun
every two minutes over folders with nothing to do.

JobItem gains a background flag (absent from older daemons, so false)
for housekeeping that changes nothing in the user's files: a pass still
scanning, the home index rebuild and the cache integrity check. The tray
keeps them in its menu line but only turns busy for transfers or a job
that is not background; a pass that queues work becomes a Syncing job
and still spins the icon.

The watcher also reconciles only the folder an event lies in, instead of
walking every mirror folder over one edit.
2026-10-09 02:27:50 +02:00
nvrl 922399b9a2 fix(gui): even breadcrumb spacing, no invented hyphens in tile names
The current folder in the breadcrumb trail was a bare label, so the "›"
before it hugged it while the ones between buttons sat in their padding.
One helper now builds the separator and the current segment for My Files,
Shared with me and Computers, with a flat button's side padding.

Grid tile names broke mid-word with a hyphen that is not in the name
("SteamSetup.e-xe"); turn Pango's inserted hyphens off.
2026-10-09 02:18:47 +02:00
nvrl 3b80e9bc8f test(core): make under-budget cache check deterministic
The timing ratio between a near-empty and a 2500-entry index measured the
CI runner's load and flaked on main. Plant an oversized index row behind
the running total instead: any budget check that consults the index would
evict and reseed, so the test fails without depending on wall-clock time.
2026-10-08 21:26:56 +02:00
nvrl dc8eb27c99 Merge pull request #34 from narrrl/fix/sync-unreadable-subfolder
fix(fuse): skip unreadable folders in sync, never evict local-only files
2026-10-08 20:59:55 +02:00
nvrl 07d4ee42d9 Merge branch 'main' into fix/sync-unreadable-subfolder 2026-10-08 20:59:21 +02:00
nvrl 0ac5e2e4fe Merge pull request #31 from narrrl/feature/windows-migration
Feature/windows migration
2026-10-08 20:57:31 +02:00
nvrl 7eb6aa0640 fix(fuse): skip unreadable folders in sync, never evict local-only files
An unreadable subfolder, such as a container's root-owned cache, failed
every pass of its synced folder, so nothing else in the tree reached
Drive (#33, B196). The scan now leaves such a path out, together with an
entry that vanished mid-scan and a name that is not UTF-8, and the pass
drops everything under it from classification, the wipe guard and the
push pass's deletions. It is never read as deleted and never downloaded
into. An unreadable or absent root still fails the pass. The activity
feed names unreadable paths once each time the set changes.

Two related bugs on the way to online-only:

- The push pass read deletions from the raw baseline, so a synced path
  that later became ignored was trashed on Drive (B197).
- The switch evicted everything once the folder was idle, including
  ignored paths such as .git/, symlinks, unreadable folders and files
  changed after the pass, none of which are on Drive (B198). It now
  refuses and names the path.

Closes #33
2026-10-08 20:45:54 +02:00
nvrl d13e74fc12 fix(gui,cli,core): offer the CAPTCHA in the browser when the web view cannot start (B195)
The verification window now runs in its own process, so the app survives
when WebKit cannot start its sandbox on secureblue, but the CAPTCHA still
could not be solved there (#29). WebKit binds the journal socket with
`--ro-bind-try`, bubblewrap only skips a missing path, not an unreadable
one, and WebKit has no switch to leave the socket out.

Proton Mail Bridge never uses a web view: it opens verify.proton.me in the
system browser and retries with the original challenge token, typed with
the offered methods. Do the same as a fallback.

- core: `browser_verification_url` and `browser_verified` build the page
  URL and the retry credential from the challenge
- gui: when the verification window fails, a dialog opens the page in the
  browser and continues the sign-in once the user is done
- cli: `pdfs login` handles a CAPTCHA the same way instead of failing
- i18n: translate the new dialog in every catalog
- docs: B195, TROUBLESHOOTING.md, ARCHITECTURE.md, changelog

Fixes #29
2026-10-08 19:02:29 +02:00
Nils Pukropp c0d211238b chore(aur): update packages to 3.1.2 2026-10-08 13:55:27 +00:00
nvrl 07c9b675d3 chore(deps) updated sdk to 0.8.1 2026-10-08 15:25:44 +02:00
nvrl f82343b844 Merge pull request #30 from narrrl/fix/secureblue-captcha
Fix/secureblue captcha
2026-10-08 15:25:05 +02:00
nvrl 3e3dd7db41 feat(fuse,core,cli,gui): move another computer's backup to this one
A user switching from a Windows PC to Linux had two half answers:
adopting the old device makes this machine back up as it, and restoring
its folders keeps them in the old device's tree. Neither puts the data
under this machine's own device.

Device roots are separate shares on the main volume, so each top-level
folder of the other device can move under this device's root in one
server-side move. Nothing is uploaded again, and the other device stays
registered, without those folders.

- core: `Request::MigrateDevice { uid }` and
  `Db::sync_folder_set_share`
- fuse: `Core::migrate_device` moves every folder, suffixes a name
  already taken here with the other device's name, keeps going past a
  failed folder, and re-points sync folders that already tracked a
  moved folder at the new share
- cli: `pdfs devices migrate UID [--yes]` confirms, moves, then runs
  the ordinary restore picker
- gui: "Move to This Computer…" on another computer's row, followed by
  the restore picker
- i18n: translate the new strings in every catalog
- docs: CLI.md, USER_GUIDE.md, RECOVERY.md, ARCHITECTURE.md, changelog
2026-10-08 13:34:28 +02:00
Nils Pukropp d09e10dc7d chore(aur): update packages to 3.1.1 2026-10-08 09:49:25 +00:00
nvrl 8d0351124c chore(release): 3.1.2 2026-10-08 11:31:18 +02:00
nvrl a888e0bb28 fix(gui): run the CAPTCHA page in its own process (B195)
On secureblue, WebKit cannot start the sandbox for the verification page:
bubblewrap is kept from the journal socket, xdg-dbus-proxy fails, and
WebKit ends its process with g_error. The webview lived in pdfs-app, so
the whole app crashed after the sign-in (#29).

- gui: `pdfs-app --human-verification` shows the page in its own window,
  reads the URL on stdin and prints the token on stdout; its logs go to
  stderr
- gui: a child that dies or fails without a token ends the sign-in with a
  message that the page could not be opened, instead of "cancelled"
- i18n: translate the new message in every catalog
- docs: B195 in the bug ledger, the cause and a one-time workaround in
  TROUBLESHOOTING.md, the child process in ARCHITECTURE.md, changelog
2026-10-08 10:25:14 +02:00
nvrl c7fc85e943 fix(gui): create the state dir before the tray and app use it (B194)
On a fresh install only the daemon and the CLI created
~/.local/state/proton-drive-linux. The app starts the tray before the
first sign-in, so the tray failed to bind its single-instance socket and
exited, and the Takeout page could not remember staged archives (#29).

- gui: pdfs-tray and pdfs-app call AppDirs::ensure at start, which also
  makes the directories owner-only
- docs: B194 in the bug ledger, changelog entry
2026-10-08 10:25:04 +02:00
nvrl 5bfae0fee1 docs(changelog): add the 3.1.1 entry
3.1.1 shipped the client identity change without a changelog entry. Also
add the missing release links for 3.1.0 and 3.1.1.
2026-10-08 10:24:07 +02:00
nvrl edd62c5f7a chore(release): 3.1.1 2026-10-07 17:01:32 +02:00
nvrl 5c1530b4b9 fix(core,gui): send the release version as the client identity
Every release sent the fixed `external-drive-linux@0.1.0-alpha`. If Proton
ever retires that version, every release fails sign-in at once and no update
can fix it.

- core: APP_VERSION and USER_AGENT follow CARGO_PKG_VERSION
  (`external-drive-linux@<version>-stable`, `proton-drive-linux/<version>`)
- core: `app_version` and `user_agent` in config.json are optional overrides;
  the fixed values older releases stored there read as unset and leave the
  file on the next save, a hand-set value is kept
- gui: the About dialog shows the release, its debug info the identity
  actually sent
- docs: describe the new default and the migration
2026-10-07 17:01:32 +02:00
Nils Pukropp ce324510de chore(aur): update packages to 3.1.0 2026-10-07 10:53:15 +00:00
nvrl 274bfd0ca2 Merge pull request #27 from narrrl/feature/two-password-mode
Feature/two password mode
2026-10-07 12:47:05 +02:00
nvrl b8cfd30ff3 fix(core): give the kernel-keyring unlock owner read access
A new key grants its owner only "view"; read needs possession, which a
process has only when its session keyring links to the user keyring. A
systemd user service or a container does not guarantee that, so the
daemon could store the unlock and then fail to read it back (EACCES).
Set the permissions explicitly after add_key.
2026-10-07 08:17:50 +02:00
nvrl 9fa88ff397 feat(core,cli,gui): support two-password accounts without storing the mailbox password
A two-password account signed in with its login password as the mailbox
password, so the key chain never unlocked and the daemon retried forever.

Login now asks for the mailbox password and checks it against the account
(proton-sdk 0.8 `unlock`), so a typo is a retry. The password is never
stored: the keyring blob keeps the per-key passphrases instead, and for a
two-password account only when the user opts in (`--remember`, or the
"Keep unlocked on this computer" switch). By default they live in the
kernel keyring for the login session and the account is locked after a
reboot until `pdfs unlock` or the app supplies them again.

- core: kernelkey module, Error::Locked / WrongMailboxPassword, unlock,
  lock, key_state, ready; blobs written by 3.0.x are migrated on first
  start; token refresh updates only the tokens in the stored blob
- cli: `pdfs unlock [--remember]`, `pdfs lock [--forget]`; status and
  diagnose report the key state; the daemon waits while locked and is
  marked non-dumpable
- gui: mailbox password dialog at sign-in and on open, locked tray state
- po: all catalogs translated
- bump to 3.1.0, depend on proton-sdk and proton-drive-rs 0.8.0

Refs #26.
2026-10-07 08:17:50 +02:00
nvrl f105159186 fix(cli,fuse): keep answering the systemd watchdog while the daemon retries
The watchdog is armed when the process starts, but only the mount's supervisor
pinged it. A daemon waiting for a login or backing off after a failed mount
(#26: key unlock failing) was aborted after WatchdogSec and left a coredump
instead of its error. Sleep through idle_sleep in those stretches; the mounted
ping still has to pass the control-socket probe.
2026-10-07 08:17:50 +02:00
nvrl a14a63c94c ci: add an on-demand Fedora test build
Manual workflow that builds the RPM and the bare binaries in a Fedora
container, so a user can try a branch against Fedora's own GTK stack.
Uploads them as an artifact and, on request, as one rolling pre-release
per branch that needs no GitHub login to download.
2026-10-07 04:27:22 +02:00
nvrl ed3477d1b3 docs: point contributors at the simulation runs and the ledger rules
DEVELOPMENT.md lists the daemon's current modules, asks for the
simulation runs on FUSE, queue and sync changes, and adds how to handle
a bug fix and a user-visible change, including the six pages the wiki
publisher copies. The release checklist moves to TESTING.md.
2026-10-06 19:29:02 +02:00
nvrl aa384ae2eb chore: cite docs/BUGS.md and drop references to notes outside the repo
66 comments cited `bugs.md B<n>`, the ledger's old name; they now say
`docs/BUGS.md B<n>`, and every cited number has an entry. About 90 more
cited offline.md, devices.md, features.md, mount-architecture.md,
plan.md and improvements.md, planning notes that were never in the
repository. Those citations are removed; the comments keep their text.
2026-10-06 19:29:02 +02:00
nvrl 10c69b0d64 test(acceptance): explain the metadata floor for local-first mounts
The throughput case said every create and unlink on a Drive mount waits
for Drive. Since 3.0.0 that holds only with local_first off; the floor
of 3 operations a second stays for that case.
2026-10-06 19:29:02 +02:00
nvrl 39be354bd2 docs(bugs): give the ledger an index and one order
The ledger ran B193 down to B78, then B1 up to B75, with T1 to T4 in
the middle and a stray top-level heading inside B14. Status lines used a
dozen wordings for the same few states, so finding what is still open
meant reading 6,600 lines.

Add a header: what the numbers promise (permanent, never reused; B76
and B77 were never assigned), the status vocabulary, and how to add an
entry, with a template. Add an index sorted by status, with what is left
for every open or partly fixed entry. Order all entries newest first
under one Entries section, and move the acceptance-runner findings to
their own section. Titles are in sentence case; anchors are unchanged.

Entry text is kept as written, except B89: the file it described,
proton-dev/CLAUDE.md, no longer exists, so it is closed.
2026-10-06 19:29:02 +02:00
nvrl 2cca8afba9 docs: describe local-first changes and sync issues for users
The user guide said only writes were queued. Since 3.0.0 creating,
renaming and deleting through the mount return at once too, and changes
Drive refuses become sync issues. Add a section on changes waiting for
Proton Drive: retry, export, discard, and the two cases that land under
another name or place instead of failing.

Troubleshooting covers changes that have not reached other devices yet,
`pdfs sync issues` and the local_first fallback, and names the real
error for symlinks and hard links (EPERM, not EOPNOTSUPP). CONFIGURATION
lists MemoryHigh and the test variables; CLI notes that `sync export`
takes a folder.
2026-10-06 19:14:29 +02:00
nvrl 6e9beb4b16 docs: rework contributor docs for the 3.0.0 local-first mount
ARCHITECTURE.md described the 2.x write path, where namespace changes
waited for Drive and every mount had its own inode space. Rewrite it
around the local tree (lid, inode = lid + 1, local~<lid> stand-ins), the
queue and its 16 drain workers, sync issues, and the remaining split
between the mount and the mirror engine. Drop the pins.json claim; pins
moved into the database at schema v5.

TESTING.md gains the simulation runs and loses the per-case narrative
that --list and BUGS.md already carry. ROADMAP.md takes over the
post-3.0.0 items from the milestone status file.

Remove MILESTONE-3.0.0-STATUS.md (a working log) and UI_UX_PLAN.md (the
2.x design audit). Code comments and CI that cited the untracked
docs/MILESTONE-3.0.0.md now point at TESTING.md, ARCHITECTURE.md and
ROADMAP.md. Threat-model links follow the section to §10.
2026-10-06 19:14:29 +02:00
nvrl 63484bf4b6 chore(images): add cover image
1920×1080 cover for link previews, social posts and the GitHub social preview.
The website renders its og.png from cover.svg.
2026-10-06 14:46:56 +02:00
nvrl 37c66b7220 chore(support) addd buy me a coffee badge + link 2026-10-06 10:49:24 +02:00
Nils Pukropp 41b6edbecb chore(aur): update packages to 3.0.0 2026-10-05 05:12:08 +00:00
nvrl 217738318e chore(release): 3.0.0 2026-10-05 05:16:57 +02:00
nvrl 9291580fd6 test(fuse): drop the known-bug matchers the runs confirmed fixed
The confirmation run on 812eb3d (PDFS_SIM_KNOWN=fail, twelve seeds)
passed every profile, so no open bug is tolerated any more: the call,
hang and damage matchers for B125 to B134 go, and any of them now fails
the seed. B126, B128 and B133 were fixed with B138, B162 and B137;
B134 stays open with local_first off only, which the runs do not mount.
2026-10-05 05:15:19 +02:00
nvrl 812eb3d0bc fix(fuse): a deleted file's trash leaves a file of the user's under the name it was sent as (B193) 2026-10-05 04:50:17 +02:00
nvrl 31863a1da1 fix(fuse): a new file closed empty reaches Drive at once (B192) 2026-10-05 04:50:10 +02:00
nvrl 57a825887c fix(core): a file deleted after its failed create was written again leaves Drive (B191) 2026-10-05 04:50:02 +02:00
nvrl 1436c5f37e fix(fuse): a stop queues the writes whose release the kernel never sent (B190) 2026-10-05 03:55:52 +02:00
nvrl f0a43fb923 fix(fuse): changes that failed on a short outage go out once the link is back (B189) 2026-10-05 03:48:13 +02:00
nvrl b47620651f fix(fuse): a restart no longer brings back the write of a file deleted while open (B188) 2026-10-05 03:37:41 +02:00
nvrl 2baad6720d fix(core): leave no temp copy when the cache adopts a blob it already holds (B187) 2026-10-05 02:52:51 +02:00
nvrl c271a1897c fix(fuse): keep what an open file reads when its delete is queued (B186) 2026-10-05 02:51:55 +02:00
nvrl d26dc8ada4 fix(fuse,core): retry a create at once when a write replaced its blob (B185) 2026-10-05 02:34:05 +02:00
nvrl 93f02672e6 docs: describe 3.0.0's local-speed writes and leave batched trashes and moves for later 2026-10-05 02:02:34 +02:00
nvrl 307305282e fix(fuse,core): what a create sent, and where, outlives a restart and a rename (B182, B183, B184)
The known-bug confirmation run failed one_client_on_a_flaky_link seeds 6
and 11 on three bugs around a create whose answer was lost:

- B182: after a restart, a write over a file whose create landed but was
  never read back forked a conflict copy. A revision of a file that has
  no revision of Drive's now adopts the landed node first.
- B183: the SHA-1s a create sent were kept in memory, so after a restart
  its trash or retry no longer knew the node Drive made. They are now on
  the op's row (pending_op.sent_sha1, schema 42).
- B184: a rename rewrote the op's target, and the trash and the retry
  looked for the node under the last name only. Each attempt's parent
  and name are recorded (pending_op.sent_to); the trash looks under all
  of them, and a retry adopts the node an earlier attempt made and moves
  it to the current name.
2026-10-05 02:01:50 +02:00
nvrl a8ed6060af feat(core,fuse,gui): show a sync issue on its node in the file browser
A refused change was only on the Sync page. A folder listing now carries
each node's issue (by local id, so a file not yet on Drive has it too),
and the browser's badge ranks it first, folders included, with the
issue's explanation as its tooltip. The event sampler publishes Files
when the set of issues changes, so the badge comes and goes with it.
2026-10-05 01:06:03 +02:00
nvrl 9dfe4c5450 feat(core,fuse,cli,gui): discard a queued change and undo it here
A change Drive will not take could be exported or retried, never let go
of. The Sync page's Discard button (with a confirmation) and
`pdfs sync discard <id>` drop it: a node made here goes, as if deleted
before it was sent; a node Drive has goes back to what Drive has, under
the same inode, with every change queued on it, while what is queued
below it stays. A change on the wire or an open file is refused.
2026-10-05 00:57:02 +02:00
nvrl da013c29d6 fix(gui): word a queued edit kept as a conflict copy in the user's language (B181)
The daemon logs it as "<reason>; local changes uploaded as <copy>" or
"...kept at <path>", which the Activity page showed as written, in
English. It now reads both and the three reasons, one sentence each.
2026-10-05 00:17:11 +02:00
nvrl 457a9d26f4 ci: keep the stacks of a hung simulation run
CI on f9b1638 failed slow-link seed 2 on a step that hung for 150 s, and
the stacks that would say where were left on the runner.
2026-10-05 00:13:42 +02:00
nvrl a68dae9422 fix(fuse): a kernel notice sent from the runtime hands its worker's tasks on (B130) 2026-10-05 00:02:56 +02:00
nvrl e743cd60cf fix(fuse): a folder whose mkdir is queued is listed from the database (B125) 2026-10-05 00:02:40 +02:00
nvrl f9b1638603 docs: record the 903452a account run and CI in the 3.0.0 status 2026-10-04 23:48:58 +02:00
nvrl 6539c7314b test(acceptance): a queue wait after a restart waits for the daemon to answer
A mirror target's root is plain local storage, so the durability case's
wait for the mount passed at once after the restart. The queue wait then
read `pdfs --json status` before the new daemon listened, took its null
"mount" for an empty queue, and the run's cleanup found no control socket.
2026-10-04 23:47:57 +02:00
nvrl 903452a15b fix(fuse): a stop wakes every drain worker (B180) 2026-10-04 23:00:07 +02:00
nvrl 4e0fd3c0b1 fix(fuse): a file read just as its create lands has its bytes (B179) 2026-10-04 22:52:00 +02:00
nvrl a6f019ebe6 fix(fuse): a folder trashed while sync is paused drops the creates in it (B178) 2026-10-04 22:51:47 +02:00
nvrl 0f3c06f982 fix(core): a folder listed just as its create lands is listed from the database (B177) 2026-10-04 22:41:57 +02:00
nvrl 4f141c78ca fix(fuse): a write closed just as its file's create lands is queued (B176) 2026-10-04 22:31:49 +02:00
nvrl ce60e006ac fix(fuse): a file replaced just as its create lands is trashed on Drive (B175) 2026-10-04 22:24:28 +02:00
nvrl 1612a9d07a fix(core): a file renamed just as its create lands keeps its access (B174) 2026-10-04 22:15:01 +02:00
nvrl 378667fb37 fix(fuse): a file deleted just as its create lands is trashed on Drive (B173) 2026-10-04 21:51:52 +02:00
nvrl 3b81cc5240 test(fuse): a simulated daemon does not ask the real API for its share id 2026-10-04 21:51:36 +02:00
nvrl d850265225 fix(fuse): a file renamed while its upload is read back keeps its new name (B172) 2026-10-04 21:34:20 +02:00
nvrl e32f72bfca fix(core,fuse): a deleted file's trash is not held back by its create (B171)
A file deleted after its create was sent keeps the op as a trash. The op
kept the create's attempts and backoff, and the drain recorded the failure
of a create attempt still on the wire on the row that was by then the trash.
The trash waited out the create's backoff.

The trash starts due, as a create that becomes a revision does, and the
drain records a failed attempt only if the op is still the kind it ran as
(`Db::record_attempt_failure`).
2026-10-04 21:23:10 +02:00
nvrl 2502af6e3c fix(fuse): a write opened as an upload lands takes the landed base (B170)
An upload that landed let go of its blob before the tree had its node. A
write open in between took the base it sampled at its start, the revision
the upload replaced, found none of its bytes cached, and queued an
incomplete revision; the next write open failed with EIO.

The drain keeps the landing node aside until the tree has it, and a write
open with no queued revision takes its base from that node, or from the
tree as it is when the handle goes in. The fake Drive can hold the drain
in that window (`hold_next_landing`).
2026-10-04 21:14:09 +02:00
nvrl 4757b957d2 fix(fuse): end a mirror pass at the stop (B169)
A stop that came while a mirror pass was uploading let the pass go on to
the next level of folders until the stop gave up on it and the runtime's
end cancelled the uploads under it. Once the daemon is stopping, a batch
starts none of its waiting ops, lets the ones sending finish, and ends
the pass; the remote walk and a new pass stop too.
2026-10-04 21:01:38 +02:00
nvrl 8e54ed0517 fix(fuse): do not send a file removed with its folder again (B168)
A delete of a file whose create was on the wire withdraws the create into
a trash. When its folder was removed too and that trash landed first,
Drive refused the create, and the drain re-homed the deleted file in the
root; only its missing bytes stopped it. The withdrawn create's trash
then read the trashed folder back into the cache before finding nothing
to trash.

A withdrawn create is no longer re-homed, and its trash is done once the
folder it was sent to is trashed or gone.
2026-10-04 20:42:04 +02:00
nvrl 9bbc994a4f perf(fuse): rebuild the hydrated listings in one pass (B167)
The hydrate rebuilt each listed folder's listing with a pass over every
entry. With 4,600 listed folders and 37,000 entries that took 1.7 s at
every start before the mount came up; one pass grouping the entries by
parent takes 3 ms.
2026-10-04 20:27:58 +02:00
nvrl ffa0fe698d test(fuse): a listing from before a create commits keeps the landed file (B166)
The account run's 0-byte read of a new file was B166 too: Drive's
listing had the node from before its create was committed, and the
queue no longer held it when read. The fake Drive can now hold a
folder's listing on its way back with one file listed unrevised.
2026-10-04 20:17:49 +02:00
nvrl 25c46cfe0a fix(fuse): list a file whose create lands while its folder is listed (B166)
A listing from Drive was overlaid with the queue as it was after Drive
answered. A create that landed in between was in neither, and a wide
folder on Wi-Fi dropped files that were never unlinked. The queue is now
read before Drive is asked too; a node that left it meanwhile is listed
as the DB has it.
2026-10-04 20:10:14 +02:00
nvrl 573a29e596 fix(fuse,cli): bound the stop's wait for busy threads (B165)
A stop joined every background thread for as long as each took. The sync
engine's mirror pass calls Drive with no deadline, and a stop that waited for
it was killed by systemd at TimeoutStopSec. The stop now waits up to 10 s for
the background threads and FUSE workers together, names any still running, and
the runtime shuts down within 2 s.
2026-10-04 19:12:19 +02:00
nvrl ce2bc0ac84 fix(fuse): list a new file once while its create lands (B164)
A listing from Drive overlays what is still queued for the folder. A create
Drive has made but whose answer is on its way, or which the drain is still
reading back, was in both: Drive's node under its new uid and the queue's
stand-in, under one name. A node queued in the folder now holds its name there.
2026-10-04 19:03:33 +02:00
nvrl b37d5e7928 fix(fuse): do not take a new file Drive lists before its commit as empty (B163) 2026-10-04 18:51:39 +02:00
nvrl 18587124ac fix(fuse): never adopt a node we trashed as an unanswered create's twin (B162)
A folder removed and made again met the old folder while its trash was on
the wire and adopted it, so the new folder's contents went into the old
one and were trashed with it. The twin search now skips hidden nodes, as
listings do.

The fake Drive can hold a trash (hold_next_trash).
2026-10-04 18:26:45 +02:00
nvrl cd18029944 fix(fuse): keep a move that lands while its create is read back (B161)
adopt_real_uid asked whether a move was queued only after reading the
node back. A move that landed in between left no op, and the answer still
had the old name, so the mount showed the file under it. Ask first.

The fake Drive can hold a read's answer (hold_answer_to_next_read) so the
test can land the move while the answer is on its way.
2026-10-04 18:20:56 +02:00
nvrl d6dd41407f fix(fuse): wake the drain when a folder is made (B160)
Queueing a folder's create left the workers asleep until their 30 s idle
poll; every other enqueue woke them. A file's create still waits for the
release that attaches its bytes.
2026-10-04 18:14:00 +02:00
nvrl 60445d3b26 fix(fuse): list again when a listing changed while it was published (B159)
A lookup in the root publishes the "Shared with me" entry only if the root
listing did not change while its plan was read, and answered EAGAIN when it
had. Right after a restart the root is listed again as the first lookups come
in, so a write could fail with "Resource temporarily unavailable".
ensure_children now takes the listing again, up to four times.
2026-10-04 17:50:39 +02:00
nvrl c2f93449ad fix(core): drop a rename replaced while its failure was being recorded (B158)
B141 drops a rename that fails with a newer one queued behind it. The
worker records the failure and releases its claim in two steps, and a
rename queued between them waited behind the old one, which was kept
with a backoff. Released, the old one was not due and the new one was,
so the new one landed first and the old one then put the first name
back.

Releasing the claim now drops the old rename too when a newer one is
queued, and both checks match the node by local id as well as by uid.
2026-10-04 17:23:51 +02:00
nvrl 4676a4e32b fix(fuse): cache an upload whose read-back failed, and know it as ours (B157)
When the read-back after an upload went unanswered, the drain let go of
the staged blob without caching it. A partial write opened next had
nothing to fill its gaps from and queued an incomplete revision, so the
write open after it was refused with EIO.

The bytes are now cached under the size and mtime the tree still has,
which is the base the next write takes. Their SHA-1 stands in for the
revision id the read-back did not bring, so that write chains onto the
upload and does not fork a conflict copy of itself.
2026-10-04 16:20:54 +02:00
nvrl 16fca10e6c perf(fuse): drain with 16 workers, and wake the idle ones when an op lands 2026-10-04 15:05:17 +02:00
nvrl dbc4d9a8c5 fix(core,fuse): a node written under its stand-in after landing updates its landed row 2026-10-04 13:38:54 +02:00
nvrl 9c93860cd8 refactor(core): rows and ops name a node's parent by local id; a landing rewrites nothing below it 2026-10-04 13:26:19 +02:00
nvrl 85ec91d6b3 fix(fuse): adopt a twin holding bytes an earlier attempt of the create sent (B156) 2026-10-04 13:14:29 +02:00
nvrl d5d3b2da3f refactor(core,fuse): find a queued op by its node's local id under either uid 2026-10-04 13:10:19 +02:00
nvrl e5f3f612f8 refactor(core,fuse): a node made here goes by its local id until it lands (schema 41)
A file or folder made on the mount used to get a minted `local~<ms>-<n>`
placeholder. It now gets `local~<lid>`, a function of the row it is given
first (`Db::add_local_row`), so the stand-in never changes and always leads
back to the row, and through it to the real uid once the node lands.
`Db::lid_of` reads it without a lookup.

Migration V41 rewrites the placeholders still stored to their row's
stand-in: the row and its node_json, its children, the ops on it or into
it (uid, parent, sidecar uid, a rename's original parent), and its pins.

Step 6a of the plan for 3.0.0 phase 3.
2026-10-04 12:32:20 +02:00
nvrl 3f74b95b34 fix(fuse): wait for a create on the wire that holds the name (B154); a write whose answer was lost is not a conflict (B155)
B154 is a regression from the B153 fix: after a failure on a held name the
op woke the ops waiting for it, but the database cannot see a create on the
wire, so the op was due again at once and spent its waits in milliseconds.
No wake while another create on the wire holds the name.

B155: a revision upload that reached Drive but lost its answer left the
file at a revision this daemon never heard of, and the retry kept the write
as a conflict copy of itself. A complete write whose bytes the remote holds,
by size and SHA-1, is sent again instead.

The fake Drive gains hold_reply_to_next_create and
lose_reply_to_next_revision for the two new sim tests.
2026-10-04 12:19:06 +02:00
nvrl 7269cb5118 fix(fuse): keep a rename made as its create lands (B152); wake an op once the name it waits for is free (B153)
Adoption asks again under the tree lock whether a rename is queued, and
writes the node row through the lock's outbox, so a rename made while
the create landed is no longer undone in the tree and the row.

An op that finds its name held fails as NameHeld: it backs off by the
minimum without doubling, and wakes the waiters for its name again once
its failure is recorded, so a trash that landed in between is not missed.
2026-10-04 06:04:20 +02:00
nvrl 6d75a56b00 fix(fuse,core): adopt a twin holding the create's bytes (B127); trash the node of a withdrawn create that lost its answer (B151)
A retried create now adopts the file its lost answer made when Drive's
SHA-1 matches the staged blob, not only when the file is empty. A delete
after a sent create keeps the op as a trash that finds and trashes the
node that create may have made; a new create of that name waits for it.
The simulation's settle now reads the log, so known damage is explained.
2026-10-04 05:41:57 +02:00
nvrl eb1c105bbf docs: record the full a6f36d5 account run in the 3.0.0 status 2026-10-04 04:45:48 +02:00
nvrl 85e80d8aac test(acceptance): retry only the sources a busy mirror refused; a mirror-to-mirror move carries an unsynced copy along 2026-10-04 04:45:48 +02:00
nvrl 23dc5c3723 fix(fuse): land a conflict copy in the root when its folder is gone (B150) 2026-10-04 04:45:48 +02:00
nvrl 50ae25bb88 fix(fuse): queue pdfs rename and move behind the mount's changes (B149)
With changes through the mount queued, `pdfs rename` and `pdfs move` went
straight to Drive ahead of a rename the mount still had queued, which then
landed and put its name back: the account run's B121 case on every on-demand
mount. They are now queued whenever the mount would queue, or the node has
anything queued, and rewrite a still-queued create's target.

The B113 acceptance case no longer requires creates to reach Drive at once;
under local-first they are queued and the moves rewrite them.
2026-10-04 04:45:48 +02:00
nvrl 9b2b9f24c5 feat(core): node rows name their parent by local id
Schema 40 adds nodes.parent_lid, backfilled from parent_uid. An upsert
and a new row's stub keep it in step, a folder's new row links the rows
stored before it, and a row merged into a landing folder hands its
children over. A restart places each node under its parent's row as it
is now, so a child whose stored node still names a landed folder's
placeholder lands under the folder's real uid.
2026-10-04 04:45:47 +02:00
nvrl ae6650f7e0 feat(core,fuse): queued ops name their node and folder by local id
Schema 39 adds pending_op.lid and parent_lid, backfilled from nodes. Every
op queued or retargeted gets both, and a create gets its row before it
is queued. The claim reads a parent's uid through parent_lid, and a
landing folder rewrites the ops made inside it by local id instead of by
its placeholder string. Ops queued against a row Drive listed follow it
when it is merged into the landing one.
2026-10-04 04:45:47 +02:00
nvrl f029e652a1 fix(core): keep a folder's pin when the folder lands (B148)
A folder pinned before its mkdir landed kept the placeholder uid in
pins.uid, which named no node once the landing moved the row to the
real uid. The landing now moves the pin with it.
2026-10-04 04:45:47 +02:00
nvrl 9b3651e4e8 fix(fuse): put a deadline on every remote call the drain makes
create_folder, trash_nodes, the twin listing and the uploads waited on
Drive without a limit, so one call that never answered held a drain
thread for good. They now give up after INTERACTIVE_CALL_TIMEOUT, and
uploads after that plus their size at 32 KiB/s; a missed deadline reads
as RequestTimeout, so the op backs off like any lost link.
2026-10-04 04:45:47 +02:00
nvrl 0dc476dfb7 test(sim): a steady Wi-Fi run and a drain-speed measurement
one_client_on_a_slow_link runs the Wi-Fi faults without outages and has
to drain within each settle. a_thousand_files_drain_as_fast_on_wifi_as_on_lan
times 100 folders and 1,000 files on LAN and Wi-Fi; it only prints for
now and runs when PDFS_SIM_MEASURE is set.
2026-10-04 04:45:47 +02:00
nvrl de258cbd60 ci(aur): retry the AUR host key scan
ssh-keyscan sometimes returns no key from the runner, which failed the
AUR publish for 2.8.0 and 2.10.0 at the fingerprint check. Retry the
scan up to five times with a longer timeout before checking the pin.
2026-10-04 03:43:30 +02:00
Nils Pukropp 4ea18e9187 chore(aur): update packages to 2.10.0 2026-10-04 01:42:24 +00:00
nvrl a6f36d56f3 feat(core,fuse,cli,gui): report changes Drive refuses as sync issues
A change Drive refuses because the storage is full, access was taken away,
its folder is gone, a folder limit is reached or the change was rejected
used to look like any other failure until six attempts had passed. The
drain now sorts the refusal into an issue and stores it on the queued op
(schema 38), so it needs attention at once in the app, tray and CLI. The
change stays queued and is retried; nothing is deleted.

The queue listing carries the issue and whether the op has file content.
ExportPendingOp copies that content to a new file: the Sync page offers it
as an Export button next to each issue, and the CLI as
`pdfs sync export <id> <dest>`. `pdfs sync issues` lists what needs
attention, and `pdfs sync queue` shows the issue per change.

docs/MILESTONE-3.0.0-STATUS.md tracks what is left for 3.0.0.
2026-10-04 03:18:57 +02:00
nvrl 77d0f254fc fix(fuse): never adopt a node another queued create is making
A file renamed away from a.txt while its create was on the wire, and a new
a.txt made after it, could both land on the first file's node: the second
create found a fresh, empty a.txt and took it for its own unanswered create.
The first file's rename then carried the second one's bytes away.

A create now leaves alone a node that another create is making under the
same name, and one the tree already knows. It waits for the name like for
any other queued change (docs/BUGS.md B147).
2026-10-04 03:15:50 +02:00
nvrl 4eccdeeda6 test(acceptance): retry a move while a mirror folder is busy syncing 2026-10-04 02:49:33 +02:00
nvrl 473842eefc fix(fuse): keep parked creates, renames and freed names from racing the drain
- B144: when Drive says a name is taken but nothing local holds it any more,
  the rename, move or create is sent once more under the wanted name before
  falling back to a conflict name. The trash that freed it could land between
  Drive's answer and the check.
- B145: the park sweep no longer drops a parked create whose node row is not
  written yet. It waits a minute, and keeps open files, before taking a
  missing row for a deleted file. The account run's B70 check failed with
  EBUSY on this.
- B146: a rename queued while an upload's read-back is on the wire is kept;
  the tree no longer takes back the name Drive still had.
2026-10-04 02:49:33 +02:00
nvrl 5e0f30cc2c feat(fuse,core): record namespace changes locally and send them from the queue
A new folder, a new file, a rename or a delete through the mount no longer
waits for Drive: it is recorded locally, the call returns at once, and the
drain sends it as it does offline. `"local_first": false` in config.json
brings back the old path for this release.

Fixes found by the simulation runs on the way:
- B129: a file deleted while its first upload is on the wire is trashed once
  the upload lands.
- B132, B140: files made in a new folder survive a restart; a landed folder
  hands its queued children over in the stored node too.
- B138: a create waits for the rename or trash that frees its name.
- B139: a file closed while its first upload lands keeps its last write.
- B141: a rename a worker holds stays queued, and the newer one waits behind
  it instead of racing it.
- B142: a rename of a file whose create lands meanwhile goes to the uid Drive
  gave it instead of answering EBUSY.
- B143: a create whose read-back fails as the link drops is adopted before
  the next op for it is sent, so its next write is not a conflict copy.
2026-10-04 01:50:38 +02:00
nvrl b3e33a7002 fix(core): never give out a pending_op id twice
A plain rowid is the largest one plus one, so a trash queued in the same
transaction that dropped the newest op took its id. The drain retires the
op it sent by id, so a rename landing deleted the trash queued over it and
the file stayed on Drive (B137). Schema 37 rebuilds pending_op with
AUTOINCREMENT, keeping every id and index.
2026-10-03 23:44:12 +02:00
nvrl f6456829fb chore(release): 2.10.0 2026-10-03 21:57:23 +02:00
nvrl 327f538fed test(core): migrate a database 2.8.2 wrote, with ops in every state
A dump of a database the 2.8.2 build wrote, with queued ops due, parked,
failing, deferred for access and claimed, and creates queued below a
folder made offline. Its migration checks every column of the queue and
the sealed revisions, as the real-database test now does too.
2026-10-03 21:55:15 +02:00
nvrl ad8b225cfd refactor(fuse): one tree for every mount
On-demand sync folders kept their own State, which the drain, the
event applier and the sweeps had to visit one by one (B74). Every mount
now serves from the primary's tree. A session keeps only its root, which
the kernel knows as inode 1, and its notification channel; work that
changes the tree tells every mounted session. Size upgrades become
daemon-wide, and moves compare mounts by root instead of by state.
2026-10-03 16:53:31 +02:00
nvrl ea27f2bcd0 test(core): migrate a copy of a real database
An ignored test takes a copy of the database named by PDFS_MIGRATE_DB
with VACUUM INTO over a read-only connection, migrates it, and checks
that every queued op survives unchanged, every node keeps its row
number as its lid, and no node is lost or doubled.
2026-10-03 16:53:31 +02:00
nvrl e4062cc97b feat(fuse,core): number inodes by local id
A node's inode is now derived from its row's local id instead of a
per-run counter, so the same node keeps the same inode across restarts
and across every place that interns it. A lid handed out before the
row is stored reads as no node.
2026-10-03 16:41:26 +02:00
nvrl c34cc93c45 fix(fuse,core): keep a rename made while its create is on the wire
A rename of a node whose create is still queued rewrites the create's
target, but the drain had already copied the op. When the create
landed, the row was deleted unread and the rename was lost (B136).

finish_create now learns where the create was sent and where it landed,
and queues a rename of the real uid when the row has moved on since.
adopt_real_uid keeps the tree's name and folder while that rename is
queued. A landed folder's children also stop naming its placeholder as
their parent in memory, which put the placeholder back on their rows.
2026-10-03 16:20:00 +02:00
nvrl 7e3df7602d feat(core): give every node row a local id that never changes
First step of phase 2 of docs/MILESTONE-3.0.0.md. Schema 36 rebuilds
`nodes` with `lid INTEGER PRIMARY KEY AUTOINCREMENT`. Each row takes its
old rowid as its lid, so the search index, keyed by rowid, stays valid.
AUTOINCREMENT keeps a deleted row's lid from coming back. `uid` stays
unique and becomes nullable, for nodes that are not on Drive yet.

A landed create's row now takes the real uid in place, keeping its lid.
When a listing had already stored the real node, its content is kept
under the placeholder's lid, which the node was known by first.

Nothing reads the lid yet apart from `Db::lid_of`.
2026-10-03 15:10:51 +02:00
nvrl 61a0d31408 chore(release): 2.9.0 2026-10-03 15:00:38 +02:00
nvrl 644a47eb36 fix(ci): quote the simulation command so the workflow parses
The ': ' in 'sim:: --' read as a YAML mapping, so GitHub rejected
ci.yml and ran no job at all since 2c46d62.
2026-10-03 14:55:13 +02:00
nvrl cd90c566cd fix(fuse,core): renames across folders, replaced files, and writes after a create
Found by the seeded simulation runs (docs/BUGS.md).

- B122: a rename across folders goes to Drive as one move-multiple
  request, so a sibling holding the new name in the old folder no longer
  fails it with EIO. The drain uses the same request.
- B123: a second write open on a file just written no longer fails with
  EIO. An empty file counts as complete, our own renames, moves and
  online creates record their echo, a duplicated event is skipped, a
  rename keeps the file's mtime while its revision stays, and a landed
  upload is cached before open handles are rebased onto it.
- B124: a node put back after a failed replacing rename leaves `hidden`
  and its folder is listed again, so it shows in the mount.
- B131: a landed create is read back with the same short retry as an
  online create, and its bytes are cached under the real uid before an
  open handle is repointed at it.
- B135: an online rename settles the tree through the state guard, so
  the database has both folders unlisted before the reply. A lookup
  right after it no longer rebuilds the destination without the file.

The simulation runs no longer tolerate B122 or B131.
2026-10-03 14:50:12 +02:00
nvrl 1c262eb601 refactor(fuse,core): keep queued-op preconditions in the database
Phase 1 of docs/MILESTONE-3.0.0.md, without a change in behaviour.

- Own sealed revisions live only in the own_sealed_rev table. The
  in-memory map in front of it is gone, so revision_conflict gives the
  same answer before and after a restart.
- A queued rename records the node's original name next to its original
  parent, and keeps both across renames that replace the row. The drain
  logs when Drive has the node neither where the rename found it nor
  where it takes it.

The SDK's NodeUpdated event carries no revision id, so echoes of our own
changes stay recognised by the self-change timer.
2026-10-03 14:49:58 +02:00
nvrl bf73a395e5 docs: record B132-B134 from the simulation runs
- B132: a folder whose mkdir is still queued is gone after a restart.
  The root's listing, served offline, was invalidated when the link
  came back, and the restart listed it on Drive without the queued
  folder.
- B133: a file deleted while its upload is in flight can still come
  back as a recovered copy. B105's check goes by op id, and SQLite gives
  the next op queued the id the delete freed.
- B134: a file deleted online while the drain checks its write is kept
  as a conflict copy. The unlink trashes the file on Drive before it
  drops the queued write, so B105's check still finds the op.
2026-10-03 09:07:56 +02:00
nvrl a5138639a8 test(fuse): delete files as they upload in the simulation runs
The runs did not find B105 with its fix reverted. Drive lists a trash
later than the round trips take, so the drain never saw its own
client's delete while it still held the write.

The FakeDrive can now hold the next read of one node on its way to
Drive. A new profile, one-client-deletes, writes a file Drive holds,
waits for the drain to read the node back, deletes the file, and lets
the read go on once Drive shows the trash. A conflict the drain keeps
because a client's own file was trashed now fails the run as a false
conflict. With B105's fix reverted, seeds 1 and 2 fail on it.

Outside the step, such a conflict is counted as B134 when the copy is
named after the file: the drain looked after an online unlink had
trashed the file on Drive but before it dropped the write. Seed 3 of
three-clients came up with it once.

Half the time the step makes the file again before the read goes on.
That is B133: the new file's write takes the op id the delete freed,
and B105's check finds it. The runs count it.

The runs also count B132, an ENOENT after a restart that restored
queued ops, in a seed that lost the link. Seed 4 of one-client-flaky
failed on it. The known-bug matchers now also see what was logged
before the call.
2026-10-03 09:07:56 +02:00
nvrl 988b78e810 docs: record the bugs the simulation runs found (B122-B131)
The first simulation runs found ten open bugs. They are documented here
and not fixed yet, as phase 0 of docs/MILESTONE-3.0.0.md changes no
behaviour. The runs count each one and end the seed where the mount
leaves the model behind.

- B122: a rename across folders fails with EIO when the source folder
  holds the new name.
- B123: a second write open on a file just written fails with EIO.
- B124: after a replacing rename fails, the file it was replacing is gone
  from the mount.
- B125: a folder made offline fails with EIO once its listing is
  invalidated.
- B126: a file created offline under a name an offline rename freed
  lands as a conflict copy.
- B127: a file created offline lands twice when the answer to its upload
  is lost.
- B128: a file created offline over one deleted offline can be lost.
- B129: a file deleted or replaced while its create is on the wire stays
  on Drive.
- B130: a read of a file that a remote change invalidates can hang the
  daemon. The event sync blocks a runtime worker in inval_inode on the
  read's page lock. Nothing then drives the runtime's timers, which the
  read's fetch is waiting for.
- B131: a write to a file whose create could not be read back lands as a
  conflict copy. The create's op is retired before the read-back, so a
  failed read-back leaves the tree on the placeholder's mtime and no
  revision id.
2026-10-03 07:47:02 +02:00
nvrl 2c46d62429 ci: run the simulation runs outside pull requests
A `simulation` job runs the seeded simulation runs of
docs/MILESTONE-3.0.0.md section 8.2 on pushes to main and manual runs. They
mount FUSE against the in-memory FakeDrive, so they need fuse3 but no
account. Like the acceptance suite they stay off pull requests. The job
has a 30 minute limit, and the runs' own watchdog turns a hang into a
failed seed well inside it.
2026-10-03 07:47:02 +02:00
nvrl 97743f17e3 test(fuse): add seeded simulation runs over the FakeDrive
The second half of the simulation harness in docs/MILESTONE-3.0.0.md
section 8.2. A run starts one or more real FUSE daemons on a shared
FakeDrive. Each client owns a folder `c<n>` that only it writes. A seed
then draws syscalls through the mounts, link flaps, restarts, pauses and
settles. Every syscall is checked against a model of the client's tree as
it happens. After the last step every link comes up and the run checks
no loss, POSIX behaviour, no false conflicts, convergence and liveness.

- sim::model is the POSIX model: the tree, what each call should answer,
  and how it changes the tree.
- sim::run has the profiles: one client on a good link, one on a flaky
  link with flaps and restarts, and three clients. PDFS_SIM_SEEDS sets
  how many seeds each runs, and PDFS_SIM_SEED replays one.
- sim::watchdog catches a call that never returns. It takes every
  thread's stack in the process, since no tracer can stop a thread
  blocked on a FUSE request. It then aborts the run's FUSE connections
  and writes the stacks next to the run's state.
- The daemons' logs at INFO and above are kept, so a known open bug is
  told apart from a new failure. Known bugs are counted, and the seed ends
  where the mount leaves the model behind. PDFS_SIM_KNOWN=fail fails on
  them instead.
- A restart retries opening the database while the last run still holds
  it.
- The FakeDrive restamps a node's modification time on a rename or move,
  as Drive does (B113).

The runs mount FUSE, so they are ignored by default. Run them with
`cargo test -p pdfs-fuse --lib sim:: -- --ignored --test-threads=1`.
Test-only; no behaviour change.
2026-10-03 07:47:01 +02:00
nvrl 3d10338807 test(fuse): run real FUSE daemons against the FakeDrive
mount() becomes a thin wrapper over mount_with(), which takes the
DriveApi to reach Drive through and a Host. The daemon's Host is
unchanged: stopped by SIGTERM or SIGINT, installed at the same point as
before, and indexing the user's home. A simulated daemon is stopped over
a channel and skips the home index, so several can share one test
process without walking the developer's home.

The account quota moves onto DriveApi. It was the only call outside the
trait that marks the link lost, so a simulated daemon would have gone
offline on it; the FakeDrive now also models a full account
(InsufficientQuota).

sim::daemon starts one daemon on its own state directory with a real
FUSE mount, and a smoke test (ignored; needs /dev/fuse) checks a local
write lands, a remote write arrives, and a write made offline lands once
the link is back.
2026-10-03 05:09:02 +02:00
nvrl d53ba1be21 test(fuse): add an in-memory FakeDrive with Drive's known faults
The first half of the simulation harness in docs/MILESTONE-3.0.0.md
section 8.1. FakeDrive is one shared server; each simulated daemon reaches
it through a FakeClient that implements DriveApi and carries its own link
state, latency, faults and an SDK-style entity cache.

The faults are the ones the account runs met: listings that lag (B118,
B48), renames and moves refused for a stale name hash because the cache
is only emptied by events (B111, B121), names that refuse a replace (B13,
B104, B106), events delayed, duplicated, reordered and echoed (B70, B105),
requests that stall, fail, or change Drive and lose their reply (B107,
B121), and non-uniform block sizes (B84, B85, B87).

Test-only; no behaviour change.
2026-10-03 04:51:35 +02:00
nvrl c73d5c9436 refactor(fuse): reach Drive through a DriveApi trait
`Core` held a concrete `ProtonDriveClient`, so no test could make Drive
answer late, answer stale or not answer. This is the first step of
phase 0 in docs/MILESTONE-3.0.0.md.

`DriveApi` (`crates/pdfs-fuse/src/drive.rs`) covers the calls the sync
core makes: listings, events, creates, uploads, downloads, renames,
moves, trashes and restores. `ProtonDriveClient` implements it by
forwarding. `Core::drive` holds it, and every call of those methods now
goes through it. Sharing, Photos, devices, invitations and public links
stay on `Core::client`.

The trait takes readers and writers as trait objects, and
`impl dyn DriveApi` puts the client's generic signatures back, so call
sites read the same. Block reads go through `RevisionRead` instead of
the SDK's `RevisionReader`, which only the SDK can construct.

No behaviour change.
2026-10-03 04:37:37 +02:00
nvrl 8d92c69279 fix(fuse): keep an upload pending until its bytes are cached (B119)
The drain retired an upload's op first, and only then read the node back
and linked the staged blob into the content cache. For that one round
trip, the status reported an empty queue for a file that wasn't cached
yet, so anything that waited for an empty queue before reading the file
could still need the network. The account run of 2026-10-03 hit it over
a slow link: "regression B119" failed in the same second the upload
landed.

`Core::landing_uploads` now counts an upload from just before its op is
retired until its bytes are cached. The status adds it to
`pending_uploads`, and reads the queue before the count, so it always
sees the upload in one of them. A drop guard uncounts it on every exit
path.

The op still goes first. An op restored after a crash would re-send a
revision this daemon hasn't recorded as its own yet, and
`revision_conflict` would keep it as a conflict copy of the user's file.
2026-10-03 03:59:05 +02:00
nvrl e32959e40a fix(fuse): land a move right after a rename, and rename across folders quickly (B121)
The SDK caches each node, including the name hash it sends with the next
rename or move, and only an event evicts it. Renames and moves didn't
update that copy. So after a rename, the next change to the same node
went out with the old name hash, Drive refused it as out of date (422),
and the retry kept sending the stale hash until the event feed came
round, up to 10 seconds later.

- Every remote rename and move goes through `Core::rename_remote` or
  `Core::move_remote`. Both drop the SDK's copy of the node after a
  successful change and before each out-of-date retry, so the retry
  sends the current hash.
- A plain move was not retried at all, so a move right after a rename
  failed with EIO through the mount and with "out of date" from
  `pdfs move`. It now retries like a rename.
- The drain's combined move-and-rename also drops the SDK's copy once
  it lands.
- A rename that also changes folders through the mount replies only
  once both halves have landed. When the move fails, the file keeps
  its new name in the source folder, the tree is told so, and the
  rename reports EIO. A dropped link still queues the change.

B111's cause in docs/BUGS.md is corrected to the same SDK cache. The
acceptance case "regression B121" renames and moves a file at once
through the mount and through `pdfs`, and checks names and bytes.
2026-10-03 03:58:38 +02:00
nvrl 0b27c884a7 test(acceptance): turn B114 to B117 into regressions and ride out a dropped link
New live cases:

- regression B48, B80, B94, B95, B98, B102 and B111 replay the bugs
  their entries describe. B48 and B111 also run against the reference.
- regression B114 to B117 replace the open B114 and B115 cases, and
  fail with `check` when the bug comes back.
- regression B119: files written through the mount stay cached once
  they are uploaded.
- regression B86: after a mirror folder switches to on-demand, the
  mount lists what the mirror uploaded just before.

`--journal-check` also counts three warnings as errors: a conflict
copy (B113), a FUSE job held for a long time (B110) and an authority
re-read because the local tree lacked it (B117, B120).

A timeout no longer ends a target when the mount still answers a stat
and a listing. Cases that make many round trips get twice the limit.

A write is queued when the kernel releases the file, after close(2)
has returned, so cases now wait for the ops they expect
(`Daemon.queued`). "regression B102" saw one revision of two because
it read the queue once.

On flaky Wi-Fi, the run crashed twice on a read that the daemon
correctly failed with EIO once its read budget ran out. The end-of-run
read-back and the mode matrix's file checks now record a result
instead. The mode matrix tries such a read once more after the daemon
is back online, and notes it.
2026-10-03 02:10:49 +02:00
nvrl 37c04f1e69 fix(fuse,core): fix B114 to B120 from the account runs
- B114: `pdfs rm`, `pdfs rename` and `pdfs move` tell each kernel
  session that the old name is gone, so it stops resolving at once
  instead of for up to 30 seconds (`Core::forget_everywhere`,
  `Core::relocate_everywhere`).
- B115: `pdfs ls` and the app's file browser wait up to 10 seconds for
  the listing's size fetch, as `stat` does, so they show a file's real
  size instead of its encrypted one.
- B116: trashing a folder also drops the creates queued in its
  subfolders. A parked one stayed queued with its bytes, and a due one
  would have landed in the root.
- B117: `pdfs move` and `pdfs rename` move the node in each mount
  instead of forgetting it, so its queued upload keeps its path in
  `pdfs sync queue` and is not put off once after the resume.
- B118: when a create or mkdir reads back nothing, the mount asks again
  and then queues it as it does offline, instead of failing with ENOENT
  for something Drive has made.
- B119: a landed upload keeps its staged bytes as the file's cached
  content, unless it is larger than a sixteenth of the cache, so
  reading a file just written does not need the network.
- B120: a landed folder's row passes to its real uid in the same
  transaction, so a file queued inside it is not put off for five
  seconds.

docs/BUGS.md records B117 to B120. The account run of 2026-10-03
verified B114 to B117 and B119 in My files.
2026-10-03 02:10:23 +02:00
nvrl 625776081b test(acceptance): cover B113, B88 and the closed pipe, and report more readably
New live cases:

- regression B113: files renamed and moved while sync is paused upload
  without a conflict copy.
- regression B88: pdfs mkdir, rename and rm by local path, the
  mount-root guard, a path outside every mount, and the refusal in a
  mirrored folder.
- pdfs output into a closed pipe exits 0 and prints nothing on stderr.
- open B114 and open B115 check two bugs that are still open. While
  one reproduces, its case ends as "known", which is listed but does
  not fail the run.

The console output names the pdfs version, the storage and the daemon
state of each target, and prints one aligned line per case, with its
details beneath. A failed case points to its docs/BUGS.md entry. The
run ends with a summary table, the slowest cases, the known issues
and the numbered failures. A queue wait now fails at once on a parked
op instead of waiting out its timeout.

docs/BUGS.md records B114, B115 and B116, which the first live run
found, and marks B113 and B88 as verified live.
2026-10-02 22:16:36 +02:00
nvrl 8cde79b8e3 fix(cli): stop quietly when stdout is closed
`pdfs ls | head` ended in a "Broken pipe" panic once `head` had its
lines, because std's `println!` panics on a failed write.

The CLI now shadows `println!` and `print!` with versions that exit 0 on
a closed pipe and report any other write error, such as a full disk,
with exit 1. Restoring the default SIGPIPE would also stop the panic,
but the same binary runs the daemon, which must not die when a socket's
peer goes away.
2026-10-02 21:16:34 +02:00
nvrl eb53d122cc fix(fuse,cli): let rm, rename and mkdir take paths in on-demand folders
`pdfs rm ~/Downloads/x` answered "is not under the mountpoint" while
`pdfs move` took the same path: the Delete, Rename and CreateFolder
handlers still read paths against the primary mount only (B88).

They now go through `route_change`, the router ResolveConflict already
used, renamed from `route_conflict`. It answers the request in the
inode space of the mount that owns the path.

An on-demand folder's root has a parent on Drive, so `pdfs rm
~/Downloads` would have trashed the whole synced folder from under its
mount. rm and rename refuse a path that names a mount's root.
2026-10-02 19:54:37 +02:00
nvrl c64252193f fix(fuse): keep the revision id of a file created through the mount
The write handle a create opens was given no revision id, so the drain's
conflict check fell back to (mtime, size). Drive advances a node's mtime
on a move or rename, so a new file moved before its first upload drained
landed as a (sync-conflict) copy next to its own empty original. Take the
minted node's revision id, and rebase handles still open on a queued
create onto the node it landed as (B113).
2026-10-02 19:25:33 +02:00
nvrl cc70a21c05 fix(aur): version -bin source filenames
makepkg reuses a cached source file of the same name, so the unversioned
proton-drive.service from an older build failed the 2.8.2 checksum. Carry
$pkgver in every file fetched from the tagged tree, like LICENSE already
did, and bump pkgrel to 2.
2026-10-01 23:50:06 +02:00
Nils Pukropp 644995cc56 chore(aur): update packages to 2.8.2 2026-10-01 19:49:42 +00:00
nvrl 50483bf3e1 chore(release): 2.8.2 2026-10-01 21:39:05 +02:00
nvrl 4caba0a34c chore(deps): require proton SDK 0.7.2
0.7.2 resolves each shared root with its own key. With 0.7.1, when
several accepted shares came from the same owner's volume, only the first
appeared under Shared with me and the rest were skipped with "missing
key" (B112, proton-sdk-rs#2).
2026-10-01 18:17:25 +02:00
Nils Pukropp e8554d6434 chore(aur): update packages to 2.8.1 2026-10-01 14:58:09 +00:00
Claude ba1fdd65be chore(release): 2.8.1 2026-09-30 17:02:31 +00:00
nvrl 0d4163b9bf fix(service): start with the graphical session, not default.target (#24)
default.target is reached before the desktop exports DISPLAY/WAYLAND_DISPLAY
to the user manager, so After=graphical-session.target never applied. The
daemon's first keyring call D-Bus-activated the Secret Service without a
display; on KDE ksecretd aborted, the first start failed, and every login
left a coredump.

Install into graphical-session.target instead, and use reenable when the
front-ends enable the unit so existing default.target.wants links are
replaced rather than kept alongside the new one.

Fixes #23
2026-09-30 19:01:50 +02:00
Nils Pukropp f3c7a809f4 chore(aur): update packages to 2.8.0 2026-09-30 02:15:19 +00:00
nvrl c1b5378045 fix(aur): download sources from GitHub, not the homepage
73e60f1 pointed url at proton-drive.narl.io, and all three AUR PKGBUILDs
built their source URLs from $url, so the release tarball, the binary
archive and the git clone were fetched from the homepage and 404ed. The
sources now come from a separate _repo on GitHub; url stays the homepage.
2026-09-30 04:13:06 +02:00
nvrl 3d6f53c16d chore(release): 2.8.0 2026-09-30 03:56:47 +02:00
nvrl 8ceee5285b chore(fuse): log how long each queued upload takes
"pending upload landed" now carries upload_ms, the time on the wire, and
total_ms, the whole op including the conflict check and gap-fill. A slow
drain then shows where its time goes without a profiler.
2026-09-30 03:55:48 +02:00
nvrl a181f1a5f0 fix(core,fuse): keep working through network drops
The mount only ever went from offline to online: nothing marked it
offline again, so after the Wi-Fi dropped every create, mkdir, delete and
rename called Drive, waited out the SDK's retries for up to two minutes
and answered EIO. Background loops kept polling the dead link.

Connectivity now lives in pdfs-fuse's new link module and goes both
ways. A call that gets no answer (a transport error or a request
timeout) marks the mount offline; a probe thread checks every 2 s,
backing off to 30 s, and on recovery re-lists folders served from the
database, retries failed operations and wakes the drain. Interactive
calls get a 20 s deadline, so a dead link costs one slow call instead of
many.

While offline:
- create, mkdir, unlink, rmdir and rename fall back to the offline
  queue instead of answering EIO;
- folders list from the database even when not marked listed, and are
  re-listed once the link returns;
- reads of uncached data wait up to 30 s for the link, then retry once
  within the 120 s read deadline;
- statfs serves the last quota instead of fetching, and quota refreshes
  are single-flight;
- the event and photo event loops wait for the link instead of polling.

A create whose request timed out may have landed anyway. When the drain
replays it and finds a same-named twin of the same kind, created within
the last two minutes and still empty, it adopts that node instead of
making a conflict copy. The drain also leaves alone a name still held
by a queued trash.
2026-09-30 03:25:00 +02:00
nvrl 7251f00e91 chore(deps): require proton SDK 0.7.1
0.7.1 sends TCP keepalives on its connections, so a link that dies
silently is noticed instead of leaving a request waiting on a dead
socket until its timeout.
2026-09-30 03:25:00 +02:00
nvrl cb38f79aa3 fix(fuse,scripts): log why a create finds no parent and fit the metadata floor to FUSE
A create that answered ENOENT left no trace in the log. It now warns
when the parent folder is no longer known, and when the parent left the
tree while its children were being listed, naming the parent inode and
the new name.

The acceptance script's metadata throughput floor was 20 operations per
second, which a FUSE mount cannot reach: each create and unlink waits for
Drive before it returns, about 350 ms apiece. On a FUSE mount the floor
is now 3 operations per second; other paths keep 20. The mount type is
read from /proc/self/mountinfo.
2026-09-29 21:22:54 +02:00
nvrl 54e9921efb fix(fuse): stop reads deadlocking on size upgrades and retry stale renames
A read that settles a provisional size invalidated the file's page cache
while the kernel held that read's page lock, so the worker waited on
itself forever (B110). Size upgrades now invalidate attributes only.

Renaming a node right after renaming it could be refused by Drive as out
of date, because the name hash read just before still held the old name.
Every remote rename now goes through Core::rename_remote, which retries
InvalidRequirements for up to ten seconds (B111).
2026-09-29 19:47:20 +02:00
nvrl f252b09757 fix(fuse): keep the size of a file open for writing across re-listings
B109: an online create seals an empty file on Drive, and until the last
close the written bytes live only in the open handle. A re-listing of
the parent, a size upgrade or a create landing re-interned the node with
Drive's empty size, the kernel capped reads at that size, and SQLite
reported "database disk image is malformed". State::keep_open_write_size
now carries the handle's size and local mtime over in all three places.

The acceptance script waits for My files to mount after a daemon restart
instead of failing at once.
2026-09-29 16:49:13 +02:00
nvrl a2992b3b9d fix(core,fuse): keep replaced and vanished files from failing saves, reads and syncs
Fixes from the account acceptance runs:

- B104: a reader open across a rename-over keeps working; the replaced
  node is unlinked like any other open file instead of forgotten.
- B105: a drain worker holding a revision withdrawn by an unlink no
  longer uploads it as a recovered-… conflict copy (Db::op_exists).
- B106: an open victim's queued ops are withdrawn at trash time, so a
  repeated editor save no longer fails with AlreadyExists; the staged
  blob goes with the last close.
- B107: a remote read gives up after 120 s with EIO instead of hanging
  its caller; the timer is built inside the runtime.
- B108: a mirror upload whose local file was deleted after the scan is
  skipped rather than counted as an error.

The acceptance script reports the failing line for non-assert errors,
ignores queue ops that predate the run, purges with delete-forever
first, and diagnoses a malformed SQLite database against a local replay.
2026-09-29 16:20:51 +02:00
nvrl 7b67db5ced fix(core,fuse,cli): harden writes, trash and cleanup found by account acceptance runs
The FUSE acceptance suite now runs on the signed-in account by default and
cleans up after itself; its first live runs found three daemon bugs.

- B101: a pwrite past EOF now authors the hole it leaves, so truncated bytes
  no longer come back from the base. A write attached to a create while that
  create uploads becomes a revision of the real uid instead of being dropped.
- B102: removing a folder drops the queued revisions of files below it, found
  through the node tree, with their blobs and in-flight uploads. `pdfs rm`
  now drops queued ops too.
- B103: each server call of a trash refresh times out after 120 s, so one hung
  call can no longer freeze the trash listing until restart.

Acceptance suite: account mode with a manifest, lock and reaper; cleanup by
uid; queued ops naming a test root count as leftovers; new edge-case,
throughput and B100/B101 cases; the wide-directory case is split from the
deep tree and gets a doubled budget. Adds `pdfs trash --json`.
2026-09-29 10:09:44 +02:00
nvrl 84c9ade8e3 fix(fuse): settle provisional sizes before reads and write opens
A file listed by the cheap enumeration reports its ciphertext size until
the size upgrade lands. Since B84 a read on that size fails with EIO, and
a write open started its handle at it. Reads and write opens now fetch
the real size first. A folder past the size-upgrade cap is queued instead
of dropped, so a large copy no longer leaves most folders provisional
(B100).
2026-09-29 07:37:41 +02:00
nvrl 9e26cd0285 chore(release): 2.7.0 2026-09-29 05:30:38 +02:00
nvrl 73e60f1151 chore: point homepage metadata at proton-drive.narl.io
The About dialog's website link, the README, and the package metadata
(Cargo, PKGBUILDs, AUR .SRCINFO, .deb, .rpm) now use the project
homepage. Source code, issue, and release links stay on GitHub.
2026-09-29 05:30:38 +02:00
nvrl 088e8f3b0d test(acceptance): cover moves between locations in the managed matrix
Every managed mode pair now also moves trees between the two folders in both
directions, moves several sources in one command, and passes a tree through
My files and out again. Each case waits for Drive before and after, then
checks the tree is only at the destination, byte for byte, with no conflict
copies. Refusal cases cover a taken name, a folder into itself, a mirror
source holding a symlink, and an on-demand file still open for writing.
2026-09-29 00:38:40 +02:00
nvrl 261d5dbf84 feat(core,fuse,cli): reuse cached content and take several sources in moves
- A mirror download first copies the file out of the content cache when the
  same revision is cached whole or block by block, so moving a file out of an
  on-demand folder into a mirror costs no traffic for what was already read.
- `pdfs move a b c DEST` moves every source, reports each failure and exits
  non-zero if any failed.
- Mirror-to-mirror moves rename with RENAME_NOREPLACE, so a name that
  appeared at the destination after the check is never overwritten.
- The local copy a move out of a mirror drops is checked again just before
  it is deleted; anything written into it meanwhile keeps the copy and is
  uploaded as new by the next pass.
2026-09-29 00:38:40 +02:00
nvrl 5f9d07aff1 feat(fuse,core,cli,gui): move between locations without transferring content
A move request with two absolute paths is resolved against every
location (My files, on-demand and mirror synced folders) and made as a
server-side move_node. The local side is levelled afterwards: mounts
forget and re-list, a mirror folder renames its copy and baseline along
or drops the copy once Drive holds it.

Anything only this disk holds is refused up front: queued ops, open
writes, unsynced or ignored mirror files and symlinks. The GUI Move
dialog gains a top-level list of locations.
2026-09-29 00:26:56 +02:00
nvrl 3fb7b9be6b chore(release): 2.6.2 2026-09-28 23:58:26 +02:00
nvrl 6c3a50ce7f fix(gui): let the Sync page use the full width
The transfer label did not ellipsize, so a long file name set the
column's minimum width and pushed it past the clamp. The page width
changed with every file.

The label now ellipsizes in the middle, and the Sync page drops the
clamp so Overview and Folders fill the space next to the sidebar.
2026-09-28 23:58:26 +02:00
nvrl f8304a0e86 fix(gui): keep Albums and Places selected when returning to Photos
Every visit to the Photos page loaded the timeline, and a finished
timeline load always shows the timeline. Leaving Albums or Places and
coming back therefore showed the timeline under the Albums or Places
toggle.

A visit now reloads the view the toggle selects. An open album or place
still loads its photos as before.
2026-09-28 23:58:26 +02:00
Nils Pukropp e63f39f8c2 chore(aur): update packages to 2.6.1 2026-09-28 21:44:18 +00:00
nvrl 6f100dbe5e chore(images) updated prompt image 2026-09-28 23:35:53 +02:00
nvrl 7c5ef269cd chore(release): 2.6.1 2026-09-28 23:35:14 +02:00
nvrl f13b6fc0ef feat(gui): add pdfs-prompt --preload for session autostart
--preload registers the resident GTK launcher and realizes its window
without showing it, so the first shortcut opens it as fast as later
ones. When a launcher is already running it exits without activating
it.

The application hold now happens in startup, which only the primary
instance runs. Taken before registration, it also kept every remote
invocation alive after it had forwarded its activation.
2026-09-28 23:35:14 +02:00
nvrl 3d61fc637b ci(release): reuse cached builds from main
The release job never hit a cache: its rust-cache key contained the job
name, tags cannot restore caches of earlier tags, and rpmbuild rebuilt
the whole workspace under its own compiler flags.

CI on main now saves shared debug and release caches that the release
workflow restores. Quality gates and the package build run in parallel,
and the GitHub release is created once both pass. The RPM spec skips
its cargo build when the caller passes --define "prebuilt 1".
2026-09-28 23:26:24 +02:00
Nils Pukropp 889b2aa4a9 chore(aur): update packages to 2.6.0 2026-09-28 21:22:30 +00:00
nvrl 8bd3867cb3 fix(gui): keep the launcher preview at its fixed width
A wrapping label asks for its whole text on one line as its natural
width, so a long location in the preview pane widened it, squeezed the
results list and stretched the window. Cap the preview labels' natural
width, limit detail values to three lines with a tooltip for the full
location, and keep the pane from taking extra width.
2026-09-28 23:04:48 +02:00
nvrl 9577387c2f chore(release): 2.6.0 2026-09-28 22:57:28 +02:00
nvrl 7a087b8827 feat(gui): rewrite the search launcher with a preview pane
The launcher (pdfs-prompt) now follows the app's theme: the theme code
moved into a shared theme.rs, the prompt's stylesheet lives in
resources/prompt.css on the libadwaita palette, the Proton-purple accent
pin is gone, and every activation re-reads the Proton theme and open_with
settings so changes in the app apply without restarting the launcher.

The window is rebuilt in src/hud/:
- one list with Recent, Pinned and Best matches sections; a click
  selects, a double-click or Enter opens, focus stays in the entry
- rows show a thumbnail, the name with its matched characters in bold,
  location, sync badge, size and age
- a preview pane with a large thumbnail, type, size, modified date,
  location and availability, plus Open (Enter), Show in Folder
  (Ctrl+Enter, via FileManager1) and Copy Path (Ctrl+C / Ctrl+Shift+C)
- thumbnails from the freedesktop cache, local decoding (written back
  to the shared cache) or the daemon, on two worker threads
- a history of opened files that fills the Recent section and adds a
  frecency bonus to search ranking
- the mount check before opening a Drive file runs off the main thread
  with a 2 s timeout, and Escape cancels a slow open
- rows can be dragged out as files
- the search debounce drops to 80 ms

New strings are translated in every catalog.
2026-09-28 22:53:46 +02:00
nvrl 7099106802 docs: show the Places map screenshot
Rename photos_mpa.png to photos_map.png and add it to the README
screenshot table.
2026-09-28 22:10:14 +02:00
nvrl 43385d36ee chore(images) added up to date images 2026-09-28 22:09:07 +02:00
nvrl 4b97f401cb docs: refresh screenshots
Replace the Photos screenshot with one of placeholder stock photos, add
Sync, conflict history and Computers screenshots, and drop the retired
Activity, Settings, Shared and restore-devices images from the README.
2026-09-28 22:08:16 +02:00
nvrl af869944cf docs: restructure and update documentation for 2.5.0
Split the README into a short front page and task-oriented pages under
docs/: installation, user guide, CLI reference, configuration and
troubleshooting, with an index in docs/README.md. Rewrite RECOVERY.md
around Continue This Backup Here and Restore Folders, and DEVELOPMENT.md
around running, debugging and releasing from source.

Bring ARCHITECTURE.md up to date with the read path, push events, the
thread map, conflict copies and the machine profile, and move the feature
design notes there. Collect planned work in ROADMAP.md. Link the threat
model from SECURITY.md and the translation rules from CONTRIBUTING.md.

Add scripts/publish-wiki.sh, which mirrors the user pages to the GitHub
wiki with rewritten links and only pushes with --push.
2026-09-28 20:04:22 +02:00
Nils Pukropp 4b90abf3b5 chore(aur): update packages to 2.5.0 2026-09-28 16:56:58 +00:00
nvrl 61266fc9b1 chore(release): 2.5.0 2026-09-28 16:37:23 +02:00
nvrl d18655538e feat(core,fuse,gui): push daemon events, polish Photos, adopt newer widgets
The app and the tray stop polling. pdfs-core adds Request::Subscribe: the
connection stays open and carries Event lines (status, transfers, and
Changed with a Topic), with a heartbeat while quiet. In pdfs-fuse, an
EventHub gives each subscriber a mailbox, code that changes something
publishes its topic, and a sampler publishes the status, transfers and sync
progress when they differ. The app follows the feed on one thread, paints
status and transfers as they arrive, and reloads the page on screen when its
topic changes, including after changes made with the pdfs CLI. A hidden
window keeps the changes and catches up when focused. Front-ends fall back
to polling a daemon that predates subscriptions. Refresh buttons leave the
page headers; F5 and Ctrl+R still reload from the server.

Photos gets a scrubber overlaid on the right edge of the timeline in place
of the slider and scrollbar. It shows the years, appears when the pointer
comes near or the timeline scrolls, names the month under the pointer and
jumps to it on drag. Upload becomes a split button whose menu holds the
Google Photos import. Tiles are pooled per row and rebound instead of
rebuilt, hover scales the thumbnail to 1.02 without a shadow, and tiles
lose their tooltip.

On libadwaita 1.8 or newer the app uses AdwSpinner, AdwToggleGroup,
AdwButtonRow and AdwShortcutsDialog, and keeps look-alike stand-ins on
older releases (src/compat.rs, widgets/compat.rs). It still builds against
libadwaita 1.5 and picks at run time, creating the newer widgets by class
name through GtkBuilder, so every package gets them. PDFS_STAND_IN_WIDGETS
forces the stand-ins.

The Sync and share-link icons draw in full again: GTK drops group
transforms from symbolic icons, so their coordinates are baked into the
paths. All catalogs are updated and complete.
2026-09-28 16:37:23 +02:00
nvrl da7ea0ec56 feat(core,gui): split Sync into views, tidy Activity, rework Computers
The Sync page gets Overview, Folders and History views under a switcher in
the header. Overview holds the status card, conflicts, transfers and the
queue; Folders holds the folder list, with Add Folder and a popover that
explains the sync modes; History is the former Activity page, which leaves
the sidebar. A banner in Folders and History points back to Overview when
changes fail or conflict.

Activity hides save-by-rename temporaries (pdfs-core gains
syncignore::is_scratch_name), with a toggle to show them again. Runs of
similar events fold into one expandable row, "Show Older" pages further back
through what the daemon keeps, and titles and details are translated
sentences built from the daemon's targets instead of its raw text.
Conflict copies say when they were made.

Computers lists every device in one group, this computer first with a
"This computer" pill; its row opens Sync's folder list. Another computer's
row browses its backup read-only in the shared file list, and its menu
offers Browse Files, Restore, Rename, "Continue This Backup Here…" (the old
identity adoption, now explained in its dialog) and Remove. Subtitles read
"last backup" or "no backups yet", and an empty list links to the download
page. DirEntry derives Default for the browse view's synthesized entries.

The sidebar shows badges for sync problems and waiting invitations, polled
every five minutes away from their pages. All catalogs are updated and
complete.
2026-09-28 15:14:59 +02:00
nvrl 127b71efd0 feat(core,fuse,gui): rebuild Trash and shared pages on one file list
The list and grid view from My files moves into widgets/file_list.rs as
FileList, so Trash, Shared with me and Shared by me share its columns,
selection, context menus, keyboard handling and list/grid switch instead of
each drawing its own rows.

Trash shows where each item came from and when it was deleted. Proton does
not expose the trash time through the SDK yet, so the daemon records when it
first sees a node in the trash (migration V35, trash_seen) and reports that;
the date becomes exact once proton-sdk-rs exposes Node::trash_time. Restoring
offers a Show action, and opening a trashed item explains that it needs to be
restored first.

Shared with me splits files and saved links into two views. Files show who
shared them, with a warning pill for unverified senders, the access they give
and when they were shared. Pending invitations sit in a banner that opens a
dialog to accept or decline each one. Several items can be left at once.
Saved links get their own list with open, copy and remove actions.

Shared by me gains a filter (all, links, people, expired links) and columns
for location, people and link state, with a copy button for live links.
Several items can stop being shared at once, the page refreshes after the
share dialog closes, and a reload rebinds only the rows that changed so the
selection survives.

All catalogs are updated and complete.
2026-09-28 14:35:37 +02:00
nvrl 2339b17d97 feat(core,gui): keep pages up while they reload
Pages no longer swap their content for a "Loading…" status on every load.
A new Loader keeps the last rows on screen and only shows the loading
state if the reply takes longer than 300 ms; a newer load always
supersedes an older one, so a late reply is dropped instead of painted.
Reloading what is on screen keeps it usable; loading something else greys
the old rows out and clears the selection, since their paths would
resolve against the new folder.

My files, search results and Trash now swap only the rows that changed,
so selection, scroll position and thumbnails survive a refresh. Activity
is one sectioned ListView: open conflicts first, then a section per day,
with the filter bar pinned above the list.

Polls give up after 5 s instead of 120 s. A daemon that is running but
doesn't answer now reads as "Not responding" in the app and the tray
rather than as disconnected.
2026-09-28 13:51:28 +02:00
nvrl bd4f797d1f feat(gui): polish pass over wording, icons and responsiveness
The Sync page gives each folder one card with its state, a Synced /
Online only switch and a menu for the rest; switching either way asks
first and the download prompt shows the free space on the disk.

Enabling, disabling and restarting the Proton Drive service ran
systemctl on GTK's main thread and froze the window. They now run in
the background, and every Retry button goes through one shared restart
helper.

Ten icons are bundled under resources/icons for glyphs no stock theme
has or that read ambiguously (sync, share, people, link, menu, map and
so on). A new test fails when the app or search prompt asks for a
symbolic icon that is neither bundled nor on a list checked against
Adwaita 50, libadwaita and GTK, or when a bundled icon is missing from
the resource manifest. The tray's sync icon, which Adwaita 50 no longer
ships, is now view-refresh.

Wording follows one glossary, recorded in docs/TRANSLATING.md: "Proton
Drive service" instead of "mount service" or "daemon", "Online only"
instead of "On-demand", "Proton Drive folder" instead of "mountpoint".
Dialog headings and buttons use title case and page titles match the
sidebar. Dates go through one formatter (app/dates.rs) and sizes
through i18n::human_bytes, and the cache label is translated.

The status tick stops while the window is hidden or minimized and runs
every fifth tick while it is unfocused, refreshing as soon as it gains
focus. "Sync complete" is announced only for large batches and only
when the window isn't focused. Smaller fixes: Empty Trash hides when
there is nothing to empty, copy buttons confirm with a check mark, the
sidebar status spins until the first report, and Photos moves Import
and Find Duplicates into a menu beside a labelled Select toggle.

All catalogs are updated and complete.
2026-09-28 13:26:59 +02:00
nvrl e872cad058 ci: move to Node 24 versions of checkout, upload-artifact and action-gh-release
actions/checkout@v4, actions/upload-artifact@v4 and
softprops/action-gh-release@v2 still target Node.js 20, which the runners
now force onto Node.js 24 with a deprecation warning. Their current majors
(checkout v7, upload-artifact v7, action-gh-release v3) run on Node.js 24.
2026-09-28 12:16:54 +02:00
Nils Pukropp 081c8fb889 chore(aur): update packages to 2.4.0 2026-09-28 07:33:52 +00:00
nvrl da35a05b84 chore(release): 2.4.0 2026-09-28 09:16:54 +02:00
nvrl a90eeecda9 feat(core,fuse,gui): find photos that look alike
The duplicate finder gains an Exact / Similar switch. Similar asks the
daemon for PhotoSimilar, which starts a background pass that hashes the
thumbnail of every shot not hashed yet and replies at once with the sets
found so far and how far hashing has got. The dialog shows a progress bar
and asks again until the pass is done, then lists the sets.

The pass takes thumbnails from the server only: a photo without one is
skipped for the run instead of downloaded whole, and the dialog says how
many photos could not be compared. Decoding and hashing are spread over
the machine's cores.

Every similar photo starts ticked Keep, since photos that look alike can
be different shots. A similar photo stands for its whole shot, so one not
kept goes to Trash with the rest of its shot.
2026-09-28 09:16:54 +02:00
nvrl c98ed0d83b feat(core): perceptual hashes for finding similar photos
A 64-bit difference hash of each thumbnail, stored in the new
photos.similar_hash column (schema 34), and the queries the similar-photo
finder needs: what is left to hash, progress, and the sets of shots that
look alike. Sets made only of byte-identical copies are left to the
duplicate finder. A refresh keeps a hash unless the photo's bytes changed.

The daemon pass, the control request and the GUI are not built yet; see
PLAN.md.
2026-09-28 09:16:54 +02:00
Nils Pukropp c46b2c1082 chore(aur): update packages to 2.3.0 2026-09-28 01:42:04 +00:00
nvrl fde9c2d271 feat(core,fuse,gui): Proton theme, photos on the map and an opt-in street map
- The Proton accent preference becomes a whole Proton theme that also
  holds against a GTK theme imported from gtk.css; folders get Proton's
  purple folder icon while it is on.
- Places map markers show one of each town's photos with a count, and
  zoomed in on a town every photo sits where it was taken
  (Request::PlaceLocations). Photos taken on one spot are listed in a
  popover. "Show on map" in the photo viewer goes to the Places map.
- The built-in map draws country borders, sea and land in their own
  colours.
- An opt-in street map from OpenFreeMap (vector tiles drawn with cairo,
  cached on disk up to 256 MB), with the OpenStreetMap credit on the map
  and in About.
- Translations for all catalogs; changelog folded into 2.3.0.
2026-09-28 03:12:57 +02:00
nvrl 4fd14eb745 chore(release): 2.3.0 2026-09-28 02:12:59 +02:00
nvrl b3c03a7162 feat(core,fuse,gui): places on a world map
The Places view gets a Grid/Map toggle. The map shows a bubble per town,
sized by its number of photos, over the outline of the world's land. Towns
whose bubbles would overlap merge into one that counts all their photos, and
clicking it zooms in until they part; clicking a single town opens it like a
card does. Drag to pan, scroll or pinch to zoom, and the buttons zoom in,
zoom out or frame every place again. Hovering names the town, or the first
few towns of a merged bubble.

No tile server is involved: none that is free has terms allowing an app like
this one to use it. The map is drawn from Natural Earth's 1:50m land
polygons (public domain), trimmed and simplified by
scripts/natural-earth-land.py into crates/pdfs-gui/data/land.txt, in Web
Mercator. The About dialog credits Natural Earth.

PlaceInfo carries the town's latitude and longitude; a daemon older than the
map sends none, and its towns are left off the map.
2026-09-28 02:12:59 +02:00
nvrl 8749d6c2c6 feat(core,fuse,gui): show how far photo locations have been read
The first timeline refresh after the Places update reads the metadata of
the whole library, and until it is done the Places view has nothing to show
and no sign that anything is happening.

The refresh now records how many photos it has resolved out of how many,
counting photos read by an earlier pass as done. Response::Places carries
it as mapping (absent from older daemons, which parse as None). The Places
view shows "Mapping photos: 120 of 480" with a bar while it is set, asks
again every two seconds, and loads the new places once the pass ends. An
empty listing during the pass says the photos are being mapped rather than
that there are no places.
2026-09-28 02:12:59 +02:00
nvrl 4ee2aed29b fix(gui): scroll the "On this day" strip with the photos timeline
The strip sat above the timeline's list and was hidden as soon as the list
left its top. Hiding it grew the viewport, which moved the list back to its
top and brought the strip back, so scrolling up near the top shook the whole
page between the two heights.

The strip is now the first row of the list (GalleryRow::Memories), so it
scrolls away with the grid and never resizes the viewport. It is still only
there atop the whole, unfiltered timeline.
2026-09-28 02:12:59 +02:00
nvrl 753056418c feat(core,fuse,gui): places view from photo locations
Photos now know where they were taken. proton-drive-rs 0.7.0 reads the
Location from a photo's extended attributes, and the timeline refresh stores
it with the photo (schema v33). The migration marks every photo unresolved
once, so the next refresh backfills locations from node metadata without
downloading anything.

Towns are looked up offline: pdfs-core bundles GeoNames' cities15000 table,
trimmed by scripts/geonames-cities.py, and files each photo under the
nearest town within 50 km. The new PhotoPlaces and PlacePhotos requests list
the towns and page one town's photos.

The Photos page gets a third view, Places, with a card per town (newest
photo, town, country, count). A place opens in the gallery like an album.
Country names come translated from the iso-codes catalogs, now an optional
dependency. The About dialog credits GeoNames (CC BY 4.0).
2026-09-28 02:12:59 +02:00
nvrl 2757977fd6 feat(gui): one Filters popover for the photos timeline
Favorites, "not in an album" and the month move from separate pills and a
dropdown into a Filters popover next to the kind tabs. The kind tabs stay
in the row because their counts are worth a glance. The button carries a
badge with the number of active filters, kind included, and the popover
has a "Clear filters" action that resets everything in one reload.

Flipping a filter while a page was still loading used to drop the reload,
leaving the timeline answering the old filter. The request is now queued
and the stale page is discarded.
2026-09-28 02:12:59 +02:00
nvrl 2f14c1688f feat(core,fuse,gui): find and trash duplicate photos
A "Find duplicates" button in the Photos header opens a review of every
set of files that share a content hash. Each set shows its copies side by
side with a Keep toggle; the copy in the most albums, then in the larger
RAW+JPEG group, then the oldest, is kept by default, and a set can't lose
its last kept copy. The copies not kept move to Trash one file at a time.

- core: Db::photo_duplicates, Request::PhotoDuplicates, and a files_only
  flag on TrashNodes (defaults to false, so old clients still trash whole
  groups).
- core: deleting a group's representative now clears the group key of its
  remaining files instead of leaving them pointing at a missing uid.
- fuse: handlers for both requests.
2026-09-28 02:12:59 +02:00
nvrl 7dbf818d83 feat(core,fuse,gui): "On this day" strip above the photos timeline
Add a PhotosOnThisDay control request, served from the stored timeline:
photos whose local capture date matches today's in earlier years, one
row per group. The GUI shows a card per year ("3 years ago") with the
year's newest photo as its cover; clicking a card scrolls the timeline
to that day. The strip is revealed only at the top of the whole,
unfiltered timeline, so it gives the grid its room back once you
scroll or filter.
2026-09-28 02:12:59 +02:00
nvrl c3ec82392a fix(gui): keep the photos timeline at its top across a relayout
Anchoring on the first photo row after a relayout scrolled the heading
above it out of view, so the timeline opened a heading's height down.
2026-09-28 02:12:58 +02:00
nvrl ce4656e46a fix(gui): square photo grid laid out to the viewport
Lay the timeline out as a grid of square tiles that spans the
scrolled window's width exactly, instead of justified rows sized from
learned aspect ratios. Track the width from the horizontal adjustment
so the widest row can no longer push the window wider, and put the
scrubber beside the list rather than over it.

Load the app stylesheet above user priority: a theme imported from the
user's gtk.css padded every button, tiles included. Keep the top photo
at its screen offset across a relayout, and only drop thumbnail
requests the unbound row still owns.
2026-09-28 02:12:58 +02:00
nvrl 557eeb5f1d feat(gui): slideshow in the photo viewer
A new top-bar button, or F5, plays the loaded photos as a slideshow: it goes
fullscreen, hides the controls and moves on every five seconds. A video plays
to its end first, and the slideshow waits for each photo to load before it
starts counting. Stepping by hand restarts the count. Escape stops the
slideshow instead of closing the viewer, and fullscreen is left only if the
slideshow entered it. The slideshow ends on the last photo of the timeline.
2026-09-28 02:12:58 +02:00
nvrl b47e6c91a0 feat(gui): group the photos timeline by zoom level, select ranges
Zooming out now changes how the timeline is sectioned: by day at the default
row height, by month further out and by year at the smallest tiles, which
gain one more zoom step. Under a month or year heading the tile caption shows
the day instead of the clock time.

Shift+click selects every photo from the last one clicked, and dragging
across the timeline selects the photos swept over, scrolling at the top and
bottom edges. Sweeping back gives photos up again.
2026-09-28 02:12:58 +02:00
nvrl 5c1da9d7a5 feat(core,fuse,gui,cli): filter the photos timeline to photos in no album
A new pill next to the favorites filter narrows the timeline to the photos
no album of ours holds yet, so what is left to file is easy to find. A
RAW+JPEG pair counts as filed when any of its files is in an album.

PhotosTimeline gains an optional not_in_album flag; older front-ends omit it
and get the whole timeline. Album contents are fetched lazily, so the first
filtered page fetches the contents of any album not yet listed. Photos added
to an album while the filter is on leave the timeline at once.
2026-09-28 02:12:58 +02:00
Nils Pukropp 0135c29a4f chore(aur): update packages to 2.2.2 2026-09-27 21:44:59 +00:00
nvrl 0b81b76cb7 feat(gui): branded app icon in the sidebar header and login
The sidebar header and the login page used the generic folder-remote-symbolic
icon. Ship the app icon from packaging/ in the GUI resources and use it in
both places, so the in-app brand matches the launcher even when the app runs
uninstalled.
2026-09-27 23:28:00 +02:00
nvrl 7470bd89f3 chore(release): 2.2.2 2026-09-27 23:28:00 +02:00
nvrl e6cb52339d fix(fuse,core): list and resolve conflict copies in synced folders, log each once (B99)
The conflict sweep flagged divergent copies anywhere in the tree but kept its
dedupe set in memory, so every daemon start logged them again. The conflict
listing only covered My Files, so copies in on-demand sync folders were never
listed, and the Activity page showed them as resolved while they were still on disk.

List copies in synced folders by their absolute local path, route resolves
to the mount that serves them, flag only what the listing can show, and
consult the activity log before flagging a copy again.
2026-09-27 23:28:00 +02:00
Nils Pukropp 912acc760a chore(aur): update packages to 2.2.1 2026-09-24 18:08:08 +00:00
nvrl 5109427c32 chore(release): 2.2.1 2026-09-24 19:49:42 +02:00
nvrl e2c4dac8df feat(gui): legal links and disclaimer in the About dialog
Add a copyright line with contact address, links to the source code and
to Proton's terms of service and privacy policy, a Legal section with the
full non-affiliation, trademark and no-warranty disclaimer, and a license
credit for proton-sdk-rs. Translated into every shipped language.
2026-09-24 19:49:42 +02:00
nvrl ebeb9cdbef chore(aur): refresh -git pkgver in update.sh and bump to 2.2.0.r1.g82e9654
makepkg --printsrcinfo does not run pkgver(), so the -git .SRCINFO kept
the version from when the package was created and push.sh never had a
change to publish. update.sh now runs makepkg -od --noprepare first so
pkgver() writes the current git describe version into the PKGBUILD.
2026-09-24 19:49:42 +02:00
Nils Pukropp 82e96543ac chore(aur): update packages to 2.2.0 2026-09-23 18:48:21 +00:00
nvrl 96c77e96cb chore(release): 2.2.0 2026-09-23 20:29:15 +02:00
nvrl 90e110e893 feat(fuse,core): thumbnails for videos and for our own uploads
Videos without a server thumbnail fell through to the local fallback,
which downloaded the whole file, failed to decode it as an image and
wrote the video off for good. Grab one frame with ffmpeg instead, a
second in or from the start for shorter clips. Probe for ffmpeg before
downloading, bound every run, and skip videos over 256 MiB.
Migration V32 gives the videos written off so far another try.

Those videos had no server thumbnail because our uploads never sent one.
Every upload path (bulk upload, drained mount writes, conflict copies,
new revisions and folder sync) now attaches a thumbnail rendered from
the local file, kept under the 60 KiB Proton's own clients use. Files
that are not images or videos, or cannot be decoded, upload bare as
before; a .ts file only counts as video with an MPEG-TS sync byte.

ffmpeg is a new optional dependency in the PKGBUILDs.
2026-09-23 20:29:15 +02:00
nvrl 0b7236bd4a fix(gui): steady the photo gallery and fill the details panel
The month scrubber sat under the overlay scrollbar, which widens under
the pointer, so the two collided. Keep the scrubber clear of it.

The gallery jumped when thumbnails loaded, on zoom and on tab switches.
Relayout anchored on the first bound row, but ListView keeps off-screen
rows bound with stale allocations, and scroll_to on an off-screen row
aligns it to the top. Anchor on the first mapped row that is actually
visible instead.

The viewer's details panel opened empty: the title's hexpand propagated
up to the revealer, which then took all the width. Set hexpand on the
revealer explicitly.

EXIF details read like a camera: Make, Model and Lens lose their EXIF
quotes, a model that already names its maker is not repeated, shutter
speeds are rounded (1/19 s), and ISO is labelled.
2026-09-23 20:29:15 +02:00
Nils Pukropp 2fa5eaa75b chore(aur): update packages to 2.1.1 2026-09-23 16:44:58 +00:00
Nils Pukropp dc9ea9f541 chore(aur): update packages to 2.1.0 2026-09-23 16:29:57 +00:00
Nils Pukropp b13d9d9f38 chore(aur): update packages to 2.1.1 2026-09-23 16:17:09 +00:00
nvrl b696adefc2 chore(release): 2.1.1 2026-09-23 18:00:03 +02:00
nvrl 519ec1af07 fix(fuse): stop a superseded upload from spinning its drain thread
A write that lands while a revision is on the wire cancels that upload
through the flag CountingReader checks before each read. The cancel was
reported as ErrorKind::Interrupted, which every read loop retries,
including the SDK's read_full_block. The flag never clears, so the drain
thread spun at 100% CPU forever and the transfer stayed listed as
uploading at 0 bytes until the daemon restarted. A growing browser
download (.crdownload) triggers it naturally.

Report the cancel as ErrorKind::Other so the SDK fails the upload;
drain_revision already treats a cancelled upload as abandoned, not
failed. Tracked as B98.
2026-09-23 17:58:06 +02:00
Nils Pukropp 4820ea1236 chore(aur): update packages to 2.1.0 2026-09-23 09:23:32 +00:00
nvrl a9d6e0280d ci(aur): publish the AUR packages from CI
A release tag now updates and pushes all three AUR packages once the
GitHub release exists, then commits the refreshed recipes back to main.
A push to main that changes the -git recipe publishes that package
alone; plain upstream commits need no AUR push because its pkgver()
follows git at build time.

Needs the AUR_SSH_PRIVATE_KEY repository secret.
2026-09-23 11:02:30 +02:00
nvrl 3e4c756361 docs: refresh the README screenshots
Retake the login, files, photos and computers screenshots, split the
search launcher into its GTK and fzf front ends, and add the Activity
page.
2026-09-23 10:57:48 +02:00
nvrl 38126ff2b3 feat(gui): translate the Photos filter tabs, subtitle and filtered empty states
The All/Photos/Videos/Raw tabs, the "12 photos" subtitle and the
"No favorite videos in June 2024" heading were still built from English
literals. Each heading is now a whole translatable sentence with a
{month} placeholder, and the subtitle uses plural forms.

Also fill in the French "Documents" search chip, which was left
untranslated because it matches the English word.
2026-09-23 10:41:30 +02:00
nvrl 9529f2124b feat(gui): translate the Photos empty states
The empty timeline and empty favorites texts go through gettext and are
translated in every catalog.
2026-09-23 10:37:19 +02:00
nvrl 77b8fd65be chore(release): 2.1.0 2026-09-23 08:08:16 +02:00
nvrl d9064ef880 feat(core,gui): gettext translations and language preference
The app, the tray and the search prompt route every user-visible string
through GNU gettext in the `pdfs` text domain, with named placeholders and
plural forms. po/ holds the template, update and build scripts, and catalogs
for de, en_GB, es, fr, it, nl, pl and pt_BR. Preferences gains a Language row
that overrides the system language from the next start (config `language`).

Packages compile the catalogs into /usr/share/locale and the desktop files
carry translated descriptions and keywords. docs/TRANSLATING.md describes
the workflow.
2026-09-23 08:07:50 +02:00
nvrl 66ab1f5b16 chore(aur): update packages to 2.0.0 2026-09-23 04:31:13 +02:00
nvrl 56161f649e docs: use the new app icon in the README 2026-09-23 04:22:16 +02:00
nvrl 69b5ccb28b chore(version) bump 2026-09-23 04:19:56 +02:00
nvrl af84ce2168 feat(gui): redesign the Shared with Me page
Show only sections with content, invitations first, and an empty state
when nothing is shared. Rows drop the mount path from the subtitle, show
access as a tinted pill (Can view / Can edit / Can manage) and move
Open, Show in My Files and Leave into a menu. The invitations banner is
gone now that invitations lead the page.
2026-09-23 04:18:41 +02:00
nvrl 52651caf8e feat(core,fuse,gui,cli): restore to this computer per device
ListDeviceRestorableFolders and RestoreDeviceFolders read another
registered device's folders and attach them with that device's share,
without changing this machine's cached device. The Computers page moves
restore into each computer's menu, and pdfs sync restore takes --device.
2026-09-23 04:15:17 +02:00
nvrl 0b63a65661 feat(gui): shared with me path header and invitations banner
The header subtitle shows the path inside a shared folder and a header
back button replaces the Back row. An adw::Banner counts pending
invitations and scrolls to them.
2026-09-23 04:11:48 +02:00
nvrl 7f84ff7dea feat(packaging): new app icon
A white cloud with the download arrow as negative space, on a rounded
purple tile with a darker lower edge, following the GNOME app-icon grid.
2026-09-23 04:10:35 +02:00
nvrl 15985f8684 feat(core,fuse,gui,cli): stop sharing and share dialog polish
StopSharing / StopSharingByUid remove the public link, external
invitations, invitations and members, trying every step and reporting
what is still shared. Shared by Me gets a Stop Sharing… item and
pdfs unshare <path> works without an id.

The Share dialog validates addresses inline, shows spinners on buttons
while requests run, offers a link expiry and shows an existing link's
expiry.
2026-09-23 04:09:32 +02:00
nvrl dfc48375e2 fix(gui): parent context menus to the view, not the item
Themes style the insides of list items with descendant selectors, and
those reached through a popover parented to a grid tile, spacing out
every menu section. Menus now live under the outermost list view.
2026-09-23 03:58:43 +02:00
nvrl 2dde69525e fix(gui): keep the details pane under the toggle's control
Pin the split view's sidebar so the wide breakpoint no longer opens the
pane by itself, and show a "No item selected" state instead of closing
it when a navigation clears the entry. The pane follows the header
toggle alone, which also tracks an overlay closed by a click outside.
2026-09-23 03:58:43 +02:00
nvrl f6136ce344 feat(core,gui): tray states and menu
The tray icon follows the drive's phase (synced, syncing, paused,
offline, needs attention) and reports NeedsAttention while changes keep
failing. The menu gains an issues item that opens the Sync page, Sign In
when signed out, Hide Tray Icon (persisted as tray_hidden) and Stop
Proton Drive, which pdfs-app confirms via --confirm-stop. A second
pdfs-app launch raises the running window.
2026-09-23 03:55:51 +02:00
nvrl 4fa61a14c3 fix(gui): clear the selection on an empty-space click
A click between the files in the grid or list drops the selection, as
in a file manager, unless Ctrl or Shift is held. Backspace goes back
while the focus is on the files.
2026-09-23 03:48:57 +02:00
nvrl fe8fb8cdcf feat(gui): activity log redesign
Group the feed by day with a time column and filter chips, fold a
conflict logged on every daemon run into one row with a count, and put
the conflicts still open in a Needs attention section. Conflict rows
open the Sync page's resolve dialog and read as resolved once the copy
is gone.
2026-09-23 03:44:21 +02:00
nvrl f457fa68fc fix(gui): open the details pane on request, not on selection
Revealing the pane on the first click of a double-click reflowed the files under the pointer, so the second click missed and the folder did not open. The pane now follows a header toggle, Alt+Enter and a Details menu item.
2026-09-23 03:38:48 +02:00
nvrl be8464f4a0 feat(gui): photos infinite scroll and month scrubber
The timeline pages itself in 1.5 screens before the end instead of offering a Load more button, and a year-marked month scrubber jumps to any month, loading long pages until it is reached.
2026-09-23 03:37:08 +02:00
nvrl a5d29969dc feat(gui): photo viewer rewrite
Auto-hiding controls, zoom and pan, a docked details panel, paging past the loaded photos with the library total in the counter, inline video playback, Save a Copy under the real name with an error toast, and Open With through the app chooser.
2026-09-23 03:34:29 +02:00
nvrl 43d1afe705 feat(core,fuse,gui,cli): album management
New requests CreateAlbum, RenameAlbum, DeleteAlbum, AddToAlbum and
RemoveFromAlbum, backed by proton-drive-rs 0.6.6. The Albums view gains a
New Album button and a card menu with Rename and Delete; photos gain Add
to Album (menu and selection bar) and Remove from Album inside an album.
CLI: pdfs new-album, rename-album, delete-album, add-to-album,
remove-from-album.

Also fixes the album view state: the timeline filters are hidden on the
album grid, the header counts albums, Back keeps the grid and its scroll
position, and the timeline is only reloaded when an album replaced it.
2026-09-23 03:25:16 +02:00
nvrl 29e5769dec feat(core,fuse,gui,cli): search scope and location column
Request::Search takes an optional folder scope (serde default, so older
front-ends keep searching everywhere). The GUI offers a This folder toggle
beside the search box and shows a Location column for hits; the CLI gains
pdfs search --in. Pins report whether a pinned file is cached, so pin-derived
search hits badge correctly.
2026-09-23 03:12:18 +02:00
nvrl 0fbc6421ba feat(gui): docked details pane on wide windows
An adw::Breakpoint at 1000sp uncollapses the details OverlaySplitView, so
the pane sits beside the files instead of over them. The pane gains
Sharing and Versions sections, filled from ListShare and ListRevisions
when an entry is selected, with Share…, Copy Link and Versions… buttons.
2026-09-23 03:06:32 +02:00
nvrl abf76cd5e8 feat(gui): shared selection and rubberband in My Files
The grid and column view wrap one MultiSelection instead of one each, so
a selection survives switching layout, and both views enable rubberband
selection.
2026-09-23 03:05:07 +02:00
nvrl 8d0a575a7c feat(gui): menu-model context menus with keyboard and background menu
Context menus are PopoverMenus built from gio::Menu through a small
ActionMenu helper, so they are keyboard navigable and read as menus. My
Files follows the item order of UI_UX_PLAN.md §6, adds Open With…, Copy
Link and Versions…, and gains a background menu on empty space and the
Menu key / Shift+F10. The photo tile menu and the ⋮ row menus on Shared
by Me and Computers use the same helper.
2026-09-23 03:04:32 +02:00
nvrl 91a5f21340 docs(core,fuse,gui,cli): US spelling and correct schema lines
User-visible strings, doc comments and docs now say "Favorite" and
"color", matching the `pdfs favorite` command; UI_UX_PLAN.md §8 records
the choice. The changelog's 1.11.x and Unreleased entries said schema 30,
but V31 (`photos.resolved_at`) shipped in 1.11.0. Adds the missing
release links for 1.10.0 to 1.11.1.
2026-09-23 03:01:27 +02:00
nvrl 8351382ef2 feat(core,fuse,gui,cli): upload and download bandwidth limits (phase 6)
Every transfer charges the bytes it moves to its direction's rate limit,
a virtual clock shared by all transfers with a one-second burst, and sleeps
off any overshoot. On runtime workers the sleep goes through block_in_place
so FUSE reads keep their threads.

The caps are set live with SetBandwidthLimits, persisted to config and
applied at mount start. Preferences gains a Network group with debounced
MiB/s editors, and the CLI gains `pdfs sync limit --up/--down`.
2026-09-23 02:43:42 +02:00
nvrl b58b60010e feat(core,fuse,gui,cli): pause a single mirrored folder (phase 6) 2026-09-23 02:36:45 +02:00
nvrl a86c3e586d feat(gui): trash multi-select, shared and computer row menus, sign-in errors (phase 6) 2026-09-23 02:31:13 +02:00
nvrl fdda35918f feat(gui): photos context menu, filter empty states and import banner (phase 5) 2026-09-23 02:26:42 +02:00
nvrl 8bc52edeb4 feat(core,gui): file browser menus, sort, history and folder-picker move (phase 4)
The My Files header gets a New menu, a grid/list split button whose menu
sorts by name, size or date (persisted as files_view in the config), and a
folder menu for Build Thumbnails and Open in File Manager. Navigation
keeps a back/forward history (Alt+Left/Right, mouse buttons, Alt+Up).
Move to… browses Drive folders and handles a whole selection, rename
preselects the stem, and files dropped from a file manager upload into
the folder on screen.
2026-09-23 02:21:39 +02:00
nvrl 792eee75a4 feat(core,fuse,gui,cli): list and resolve sync conflicts (phase 3)
Conflict copies under My Files are paired with their original and listed
over the control socket. The Sync page shows them with a resolve dialog
(keep original, keep copy, keep both under a new name), and the CLI gains
`pdfs conflicts` and `pdfs conflicts resolve`. Every resolution goes
through the Proton trash, and files that are open, being written or still
owed an upload are refused.
2026-09-23 02:14:56 +02:00
nvrl 067561564e feat(core,fuse,gui,cli): pause/resume sync and a visible upload queue (phase 2)
Backend (docs/UI_UX_PLAN.md B-1, B-2):
- Request::SetSyncPaused { paused, until } pauses the drain worker and the
  mirror reconcile. FUSE reads, write staging, the watcher and the poll keep
  running. The pause is persisted in the `state` table (`sync_paused_until`),
  so it survives a restart; a timed pause ends by itself through a timer
  thread, re-armed on mount. Resuming wakes the drain and runs a full
  reconcile. Response::Status gains `paused` and `paused_until`.
- Request::ListPendingOps lists the queue with paths, attempts, last error,
  next attempt and parked/failing flags. Request::RetryPendingOp { id } makes
  one backed-off op (or every failed one) due now. Parked ops are never
  un-parked and nothing is dropped from the queue.

Front ends:
- Sync page: a status card with Pause/Resume (split button with 1 h, 8 h and
  24 h pauses) and a "Waiting to Upload" list with Retry now and Retry All.
- The sidebar strip and the tray show the paused state; the tray gains Pause
  Sync / Resume Sync and names failing changes.
- CLI: `pdfs sync pause [--for 30m|1h|2d]`, `pdfs sync resume`,
  `pdfs sync queue`, `pdfs sync retry [id]`; `pdfs status` reports a pause.

Not in this phase: merging Activity into the Sync page, discarding a queued
op (needs a recovery copy first), and per-folder pause.
2026-09-23 02:06:41 +02:00
nvrl cf9e1665ca feat(core,gui): phase 1 shell — per-page headers, sidebar footer, Preferences dialog
Restructure the app shell per docs/UI_UX_PLAN.md phase 1:

- Every destination page gets its own AdwHeaderBar via page_frame(), with
  page actions packed into it and a busy spinner beside the title.
- The sidebar is regrouped (files, then a Sync section) with a primary menu
  (Preferences, Keyboard Shortcuts, About) and a footer showing sync state,
  storage quota and the account with Sign Out.
- The Settings page is gone: startup, folder location, appearance and cache
  options move into an AdwPreferencesDialog (Ctrl+,); transfers move to the
  Sync page; developer details move into About's debug info.
- Inline CSS moves to a gresource stylesheet using libadwaita colour
  variables. The system accent is the default; the Proton purple accent is
  an opt-in preference (new `proton_accent` config key, defaults off).
- Add a keyboard shortcuts dialog (Ctrl+?).

libadwaita requirement stays at 1.5, so Ubuntu 24.04 remains supported.
2026-09-23 01:50:37 +02:00
nvrl cc308ac25a fix(core,fuse,gui): UI/UX quick wins from the audit (phase 0)
- Move Google Takeout import out of Settings; Photos owns it, and the
  Takeout page's back button returns there.
- Trash from the file browser is Undo-only (no confirm); F2 on a
  multi-selection explains why it does nothing.
- Ask before sign-out, rejecting an invitation, removing someone's access,
  removing a public link, and switching a folder to on-demand.
- Clearer offline wording ("Make available offline" / "Make online only")
  with dedicated offline, online-only and upload symbolic icons; the pin row
  is hidden for folders.
- Search hits report whether a file is cached, so search results show the
  offline badge.
- Activity log: readable Empty Trash / link entries, distinct icons, and
  error/warning styling for failures and conflicts.
- Proper singular/plural counts instead of "item(s)" / "archive(s)".
- Name earlier versions by save time instead of revision id; capitalise
  the public link role; stop-syncing uses a stop icon, not a trash can.
- Label the Photos Upload and "Show on map" buttons; toast when a
  thumbnail build finishes; the versions button follows the mount state.
- Mode switches and device mutations reload both sync pages.
2026-09-23 01:34:35 +02:00
nvrl 690b91deab docs: add UI/UX audit and improvement plan
Audit of every GUI page, the photo viewer, the tray and the backend
capabilities a sync UI needs. Lists found bugs, a glossary, a new
information architecture (Sync hub, Preferences dialog) and a phased
roadmap including pause/resume and conflict resolution.
2026-09-23 01:24:41 +02:00
nvrl 9cd8a196aa chore(packaging): add AUR package sources for source, git, and binary builds
packaging/aur/ now holds the full content of three AUR repositories:
proton-drive-for-linux builds the latest tag from source,
proton-drive-for-linux-git builds main, and proton-drive-for-linux-bin
installs the binaries attached to the GitHub release. Each carries a
PKGBUILD, a generated .SRCINFO, an ignore-everything .gitignore, and the
0BSD license the AUR expects for package sources.

update.sh bumps the release-based packages and regenerates the .SRCINFO
files; push.sh publishes each package to its AUR remote. The README now
points users at the AUR packages and keeps the in-tree PKGBUILD as the
development build.
2026-09-21 12:58:13 +02:00
nvrl 860d29cfe8 fix(core,fuse): un-park abandoned creates, retire orphan staged bytes, serialise refreshes
Four faults found by reading the journal for issues that were never fixed,
tracked as B94-B97 in docs/BUGS.md.

A create parked behind a temp-file rename (B70) had no exit but that rename,
so 43 uploads sat in the queue for weeks holding their staged bytes. The
primary drain worker now sweeps parked creates while the queue is idle: one
whose node is gone is discarded, and one older than PARK_EXPIRY_MS that nobody
holds open is un-parked rather than dropped, since staged bytes are the only
copy of the write. An open node stays parked however old it is.

.tmp and .temp leave TRANSIENT_SUFFIXES. The list served two mechanisms with
different consequences: ignoring a name during mirror reconcile is reversible,
parking a create is not, and a Takeout index.tmp is a final name that nothing
will ever rename.

Staged blobs that match no queued op and name no node were kept forever and
warned about once per blob per mount (95 blobs, ~445 MiB, oldest two months).
Blobs past STAGING_ORPHAN_RETAIN are retired; the rest are one aggregated
warning.

Refresh control requests now run one per scope. Every scope is individually
cheap, but duplicates contend for the database lock the running refresh holds,
which left five in flight for twenty-three minutes. request_kind names the
scope so the report says which view wedged. The photo metadata pass also stops
at shutdown instead of running into the cancelled runtime and logging one
"skipping undecryptable photo" per node.
2026-09-19 20:29:48 +02:00
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
272 changed files with 185761 additions and 14907 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.
+14
View File
@@ -0,0 +1,14 @@
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: Troubleshooting and FAQ
url: https://github.com/narrrl/proton-drive-linux/wiki/Troubleshooting
about: Fixes for common problems, and what to include in a bug report.
- 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
+27
View File
@@ -0,0 +1,27 @@
## 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)
- [ ] Adds or renames a page in `docs/` (listed in `SECTIONS` in `scripts/publish-wiki.sh`, which publishes it to the wiki)
- [ ] None of the above
<!-- Screenshots for GTK changes. -->
+107
View File
@@ -0,0 +1,107 @@
name: AUR
on:
# The -git package computes its pkgver from git at build time, so a new commit
# upstream needs no AUR push. Only a changed -git recipe is published.
push:
branches: [main]
paths:
- 'packaging/aur/proton-drive-for-linux-git/**'
# Called by release.yml once the GitHub release and its assets exist.
workflow_call:
inputs:
version:
required: true
type: string
secrets:
AUR_SSH_PRIVATE_KEY:
required: true
workflow_dispatch:
inputs:
version:
description: 'Release to publish, such as 2.1.0. Leave empty to publish only the -git package.'
required: false
type: string
permissions:
contents: write
concurrency:
group: aur
cancel-in-progress: false
jobs:
publish:
name: Publish AUR packages
runs-on: ubuntu-24.04
container: archlinux:base-devel
env:
# makepkg refuses to run as root, so the packaging steps run as `builder`.
HOME: /home/builder
VERSION: ${{ inputs.version }}
steps:
- name: Install packaging tools
run: pacman -Syu --noconfirm --needed git openssh pacman-contrib
- name: Checkout repository
uses: actions/checkout@v7
with:
# The updated recipes are committed back to main, not to the tag.
ref: main
- name: Create build user
run: |
useradd --create-home builder
chown -R builder: "$GITHUB_WORKSPACE"
- name: Configure AUR access
env:
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
run: |
install -d -m700 -o builder -g builder "$HOME/.ssh"
printf '%s\n' "$AUR_SSH_PRIVATE_KEY" > "$HOME/.ssh/aur"
cat > "$HOME/.ssh/config" <<'EOF'
Host aur.archlinux.org
IdentityFile ~/.ssh/aur
User aur
EOF
# Pin the host key to the fingerprint published on aur.archlinux.org.
# The scan sometimes returns nothing from the runner, so retry it.
for attempt in 1 2 3 4 5; do
ssh-keyscan -T 15 -t ed25519 aur.archlinux.org > "$HOME/.ssh/known_hosts" &&
[ -s "$HOME/.ssh/known_hosts" ] && break
echo "ssh-keyscan attempt $attempt returned no key; retrying"
sleep 10
done
ssh-keygen -lf "$HOME/.ssh/known_hosts" | grep -q 'SHA256:RFzBCUItH9LZS0cKB5UE6ceAYhBD5C8GeOBip8Z11+4'
chown -R builder: "$HOME/.ssh"
chmod 600 "$HOME/.ssh/aur"
runuser -u builder -- git config --global user.name 'Nils Pukropp'
runuser -u builder -- git config --global user.email 'contact@narl.io'
- name: Update release packages
if: env.VERSION != ''
run: runuser -u builder -- packaging/aur/update.sh "${VERSION#v}"
- name: Refresh the -git metadata
if: env.VERSION == ''
working-directory: packaging/aur/proton-drive-for-linux-git
run: runuser -u builder -- sh -c 'makepkg --printsrcinfo > .SRCINFO'
- name: Publish to the AUR
run: |
if [ -n "$VERSION" ]; then
runuser -u builder -- packaging/aur/push.sh
else
runuser -u builder -- packaging/aur/push.sh proton-drive-for-linux-git
fi
- name: Commit the updated recipes to main
if: env.VERSION != ''
run: |
runuser -u builder -- git add packaging/aur
if ! runuser -u builder -- git diff --cached --quiet; then
runuser -u builder -- git commit -m "chore(aur): update packages to ${VERSION#v}"
runuser -u builder -- git push origin HEAD:main
fi
+90 -2
View File
@@ -13,7 +13,7 @@ jobs:
rust:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Install native dependencies
run: |
@@ -32,14 +32,20 @@ jobs:
with:
toolchain: '1.96'
components: rustfmt, clippy
cache: false
# Tags can only restore caches saved on main, so only main saves one. The
# release workflow restores this key for its quality gates.
- uses: swatinem/rust-cache@v2
with:
shared-key: debug
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Formatting
run: cargo fmt --all -- --check
- name: Lints
run: cargo clippy --workspace --all-targets -- -D warnings
run: cargo clippy --workspace --all-targets --locked -- -D warnings
- name: Unit tests
run: cargo test --workspace --locked
@@ -57,3 +63,85 @@ jobs:
# Real Proton credentials and /dev/fuse deliberately do not run on pull
# requests. Follow docs/TESTING.md on a dedicated test-account runner.
# The seeded simulation runs (docs/TESTING.md): real FUSE
# daemons against an in-memory Drive, so they need /dev/fuse but no account.
# They run on pull requests too: most data-loss fixes are only exercised
# here, and a change that breaks one should not reach main first.
simulation:
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
- name: Install native dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
pkg-config \
fuse3 \
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'
cache: false
- uses: swatinem/rust-cache@v2
with:
shared-key: debug
save-if: false
- name: Simulation runs
run: 'cargo test -p pdfs-fuse --lib --locked sim:: -- --ignored --test-threads=1'
# A hung step writes every daemon thread's stack next to the run's
# state; without them a hang that does not replay cannot be told apart.
- name: Keep the stacks of a hung run
if: failure()
uses: actions/upload-artifact@v7
with:
name: simulation-stacks
path: /tmp/pdfs-sim-*/stacks.txt
if-no-files-found: ignore
# Keeps the release-profile dependencies cached on main, where the release
# workflow can restore them; a tag cannot reuse the caches of earlier tags.
release-cache:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
- 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'
cache: false
- uses: swatinem/rust-cache@v2
with:
shared-key: release
- name: Release build
run: cargo build --release --locked
+66
View File
@@ -0,0 +1,66 @@
name: COPR
on:
# Called by release.yml once the GitHub release and its assets exist: COPR
# downloads the tag archive and the vendored crates from the release.
workflow_call:
inputs:
version:
required: true
type: string
secrets:
COPR_CONFIG:
required: true
workflow_dispatch:
inputs:
version:
description: 'Release to build, such as 3.5.0. It must already have its vendor tarball.'
required: true
type: string
permissions:
contents: read
concurrency:
group: copr
cancel-in-progress: false
jobs:
build:
name: Build in COPR
runs-on: ubuntu-24.04
container: fedora:latest
env:
VERSION: ${{ inputs.version }}
COPR_PROJECT: narl-rs/proton-drive-linux
steps:
- name: Install packaging tools
run: dnf -y install copr-cli rpm-build rpmdevtools
- name: Checkout repository
uses: actions/checkout@v7
- name: Configure COPR access
env:
COPR_CONFIG: ${{ secrets.COPR_CONFIG }}
run: |
install -d -m700 ~/.config
printf '%s\n' "$COPR_CONFIG" > ~/.config/copr
chmod 600 ~/.config/copr
# COPR rebuilds the source package without our --define, so the version
# has to be written into the spec itself.
- name: Build the source package
run: |
version="${VERSION#v}"
spec=packaging/proton-drive-linux.spec
sed -i -E "s/^(%\{!\?version: %global version )[^}]*\}/\1${version}}/" "$spec"
grep -qF "%global version ${version}}" "$spec"
mkdir -p rpmbuild/SOURCES
spectool -g -C rpmbuild/SOURCES "$spec"
rpmbuild -bs --define "_topdir $PWD/rpmbuild" "$spec"
# Waits for the build, so a failed COPR build fails this job.
- name: Submit to COPR
run: copr-cli build "$COPR_PROJECT" rpmbuild/SRPMS/*.src.rpm
+59
View File
@@ -0,0 +1,59 @@
name: Nix
# Builds the flake (docs/INSTALL.md#nixos-flake) whenever its recipe, the lock
# files or the code change, so a NixOS user's `nix build` keeps working. Run it
# from the Actions tab for any branch to get a build to try.
on:
push:
paths:
- 'flake.nix'
- 'flake.lock'
- 'packaging/nix/**'
- 'Cargo.lock'
- 'Cargo.toml'
- 'crates/**'
- 'po/**'
- '.github/workflows/nix.yml'
pull_request:
paths:
- 'flake.nix'
- 'flake.lock'
- 'packaging/nix/**'
- 'Cargo.lock'
- '.github/workflows/nix.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: nix-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
- uses: cachix/install-nix-action@v31
# Pulls from the project's binary cache, and pushes what this run built
# when the token is there; a fork's pull request has none and only pulls.
- uses: cachix/cachix-action@v17
with:
name: proton-drive-linux
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
- name: Check the flake
run: nix flake check --print-build-logs --no-build
- name: Build the package
run: nix build --print-build-logs .#proton-drive-linux
- name: Smoke test
run: |
./result/bin/pdfs --version
for bin in pdfs-app pdfs-tray pdfs-prompt; do test -x "result/bin/$bin"; done
grep -q "ExecStart=$(readlink -f result)/bin/pdfs" result/lib/systemd/user/proton-drive.service
ls result/share/locale/*/LC_MESSAGES/pdfs.mo
+172 -30
View File
@@ -10,14 +10,68 @@ permissions:
contents: write
jobs:
checks:
name: Release quality gates
runs-on: ubuntu-24.04
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Install system 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: Set up Rust
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: '1.96'
components: rustfmt, clippy
cache: false
# A tag can read the caches of main but its own caches are useless to the next
# tag, so this only restores what CI saved on main.
- name: Cache Cargo target
uses: swatinem/rust-cache@v2
with:
shared-key: debug
save-if: false
- 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)"
spec_version="$(sed -n 's/^%{!?version: %global version \([^}]*\)}/\1/p' packaging/proton-drive-linux.spec)"
test "$tag_version" = "$manifest_version"
test "$tag_version" = "$pkgbuild_version"
test "$tag_version" = "$spec_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
build-release:
name: Build and Release
name: Build packages
runs-on: ubuntu-24.04
environment: 'rpm signing'
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Install system dependencies
run: |
@@ -31,33 +85,21 @@ jobs:
libsecret-1-dev \
libdbus-1-dev \
desktop-file-utils \
gettext \
rpm
- name: Set up Rust
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: '1.96'
components: rustfmt, clippy
cache: false
# Restores the release-profile dependencies that CI builds on main.
- 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
with:
shared-key: release
save-if: false
- name: Build release binaries
run: |
@@ -84,10 +126,17 @@ jobs:
cp target/release/pdfs-tray dist/binaries/
cp target/release/pdfs-app dist/binaries/
cp target/release/pdfs-prompt dist/binaries/
po/build.sh dist/binaries/locale
# Create tarball
tar -czf proton-drive-linux-${{ env.VERSION }}-x86_64.tar.gz -C dist/binaries .
# Every crate the build needs, so COPR can build the spec without network.
- name: Package vendored crates (.tar.xz)
run: |
cargo vendor --locked dist/vendor > /dev/null
tar -cJf proton-drive-linux-vendor-${{ env.VERSION }}.tar.xz -C dist vendor
- name: Package Debian package (.deb)
run: |
mkdir -p deb/DEBIAN
@@ -110,6 +159,10 @@ jobs:
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/
po/build.sh deb/usr/share/locale
# Stops the running daemon before removal, so the mount stops mirroring deletes
install -m755 packaging/deb/prerm deb/DEBIAN/prerm
# Calculate Installed-Size in KB for the control file
INSTALLED_SIZE=$(du -sk deb | cut -f1)
@@ -122,13 +175,14 @@ 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, libwebkitgtk-6.0-4
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, desktop application, and search launcher
Homepage: https://proton-drive.narl.io
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
@@ -148,6 +202,7 @@ jobs:
--define "_topdir $PWD/rpmbuild" \
--define "_sourcedir $PWD" \
--define "git_dir $PWD" \
--define "prebuilt 1" \
--define "version ${RPM_VERSION}" \
--define "_dbpath $PWD/rpmbuild/db" \
packaging/proton-drive-linux.spec
@@ -177,24 +232,111 @@ jobs:
- name: Upload Workflow Artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: proton-drive-linux-builds
path: |
*.deb
*.tar.gz
*.tar.xz
*.rpm
if-no-files-found: error
- name: Create GitHub Release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
# The flake is the Nix release: a tag is what `github:narrrl/proton-drive-linux/vX.Y.Z`
# pins, so it has to build before the release is announced.
nix:
name: Build the Nix package
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
- uses: cachix/install-nix-action@v31
# Pulls from the project's binary cache, and pushes what this run built
# when the token is there; a fork's pull request has none and only pulls.
- uses: cachix/cachix-action@v17
with:
name: proton-drive-linux
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
- name: Build the flake
run: nix build --print-build-logs .#proton-drive-linux
- name: Check the version
run: |
built="$(./result/bin/pdfs --version)"
echo "$built"
case "$GITHUB_REF" in
refs/tags/v*) grep -qF "${GITHUB_REF_NAME#v}" <<< "$built" ;;
esac
publish:
name: Create GitHub Release
needs: [checks, build-release, nix]
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Download packages
uses: actions/download-artifact@v8
with:
name: proton-drive-linux-builds
# The release notes are this version's section of the changelog, from its
# heading up to the next one.
- name: Extract release notes
run: |
version="${GITHUB_REF_NAME#v}"
awk -v heading="## [$version]" \
'index($0, heading) == 1 { found = 1; print; next }
found && /^## \[/ { exit }
found' docs/CHANGELOG.md > release-notes.md
if [ ! -s release-notes.md ]; then
echo "::error::docs/CHANGELOG.md has no section for $version"
exit 1
fi
- name: Create GitHub Release
uses: softprops/action-gh-release@v3
with:
body_path: release-notes.md
files: |
*.deb
*.tar.gz
*.tar.xz
*.rpm
draft: false
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
aur:
name: Publish AUR packages
needs: publish
if: startsWith(github.ref, 'refs/tags/')
uses: ./.github/workflows/aur.yml
with:
version: ${{ github.ref_name }}
secrets:
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
copr:
name: Build COPR packages
needs: publish
if: startsWith(github.ref, 'refs/tags/')
uses: ./.github/workflows/copr.yml
with:
version: ${{ github.ref_name }}
secrets:
COPR_CONFIG: ${{ secrets.COPR_CONFIG }}
wiki:
name: Publish the wiki
needs: publish
if: startsWith(github.ref, 'refs/tags/')
uses: ./.github/workflows/wiki.yml
with:
ref: ${{ github.ref_name }}
+121
View File
@@ -0,0 +1,121 @@
name: Test build
# On-demand Fedora RPM of any branch, for someone to try a fix before it ships.
# Run it from the Actions tab ("Run workflow", pick the branch). Built inside a
# Fedora container so the binaries link against Fedora's own GTK, libadwaita and
# WebKitGTK, not the Ubuntu ones the release RPM is built against.
#
# The RPM is unsigned and versioned `<Cargo version>-1.test.<sha>`, so it sorts
# below the release it precedes and `dnf` replaces it when that ships.
on:
workflow_dispatch:
inputs:
fedora:
description: 'Fedora release to build on'
required: false
default: '44'
type: string
prerelease:
description: 'Also publish a pre-release anyone can download without a GitHub login'
required: false
default: false
type: boolean
permissions:
contents: read
concurrency:
group: test-build-${{ github.ref }}
cancel-in-progress: true
jobs:
rpm:
name: Fedora ${{ inputs.fedora }} RPM
runs-on: ubuntu-24.04
container: fedora:${{ inputs.fedora }}
steps:
- name: Install build dependencies
run: |
dnf install -y --setopt=install_weak_deps=False \
git rpm-build gcc gcc-c++ make gettext curl \
pkgconf-pkg-config fuse3-devel gtk4-devel libadwaita-devel \
libsecret-devel dbus-devel glib2-devel webkitgtk6.0-devel
- name: Checkout repository
uses: actions/checkout@v7
# Fedora's packaged Rust can trail the workspace's minimum (1.96).
- name: Set up Rust
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: '1.96'
cache: false
- name: Cache Cargo target
uses: swatinem/rust-cache@v2
with:
shared-key: fedora-test-build
save-if: false
- name: Build release binaries
run: cargo build --release --locked
- name: Package RPM
run: |
git config --global --add safe.directory "$GITHUB_WORKSPACE"
VERSION=$(grep -m1 '^version' Cargo.toml | cut -d'"' -f2)
SHORT=${GITHUB_SHA::7}
mkdir -p rpmbuild/{BUILD,RPMS,SOURCES,SPECS,SRPMS,BUILDROOT,db}
rpmbuild -bb \
--nodeps \
--define "_topdir $PWD/rpmbuild" \
--define "_sourcedir $PWD" \
--define "git_dir $PWD" \
--define "prebuilt 1" \
--define "version ${VERSION}" \
--define "dist .test.${SHORT}" \
--define "_dbpath $PWD/rpmbuild/db" \
packaging/proton-drive-linux.spec
mkdir -p out
cp rpmbuild/RPMS/x86_64/*.rpm out/
# The bare binaries, for trying one without installing anything.
tar -czf "out/proton-drive-linux-${VERSION}-${SHORT}-fedora${{ inputs.fedora }}-x86_64.tar.gz" \
-C target/release pdfs pdfs-tray pdfs-app pdfs-prompt
ls -l out
- name: Upload artifact
uses: actions/upload-artifact@v7
with:
name: proton-drive-linux-fedora${{ inputs.fedora }}-${{ github.sha }}
path: out/*
retention-days: 14
if-no-files-found: error
prerelease:
name: Publish pre-release
needs: rpm
if: inputs.prerelease
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- name: Download build
uses: actions/download-artifact@v8
with:
path: out
merge-multiple: true
# One rolling pre-release per branch: a new run replaces the last one, so
# the link a tester was given keeps pointing at the newest build.
- name: Publish
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: |
TAG="test-$(echo "${GITHUB_REF_NAME}" | tr -c 'A-Za-z0-9._\n' '-')"
gh release delete "$TAG" --cleanup-tag --yes 2>/dev/null || true
gh release create "$TAG" out/* \
--target "$GITHUB_SHA" \
--prerelease \
--title "Test build of ${GITHUB_REF_NAME} (${GITHUB_SHA::7})" \
--notes "Unsigned test build of branch \`${GITHUB_REF_NAME}\` at ${GITHUB_SHA}. Not a release; replaced by the next test build of this branch."
+77
View File
@@ -0,0 +1,77 @@
name: Wiki
# docs/ is the source of the GitHub wiki (docs/DEVELOPMENT.md#common-changes).
# Every change to the documentation checks its links; release.yml calls this
# once the release is published, so the wiki documents the latest release. Run
# it from the Actions tab to publish main in between.
on:
push:
branches: [main]
paths:
- 'docs/**'
- 'images/**'
- 'README.md'
- 'CONTRIBUTING.md'
- 'SECURITY.md'
- 'scripts/publish-wiki.sh'
- '.github/workflows/wiki.yml'
pull_request:
paths:
- 'docs/**'
- 'images/**'
- 'README.md'
- 'CONTRIBUTING.md'
- 'SECURITY.md'
- 'scripts/publish-wiki.sh'
- '.github/workflows/wiki.yml'
workflow_call:
inputs:
ref:
description: 'Tag or branch to publish'
required: true
type: string
workflow_dispatch:
inputs:
ref:
description: 'Tag or branch to publish'
required: false
default: 'main'
type: string
permissions:
contents: read
jobs:
check:
name: Check documentation links
if: inputs.ref == ''
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
- name: Check links
run: scripts/publish-wiki.sh --check "$RUNNER_TEMP"
publish:
name: Publish to the wiki
# Set only by release.yml and the Actions tab, so pushes and pull requests
# never publish.
if: inputs.ref != ''
runs-on: ubuntu-24.04
concurrency:
group: wiki-publish
cancel-in-progress: false
permissions:
contents: write
steps:
- uses: actions/checkout@v7
with:
ref: ${{ inputs.ref }}
- name: Publish
env:
WIKI_REMOTE: https://x-access-token:${{ github.token }}@github.com/${{ github.repository }}.wiki.git
run: |
git config --global user.name 'github-actions[bot]'
git config --global user.email '41898282+github-actions[bot]@users.noreply.github.com'
scripts/publish-wiki.sh --push "$RUNNER_TEMP"
+2
View File
@@ -3,3 +3,5 @@
*.pdb
.env
/rpmbuild
/result
/result-*
+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.
+81
View File
@@ -0,0 +1,81 @@
# 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 [`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md) for building, running and debugging from
source, and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for how the daemon, front ends, and
SDK fit together. [`docs/README.md`](docs/README.md) indexes the rest. The same pages are on
the [wiki](https://github.com/narrrl/proton-drive-linux/wiki).
- 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 [`docs/INSTALL.md`](docs/INSTALL.md#build-dependencies). Minimum
supported Rust version is 1.96. [`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md#running-from-source)
explains how to run your build next to, or instead of, the installed service.
## 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 queue should also pass the simulation runs and 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.
- **The GUI is fully translated.** Wrap every user-visible string in the helpers from
`crates/pdfs-gui/src/i18n.rs`, run `po/update.sh`, and translate the new entries in every
catalog in `po/LINGUAS` in the same change. See [`docs/TRANSLATING.md`](docs/TRANSLATING.md).
The CLI and daemon messages stay in English.
- **Document what users see.** A new command, setting or behaviour belongs in the matching page
under `docs/` (a user guide topic, the CLI reference or configuration) and in the `[Unreleased]`
section of [`docs/CHANGELOG.md`](docs/CHANGELOG.md). Each release publishes `docs/` to the
wiki. A new page goes into `SECTIONS` in `scripts/publish-wiki.sh`, and
`scripts/publish-wiki.sh --check` must pass. CI runs it on every change to `docs/`.
## 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
+645 -794
View File
File diff suppressed because it is too large. Load diff
+32 -9
View File
@@ -3,15 +3,20 @@ resolver = "2"
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
[workspace.package]
version = "1.9.0"
version = "3.6.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://proton-drive.narl.io"
readme = "README.md"
keywords = ["proton", "drive", "fuse", "sync", "linux"]
categories = ["filesystem", "command-line-utilities"]
[workspace.dependencies]
proton-sdk = { version = "0.6.1" }
proton-drive-rs = { version = "0.6.1" }
proton-sdk = { version = "0.10.1" }
proton-drive-rs = { version = "0.10.1" }
# Internal crates.
pdfs-core = { path = "crates/pdfs-core" }
@@ -20,6 +25,8 @@ 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"
zeroize = "1"
thiserror = "2"
anyhow = "1"
# Non-poisoning mutexes. A handler that panics while holding a lock must not
@@ -29,24 +36,40 @@ anyhow = "1"
parking_lot = "0.12"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
directories = "5"
directories = "6"
ignore = "0.4"
sha2 = "0.10"
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.10"
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.32", features = ["bundled"] }
keyring = { version = "3", features = ["sync-secret-service", "crypto-rust"] }
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"
async-trait = "0.1"
futures = "0.3"
fuser = "0.17"
fuser = "0.18"
libc = "0.2"
[profile.release]
# No package ships a separate debug package (the PKGBUILD sets `!debug`, the
# RPM spec sets `debug_package` to nil), and the unstripped binaries weighed
# 43 MB (`pdfs`), 24 MB (`pdfs-app`) and 12 MB (`pdfs-tray`).
strip = "symbols"
# Off on purpose: LTO has broken GTK links for this workspace, which is why
# the packages turn it off as well.
lto = false
# `Workers` and the thumbnail build catch panics to keep the daemon alive, so
# unwinding must stay on.
panic = "unwind"
+149 -326
View File
@@ -1,377 +1,200 @@
# Proton Drive client for Linux (unofficial)
<div align="center">
A fast, unofficial Proton Drive client for Linux. This client features an advanced files-on-demand FUSE virtual mount with block-level caching, a command-line interface (CLI), and a fully non-blocking GTK4 desktop application with system tray integration.
<img src="images/logo.svg" alt="" width="112" height="112">
# Proton Drive for Linux
**Proton Drive on the Linux desktop.** Your files as a folder that downloads on demand, synced
local folders, Photos, sharing, and a native GTK4 app with tray and search launcher, plus a
scriptable CLI.
**[proton-drive.narl.io](https://proton-drive.narl.io)**
[![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)
[![AUR](https://img.shields.io/aur/version/proton-drive-for-linux)](https://aur.archlinux.org/packages/proton-drive-for-linux)
[![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)
[![Buy me a coffee](https://img.shields.io/badge/Buy%20me%20a%20coffee-FFDD00?logo=buymeacoffee&logoColor=black)](https://buymeacoffee.com/narl)
<img src="images/files.png" alt="My files in the Proton Drive app" 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. Try it with data you have another copy of first, and
> read [what survives a lost machine](docs/RECOVERY.md) before you rely on it.
## Features
- **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.
- **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.
- **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.
- **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.
- **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.
**Files**
## Quick Search and Media Opening
- **Your Drive as a folder.** `~/ProtonDrive` works with every program. Files download when they
are opened, a block at a time, so a video starts playing and a large archive can be read
without fetching all of it.
- **Available offline.** Keep chosen files and folders fully downloaded; everything else is
cached within a size limit you set.
- **Synced folders.** Back up folders such as `~/Documents` under your computer in Proton Drive,
as a full two-way copy or online only. Gitignore-style `.pdfsignore` rules, conflict copies
instead of overwrites, pause and bandwidth limits.
- **Works at local speed.** Making, renaming and deleting files and folders returns at once, online
or offline. Changes are staged on disk and queued before they are acknowledged, so they survive
a lost connection, a crash or a reboot and reach Drive when they can.
`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.
**Desktop app**
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.
- **Browse** My files, Shared with me, Shared by me, Computers and Trash, with thumbnails
(including camera RAW), drag and drop, details and **version history**.
- **Photos.** Timeline with favorites, albums, and Places on a map; RAW+JPEG pairs shown as one
photo; duplicate finder; slideshow; **import from Google Photos** (Takeout).
- **Sharing.** Invite people as viewer, editor or admin, create public links with password and
expiry, accept invitations.
- **Sync overview.** What is uploading, what is waiting, what failed and why, and a history of
what happened. A change Drive refuses (storage full, access taken away) shows at once, on the
file too, and can be exported or discarded.
- **Tray icon** with status, pause and quick actions, and a **search launcher** (`pdfs-prompt`)
that searches Proton Drive and your home folder together, as its own window, through fuzzel,
rofi or wofi, or live in `fzf`.
- **Restore a computer.** Continue a backup on a new machine and bring its folders back in one
step.
- **Sign-in** with two-factor authentication and CAPTCHA; the session is kept in the system
keyring.
- Available in English, German, Spanish, French, Italian, Dutch, Polish and Brazilian
Portuguese.
### Using your own launcher (fuzzel, rofi, wofi, …)
**Command line**
`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:
- About 55 `pdfs` commands cover files, sync, sharing, Photos, Trash, versions and diagnostics.
Query commands print JSON with `--json`.
## Install
| Distribution | Package |
|---|---|
| Fedora 44 or newer | [`narl-rs/proton-drive-linux`](https://copr.fedorainfracloud.org/coprs/narl-rs/proton-drive-linux/) in COPR, updated with `dnf upgrade` |
| Arch Linux | [`proton-drive-for-linux`](https://aur.archlinux.org/packages/proton-drive-for-linux) (also `-bin` and `-git`) in the AUR |
| Debian, Ubuntu 24.04 or newer | `.deb` from the [latest release](https://github.com/narrrl/proton-drive-linux/releases/latest) |
| NixOS | The flake in this repository: see [Installation → NixOS](docs/INSTALL.md#nixos-flake) |
| Anything else | [Build from source](docs/INSTALL.md#build-from-source) |
**Fedora**
```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
sudo dnf copr enable narl-rs/proton-drive-linux
sudo dnf install proton-drive-linux
```
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:
**Arch Linux**
- `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
}
}
```bash
paru -S proton-drive-for-linux
```
`--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.
**Debian and Ubuntu:** download the `.deb` from the
[latest release](https://github.com/narrrl/proton-drive-linux/releases/latest), then:
## 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"] }
]
}
}
```bash
sudo apt install ./proton-drive-linux_*_amd64.deb
```
- `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.
Then open **Proton Drive** from your applications menu and sign in. The app starts the background
service, and your files appear in `~/ProtonDrive`.
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`.
Signing in from a terminal instead? `pdfs login` stores the session but does not start the
service. Enable it once:
## 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.
```bash
pdfs login
systemctl --user enable --now proton-drive.service
pdfs status
```
It exits non-zero if any check fails, so it works in a health-check script.
New to the client? [Getting started](docs/GETTING_STARTED.md) walks through the first steps.
[Installation](docs/INSTALL.md) has the optional extras (tray on GNOME, RAW and video
thumbnails), updating, headless use and uninstalling.
For the local metadata database and content cache:
## Documentation
| Command | What it does |
The documentation is in [`docs/`](docs/README.md) and on the
[wiki](https://github.com/narrrl/proton-drive-linux/wiki).
| | |
|---|---|
| `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`.
| [Getting started](docs/GETTING_STARTED.md) | From installing to your first synced folder |
| [User guide](docs/USER_GUIDE.md) | Everything the app, tray and launcher do |
| [FAQ](docs/FAQ.md) | Short answers to common questions |
| [Command-line reference](docs/CLI.md) | Every `pdfs` command and scripting with `--json` |
| [Configuration](docs/CONFIGURATION.md) | `config.json`, file locations, environment variables, the service |
| [Troubleshooting](docs/TROUBLESHOOTING.md) | Diagnosing problems and fixing common ones |
| [Recovering on a new computer](docs/RECOVERY.md) | Getting your synced folders back after a reinstall or a lost machine |
| [Security model](docs/SECURITY_MODEL.md) | What protects your files, what stays on this computer, what the client connects to |
| [Changelog](docs/CHANGELOG.md) | What changed in each release |
For contributors: [development guide](docs/DEVELOPMENT.md), [architecture](docs/ARCHITECTURE.md),
[testing](docs/TESTING.md), [translating](docs/TRANSLATING.md), [roadmap](docs/ROADMAP.md) and the
[bug ledger](docs/BUGS.md). The [documentation index](docs/README.md) lists everything.
## Screenshots
### GUI Application & Launcher
<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>
<td align="center" width="50%"><img src="images/files.png" alt="My files" width="100%"><br><sub><b>My files</b></sub></td>
<td align="center" width="50%"><img src="images/photos.png" alt="Photos timeline" width="100%"><br><sub><b>Photos</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>
<td align="center" width="50%"><img src="images/photos_map.png" alt="Photos places map" width="100%"><br><sub><b>Places</b></sub></td>
<td align="center" width="50%"><img src="images/computers.png" alt="Computers" width="100%"><br><sub><b>Computers</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>
<td align="center" width="50%"><img src="images/sync.png" alt="Sync folders" width="100%"><br><sub><b>Sync</b></sub></td>
<td align="center" width="50%"><img src="images/conflicts.png" alt="Sync history showing resolved conflicts" width="100%"><br><sub><b>Sync history</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>
<td align="center" width="50%"><img src="images/prompt_gtk.png" alt="Search launcher" width="100%"><br><sub><b>Search launcher</b></sub></td>
<td align="center" width="50%"><img src="images/prompt_fzf.png" alt="Search launcher in fzf" width="100%"><br><sub><b>Search launcher in fzf</b></sub></td>
</tr>
<tr>
<td align="center" width="50%"><img src="images/login.png" alt="Sign in" width="100%"><br><sub><b>Sign in</b></sub></td>
<td></td>
</tr>
</table>
---
## How it works
## Prerequisites
A background service (`proton-drive.service`, the `pdfs daemon` command) holds your session,
mounts `~/ProtonDrive` through FUSE, runs the upload queue and the sync engine, and follows
Proton's event stream for changes made elsewhere. The app, the tray, the launcher and the CLI are
clients of that service over a local socket. Everything is end-to-end encrypted with your Proton
keys before it leaves the machine, through a Rust implementation of Proton's Drive SDK.
To compile the application from source or run the built binaries, ensure you have the following system libraries installed on your distribution:
Files you open are decrypted into a local cache so programs can read them. What that means for
the security of the machine is spelled out in the
[security model](docs/SECURITY_MODEL.md#what-is-kept-on-this-computer), and in full detail in
the [threat model](docs/ARCHITECTURE.md#10-threat-model-what-this-client-writes-to-disk-in-plaintext).
### Ubuntu / Debian (24.04+)
```bash
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
```
## Contributing
### Arch Linux
```bash
sudo pacman -S --needed pkgconf fuse3 gtk4 libadwaita libsecret dbus webkitgtk-6.0
```
Bug reports, translations and patches are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for the
workflow and the checks CI runs, and [SECURITY.md](SECURITY.md) to report a vulnerability
privately.
### 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 cargo rust
```
## Support
Runtime extras (pick your desktop):
```bash
# GNOME — keyring + tray (AppIndicator)
sudo dnf install -y gnome-keyring gnome-shell-extension-appindicator xdg-utils
Proton Drive Linux is free and built in spare time. If it saves you a few headaches, you can
[buy me a coffee](https://buymeacoffee.com/narl).
# KDE Plasma — KWallet (Secret Service); tray works via built-in SNI
sudo dnf install -y kwallet xdg-utils
```
---
## Building from Source
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
cd proton-drive-linux
```
2. Build the workspace in release mode:
```bash
cargo build --release --locked
```
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.
---
## Installation & Packages
### 1. Debian / Ubuntu (.deb)
Install the debian package via `dpkg` or `apt`:
```bash
sudo apt install ./proton-drive-linux_*.deb
```
### 2. Arch Linux
A local `PKGBUILD` is available under the `packaging/` directory. You can build and install it using:
```bash
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)
This project has a GitHub Actions CI workflow configured under `.github/workflows/release.yml`.
### How it works:
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. **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`, `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`, `.rpm`, and `.tar.gz` packages as release assets.
- For manual runs, compiles and exposes the packages as workflow run artifacts for testing.
---
<a href="https://buymeacoffee.com/narl"><img src="https://img.shields.io/badge/Buy%20me%20a%20coffee-FFDD00?logo=buymeacoffee&logoColor=black" alt="Buy me a coffee"></a>
## License
This project is licensed under the [MIT License](LICENSE).
Released under the [MIT License](LICENSE).
The Places map uses [GeoNames](https://www.geonames.org/) data (CC BY 4.0) and
[Natural Earth](https://www.naturalearthdata.com/) outlines (public domain). The optional street
map comes from [OpenFreeMap](https://openfreemap.org/), © [OpenStreetMap](https://www.openstreetmap.org/copyright)
contributors.
Proton Drive is a service of Proton AG. This project is an independent client and carries no
affiliation with or endorsement from Proton AG.
+42
View File
@@ -0,0 +1,42 @@
# Security Policy
## Supported versions
Fixes land on the latest release. Please reproduce on the most recent version before reporting.
For what the client protects and what it does not, written for users, see the
[security model](docs/SECURITY_MODEL.md).
## 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.
What the client deliberately keeps in plaintext on disk, and why, is documented in the
[threat model](docs/ARCHITECTURE.md#10-threat-model-what-this-client-writes-to-disk-in-plaintext).
Behaviour described there is known and not a vulnerability by itself; a way to make it worse
than described is.
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.
+3
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]]
@@ -21,3 +23,4 @@ tracing-subscriber.workspace = true
clap.workspace = true
rpassword.workspace = true
serde_json.workspace = true
zeroize.workspace = true
+122
View File
@@ -0,0 +1,122 @@
//! Log priorities for journald.
//!
//! journald files each line a service writes at the unit's default priority,
//! info, unless the line opens with a syslog `<N>` prefix. Without one every
//! WARN and ERROR the daemon logs is stored as info, and the
//! `journalctl -p warning` that the troubleshooting guide asks for shows
//! nothing (`docs/BUGS.md` B258).
use std::fmt;
use std::os::unix::fs::MetadataExt;
use tracing::{Event, Level, Subscriber};
use tracing_subscriber::fmt::format::Writer;
use tracing_subscriber::fmt::{FmtContext, FormatEvent, FormatFields};
use tracing_subscriber::registry::LookupSpan;
/// Whether standard output is the journal stream systemd connected this
/// process to.
///
/// `JOURNAL_STREAM` alone does not say so: a terminal started from a user unit
/// inherits it, and every `pdfs` run in that terminal would print the
/// prefixes. systemd sets it to the stream's `device:inode`, so it is compared
/// against what fd 1 actually is.
pub(crate) fn stdout_is_journal() -> bool {
let Some(stream) = std::env::var_os("JOURNAL_STREAM") else {
return false;
};
let Ok(stdout) = std::fs::metadata("/proc/self/fd/1") else {
return false;
};
stream
.to_str()
.is_some_and(|value| names_stream(value, stdout.dev(), stdout.ino()))
}
/// Whether a `JOURNAL_STREAM` value names the file `dev`:`ino`.
fn names_stream(value: &str, dev: u64, ino: u64) -> bool {
value
.split_once(':')
.is_some_and(|(d, i)| d.parse() == Ok(dev) && i.parse() == Ok(ino))
}
/// The syslog priority journald should file a `level` line at.
fn priority(level: &Level) -> u8 {
match *level {
Level::ERROR => 3,
Level::WARN => 4,
Level::INFO => 6,
Level::DEBUG | Level::TRACE => 7,
}
}
/// An event format that opens each line with its `<N>` priority, then writes
/// the line as `F` would.
pub(crate) struct Prioritized<F>(pub(crate) F);
impl<S, N, F> FormatEvent<S, N> for Prioritized<F>
where
S: Subscriber + for<'a> LookupSpan<'a>,
N: for<'a> FormatFields<'a> + 'static,
F: FormatEvent<S, N>,
{
fn format_event(
&self,
ctx: &FmtContext<'_, S, N>,
mut writer: Writer<'_>,
event: &Event<'_>,
) -> fmt::Result {
write!(writer, "<{}>", priority(event.metadata().level()))?;
self.0.format_event(ctx, writer, event)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io;
use std::sync::{Arc, Mutex};
#[derive(Clone)]
struct Buf(Arc<Mutex<Vec<u8>>>);
impl io::Write for Buf {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[test]
fn each_line_opens_with_the_priority_of_its_level() {
let buf = Buf(Arc::new(Mutex::new(Vec::new())));
let writer = buf.clone();
let subscriber = tracing_subscriber::fmt()
.with_max_level(Level::DEBUG)
.with_ansi(false)
.with_writer(move || writer.clone())
.event_format(Prioritized(tracing_subscriber::fmt::format()))
.finish();
tracing::subscriber::with_default(subscriber, || {
tracing::error!("e");
tracing::warn!("w");
tracing::info!("i");
tracing::debug!("d");
});
let out = String::from_utf8(buf.0.lock().unwrap().clone()).unwrap();
let prefixes: Vec<&str> = out.lines().map(|line| &line[..3]).collect();
assert_eq!(prefixes, ["<3>", "<4>", "<6>", "<7>"]);
}
#[test]
fn journal_stream_must_name_the_same_file() {
assert!(names_stream("8:12345", 8, 12345));
assert!(!names_stream("8:12345", 8, 54321));
assert!(!names_stream("9:12345", 8, 12345));
assert!(!names_stream("8", 8, 12345));
assert!(!names_stream("", 8, 12345));
}
}
+2026 -119
View File
File diff suppressed because it is too large. Load diff
+7 -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]
@@ -21,7 +23,11 @@ sha2.workspace = true
sha1.workspace = true
zip.workspace = true
rusqlite.workspace = true
keyring.workspace = true
keyring-core.workspace = true
dbus-secret-service-keyring-store.workspace = true
parking_lot.workspace = true
async-trait.workspace = true
libc.workspace = true
zeroize.workspace = true
base64.workspace = true
exif = { version = "0.6", package = "kamadak-exif" }
File diff suppressed because it is too large. Load diff
+250
View File
@@ -0,0 +1,250 @@
AD Andorra
AE United Arab Emirates
AF Afghanistan
AG Antigua and Barbuda
AI Anguilla
AL Albania
AM Armenia
AO Angola
AQ Antarctica
AR Argentina
AS American Samoa
AT Austria
AU Australia
AW Aruba
AX Åland Islands
AZ Azerbaijan
BA Bosnia and Herzegovina
BB Barbados
BD Bangladesh
BE Belgium
BF Burkina Faso
BG Bulgaria
BH Bahrain
BI Burundi
BJ Benin
BL Saint Barthélemy
BM Bermuda
BN Brunei Darussalam
BO Bolivia, Plurinational State of
BQ Bonaire, Sint Eustatius and Saba
BR Brazil
BS Bahamas
BT Bhutan
BV Bouvet Island
BW Botswana
BY Belarus
BZ Belize
CA Canada
CC Cocos (Keeling) Islands
CD Congo, The Democratic Republic of the
CF Central African Republic
CG Congo
CH Switzerland
CI Côte d'Ivoire
CK Cook Islands
CL Chile
CM Cameroon
CN China
CO Colombia
CR Costa Rica
CU Cuba
CV Cabo Verde
CW Curaçao
CX Christmas Island
CY Cyprus
CZ Czechia
DE Germany
DJ Djibouti
DK Denmark
DM Dominica
DO Dominican Republic
DZ Algeria
EC Ecuador
EE Estonia
EG Egypt
EH Western Sahara
ER Eritrea
ES Spain
ET Ethiopia
FI Finland
FJ Fiji
FK Falkland Islands (Malvinas)
FM Micronesia, Federated States of
FO Faroe Islands
FR France
GA Gabon
GB United Kingdom
GD Grenada
GE Georgia
GF French Guiana
GG Guernsey
GH Ghana
GI Gibraltar
GL Greenland
GM Gambia
GN Guinea
GP Guadeloupe
GQ Equatorial Guinea
GR Greece
GS South Georgia and the South Sandwich Islands
GT Guatemala
GU Guam
GW Guinea-Bissau
GY Guyana
HK Hong Kong
HM Heard Island and McDonald Islands
HN Honduras
HR Croatia
HT Haiti
HU Hungary
ID Indonesia
IE Ireland
IL Israel
IM Isle of Man
IN India
IO British Indian Ocean Territory
IQ Iraq
IR Iran, Islamic Republic of
IS Iceland
IT Italy
JE Jersey
JM Jamaica
JO Jordan
JP Japan
KE Kenya
KG Kyrgyzstan
KH Cambodia
KI Kiribati
KM Comoros
KN Saint Kitts and Nevis
KP Korea, Democratic People's Republic of
KR Korea, Republic of
KW Kuwait
KY Cayman Islands
KZ Kazakhstan
LA Lao People's Democratic Republic
LB Lebanon
LC Saint Lucia
LI Liechtenstein
LK Sri Lanka
LR Liberia
LS Lesotho
LT Lithuania
LU Luxembourg
LV Latvia
LY Libya
MA Morocco
MC Monaco
MD Moldova, Republic of
ME Montenegro
MF Saint Martin (French part)
MG Madagascar
MH Marshall Islands
MK North Macedonia
ML Mali
MM Myanmar
MN Mongolia
MO Macao
MP Northern Mariana Islands
MQ Martinique
MR Mauritania
MS Montserrat
MT Malta
MU Mauritius
MV Maldives
MW Malawi
MX Mexico
MY Malaysia
MZ Mozambique
NA Namibia
NC New Caledonia
NE Niger
NF Norfolk Island
NG Nigeria
NI Nicaragua
NL Netherlands
NO Norway
NP Nepal
NR Nauru
NU Niue
NZ New Zealand
OM Oman
PA Panama
PE Peru
PF French Polynesia
PG Papua New Guinea
PH Philippines
PK Pakistan
PL Poland
PM Saint Pierre and Miquelon
PN Pitcairn
PR Puerto Rico
PS Palestine, State of
PT Portugal
PW Palau
PY Paraguay
QA Qatar
RE Réunion
RO Romania
RS Serbia
RU Russian Federation
RW Rwanda
SA Saudi Arabia
SB Solomon Islands
SC Seychelles
SD Sudan
SE Sweden
SG Singapore
SH Saint Helena, Ascension and Tristan da Cunha
SI Slovenia
SJ Svalbard and Jan Mayen
SK Slovakia
SL Sierra Leone
SM San Marino
SN Senegal
SO Somalia
SR Suriname
SS South Sudan
ST Sao Tome and Principe
SV El Salvador
SX Sint Maarten (Dutch part)
SY Syrian Arab Republic
SZ Eswatini
TC Turks and Caicos Islands
TD Chad
TF French Southern Territories
TG Togo
TH Thailand
TJ Tajikistan
TK Tokelau
TL Timor-Leste
TM Turkmenistan
TN Tunisia
TO Tonga
TR Türkiye
TT Trinidad and Tobago
TV Tuvalu
TW Taiwan, Province of China
TZ Tanzania, United Republic of
UA Ukraine
UG Uganda
UM United States Minor Outlying Islands
US United States
UY Uruguay
UZ Uzbekistan
VA Holy See (Vatican City State)
VC Saint Vincent and the Grenadines
VE Venezuela, Bolivarian Republic of
VG Virgin Islands, British
VI Virgin Islands, U.S.
VN Viet Nam
VU Vanuatu
WF Wallis and Futuna
WS Samoa
XK Kosovo
YE Yemen
YT Mayotte
ZA South Africa
ZM Zambia
ZW Zimbabwe
1 AD Andorra
2 AE United Arab Emirates
3 AF Afghanistan
4 AG Antigua and Barbuda
5 AI Anguilla
6 AL Albania
7 AM Armenia
8 AO Angola
9 AQ Antarctica
10 AR Argentina
11 AS American Samoa
12 AT Austria
13 AU Australia
14 AW Aruba
15 AX Åland Islands
16 AZ Azerbaijan
17 BA Bosnia and Herzegovina
18 BB Barbados
19 BD Bangladesh
20 BE Belgium
21 BF Burkina Faso
22 BG Bulgaria
23 BH Bahrain
24 BI Burundi
25 BJ Benin
26 BL Saint Barthélemy
27 BM Bermuda
28 BN Brunei Darussalam
29 BO Bolivia, Plurinational State of
30 BQ Bonaire, Sint Eustatius and Saba
31 BR Brazil
32 BS Bahamas
33 BT Bhutan
34 BV Bouvet Island
35 BW Botswana
36 BY Belarus
37 BZ Belize
38 CA Canada
39 CC Cocos (Keeling) Islands
40 CD Congo, The Democratic Republic of the
41 CF Central African Republic
42 CG Congo
43 CH Switzerland
44 CI Côte d'Ivoire
45 CK Cook Islands
46 CL Chile
47 CM Cameroon
48 CN China
49 CO Colombia
50 CR Costa Rica
51 CU Cuba
52 CV Cabo Verde
53 CW Curaçao
54 CX Christmas Island
55 CY Cyprus
56 CZ Czechia
57 DE Germany
58 DJ Djibouti
59 DK Denmark
60 DM Dominica
61 DO Dominican Republic
62 DZ Algeria
63 EC Ecuador
64 EE Estonia
65 EG Egypt
66 EH Western Sahara
67 ER Eritrea
68 ES Spain
69 ET Ethiopia
70 FI Finland
71 FJ Fiji
72 FK Falkland Islands (Malvinas)
73 FM Micronesia, Federated States of
74 FO Faroe Islands
75 FR France
76 GA Gabon
77 GB United Kingdom
78 GD Grenada
79 GE Georgia
80 GF French Guiana
81 GG Guernsey
82 GH Ghana
83 GI Gibraltar
84 GL Greenland
85 GM Gambia
86 GN Guinea
87 GP Guadeloupe
88 GQ Equatorial Guinea
89 GR Greece
90 GS South Georgia and the South Sandwich Islands
91 GT Guatemala
92 GU Guam
93 GW Guinea-Bissau
94 GY Guyana
95 HK Hong Kong
96 HM Heard Island and McDonald Islands
97 HN Honduras
98 HR Croatia
99 HT Haiti
100 HU Hungary
101 ID Indonesia
102 IE Ireland
103 IL Israel
104 IM Isle of Man
105 IN India
106 IO British Indian Ocean Territory
107 IQ Iraq
108 IR Iran, Islamic Republic of
109 IS Iceland
110 IT Italy
111 JE Jersey
112 JM Jamaica
113 JO Jordan
114 JP Japan
115 KE Kenya
116 KG Kyrgyzstan
117 KH Cambodia
118 KI Kiribati
119 KM Comoros
120 KN Saint Kitts and Nevis
121 KP Korea, Democratic People's Republic of
122 KR Korea, Republic of
123 KW Kuwait
124 KY Cayman Islands
125 KZ Kazakhstan
126 LA Lao People's Democratic Republic
127 LB Lebanon
128 LC Saint Lucia
129 LI Liechtenstein
130 LK Sri Lanka
131 LR Liberia
132 LS Lesotho
133 LT Lithuania
134 LU Luxembourg
135 LV Latvia
136 LY Libya
137 MA Morocco
138 MC Monaco
139 MD Moldova, Republic of
140 ME Montenegro
141 MF Saint Martin (French part)
142 MG Madagascar
143 MH Marshall Islands
144 MK North Macedonia
145 ML Mali
146 MM Myanmar
147 MN Mongolia
148 MO Macao
149 MP Northern Mariana Islands
150 MQ Martinique
151 MR Mauritania
152 MS Montserrat
153 MT Malta
154 MU Mauritius
155 MV Maldives
156 MW Malawi
157 MX Mexico
158 MY Malaysia
159 MZ Mozambique
160 NA Namibia
161 NC New Caledonia
162 NE Niger
163 NF Norfolk Island
164 NG Nigeria
165 NI Nicaragua
166 NL Netherlands
167 NO Norway
168 NP Nepal
169 NR Nauru
170 NU Niue
171 NZ New Zealand
172 OM Oman
173 PA Panama
174 PE Peru
175 PF French Polynesia
176 PG Papua New Guinea
177 PH Philippines
178 PK Pakistan
179 PL Poland
180 PM Saint Pierre and Miquelon
181 PN Pitcairn
182 PR Puerto Rico
183 PS Palestine, State of
184 PT Portugal
185 PW Palau
186 PY Paraguay
187 QA Qatar
188 RE Réunion
189 RO Romania
190 RS Serbia
191 RU Russian Federation
192 RW Rwanda
193 SA Saudi Arabia
194 SB Solomon Islands
195 SC Seychelles
196 SD Sudan
197 SE Sweden
198 SG Singapore
199 SH Saint Helena, Ascension and Tristan da Cunha
200 SI Slovenia
201 SJ Svalbard and Jan Mayen
202 SK Slovakia
203 SL Sierra Leone
204 SM San Marino
205 SN Senegal
206 SO Somalia
207 SR Suriname
208 SS South Sudan
209 ST Sao Tome and Principe
210 SV El Salvador
211 SX Sint Maarten (Dutch part)
212 SY Syrian Arab Republic
213 SZ Eswatini
214 TC Turks and Caicos Islands
215 TD Chad
216 TF French Southern Territories
217 TG Togo
218 TH Thailand
219 TJ Tajikistan
220 TK Tokelau
221 TL Timor-Leste
222 TM Turkmenistan
223 TN Tunisia
224 TO Tonga
225 TR Türkiye
226 TT Trinidad and Tobago
227 TV Tuvalu
228 TW Taiwan, Province of China
229 TZ Tanzania, United Republic of
230 UA Ukraine
231 UG Uganda
232 UM United States Minor Outlying Islands
233 US United States
234 UY Uruguay
235 UZ Uzbekistan
236 VA Holy See (Vatican City State)
237 VC Saint Vincent and the Grenadines
238 VE Venezuela, Bolivarian Republic of
239 VG Virgin Islands, British
240 VI Virgin Islands, U.S.
241 VN Viet Nam
242 VU Vanuatu
243 WF Wallis and Futuna
244 WS Samoa
245 XK Kosovo
246 YE Yemen
247 YT Mayotte
248 ZA South Africa
249 ZM Zambia
250 ZW Zimbabwe
+1079 -125
View File
File diff suppressed because it is too large. Load diff
+189 -43
View File
@@ -19,6 +19,7 @@
use std::collections::{BTreeMap, HashMap, HashSet};
use std::fmt;
use std::io::{Read, Seek, SeekFrom};
use std::os::unix::fs::PermissionsExt as _;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
@@ -90,7 +91,7 @@ const TOUCH_FLUSH_AT: usize = 256;
///
/// Public because it is the *contract* a block has to satisfy, not a detail of
/// this cache: the network read path validates against it too, so a block that
/// comes back short is refused before it can truncate a file (bugs.md B84).
/// comes back short is refused before it can truncate a file (`docs/BUGS.md` B84).
pub fn block_len(size: u64, idx: u64) -> u64 {
let start = idx.saturating_mul(BLOCK_SIZE);
size.saturating_sub(start).min(BLOCK_SIZE)
@@ -297,6 +298,10 @@ pub struct Pin {
/// which is why it is defaulted.
#[serde(default)]
pub is_dir: Option<bool>,
/// A pinned file whose content is on disk right now. The daemon fills it
/// in when it answers `ListPins`; folders and older daemons say `false`.
#[serde(default)]
pub cached: bool,
}
/// The legacy JSON pin registry, kept only to import a pre-P5 `pins.json` into
@@ -306,8 +311,7 @@ struct PinFile {
pins: BTreeMap<String, Pin>,
}
/// What a staged file actually contains, written beside it as `<name>.json`
/// (offline.md Phase 2).
/// What a staged file actually contains, written beside it as `<name>.json`.
///
/// A staged file is **not** necessarily valid whole-file content, and that is
/// the whole reason this exists. A partial overwrite commits by filling the
@@ -360,6 +364,14 @@ pub struct StagedWrite {
pub based_on: Option<Baseline>,
}
impl StagedWrite {
/// Whether `authored` ranges cover a file of `len` bytes, so the staged
/// bytes are its complete new content. An empty file has nothing to cover.
pub fn covers(authored: &[(u64, u64)], len: u64) -> bool {
len == 0 || authored == [(0, len)]
}
}
/// The identity of a remote revision, as far as we can observe it.
///
/// `revision_id` is the authoritative identity when present: the server assigns a
@@ -415,6 +427,10 @@ pub struct ContentCache {
/// Subdirectory holding cached thumbnails (`<key>.t<n>` + `.meta`). Kept out
/// of `content_dir` so the budget scan never sees thumbnail files.
thumb_dir: PathBuf,
/// Owner-protected scratch space for decrypted camera RAWs passed to
/// exiftool. Files are removed on drop and swept on the next cache open after
/// an unclean shutdown.
raw_thumb_dir: PathBuf,
/// Subdirectory holding on-demand block-cache files (`<key>.b<idx>` +
/// `.meta`). Kept out of `content_dir` so the whole-file budget scan never
/// sees them; blocks carry their own LRU budget.
@@ -422,9 +438,9 @@ pub struct ContentCache {
/// Subdirectory for write-handle scratch files (disk-backed write buffers).
/// Emptied on open so a crashed run leaves no orphans.
scratch_dir: PathBuf,
/// Subdirectory holding the bytes of writes that have not been uploaded yet
/// (offline.md Phase 2/3) — every released write passes through here, since
/// the upload is a queued op performed later. Unlike `scratch_dir` this is
/// Subdirectory holding the bytes of writes that have not been uploaded yet.
/// Every released write passes through here, since the upload is a queued
/// op performed later. Unlike `scratch_dir` this is
/// **never** emptied on open: these are the only copy of content the user
/// authored, and the whole point is that they outlive the daemon.
staging_dir: PathBuf,
@@ -459,7 +475,7 @@ pub struct ContentCache {
thumb_bytes: AtomicU64,
/// Unified metadata DB. Its `cache_entries` table is the LRU index: every
/// store/read/evict updates it, and the budget enforcers query it instead of
/// scanning the cache directories (plan.md P4).
/// scanning the cache directories.
db: Arc<Db>,
/// LRU touches not yet written to `cache_entries`, keyed by cache key.
///
@@ -507,6 +523,19 @@ impl ContentCache {
std::fs::create_dir_all(&content_dir)?;
let thumb_dir = content_dir.join("thumbs");
std::fs::create_dir_all(&thumb_dir)?;
let raw_thumb_dir = content_dir.join("raw-thumbnails");
std::fs::create_dir_all(&raw_thumb_dir)?;
std::fs::set_permissions(&raw_thumb_dir, std::fs::Permissions::from_mode(0o700))?;
if let Ok(entries) = std::fs::read_dir(&raw_thumb_dir) {
for entry in entries.flatten() {
let name = entry.file_name();
if name.to_string_lossy().starts_with("pdfs-raw-")
&& entry.file_type().is_ok_and(|kind| kind.is_file())
{
let _ = std::fs::remove_file(entry.path());
}
}
}
let block_dir = content_dir.join("blocks");
std::fs::create_dir_all(&block_dir)?;
// Scratch holds disk-backed write buffers. A previous run's leftovers are
@@ -530,6 +559,7 @@ impl ContentCache {
let cache = Self {
content_dir,
thumb_dir,
raw_thumb_dir,
block_dir,
scratch_dir,
staging_dir,
@@ -707,6 +737,60 @@ impl ContentCache {
Some(buf)
}
/// Write the whole content of `uid` at `(mtime, size)` to `dst` from the
/// cache alone, and report whether that worked.
///
/// A whole-file blob is cloned. Otherwise every block of the revision has to
/// be cached, under its recorded geometry or, failing that, the uniform one
/// the read path would use. Anything short of the full content leaves
/// nothing at `dst`, so a caller falls back to a download with a clean slate.
///
/// This is what lets a mirror folder take a file an on-demand mount has
/// already read, after a move between them, without fetching it again. The
/// trust is the read path's: the same `(mtime, size)` key, and blocks that
/// were validated when they were fetched.
pub fn copy_cached_to(&self, uid: &NodeUid, mtime: i64, size: u64, dst: &Path) -> bool {
if size == 0 {
return false;
}
if let Some(blob) = self.valid_blob(uid, mtime, size) {
if Self::clone_or_copy(&blob, dst).is_ok_and(|len| len == size) {
self.touch(Self::key(uid));
return true;
}
let _ = std::fs::remove_file(dst);
return false;
}
let geometry = self
.block_geometry(uid, mtime, size)
.unwrap_or_else(|| BlockGeometry::uniform(size));
if geometry.size() != size {
return false;
}
let spans = geometry.spans(0, size);
// Checked up front so a file with one block missing costs stats, not a
// copy of every block before it.
if !spans
.iter()
.all(|&span| self.has_block(uid, mtime, size, span))
{
return false;
}
let written = (|| -> Option<()> {
use std::io::Write;
let mut out = std::fs::File::create(dst).ok()?;
for span in spans {
out.write_all(&self.cached_block(uid, mtime, size, span)?)
.ok()?;
}
(out.metadata().ok()?.len() == size).then_some(())
})();
if written.is_none() {
let _ = std::fs::remove_file(dst);
}
written.is_some()
}
/// Store `bytes` as the cached content for `uid`, tagged with `(mtime,
/// size)`. The blob is written to a temp file then renamed so a concurrent
/// reader never sees a partial blob; the meta tag is written last so a
@@ -760,6 +844,9 @@ impl ContentCache {
}
let bytes = std::fs::metadata(&tmp)?.len();
std::fs::rename(&tmp, &blob)?;
// Linked from the blob already cached, the temp name was a second name
// of it, and the rename did nothing (`docs/BUGS.md` B187).
let _ = std::fs::remove_file(&tmp);
let meta = serde_json::to_vec(&Meta { mtime, size })?;
std::fs::write(self.meta_path(uid), meta)?;
self.db
@@ -901,7 +988,7 @@ impl ContentCache {
/// a miss ([`cached_block`](Self::cached_block) checks), so a bad block
/// cannot escape this cache even though this does not check. Validating a
/// block is the *reader's* job, at the point it is fetched — see
/// `Core::read_block` (bugs.md B84).
/// `Core::read_block` (`docs/BUGS.md` B84).
pub fn store_block(
&self,
uid: &NodeUid,
@@ -1403,9 +1490,8 @@ impl ContentCache {
/// This is what makes a write survive its upload: the caller is releasing a
/// write handle and would otherwise delete the file, so until the bytes are
/// on the remote, staging holds the only copy. Every dirty handle goes
/// through here — the upload is a queued op performed later (offline.md
/// Phase 3), and a staged file is also what a human can recover from if the
/// queue never drains.
/// through here — the upload is a queued op performed later, and a staged
/// file is also what a human can recover from if the queue never drains.
///
/// Falls back to a copy when the rename crosses a filesystem boundary; a
/// failure here means we could not save the bytes at all, so it is reported
@@ -1666,6 +1752,12 @@ impl ContentCache {
.join(format!("{}.t{ttype}.meta", Self::key(uid)))
}
/// Owner-protected transient directory used to stage decrypted camera RAWs
/// for metadata helpers. Callers still remove individual files promptly.
pub fn raw_thumbnail_staging_dir(&self) -> &Path {
&self.raw_thumb_dir
}
/// Serve a cached thumbnail of `ttype` for `uid`, or `None` on miss/stale.
/// Validated by node `mtime` alone: a new revision (which is what changes the
/// thumbnail) bumps the mtime, so a stale thumbnail is detected. Thumbnails
@@ -1829,6 +1921,7 @@ impl ContentCache {
path: row.path,
recursive: row.recursive,
is_dir: row.is_dir,
cached: false,
})
.collect()
}
@@ -1972,6 +2065,17 @@ mod tests {
NodeUid::new(VolumeId::from("vol"), LinkId::from(link))
}
#[test]
fn an_empty_write_covers_the_file_and_a_gap_does_not() {
assert!(
StagedWrite::covers(&[], 0),
"an empty file has nothing to cover"
);
assert!(StagedWrite::covers(&[(0, 7)], 7));
assert!(!StagedWrite::covers(&[(0, 3), (4, 7)], 7));
assert!(!StagedWrite::covers(&[], 7));
}
/// A unique temp directory removed on drop; avoids a dev-dependency.
struct TempDir(PathBuf);
impl TempDir {
@@ -2094,6 +2198,24 @@ mod tests {
assert_eq!(c.read_range(&u, 200, 5, 0, 5).unwrap(), b"newer");
}
/// A landed create and the revision after it can adopt the same staged
/// blob. The link to the temp name is then a second name of the cached
/// blob, and a rename between two names of one file does nothing: the
/// temp name stayed until the next start swept it (`docs/BUGS.md` B187).
#[test]
fn store_file_twice_from_the_same_blob_leaves_no_temp_file() {
let (c, d) = cache();
let u = uid("a");
let src = d.path().join("staged-blob");
std::fs::write(&src, b"staged").unwrap();
c.store_file(&u, 100, 6, &src).unwrap();
c.store_file(&u, 100, 6, &src).unwrap();
assert!(!c.blob_path(&u).with_extension("tmp").exists());
assert_eq!(c.read_range(&u, 100, 6, 0, 6).unwrap(), b"staged");
}
#[test]
fn reconcile_rebuilds_index_from_disk() {
let dir = TempDir::new();
@@ -2187,42 +2309,26 @@ mod tests {
/// call also needs.
#[test]
fn budget_check_is_free_when_under_budget() {
use std::time::Instant;
// Cap far above what we store, so every store stays under budget.
let (c, _d) = cache_capped(1 << 30);
let payload = vec![0u8; 4096];
// Cost of a store against a near-empty index...
let t = Instant::now();
for i in 0..500u64 {
for i in 0..8u64 {
c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, i), &payload)
.unwrap();
}
let empty = t.elapsed() / 500;
// ...and against one with a few thousand entries in it.
for i in 500..2500u64 {
c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, i), &payload)
.unwrap();
}
let t = Instant::now();
for i in 2500..3000u64 {
c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, i), &payload)
.unwrap();
}
let full = t.elapsed() / 500;
// Plant an index row far over the cap behind the running total's back,
// and older than every real block. Any budget check that consults the
// index would see the pool over budget, evict, and reseed the total.
c.db.cache_touch("planted", KIND_BLOCK, 1 << 40, 0).unwrap();
println!("B4: store_block under budget — {empty:?} at ~0 entries, {full:?} at 2500");
// A ratio, not a wall-clock bound: the claim is that the under-budget
// path does not grow with the index, and only a ratio tests that. An
// absolute threshold measures the machine's load instead, and this test
// runs alongside the rest of the suite.
assert!(
full < empty * 3,
"under-budget store should not scale with the index: \
{empty:?} at ~0 entries vs {full:?} at 2500"
);
c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, 8), &payload)
.unwrap();
// Not a timing ratio: that measured the CI runner's load and flaked.
assert_eq!(c.block_bytes.load(Ordering::Relaxed), 9 * 4096);
let entries = c.db.cache_entries_by_kind(KIND_BLOCK).unwrap();
assert_eq!(entries.len(), 10, "under-budget store evicted: {entries:?}");
}
/// The running totals are a fast path, not a second source of truth: after
@@ -2531,6 +2637,46 @@ mod tests {
assert!(c.cached_block(&u, 100, 5000, blk(5000, 0)).is_none());
}
#[test]
fn a_cached_blob_is_copied_out_whole() {
let (c, d) = cache();
let u = uid("blob");
c.store(&u, 7, 5, b"hello").unwrap();
let dst = d.path().join("out");
assert!(c.copy_cached_to(&u, 7, 5, &dst));
assert_eq!(std::fs::read(&dst).unwrap(), b"hello");
// Another revision is not this one.
assert!(!c.copy_cached_to(&u, 8, 5, &d.path().join("stale")));
assert!(!d.path().join("stale").exists());
}
#[test]
fn cached_blocks_are_copied_out_in_order() {
let (c, d) = cache();
let u = uid("blocks");
let size = BLOCK_SIZE + 3;
let first = vec![1_u8; BLOCK_SIZE as usize];
c.store_block(&u, 7, size, blk(size, 0), &first).unwrap();
c.store_block(&u, 7, size, blk(size, 1), b"end").unwrap();
let dst = d.path().join("out");
assert!(c.copy_cached_to(&u, 7, size, &dst));
let bytes = std::fs::read(&dst).unwrap();
assert_eq!(bytes.len() as u64, size);
assert_eq!(&bytes[..BLOCK_SIZE as usize], first.as_slice());
assert_eq!(&bytes[BLOCK_SIZE as usize..], b"end");
}
#[test]
fn a_file_with_a_block_missing_is_not_copied_out() {
let (c, d) = cache();
let u = uid("partial");
let size = BLOCK_SIZE + 3;
c.store_block(&u, 7, size, blk(size, 1), b"end").unwrap();
let dst = d.path().join("out");
assert!(!c.copy_cached_to(&u, 7, size, &dst));
assert!(!dst.exists(), "a partial copy must not be left behind");
}
/// The [`BlockSpan`] the uniform geometry gives block `idx` of a file of
/// `size` bytes — the shape these tests were written against, and the one
/// this client's own uploads still have.
@@ -2543,7 +2689,7 @@ mod tests {
}
/// The geometry the whole read path validates against, here and over the
/// network (bugs.md B84): full blocks, then whatever is left.
/// network (`docs/BUGS.md` B84): full blocks, then whatever is left.
#[test]
fn block_len_is_full_blocks_then_the_remainder() {
let size = BLOCK_SIZE * 2 + 17;
@@ -2623,7 +2769,7 @@ mod tests {
/// A cached block is addressed by the range it covers, not by its index
/// alone. Re-planning a file onto its real geometry must therefore miss the
/// blocks the uniform fallback cached, rather than serve their bytes at the
/// wrong offset (bugs.md B85).
/// wrong offset (`docs/BUGS.md` B85).
#[test]
fn a_cached_block_is_not_served_to_a_different_geometry() {
let (c, _d) = cache();
@@ -2690,7 +2836,7 @@ mod tests {
/// A block of the wrong length cannot escape the cache even though the
/// store does not check: the read validates it and reports a miss, so the
/// next read refetches rather than serving a hole (bugs.md B84).
/// next read refetches rather than serving a hole (`docs/BUGS.md` B84).
#[test]
fn a_block_of_the_wrong_length_reads_as_a_miss() {
let (c, _d) = cache();
@@ -2755,7 +2901,7 @@ mod tests {
);
}
/// A failed upload must never cost the user their bytes (offline.md Phase 2):
/// A failed upload must never cost the user their bytes:
/// the scratch file moves to staging intact, and staging survives a reopen
/// the way scratch deliberately does not.
#[test]
+450 -28
View File
@@ -3,7 +3,7 @@
use std::path::{Path, PathBuf};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use serde::{Deserialize, Deserializer, Serialize};
use crate::error::{Error, Result};
use crate::menu::PromptConfig;
@@ -12,8 +12,19 @@ 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.
pub const APP_VERSION: &str = "external-drive-linux@0.1.0-alpha";
pub const USER_AGENT: &str = "proton-drive-linux/0.1.0";
/// Follows the release so Proton can tell releases apart and retire old ones
/// without retiring every one.
pub const APP_VERSION: &str = concat!(
"external-drive-linux@",
env!("CARGO_PKG_VERSION"),
"-stable"
);
pub const USER_AGENT: &str = concat!("proton-drive-linux/", env!("CARGO_PKG_VERSION"));
/// The fixed identity every release up to 3.1.0 wrote into `config.json`. Read
/// back as "unset" so those configs follow the release instead of pinning it.
const LEGACY_APP_VERSION: &str = "external-drive-linux@0.1.0-alpha";
const LEGACY_USER_AGENT: &str = "proton-drive-linux/0.1.0";
/// Keyring service name; one entry per credential kind keyed by username.
pub const KEYRING_SERVICE: &str = "proton-drive-linux";
@@ -76,15 +87,38 @@ fn resolve_sweep_mode(env: Option<&str>, stored: Option<SweepMode>) -> SweepMode
}
/// Configuration structure allowing the user to customize client identification headers.
#[derive(Serialize, Deserialize, Debug, Clone)]
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct AppConfig {
pub app_version: String,
pub user_agent: String,
/// Override for [`APP_VERSION`]. `None` means "use the release's own";
/// the old fixed default reads as `None`, so it leaves the file on the
/// next save.
#[serde(
default,
deserialize_with = "identity_override",
skip_serializing_if = "Option::is_none"
)]
pub app_version: Option<String>,
/// Override for [`USER_AGENT`], handled like `app_version`.
#[serde(
default,
deserialize_with = "identity_override",
skip_serializing_if = "Option::is_none"
)]
pub user_agent: Option<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>,
/// Upload cap in bytes per second shared by every upload. `None` or `0`
/// means no cap. Defaulted for configs predating the field.
#[serde(default)]
pub upload_limit: Option<u64>,
/// Download cap in bytes per second shared by every download, FUSE reads
/// included. `None` or `0` means no cap. Defaulted for configs predating
/// the field.
#[serde(default)]
pub download_limit: 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.
@@ -119,25 +153,176 @@ pub struct AppConfig {
/// field.
#[serde(default)]
pub prompt: Option<PromptConfig>,
/// Paint the desktop app in Proton's colours instead of the system theme
/// and accent colour. `None` means "follow the system". Defaulted for
/// configs predating the field; also read from `proton_accent`, its name
/// while it only changed the accent.
#[serde(default, alias = "proton_accent")]
pub proton_theme: Option<bool>,
/// How the desktop app's My Files page lays out and orders a folder.
/// Defaulted for configs predating the field.
#[serde(default)]
pub files_view: FilesView,
/// The desktop app's main window size, remembered between runs. Defaulted
/// for configs predating the field.
#[serde(default)]
pub window: WindowState,
/// The user hid the tray icon from its own menu. The tray exits at start
/// while this is set, and the app does not spawn it; Preferences turns it
/// back on. Defaulted for configs predating the field.
#[serde(default)]
pub tray_hidden: bool,
/// Language for the desktop app, tray and search prompt, as a gettext code
/// such as `de` or `pt_BR`. `None` means "follow the system locale".
/// Defaulted for configs predating the field.
#[serde(default)]
pub language: Option<String>,
/// Draw the Places map from OpenFreeMap's street map instead of the
/// built-in outline. Off by default: the tiles come from a third party,
/// which then learns which parts of the world are looked at. Defaulted for
/// configs predating the field.
#[serde(default)]
pub online_map: bool,
/// Record a new folder, a new file, a rename or a delete through the mount
/// locally and send it from the queue, instead of waiting for Drive in the
/// syscall. `None` means on. `false` brings back the old path for one
/// release (docs/ROADMAP.md). Defaulted for configs predating
/// the field.
#[serde(default)]
pub local_first: Option<bool>,
/// Ask GitHub once a day whether a newer release is out, and say so in the
/// desktop app. Off by default: GitHub then sees the address and version
/// asking. Never used where a package manager installs updates (see
/// [`crate::update::updates_managed`]). Defaulted for configs predating the
/// field.
#[serde(default)]
pub update_check: bool,
/// When the update check last asked GitHub, in Unix seconds, so a restart
/// does not ask again within [`crate::update::CHECK_INTERVAL`]. Defaulted
/// for configs predating the field.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub update_last_checked: Option<i64>,
/// The latest release that check found, such as `3.6.0`, so the app can
/// say so again after a restart without asking. Older than or equal to the
/// running version once the update is installed. Defaulted for configs
/// predating the field.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub update_latest: Option<String>,
}
impl Default for AppConfig {
/// The My Files page's view choices, remembered between runs.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(default)]
pub struct FilesView {
/// Columns instead of the icon grid.
pub list: bool,
pub sort: FileSort,
pub descending: bool,
/// Folders ahead of files, whatever the sort.
pub folders_first: bool,
}
impl Default for FilesView {
fn default() -> Self {
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,
list: false,
sort: FileSort::Name,
descending: false,
folders_first: true,
}
}
}
/// The desktop app's main window, as it was when it last closed.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(default)]
pub struct WindowState {
/// The unmaximised size, in logical pixels.
pub width: i32,
pub height: i32,
pub maximized: bool,
}
impl WindowState {
/// Below this the saved size is taken for damage and the default is used.
pub const MIN_WIDTH: i32 = 480;
pub const MIN_HEIGHT: i32 = 400;
/// This state, with a size too small to use replaced by the default.
pub fn usable(self) -> Self {
if self.width < Self::MIN_WIDTH || self.height < Self::MIN_HEIGHT {
Self {
maximized: self.maximized,
..Self::default()
}
} else {
self
}
}
}
impl Default for WindowState {
fn default() -> Self {
Self {
width: 980,
height: 680,
maximized: false,
}
}
}
/// The key a My Files listing is ordered by.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum FileSort {
#[default]
Name,
Size,
Modified,
}
impl FileSort {
/// The name used in the desktop app's actions and the config file.
pub fn as_str(self) -> &'static str {
match self {
FileSort::Name => "name",
FileSort::Size => "size",
FileSort::Modified => "modified",
}
}
pub fn parse(value: &str) -> Option<Self> {
match value {
"name" => Some(FileSort::Name),
"size" => Some(FileSort::Size),
"modified" => Some(FileSort::Modified),
_ => None,
}
}
}
/// Reads an `app_version` or `user_agent` override, dropping the fixed
/// identity older releases stored as the default.
fn identity_override<'de, D: Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<Option<String>, D::Error> {
let value = Option::<String>::deserialize(deserializer)?;
Ok(value.filter(|v| v != LEGACY_APP_VERSION && v != LEGACY_USER_AGENT))
}
impl AppConfig {
/// The `x-pm-appversion` sent to Proton: the user's override, or
/// [`APP_VERSION`].
pub fn resolved_app_version(&self) -> &str {
self.app_version.as_deref().unwrap_or(APP_VERSION)
}
/// The `User-Agent` sent to Proton: the user's override, or
/// [`USER_AGENT`].
pub fn resolved_user_agent(&self) -> &str {
self.user_agent.as_deref().unwrap_or(USER_AGENT)
}
/// 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 {
@@ -185,6 +370,22 @@ impl AppConfig {
}
}
/// Fail when the config file at `path` exists but does not parse, so that
/// [`AppDirs::save_config`] never writes over settings it could not read.
fn refuse_to_replace_unreadable(path: &Path) -> Result<()> {
match std::fs::read_to_string(path) {
Ok(content) => match serde_json::from_str::<AppConfig>(&content) {
Ok(_) => Ok(()),
Err(e) => Err(Error::Other(format!(
"{} cannot be read ({e}); fix it or move it aside before changing settings",
path.display()
))),
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.into()),
}
}
/// Resolved XDG locations for state, cache, and the default mountpoint.
pub struct AppDirs {
dirs: ProjectDirs,
@@ -216,6 +417,7 @@ impl AppDirs {
// Preserve the original bytes for diagnosis/recovery. In
// particular, never replace a truncated config with defaults
// and silently lose mount, device, or ignore settings.
// `save_config` refuses to write over it for the same reason.
tracing::error!(path = %path.display(), error = %e, "config is unreadable; using in-memory defaults without overwriting it");
return AppConfig::default();
}
@@ -223,9 +425,8 @@ impl AppDirs {
}
let default_config = AppConfig::default();
if let Ok(content) = serde_json::to_string_pretty(&default_config) {
let _ = std::fs::create_dir_all(self.dirs.config_dir());
let _ = std::fs::write(&path, content);
if let Err(e) = self.save_config(&default_config) {
tracing::warn!(path = %path.display(), error = %e, "could not write the default config");
}
default_config
}
@@ -233,8 +434,15 @@ impl AppDirs {
/// 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.
///
/// Refuses while the file on disk does not parse. Every caller loads,
/// changes one field and saves, and [`load_config`](Self::load_config)
/// gives defaults for a file it cannot read, so the first change after
/// the file was damaged wrote defaults over the mountpoint, the device and
/// the ignore rules the user had (`docs/BUGS.md` B223).
pub fn save_config(&self, config: &AppConfig) -> Result<()> {
let path = self.config_path();
refuse_to_replace_unreadable(&path)?;
let parent = path
.parent()
.ok_or_else(|| Error::Other("configuration path has no parent".into()))?;
@@ -258,6 +466,46 @@ impl AppDirs {
Ok(())
}
/// This machine's id (e.g. ~/.config/proton-drive-linux/machine-id). See
/// [`machine_id`](Self::machine_id).
pub fn machine_id_path(&self) -> PathBuf {
self.dirs.config_dir().join("machine-id")
}
/// A random id for this machine, made on first use and kept in
/// [`machine_id_path`](Self::machine_id_path).
///
/// It names this machine's profile in a device it may share with other
/// computers. Random rather than `/etc/machine-id`, so nothing that
/// identifies the host goes to Drive. In a file of its own rather than
/// `config.json`, because the app saves the config from a copy it loaded
/// earlier, and that would drop an id the daemon had just added.
pub fn machine_id(&self) -> Result<String> {
let path = self.machine_id_path();
match std::fs::read_to_string(&path) {
Ok(id) if is_machine_id(id.trim()) => return Ok(id.trim().to_string()),
Ok(_) => {
tracing::warn!(path = %path.display(), "machine id is unusable; making a new one");
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e.into()),
}
let mut bytes = [0u8; 16];
use std::io::Read as _;
std::fs::File::open("/dev/urandom")?.read_exact(&mut bytes)?;
let id: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
let parent = path
.parent()
.ok_or_else(|| Error::Other("machine id path has no parent".into()))?;
std::fs::create_dir_all(parent)?;
let tmp = path.with_extension(format!("tmp-{}", std::process::id()));
std::fs::write(&tmp, format!("{id}\n"))?;
std::fs::File::open(&tmp)?.sync_all()?;
std::fs::rename(&tmp, &path)?;
std::fs::File::open(parent)?.sync_all()?;
Ok(id)
}
/// 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.
@@ -329,7 +577,7 @@ impl AppDirs {
/// 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).
/// not something this client established (`docs/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
@@ -346,6 +594,12 @@ impl AppDirs {
}
}
/// Whether `id` is what [`AppDirs::machine_id`] writes: 32 lowercase hex
/// digits.
fn is_machine_id(id: &str) -> bool {
id.len() == 32 && id.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
}
/// 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<()> {
@@ -380,7 +634,7 @@ fn restrict_dir(dir: &Path) -> Result<()> {
/// 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).
/// other file (`docs/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
@@ -401,7 +655,29 @@ mod tests {
std::fs::metadata(p).unwrap().permissions().mode() & 0o777
}
/// bugs.md B6. A permissive mode here is not a cosmetic problem: these
/// A damaged config must not be replaced by defaults the next time a
/// setting changes (`docs/BUGS.md` B223).
#[test]
fn an_unreadable_config_is_never_written_over() {
let dir = std::env::temp_dir().join(format!(
"pdfs-cfg-guard-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("config.json");
assert!(refuse_to_replace_unreadable(&path).is_ok(), "no file yet");
std::fs::write(&path, serde_json::to_vec(&AppConfig::default()).unwrap()).unwrap();
assert!(refuse_to_replace_unreadable(&path).is_ok());
std::fs::write(&path, br#"{"mountpoint": "/home/me/Pro"#).unwrap();
assert!(refuse_to_replace_unreadable(&path).is_err(), "truncated");
let _ = std::fs::remove_dir_all(&dir);
}
/// `docs/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]
@@ -468,27 +744,159 @@ mod tests {
#[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(),
app_version: Some("external-drive-test-client@1.0.0".to_string()),
user_agent: Some("test-agent/1.0".to_string()),
cache_budget: Some(1234),
upload_limit: Some(500_000),
download_limit: None,
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,
proton_theme: Some(true),
files_view: FilesView {
list: true,
sort: FileSort::Modified,
descending: true,
folders_first: false,
},
window: WindowState {
width: 1200,
height: 800,
maximized: true,
},
tray_hidden: true,
language: Some("de".to_string()),
online_map: true,
local_first: Some(false),
update_check: true,
update_last_checked: Some(1_760_000_000),
update_latest: Some("3.6.0".to_string()),
};
let json = serde_json::to_string(&config).unwrap();
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.app_version, "external-drive-test-client@1.0.0");
assert_eq!(decoded.user_agent, "test-agent/1.0");
assert_eq!(
decoded.resolved_app_version(),
"external-drive-test-client@1.0.0"
);
assert_eq!(decoded.resolved_user_agent(), "test-agent/1.0");
assert_eq!(decoded.proton_theme, Some(true));
assert!(decoded.tray_hidden);
assert!(decoded.online_map);
assert!(decoded.update_check);
assert_eq!(decoded.update_last_checked, Some(1_760_000_000));
assert_eq!(decoded.update_latest.as_deref(), Some("3.6.0"));
assert_eq!(decoded.language.as_deref(), Some("de"));
assert_eq!(decoded.upload_limit, Some(500_000));
assert_eq!(decoded.download_limit, None);
assert_eq!(decoded.files_view, config.files_view);
assert_eq!(decoded.window, config.window);
}
#[test]
fn a_config_without_a_window_opens_at_the_default_size() {
let decoded: AppConfig =
serde_json::from_str(r#"{"app_version":"a","user_agent":"b"}"#).unwrap();
assert_eq!(decoded.window, WindowState::default());
let partial: WindowState = serde_json::from_str(r#"{"maximized":true}"#).unwrap();
assert_eq!((partial.width, partial.height), (980, 680));
assert!(partial.maximized);
}
#[test]
fn a_window_saved_too_small_opens_at_the_default_size() {
let tiny = WindowState {
width: 0,
height: 500,
maximized: true,
};
assert_eq!(
tiny.usable(),
WindowState {
maximized: true,
..WindowState::default()
}
);
let fine = WindowState {
width: 640,
height: 480,
maximized: false,
};
assert_eq!(fine.usable(), fine);
}
#[test]
fn a_config_without_a_files_view_gets_the_default() {
let decoded: AppConfig =
serde_json::from_str(r#"{"app_version":"a","user_agent":"b"}"#).unwrap();
assert_eq!(decoded.files_view, FilesView::default());
let partial: FilesView = serde_json::from_str(r#"{"sort":"size"}"#).unwrap();
assert_eq!(partial.sort, FileSort::Size);
assert!(partial.folders_first);
}
#[test]
fn the_old_proton_accent_setting_turns_on_the_proton_theme() {
let decoded: AppConfig =
serde_json::from_str(r#"{"app_version":"a","user_agent":"b","proton_accent":true}"#)
.unwrap();
assert_eq!(decoded.proton_theme, Some(true));
}
#[test]
fn test_default_app_config() {
let default_config = AppConfig::default();
assert_eq!(default_config.app_version, APP_VERSION);
assert_eq!(default_config.user_agent, USER_AGENT);
assert_eq!(default_config.resolved_app_version(), APP_VERSION);
assert_eq!(default_config.resolved_user_agent(), USER_AGENT);
}
#[test]
fn the_client_identity_follows_the_release() {
let version = env!("CARGO_PKG_VERSION");
assert_eq!(
APP_VERSION,
format!("external-drive-linux@{version}-stable")
);
assert_eq!(USER_AGENT, format!("proton-drive-linux/{version}"));
}
#[test]
fn an_old_config_with_the_fixed_identity_follows_the_release() {
// Every config written up to 3.1.0 holds these two values, so they must
// not pin the identity once Proton retires 0.1.0.
let json = format!(
r#"{{"app_version":"{LEGACY_APP_VERSION}","user_agent":"{LEGACY_USER_AGENT}","tray_hidden":true}}"#
);
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.resolved_app_version(), APP_VERSION);
assert_eq!(decoded.resolved_user_agent(), USER_AGENT);
assert!(decoded.tray_hidden, "the rest of the config survives");
let saved = serde_json::to_string(&decoded).unwrap();
assert!(!saved.contains("app_version"), "{saved}");
assert!(!saved.contains("user_agent"), "{saved}");
}
#[test]
fn a_hand_set_client_identity_is_kept() {
let json = r#"{"app_version":"external-drive-linux@9.9.9-beta","user_agent":"custom/1"}"#;
let decoded: AppConfig = serde_json::from_str(json).unwrap();
assert_eq!(
decoded.resolved_app_version(),
"external-drive-linux@9.9.9-beta"
);
assert_eq!(decoded.resolved_user_agent(), "custom/1");
let saved = serde_json::to_string(&decoded).unwrap();
assert!(saved.contains("external-drive-linux@9.9.9-beta"), "{saved}");
}
#[test]
fn a_config_without_a_client_identity_loads() {
let decoded: AppConfig = serde_json::from_str("{}").unwrap();
assert_eq!(decoded.resolved_app_version(), APP_VERSION);
assert_eq!(decoded.resolved_user_agent(), USER_AGENT);
}
#[test]
@@ -547,6 +955,15 @@ mod tests {
assert_eq!(decoded.conflict_sweep, Some(SweepMode::Enforce));
}
#[test]
fn a_machine_id_is_32_lowercase_hex_digits() {
assert!(is_machine_id("0123456789abcdef0123456789abcdef"));
assert!(!is_machine_id("0123456789ABCDEF0123456789ABCDEF"));
assert!(!is_machine_id("0123456789abcdef"));
assert!(!is_machine_id("../../../../etc/passwd/0123456789"));
assert!(!is_machine_id(""));
}
#[test]
fn a_config_predating_ignore_patterns_still_loads() {
// Every installed config on disk looks like this; the field must not be
@@ -554,6 +971,11 @@ mod tests {
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!(!decoded.tray_hidden, "an old config keeps the tray icon");
assert!(
decoded.language.is_none(),
"an old config follows the system"
);
assert_eq!(
decoded.resolved_ignore_patterns().len(),
DEFAULT_IGNORE_PATTERNS.len(),
File diff suppressed because it is too large. Load diff
+11
View File
@@ -29,6 +29,17 @@ impl Db {
Ok(())
}
/// Whether the kept log already holds an entry of `kind` for `target`. Lets
/// the daemon report something once across restarts rather than once per
/// run, without keeping a second table in step with the log.
pub fn activity_has(&self, kind: ActivityKind, target: &str) -> Result<bool> {
let kind = serde_json::to_string(&kind)?;
let conn = self.read();
let mut stmt =
conn.prepare("SELECT 1 FROM activity WHERE kind = ?1 AND target = ?2 LIMIT 1")?;
Ok(stmt.exists(params![kind, target])?)
}
/// 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.
+23 -2
View File
@@ -178,16 +178,33 @@ impl Db {
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
// Album rows have no favorite 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();
@@ -222,10 +239,14 @@ impl Db {
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
// Album rows have no favorite 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<_>>()?;
+249
View File
@@ -0,0 +1,249 @@
/* WARNING: Script requires that SQLITE_DBCONFIG_DEFENSIVE be disabled */
PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION;
CREATE TABLE sync_state (key TEXT PRIMARY KEY, value TEXT);
INSERT INTO sync_state VALUES('schema_version','35');
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
, node_json TEXT, listed INTEGER NOT NULL DEFAULT 0, path TEXT);
INSERT INTO nodes VALUES('vol~root',NULL,'My Files',1,0,200,NULL,0,'{"uid":{"volume_id":"vol","link_id":"root"},"parent_uid":null,"kind":"Folder","name":"My Files","creation_time":100,"modification_time":200,"trashed":false,"is_shared":false,"is_shared_publicly":false,"signature_email":null,"membership":null,"direct_role":null,"share_id":null,"photo":null,"album":null,"verification":{"name":"NotSigned","passphrase":"NotSigned","content_key":null,"extended_attributes":null}}',0,'');
INSERT INTO nodes VALUES('vol~docs','vol~root','Docs',1,0,200,NULL,0,'{"uid":{"volume_id":"vol","link_id":"docs"},"parent_uid":{"volume_id":"vol","link_id":"root"},"kind":"Folder","name":"Docs","creation_time":100,"modification_time":200,"trashed":false,"is_shared":false,"is_shared_publicly":false,"signature_email":null,"membership":null,"direct_role":null,"share_id":null,"photo":null,"album":null,"verification":{"name":"NotSigned","passphrase":"NotSigned","content_key":null,"extended_attributes":null}}',0,'Docs');
INSERT INTO nodes VALUES('vol~report','vol~docs','report.txt',0,10,200,NULL,0,'{"uid":{"volume_id":"vol","link_id":"report"},"parent_uid":{"volume_id":"vol","link_id":"docs"},"kind":{"File":{"media_type":"text/plain","total_size_on_storage":20,"active_revision_state":null,"active_revision_id":null,"claimed_size":10,"claimed_modification_time":null,"content_sha1":null}},"name":"report.txt","creation_time":100,"modification_time":200,"trashed":false,"is_shared":false,"is_shared_publicly":false,"signature_email":null,"membership":null,"direct_role":null,"share_id":null,"photo":null,"album":null,"verification":{"name":"NotSigned","passphrase":"NotSigned","content_key":null,"extended_attributes":null}}',0,'Docs/report.txt');
INSERT INTO nodes VALUES('vol~old','vol~docs','old.txt',0,3,200,NULL,0,'{"uid":{"volume_id":"vol","link_id":"old"},"parent_uid":{"volume_id":"vol","link_id":"docs"},"kind":{"File":{"media_type":"text/plain","total_size_on_storage":13,"active_revision_state":null,"active_revision_id":null,"claimed_size":3,"claimed_modification_time":null,"content_sha1":null}},"name":"old.txt","creation_time":100,"modification_time":200,"trashed":false,"is_shared":false,"is_shared_publicly":false,"signature_email":null,"membership":null,"direct_role":null,"share_id":null,"photo":null,"album":null,"verification":{"name":"NotSigned","passphrase":"NotSigned","content_key":null,"extended_attributes":null}}',0,'Docs/old.txt');
INSERT INTO nodes VALUES('vol~shared','vol~docs','shared.txt',0,4,200,NULL,0,'{"uid":{"volume_id":"vol","link_id":"shared"},"parent_uid":{"volume_id":"vol","link_id":"docs"},"kind":{"File":{"media_type":"text/plain","total_size_on_storage":14,"active_revision_state":null,"active_revision_id":null,"claimed_size":4,"claimed_modification_time":null,"content_sha1":null}},"name":"shared.txt","creation_time":100,"modification_time":200,"trashed":false,"is_shared":false,"is_shared_publicly":false,"signature_email":null,"membership":null,"direct_role":null,"share_id":null,"photo":null,"album":null,"verification":{"name":"NotSigned","passphrase":"NotSigned","content_key":null,"extended_attributes":null}}',0,'Docs/shared.txt');
INSERT INTO nodes VALUES('local~dir','vol~root','Offline folder',1,0,200,NULL,0,'{"uid":{"volume_id":"local","link_id":"dir"},"parent_uid":{"volume_id":"vol","link_id":"root"},"kind":"Folder","name":"Offline folder","creation_time":100,"modification_time":200,"trashed":false,"is_shared":false,"is_shared_publicly":false,"signature_email":null,"membership":null,"direct_role":null,"share_id":null,"photo":null,"album":null,"verification":{"name":"NotSigned","passphrase":"NotSigned","content_key":null,"extended_attributes":null}}',1,'Offline folder');
INSERT INTO nodes VALUES('local~inner','local~dir','inner.txt',0,5,200,NULL,0,'{"uid":{"volume_id":"local","link_id":"inner"},"parent_uid":{"volume_id":"local","link_id":"dir"},"kind":{"File":{"media_type":"text/plain","total_size_on_storage":15,"active_revision_state":null,"active_revision_id":null,"claimed_size":5,"claimed_modification_time":null,"content_sha1":null}},"name":"inner.txt","creation_time":100,"modification_time":200,"trashed":false,"is_shared":false,"is_shared_publicly":false,"signature_email":null,"membership":null,"direct_role":null,"share_id":null,"photo":null,"album":null,"verification":{"name":"NotSigned","passphrase":"NotSigned","content_key":null,"extended_attributes":null}}',0,'Offline folder/inner.txt');
INSERT INTO nodes VALUES('local~part','vol~root','movie.part',0,7,200,NULL,0,'{"uid":{"volume_id":"local","link_id":"part"},"parent_uid":{"volume_id":"vol","link_id":"root"},"kind":{"File":{"media_type":"text/plain","total_size_on_storage":17,"active_revision_state":null,"active_revision_id":null,"claimed_size":7,"claimed_modification_time":null,"content_sha1":null}},"name":"movie.part","creation_time":100,"modification_time":200,"trashed":false,"is_shared":false,"is_shared_publicly":false,"signature_email":null,"membership":null,"direct_role":null,"share_id":null,"photo":null,"album":null,"verification":{"name":"NotSigned","passphrase":"NotSigned","content_key":null,"extended_attributes":null}}',0,'movie.part');
CREATE TABLE cache_entries (
cache_key TEXT PRIMARY KEY,
size_bytes INTEGER,
last_accessed INTEGER,
is_pinned INTEGER NOT NULL DEFAULT 0
, kind TEXT NOT NULL DEFAULT 'blob');
CREATE TABLE pins (
uid TEXT PRIMARY KEY,
path TEXT NOT NULL,
recursive INTEGER NOT NULL DEFAULT 0
);
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 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
, media_type TEXT, kind INTEGER NOT NULL DEFAULT 0, favorite INTEGER NOT NULL DEFAULT 0, content_hash TEXT, main_uid TEXT, group_key TEXT, resolved_at INTEGER, latitude REAL, longitude REAL, place_id INTEGER, similar_hash INTEGER);
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
, parent_uid TEXT, trashed_at INTEGER);
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
, pending_mode TEXT);
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, local_mtime_ns INTEGER,
PRIMARY KEY (folder_id, rel_path)
);
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 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
, parent_uid TEXT, name TEXT, claimed_at INTEGER NOT NULL DEFAULT 0, access_deferred_since INTEGER NOT NULL DEFAULT 0);
INSERT INTO pending_op VALUES(1,'mkdir','local~dir',NULL,NULL,1700000000000,0,NULL,0,'vol~root','Offline folder',4611686018427387903,0);
INSERT INTO pending_op VALUES(2,'create','local~inner','/blobs/inner','{"len":5,"mtime":1700000000,"based_on":null}',1700000000000,0,NULL,0,'local~dir','inner.txt',0,0);
INSERT INTO pending_op VALUES(3,'create','local~part','/blobs/part','{"len":5,"mtime":1700000000,"based_on":null}',1700000000000,0,NULL,8000000000000,'vol~root','movie.part',0,0);
INSERT INTO pending_op VALUES(4,'revision','vol~report','/blobs/report','{"len":5,"mtime":1700000000,"based_on":null}',1700000000000,2,'network unreachable',1700000900000,NULL,NULL,0,0);
INSERT INTO pending_op VALUES(5,'rename','vol~shared',NULL,'{"original_parent_uid":"vol~docs","original_name":"shared.txt"}',1700000000000,0,NULL,1700000700000,'vol~root','shared-moved.txt',0,1700000100000);
INSERT INTO pending_op VALUES(6,'trash','vol~old',NULL,NULL,1700000000000,0,NULL,0,NULL,NULL,0,0);
CREATE TABLE IF NOT EXISTS 'local_fts_data'(id INTEGER PRIMARY KEY, block BLOB);
INSERT INTO local_fts_data VALUES(1,x'000000');
INSERT INTO local_fts_data VALUES(10,x'00000000000000');
CREATE TABLE IF NOT EXISTS 'local_fts_idx'(segid, term, pgno, PRIMARY KEY(segid, term)) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS 'local_fts_docsize'(id INTEGER PRIMARY KEY, sz BLOB);
CREATE TABLE IF NOT EXISTS 'local_fts_config'(k PRIMARY KEY, v) WITHOUT ROWID;
INSERT INTO local_fts_config VALUES('version',4);
CREATE TABLE share_access (
root_uid TEXT PRIMARY KEY,
access TEXT NOT NULL
CHECK (access IN ('owner', 'editor', 'viewer', 'unknown'))
);
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 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 TABLE IF NOT EXISTS 'nodes_fts_data'(id INTEGER PRIMARY KEY, block BLOB);
INSERT INTO nodes_fts_data VALUES(1,x'083850');
INSERT INTO nodes_fts_data VALUES(10,x'000000000108080008010101020101030101040101050101060101070101080101');
INSERT INTO nodes_fts_data VALUES(137438953473,x'000000340430206669010204010366696c0102050103696c6501020601036c657301020701036d79200102020103792066010203040808080808');
INSERT INTO nodes_fts_data VALUES(274877906945,x'0000001a0430646f6302080201010201036f6373020803010103040b');
INSERT INTO nodes_fts_data VALUES(412316860417,x'0000008b04302e747803080801010d01032f72650306010106010363732f03060101040103646f630306010102010365706f03080301010801036f637303060101030202727403080501010a0103706f7203080401010901037265700308020101070202742e03080601010b0103732f7203060101050103742e7403080701010c0202787403080901010e040b0a0a0a0b0a0a0b0b0a0a0b');
INSERT INTO nodes_fts_data VALUES(549755813889,x'0000006b04302e747804080501010a01032f6f6c0406010106010363732f04060101040103642e7404080401010902026f63040601010201036c642e04080301010801036f6373040601010302026c640408020101070103732f6f0406010105010374787404080601010b040b0a0a0b090b0a0a0a');
INSERT INTO nodes_fts_data VALUES(687194767361,x'0000008c04302e747805080801010d01032f736805060101060103617265050804010109010363732f05060101040103642e7405080701010c02026f630506010102010365642e05080601010b010368617205080301010801036f63730506010103010372656405080501010a0103732f73050601010502026861050802010107010374787405080901010e040b0a0b0a0b090b0b0a0b0a0a');
INSERT INTO nodes_fts_data VALUES(824633720833,x'00000084043020666f060809010109010364657206080d01010d0103652066060808010108010366666c06080301010302026c6906080401010402026f6c06080a01010a0103696e6506080601010601036c646506080c01010c0202696e06080501010501036e652006080701010701036f666606080201010202026c6406080b01010b040b0b0b0b0a0a0b0b0a0b0b');
INSERT INTO nodes_fts_data VALUES(962072674305,x'000000da043020666f070601010901032e747807080701011601032f696e07060101100103646572070601010d010365206607060101080202722e07080501011403012f070601010e010366666c070601010302026c69070601010402026f6c070601010a0103696e65070601010603016e07080201011101036c6465070601010c0202696e070601010501036e6520070601010703017207080401011302026e6507080301011201036f6666070601010202026c64070601010b0103722e7407080601011502022f69070601010f0103747874070808010117040a0b0a0a0a0a080a09090a090a090a090a0a090b09');
INSERT INTO nodes_fts_data VALUES(1099511627777,x'0000005c04302e706108080701010701036172740808090101090103652e70080806010106010369652e08080501010501036d6f7608080201010201036f766908080301010301037061720808080101080103766965080804010104040b0b0b0b0b0b0b');
CREATE TABLE IF NOT EXISTS 'nodes_fts_idx'(segid, term, pgno, PRIMARY KEY(segid, term)) WITHOUT ROWID;
INSERT INTO nodes_fts_idx VALUES(1,x'',2);
INSERT INTO nodes_fts_idx VALUES(2,x'',2);
INSERT INTO nodes_fts_idx VALUES(3,x'',2);
INSERT INTO nodes_fts_idx VALUES(4,x'',2);
INSERT INTO nodes_fts_idx VALUES(5,x'',2);
INSERT INTO nodes_fts_idx VALUES(6,x'',2);
INSERT INTO nodes_fts_idx VALUES(7,x'',2);
INSERT INTO nodes_fts_idx VALUES(8,x'',2);
CREATE TABLE IF NOT EXISTS 'nodes_fts_content'(id INTEGER PRIMARY KEY, c0, c1);
INSERT INTO nodes_fts_content VALUES(1,'My Files','');
INSERT INTO nodes_fts_content VALUES(2,'Docs','Docs');
INSERT INTO nodes_fts_content VALUES(3,'report.txt','Docs/report.txt');
INSERT INTO nodes_fts_content VALUES(4,'old.txt','Docs/old.txt');
INSERT INTO nodes_fts_content VALUES(5,'shared.txt','Docs/shared.txt');
INSERT INTO nodes_fts_content VALUES(6,'Offline folder','Offline folder');
INSERT INTO nodes_fts_content VALUES(7,'inner.txt','Offline folder/inner.txt');
INSERT INTO nodes_fts_content VALUES(8,'movie.part','movie.part');
CREATE TABLE IF NOT EXISTS 'nodes_fts_docsize'(id INTEGER PRIMARY KEY, sz BLOB);
INSERT INTO nodes_fts_docsize VALUES(1,x'0600');
INSERT INTO nodes_fts_docsize VALUES(2,x'0202');
INSERT INTO nodes_fts_docsize VALUES(3,x'080d');
INSERT INTO nodes_fts_docsize VALUES(4,x'050a');
INSERT INTO nodes_fts_docsize VALUES(5,x'080d');
INSERT INTO nodes_fts_docsize VALUES(6,x'0c0c');
INSERT INTO nodes_fts_docsize VALUES(7,x'0716');
INSERT INTO nodes_fts_docsize VALUES(8,x'0808');
CREATE TABLE IF NOT EXISTS 'nodes_fts_config'(k PRIMARY KEY, v) WITHOUT ROWID;
INSERT INTO nodes_fts_config VALUES('version',4);
CREATE TABLE own_sealed_rev (
uid TEXT PRIMARY KEY,
revision_id TEXT NOT NULL,
sealed_at INTEGER NOT NULL
);
INSERT INTO own_sealed_rev VALUES('vol~report','rev-1',1700000000000);
PRAGMA writable_schema=ON;
INSERT INTO sqlite_schema(type,name,tbl_name,rootpage,sql)VALUES('table','local_fts','local_fts',0,'CREATE VIRTUAL TABLE local_fts USING fts5(
name, path, content=''local_files'', content_rowid=''id'', tokenize=''trigram''
)');
INSERT INTO sqlite_schema(type,name,tbl_name,rootpage,sql)VALUES('table','nodes_fts','nodes_fts',0,'CREATE VIRTUAL TABLE nodes_fts USING fts5(
name, path, tokenize=''trigram''
)');
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;
CREATE INDEX idx_nodes_parent ON nodes(parent_uid);
CREATE INDEX idx_photos_seq ON photos(seq);
CREATE INDEX pending_op_uid ON pending_op(uid);
CREATE INDEX pending_op_parent ON pending_op(parent_uid);
CREATE INDEX cache_entries_lru ON cache_entries(kind, last_accessed);
CREATE INDEX idx_nodes_name_nocase ON nodes(name COLLATE NOCASE);
CREATE INDEX idx_local_files_name_nocase
ON local_files(name COLLATE NOCASE);
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';
CREATE INDEX idx_album_photos_seq ON album_photos(album_uid, seq);
CREATE INDEX idx_album_photos_uid ON album_photos(uid);
CREATE INDEX idx_photos_favorite ON photos(favorite, seq);
CREATE INDEX idx_sync_entry_remote_uid ON sync_entry(remote_uid);
CREATE INDEX idx_local_files_scan_gen ON local_files(scan_gen);
CREATE INDEX idx_nodes_parent_live ON nodes(parent_uid) WHERE trashed = 0;
CREATE INDEX idx_pending_op_claimed
ON pending_op(uid) WHERE claimed_at <> 0;
CREATE INDEX idx_trash_parent ON trash(parent_uid);
CREATE INDEX idx_photos_group ON photos(group_key);
CREATE INDEX idx_photos_unresolved ON photos(resolved_at);
CREATE INDEX idx_photos_place ON photos(place_id);
PRAGMA writable_schema=OFF;
COMMIT;
+71
View File
@@ -111,9 +111,80 @@ impl Db {
)?;
let count: i64 = tx.query_row("SELECT COUNT(*) FROM local_files", [], |r| r.get(0))?;
tx.commit()?;
drop(conn);
self.compact_search_indexes_if_due(finished_at)?;
Ok(count)
}
/// Close a scan that did not see everything: it stopped at the entry cap,
/// or a batch failed to write. Nothing is pruned, since a path it missed is
/// not a path that is gone; the next complete scan prunes. Returns the
/// number of indexed entries.
///
/// Pruning after such a scan dropped every entry it had not reached, and
/// the next scan put them back. That churn is most of what grew
/// `local_fts` to 16 times its size (`docs/BUGS.md` B217).
pub fn local_finish_partial_scan(&self, finished_at: i64) -> Result<i64> {
let conn = self.conn.lock();
conn.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 = conn.query_row("SELECT COUNT(*) FROM local_files", [], |r| r.get(0))?;
Ok(count)
}
/// Compact both full-text indexes, at most once a day.
///
/// FTS5 keeps a deleted entry's data until the segments holding it are
/// merged, and with the few large segments these indexes settle into that
/// is close to never. Every file the local scan sees disappear or appear
/// leaves some behind, and every node row the tree replaces does too: on
/// one machine `local_fts` held 317 MiB for 40,000 paths, against 20 MiB
/// rebuilt, and a search took four times as long (`docs/BUGS.md` B217).
///
/// `local_fts` is rebuilt from `local_files`, which also drops anything
/// left over, and `nodes_fts`, which has no content table to rebuild from,
/// is merged into one segment. Each runs in its own transaction on the
/// write connection, about half a second on an index that size; once a
/// day keeps the leftovers small without paying that at every scan.
pub fn compact_search_indexes_if_due(&self, now: i64) -> Result<()> {
const EVERY_SECS: i64 = 24 * 60 * 60;
let last: i64 = self
.read()
.query_row(
"SELECT value FROM sync_state WHERE key = 'fts_compacted_at'",
[],
|row| row.get::<_, String>(0),
)
.optional()?
.and_then(|v| v.parse().ok())
.unwrap_or(0);
if now - last < EVERY_SECS {
return Ok(());
}
let started = std::time::Instant::now();
{
let conn = self.conn.lock();
conn.execute("INSERT INTO local_fts(local_fts) VALUES('rebuild')", [])?;
}
{
let conn = self.conn.lock();
conn.execute("INSERT INTO nodes_fts(nodes_fts) VALUES('optimize')", [])?;
conn.execute(
"INSERT INTO sync_state (key, value) VALUES ('fts_compacted_at', ?1)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
[now.to_string()],
)?;
}
tracing::info!(
elapsed_ms = started.elapsed().as_millis() as u64,
"compacted the search indexes"
);
Ok(())
}
/// 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.
+45 -4
View File
@@ -1,4 +1,4 @@
//! Database health reporting and repair (features.md 4.3).
//! Database health reporting and repair.
//!
//! 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
@@ -91,8 +91,13 @@ impl Db {
/// 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.
///
/// Runs on a read connection: it reads every page, which took 34 s on a
/// 567 MiB file, and on the write connection every write in the daemon
/// waited that long behind it (`docs/BUGS.md` B218). WAL lets it read a
/// consistent snapshot while the writer goes on.
pub fn integrity_check(&self) -> Result<Vec<String>> {
let conn = self.conn.lock();
let conn = self.read();
let mut stmt = conn.prepare("PRAGMA integrity_check")?;
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
let mut problems = Vec::new();
@@ -107,9 +112,11 @@ impl Db {
Ok(problems)
}
/// Size and row-count snapshot for `pdfs cache inspect`.
/// Size and row-count snapshot for `pdfs cache inspect`. On a read
/// connection, for the reason [`integrity_check`](Self::integrity_check)
/// gives: counting the rows of every table reads most of the file.
pub fn stats(&self) -> Result<DbStats> {
let conn = self.conn.lock();
let conn = self.read();
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))?;
@@ -218,6 +225,40 @@ mod tests {
assert!(db.integrity_check().unwrap().is_empty());
}
/// `pdfs diagnose` and `pdfs cache inspect` read the whole file. Behind the
/// write lock they froze every write in the daemon for as long as that
/// took (`docs/BUGS.md` B218).
#[test]
fn the_integrity_check_and_stats_do_not_wait_for_the_writer() {
let path = std::env::temp_dir().join(format!(
"pdfs-check-{}-{}.db",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let db = std::sync::Arc::new(Db::open(&path).unwrap());
let writer = db.conn.lock();
let (tx, rx) = std::sync::mpsc::channel();
let reader = std::sync::Arc::clone(&db);
std::thread::spawn(move || {
let checked = reader.integrity_check().map(|p| p.is_empty());
let counted = reader.stats().map(|s| s.page_count > 0);
let _ = tx.send((checked.ok(), counted.ok()));
});
let answer = rx.recv_timeout(std::time::Duration::from_secs(10));
drop(writer);
assert_eq!(answer.ok(), Some((Some(true), Some(true))));
drop(db);
for suffix in ["", "-wal", "-shm"] {
let mut file = path.as_os_str().to_owned();
file.push(suffix);
let _ = std::fs::remove_file(std::path::PathBuf::from(file));
}
let _ = std::fs::remove_file(path.with_extension("db.lock"));
}
#[test]
fn stats_report_the_schema_and_the_known_tables() {
let db = Db::open_in_memory().unwrap();
+543 -7
View File
@@ -5,11 +5,13 @@
//! 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 rusqlite::OptionalExtension;
use super::Db;
use crate::Result;
/// Current schema version. Bump on every forward migration added below.
pub(super) const SCHEMA_VERSION: i64 = 28;
pub(super) const SCHEMA_VERSION: i64 = 44;
impl Db {
pub(super) fn migrate(&self) -> Result<()> {
@@ -218,6 +220,253 @@ impl Db {
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)?;
}
}
if current < 32 {
// Data only, so safe to repeat; each table is guarded for a very
// old fixture that never had it.
for table in ["photos", "album_photos"] {
let has_table: bool = tx.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1",
[table],
|row| row.get::<_, i64>(0),
)? > 0;
if has_table {
tx.execute(&MIGRATION_V32.replace("{table}", table), [])?;
}
}
}
if current < 33 {
// Same guards as V26-V31.
let has_column: bool = tx.query_row(
"SELECT COUNT(*) FROM pragma_table_info('photos') WHERE name = 'place_id'",
[],
|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_V33)?;
}
}
if current < 34 {
// Same guards as V26-V33.
let has_column: bool = tx.query_row(
"SELECT COUNT(*) FROM pragma_table_info('photos') WHERE name = 'similar_hash'",
[],
|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_V34)?;
}
}
if current < 35 {
// Same guards as V26-V34.
let has_column: bool = tx.query_row(
"SELECT COUNT(*) FROM pragma_table_info('trash') WHERE name = 'trashed_at'",
[],
|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_V35)?;
}
}
if current < 36 {
// Same guards as V26-V35.
let has_column: bool = tx.query_row(
"SELECT COUNT(*) FROM pragma_table_info('nodes') WHERE name = 'lid'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
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 && !has_column {
tx.execute_batch(MIGRATION_V36)?;
}
}
if current < 37 {
// Same guards as V26-V36.
let sql: Option<String> = tx
.query_row(
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'pending_op'",
[],
|row| row.get(0),
)
.optional()?;
if sql.is_some_and(|sql| !sql.contains("AUTOINCREMENT")) {
tx.execute_batch(MIGRATION_V37)?;
}
}
if current < 38 {
// Same guards as V26-V37.
let has_column: bool = tx.query_row(
"SELECT COUNT(*) FROM pragma_table_info('pending_op') WHERE name = 'issue'",
[],
|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_V38)?;
}
}
if current < 39 {
// Same guards as V26-V38.
let has_column: bool = tx.query_row(
"SELECT COUNT(*) FROM pragma_table_info('pending_op') WHERE name = 'lid'",
[],
|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_V39)?;
}
}
if current < 40 {
// Same guards as V26-V39.
let has_column: bool = tx.query_row(
"SELECT COUNT(*) FROM pragma_table_info('nodes') WHERE name = 'parent_lid'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
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 && !has_column {
tx.execute_batch(MIGRATION_V40)?;
}
}
if current < 41 {
// Needs both tables; on a database that never had them there is
// nothing to rewrite.
let tables: i64 = tx.query_row(
"SELECT COUNT(*) FROM sqlite_master
WHERE type = 'table' AND name IN ('nodes', 'pending_op', 'pins')",
[],
|row| row.get(0),
)?;
if tables == 3 {
tx.execute_batch(MIGRATION_V41)?;
}
}
if current < 42 {
// Same guards as V26-V40.
let has_column: bool = tx.query_row(
"SELECT COUNT(*) FROM pragma_table_info('pending_op') WHERE name = 'sent_sha1'",
[],
|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_V42)?;
}
}
if current < 43 {
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 {
tx.execute_batch(MIGRATION_V43)?;
}
}
if current < 44 {
// Same guards as V26-V42.
let has_column: bool = tx.query_row(
"SELECT COUNT(*) FROM pragma_table_info('sync_entry') \
WHERE name = 'remote_revision_id'",
[],
|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_V44)?;
}
}
tx.execute(
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
@@ -355,7 +604,7 @@ CREATE TABLE trash (
);
";
/// Schema v8: device sync (devices.md). This machine registers as one Proton Drive
/// Schema v8: device sync. 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,
@@ -416,8 +665,8 @@ 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
/// the bytes and records the intended upload here, and a drain worker performs it.
/// 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
@@ -444,7 +693,7 @@ 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).
/// already minted a uid for.
///
/// 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
@@ -632,7 +881,7 @@ 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.
/// V20: whether a photo is a favorite.
///
/// 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
@@ -834,7 +1083,7 @@ ALTER TABLE pending_op ADD COLUMN access_deferred_since INTEGER NOT NULL DEFAULT
/// 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).
/// older content until something else about the file moved (`docs/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
@@ -846,3 +1095,290 @@ ALTER TABLE pending_op ADD COLUMN access_deferred_since INTEGER NOT NULL DEFAULT
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,
/// favorite 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;
";
/// V32: give videos written off as un-thumbnailable another chance.
///
/// Before local video thumbnails, the fallback fed a video's bytes to the image
/// decoder, which could only fail, and that failure was recorded as the
/// permanent "no thumbnail" verdict. Run once per table in `{table}`.
const MIGRATION_V32: &str = "UPDATE {table} SET thumb_state = 0 WHERE kind = 1 AND thumb_state = 2";
/// Schema v33: where each photo was taken.
///
/// `latitude` and `longitude` come from the location other clients write into
/// a photo's extended attributes; `place_id` is the GeoNames id of the nearest
/// town (see `crate::places`), so the Places view is a plain `GROUP BY`.
///
/// Every photo is marked unresolved, so the next refresh reads the location of
/// the whole library once. That is the backfill: the metadata is on the node
/// the refresh reads anyway, and nothing has to be downloaded.
const MIGRATION_V33: &str = "
ALTER TABLE photos ADD COLUMN latitude REAL;
ALTER TABLE photos ADD COLUMN longitude REAL;
ALTER TABLE photos ADD COLUMN place_id INTEGER;
CREATE INDEX IF NOT EXISTS idx_photos_place ON photos(place_id);
UPDATE photos SET resolved_at = NULL;
";
/// Schema v34: what each photo looks like, for finding similar ones.
///
/// `similar_hash` is a 64-bit difference hash of the photo's thumbnail (see
/// `crate::similar`): resized and re-encoded copies of one picture land a few
/// bits apart, where the server's `content_hash` only matches byte-identical
/// files. `NULL` until the daemon has hashed the thumbnail, which it does as
/// thumbnails arrive and when the similar-photo finder asks for the rest.
const MIGRATION_V34: &str = "
ALTER TABLE photos ADD COLUMN similar_hash INTEGER;
";
/// Schema v35: when each trashed node went to the trash.
///
/// The API does not tell this client, so `trashed_at` is when the daemon first
/// saw the node in the trash, in epoch seconds. `NULL` when it cannot say: rows
/// from before this version, and nodes found by a refresh that did not follow a
/// complete one, which may have been in the trash for a long time already.
const MIGRATION_V35: &str = "
ALTER TABLE trash ADD COLUMN trashed_at INTEGER;
";
/// Schema v36: every node row has a local id (`lid`); see
/// docs/ARCHITECTURE.md §2.
///
/// The `uid` was the row's key, so a node had no identity until Drive minted
/// one, and a create that landed moved its row to a new key. The `lid` is given
/// when the row is first written and never changes or comes back, which is what
/// `AUTOINCREMENT` adds over a plain rowid. It takes the old rowid, so the
/// search index, keyed by rowid since v15, stays valid. The `uid` becomes
/// nullable for a node that is not on Drive yet.
const MIGRATION_V36: &str = "
CREATE TABLE nodes_v36 (
lid INTEGER PRIMARY KEY AUTOINCREMENT,
uid TEXT UNIQUE,
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,
node_json TEXT,
listed INTEGER NOT NULL DEFAULT 0,
path TEXT
);
INSERT INTO nodes_v36
(lid, uid, parent_uid, name, is_dir, size, mtime, revision_hash, trashed, node_json, listed,
path)
SELECT rowid, uid, parent_uid, name, is_dir, size, mtime, revision_hash, trashed, node_json,
listed, path
FROM nodes;
DROP TABLE nodes;
ALTER TABLE nodes_v36 RENAME TO nodes;
CREATE INDEX idx_nodes_parent ON nodes(parent_uid);
CREATE INDEX idx_nodes_name_nocase ON nodes(name COLLATE NOCASE);
CREATE INDEX idx_nodes_parent_live ON nodes(parent_uid) WHERE trashed = 0;
";
/// Schema v37: a `pending_op` id is never given out twice.
///
/// A plain rowid is the largest one plus one, so an op queued right after the
/// newest one was dropped took its id. The drain retires the op it is
/// working on by id, and so deleted the new op: a trash queued over a rename
/// on the wire was lost when the rename landed. `AUTOINCREMENT` never reuses
/// an id. Ids are kept, as is every index.
const MIGRATION_V37: &str = "
CREATE TABLE pending_op_v37 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
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,
parent_uid TEXT,
name TEXT,
claimed_at INTEGER NOT NULL DEFAULT 0,
access_deferred_since INTEGER NOT NULL DEFAULT 0
);
INSERT INTO pending_op_v37
(id, kind, uid, blob_path, meta_json, created_at, attempts, last_error, next_attempt_at,
parent_uid, name, claimed_at, access_deferred_since)
SELECT id, kind, uid, blob_path, meta_json, created_at, attempts, last_error,
next_attempt_at, parent_uid, name, claimed_at, access_deferred_since
FROM pending_op;
DROP TABLE pending_op;
ALTER TABLE pending_op_v37 RENAME TO pending_op;
CREATE INDEX pending_op_uid ON pending_op(uid);
CREATE INDEX pending_op_parent ON pending_op(parent_uid);
CREATE INDEX idx_pending_op_claimed
ON pending_op(uid) WHERE claimed_at <> 0;
";
/// Schema v38: why Drive refused a queued op, when it did (a sync issue).
/// `NULL` for an op that has not been refused, which is every op migrated.
const MIGRATION_V38: &str = "
ALTER TABLE pending_op ADD COLUMN issue TEXT;
";
/// Schema v39: a queued op names its node and its parent by local id too.
///
/// A create made inside a folder that had not landed named the folder by its
/// placeholder uid, and every landing had to find and rewrite those strings.
/// The local id stays the same when the folder lands. An op whose node has no
/// row keeps `NULL` and is matched by uid, as before.
const MIGRATION_V39: &str = "
ALTER TABLE pending_op ADD COLUMN lid INTEGER;
ALTER TABLE pending_op ADD COLUMN parent_lid INTEGER;
UPDATE pending_op
SET lid = (SELECT lid FROM nodes WHERE nodes.uid = pending_op.uid),
parent_lid = (SELECT lid FROM nodes WHERE nodes.uid = pending_op.parent_uid);
CREATE INDEX pending_op_lid ON pending_op(lid);
CREATE INDEX pending_op_parent_lid ON pending_op(parent_lid);
";
/// Schema v40: a node row names its parent by local id too.
///
/// The local id stays when a folder made here lands and its row takes the uid
/// Drive gave it, so a child linked by it needs no rewrite. `NULL` while the
/// parent has no row, as `parent_uid` then names nothing either.
const MIGRATION_V40: &str = "
ALTER TABLE nodes ADD COLUMN parent_lid INTEGER;
UPDATE nodes SET parent_lid = (SELECT p.lid FROM nodes p WHERE p.uid = nodes.parent_uid);
CREATE INDEX idx_nodes_parent_lid ON nodes(parent_lid);
";
/// Schema v41: a node made on this machine goes by `local~<lid>` until it
/// lands, a function of its row, where it used to get a `local~<ms>-<n>`
/// placeholder. Every placeholder still stored is rewritten to its row's
/// stand-in: the row itself and its `node_json`, its children, the ops on it
/// or into it, and its pins. A placeholder op with no row keeps its uid and
/// drains as before.
const MIGRATION_V41: &str = "
CREATE TEMP TABLE local_v41 AS
SELECT uid AS old, 'local~' || lid AS new, lid FROM nodes
WHERE uid LIKE 'local~%' AND uid <> 'local~' || lid;
UPDATE pending_op SET uid = (SELECT new FROM local_v41 WHERE old = pending_op.uid)
WHERE uid IN (SELECT old FROM local_v41);
UPDATE pending_op SET parent_uid = (SELECT new FROM local_v41 WHERE old = pending_op.parent_uid)
WHERE parent_uid IN (SELECT old FROM local_v41);
UPDATE pending_op
SET meta_json = json_set(meta_json, '$.uid',
(SELECT new FROM local_v41 WHERE old = json_extract(pending_op.meta_json, '$.uid')))
WHERE json_valid(meta_json)
AND json_extract(meta_json, '$.uid') IN (SELECT old FROM local_v41);
UPDATE pending_op
SET meta_json = json_set(meta_json, '$.original_parent_uid',
(SELECT new FROM local_v41
WHERE old = json_extract(pending_op.meta_json, '$.original_parent_uid')))
WHERE json_valid(meta_json)
AND json_extract(meta_json, '$.original_parent_uid') IN (SELECT old FROM local_v41);
UPDATE pins SET uid = (SELECT new FROM local_v41 WHERE old = pins.uid)
WHERE uid IN (SELECT old FROM local_v41);
UPDATE nodes
SET node_json = json_set(node_json, '$.parent_uid.link_id',
(SELECT CAST(lid AS TEXT) FROM local_v41 WHERE old = nodes.parent_uid))
WHERE parent_uid IN (SELECT old FROM local_v41)
AND json_valid(node_json)
AND json_extract(node_json, '$.parent_uid.volume_id') = 'local';
UPDATE nodes SET parent_uid = (SELECT new FROM local_v41 WHERE old = nodes.parent_uid)
WHERE parent_uid IN (SELECT old FROM local_v41);
UPDATE nodes
SET node_json = json_set(node_json, '$.uid.link_id', CAST(lid AS TEXT))
WHERE lid IN (SELECT lid FROM local_v41)
AND json_valid(node_json)
AND json_extract(node_json, '$.uid.volume_id') = 'local';
UPDATE nodes SET uid = 'local~' || lid WHERE lid IN (SELECT lid FROM local_v41);
DROP TABLE local_v41;
";
/// Schema v42: the SHA-1 of each blob a queued create sent, space-separated.
/// A create whose answer was lost may have made its file with any of them,
/// and the op that finds that file again may run after a restart. `NULL` for
/// an op that has sent nothing, which is every op migrated: what the daemon
/// that wrote it sent is not known. Likewise the parent and name each attempt
/// was sent under, as a JSON list of pairs: a rename since then moves the op
/// but not the file Drive may have made.
const MIGRATION_V42: &str = "
ALTER TABLE pending_op ADD COLUMN sent_sha1 TEXT;
ALTER TABLE pending_op ADD COLUMN sent_to TEXT;
";
/// Schema v43: queued ops by kind and name.
///
/// Before each create, rename and trash the drain asks whether a queued trash
/// or rename holds the name it wants, and a released name wakes the ops
/// waiting for it. Each of those scanned the whole queue, so a large offline
/// batch made draining it quadratic. The kind leads, so a lookup of every
/// queued rename uses the index too.
const MIGRATION_V43: &str = "
CREATE INDEX IF NOT EXISTS pending_op_kind_name ON pending_op(kind, name);
";
/// Schema v44: a synced file's baseline remembers the revision it was synced
/// at.
///
/// A mirrored file edited here is uploaded as a new revision of whatever is
/// current on Drive, and another computer syncing the same folder may have
/// committed one since this pass looked. The revision id is what the upload
/// checks first, so that edit becomes a conflict copy instead of being
/// replaced (B249). `NULL` means "this row predates the column", and the check
/// falls back to the modification time and size; each row gains a real value
/// the next time its path is synced.
const MIGRATION_V44: &str = "
ALTER TABLE sync_entry ADD COLUMN remote_revision_id TEXT;
";
+167 -18
View File
@@ -56,10 +56,11 @@ 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,
AttachedBlob, CreateLanding, CreateRetired, FAILING_ATTEMPTS, LOCAL_VOLUME, OP_CREATE,
OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_EXPIRY_MS, PARK_UNTIL, PendingCounts,
PendingOp, RenameMeta, local_lid, local_uid, op_supersedes,
};
pub use photos::{StoredPhoto, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN, TimelineRow};
pub use photos::{StoredPhoto, StoredPlace, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN, TimelineRow};
pub use pins::PinRow;
pub use sync::{StoredSyncEntry, StoredSyncFolder};
pub use trash::StoredTrash;
@@ -178,6 +179,45 @@ impl Drop for Reader<'_> {
}
}
/// The write connection with `synchronous=FULL`, back to NORMAL on drop.
///
/// NORMAL in WAL mode keeps a commit across a crash of the process but not
/// across a power loss: the WAL is synced only at checkpoints. That is fine for
/// metadata the next listing re-fetches, and not for the queue. A staged blob
/// is synced before its op is queued, and the op row is what makes it
/// reachable; losing that row loses a write `close()` already reported as
/// done. A FULL commit syncs the WAL, which covers every NORMAL commit before
/// it as well.
pub(super) struct Durable<'a>(parking_lot::MutexGuard<'a, Connection>);
impl<'a> Durable<'a> {
pub(super) fn new(conn: parking_lot::MutexGuard<'a, Connection>) -> Result<Self> {
conn.pragma_update(None, "synchronous", "FULL")?;
Ok(Self(conn))
}
}
impl std::ops::Deref for Durable<'_> {
type Target = Connection;
fn deref(&self) -> &Connection {
&self.0
}
}
impl std::ops::DerefMut for Durable<'_> {
fn deref_mut(&mut self) -> &mut Connection {
&mut self.0
}
}
impl Drop for Durable<'_> {
fn drop(&mut self) {
// A failure leaves the writer at FULL: slower, never less safe.
let _ = self.0.pragma_update(None, "synchronous", "NORMAL");
}
}
/// 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
@@ -215,18 +255,34 @@ impl Db {
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.
// Most of what is in here is derived from the account and is
// fetched again. The queue is not: it holds writes accepted
// here that Drive does not have yet, and the mirror baselines
// and pins are this machine's alone. So the damaged file is
// moved aside, kept as evidence, and those tables are copied
// out of it into the new one as far as SQLite can still read
// them (`docs/BUGS.md` B216).
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)"
"cache database is corrupt; moving it aside, rebuilding, and \
copying the upload queue, sync baselines and pins out of it"
);
quarantine_corrupt_db(path)?;
Self::open_configured(path)?
let damaged = quarantine_corrupt_db(path)?;
let conn = Self::open_configured(path)?;
for (table, result) in salvage(&conn, &damaged) {
match result {
Ok(rows) => {
tracing::warn!(table, rows, "salvaged from the damaged database")
}
Err(error) => tracing::error!(
table,
%error,
"could not salvage from the damaged database"
),
}
}
conn
}
Err(e) => return Err(e),
};
@@ -253,6 +309,12 @@ impl Db {
Ok(db)
}
/// The write connection for a commit that accepts a user's change into
/// the queue. See [`Durable`].
pub(super) fn durable(&self) -> Result<Durable<'_>> {
Durable::new(self.conn.lock())
}
/// A connection for a `SELECT`-only method.
///
/// Reads see the last committed state, which is the same thing they saw
@@ -280,7 +342,8 @@ impl Db {
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.
// standard durability/throughput tradeoff for WAL; the queue commits
// that need more raise it to FULL for themselves (`Durable`).
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "synchronous", "NORMAL")?;
conn.pragma_update(None, "foreign_keys", "ON")?;
@@ -427,12 +490,96 @@ fn is_corrupt(e: &Error) -> bool {
)
}
/// Move a damaged database (and its WAL sidecars) out of the way so the next
/// open starts from empty.
/// The tables [`salvage`] copies out of a damaged database, in order, each
/// with the rows it keeps.
///
/// 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<()> {
/// The queue goes with the node rows it names by local id: a create made here
/// has no other record of its node, and a real node keeps its local id when its
/// folder is listed again. Those rows come back unlisted, so their folders are
/// fetched again rather than trusted. Claims are cleared by [`Db::open`] as
/// after any other crash.
const SALVAGE: &[(&str, &str)] = &[
(
"nodes",
"lid IN (SELECT lid FROM old.pending_op WHERE lid IS NOT NULL
UNION SELECT parent_lid FROM old.pending_op WHERE parent_lid IS NOT NULL)
OR uid LIKE 'local~%'",
),
("pending_op", "1"),
("sync_folder", "1"),
("sync_entry", "1"),
("pins", "1"),
("own_sealed_rev", "1"),
];
/// Copy what only this machine has out of the damaged database at `damaged`
/// into the fresh one `conn` just created. Returns how each table went.
///
/// Each table is copied in its own transaction, so a table SQLite cannot read
/// to the end is left out whole rather than half copied, and does not stop
/// the others. Only the columns both files have are copied, which covers a
/// file that broke in the middle of an upgrade. The damaged file is opened read
/// only and left as it was.
fn salvage(conn: &Connection, damaged: &Path) -> Vec<(&'static str, Result<usize>)> {
let uri = format!(
"file:{}?mode=ro",
damaged
.to_string_lossy()
.replace('%', "%25")
.replace('?', "%3f")
.replace('#', "%23")
);
if let Err(e) = conn.execute("ATTACH DATABASE ?1 AS old", [&uri]) {
let e = Error::Db(e);
tracing::error!(error = %e, "could not open the damaged database to salvage it");
return Vec::new();
}
let results = SALVAGE
.iter()
.map(|&(table, rows)| (table, salvage_table(conn, table, rows)))
.collect();
let _ = conn.execute("UPDATE nodes SET listed = 0", []);
if let Err(e) = conn.execute("DETACH DATABASE old", []) {
tracing::warn!(error = %e, "could not detach the damaged database");
}
results
}
fn salvage_table(conn: &Connection, table: &str, rows: &str) -> Result<usize> {
let columns = |schema: &str| -> Result<Vec<String>> {
let mut stmt = conn.prepare(&format!("PRAGMA {schema}.table_info({table})"))?;
let names = stmt
.query_map([], |r| r.get::<_, String>(1))?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(names)
};
let new = columns("main")?;
let shared: Vec<String> = columns("old")?
.into_iter()
.filter(|c| new.contains(c))
.map(|c| format!("\"{c}\""))
.collect();
if shared.is_empty() {
return Ok(0);
}
let shared = shared.join(", ");
let tx = conn.unchecked_transaction()?;
let copied = tx.execute(
&format!(
"INSERT OR IGNORE INTO main.{table} ({shared}) SELECT {shared} FROM old.{table} WHERE {rows}"
),
[],
)?;
tx.commit()?;
Ok(copied)
}
/// Move a damaged database (and its WAL sidecars) out of the way so the next
/// open starts from empty. Returns where the database itself went.
///
/// Renamed rather than deleted: it is the only evidence of what went wrong,
/// and [`salvage`] reads what only this machine has back out of it.
fn quarantine_corrupt_db(path: &Path) -> Result<PathBuf> {
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
@@ -456,7 +603,9 @@ fn quarantine_corrupt_db(path: &Path) -> Result<()> {
Err(e) => tracing::warn!(file = %from.display(), error = %e, "could not quarantine"),
}
}
Ok(())
let mut kept = path.as_os_str().to_owned();
kept.push(format!(".corrupt-{stamp}"));
Ok(PathBuf::from(kept))
}
#[cfg(test)]
+1
View File
@@ -171,6 +171,7 @@ impl Db {
pending_mode: pending_mode.as_deref().map(MountMode::from),
mounted: false,
progress: None,
paused: false,
});
}
Ok(mounts)
+470 -93
View File
@@ -3,9 +3,11 @@
use std::collections::HashSet;
use rusqlite::types::Value;
use rusqlite::{OptionalExtension, Transaction, params};
use super::Db;
use super::ops::{OP_CREATE, OP_MKDIR, OP_RENAME, local_lid, local_uid, wake_ops_waiting_for_tx};
use crate::{Access, Result};
use proton_drive_rs::proton_sdk::ids::{LinkId, NodeUid, VolumeId};
use proton_drive_rs::{Node, NodeKind};
@@ -17,6 +19,8 @@ use super::utils::{
pub struct StoredNode {
pub node: Node,
pub listed: bool,
/// The row's local id ([`Db::lid_of`]).
pub lid: i64,
}
/// One full-text search match: the stored [`Node`] plus its mountpoint-relative
@@ -62,12 +66,13 @@ impl Db {
/// 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 (key, parent) = parent_key(&conn, &parent.to_string())?;
let mut stmt = conn.prepare(&format!(
"SELECT {NODE_NOW} FROM nodes n LEFT JOIN nodes p ON p.lid = n.parent_lid
WHERE n.{key} = ?1 AND n.trashed = 0 AND n.node_json IS NOT NULL
ORDER BY n.name, n.uid"
))?;
let rows = stmt.query_map([parent], |row| row.get::<_, String>(0))?;
let mut nodes = Vec::new();
for row in rows {
nodes.push(serde_json::from_str(&row?)?);
@@ -264,10 +269,25 @@ impl Db {
/// 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<()> {
///
/// An op that found the name still held by this trash failed with a
/// backoff, and the name is free now. It is made due at once
/// ([`Db::wake_ops_waiting_for`]).
///
/// Returns the staged blob of the create the trash withdrew, if it kept
/// one, for the caller to discard.
pub fn complete_trash_op(&self, op_id: i64, uid: &NodeUid) -> Result<Option<String>> {
let uid = uid.to_string();
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
let (name, blob): (Option<String>, Option<String>) = tx
.query_row(
"SELECT name, blob_path FROM pending_op WHERE id = ?1",
params![op_id],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.optional()?
.unwrap_or_default();
let rowid: Option<i64> = tx
.query_row(
"SELECT rowid FROM nodes WHERE uid = ?1",
@@ -280,17 +300,23 @@ impl Db {
tx.execute("DELETE FROM nodes_fts WHERE rowid = ?1", params![rowid])?;
}
tx.execute("DELETE FROM pending_op WHERE id = ?1", params![op_id])?;
if let Some(name) = name {
wake_ops_waiting_for_tx(&tx, &name)?;
}
tx.commit()?;
Ok(())
Ok(blob)
}
/// 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 (key, parent) = parent_key(&conn, &parent_uid.to_string())?;
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM nodes WHERE parent_uid = ?1 AND trashed = 0",
params![uid_str],
&format!(
"SELECT COUNT(*) FROM nodes
WHERE {key} = ?1 AND trashed = 0 AND node_json IS NOT NULL"
),
params![parent],
|row| row.get(0),
)?;
Ok(count > 0)
@@ -305,9 +331,11 @@ impl Db {
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)
})
.query_row(
"SELECT 1 FROM nodes WHERE uid = ?1 AND node_json IS NOT NULL",
params![uid],
|r| r.get(0),
)
.optional()?;
if exists.is_none() {
return Ok(None);
@@ -325,11 +353,11 @@ impl Db {
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
"WITH RECURSIVE anc(uid, parent_lid, name, depth) AS (
SELECT uid, parent_lid, 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
SELECT n.uid, n.parent_lid, n.name, anc.depth + 1
FROM nodes n JOIN anc ON n.lid = anc.parent_lid
WHERE anc.depth < 1024
)
SELECT uid, name FROM anc ORDER BY depth",
@@ -355,6 +383,22 @@ impl Db {
/// 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>> {
self.search_in(query, limit, None)
}
/// [`search`](Self::search) restricted to the subtree under the
/// mountpoint-relative folder `scope`. `None` or an empty scope searches
/// everything.
pub fn search_in(
&self,
query: &str,
limit: usize,
scope: Option<&str>,
) -> Result<Vec<SearchHit>> {
let scope = scope.map(|s| s.trim_matches('/')).filter(|s| !s.is_empty());
// Rows whose path is not persisted yet pass the SQL filter and are
// checked once their path is walked below.
let scope_pat = scope.map(|s| format!("{}/%", like_escape(s)));
let query = query.trim();
if query.is_empty() {
return Ok(Vec::new());
@@ -362,12 +406,14 @@ impl Db {
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)?)?
let mut stmt = conn.prepare(&format!(
"SELECT {NODE_NOW}, n.uid, n.path
FROM nodes n LEFT JOIN nodes p ON p.lid = n.parent_lid
WHERE n.name LIKE ?1 ESCAPE '\\' AND n.trashed = 0 AND n.node_json IS NOT NULL
AND (?3 IS NULL OR n.path IS NULL OR n.path LIKE ?3 ESCAPE '\\')
ORDER BY n.name LIMIT ?2"
))?;
collect_hits(stmt.query_map(params![pat, limit as i64, scope_pat], 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.
@@ -376,13 +422,15 @@ impl Db {
.map(|word| format!("\"{}\"", word.replace('"', "\"\"")))
.collect::<Vec<_>>()
.join(" AND ");
let mut stmt = conn.prepare(
"SELECT n.node_json, n.uid, n.path
let mut stmt = conn.prepare(&format!(
"SELECT {NODE_NOW}, n.uid, n.path
FROM nodes_fts f JOIN nodes n ON n.rowid = f.rowid
LEFT JOIN nodes p ON p.lid = n.parent_lid
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)?)?
AND (?3 IS NULL OR n.path IS NULL OR n.path LIKE ?3 ESCAPE '\\')
ORDER BY f.rank LIMIT ?2"
))?;
collect_hits(stmt.query_map(params![phrase, limit as i64, scope_pat], hit_row)?)?
};
let mut hits = Vec::with_capacity(rows.len());
@@ -392,6 +440,13 @@ impl Db {
Some(path) => path,
None => walk_path_of(&conn, &uid)?,
};
if let Some(scope) = scope
&& !path
.strip_prefix(scope)
.is_some_and(|rest| rest.starts_with('/'))
{
continue;
}
hits.push(SearchHit { node, path });
}
Ok(hits)
@@ -411,20 +466,22 @@ impl Db {
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",
)?;
let mut stmt = conn.prepare(&format!(
"SELECT {NODE_NOW}, n.uid, n.path
FROM nodes n LEFT JOIN nodes p ON p.lid = n.parent_lid
WHERE n.name LIKE ?1 ESCAPE '\\' AND n.trashed = 0 AND n.node_json IS NOT NULL
ORDER BY n.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
let mut stmt = conn.prepare(&format!(
"SELECT {NODE_NOW}, n.uid, n.path
FROM nodes_fts f JOIN nodes n ON n.rowid = f.rowid
LEFT JOIN nodes p ON p.lid = n.parent_lid
WHERE nodes_fts MATCH ?1 AND n.trashed = 0 AND n.node_json IS NOT NULL
ORDER BY f.rank LIMIT ?2",
)?;
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
@@ -434,12 +491,13 @@ impl Db {
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 stmt = conn.prepare(&format!(
"SELECT {NODE_NOW}, n.uid, n.path
FROM nodes n LEFT JOIN nodes p ON p.lid = n.parent_lid
WHERE n.name COLLATE NOCASE LIKE ?1 ESCAPE '\\'
AND n.trashed = 0 AND n.node_json IS NOT NULL
ORDER BY n.name COLLATE NOCASE LIMIT ?2"
))?;
let mut extra = Vec::new();
for pattern in [
format!("{}%", like_escape(query)),
@@ -475,40 +533,50 @@ impl Db {
/// re-enumerates from the remote on next access.
pub fn set_listed(&self, uid: &NodeUid, listed: bool) -> Result<()> {
let conn = self.conn.lock();
let (key, uid) = row_key(&uid.to_string());
conn.execute(
"UPDATE nodes SET listed = ?2 WHERE uid = ?1",
params![uid.to_string(), listed as i64],
&format!("UPDATE nodes SET listed = ?2 WHERE {key} = ?1"),
params![uid, listed as i64],
)?;
Ok(())
}
/// Load every persisted node for cold-start hydration of the `State` maps.
///
/// Each node names its parent as the parent's row has it now, found by
/// local id: a folder that landed has its new uid there, while the node
/// stored below it may still hold the placeholder.
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 mut stmt = conn.prepare(&format!(
"SELECT {NODE_NOW}, n.listed, n.lid
FROM nodes n LEFT JOIN nodes p ON p.lid = n.parent_lid
WHERE n.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))
Ok((json, listed != 0, row.get(2)?))
})?;
let mut out = Vec::new();
for row in rows {
let (json, listed) = row?;
let (json, listed, lid) = row?;
let node: Node = serde_json::from_str(&json)?;
out.push(StoredNode { node, listed });
out.push(StoredNode { node, listed, lid });
}
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).
/// when the API is unreachable, so the mount can still serve the cached tree.
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",
&format!(
"SELECT {NODE_NOW} FROM nodes n LEFT JOIN nodes p ON p.lid = n.parent_lid
WHERE n.uid = ?1 AND n.node_json IS NOT NULL"
),
params![uid],
|r| r.get(0),
)
@@ -519,31 +587,178 @@ impl Db {
}
}
/// The uid of the node with local id `lid`: `None` when it has no row, or
/// is not on Drive yet.
pub fn uid_of_lid(&self, lid: i64) -> Result<Option<String>> {
let conn = self.read();
Ok(conn
.query_row("SELECT uid FROM nodes WHERE lid = ?1", params![lid], |r| {
r.get(0)
})
.optional()?
.flatten())
}
/// The local id of the node stored under `uid`, or `None` when it has no
/// row. It stays the same for as long as the row exists, including when a
/// create lands and the row takes the uid Drive gave it: a node made on
/// this machine is found by its stand-in ([`local_uid`]) before and after.
pub fn lid_of(&self, uid: &str) -> Result<Option<i64>> {
node_lid(&self.read(), uid)
}
/// The local id of each node, in order, giving a row to each that has none.
///
/// A new row is only a stub: it has no `node_json`, so no listing or search
/// returns it until the node is upserted, which the caller still owes. That
/// keeps this cheap enough to call with the inode lock held, which is
/// where an inode number is first needed.
pub fn lids_for(&self, nodes: &[&Node]) -> Result<Vec<i64>> {
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
let mut lids = Vec::with_capacity(nodes.len());
{
let mut known = tx.prepare_cached("SELECT lid FROM nodes WHERE uid = ?1")?;
let mut stub = tx.prepare_cached(
"INSERT INTO nodes (uid, parent_uid, name, is_dir, mtime, trashed, parent_lid)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
)?;
for node in nodes {
let uid = node.uid.to_string();
let lid = match known.query_row(params![uid], |r| r.get(0)).optional()? {
Some(lid) => lid,
None => {
let parent = node.parent_uid.as_ref().map(ToString::to_string);
let parent_lid = match &parent {
Some(parent) => node_lid(&tx, parent)?,
None => None,
};
stub.execute(params![
uid,
parent,
node.name,
node.is_folder() as i64,
node.modification_time,
node.trashed as i64,
parent_lid,
])?;
let lid = tx.last_insert_rowid();
adopt_children_tx(&tx, &uid, lid)?;
lid
}
};
lids.push(lid);
}
}
tx.commit()?;
Ok(lids)
}
/// Give a node being made on this machine its row under `parent_uid`, and
/// return its local id. The row goes by [`local_uid`] of that id until
/// the node lands.
///
/// Like [`Db::lids_for`], only a stub: the caller upserts the node.
pub fn add_local_row(
&self,
parent_uid: &str,
name: &str,
is_dir: bool,
mtime: i64,
) -> Result<i64> {
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
tx.execute(
"INSERT INTO nodes (uid, parent_uid, name, is_dir, mtime, trashed, parent_lid)
VALUES (NULL, ?1, ?2, ?3, ?4, 0,
COALESCE(?5, (SELECT lid FROM nodes WHERE uid = ?1)))",
params![
parent_uid,
name,
is_dir as i64,
mtime,
local_lid(parent_uid)
],
)?;
let lid = tx.last_insert_rowid();
tx.execute(
"UPDATE nodes SET uid = ?2 WHERE lid = ?1",
params![lid, local_uid(lid)],
)?;
tx.commit()?;
Ok(lid)
}
/// 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 (key, folder) = row_key(&parent.to_string());
let listed: Option<i64> = conn
.query_row(
"SELECT listed FROM nodes WHERE uid = ?1",
params![parent.to_string()],
&format!("SELECT listed FROM nodes WHERE {key} = ?1"),
params![folder],
|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))?;
drop(conn);
self.known_children(parent).map(Some)
}
/// The children the DB knows under `parent`, whether or not the folder's
/// listing is still marked complete. Only for when nothing better can be
/// had: a listing dropped as stale may miss or keep a child.
pub fn known_children(&self, parent: &NodeUid) -> Result<Vec<Node>> {
let conn = self.read();
let (key, parent) = parent_key(&conn, &parent.to_string())?;
let mut stmt = conn.prepare(&format!(
"SELECT {NODE_NOW} FROM nodes n LEFT JOIN nodes p ON p.lid = n.parent_lid
WHERE n.{key} = ?1 AND n.node_json IS NOT NULL AND n.trashed = 0"
))?;
let rows = stmt.query_map(params![parent], |r| r.get::<_, String>(0))?;
let mut out = Vec::new();
for json in rows {
out.push(serde_json::from_str(&json?)?);
}
Ok(Some(out))
Ok(out)
}
/// What a queued change does to `parent`'s listing, which Drive does not
/// show until it lands: the children made or moved in by a queued create,
/// mkdir or rename, as the DB holds them, and the nodes a queued rename
/// moves elsewhere.
///
/// A listing read from Drive is laid over with these, or a folder listed
/// while a create in it is queued, after a restart say, loses the file
/// until the create lands (`docs/BUGS.md` B132).
pub fn queued_children(&self, parent: &NodeUid) -> Result<(Vec<Node>, HashSet<String>)> {
let conn = self.read();
let (key, parent) = parent_key(&conn, &parent.to_string())?;
let mut stmt = conn.prepare(&format!(
"SELECT {NODE_NOW} FROM nodes n LEFT JOIN nodes p ON p.lid = n.parent_lid
WHERE n.{key} = ?1 AND n.node_json IS NOT NULL AND n.trashed = 0
AND EXISTS (SELECT 1 FROM pending_op o
WHERE (o.lid = n.lid OR o.uid = n.uid) AND o.kind IN (?2, ?3, ?4))"
))?;
let rows = stmt.query_map(params![parent, OP_CREATE, OP_MKDIR, OP_RENAME], |r| {
r.get::<_, String>(0)
})?;
let mut here = Vec::new();
for json in rows {
here.push(serde_json::from_str(&json?)?);
}
let mut stmt = conn.prepare(&format!(
"SELECT DISTINCT n.uid FROM pending_op p JOIN nodes n ON n.lid = p.lid OR n.uid = p.uid
WHERE p.kind = ?2 AND n.{key} IS NOT ?1"
))?;
let gone = stmt
.query_map(params![parent, OP_RENAME], |r| r.get::<_, String>(0))?
.collect::<std::result::Result<HashSet<_>, _>>()?;
Ok((here, gone))
}
// --- Content-cache LRU index (P4) -------------------------------------
@@ -565,9 +780,157 @@ struct PriorRow {
trashed: bool,
}
/// [`Db::lid_of`] on `conn`, which may be a transaction.
pub(super) fn node_lid(conn: &rusqlite::Connection, uid: &str) -> Result<Option<i64>> {
let row = match local_lid(uid) {
Some(lid) => conn.query_row("SELECT lid FROM nodes WHERE lid = ?1", params![lid], |r| {
r.get(0)
}),
None => conn.query_row("SELECT lid FROM nodes WHERE uid = ?1", params![uid], |r| {
r.get(0)
}),
};
Ok(row.optional()?)
}
/// The column and value that find `uid`'s own row. A stand-in is found by its
/// local id: a create that lands gives the row the uid Drive made before the
/// tree has it, and the tree asks by the stand-in until then (`docs/BUGS.md`
/// B177).
fn row_key(uid: &str) -> (&'static str, Value) {
match local_lid(uid) {
Some(lid) => ("lid", Value::Integer(lid)),
None => ("uid", Value::Text(uid.to_owned())),
}
}
/// The column and value a row names `parent` by: its local id when it has a
/// row, which the children keep when it lands, or its uid when it has none,
/// as a device folder's root does.
fn parent_key(conn: &rusqlite::Connection, parent: &str) -> Result<(&'static str, Value)> {
Ok(match node_lid(conn, parent)? {
Some(lid) => ("parent_lid", Value::Integer(lid)),
None => ("parent_uid", Value::Text(parent.to_owned())),
})
}
/// `n.node_json` naming its parent as the parent's row `p`, joined by
/// `n.parent_lid`, has it now. A folder that lands takes its real uid in its
/// own row only, so a node stored below it still names the stand-in.
const NODE_NOW: &str = "CASE WHEN p.uid IS NULL OR p.uid = n.parent_uid THEN n.node_json
ELSE json_set(n.node_json, '$.parent_uid', json_object(
'volume_id', substr(p.uid, 1, instr(p.uid, '~') - 1),
'link_id', substr(p.uid, instr(p.uid, '~') + 1))) END";
/// Hand a drained placeholder's row to the real uid it landed as.
///
/// Nothing else is rewritten. The rows below it and the ops made inside it
/// name it by its local id, which stays; what reads them for a uid takes the
/// one the row has now ([`NODE_NOW`], `PARENT_NOW` in `ops.rs`).
///
/// Dropping the placeholder row and waiting for the server's copy of the real
/// node left a window with no row for either uid. A queued child drained in
/// that window found no authority for its parent, re-read it over the network
/// and was deferred five seconds behind a folder that had just landed
/// (`docs/BUGS.md` B120). The placeholder's own node, readdressed, stands in
/// until the server's copy is upserted over it. A row the real uid already
/// has is newer than the placeholder, so its node is kept, under the
/// placeholder's `lid`: the node was known by that one first.
pub(super) fn adopt_placeholder_row_tx(
tx: &Transaction<'_>,
local: &str,
real: &str,
) -> Result<()> {
// A folder pinned before it landed stays pinned: the pin keyed by the
// placeholder was dropped with it (`docs/BUGS.md` B148).
tx.execute(
"UPDATE OR IGNORE pins SET uid = ?2 WHERE uid = ?1",
params![local, real],
)?;
tx.execute("DELETE FROM pins WHERE uid = ?1", params![local])?;
let row: Option<(i64, Option<String>)> = tx
.query_row(
"SELECT lid, node_json FROM nodes WHERE uid = ?1",
params![local],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.optional()?;
// Ops queued before schema 39 have no `parent_lid`, nor do ops made
// under a placeholder that never had a row.
tx.execute(
"UPDATE pending_op SET parent_uid = ?2 WHERE parent_lid IS NULL AND parent_uid = ?1",
params![local, real],
)?;
let Some((lid, json)) = row else {
return Ok(());
};
let known: Option<(i64, Option<String>)> = tx
.query_row(
"SELECT lid, node_json FROM nodes WHERE uid = ?1",
params![real],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.optional()?;
if let Some((known_lid, _)) = known {
// Ops queued against the row Drive listed now name the landing one.
tx.execute(
"UPDATE pending_op SET lid = ?2 WHERE lid = ?1",
params![known_lid, lid],
)?;
tx.execute(
"UPDATE pending_op SET parent_lid = ?2 WHERE parent_lid = ?1",
params![known_lid, lid],
)?;
tx.execute(
"UPDATE nodes SET parent_lid = ?2 WHERE parent_lid = ?1",
params![known_lid, lid],
)?;
tx.execute("DELETE FROM nodes WHERE lid = ?1", params![known_lid])?;
tx.execute("DELETE FROM nodes_fts WHERE rowid = ?1", params![known_lid])?;
}
tx.execute(
"UPDATE nodes SET uid = ?2 WHERE lid = ?1",
params![lid, real],
)?;
let json = known.and_then(|(_, known_json)| known_json).or(json);
let (Some(json), Some(real)) = (json, parse_node_uid(real)) else {
return Ok(());
};
let mut node: Node = serde_json::from_str(&json)?;
node.uid = real;
upsert_node_tx(tx, &node)
}
/// Link the rows that name `uid` as their parent to its new row `lid`: they
/// were stored before it, or under a row of the same uid since dropped.
fn adopt_children_tx(tx: &Transaction<'_>, uid: &str, lid: i64) -> Result<()> {
tx.execute(
"UPDATE nodes SET parent_lid = ?2 WHERE parent_uid = ?1 AND parent_lid IS NOT ?2",
params![uid, lid],
)?;
Ok(())
}
fn upsert_node_tx(tx: &Transaction<'_>, node: &Node) -> Result<()> {
let json = serde_json::to_string(node)?;
let uid = node.uid.to_string();
// A node written under its stand-in after it landed is its row, which has
// the uid Drive gave it by now: written under the stand-in, it would bring
// the placeholder row back beside it.
if let Some(lid) = local_lid(&uid) {
let landed: Option<String> = tx
.query_row(
"SELECT uid FROM nodes WHERE lid = ?1 AND uid IS NOT ?2",
params![lid, uid],
|row| row.get(0),
)
.optional()?;
if let Some(real) = landed.as_deref().and_then(parse_node_uid) {
let mut node = node.clone();
node.uid = real;
return upsert_node_tx(tx, &node);
}
}
let json = serde_json::to_string(node)?;
let parent_uid = node.parent_uid.as_ref().map(|u| u.to_string());
let prior: Option<PriorRow> = tx
@@ -589,26 +952,34 @@ fn upsert_node_tx(tx: &Transaction<'_>, node: &Node) -> Result<()> {
// 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(),
let (parent_lid, path) = match &parent_uid {
None => (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)
let parent_row: Option<(i64, Option<String>)> = match node_lid(tx, parent)? {
Some(lid) => tx
.query_row(
"SELECT lid, path FROM nodes WHERE lid = ?1",
params![lid],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.optional()?,
None => None,
};
let (lid, parent_path) = parent_row.unzip();
(
lid,
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)
(uid, parent_uid, name, is_dir, size, mtime, trashed, node_json, path, parent_lid)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
ON CONFLICT(uid) DO UPDATE SET
parent_uid = excluded.parent_uid,
parent_lid = excluded.parent_lid,
name = excluded.name,
is_dir = excluded.is_dir,
size = excluded.size,
@@ -626,6 +997,7 @@ fn upsert_node_tx(tx: &Transaction<'_>, node: &Node) -> Result<()> {
node.trashed as i64,
json,
path,
parent_lid,
],
)?;
let rowid: i64 = tx.query_row(
@@ -633,6 +1005,9 @@ fn upsert_node_tx(tx: &Transaction<'_>, node: &Node) -> Result<()> {
params![uid],
|row| row.get(0),
)?;
if prior.is_none() {
adopt_children_tx(tx, &uid, rowid)?;
}
// FTS5 has no UPSERT, so the node's own row is always replaced.
let indexable = node_is_indexable_tx(tx, &uid)?;
@@ -655,7 +1030,7 @@ fn upsert_node_tx(tx: &Transaction<'_>, node: &Node) -> Result<()> {
|| prior.trashed != node.trashed
});
if node.is_folder() && subtree_moved {
reindex_subtree_tx(tx, &uid, &path, indexable)?;
reindex_subtree_tx(tx, rowid, &path, indexable)?;
}
Ok(())
}
@@ -676,22 +1051,22 @@ fn upsert_node_tx(tx: &Transaction<'_>, node: &Node) -> Result<()> {
/// this deep.
fn reindex_subtree_tx(
tx: &Transaction<'_>,
folder: &str,
folder: i64,
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,
"WITH RECURSIVE sub(rowid, name, path, blocked, depth) AS (
SELECT n.rowid, n.name,
CASE WHEN ?2 = '' THEN n.name ELSE ?2 || '/' || n.name END,
n.trashed, 0
FROM nodes n WHERE n.parent_uid = ?1
FROM nodes n WHERE n.parent_lid = ?1
UNION ALL
SELECT n.rowid, n.uid, n.name,
SELECT n.rowid, 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
FROM nodes n JOIN sub ON n.parent_lid = sub.rowid
WHERE sub.depth < 256
)
SELECT rowid, path, blocked FROM sub",
@@ -716,8 +1091,10 @@ fn reindex_subtree_tx(
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(
// Not a stub row (`Db::lids_for`): it is indexed when its node is
// written.
"INSERT INTO nodes_fts (rowid, name, path)
SELECT rowid, name, ?2 FROM nodes WHERE rowid = ?1",
SELECT rowid, name, ?2 FROM nodes WHERE rowid = ?1 AND node_json IS NOT NULL",
)?;
for (rowid, path, blocked) in descendants {
set_path.execute(params![rowid, path])?;
@@ -730,7 +1107,8 @@ fn reindex_subtree_tx(
}
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 (key, parent) = parent_key(tx, parent)?;
let mut stmt = tx.prepare(&format!("SELECT uid FROM nodes WHERE {key} = ?1"))?;
let rows = stmt.query_map([parent], |row| row.get::<_, String>(0))?;
let mut uids = Vec::new();
for row in rows {
@@ -755,14 +1133,14 @@ fn direct_child_uids_tx(tx: &Transaction<'_>, parent: &str) -> Result<Vec<String
/// 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)
"WITH RECURSIVE ancestors(lid, parent_lid, trashed, depth, path) AS (
SELECT lid, parent_lid, trashed, 0, ',' || lid || ','
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 n.lid, n.parent_lid, n.trashed, a.depth + 1,
a.path || n.lid || ','
FROM ancestors a JOIN nodes n ON n.lid = a.parent_lid
WHERE instr(a.path, ',' || n.lid || ',') = 0
)
SELECT CASE
WHEN COUNT(*) = 0 THEN 0
@@ -770,8 +1148,7 @@ fn node_is_indexable_tx(tx: &Transaction<'_>, uid: &str) -> Result<bool> {
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)
AND EXISTS (SELECT 1 FROM nodes p WHERE p.lid = a.parent_lid)
) THEN 0
ELSE 1
END
@@ -789,7 +1166,7 @@ fn tombstone_subtree_tx(tx: &Transaction<'_>, root_uid: &str) -> Result<()> {
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
JOIN subtree s ON n.parent_lid = s.rowid
)
SELECT rowid, uid, node_json FROM subtree",
)?;
File diff suppressed because it is too large. Load diff
+753 -78
View File
@@ -3,7 +3,7 @@
use rusqlite::params;
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use super::Db;
use crate::Result;
@@ -34,67 +34,380 @@ pub struct StoredPhoto {
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: `(uid, capture_time, name, media_type,
/// favorite)`. The two trailing options are "what this refresh learned" — a
/// `None` keeps whatever is already stored rather than clearing it.
pub type TimelineRow = (String, i64, Option<String>, Option<String>, Option<bool>);
/// A town photos were taken in: its GeoNames id (see [`crate::places`]), how
/// many photos it holds, and the newest of them.
#[derive(Clone, Debug, PartialEq)]
pub struct StoredPlace {
pub id: u32,
pub count: usize,
pub cover: StoredPhoto,
}
/// 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>,
/// Where the photo was taken, as `(latitude, longitude)` in decimal degrees.
pub location: Option<(f64, f64)>,
/// 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>,
location: Option<(f64, f64)>,
/// What its thumbnail looks like (see [`crate::similar`]).
similar_hash: Option<i64>,
/// 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. Each item is
/// `(uid, capture_time, name, media_type, favorite)`; `favorite` is `None`
/// when the refresh could not resolve that photo's node, in which case the
/// flag already stored is kept rather than silently cleared.
/// 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()?;
// `media_type` is 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 a photo's media type yet when it replaces the timeline. Keep
// any previously learned value so the Photos/Videos/Raw split survives a
// refresh instead of collapsing back to name-extension guesses.
let learned: HashMap<String, (Option<f64>, i64, Option<String>, bool)> = {
let mut stmt =
tx.prepare("SELECT uid, ratio, thumb_state, media_type, favorite FROM photos")?;
// `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, latitude, longitude, similar_hash FROM photos",
)?;
let rows = stmt.query_map([], |r| {
Ok((
r.get::<_, String>(0)?,
(r.get(1)?, r.get(2)?, r.get(3)?, r.get::<_, i64>(4)? != 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)?,
location: r
.get::<_, Option<f64>>(9)?
.zip(r.get::<_, Option<f64>>(10)?),
similar_hash: r.get(11)?,
},
))
})?;
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);
// New bytes may show another picture: hash it again.
if row.content_hash != carried.content_hash {
carried.similar_hash = None;
}
carried.content_hash = row.content_hash.clone();
carried.main_uid = row.main_uid.clone();
carried.location = row.location;
} 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.location = row.location.or(carried.location);
}
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)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
(uid, capture_time, name, ratio, thumb_state, seq, media_type, kind, favorite,
content_hash, main_uid, group_key, resolved_at, latitude, longitude,
place_id, similar_hash)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15,
?16, ?17)",
)?;
for (seq, (uid, capture_time, name, media_type, favorite)) in items.iter().enumerate() {
let (ratio, thumb_state, learned_media, learned_favorite) = learned
.get(uid)
.cloned()
.unwrap_or((None, THUMB_UNKNOWN, None, false));
let media_type = media_type.clone().or(learned_media);
let favorite = favorite.unwrap_or(learned_favorite);
// 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(name.as_deref(), media_type.as_deref());
for (seq, ((row, carried, kind), group_key)) in
resolved.iter().zip(group_keys.iter()).enumerate()
{
stmt.execute(params![
uid,
capture_time,
name,
ratio,
thumb_state,
row.uid,
row.capture_time,
carried.name,
carried.ratio,
carried.thumb_state,
seq as i64,
media_type,
carried.media_type,
kind.as_i64(),
favorite as i64,
carried.favorite as i64,
carried.content_hash,
carried.main_uid,
group_key,
carried.resolved_at,
carried.location.map(|(lat, _)| lat),
carried.location.map(|(_, lon)| lon),
carried
.location
.and_then(|(lat, lon)| crate::places::nearest(lat, lon))
.map(|city| city.id),
carried.similar_hash,
])?;
}
}
@@ -102,11 +415,93 @@ impl Db {
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])?;
}
}
// A group whose representative went is left pointing at nothing, which
// drops its other files off every page. Make them stand alone until the
// next refresh groups them again.
tx.execute(
"UPDATE photos SET group_key = NULL WHERE group_key IS NOT NULL \
AND group_key NOT IN (SELECT uid FROM photos)",
[],
)?;
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.
/// `favorites` restricts the page to favorited photos, and `not_in_album`
/// to the shots no album holds any file of.
///
/// 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,
@@ -114,27 +509,38 @@ impl Db {
kind: Option<crate::control::PhotoKind>,
range: Option<(i64, i64)>,
favorites: bool,
not_in_album: 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(
"SELECT uid, capture_time, name, ratio, thumb_state, kind, favorite FROM photos",
);
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!("kind = ?{}", binds.len()));
conds.push(format!("p.kind = ?{}", binds.len()));
}
if let Some((from, to)) = range {
binds.push(from);
conds.push(format!("capture_time >= ?{}", binds.len()));
conds.push(format!("p.capture_time >= ?{}", binds.len()));
binds.push(to);
conds.push(format!("capture_time < ?{}", binds.len()));
conds.push(format!("p.capture_time < ?{}", binds.len()));
}
if favorites {
conds.push("favorite = 1".to_string());
conds.push("p.favorite = 1".to_string());
}
// By group, not by file: a shot filed as its JPEG is filed, even though
// its RAW was never added anywhere.
if not_in_album {
conds.push(
"NOT EXISTS (SELECT 1 FROM album_photos a JOIN photos m ON m.uid = a.uid \
WHERE COALESCE(m.group_key, m.uid) = COALESCE(p.group_key, p.uid))"
.to_string(),
);
}
if !conds.is_empty() {
sql.push_str(" WHERE ");
@@ -145,22 +551,41 @@ impl Db {
binds.push(offset as i64);
let offset_pos = binds.len();
sql.push_str(&format!(
" ORDER BY seq LIMIT ?{limit_pos} OFFSET ?{offset_pos}"
" 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), |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)?),
favorite: r.get::<_, i64>(6)? != 0,
})
})?
.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)
}
@@ -175,9 +600,15 @@ impl Db {
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(k) => (" WHERE kind = ?1", vec![k.as_i64()]),
None => ("", Vec::new()),
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, \
@@ -198,11 +629,267 @@ impl Db {
Ok(rows)
}
/// Photos taken on `today`'s calendar day in earlier years, newest first —
/// the "On this day" strip. `today` is epoch seconds: its local month and day
/// pick the date, and its own year is left out, because today's photos are
/// what the timeline already opens on. One row per group, like the timeline.
pub fn photos_on_this_day(&self, today: i64, limit: usize) -> Result<Vec<StoredPhoto>> {
let conn = self.read();
let sql = format!(
"{PHOTO_SELECT} WHERE p.uid = COALESCE(p.group_key, p.uid) \
AND strftime('%m-%d', p.capture_time, 'unixepoch', 'localtime') \
= strftime('%m-%d', ?1, 'unixepoch', 'localtime') \
AND strftime('%Y', p.capture_time, 'unixepoch', 'localtime') \
< strftime('%Y', ?1, 'unixepoch', 'localtime') \
ORDER BY p.seq LIMIT ?2"
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt
.query_map(params![today, limit as i64], stored_photo)?
.collect::<rusqlite::Result<_>>()?;
Ok(rows)
}
/// Every town photos were taken in, with how many and the newest one as its
/// cover, most photographed first. Counted per group, like the timeline;
/// photos without a location, or taken far from any town, are in none.
pub fn photo_places(&self) -> Result<Vec<StoredPlace>> {
let rows: Vec<(u32, usize, String)> = {
let conn = self.read();
// SQLite fills the bare `uid` from the row `MIN(seq)` picked, which
// is the newest photo of the place.
let mut stmt = conn.prepare(
"SELECT place_id, COUNT(*), uid, MIN(seq) FROM photos \
WHERE uid = COALESCE(group_key, uid) AND place_id IS NOT NULL \
GROUP BY place_id ORDER BY COUNT(*) DESC, MIN(seq)",
)?;
stmt.query_map([], |r| {
Ok((r.get(0)?, r.get::<_, i64>(1)? as usize, r.get(2)?))
})?
.collect::<rusqlite::Result<_>>()?
};
let uids: Vec<String> = rows.iter().map(|(_, _, uid)| uid.clone()).collect();
let mut covers: HashMap<String, StoredPhoto> = self
.photos_by_uid(&uids)?
.into_iter()
.map(|photo| (photo.uid.clone(), photo))
.collect();
Ok(rows
.into_iter()
.filter_map(|(id, count, uid)| {
Some(StoredPlace {
id,
count,
cover: covers.remove(&uid)?,
})
})
.collect())
}
/// One page of the photos taken in a town, newest first, one per group.
pub fn photos_at_place(
&self,
id: u32,
offset: usize,
limit: usize,
) -> Result<Vec<StoredPhoto>> {
let conn = self.read();
let sql = format!(
"{PHOTO_SELECT} WHERE p.uid = COALESCE(p.group_key, p.uid) AND p.place_id = ?1 \
ORDER BY p.seq LIMIT ?2 OFFSET ?3"
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt
.query_map(params![id, limit as i64, offset as i64], stored_photo)?
.collect::<rusqlite::Result<_>>()?;
Ok(rows)
}
/// Every photo taken in a town, one per group as in [`Self::photos_at_place`],
/// with where exactly it was taken. A group's own location is that of the
/// photo standing for it.
pub fn photo_locations_at_place(&self, id: u32) -> Result<Vec<(StoredPhoto, (f64, f64))>> {
let photos = self.photos_at_place(id, 0, usize::MAX >> 1)?;
let located: HashMap<String, (f64, f64)> = {
let conn = self.read();
let mut stmt = conn.prepare(
"SELECT uid, latitude, longitude FROM photos \
WHERE place_id = ?1 AND latitude IS NOT NULL AND longitude IS NOT NULL",
)?;
stmt.query_map(params![id], |r| Ok((r.get(0)?, (r.get(1)?, r.get(2)?))))?
.collect::<rusqlite::Result<_>>()?
};
Ok(photos
.into_iter()
.filter_map(|photo| {
let at = *located.get(&photo.uid)?;
Some((photo, at))
})
.collect())
}
/// Sets of photos with the same content hash, each set ordered with the
/// copy worth keeping first, and the sets newest first.
///
/// The copy to keep is the one in the most albums, then the one that is part
/// of a larger group (the JPEG's RAW is filed with it, a stray re-upload is
/// not), then the earliest in server order. At most one file per group is
/// listed: the files of one RAW+JPEG shot are not copies of each other, and a
/// set left with one member is no set.
pub fn photo_duplicates(&self) -> Result<Vec<Vec<StoredPhoto>>> {
let rows: Vec<(String, String, String)> = {
let conn = self.read();
let mut stmt = conn.prepare(
"SELECT p.uid, p.content_hash, COALESCE(p.group_key, p.uid) AS k FROM photos p \
WHERE p.content_hash IN (SELECT content_hash FROM photos \
WHERE content_hash IS NOT NULL GROUP BY content_hash HAVING COUNT(*) > 1) \
ORDER BY p.content_hash, \
(SELECT COUNT(*) FROM album_photos a WHERE a.uid = p.uid) DESC, \
(SELECT COUNT(*) FROM photos m WHERE COALESCE(m.group_key, m.uid) = COALESCE(p.group_key, p.uid)) DESC, \
p.seq",
)?;
stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?
.collect::<rusqlite::Result<_>>()?
};
let uids: Vec<String> = rows.iter().map(|(uid, _, _)| uid.clone()).collect();
let mut stored: HashMap<String, StoredPhoto> = self
.photos_by_uid(&uids)?
.into_iter()
.map(|photo| (photo.uid.clone(), photo))
.collect();
let mut sets: Vec<Vec<StoredPhoto>> = Vec::new();
let mut hash_of_set: Option<&str> = None;
let mut groups_in_set: HashSet<&str> = HashSet::new();
for (uid, hash, group) in &rows {
if hash_of_set != Some(hash.as_str()) {
hash_of_set = Some(hash);
groups_in_set.clear();
sets.push(Vec::new());
}
if !groups_in_set.insert(group) {
continue;
}
if let (Some(set), Some(photo)) = (sets.last_mut(), stored.remove(uid)) {
set.push(photo);
}
}
sets.retain(|set| set.len() > 1);
sets.sort_by_key(|set| std::cmp::Reverse(set.iter().map(|p| p.capture_time).max()));
Ok(sets)
}
/// Shots whose look hasn't been hashed yet, by the uid of the photo the
/// grid shows for each, newest first, at most `limit`. Videos, and photos
/// with no thumbnail to hash, are left out. A shot counts as hashed once
/// any of its files is, since they all show one picture.
pub fn photos_unhashed(&self, limit: usize) -> Result<Vec<StoredPhoto>> {
let uids: Vec<String> = {
let conn = self.read();
let mut stmt = conn.prepare(
"SELECT COALESCE(group_key, uid) AS k FROM photos \
GROUP BY k \
HAVING MAX(similar_hash) IS NULL \
AND MAX(kind != ?1 AND thumb_state != ?2) \
ORDER BY MAX(capture_time) DESC LIMIT ?3",
)?;
stmt.query_map(
params![
crate::control::PhotoKind::Video.as_i64(),
THUMB_NONE,
limit as i64
],
|r| r.get(0),
)?
.collect::<rusqlite::Result<_>>()?
};
self.photos_by_uid(&uids)
}
/// Record the look of photo `uid`: its difference hash, or
/// [`crate::similar::NO_HASH`] when its thumbnail gave none.
pub fn photo_set_similar_hash(&self, uid: &str, hash: u64) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"UPDATE photos SET similar_hash = ?2 WHERE uid = ?1",
params![uid, hash as i64],
)?;
Ok(())
}
/// How many shots have been hashed, out of how many could be.
pub fn similar_progress(&self) -> Result<(usize, usize)> {
let conn = self.read();
let (hashed, total): (i64, i64) = conn.query_row(
"SELECT COUNT(*) FILTER (WHERE hashed), COUNT(*) FROM \
(SELECT MAX(similar_hash) IS NOT NULL AS hashed FROM photos \
GROUP BY COALESCE(group_key, uid) \
HAVING MAX(similar_hash) IS NOT NULL \
OR MAX(kind != ?1 AND thumb_state != ?2))",
params![crate::control::PhotoKind::Video.as_i64(), THUMB_NONE],
|r| Ok((r.get(0)?, r.get(1)?)),
)?;
Ok((hashed.max(0) as usize, total.max(0) as usize))
}
/// Sets of shots that look alike (see [`crate::similar`]), one photo per
/// shot in capture order, and the sets newest first. A set whose photos
/// are all byte-identical copies is left to [`Self::photo_duplicates`].
pub fn photo_similar(&self) -> Result<Vec<Vec<StoredPhoto>>> {
let rows: Vec<(String, u64, Option<String>)> = {
let conn = self.read();
let mut stmt = conn.prepare(
"SELECT COALESCE(group_key, uid) AS k, MAX(similar_hash), \
MAX(content_hash) FILTER (WHERE uid = COALESCE(group_key, uid)) \
FROM photos GROUP BY k \
HAVING MAX(similar_hash) IS NOT NULL AND MAX(similar_hash) != ?1",
)?;
stmt.query_map(params![crate::similar::NO_HASH as i64], |r| {
Ok((r.get(0)?, r.get::<_, i64>(1)? as u64, r.get(2)?))
})?
.collect::<rusqlite::Result<_>>()?
};
let hashes: Vec<u64> = rows.iter().map(|(_, hash, _)| *hash).collect();
let found: Vec<Vec<usize>> = crate::similar::sets(&hashes)
.into_iter()
.filter(|set| {
let first = &rows[set[0]].2;
first.is_none() || set.iter().any(|&i| rows[i].2 != *first)
})
.collect();
let uids: Vec<String> = found.iter().flatten().map(|&i| rows[i].0.clone()).collect();
let mut stored: HashMap<String, StoredPhoto> = self
.photos_by_uid(&uids)?
.into_iter()
.map(|photo| (photo.uid.clone(), photo))
.collect();
let mut sets: Vec<Vec<StoredPhoto>> = found
.iter()
.map(|set| {
let mut photos: Vec<StoredPhoto> = set
.iter()
.filter_map(|&i| stored.remove(&rows[i].0))
.collect();
photos.sort_by_key(|p| p.capture_time);
photos
})
.filter(|set| set.len() > 1)
.collect();
sets.sort_by_key(|set| std::cmp::Reverse(set.iter().map(|p| p.capture_time).max()));
Ok(sets)
}
/// 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();
let mut stmt = conn.prepare("SELECT kind, COUNT(*) FROM photos GROUP BY kind")?;
// 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 {
@@ -225,21 +912,9 @@ impl Db {
}
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, favorite FROM photos
WHERE uid IN ({placeholders})"
))?;
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)?),
favorite: r.get::<_, i64>(6)? != 0,
})
})?;
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?);
@@ -247,7 +922,7 @@ impl Db {
Ok(photos)
}
/// Record a photo's favourite flag locally, after the server accepted the
/// Record a photo's favorite 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<()> {
+15 -13
View File
@@ -64,11 +64,11 @@ impl Db {
}
/// Whether `uid` is pinned — directly, or because a strict ancestor folder
/// is pinned recursively. The ancestor check walks `parent_uid` to the root
/// is pinned recursively. The ancestor check walks `parent_lid` 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`
/// The walk is depth-capped like [`super::utils::path_of`]: a parent
/// 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.
@@ -85,11 +85,11 @@ impl Db {
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
"WITH RECURSIVE anc(uid, parent_lid, depth) AS (
SELECT uid, parent_lid, 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
SELECT n.uid, n.parent_lid, anc.depth + 1
FROM nodes n JOIN anc ON n.lid = anc.parent_lid
WHERE anc.depth < {MAX_PATH_DEPTH}
)
SELECT 1 FROM anc a JOIN pins p ON p.uid = a.uid
@@ -109,10 +109,11 @@ impl Db {
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
"WITH RECURSIVE sub(uid, lid) AS (
SELECT p.uid, n.lid FROM pins p LEFT JOIN nodes n ON n.uid = p.uid
WHERE p.recursive = 1
UNION
SELECT n.uid FROM nodes n JOIN sub ON n.parent_uid = sub.uid
SELECT n.uid, n.lid FROM nodes n JOIN sub ON n.parent_lid = sub.lid
)
SELECT uid FROM sub
UNION
@@ -126,15 +127,16 @@ impl Db {
Ok(out)
}
/// Every descendant uid of `folder` (all depths), via a `parent_uid` CTE.
/// Every descendant uid of `folder` (all depths), via a `parent_lid` 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
"WITH RECURSIVE sub(uid, lid) AS (
SELECT c.uid, c.lid FROM nodes c JOIN nodes f ON c.parent_lid = f.lid
WHERE f.uid = ?1
UNION
SELECT n.uid FROM nodes n JOIN sub ON n.parent_uid = sub.uid
SELECT n.uid, n.lid FROM nodes n JOIN sub ON n.parent_lid = sub.lid
)
SELECT uid FROM sub",
)?;
+26 -9
View File
@@ -95,11 +95,27 @@ impl Db {
/// 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.
///
/// A stand-in ([`local_uid`](super::local_uid)) finds its row by local id,
/// so it still does once its create has landed and the row has the uid
/// Drive gave it. The tree learns that uid a moment later, and a rename in
/// between was refused (`docs/BUGS.md` B174).
pub fn effective_node_access(&self, uid: &NodeUid) -> Result<Option<Access>> {
let conn = self.read();
let key = uid.to_string();
let key = match super::local_lid(&key) {
Some(lid) => conn
.query_row("SELECT uid FROM nodes WHERE lid = ?1", [lid], |row| {
row.get::<_, Option<String>>(0)
})
.optional()?
.flatten()
.unwrap_or(key),
None => key,
};
let exists: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM nodes WHERE uid = ?1)",
[uid.to_string()],
"SELECT EXISTS(SELECT 1 FROM nodes WHERE uid = ?1 AND node_json IS NOT NULL)",
[&key],
|row| row.get(0),
)?;
if !exists {
@@ -107,25 +123,26 @@ impl Db {
}
let value = conn
.query_row(
"WITH RECURSIVE ancestors(uid, parent_uid, depth, path) AS (
SELECT uid, parent_uid, 0, char(31) || uid || char(31)
"WITH RECURSIVE ancestors(uid, parent_uid, parent_lid, depth, path) AS (
SELECT uid, parent_uid, parent_lid, 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)
SELECT COALESCE(n.uid, a.parent_uid), n.parent_uid, n.parent_lid,
a.depth + 1,
a.path || COALESCE(n.uid, a.parent_uid) || char(31)
FROM ancestors a
LEFT JOIN nodes n ON n.uid = a.parent_uid
LEFT JOIN nodes n ON n.lid = a.parent_lid
WHERE a.parent_uid IS NOT NULL
AND instr(
a.path,
char(31) || a.parent_uid || char(31)
char(31) || COALESCE(n.uid, 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()],
[&key],
|row| row.get::<_, String>(0),
)
.optional()?;
+117 -13
View File
@@ -25,8 +25,8 @@ pub struct StoredSyncFolder {
}
/// 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 —
/// last successful sync, so the next reconcile can tell which side changed.
/// `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)]
@@ -39,12 +39,18 @@ pub struct StoredSyncEntry {
///
/// `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
/// edit at all, so those never uploaded (`docs/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>,
/// The id of the remote revision this row was synced at. A mirror upload
/// checks it before it supersedes what Drive holds, because another client
/// may have committed a revision since (`docs/BUGS.md` B249). `None` on a
/// row written before schema 44, or by a pass that did not learn it; the
/// check then falls back to `remote_rev` and `remote_hash`.
pub remote_revision_id: Option<String>,
}
impl Db {
@@ -139,6 +145,17 @@ impl Db {
Ok(())
}
/// Re-point a synced folder at the share that now holds its remote folder,
/// after the folder moved to another device without changing its uid.
pub fn sync_folder_set_share(&self, id: i64, share_id: &str) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"UPDATE sync_folder SET remote_share_id = ?2 WHERE id = ?1",
params![id, share_id],
)?;
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<()> {
@@ -177,7 +194,7 @@ impl Db {
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
local_mtime_ns, remote_revision_id
FROM sync_entry WHERE folder_id = ?1",
)?;
let rows = stmt.query_map(params![folder_id], |r| {
@@ -189,6 +206,7 @@ impl Db {
remote_rev: r.get(4)?,
remote_hash: r.get(5)?,
local_mtime_ns: r.get(6)?,
remote_revision_id: r.get(7)?,
})
})?;
let mut map = HashMap::new();
@@ -227,15 +245,16 @@ impl Db {
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)
remote_hash, local_mtime_ns, remote_revision_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
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",
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,
remote_revision_id = excluded.remote_revision_id",
params![
folder_id,
e.rel_path,
@@ -245,6 +264,7 @@ impl Db {
e.remote_rev,
e.remote_hash,
e.local_mtime_ns,
e.remote_revision_id,
],
)?;
Ok(())
@@ -260,10 +280,94 @@ impl Db {
Ok(())
}
/// Drop the baseline rows of `rel_path` and everything under it.
///
/// Used when a subtree leaves a mirror folder through a move on Drive: its
/// local copy is about to be removed, and rows left behind would read as
/// "deleted locally" and trash the moved nodes at their new place.
pub fn sync_entries_remove_subtree(&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 OR substr(rel_path, 1, length(?2) + 1) = ?2 || '/')",
params![folder_id, rel_path],
)?;
Ok(())
}
/// Move the baseline rows of `from_rel` and everything under it to `to_rel`
/// in folder `to_folder`, in one transaction.
///
/// Used when a move on Drive took a mirror subtree along with its local
/// copy: the rows still describe both sides correctly, only under a new
/// path, so the next pass on either folder has nothing to do. The two
/// folders may be the same one.
pub fn sync_entries_move(
&self,
from_folder: i64,
from_rel: &str,
to_folder: i64,
to_rel: &str,
) -> Result<()> {
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
{
let mut select = tx.prepare(
"SELECT rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash,
local_mtime_ns, remote_revision_id
FROM sync_entry
WHERE folder_id = ?1
AND (rel_path = ?2 OR substr(rel_path, 1, length(?2) + 1) = ?2 || '/')",
)?;
let rows = select
.query_map(params![from_folder, from_rel], |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)?,
remote_revision_id: r.get(7)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
let mut delete =
tx.prepare("DELETE FROM sync_entry WHERE folder_id = ?1 AND rel_path = ?2")?;
for row in &rows {
delete.execute(params![from_folder, row.rel_path])?;
}
let mut insert = tx.prepare(
"INSERT OR REPLACE INTO sync_entry
(folder_id, rel_path, remote_uid, local_mtime, local_size, remote_rev,
remote_hash, local_mtime_ns, remote_revision_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
)?;
for row in rows {
let rel = format!("{to_rel}{}", &row.rel_path[from_rel.len()..]);
insert.execute(params![
to_folder,
rel,
row.remote_uid,
row.local_mtime,
row.local_size,
row.remote_rev,
row.remote_hash,
row.local_mtime_ns,
row.remote_revision_id,
])?;
}
}
tx.commit()?;
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).
/// leaves an empty baseline + full remote = pure download.
pub fn sync_entries_clear(&self, folder_id: i64) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
File diff suppressed because it is too large. Load diff
+46 -4
View File
@@ -1,5 +1,7 @@
//! Trashed nodes, persisted so the trash view opens without a round-trip.
use std::collections::HashMap;
use rusqlite::params;
use super::Db;
@@ -11,6 +13,13 @@ pub struct StoredTrash {
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>,
/// When the daemon first saw the node in the trash, epoch seconds. `None`
/// when it cannot say (see schema v35).
pub trashed_at: Option<i64>,
}
impl Db {
@@ -20,7 +29,8 @@ impl Db {
tx.execute("DELETE FROM trash", [])?;
{
let mut stmt = tx.prepare(
"INSERT INTO trash (uid, name, is_dir, size, mtime) VALUES (?1, ?2, ?3, ?4, ?5)",
"INSERT INTO trash (uid, name, is_dir, size, mtime, parent_uid, trashed_at) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
)?;
for item in items {
stmt.execute(params![
@@ -28,7 +38,9 @@ impl Db {
item.name,
item.is_dir as i64,
item.size,
item.mtime
item.mtime,
item.parent_uid,
item.trashed_at
])?;
}
}
@@ -41,7 +53,7 @@ impl Db {
pub fn trash_list(&self) -> Result<Vec<StoredTrash>> {
let conn = self.read();
let mut stmt = conn.prepare(
"SELECT uid, name, is_dir, size, mtime FROM trash
"SELECT uid, name, is_dir, size, mtime, parent_uid, trashed_at FROM trash
ORDER BY is_dir DESC, name COLLATE NOCASE",
)?;
let rows = stmt.query_map([], |r| {
@@ -51,6 +63,8 @@ impl Db {
is_dir: r.get::<_, i64>(2)? != 0,
size: r.get(3)?,
mtime: r.get(4)?,
parent_uid: r.get(5)?,
trashed_at: r.get(6)?,
})
})?;
let mut items = Vec::new();
@@ -60,5 +74,33 @@ impl Db {
Ok(items)
}
// ---- device sync (devices.md) -----------------------------------------
/// 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)
}
/// When each node now in the trash was first seen there, keyed by uid, so
/// a refresh can carry the times over into the listing it writes.
pub fn trash_seen(&self) -> Result<HashMap<String, Option<i64>>> {
let conn = self.read();
let mut stmt = conn.prepare("SELECT uid, trashed_at FROM trash")?;
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?;
let mut seen = HashMap::new();
for row in rows {
let (uid, at) = row?;
seen.insert(uid, at);
}
Ok(seen)
}
// ---- device sync -----------------------------------------
}
+6 -6
View File
@@ -72,18 +72,18 @@ pub(super) fn path_of(conn: &Connection, uid: &str) -> Result<String> {
}
}
/// Resolve a path by walking `parent_uid` to the root via a recursive CTE.
/// Resolve a path by walking `parent_lid` to the root via a recursive CTE.
///
/// A cycle in `parent_uid` is corrupt data the API can still hand us, and
/// A cycle in the parents 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
"WITH RECURSIVE anc(parent_lid, parent_uid, name, depth) AS (
SELECT parent_lid, 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
SELECT n.parent_lid, n.parent_uid, n.name, anc.depth + 1
FROM nodes n JOIN anc ON n.lid = anc.parent_lid
WHERE anc.depth < {MAX_PATH_DEPTH}
)
SELECT name, parent_uid FROM anc ORDER BY depth DESC"
+160 -23
View File
@@ -11,7 +11,7 @@ pub enum Error {
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),
@@ -32,6 +32,16 @@ pub enum Error {
#[error("saved session can no longer unlock your keys — run `pdfs login` again")]
ReloginRequired,
/// The account has a separate mailbox password that has not been entered
/// since login, so its keys cannot be unlocked. The fix is
/// [`auth::unlock`](crate::auth::unlock), not a new login.
#[error("account is locked — enter the mailbox password with `pdfs unlock`")]
Locked,
/// The mailbox password was checked against the account and is wrong.
#[error("that mailbox password does not unlock your account")]
WrongMailboxPassword,
/// 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)).
@@ -66,6 +76,20 @@ impl Error {
}
}
impl Error {
/// Whether a control-socket round-trip gave up waiting for the reply: the
/// daemon is running but stuck, as opposed to not running at all.
pub fn is_timeout(&self) -> bool {
matches!(
self,
Error::Io(e) if matches!(
e.kind(),
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
)
)
}
}
pub type Result<T> = std::result::Result<T, Error>;
/// A failure on its way to a front-end: prose for the user, plus the
@@ -130,19 +154,20 @@ impl CoreError {
/// 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.
/// A transport error, a timeout or 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. The SDK's other failures say what went wrong instead
/// (see [`proton_kind`]).
///
/// Callers also hand it this crate's [`Error`] from the cache and the
/// database, which is not a transport error at all. Taking those for
/// "offline" told the user their connection was down when a pin failed on
/// the database (`docs/BUGS.md` B266), so the source chain is searched for
/// either type before falling back.
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}"))
Self::new(api_kind_of(e), format!("{what}: {e}"))
}
/// Prefix the message while keeping the classification.
@@ -184,16 +209,57 @@ impl From<serde_json::Error> for CoreError {
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())
Self::new(core_kind(&e), e.to_string())
}
}
/// What this crate's [`Error`] means to a front-end.
fn core_kind(e: &Error) -> ErrorKind {
match e {
Error::NotLoggedIn
| Error::ReloginRequired
| Error::Locked
| Error::WrongMailboxPassword
| Error::HumanVerificationRequired(_) => ErrorKind::Denied,
Error::Proton(proton) => proton_kind(proton),
_ => ErrorKind::Internal,
}
}
/// The kind for [`CoreError::from_api`]: the first SDK or crate error in `e`'s
/// source chain decides it.
fn api_kind_of(e: &(dyn std::error::Error + 'static)) -> ErrorKind {
let mut cause = Some(e);
while let Some(err) = cause {
if let Some(proton) = err.downcast_ref::<proton_sdk::ProtonError>() {
return proton_kind(proton);
}
if let Some(core) = err.downcast_ref::<Error>() {
return core_kind(core);
}
cause = err.source();
}
// Not the SDK's at all: a timeout around the call, or the socket.
ErrorKind::Offline
}
/// What a failed SDK call means to a front-end.
///
/// Only a transport failure is the connection. The rest happened on this
/// machine, and calling them "offline" sent the user to check a network that
/// worked (issue #38: a photo upload on an account without a photo library
/// read "You're offline").
fn proton_kind(e: &proton_sdk::ProtonError) -> ErrorKind {
use proton_sdk::ProtonError as P;
match e {
P::Api(api) => api_kind(api.code),
P::Transport(_) => ErrorKind::Offline,
// Proton answered, but not with anything this client can read.
P::Serialization(_) => ErrorKind::Remote,
// The SDK refused the call before sending it, such as an upload to a
// photo library the account does not have.
P::InvalidOperation(_) => ErrorKind::Invalid,
P::Crypto(_) | P::KeysLocked => ErrorKind::Internal,
}
}
@@ -252,6 +318,32 @@ fn api_kind(code: proton_sdk::api::ResponseCode) -> ErrorKind {
}
}
/// Whether `e` says the session itself is gone: its refresh token was refused,
/// or the account was deleted or disabled.
///
/// Nothing but a new login helps then, and no retry will ever succeed, so a
/// caller must not take it for a lost connection and wait for it to come back
/// (issue #35: a revoked session was shown as "offline" for good).
pub fn session_revoked(e: &proton_sdk::ProtonError) -> bool {
use proton_sdk::api::ResponseCode as C;
match e {
proton_sdk::ProtonError::Api(api) => {
api.is_invalid_refresh_token()
|| matches!(api.code, C::AccountDeleted | C::AccountDisabled)
}
_ => false,
}
}
/// Whether `e` is Proton's temporary block on a sign-in (code 2028).
///
/// Its message says the app version is no longer supported, which is not what
/// happened: the release is current and only waiting helps (issues #28, #39).
/// A front-end must say that instead of passing the message through.
pub fn sign_in_blocked(e: &proton_sdk::ProtonError) -> bool {
matches!(e, proton_sdk::ProtonError::Api(api) if api.code == proton_sdk::api::ResponseCode::Blocked)
}
/// The result every request-serving method in the daemon returns.
pub type CoreResult<T> = std::result::Result<T, CoreError>;
@@ -283,6 +375,25 @@ mod tests {
assert_eq!(api_kind(C::ProtonDriveUnknown), ErrorKind::Remote);
}
#[test]
fn only_a_refused_refresh_or_a_closed_account_revokes_the_session() {
assert!(session_revoked(&api(C::InvalidRefreshToken)));
assert!(session_revoked(&api(C::AccountDeleted)));
assert!(session_revoked(&api(C::AccountDisabled)));
// One file the account may not touch says nothing about the session.
assert!(!session_revoked(&api(C::Forbidden)));
assert!(!session_revoked(&api(C::Timeout)));
assert!(!session_revoked(&ProtonError::KeysLocked));
}
#[test]
fn only_code_2028_is_a_blocked_sign_in() {
assert!(sign_in_blocked(&api(C::Blocked)));
assert!(!sign_in_blocked(&api(C::OutdatedApp)));
assert!(!sign_in_blocked(&api(C::TooManyRequests)));
assert!(!sign_in_blocked(&ProtonError::KeysLocked));
}
/// 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.
@@ -313,6 +424,32 @@ mod tests {
assert_eq!(CoreError::from_api(&io, "list").kind, ErrorKind::Offline);
}
/// Issue #38: the SDK refused a photo upload before sending it, and the
/// app said "You're offline" while every other call went through.
#[test]
fn a_call_the_sdk_refuses_to_send_is_not_offline() {
let refused = ProtonError::invalid_operation("account has no photos volume");
let e = CoreError::from_api(&refused, "upload photo failed");
assert_eq!(e.kind, ErrorKind::Invalid);
assert!(e.message.ends_with("account has no photos volume"));
let e: CoreError = Error::Proton(ProtonError::KeysLocked).into();
assert_ne!(e.kind, ErrorKind::Offline);
}
/// A pin or a search that fails on the database is not the connection, so
/// it must not say "You're offline" (`docs/BUGS.md` B266). An SDK error
/// wrapped in this crate's type classifies as the SDK error does.
#[test]
fn a_database_error_handed_to_from_api_is_not_offline() {
let db = Error::Other("database is locked".into());
assert_eq!(CoreError::from_api(&db, "pin").kind, ErrorKind::Internal);
let wrapped = Error::Proton(api(C::DoesNotExist));
assert_eq!(
CoreError::from_api(&wrapped, "load node").kind,
ErrorKind::NotFound
);
}
/// `Error` is the plumbing type; converting it must classify as sharply as
/// the direct path, or the two disagree about the same failure.
#[test]
+133
View File
@@ -0,0 +1,133 @@
//! When a photo was taken, as its own EXIF tags say.
//!
//! The fallback for a photo nothing else dates: a Takeout photo without a
//! sidecar or a date in its name, and a photo uploaded from the app. Without
//! it the first was filed under the moment of the import and the second under
//! the file's modification time, which a copy resets.
use std::io::{BufRead, Seek};
use std::path::Path;
use crate::takeout::days_from_civil;
/// The capture time in the EXIF of the file at `path`, in epoch seconds.
pub fn capture_time_of(path: &Path) -> Option<i64> {
let file = std::fs::File::open(path).ok()?;
capture_time(&mut std::io::BufReader::new(file))
}
/// The capture time in the EXIF `reader` holds: `DateTimeOriginal`, then
/// `DateTimeDigitized`, each adjusted by its `OffsetTime…` tag when the camera
/// wrote one. Without an offset the time is read as UTC, as a date in a file
/// name is (`takeout::capture_time_from_name`).
pub fn capture_time(reader: &mut (impl BufRead + Seek)) -> Option<i64> {
let exif = exif::Reader::new().read_from_container(reader).ok()?;
let ascii = |tag: exif::Tag| -> Option<String> {
let field = exif.get_field(tag, exif::In::PRIMARY)?;
match &field.value {
exif::Value::Ascii(parts) => parts.first().map(|part| {
String::from_utf8_lossy(part)
.trim_matches('\0')
.trim()
.to_string()
}),
_ => None,
}
};
[
(exif::Tag::DateTimeOriginal, exif::Tag::OffsetTimeOriginal),
(exif::Tag::DateTimeDigitized, exif::Tag::OffsetTimeDigitized),
]
.into_iter()
.find_map(|(time, offset)| {
let local = parse_exif_time(&ascii(time)?)?;
let offset = ascii(offset).and_then(|o| parse_offset(&o)).unwrap_or(0);
Some(local - offset)
})
}
/// `YYYY:MM:DD HH:MM:SS` as epoch seconds, read as UTC. `None` for the
/// all-zero or blank value cameras write when they do not know.
fn parse_exif_time(text: &str) -> Option<i64> {
let bytes = text.as_bytes();
if bytes.len() < 19 || bytes[4] != b':' || bytes[7] != b':' || bytes[13] != b':' {
return None;
}
let number = |range: std::ops::Range<usize>| text.get(range)?.parse::<i64>().ok();
let (year, month, day) = (number(0..4)?, number(5..7)?, number(8..10)?);
let (hour, minute, second) = (number(11..13)?, number(14..16)?, number(17..19)?);
if !(1800..=9999).contains(&year)
|| !(1..=12).contains(&month)
|| !(1..=31).contains(&day)
|| hour > 23
|| minute > 59
|| second > 60
{
return None;
}
Some(days_from_civil(year, month, day) * 86_400 + hour * 3600 + minute * 60 + second)
}
/// `+HH:MM` or `-HH:MM` as seconds east of UTC.
fn parse_offset(text: &str) -> Option<i64> {
let sign = match text.as_bytes().first()? {
b'+' => 1,
b'-' => -1,
_ => return None,
};
let (hours, minutes) = text.get(1..)?.split_once(':')?;
let (hours, minutes) = (hours.parse::<i64>().ok()?, minutes.parse::<i64>().ok()?);
(hours <= 14 && minutes < 60).then_some(sign * (hours * 3600 + minutes * 60))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_exif_time_reads_as_utc_and_its_offset_moves_it() {
// 2023-06-01 12:00:00 UTC.
let noon = 1_685_620_800;
assert_eq!(parse_exif_time("2023:06:01 12:00:00"), Some(noon));
assert_eq!(parse_offset("+02:00"), Some(7200));
assert_eq!(parse_offset("-05:30"), Some(-19_800));
assert_eq!(
parse_exif_time("2023:06:01 14:00:00").map(|t| t - parse_offset("+02:00").unwrap()),
Some(noon)
);
}
#[test]
fn an_unknown_exif_time_is_no_time() {
assert_eq!(parse_exif_time("0000:00:00 00:00:00"), None);
assert_eq!(parse_exif_time(" : : : : "), None);
assert_eq!(parse_exif_time(""), None);
assert_eq!(parse_offset("02:00"), None);
}
#[test]
fn a_photo_is_dated_by_its_original_time_and_offset() {
let ascii = |tag, text: &str| exif::Field {
tag,
ifd_num: exif::In::PRIMARY,
value: exif::Value::Ascii(vec![text.as_bytes().to_vec()]),
};
let taken = ascii(exif::Tag::DateTimeOriginal, "2023:06:01 14:00:00");
let offset = ascii(exif::Tag::OffsetTimeOriginal, "+02:00");
let digitized = ascii(exif::Tag::DateTimeDigitized, "2024:01:01 00:00:00");
let mut writer = exif::experimental::Writer::new();
writer.push_field(&taken);
writer.push_field(&offset);
writer.push_field(&digitized);
let mut tiff = std::io::Cursor::new(Vec::new());
writer.write(&mut tiff, false).unwrap();
tiff.set_position(0);
assert_eq!(capture_time(&mut tiff), Some(1_685_620_800));
}
#[test]
fn a_file_without_exif_has_no_capture_time() {
let mut png = std::io::Cursor::new(b"\x89PNG\r\n\x1a\n".to_vec());
assert_eq!(capture_time(&mut png), None);
}
}
+233
View File
@@ -0,0 +1,233 @@
//! A secret held in the Linux kernel keyring, for as long as the user is logged in.
//!
//! This is where the key passphrases of a two-password account live by default:
//! the mailbox password is never stored, and the secrets derived from it are
//! kept in kernel memory — not on disk, not in the Secret Service — and vanish
//! when the user's last session ends or the machine reboots. Both the daemon and
//! `pdfs unlock` reach the same entry because it hangs off the *user* keyring.
//! It is also where session tokens wait while the Secret Service refuses them
//! ([`UNSAVED_TOKENS`]), so they outlive the process that holds them.
//!
//! Kernel keys are not paged to swap, are invisible to other users, and are
//! wiped by [`clear`] (`KEYCTL_REVOKE`), which makes them a better home than a
//! process's heap. Processes of the same user can still read them, exactly as
//! they could ptrace the daemon: this protects against disk, backup and other
//! users, not against code already running as you.
//!
//! The keyring may be unavailable (a container, a kernel built without
//! `CONFIG_KEYS`); every operation then reports it as an error and the caller
//! falls back to asking again, never to storing the secret somewhere weaker.
use std::io;
use zeroize::Zeroizing;
/// One secret in the user keyring, by its description. Descriptions are
/// namespaced so they cannot collide with another program's keys.
pub struct Key {
description: &'static [u8],
}
/// The account's key passphrases (see the module docs).
#[cfg(not(test))]
pub const PASSPHRASES: Key = Key {
description: b"pdfs:key-passphrases\0",
};
/// Tests must never touch a real unlock.
#[cfg(test)]
pub const PASSPHRASES: Key = Key {
description: b"pdfs:key-passphrases:test\0",
};
/// Session tokens the Secret Service refused to store, until it takes them.
/// A refresh token held only in a process's memory dies with the process.
#[cfg(not(test))]
pub const UNSAVED_TOKENS: Key = Key {
description: b"pdfs:unsaved-tokens\0",
};
#[cfg(test)]
pub const UNSAVED_TOKENS: Key = Key {
description: b"pdfs:unsaved-tokens:test\0",
};
const KEY_TYPE: &[u8] = b"user\0";
const KEY_SPEC_USER_KEYRING: i32 = -4;
const KEYCTL_REVOKE: libc::c_long = 3;
const KEYCTL_SETPERM: libc::c_long = 5;
const KEYCTL_SEARCH: libc::c_long = 10;
const KEYCTL_READ: libc::c_long = 11;
/// Possessor and owner may view, read, write, search, link and set attributes;
/// group and others get nothing.
///
/// A new key's default grants the owner only *view*, and read access to the
/// possessor. A process possesses the user keyring only when its session
/// keyring links to it, which a systemd user service or a container does not
/// guarantee — so with the default, the daemon could store a key it then
/// cannot read back. The owner is the same user that could ptrace the daemon
/// anyway, so granting it read access gives up nothing.
const PERM_OWNER_AND_POSSESSOR: u32 = 0x3f3f_0000;
fn last_error<T>() -> io::Result<T> {
Err(io::Error::last_os_error())
}
fn find(description: &[u8]) -> io::Result<Option<libc::c_long>> {
// SAFETY: both strings are NUL-terminated and outlive the call.
let id = unsafe {
libc::syscall(
libc::SYS_keyctl,
KEYCTL_SEARCH,
KEY_SPEC_USER_KEYRING,
KEY_TYPE.as_ptr(),
description.as_ptr(),
0,
)
};
if id >= 0 {
return Ok(Some(id));
}
let err = io::Error::last_os_error();
match err.raw_os_error() {
// ENOKEY: not stored. EKEYREVOKED/EKEYEXPIRED: stored once, now gone.
Some(libc::ENOKEY | libc::EKEYREVOKED | libc::EKEYEXPIRED) => Ok(None),
_ => Err(err),
}
}
/// Stop this process from being core-dumped or ptraced by an unprivileged
/// process of the same user, so the secrets in its memory stay out of crash
/// reports. Call once at start of a long-lived process that holds them.
pub fn forbid_dumps() {
// SAFETY: `PR_SET_DUMPABLE` takes plain integers and only affects this process.
let rc = unsafe { libc::prctl(libc::PR_SET_DUMPABLE, 0, 0, 0, 0) };
if rc != 0 {
tracing::warn!(error = %io::Error::last_os_error(), "could not mark the process non-dumpable");
}
}
impl Key {
/// Store `secret`, replacing any earlier one.
pub fn store(&self, secret: &[u8]) -> io::Result<()> {
// SAFETY: strings are NUL-terminated; the payload pointer/length pair
// describes `secret`, which outlives the call.
let id = unsafe {
libc::syscall(
libc::SYS_add_key,
KEY_TYPE.as_ptr(),
self.description.as_ptr(),
secret.as_ptr(),
secret.len(),
KEY_SPEC_USER_KEYRING,
)
};
if id < 0 {
return last_error();
}
// SAFETY: plain integer arguments.
let rc = unsafe {
libc::syscall(
libc::SYS_keyctl,
KEYCTL_SETPERM,
id,
PERM_OWNER_AND_POSSESSOR,
)
};
if rc < 0 {
// A key whose permissions are unknown is not one to leave behind.
let err = io::Error::last_os_error();
let _ = self.clear();
return Err(err);
}
Ok(())
}
/// Read the stored secret, or `None` when there is none.
pub fn load(&self) -> io::Result<Option<Zeroizing<Vec<u8>>>> {
let Some(id) = find(self.description)? else {
return Ok(None);
};
// Size first, then read; retry if the key was replaced in between.
for _ in 0..3 {
// SAFETY: a null buffer asks the kernel only for the payload length.
let len = unsafe {
libc::syscall(
libc::SYS_keyctl,
KEYCTL_READ,
id,
std::ptr::null_mut::<u8>(),
0usize,
)
};
if len < 0 {
return match io::Error::last_os_error().raw_os_error() {
Some(libc::ENOKEY | libc::EKEYREVOKED | libc::EKEYEXPIRED) => Ok(None),
_ => last_error(),
};
}
let mut buf = Zeroizing::new(vec![0u8; len as usize]);
// SAFETY: `buf` is `len` writable bytes.
let read = unsafe {
libc::syscall(
libc::SYS_keyctl,
KEYCTL_READ,
id,
buf.as_mut_ptr(),
buf.len(),
)
};
if read < 0 {
return last_error();
}
if read as usize <= buf.len() {
buf.truncate(read as usize);
return Ok(Some(buf));
}
}
Err(io::Error::other("the key changed while it was being read"))
}
/// Remove the stored secret. Succeeds when there is none.
pub fn clear(&self) -> io::Result<()> {
let Some(id) = find(self.description)? else {
return Ok(());
};
// SAFETY: plain integer arguments.
let rc = unsafe { libc::syscall(libc::SYS_keyctl, KEYCTL_REVOKE, id) };
if rc < 0 {
return match io::Error::last_os_error().raw_os_error() {
Some(libc::ENOKEY | libc::EKEYREVOKED) => Ok(()),
_ => last_error(),
};
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
// The kernel keyring is process-wide state and a sandbox may not offer it,
// so one test covers the whole round trip and skips when it is unavailable.
#[test]
fn stored_secret_round_trips_and_clears() {
if PASSPHRASES.store(b"probe").is_err() {
eprintln!("kernel keyring unavailable; skipping");
return;
}
let saved = PASSPHRASES
.load()
.unwrap()
.expect("stored secret is readable");
assert_eq!(saved.as_slice(), b"probe");
PASSPHRASES.store(b"replaced").unwrap();
assert_eq!(PASSPHRASES.load().unwrap().unwrap().as_slice(), b"replaced");
PASSPHRASES.clear().unwrap();
assert!(PASSPHRASES.load().unwrap().is_none());
PASSPHRASES.clear().unwrap();
}
}
+294
View File
@@ -0,0 +1,294 @@
//! Trace the key chain that opens My Files, for `pdfs diagnose`.
//!
//! The daemon only says "missing key" when the My Files share does not open
//! (#40). This walks the chain the other way and names the link that breaks:
//! the share's passphrase is encrypted to an address key, whose token is
//! encrypted to an account key, which opens with the stored passphrase. Every
//! finding holds key IDs and flags only, never key material, so the output is
//! safe to post in an issue.
use proton_sdk::account::{AddressKeyReport, KeyReport, UserKeyReport};
use proton_sdk::ids::AddressId;
use crate::auth;
use crate::error::Result;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Level {
Ok,
Warn,
Fail,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Finding {
pub level: Level,
pub label: String,
pub detail: String,
}
/// The address and recipients of the My Files share passphrase, or why they
/// could not be read.
pub type ShareRecipients = std::result::Result<(AddressId, Vec<Option<String>>), String>;
/// Resume the stored session and ask Proton for the account's keys and the
/// My Files share. Fails when there is no session, the keys are locked, or
/// Proton cannot be reached; reading the share is allowed to fail on its own.
///
/// The session's tokens are the daemon's too. A refresh here spends the
/// refresh token the daemon holds, which it recovers from by resuming the
/// stored session (see [`auth::stored_session_is_newer`]).
pub async fn run() -> Result<(KeyReport, ShareRecipients)> {
let (client, _session) = auth::resume_client().await?;
let report = client.account().key_report().await?;
let share = client
.my_files_share_recipients()
.await
.map_err(|e| e.to_string());
Ok((report, share))
}
/// One finding per key, then one for the My Files share.
pub fn findings(report: &KeyReport, share: &ShareRecipients) -> Vec<Finding> {
let mut findings = Vec::new();
for key in &report.user_keys {
findings.push(user_key_finding(key));
}
for address in &report.addresses {
for key in &address.keys {
findings.push(address_key_finding(report, &address.email, key));
}
}
findings.push(share_finding(report, share));
findings
}
fn user_key_finding(key: &UserKeyReport) -> Finding {
let label = format!("account key {}", short(key.id.as_str()));
let (level, detail) = match (key.active, key.unlocks) {
(true, true) => (Level::Ok, "opens".to_string()),
(true, false) => (
Level::Fail,
format!(
"does not open: {}",
key.error.as_deref().unwrap_or("unknown")
),
),
(false, true) => (Level::Ok, "inactive, opens with your password".to_string()),
(false, false) => (Level::Ok, "inactive, does not open".to_string()),
};
Finding {
level,
label,
detail: with_primary(key.primary, detail),
}
}
fn address_key_finding(report: &KeyReport, email: &str, key: &AddressKeyReport) -> Finding {
let label = format!("{email} key {}", short(key.id.as_str()));
let (level, detail) = if !key.active {
(Level::Ok, "inactive, not used".to_string())
} else if key.unlocks {
(Level::Ok, "opens".to_string())
} else {
// A non-primary key matters only for what was encrypted to it; the
// share finding says whether My Files is.
let level = if key.primary {
Level::Fail
} else {
Level::Warn
};
(level, format!("does not open: {}", why_closed(report, key)))
};
Finding {
level,
label,
detail: with_primary(key.primary, detail),
}
}
/// Why an active address key does not open, named by the account key its
/// token was meant for when the token has one.
fn why_closed(report: &KeyReport, key: &AddressKeyReport) -> String {
let error = key.error.as_deref().unwrap_or("unknown");
let Some(recipients) = &key.token_recipients else {
return error.to_string();
};
let named: Vec<&str> = recipients.iter().flatten().map(String::as_str).collect();
if let Some(user_key) = named.iter().find_map(|r| report.user_key_named(r)) {
let id = short(user_key.id.as_str());
return match (user_key.active, user_key.unlocks) {
(false, true) => {
format!(
"its token is for inactive account key {id}, which opens with your password"
)
}
(false, false) => {
format!("its token is for inactive account key {id}, which does not open either")
}
(true, false) => format!("its token is for account key {id}, which does not open"),
// The token opened; the key itself refused its passphrase.
(true, true) => error.to_string(),
};
}
match named.first() {
Some(recipient) => {
format!("its token is for key {recipient}, which is none of your account keys")
}
None if recipients.is_empty() => error.to_string(),
None => "its token does not name the key it is for".to_string(),
}
}
fn share_finding(report: &KeyReport, share: &ShareRecipients) -> Finding {
let label = "My Files share".to_string();
let (level, detail) = match share {
Err(e) => (Level::Warn, format!("could not be read: {e}")),
Ok((address_id, recipients)) => {
let named: Vec<&str> = recipients.iter().flatten().map(String::as_str).collect();
match named.iter().find_map(|r| report.address_key_named(r)) {
Some((address, key)) => {
let name = format!("{} key {}", address.email, short(key.id.as_str()));
if key.unlocks {
(Level::Ok, format!("encrypted to {name}, which opens"))
} else {
(
Level::Fail,
format!("encrypted to {name}, which does not open"),
)
}
}
None => (
Level::Fail,
match named.first() {
Some(recipient) => format!(
"encrypted to key {recipient}, which is none of the keys of address {address_id}"
),
None => "its passphrase does not name the key it is for".to_string(),
},
),
}
}
};
Finding {
level,
label,
detail,
}
}
fn with_primary(primary: bool, detail: String) -> String {
if primary {
format!("primary, {detail}")
} else {
detail
}
}
/// The start of a Proton key ID: enough to tell keys apart and to find them
/// in the daemon log, which prints the whole ID.
fn short(id: &str) -> String {
match id.char_indices().nth(8) {
Some((end, _)) => format!("{}…", &id[..end]),
None => id.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use proton_sdk::account::AddressReport;
use proton_sdk::ids::{AddressKeyId, UserKeyId};
fn user_key(id: &str, active: bool, unlocks: bool, pgp: &str) -> UserKeyReport {
UserKeyReport {
id: UserKeyId::new(id),
primary: active,
active,
unlocks,
error: (!unlocks).then(|| "unlock: wrong passphrase".to_string()),
key_ids: vec![pgp.to_string()],
}
}
fn address_key(id: &str, primary: bool, token_for: &str, pgp: &str) -> AddressKeyReport {
AddressKeyReport {
id: AddressKeyId::new(id),
primary,
active: true,
token_recipients: Some(vec![Some(token_for.to_string())]),
unlocks: token_for == "u1",
error: (token_for != "u1").then(|| "token: failed to decrypt: missing key".to_string()),
key_ids: vec![pgp.to_string()],
}
}
/// The account in #40, as far as its log shows: the primary address key
/// opens, an older one does not, and My Files is encrypted to the older one.
fn report(stranded_token_for: &str) -> KeyReport {
KeyReport {
user_keys: vec![
user_key("UserKeyOne-123", true, true, "u1"),
user_key("UserKeyTwo-456", false, true, "u2"),
],
addresses: vec![AddressReport {
id: AddressId::new("address-1"),
email: "user@proton.me".to_string(),
keys: vec![
address_key("AddrKeyNew-789", true, "u1", "a1"),
address_key("AddrKeyOld-000", false, stranded_token_for, "a2"),
],
}],
}
}
#[test]
fn a_stranded_address_key_is_traced_to_the_inactive_account_key() {
let report = report("u2");
let share = Ok((AddressId::new("address-1"), vec![Some("a2".to_string())]));
let findings = findings(&report, &share);
assert_eq!(findings[0].detail, "primary, opens");
assert_eq!(findings[1].detail, "inactive, opens with your password");
assert_eq!(findings[2].level, Level::Ok);
assert_eq!(findings[3].level, Level::Warn);
assert_eq!(findings[3].label, "user@proton.me key AddrKeyO…");
assert_eq!(
findings[3].detail,
"does not open: its token is for inactive account key UserKeyT…, which opens with your password"
);
assert_eq!(findings[4].level, Level::Fail);
assert_eq!(
findings[4].detail,
"encrypted to user@proton.me key AddrKeyO…, which does not open"
);
}
#[test]
fn a_token_for_a_key_the_account_does_not_list_says_so() {
let report = report("ffff");
let detail = &findings(&report, &Err("offline".to_string()))[3].detail;
assert_eq!(
detail,
"does not open: its token is for key ffff, which is none of your account keys"
);
}
#[test]
fn a_share_that_opens_and_one_that_cannot_be_read() {
let report = report("u2");
let open = Ok((AddressId::new("address-1"), vec![Some("a1".to_string())]));
let last = findings(&report, &open).pop().unwrap();
assert_eq!(last.level, Level::Ok);
assert_eq!(
last.detail,
"encrypted to user@proton.me key AddrKeyN…, which opens"
);
let last = findings(&report, &Err("offline".to_string()))
.pop()
.unwrap();
assert_eq!(last.level, Level::Warn);
assert_eq!(last.detail, "could not be read: offline");
}
}
+9
View File
@@ -10,17 +10,26 @@ pub mod config;
pub mod control;
pub mod db;
pub mod error;
pub mod exiftime;
pub mod kernelkey;
pub mod keycheck;
pub mod localindex;
pub mod menu;
pub mod mountfail;
pub mod mounts;
pub mod opener;
pub mod places;
pub mod profile;
pub mod sdkcache;
pub mod search;
pub mod service;
pub mod shell;
pub mod similar;
pub mod syncignore;
pub mod syncroot;
pub mod takeout;
pub mod tray;
pub mod update;
pub use access::{Access, access_for, perm_bits};
pub use error::{CoreError, CoreResult, Error, Result};
+102 -1
View File
@@ -42,7 +42,7 @@ 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;
pub 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
@@ -199,6 +199,9 @@ fn to_entry(entry: &ignore::DirEntry) -> Option<LocalEntry> {
/// 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(),
@@ -207,6 +210,66 @@ pub fn default_excludes(mountpoint: &Path, state_dir: &Path, cache_dir: &Path) -
]
}
/// 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::*;
@@ -268,4 +331,42 @@ mod tests {
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");
}
}
+108 -1
View File
@@ -72,6 +72,25 @@ const KNOWN_MENUS: [MenuFlavor; 6] = [
},
];
/// 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`
@@ -105,6 +124,13 @@ pub struct PromptConfig {
/// 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")]
@@ -124,6 +150,8 @@ pub enum PromptMode {
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
@@ -186,6 +214,43 @@ pub fn resolve_menu(configured: Option<&Vec<String>>) -> Option<Vec<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
@@ -305,7 +370,8 @@ fn flavor_of(program: &str) -> Option<&'static MenuFlavor> {
KNOWN_MENUS.iter().find(|flavor| flavor.program == base)
}
fn on_path(program: &str) -> bool {
/// 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;
};
@@ -408,6 +474,47 @@ mod tests {
);
}
#[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()];
+75
View File
@@ -0,0 +1,75 @@
//! Why the daemon's mount keeps failing, for the front ends to read.
//!
//! The control socket opens only once a mount has succeeded, so a daemon whose
//! mount fails on every try had no way to say why. The app and the tray showed
//! "Connecting…" for days while the reason sat in the journal (`docs/BUGS.md`
//! B263). The daemon now writes its last failure to a small file in the state
//! directory, next to the control socket, and removes it once a mount is up.
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
/// The file's name inside the state directory.
const FILE_NAME: &str = "mount-failure.json";
/// The last failed mount attempt.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct MountFailure {
/// The error, in English, as the journal has it.
pub error: String,
/// Unix seconds of the first failure in this run of failures.
pub since: i64,
/// Failed attempts in a row.
pub attempts: u32,
/// Unix seconds of the next attempt.
pub next_attempt: i64,
}
/// Where the failure is kept for the state directory `state_dir`.
pub fn path(state_dir: &Path) -> PathBuf {
state_dir.join(FILE_NAME)
}
/// Record `failure`, replacing the one before it. Written to a temporary file
/// and renamed, so a reader never sees half of it.
pub fn record(state_dir: &Path, failure: &MountFailure) -> std::io::Result<()> {
let json = serde_json::to_vec(failure).map_err(std::io::Error::other)?;
let tmp = state_dir.join(format!("{FILE_NAME}.tmp"));
std::fs::write(&tmp, json)?;
std::fs::rename(&tmp, path(state_dir))
}
/// Forget the failure: a mount is up, or a new daemon has started.
pub fn clear(state_dir: &Path) {
let _ = std::fs::remove_file(path(state_dir));
}
/// The recorded failure, if there is one and it can be read.
pub fn read(state_dir: &Path) -> Option<MountFailure> {
let bytes = std::fs::read(path(state_dir)).ok()?;
serde_json::from_slice(&bytes).ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_recorded_failure_reads_back_until_cleared() {
let dir = std::env::temp_dir().join(format!("pdfs-mountfail-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
assert_eq!(read(&dir), None);
let failure = MountFailure {
error: "fetch My Files root: missing key".into(),
since: 1_700_000_000,
attempts: 3,
next_attempt: 1_700_000_040,
};
record(&dir, &failure).unwrap();
assert_eq!(read(&dir), Some(failure));
clear(&dir);
assert_eq!(read(&dir), None);
let _ = std::fs::remove_dir_all(&dir);
}
}
+3
View File
@@ -110,6 +110,9 @@ pub struct MountSpec {
/// Live device sync progress, absent when no pass is running.
#[serde(default)]
pub progress: Option<SyncProgress>,
/// A synced folder paused on its own. Live daemon state, not a stored column.
#[serde(default)]
pub paused: bool,
}
#[cfg(test)]
+47 -3
View File
@@ -23,8 +23,9 @@
//! ```
use std::ffi::OsStr;
use std::os::unix::process::CommandExt;
use std::path::Path;
use std::process::Command;
use std::process::{Command, Stdio};
use serde::{Deserialize, Serialize};
@@ -165,16 +166,44 @@ pub fn open_named(policy: &OpenWith, path: &Path, name: &str, is_dir: bool) {
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");
if let Err(e) = Command::new(program).args(args).spawn() {
tracing::error!(command = ?argv, "open failed: {e}");
let spawned = Command::new(program)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.process_group(0)
.spawn();
match spawned {
Ok(child) => reap(child),
Err(e) => tracing::error!(command = ?argv, "open failed: {e}"),
}
}
/// Wait for `child` on a thread of its own. A child nobody waits for stays a
/// zombie for as long as its parent lives, which for the app and the tray is
/// the whole session.
pub fn reap(mut child: std::process::Child) {
std::thread::spawn(move || {
let _ = child.wait();
});
}
/// [`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.
@@ -453,6 +482,21 @@ fn glob_match(pattern: &str, name: &str) -> bool {
mod tests {
use super::*;
#[test]
fn a_reaped_child_does_not_stay_a_zombie() {
let child = std::process::Command::new("true").spawn().unwrap();
let proc_entry = std::path::PathBuf::from(format!("/proc/{}", child.id()));
reap(child);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while proc_entry.exists() && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert!(
!proc_entry.exists(),
"the child is still in the process table"
);
}
fn rule(patterns: &[&str], command: &[&str], terminal: bool) -> OpenRule {
OpenRule {
patterns: patterns.iter().map(|p| (*p).to_string()).collect(),
+185
View File
@@ -0,0 +1,185 @@
//! Offline reverse geocoding: the town a photo was taken in, from its GPS
//! position, without asking any service.
//!
//! The towns are GeoNames' `cities15000` dump (every place with more than
//! 15,000 people), trimmed to id, name, position and country in
//! `data/cities15000.tsv` by `scripts/geonames-cities.py`. The script drops the
//! parts of a city, such as Kreuzberg or "Paris 15 Vaugirard", so that a photo
//! lands in the town people would name. GeoNames publishes the dump under
//! CC BY 4.0, which is why the About dialog credits it.
//!
//! `data/countries.tsv` maps each country code to its ISO 3166-1 English name,
//! as the `iso-codes` project spells it, so a front end can translate it
//! through that project's `iso_3166-1` gettext domain.
use std::collections::HashMap;
use std::sync::OnceLock;
/// How far from the nearest town a photo may be and still count as taken
/// there, in km. Farther out (at sea, in the wilderness) it has no place.
pub const MAX_DISTANCE_KM: f64 = 50.0;
/// Mean Earth radius in km, for the haversine distance.
const EARTH_RADIUS_KM: f64 = 6371.0;
const CITIES: &str = include_str!("../data/cities15000.tsv");
const COUNTRIES: &str = include_str!("../data/countries.tsv");
/// One town of the table.
#[derive(Debug, Clone, PartialEq)]
pub struct City {
/// The GeoNames id, stable across dumps.
pub id: u32,
pub name: &'static str,
pub latitude: f64,
pub longitude: f64,
/// ISO 3166-1 alpha-2 country code.
pub country: &'static str,
}
/// The towns, and an index of them by whole-degree cell.
struct Table {
cities: Vec<City>,
cells: HashMap<(i32, i32), Vec<usize>>,
}
fn table() -> &'static Table {
static TABLE: OnceLock<Table> = OnceLock::new();
TABLE.get_or_init(|| {
let cities: Vec<City> = CITIES.lines().filter_map(parse_city).collect();
let mut cells: HashMap<(i32, i32), Vec<usize>> = HashMap::new();
for (index, city) in cities.iter().enumerate() {
cells
.entry(cell(city.latitude, city.longitude))
.or_default()
.push(index);
}
Table { cities, cells }
})
}
fn parse_city(line: &'static str) -> Option<City> {
let mut fields = line.split('\t');
Some(City {
id: fields.next()?.parse().ok()?,
name: fields.next()?,
latitude: fields.next()?.parse().ok()?,
longitude: fields.next()?.parse().ok()?,
country: fields.next()?,
})
}
fn cell(latitude: f64, longitude: f64) -> (i32, i32) {
(latitude.floor() as i32, longitude.floor() as i32)
}
/// Great-circle distance between two positions, in km.
pub fn distance_km(a: (f64, f64), b: (f64, f64)) -> f64 {
let (lat1, lat2) = (a.0.to_radians(), b.0.to_radians());
let dlat = lat2 - lat1;
let dlon = (b.1 - a.1).to_radians();
let h = (dlat / 2.0).sin().powi(2) + lat1.cos() * lat2.cos() * (dlon / 2.0).sin().powi(2);
2.0 * EARTH_RADIUS_KM * h.sqrt().min(1.0).asin()
}
/// The town nearest to a position, if one lies within [`MAX_DISTANCE_KM`].
pub fn nearest(latitude: f64, longitude: f64) -> Option<&'static City> {
if !latitude.is_finite() || !longitude.is_finite() {
return None;
}
let table = table();
// A degree of latitude is about 111 km everywhere; a degree of longitude
// shrinks towards the poles, so more cells are needed to cover the radius.
let lat_span = (MAX_DISTANCE_KM / 111.0).ceil() as i32;
let lon_span = {
let scale = latitude.to_radians().cos().abs().max(0.01);
((MAX_DISTANCE_KM / (111.0 * scale)).ceil() as i32).min(180)
};
let (row, column) = cell(latitude, longitude);
let mut best: Option<(f64, &City)> = None;
for dlat in -lat_span..=lat_span {
for dlon in -lon_span..=lon_span {
// Longitude wraps at the antimeridian: -181 is 179.
let lon_cell = (column + dlon + 180).rem_euclid(360) - 180;
let Some(indices) = table.cells.get(&(row + dlat, lon_cell)) else {
continue;
};
for &index in indices {
let city = &table.cities[index];
let d = distance_km((latitude, longitude), (city.latitude, city.longitude));
if d <= MAX_DISTANCE_KM && best.is_none_or(|(bd, _)| d < bd) {
best = Some((d, city));
}
}
}
}
best.map(|(_, city)| city)
}
/// The town with this GeoNames id.
pub fn city(id: u32) -> Option<&'static City> {
let cities = &table().cities;
// The table is sorted by id.
cities
.binary_search_by_key(&id, |city| city.id)
.ok()
.map(|index| &cities[index])
}
/// The English ISO 3166-1 name of a country code, such as "Germany" for "DE".
pub fn country_name(code: &str) -> Option<&'static str> {
COUNTRIES.lines().find_map(|line| {
let (c, name) = line.split_once('\t')?;
(c == code).then_some(name)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_position_in_a_town_resolves_to_that_town() {
let berlin = nearest(52.52, 13.405).expect("Berlin is a town");
assert_eq!(berlin.name, "Berlin");
assert_eq!(berlin.country, "DE");
assert_eq!(city(berlin.id), Some(berlin));
}
#[test]
fn the_open_sea_has_no_town() {
assert_eq!(nearest(0.0, -30.0), None);
assert_eq!(nearest(f64::NAN, 0.0), None);
}
#[test]
fn a_town_across_the_antimeridian_is_still_found() {
// Just east of 180°, the nearest towns in Fiji are west of it.
let town = nearest(-16.8, 179.99);
assert!(town.is_none_or(|t| t.country == "FJ"));
let suva = nearest(-18.14, 178.44).expect("Suva is a town");
assert_eq!(suva.country, "FJ");
}
#[test]
fn every_town_has_a_named_country() {
for city in &table().cities {
assert!(
country_name(city.country).is_some(),
"{} has no country name for {}",
city.name,
city.country
);
}
assert_eq!(country_name("DE"), Some("Germany"));
}
#[test]
fn distances_are_great_circle_km() {
let d = distance_km((52.52, 13.405), (48.137, 11.575));
assert!(
(d - 504.0).abs() < 5.0,
"Berlin to Munich is about 504 km, got {d}"
);
}
}
+94 -2
View File
@@ -1,4 +1,4 @@
//! The machine profile that travels with a device (features.md 5.3).
//! The machine profile that travels with a device.
//!
//! 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,
@@ -19,9 +19,31 @@ use serde::{Deserialize, Serialize};
use crate::mounts::MountMode;
/// File name of the profile.
/// File name of the profile clients before 3.5.0 wrote, one per device.
///
/// Read, never written: two computers sharing a device each overwrote the
/// other's. See [`profile_file_name`].
pub const PROFILE_FILE_NAME: &str = "profile.json";
/// File name of one machine's profile: `profile-<machine id>.json`.
///
/// One per machine, because several computers may sync into one device, and
/// each describes its own arrangement. A single shared file went to whichever
/// computer saved last.
pub fn profile_file_name(machine_id: &str) -> String {
format!("profile-{machine_id}.json")
}
/// Whether `name` is a profile: [`PROFILE_FILE_NAME`] or one machine's
/// [`profile_file_name`].
pub fn is_profile_file_name(name: &str) -> bool {
name == PROFILE_FILE_NAME
|| name
.strip_prefix("profile-")
.and_then(|rest| rest.strip_suffix(".json"))
.is_some_and(|id| !id.is_empty() && id.bytes().all(|b| b.is_ascii_alphanumeric()))
}
/// 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
@@ -74,6 +96,10 @@ pub struct Profile {
/// 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,
/// The writing machine's id, as in its [`profile_file_name`]. `None` in a
/// profile written before 3.5.0.
#[serde(default)]
pub machine_id: Option<String>,
/// When it was written, epoch seconds.
pub saved_at: i64,
#[serde(default)]
@@ -92,6 +118,25 @@ pub struct Profile {
pub mountpoint: Option<String>,
}
/// The saved arrangement of the folder with uid `remote_uid`, from the
/// profiles of every computer that syncs the device.
///
/// The profile of the machine named `hostname` wins, since it describes this
/// computer; otherwise the most recently saved one that has the folder.
pub fn saved_folder<'a>(
profiles: &'a [Profile],
remote_uid: &str,
hostname: &str,
) -> Option<&'a ProfileFolder> {
let holding = |p: &&'a Profile| p.folders.iter().any(|f| f.remote_uid == remote_uid);
let profile = profiles
.iter()
.filter(holding)
.find(|p| p.hostname == hostname)
.or_else(|| profiles.iter().filter(holding).max_by_key(|p| p.saved_at))?;
profile.folders.iter().find(|f| f.remote_uid == remote_uid)
}
impl Profile {
/// Parse a profile document, refusing one written by a newer client.
pub fn parse(bytes: &[u8]) -> Result<Self, String> {
@@ -122,6 +167,7 @@ mod tests {
version: PROFILE_VERSION,
device_uid: "dev-1".to_string(),
hostname: "laptop-xyz".to_string(),
machine_id: Some("0123456789abcdef0123456789abcdef".to_string()),
saved_at: 1_700_000_000,
folders: vec![ProfileFolder {
remote_uid: "vol~link".to_string(),
@@ -219,6 +265,52 @@ mod tests {
assert!(p.ignore_patterns.is_none());
}
#[test]
fn a_profile_name_is_the_legacy_one_or_one_per_machine() {
assert!(is_profile_file_name(PROFILE_FILE_NAME));
let name = profile_file_name("0123456789abcdef0123456789abcdef");
assert_eq!(name, "profile-0123456789abcdef0123456789abcdef.json");
assert!(is_profile_file_name(&name));
assert!(!is_profile_file_name("profile-.json"));
assert!(!is_profile_file_name("profile-a/b.json"));
assert!(!is_profile_file_name("profile (1).json"));
assert!(!is_profile_file_name("notes.json"));
}
/// A profile written before 3.5.0 has no machine id and still parses.
#[test]
fn a_profile_without_a_machine_id_parses() {
let json = br#"{"version":1,"device_uid":"d","hostname":"h","saved_at":0}"#;
assert_eq!(Profile::parse(json).unwrap().machine_id, None);
}
/// Several computers sync one device, each with its own profile. This
/// computer's own wins, then the newest that has the folder.
#[test]
fn the_saved_folder_comes_from_this_computer_then_the_newest_profile() {
let profile = |hostname: &str, saved_at: i64, path: &str| Profile {
hostname: hostname.to_string(),
saved_at,
folders: vec![ProfileFolder {
remote_uid: "vol~docs".to_string(),
local_path: path.to_string(),
mode: MountMode::Mirror,
}],
..sample()
};
let profiles = vec![
profile("desktop", 10, "/home/a/Docs"),
profile("laptop", 5, "/home/b/Docs"),
profile("old", 1, "/home/c/Docs"),
];
let path =
|host: &str| saved_folder(&profiles, "vol~docs", host).map(|f| f.local_path.as_str());
assert_eq!(path("laptop"), Some("/home/b/Docs"));
assert_eq!(path("new-machine"), Some("/home/a/Docs"));
assert_eq!(saved_folder(&profiles, "vol~other", "laptop"), None);
assert_eq!(saved_folder(&[], "vol~docs", "laptop"), None);
}
#[test]
fn future_version_is_refused() {
let mut p = sample();
+19 -4
View File
@@ -47,10 +47,19 @@ fn systemctl_query(args: &[&str]) -> bool {
.unwrap_or(false)
}
/// Enable the service so it survives reboots/logins and start it now. Called on
/// successful login.
/// 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. `reenable` for the reason given on
/// [`enable`].
pub fn enable_start() -> bool {
systemctl(&["enable", "--now"])
let enabled = systemctl(&["reenable"]);
systemctl(&["restart"]) && enabled
}
/// Stop the running mount without disabling it. Called by the tray's
@@ -89,8 +98,14 @@ pub fn is_enabled() -> bool {
/// 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).
///
/// `reenable` rather than `enable`: older installs were enabled
/// into `default.target.wants`, and a plain `enable` would add the new
/// `graphical-session.target.wants` link while leaving the old one to start the
/// unit too early. `reenable` drops every existing link and recreates them from
/// the current `[Install]` section.
pub fn enable() -> bool {
systemctl(&["enable"])
systemctl(&["reenable"])
}
/// Disable auto-start without stopping the running mount. Used by the Settings
+16 -1
View File
@@ -23,6 +23,11 @@ const SCRIPTS: [(&str, &str); 2] = [
/// `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.
///
/// The `--` keeps a file named like an option, such as `--json`, a path. A
/// name with a newline in it still arrives split in two from Nautilus, whose
/// list is newline-separated; `pdfs` then fails on both halves, which touches
/// nothing.
fn script_body(subcommand: &str) -> String {
format!(
"#!/bin/sh\n\
@@ -41,7 +46,7 @@ fn script_body(subcommand: &str) -> String {
\n\
printf '%s' \"$paths\" | while IFS= read -r path; do\n\
\x20 [ -n \"$path\" ] || continue\n\
\x20 pdfs {subcommand} \"$path\" || exit 1\n\
\x20 pdfs {subcommand} -- \"$path\" || exit 1\n\
done\n"
)
}
@@ -95,3 +100,13 @@ fn write_scripts(dir: &PathBuf) -> io::Result<()> {
tracing::debug!("installed file-manager scripts in {dir:?}");
Ok(())
}
#[cfg(test)]
mod tests {
use super::script_body;
#[test]
fn the_script_ends_options_before_each_path() {
assert!(script_body("pin").contains(r#"pdfs pin -- "$path""#));
}
}
+193
View File
@@ -0,0 +1,193 @@
//! Telling photos apart by what they show rather than by their bytes.
//!
//! The server's content hash only matches byte-identical files. A photo that
//! was resized, re-encoded or sent through a messenger is another file, so the
//! similar-photo finder hashes each thumbnail instead: a 64-bit difference
//! hash ("dHash"), which compares neighbouring brightness in a 9×8 grid. Two
//! copies of one picture land a few bits apart, and different pictures about
//! half of the bits apart.
/// Hashes this many bits apart or fewer show the same picture.
pub const SIMILAR_BITS: u32 = 6;
/// Stored for a photo whose thumbnail was looked at and gave no usable hash
/// (it couldn't be decoded, or is one flat colour), so it isn't looked at
/// again. A real hash is never 0: a picture that would hash to 0 has no
/// brightness changes, which [`difference_hash`] turns down as flat.
pub const NO_HASH: u64 = 0;
/// Samples of the grid that differ by less than this in brightness, all of
/// them, make a flat picture — a black frame, a blank page — whose hash is
/// noise and would match every other flat picture.
const FLAT: u8 = 8;
/// The difference hash of an 8-bit greyscale image `width` px wide, rows
/// stored one after another in `luma`. `None` for an empty or flat image.
pub fn difference_hash(width: usize, height: usize, luma: &[u8]) -> Option<u64> {
if width == 0 || height == 0 || luma.len() < width * height {
return None;
}
// Box-average down to 9×8, so every source pixel counts once and the hash
// doesn't depend on which pixels a nearest-neighbour pick happens to hit.
let mut grid = [[0u8; 9]; 8];
for (gy, row) in grid.iter_mut().enumerate() {
let (y0, y1) = span(gy, 8, height);
for (gx, cell) in row.iter_mut().enumerate() {
let (x0, x1) = span(gx, 9, width);
let mut sum = 0u64;
for y in y0..y1 {
sum += luma[y * width + x0..y * width + x1]
.iter()
.map(|&v| u64::from(v))
.sum::<u64>();
}
*cell = (sum / ((y1 - y0) * (x1 - x0)) as u64) as u8;
}
}
let (min, max) = grid
.iter()
.flatten()
.fold((u8::MAX, 0), |(min, max), &v| (min.min(v), max.max(v)));
if max - min < FLAT {
return None;
}
let mut hash = 0u64;
for row in &grid {
for pair in row.windows(2) {
hash = hash << 1 | u64::from(pair[0] < pair[1]);
}
}
(hash != NO_HASH).then_some(hash)
}
/// The source pixels `from..to` that cell `i` of `cells` covers along an edge
/// of `len` px; never empty, even for an edge shorter than the grid.
fn span(i: usize, cells: usize, len: usize) -> (usize, usize) {
let from = (i * len / cells).min(len - 1);
let to = ((i + 1) * len / cells).max(from + 1);
(from, to)
}
/// Whether two hashes show the same picture.
pub fn alike(a: u64, b: u64) -> bool {
(a ^ b).count_ones() <= SIMILAR_BITS
}
/// Group `hashes` into sets of alike ones, by index, each set in index order
/// and the sets by their first index. A hash alike to none other is in no
/// set. Alike is taken as catching: a burst of shots each a little different
/// from the last is one set.
pub fn sets(hashes: &[u64]) -> Vec<Vec<usize>> {
// Two hashes at most SIMILAR_BITS apart agree on at least one of their
// eight bytes, as long as SIMILAR_BITS < 8. So only hashes sharing some
// byte are compared, not every pair of the library.
const _: () = assert!(SIMILAR_BITS < 8);
let mut parent: Vec<usize> = (0..hashes.len()).collect();
fn root(parent: &mut [usize], mut i: usize) -> usize {
while parent[i] != i {
parent[i] = parent[parent[i]];
i = parent[i];
}
i
}
for byte in 0..8 {
let mut buckets: Vec<Vec<usize>> = vec![Vec::new(); 256];
for (i, &hash) in hashes.iter().enumerate() {
buckets[(hash >> (byte * 8)) as u8 as usize].push(i);
}
for bucket in &buckets {
for (n, &a) in bucket.iter().enumerate() {
for &b in &bucket[n + 1..] {
if alike(hashes[a], hashes[b]) {
let (ra, rb) = (root(&mut parent, a), root(&mut parent, b));
if ra != rb {
parent[ra.max(rb)] = ra.min(rb);
}
}
}
}
}
}
let mut by_root: std::collections::BTreeMap<usize, Vec<usize>> = Default::default();
for i in 0..hashes.len() {
let r = root(&mut parent, i);
by_root.entry(r).or_default().push(i);
}
by_root.into_values().filter(|set| set.len() > 1).collect()
}
#[cfg(test)]
mod tests {
use super::*;
/// A 90×80 picture: a bright disc on a gradient.
fn picture(width: usize, height: usize) -> Vec<u8> {
let mut luma = Vec::with_capacity(width * height);
for y in 0..height {
for x in 0..width {
let (fx, fy) = (x as f64 / width as f64, y as f64 / height as f64);
let disc = ((fx - 0.3).powi(2) + (fy - 0.6).powi(2)).sqrt() < 0.2;
let v = if disc { 240.0 } else { 40.0 + 120.0 * fx * fy };
luma.push(v as u8);
}
}
luma
}
#[test]
fn a_resized_copy_hashes_alike() {
let big = difference_hash(900, 800, &picture(900, 800)).unwrap();
let small = difference_hash(91, 79, &picture(91, 79)).unwrap();
assert!(
alike(big, small),
"{} bits apart",
(big ^ small).count_ones()
);
}
#[test]
fn a_different_picture_hashes_apart() {
let one = difference_hash(90, 80, &picture(90, 80)).unwrap();
let mirrored: Vec<u8> = picture(90, 80)
.chunks(90)
.flat_map(|row| row.iter().rev().copied().collect::<Vec<_>>())
.collect();
let other = difference_hash(90, 80, &mirrored).unwrap();
assert!(!alike(one, other));
}
#[test]
fn a_flat_picture_has_no_hash() {
assert_eq!(difference_hash(64, 64, &[17; 64 * 64]), None);
assert_eq!(difference_hash(0, 0, &[]), None);
assert_eq!(difference_hash(4, 4, &[0; 3]), None);
}
#[test]
fn a_picture_smaller_than_the_grid_still_hashes() {
let luma: Vec<u8> = (0..4 * 3).map(|i| (i * 20) as u8).collect();
assert!(difference_hash(4, 3, &luma).is_some());
}
#[test]
fn alike_hashes_form_sets_and_catch_on() {
let a = 0x0123_4567_89ab_cdef_u64;
let hashes = [
a,
!a,
a ^ 0b111,
a ^ 0b111 ^ (0b1111 << 40),
0x5555_0000_ffff_aaaa,
];
assert_eq!(sets(&hashes), vec![vec![0, 2, 3]]);
}
#[test]
fn hashes_apart_in_most_bytes_are_still_found() {
// One bit apart in each of six bytes: six bits in all, and only the
// top two bytes agree.
let a = 1u64 << 63;
let b = (0..6).fold(a, |h, byte| h | 1 << (byte * 8));
assert_eq!(sets(&[a, b]), vec![vec![0, 1]]);
}
}
+63 -10
View File
@@ -1,4 +1,4 @@
//! Selective-sync ignore rules for mirror folders (features.md 1.1).
//! Selective-sync ignore rules for mirror folders.
//!
//! A mirror folder otherwise uploads every path beneath its root, which for a
//! project directory means `node_modules/`, `.git/`, and `target/` — quota
@@ -45,18 +45,30 @@ pub const DEFAULT_IGNORE_PATTERNS: &[&str] = &[
"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.
/// Suffixes that mean a file is incomplete *by construction*: only the program
/// writing it gives it this name, only while the write is in flight, and the
/// finished article always arrives under a different one. 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.
///
/// `.tmp`/`.temp` are deliberately **not** here, though they are in
/// [`DEFAULT_IGNORE_PATTERNS`]. The two lists answer different questions. Skipping
/// a `report.tmp` during a mirror reconcile costs nothing — the file stays on
/// disk, and a later pass picks it up if the rules change. Parking one on the
/// on-demand mount is irreversible in practice: the only exit is a rename to a
/// finished name, and a file that is simply *called* `index.tmp` — a static-site
/// template, a tool's output — never gets one. It then never reaches Drive at
/// all, silently, while its bytes pin `staging/`. That is what a real Takeout
/// tree of `index.tmp`/`preview.tmp` gallery templates did: 43 creates parked
/// forever. The generic-scratch case they were added for renames within
/// milliseconds, so it never needed the park; the download suffixes below, which
/// stay in flight for minutes, are what B70 was actually about.
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)
];
@@ -84,6 +96,26 @@ pub fn is_transient_name(name: &str) -> bool {
false
}
/// Whether `name` is scratch another program writes and throws away: the
/// [`is_transient_name`] set, plus the save-by-rename temporaries of GTK, Qt,
/// Chromium and Electron, generic `.tmp` files and Emacs lock files. None of
/// them is a file the user made, so a front-end can hide them from a history
/// of what happened to the user's files.
pub fn is_scratch_name(name: &str) -> bool {
if is_transient_name(name) {
return true;
}
let lower = name.to_ascii_lowercase();
lower.ends_with(".tmp")
|| lower.ends_with(".temp")
|| name.starts_with(".goutputstream-")
|| name.starts_with(".org.chromium.")
|| name.starts_with(".com.google.chrome.")
|| name.starts_with(".#")
|| (name.starts_with('#') && name.ends_with('#') && name.len() > 1)
|| (name.starts_with('.') && name.contains(".tmp."))
}
/// Filenames accepted as a folder's ignore file, in precedence order.
const IGNORE_FILE_NAMES: &[&str] = &[".pdfsignore", ".protonignore"];
@@ -319,8 +351,6 @@ mod tests {
"movie.mkv.part",
"big.iso.partial",
"photo.jpg.download",
"export.xml.tmp",
"notes.TEMP",
".notes.txt.swp",
"doc.txt~",
".~lock.report.odt#",
@@ -331,6 +361,25 @@ mod tests {
}
}
#[test]
fn save_by_rename_temporaries_are_scratch() {
for name in [
".goutputstream-4XK2B2",
".org.chromium.Chromium.aBc123",
"~$report.docx",
".~lock.budget.ods#",
".#notes.org",
"#notes.org#",
"cache.tmp",
"page.html.crdownload",
] {
assert!(is_scratch_name(name), "{name}");
}
for name in ["report.docx", "notes.org", "#1 hits.txt", ".bashrc"] {
assert!(!is_scratch_name(name), "{name}");
}
}
#[test]
fn finished_names_are_not_transient() {
for name in [
@@ -340,6 +389,10 @@ mod tests {
"archive.partly.zip", // ".partly" is not ".part"
"template.dotx",
"readme",
// A file that is *named* `.tmp` and never renamed: parking it would
// keep it off Drive for good.
"index.tmp",
"notes.TEMP",
] {
assert!(!is_transient_name(name), "{name} should not be transient");
}
+209
View File
@@ -0,0 +1,209 @@
//! Which local folders may become a sync root.
//!
//! A mirror uploads everything under its root and deletes what Drive deleted,
//! so a root that holds more than the user meant is dangerous. `$HOME` takes
//! the decrypted content cache, `cache.db` and `~/.ssh` up to Drive, and walks
//! into the mount, which downloads the whole Drive only to upload it again.
//! Every place a root is added or restored goes through [`problem`].
use std::path::{Component, Path, PathBuf};
use crate::config::{AppConfig, AppDirs};
/// The paths a sync root must stay clear of.
#[derive(Debug, Clone, Default)]
pub struct Reserved {
/// The user's home folder. A root may sit inside it, not be it or above it.
pub home: Option<PathBuf>,
/// The Proton Drive mount.
pub mountpoint: Option<PathBuf>,
/// This app's own configuration, state and cache folders.
pub app_dirs: Vec<PathBuf>,
}
impl Reserved {
/// The reserved paths of this user and `config`, resolved like a root.
pub fn current(config: &AppConfig) -> Self {
let Ok(dirs) = AppDirs::new() else {
return Self::default();
};
let resolved = |path: PathBuf| resolve(&path).unwrap_or(path);
let mut app_dirs = vec![dirs.state_dir(), dirs.cache_dir()];
if let Some(config_dir) = dirs.config_path().parent() {
app_dirs.push(config_dir.to_path_buf());
}
Self {
home: dirs.home_dir().map(resolved),
mountpoint: Some(resolved(dirs.resolved_mountpoint(config))),
app_dirs: app_dirs.into_iter().map(resolved).collect(),
}
}
}
/// `path` as a root is compared: absolute, with symlinks resolved as far as the
/// path exists. A folder a restore is about to create does not exist yet, so
/// the part past the deepest existing folder is kept as written.
///
/// A `..` is refused rather than resolved: before a symlink it means something
/// else than after it, and a path recorded on another machine has no business
/// holding one.
pub fn resolve(path: &Path) -> Result<PathBuf, String> {
if !path.is_absolute() {
return Err(format!("{} is not an absolute path", path.display()));
}
if path.components().any(|c| c == Component::ParentDir) {
return Err(format!("{} goes up with `..`", path.display()));
}
let mut existing = path.to_path_buf();
let mut rest = Vec::new();
loop {
match existing.canonicalize() {
Ok(canonical) => {
return Ok(rest
.into_iter()
.rev()
.fold(canonical, |path: PathBuf, part| path.join(part)));
}
Err(_) => {
let Some(name) = existing.file_name().map(|n| n.to_os_string()) else {
return Ok(path.to_path_buf());
};
rest.push(name);
if !existing.pop() {
return Ok(path.to_path_buf());
}
}
}
}
}
/// Why `root` cannot be synced, or `None` when it can. `root` is resolved
/// ([`resolve`]); `others` are the roots already synced.
pub fn problem(root: &Path, others: &[PathBuf], reserved: &Reserved) -> Option<String> {
let shown = root.display();
if root.parent().is_none() {
return Some(format!("{shown} is the root of the file system"));
}
if let Some(home) = &reserved.home {
if root == home {
return Some(format!(
"{shown} is your home folder; pick a folder inside it"
));
}
if home.starts_with(root) {
return Some(format!("{shown} holds your home folder"));
}
}
if let Some(mountpoint) = &reserved.mountpoint
&& overlaps(root, mountpoint)
{
return Some(format!(
"{shown} overlaps the Proton Drive folder {}",
mountpoint.display()
));
}
if let Some(dir) = reserved.app_dirs.iter().find(|dir| overlaps(root, dir)) {
return Some(format!(
"{shown} overlaps this app's own data in {}",
dir.display()
));
}
if others.iter().any(|other| other == root) {
return Some(format!("{shown} is already synced"));
}
if let Some(other) = others.iter().find(|other| overlaps(root, other)) {
return Some(format!(
"{shown} overlaps the synced folder {}",
other.display()
));
}
None
}
/// Whether one of `a` and `b` is the other or inside it.
fn overlaps(a: &Path, b: &Path) -> bool {
a.starts_with(b) || b.starts_with(a)
}
#[cfg(test)]
mod tests {
use super::*;
fn reserved() -> Reserved {
Reserved {
home: Some("/home/u".into()),
mountpoint: Some("/home/u/ProtonDrive".into()),
app_dirs: vec![
"/home/u/.local/state/proton-drive-linux".into(),
"/home/u/.cache/proton-drive-linux".into(),
"/home/u/.config/proton-drive-linux".into(),
],
}
}
fn refused(root: &str, others: &[&str]) -> bool {
let others: Vec<PathBuf> = others.iter().map(PathBuf::from).collect();
problem(Path::new(root), &others, &reserved()).is_some()
}
#[test]
fn a_folder_inside_home_can_be_synced() {
assert!(!refused("/home/u/Documents", &[]));
assert!(!refused("/home/u/Pictures", &["/home/u/Documents"]));
assert!(!refused("/mnt/data/photos", &[]));
}
#[test]
fn the_file_system_root_home_and_its_parents_are_refused() {
assert!(refused("/", &[]));
assert!(refused("/home/u", &[]));
assert!(refused("/home", &[]));
}
#[test]
fn the_mount_and_the_apps_own_data_are_refused_either_way_round() {
assert!(refused("/home/u/ProtonDrive", &[]));
assert!(refused("/home/u/ProtonDrive/Documents", &[]));
assert!(refused("/home/u/.cache", &[]));
assert!(refused(
"/home/u/.local/state/proton-drive-linux/staging",
&[]
));
assert!(refused("/home/u/.config", &[]));
}
#[test]
fn a_root_inside_or_around_another_root_is_refused() {
assert!(refused("/home/u/Documents", &["/home/u/Documents"]));
assert!(refused("/home/u/Documents/Work", &["/home/u/Documents"]));
assert!(refused("/home/u/stuff", &["/home/u/stuff/Documents"]));
// A shared prefix of characters is not a shared folder.
assert!(!refused("/home/u/Documents2", &["/home/u/Documents"]));
}
#[test]
fn a_path_that_goes_up_or_is_relative_does_not_resolve() {
assert!(resolve(Path::new("/home/u/../../etc")).is_err());
assert!(resolve(Path::new("Documents")).is_err());
}
#[test]
fn a_folder_still_to_be_created_resolves_under_its_existing_parent() {
let base = std::env::temp_dir().join(format!("pdfs-syncroot-{}", std::process::id()));
std::fs::create_dir_all(&base).unwrap();
let canonical = base.canonicalize().unwrap();
assert_eq!(
resolve(&base.join("new/inner")).unwrap(),
canonical.join("new/inner")
);
let link = base.join("link");
let _ = std::fs::remove_file(&link);
std::os::unix::fs::symlink(&canonical, &link).unwrap();
assert_eq!(
resolve(&link.join("later")).unwrap(),
canonical.join("later"),
"a symlink is followed to where it points"
);
std::fs::remove_dir_all(&base).unwrap();
}
}
+554 -39
View File
@@ -346,6 +346,11 @@ fn assemble(entries: Vec<RawEntry>, sidecars: &HashMap<String, Vec<u8>>) -> Take
.insert(file.to_string(), body);
}
let indexes: HashMap<&str, SidecarIndex<'_>> = per_folder_sidecars
.iter()
.map(|(folder, in_folder)| (folder.as_str(), SidecarIndex::new(in_folder)))
.collect();
let mut scan = TakeoutScan::default();
let mut seen_albums: HashMap<String, ()> = HashMap::new();
@@ -364,9 +369,13 @@ fn assemble(entries: Vec<RawEntry>, sidecars: &HashMap<String, Vec<u8>>) -> Take
continue;
};
let sidecar = per_folder_sidecars
.get(&folder)
.and_then(|in_folder| find_sidecar(&file_name, in_folder))
let found = indexes
.get(folder.as_str())
.and_then(|index| find_sidecar(&file_name, index));
// An edited copy keeps its own name: the title is its original's.
let title_is_ours = found.is_some_and(|((rule, _), _)| rule != EDITED_COPY);
let sidecar = found
.map(|(_, body)| body)
.and_then(|body| serde_json::from_slice::<PhotoSidecar>(body).ok())
.unwrap_or_default();
@@ -388,27 +397,33 @@ fn assemble(entries: Vec<RawEntry>, sidecars: &HashMap<String, Vec<u8>>) -> Take
let name = sidecar
.title
.as_deref()
.filter(|_| title_is_ours)
.map(str::trim)
.filter(|title| !title.is_empty() && same_extension(title, &file_name))
.unwrap_or(&file_name)
.to_string();
// Sidecar first, then the date stamped into the name.
let capture_time = sidecar
.photo_taken_time
.as_ref()
.and_then(SidecarTime::epoch_seconds)
.or_else(|| {
sidecar
.creation_time
.as_ref()
.and_then(SidecarTime::epoch_seconds)
})
.or_else(|| capture_time_from_name(&name))
.or_else(|| capture_time_from_name(&file_name));
scan.photos.push(TakeoutPhoto {
archive: raw.archive,
entry: raw.entry.clone(),
name,
size: raw.size,
media_type: media_type.to_string(),
capture_time: sidecar
.photo_taken_time
.as_ref()
.and_then(SidecarTime::epoch_seconds)
.or_else(|| {
sidecar
.creation_time
.as_ref()
.and_then(SidecarTime::epoch_seconds)
}),
capture_time,
favorite: sidecar.favorited,
bucket,
});
@@ -507,34 +522,332 @@ fn unswap_duplicate_marker(stem: &str) -> Option<String> {
Some(format!("{}{}{}", &head[..dot], marker, &head[dot..]))
}
/// Find the sidecar belonging to `file_name` among the JSON files of its folder.
/// The capture time a *file name* implies, in epoch seconds, or `None` when it
/// carries no plausible date.
///
/// Tried in order of confidence: the exact name, the `(N)`-swapped name, then a
/// prefix match for the case where truncation cut into the photo name itself.
fn find_sidecar<'a>(
file_name: &str,
in_folder: &BTreeMap<String, &'a Vec<u8>>,
) -> Option<&'a Vec<u8>> {
let mut prefix_match: Option<&'a Vec<u8>> = None;
let mut prefix_len = 0usize;
/// The last resort when a photo has no sidecar. Camera and messenger apps stamp
/// the date into the name — `PXL_20260818_171030868.jpg`,
/// `IMG_20230219_171030.jpg`, `Screenshot_20230101-102950.png`,
/// `IMG-20230219-WA0001.jpg`, `Screenshot from 2023-01-01 10-29-50.png` — and a
/// date that is merely *approximately* right still files the photo in the right
/// year, which "the moment it was imported" does not.
///
/// The wall clock in a name has no zone attached, so it is read as UTC. That can
/// be a few hours out; it is never the years out that the import-time fallback
/// is. A sidecar always wins over this.
pub fn capture_time_from_name(name: &str) -> Option<i64> {
let bytes = name.as_bytes();
let digit = |i: usize| bytes.get(i).is_some_and(u8::is_ascii_digit);
let num =
|start: usize, len: usize| -> Option<i64> { name.get(start..start + len)?.parse().ok() };
for (sidecar_name, body) in in_folder {
let stem = strip_sidecar_suffix(sidecar_name);
if stem == file_name {
return Some(body);
for start in 0..bytes.len() {
if !digit(start) || (start > 0 && digit(start - 1)) {
continue;
}
if unswap_duplicate_marker(&stem).as_deref() == Some(file_name) {
return Some(body);
let run = |from: usize, len: usize| (0..len).all(|k| digit(from + k));
// `YYYYMMDD` as one run of exactly eight digits, or `YYYY-MM-DD` with
// any single separator repeated between the parts.
let (year, month, day, after) = if run(start, 8) && !digit(start + 8) {
(
num(start, 4)?,
num(start + 4, 2)?,
num(start + 6, 2)?,
start + 8,
)
} else if run(start, 4)
&& !digit(start + 4)
&& bytes.get(start + 4) == bytes.get(start + 7)
&& run(start + 5, 2)
&& run(start + 8, 2)
&& !digit(start + 10)
{
(
num(start, 4)?,
num(start + 5, 2)?,
num(start + 8, 2)?,
start + 10,
)
} else {
continue;
};
if !(1990..=2100).contains(&year) || !(1..=12).contains(&month) || !(1..=31).contains(&day)
{
continue;
}
// Truncated: the stem is a prefix of the photo's name. Keep the longest
// such prefix — a folder holding `IMG_1.jpg` and `IMG_12.jpg` must not
// let the shorter stem claim the longer photo.
if !stem.is_empty() && file_name.starts_with(&stem) && stem.len() > prefix_len {
prefix_len = stem.len();
prefix_match = Some(body);
// An optional time right behind the date, past one separator character.
let after = match bytes.get(after) {
Some(b'_' | b'-' | b' ' | b'.' | b',' | b'T' | b't' | b'@') => after + 1,
_ => after,
};
let time = if run(after, 6) {
// Six digits, possibly with the milliseconds tail Pixel appends.
Some((num(after, 2)?, num(after + 2, 2)?, num(after + 4, 2)?))
} else if run(after, 2)
&& !digit(after + 2)
&& bytes.get(after + 2) == bytes.get(after + 5)
&& run(after + 3, 2)
&& run(after + 6, 2)
{
Some((num(after, 2)?, num(after + 3, 2)?, num(after + 6, 2)?))
} else {
None
};
let (hour, minute, second) = match time {
Some((h, m, sec)) if h < 24 && m < 60 && sec < 60 => (h, m, sec),
_ => (0, 0, 0),
};
return Some(
days_from_civil(year, month, day) * 86_400 + hour * 3_600 + minute * 60 + second,
);
}
None
}
/// Days between 1970-01-01 and a proleptic-Gregorian date (Howard Hinnant's
/// `days_from_civil`). Avoids pulling a date crate in for one conversion.
pub(crate) fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
let year = year - i64::from(month <= 2);
let era = if year >= 0 { year } else { year - 399 } / 400;
let year_of_era = year - era * 400;
let day_of_year = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + day - 1;
let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
era * 146_097 + day_of_era - 719_468
}
/// The inverse of [`unswap_duplicate_marker`]: `IMG_1234(1).jpg` as Google
/// writes its sidecar, `IMG_1234.jpg(1)`. `None` when the name carries no
/// duplicate marker.
fn swap_duplicate_marker(file_name: &str) -> Option<String> {
let marker_start = file_name.rfind('(')?;
let marker_end = marker_start + file_name[marker_start..].find(')')? + 1;
let marker = &file_name[marker_start..marker_end];
if !marker[1..marker.len() - 1]
.chars()
.all(|c| c.is_ascii_digit())
{
return None;
}
let tail = &file_name[marker_end..];
if !tail.starts_with('.') {
return None;
}
Some(format!("{}{}{}", &file_name[..marker_start], tail, marker))
}
/// How long Google lets a sidecar's file name get, `.json` included. A longer
/// one is cut, into its suffix first and then into the photo name.
const SIDECAR_NAME_CAP: usize = 51;
/// The rank [`sidecar_rank`] gives an edited copy that shares its original's
/// sidecar. Its date is the original's, but its name is its own.
const EDITED_COPY: u8 = 4;
/// The sidecars of one folder by their `.json`-stripped stem, so a photo is
/// ranked only against those that can match it.
///
/// Every rule in [`sidecar_rank`] needs the stem to start with a prefix of the
/// photo name that the name itself determines, and a sorted map finds the
/// stems with a given prefix as one range. Ranking every photo against every
/// sidecar instead took 27 s for a folder of 10,000 photos.
struct SidecarIndex<'a> {
by_stem: BTreeMap<&'a str, Vec<(&'a str, &'a Vec<u8>)>>,
}
impl<'a> SidecarIndex<'a> {
fn new(in_folder: &'a BTreeMap<String, &'a Vec<u8>>) -> Self {
let mut by_stem: BTreeMap<&'a str, Vec<(&'a str, &'a Vec<u8>)>> = BTreeMap::new();
for (name, body) in in_folder {
by_stem
.entry(strip_json_suffix(name))
.or_default()
.push((name.as_str(), *body));
}
Self { by_stem }
}
/// Add every sidecar whose stem starts with `prefix` to `out`.
fn starting_with(&self, prefix: &str, out: &mut BTreeMap<&'a str, &'a Vec<u8>>) {
let from = (
std::ops::Bound::Included(prefix),
std::ops::Bound::Unbounded,
);
for (stem, sidecars) in self.by_stem.range::<str, _>(from) {
if !stem.starts_with(prefix) {
break;
}
out.extend(sidecars.iter().copied());
}
}
prefix_match
/// Add every sidecar whose stem is `head`, or starts with `head` and a `.`:
/// those that have `head` as a boundary ([`suffix_boundaries`]).
fn bounded_by(&self, head: &str, out: &mut BTreeMap<&'a str, &'a Vec<u8>>) {
if let Some(sidecars) = self.by_stem.get(head) {
out.extend(sidecars.iter().copied());
}
self.starting_with(&format!("{head}."), out);
}
/// Every sidecar [`sidecar_rank`] could match `file_name` to, by name. A
/// superset: each one is still ranked.
fn candidates(&self, file_name: &str) -> BTreeMap<&'a str, &'a Vec<u8>> {
let mut out = BTreeMap::new();
// Rules 0 and 2: the stem starts with the name, or with its swapped form.
self.starting_with(file_name, &mut out);
if let Some(swapped) = swap_duplicate_marker(file_name) {
self.starting_with(&swapped, &mut out);
}
// Rule 1: the stem of `A(N).B` starts with `A.B(N)`, so with `A.`.
for (paren, _) in file_name.match_indices('(') {
self.starting_with(&format!("{}.", &file_name[..paren]), &mut out);
}
// Rule 3: a boundary of the stem is a prefix of the name.
for end in file_name
.char_indices()
.map(|(index, _)| index)
.skip(1)
.chain([file_name.len()])
{
self.bounded_by(&file_name[..end], &mut out);
}
// Rule 4: `<original>-<word>.<ext>` has `<original>.<ext>` as a boundary.
if let Some((_, extension)) = file_name.rsplit_once('.') {
for (dash, _) in file_name.match_indices('-') {
self.bounded_by(&format!("{}.{extension}", &file_name[..dash]), &mut out);
}
}
out
}
}
/// Find the sidecar belonging to `file_name` among the JSON files of its folder,
/// with the rank it matched at.
///
/// Tried in order of confidence: the exact name, the `(N)`-swapped name, an
/// unknown trailing suffix, a prefix match for the case where truncation cut
/// into the photo name itself, and last an edited copy's original. The
/// best-ranked candidate in the folder wins; of equal ranks, the first by
/// sidecar name.
fn find_sidecar<'a>(
file_name: &str,
index: &SidecarIndex<'a>,
) -> Option<((u8, usize), &'a Vec<u8>)> {
let mut best: Option<((u8, usize), &'a Vec<u8>)> = None;
for (sidecar_name, body) in index.candidates(file_name) {
let Some(rank) = sidecar_rank(strip_json_suffix(sidecar_name), file_name) else {
continue;
};
if best.as_ref().is_none_or(|(current, _)| rank < *current) {
best = Some((rank, body));
}
}
best
}
/// How well a sidecar's `.json`-stripped `stem` matches `file_name`, as a sort
/// key where **lower is better**: the leading number is the rule that matched,
/// the second breaks ties within it.
fn sidecar_rank(stem: &str, file_name: &str) -> Option<(u8, usize)> {
// 0 — the stem is the photo name once the (English) suffix is off.
let plain = strip_sidecar_suffix(stem);
if plain == file_name {
return Some((0, 0));
}
// 1 — the same, for `IMG_1234.jpg(1).json` against `IMG_1234(1).jpg`.
if unswap_duplicate_marker(&plain).as_deref() == Some(file_name) {
return Some((1, 0));
}
// 2 — the photo name is intact but a suffix we do not recognise follows it.
// Google *localizes* `.supplemental-metadata` (a German export writes
// `.ergänzende-Metadaten`) and has shipped misspelt variants of it, so the
// suffix cannot be enumerated — matching on the intact name in front of it
// is what stays locale-agnostic. The shortest leftover wins, so a folder
// holding both `IMG_1.jpg.<suffix>` and `IMG_1.jpg(1).<suffix>` gives each
// photo its own.
for candidate in [
Some(file_name.to_string()),
swap_duplicate_marker(file_name),
]
.into_iter()
.flatten()
{
if let Some(rest) = stem.strip_prefix(&candidate)
&& looks_like_sidecar_suffix(rest)
{
return Some((2, rest.len()));
}
}
// 3 — truncation ate into the photo name itself, leaving only a prefix of
// it. Every suffix boundary in the stem is tried, so this holds for a
// localized suffix too; the longest surviving prefix wins.
//
// Only for a sidecar whose name is as long as Google lets one get: a name
// that short of the cap was not cut, so a prefix of it is the name of a
// different photo. Without this, `IMG_1.jpg`'s sidecar gave its name and
// date to an `IMG_12.jpg` that had none (`docs/BUGS.md` B225).
if stem.chars().count() + ".json".len() >= SIDECAR_NAME_CAP - 4 {
let mut longest: Option<usize> = None;
for head in suffix_boundaries(stem) {
if !head.is_empty()
&& file_name.starts_with(head)
&& longest.is_none_or(|len| head.len() > len)
{
longest = Some(head.len());
}
}
if let Some(len) = longest {
return Some((3, usize::MAX - len));
}
}
// 4 — an edited copy, `IMG_1-edited.jpg` (the word is localized), which
// Google exports without a sidecar of its own next to `IMG_1.jpg`'s.
suffix_boundaries(stem)
.filter_map(|head| head.rsplit_once('.'))
.any(|(original, extension)| is_edited_copy_of(file_name, original, extension))
.then_some((EDITED_COPY, 0))
}
/// Whether `file_name` is `<original>-<word>.<extension>`, the name Google
/// gives an edited copy of `<original>.<extension>`.
fn is_edited_copy_of(file_name: &str, original: &str, extension: &str) -> bool {
!original.is_empty()
&& file_name
.strip_prefix(original)
.and_then(|rest| rest.strip_prefix('-'))
.and_then(|rest| rest.strip_suffix(extension))
.and_then(|rest| rest.strip_suffix('.'))
.is_some_and(|word| !word.is_empty() && word.chars().all(char::is_alphabetic))
}
/// The stem plus each of its prefixes ending before a `.`, longest first — the
/// possible boundaries between a photo name and a sidecar suffix.
fn suffix_boundaries(stem: &str) -> impl Iterator<Item = &str> {
std::iter::once(stem).chain(
stem.char_indices()
.filter(|(_, c)| *c == '.')
.map(|(index, _)| &stem[..index])
.collect::<Vec<_>>()
.into_iter()
.rev(),
)
}
/// Whether `rest` — what follows an intact photo name in a sidecar's stem —
/// looks like a metadata suffix rather than the tail of a different file's name.
/// Google's suffixes all start at a `.`, or at the `(N)` duplicate marker.
fn looks_like_sidecar_suffix(rest: &str) -> bool {
rest.starts_with('.') || rest.starts_with('(')
}
/// Drop a trailing `.json`, case-insensitively.
fn strip_json_suffix(sidecar: &str) -> &str {
match sidecar.len().checked_sub(5) {
Some(cut) if sidecar[cut..].eq_ignore_ascii_case(".json") => &sidecar[..cut],
_ => sidecar,
}
}
#[cfg(test)]
@@ -623,7 +936,7 @@ mod tests {
}
#[test]
fn sidecar_supplies_capture_time_and_favourite() {
fn sidecar_supplies_capture_time_and_favorite() {
let mut sidecars = HashMap::new();
sidecar(
&mut sidecars,
@@ -663,6 +976,106 @@ mod tests {
assert_eq!(scan.photos[0].capture_time, Some(1_500_000_000));
}
#[test]
fn a_localized_supplemental_suffix_still_matches() {
// A German export writes `.ergänzende-Metadaten` where an English one
// writes `.supplemental-metadata`; the photo name in front of it is
// what the match hangs on.
let mut sidecars = HashMap::new();
sidecar(
&mut sidecars,
"Takeout/Google Fotos/Fotos von 2023/IMG-20230219-WA0001.jpg.ergänzende-Metadaten.json",
r#"{"photoTakenTime":{"timestamp":"1676800000"}}"#,
);
let scan = assemble(
vec![entry(
0,
"Takeout/Google Fotos/Fotos von 2023/IMG-20230219-WA0001.jpg",
10,
)],
&sidecars,
);
assert_eq!(scan.photos[0].capture_time, Some(1_676_800_000));
}
#[test]
fn a_localized_suffix_does_not_let_one_sidecar_claim_a_sibling() {
let mut sidecars = HashMap::new();
for name in ["IMG_1.jpg", "IMG_1(1).jpg"] {
sidecar(
&mut sidecars,
&format!("Takeout/Google Fotos/Album/{name}.ergänzende-Metadaten.json"),
r#"{"photoTakenTime":{"timestamp":"1"}}"#,
);
}
// Google writes the duplicate's sidecar with the marker after the
// extension, so this is the spelling that actually ships.
sidecars.remove("Album/IMG_1(1).jpg.ergänzende-Metadaten.json");
sidecar(
&mut sidecars,
"Takeout/Google Fotos/Album/IMG_1.jpg(1).ergänzende-Metadaten.json",
r#"{"photoTakenTime":{"timestamp":"2"}}"#,
);
let scan = assemble(
vec![
entry(0, "Takeout/Google Fotos/Album/IMG_1.jpg", 10),
entry(0, "Takeout/Google Fotos/Album/IMG_1(1).jpg", 10),
],
&sidecars,
);
assert_eq!(scan.photos[0].capture_time, Some(1));
assert_eq!(scan.photos[1].capture_time, Some(2));
}
#[test]
fn the_file_name_date_is_the_fallback_when_no_sidecar_matches() {
let scan = assemble(
vec![entry(
0,
"Takeout/Google Fotos/Fotos von 2023/IMG-20230219-WA0001.jpg",
10,
)],
&HashMap::new(),
);
// 2023-02-19 00:00:00 UTC.
assert_eq!(scan.photos[0].capture_time, Some(1_676_764_800));
}
#[test]
fn file_name_dates_cover_the_common_camera_and_messenger_spellings() {
// 2026-08-18 17:10:30 UTC, with Pixel's milliseconds tail.
assert_eq!(
capture_time_from_name("PXL_20260818_171030868.RAW-01.COVER.jpg"),
Some(1_787_073_030)
);
assert_eq!(
capture_time_from_name("IMG_20260818_171030.jpg"),
Some(1_787_073_030)
);
assert_eq!(
capture_time_from_name("Screenshot_20260818-171030.png"),
Some(1_787_073_030)
);
assert_eq!(
capture_time_from_name("Screenshot from 2026-08-18 17-10-30.png"),
Some(1_787_073_030)
);
// A date with no usable time lands at midnight, not at import time.
assert_eq!(
capture_time_from_name("IMG-20260818-WA0001.jpg"),
Some(1_787_011_200)
);
}
#[test]
fn a_name_without_a_plausible_date_yields_nothing() {
assert_eq!(capture_time_from_name("100_1234.JPG"), None);
assert_eq!(capture_time_from_name("DSC00042.jpg"), None);
// A nine-digit run is not a date; neither is an out-of-range month.
assert_eq!(capture_time_from_name("IMG_202608181.jpg"), None);
assert_eq!(capture_time_from_name("IMG_20261818.jpg"), None);
}
#[test]
fn truncated_supplemental_suffixes_still_match() {
// Google caps the sidecar name at 51 chars, cutting into the suffix.
@@ -705,21 +1118,25 @@ mod tests {
#[test]
fn longest_prefix_wins_when_the_photo_name_itself_was_truncated() {
// Both sidecar names are at Google's cap, so both were cut into the
// photo name; the longer surviving prefix is the better claim.
let short = "Takeout/Google Photos/Photos from 2019/PXL_20190101_120000000.PORTRAIT_ORIGINAL_E.json";
let long = "Takeout/Google Photos/Photos from 2019/PXL_20190101_120000000.PORTRAIT_ORIGINAL_EXPO.json";
let mut sidecars = HashMap::new();
sidecar(
&mut sidecars,
"Takeout/Google Photos/Photos from 2019/IMG_1.json",
short,
r#"{"photoTakenTime":{"timestamp":"1"}}"#,
);
sidecar(
&mut sidecars,
"Takeout/Google Photos/Photos from 2019/IMG_12.json",
long,
r#"{"photoTakenTime":{"timestamp":"2"}}"#,
);
let scan = assemble(
vec![entry(
0,
"Takeout/Google Photos/Photos from 2019/IMG_12345.jpg",
"Takeout/Google Photos/Photos from 2019/PXL_20190101_120000000.PORTRAIT_ORIGINAL_EXPOSURE.jpg",
10,
)],
&sidecars,
@@ -728,6 +1145,104 @@ mod tests {
assert_eq!(scan.photos[0].capture_time, Some(2));
}
/// A short sidecar name was not cut, so a prefix of it names another photo:
/// `IMG_1.jpg`'s sidecar gave its title and date to `IMG_12.jpg`
/// (`docs/BUGS.md` B225).
#[test]
fn a_photo_without_a_sidecar_does_not_take_a_shorter_named_siblings() {
let mut sidecars = HashMap::new();
sidecar(
&mut sidecars,
"Takeout/Google Photos/Photos from 2019/IMG_1.jpg.supplemental-metadata.json",
r#"{"title":"IMG_1.jpg","photoTakenTime":{"timestamp":"1"}}"#,
);
let scan = assemble(
vec![
entry(0, "Takeout/Google Photos/Photos from 2019/IMG_1.jpg", 10),
entry(0, "Takeout/Google Photos/Photos from 2019/IMG_12.jpg", 10),
],
&sidecars,
);
assert_eq!(scan.photos[0].capture_time, Some(1));
assert_eq!(scan.photos[1].name, "IMG_12.jpg");
assert_eq!(scan.photos[1].capture_time, None);
}
/// An edited copy has no sidecar of its own. It takes its original's date,
/// but keeps its own name rather than the original's title.
#[test]
fn an_edited_copy_takes_its_originals_date_and_keeps_its_name() {
let mut sidecars = HashMap::new();
sidecar(
&mut sidecars,
"Takeout/Google Fotos/Urlaub/IMG_2.jpg.ergänzende-Metadaten.json",
r#"{"title":"Holiday.jpg","photoTakenTime":{"timestamp":"7"}}"#,
);
let scan = assemble(
vec![
entry(0, "Takeout/Google Fotos/Urlaub/IMG_2.jpg", 10),
entry(0, "Takeout/Google Fotos/Urlaub/IMG_2-bearbeitet.jpg", 10),
],
&sidecars,
);
assert_eq!(scan.photos[0].name, "Holiday.jpg");
assert_eq!(scan.photos[1].name, "IMG_2-bearbeitet.jpg");
assert_eq!(scan.photos[1].capture_time, Some(7));
}
/// The index looks only at the sidecars that can match, and must find the
/// same one that ranking every sidecar in the folder finds.
#[test]
fn the_sidecar_index_finds_what_ranking_every_sidecar_finds() {
let long = "A_very_long_holiday_photo_name_from_the_beach_2019";
let names = [
"IMG_1.jpg".to_string(),
"IMG_12.jpg".to_string(),
"IMG_1(1).jpg".to_string(),
"IMG_1-edited.jpg".to_string(),
"IMG_1-bearbeitet.jpg".to_string(),
"my.photo(2).jpg".to_string(),
"x(1).jpg".to_string(),
"Ölbild.png".to_string(),
"PXL_20230101_101010.mp4".to_string(),
format!("{long}.jpg"),
format!("{long}_2.jpg"),
"nothing.heic".to_string(),
];
let sidecar_names = [
"IMG_1.jpg.supplemental-metadata.json".to_string(),
"IMG_1.jpg(1).supplemental-metadata.json".to_string(),
"IMG_12.jpg.ergänzende-Metadaten.json".to_string(),
"my.photo.jpg(2).json".to_string(),
"x.jpg(1).json".to_string(),
"Ölbild.png.suppl.json".to_string(),
"PXL_20230101_101010.mp4.JSON".to_string(),
format!("{}.json", &long[..44]),
format!("{long}.jp.json"),
"IMG.json".to_string(),
"unrelated.json".to_string(),
];
let bodies: Vec<Vec<u8>> = (0..sidecar_names.len())
.map(|i| i.to_string().into_bytes())
.collect();
let in_folder: BTreeMap<String, &Vec<u8>> =
sidecar_names.iter().cloned().zip(bodies.iter()).collect();
let index = SidecarIndex::new(&in_folder);
for name in &names {
let mut every: Option<((u8, usize), &Vec<u8>)> = None;
for (sidecar_name, body) in &in_folder {
if let Some(rank) = sidecar_rank(strip_json_suffix(sidecar_name), name)
&& every.as_ref().is_none_or(|(current, _)| rank < *current)
{
every = Some((rank, *body));
}
}
assert_eq!(find_sidecar(name, &index), every, "{name}");
}
}
#[test]
fn sidecar_title_replaces_a_mangled_on_disk_name() {
let mut sidecars = HashMap::new();
+20
View File
@@ -0,0 +1,20 @@
//! Talking to a running `pdfs-tray`.
//!
//! The tray binds [`AppDirs::tray_socket`] to keep a single instance. The same
//! socket takes one command, [`QUIT`], so the desktop app can take the icon
//! away when the user hides it or stops Proton Drive.
use std::io::Write;
use std::os::unix::net::UnixStream;
use crate::config::AppDirs;
/// The line that asks the tray to exit.
pub const QUIT: &str = "quit";
/// Ask a running tray to exit. False when no tray answers.
pub fn quit(dirs: &AppDirs) -> bool {
UnixStream::connect(dirs.tray_socket())
.and_then(|mut stream| stream.write_all(format!("{QUIT}\n").as_bytes()))
.is_ok()
}
+178
View File
@@ -0,0 +1,178 @@
//! The opt-in check for a newer release ([`crate::config::AppConfig::update_check`]).
//!
//! Only the decisions live here: what GitHub's answer says, which version is
//! newer, when to ask again, and whether a package manager already owns
//! updates. The desktop app does the request itself.
use std::path::Path;
use std::time::Duration;
/// GitHub's latest published release. Drafts and pre-releases never show up
/// here, so a tagged release candidate does not reach anyone.
pub const RELEASES_LATEST_API: &str =
"https://api.github.com/repos/narrrl/proton-drive-linux/releases/latest";
/// How often the app asks, across restarts.
pub const CHECK_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
/// Installed next to the binaries' `share/` by packages whose package manager
/// brings new releases (COPR, the AUR, Nix). The release `.deb`, `.rpm` and
/// `.tar.gz` do not ship it, since nothing updates them.
pub const MANAGED_MARKER: &str = "share/proton-drive-linux/managed-updates";
/// The version of the release in a `/releases/latest` answer, without the
/// tag's `v`. `None` for an answer that does not parse or a tag that is not
/// `X.Y.Z`, so a changed API can never claim an update.
pub fn latest_version(body: &[u8]) -> Option<String> {
let json: serde_json::Value = serde_json::from_slice(body).ok()?;
let tag = json.get("tag_name")?.as_str()?;
let version = tag.strip_prefix('v').unwrap_or(tag);
parse(version).map(|_| version.to_owned())
}
/// Whether `version` is a later release than `current`. Anything that is not
/// `X.Y.Z` on either side is not.
pub fn is_newer(version: &str, current: &str) -> bool {
matches!((parse(version), parse(current)), (Some(v), Some(c)) if v > c)
}
/// The release page of `version` on GitHub.
pub fn release_url(version: &str) -> String {
format!("https://github.com/narrrl/proton-drive-linux/releases/tag/v{version}")
}
/// `X.Y.Z` as numbers, so `3.10.0` sorts after `3.9.0`.
fn parse(version: &str) -> Option<(u64, u64, u64)> {
let mut parts = version.split('.').map(|p| p.parse::<u64>().ok());
let triple = (parts.next()??, parts.next()??, parts.next()??);
parts.next().is_none().then_some(triple)
}
/// How long until the next check is due, given the last one in Unix seconds.
/// Zero when it is due now. A clock set back past the last check counts as
/// due, so a wrong clock cannot stop checks.
pub fn next_check_in(last_checked: Option<i64>, now: i64) -> Duration {
let interval = CHECK_INTERVAL.as_secs() as i64;
match last_checked {
Some(last) if (0..interval).contains(&(now - last)) => {
Duration::from_secs((interval - (now - last)) as u64)
}
_ => Duration::ZERO,
}
}
/// Whether the binary at `exe` was installed by a package manager that updates
/// it, which [`MANAGED_MARKER`] next to its `bin/` says.
pub fn updates_managed(exe: &Path) -> bool {
exe.parent()
.and_then(Path::parent)
.is_some_and(|prefix| prefix.join(MANAGED_MARKER).exists())
}
#[cfg(test)]
mod tests {
use super::*;
fn latest(tag: &str) -> Vec<u8> {
format!(
r#"{{"tag_name":"{tag}","html_url":"https://github.com/narrrl/proton-drive-linux/releases/tag/{tag}","draft":false}}"#
)
.into_bytes()
}
#[test]
fn the_latest_release_is_read_without_its_v() {
assert_eq!(latest_version(&latest("v3.6.0")).as_deref(), Some("3.6.0"));
assert_eq!(latest_version(&latest("3.6.0")).as_deref(), Some("3.6.0"));
}
#[test]
fn an_answer_that_does_not_parse_has_no_version() {
assert_eq!(latest_version(b"not json"), None);
assert_eq!(latest_version(b"{}"), None);
assert_eq!(latest_version(br#"{"tag_name":7}"#), None);
assert_eq!(latest_version(&latest("nightly")), None);
assert_eq!(latest_version(&latest("v4.0.0-rc1")), None);
assert_eq!(latest_version(&latest("v4.0")), None);
assert_eq!(latest_version(&latest("v4.0.0.1")), None);
}
#[test]
fn only_a_later_release_is_newer() {
assert!(is_newer("3.6.0", "3.5.0"));
assert!(!is_newer("3.5.0", "3.5.0"));
assert!(!is_newer("3.4.1", "3.5.0"));
assert!(!is_newer("2.99.99", "3.0.0"));
}
#[test]
fn versions_compare_as_numbers() {
assert!(is_newer("3.10.0", "3.9.0"));
assert!(is_newer("3.5.10", "3.5.9"));
assert!(is_newer("4.0.0", "3.99.99"));
}
#[test]
fn a_version_that_is_not_x_y_z_is_never_newer() {
assert!(!is_newer("4.0.0", "3.5.0-dev"));
assert!(!is_newer("garbage", "3.5.0"));
}
#[test]
fn the_release_page_uses_the_tag() {
assert_eq!(
release_url("3.6.0"),
"https://github.com/narrrl/proton-drive-linux/releases/tag/v3.6.0"
);
}
#[test]
fn a_check_is_due_once_a_day() {
let day = CHECK_INTERVAL.as_secs() as i64;
assert_eq!(next_check_in(None, 1_000), Duration::ZERO);
assert_eq!(next_check_in(Some(1_000), 1_000), CHECK_INTERVAL);
assert_eq!(
next_check_in(Some(1_000), 1_000 + day - 1),
Duration::from_secs(1)
);
assert_eq!(next_check_in(Some(1_000), 1_000 + day), Duration::ZERO);
assert_eq!(
next_check_in(Some(1_000), 999),
Duration::ZERO,
"a clock set back must not stop checks"
);
}
/// A unique temp directory removed on drop; avoids a dev-dependency, as in
/// [`crate::syncignore`]'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-update-{}-{}",
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);
}
}
#[test]
fn the_marker_next_to_bin_means_a_package_manager_updates() {
let prefix = TempDir::new();
let exe = prefix.0.join("bin/pdfs-app");
assert!(!updates_managed(&exe));
let marker = prefix.0.join(MANAGED_MARKER);
std::fs::create_dir_all(marker.parent().unwrap()).unwrap();
std::fs::write(&marker, "").unwrap();
assert!(updates_managed(&exe));
}
}
+7
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 = "FUSE on-demand filesystem for Proton Drive"
[dependencies]
@@ -13,11 +15,16 @@ zip.workspace = true
sha1.workspace = true
proton-drive-rs.workspace = true
futures.workspace = true
async-trait.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
[dev-dependencies]
tracing-subscriber.workspace = true
+132 -4
View File
@@ -15,7 +15,7 @@
use std::collections::{HashMap, HashSet};
use std::sync::atomic::Ordering;
use pdfs_core::control::{AlbumInfo, PhotoItem};
use pdfs_core::control::{AlbumInfo, PhotoItem, Topic};
use pdfs_core::db::StoredAlbum;
use pdfs_core::{CoreError, CoreResult};
@@ -137,10 +137,12 @@ impl Core {
}
let core = self.clone();
self.rt.spawn(async move {
if let Err(e) = core.refresh_albums().await {
warn!(error = %e, "background album refresh failed");
}
let refreshed = core.refresh_albums().await;
core.albums_refreshing.store(false, Ordering::SeqCst);
match refreshed {
Ok(_) => core.events.publish(&[Topic::Photos]),
Err(e) => warn!(error = %e, "background album refresh failed"),
}
});
}
@@ -175,6 +177,40 @@ impl Core {
Ok(page.into_iter().map(|p| self.photo_item(p)).collect())
}
/// Make sure every album of ours has its contents on disk, for a filter
/// that asks which photos no album holds. Contents are otherwise fetched
/// the first time an album is opened, so an album never opened would read
/// as empty and its photos as unfiled.
///
/// Shared-with-me albums are skipped: their photos live on the sharer's
/// volume and are never in our timeline. The freshness stamps are no guide
/// here — any album change clears all of them — so only an album whose
/// server count disagrees with an empty local copy is waited for.
pub(crate) fn ensure_album_contents(&self) -> CoreResult<()> {
if self.db.albums_count().map_err(CoreError::from)? == 0
&& !self.rt.block_on(self.refresh_albums())?
{
return Ok(());
}
let albums = self.db.albums_list().map_err(CoreError::from)?;
for album in albums.iter().filter(|a| !a.shared && a.photo_count > 0) {
let stored = self
.db
.album_photos_count(&album.uid)
.map_err(CoreError::from)?;
if stored > 0 {
continue;
}
let Some(uid) = parse_uid(&album.uid) else {
continue;
};
if let Err(error) = self.rt.block_on(self.refresh_album(&uid)) {
warn!(album = %album.uid, %error, "fetching an album's contents failed");
}
}
Ok(())
}
/// 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();
@@ -221,6 +257,84 @@ impl Core {
Ok(())
}
/// Create an album and return its uid. The listing is refreshed before
/// answering, so the next [`Core::albums`] already shows it.
pub(crate) fn create_album(&self, name: &str) -> CoreResult<String> {
let uid = self
.rt
.block_on(self.photos().create_album(name))
.map_err(|e| CoreError::from_api(&e, "create album"))?;
self.after_album_change(None);
Ok(uid.to_string())
}
pub(crate) fn rename_album(&self, album: &NodeUid, name: &str) -> CoreResult<()> {
self.rt
.block_on(self.photos().rename_album(album, name))
.map_err(|e| CoreError::from_api(&e, "rename album"))?;
self.after_album_change(None);
Ok(())
}
/// Delete an album. Its photos stay in the timeline unless `delete_photos`,
/// which only matters for photos that exist in the album alone.
pub(crate) fn delete_album(&self, album: &NodeUid, delete_photos: bool) -> CoreResult<()> {
self.rt
.block_on(self.photos().delete_album(album, delete_photos))
.map_err(|e| CoreError::from_api(&e, "delete album"))?;
let key = album.to_string();
if let Err(error) = self.db.album_photos_replace(&key, &[]) {
warn!(%album, %error, "a deleted album's photos could not be dropped");
}
self.after_album_change(None);
Ok(())
}
/// Add photos to an album, per photo: `(added, failed)`.
pub(crate) fn add_to_album(
&self,
album: &NodeUid,
photos: &[NodeUid],
) -> CoreResult<AlbumOutcome> {
let outcomes = self
.rt
.block_on(self.photos().add_photos_to_album(album, photos))
.map_err(|e| CoreError::from_api(&e, "add to album"))?;
self.after_album_change(Some(album));
Ok(album_outcome(outcomes))
}
/// Take photos out of an album, per photo: `(removed, failed)`. The photos
/// stay in the timeline.
pub(crate) fn remove_from_album(
&self,
album: &NodeUid,
photos: &[NodeUid],
) -> CoreResult<AlbumOutcome> {
let outcomes = self
.rt
.block_on(self.photos().remove_photos_from_album(album, photos))
.map_err(|e| CoreError::from_api(&e, "remove from album"))?;
self.after_album_change(Some(album));
Ok(album_outcome(outcomes))
}
/// Bring the persisted listing, and `album`'s contents when given, up to
/// date after a change made here, so the reply is followed by a listing
/// that shows it. A failed refresh only leaves them stale: the change
/// itself went through.
fn after_album_change(&self, album: Option<&NodeUid>) {
self.invalidate_albums();
if let Err(error) = self.rt.block_on(self.refresh_albums()) {
warn!(%error, "album listing refresh after a change failed");
}
if let Some(album) = album
&& let Err(error) = self.rt.block_on(self.refresh_album(album))
{
warn!(%album, %error, "album refresh after a change failed");
}
}
/// 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.
@@ -230,6 +344,20 @@ impl Core {
}
}
/// Photos an album change applied to, and the ones it failed for with why.
pub(crate) type AlbumOutcome = (Vec<String>, Vec<(String, String)>);
fn album_outcome(outcomes: Vec<pdfs_core::batch::Outcome>) -> AlbumOutcome {
let (done, failed) = pdfs_core::batch::split(outcomes);
(
done.iter().map(|uid| uid.to_string()).collect(),
failed
.into_iter()
.map(|(uid, error)| (uid.to_string(), error.to_string()))
.collect(),
)
}
/// Project a persisted album into the wire item a front-end paints.
fn album_info(album: StoredAlbum) -> AlbumInfo {
AlbumInfo {
+390 -23
View File
@@ -2,6 +2,7 @@
use super::*;
use crate::shutdown::Shutdown;
use pdfs_core::error::session_revoked;
/// Drop `uid` from one mount's tree and tell that mount's kernel session the
/// entry is gone. Shared by the trash and delete paths, which differ only in
@@ -9,7 +10,7 @@ use crate::shutdown::Shutdown;
///
/// Returns whether this mount actually held the node, so the caller can evict
/// its content blob once if *any* mount did.
fn forget_and_notify(st: &mut State, notify: &mut NotifyBatch, uid: &NodeUid) -> bool {
pub(crate) fn forget_and_notify(st: &mut State, notify: &mut NotifyBatch, uid: &NodeUid) -> bool {
// Capture the inode before `forget` clears the uid mapping.
let child = st.by_uid.get(uid).copied();
let Some((parent, name)) = st.forget(uid) else {
@@ -97,15 +98,13 @@ fn event_serializes_shared_publication(event: &DriveEvent, own_volume: &VolumeId
/// entry forces the next `lookup`/`readdir` to re-enumerate from the remote, so
/// most events only need to invalidate listings rather than re-fetch eagerly.
///
/// Applied to **every** mounted inode space, not just the primary one. This task
/// is per-daemon while node state is per-mount, and a `DriveEvent` names a uid,
/// not a mount — so a file trashed from another device has to be withdrawn from
/// whichever of our sessions is showing it, which for anything under a sync
/// folder is a [`Core::fork_state`] fork rather than `core.state`. Reaching for
/// the primary state alone left forks serving a deleted file indefinitely, the
/// read-side twin of `docs/BUGS.md` B74. Inode numbers are per-mount, so each
/// mount must be notified through its **own** channel; that pairing is what
/// [`Core::for_each_mount`] exists to preserve.
/// Applied to the tree every mount shares, and told to **every** mounted
/// session. A `DriveEvent` names a uid, not a mount, so a file trashed from
/// another device has to be withdrawn from whichever of our sessions is showing
/// it — which for anything under a sync folder is a [`Core::fork_state`] fork.
/// Telling the primary session alone left forks serving a deleted file, the
/// read-side twin of `docs/BUGS.md` B74. [`Core::for_each_mount`] sends what
/// the kernel needs to hear to every session.
fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdfs_core::Result<()> {
let foreign_delete = is_foreign_node_delete(event, &core.primary_root_uid.volume_id);
let serializes_shared_publication =
@@ -145,7 +144,7 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdf
// create, and re-fetching would replace the size and mtime of the
// write we just accepted with the stale revision's — making a file
// that was copied in seconds ago read as empty until its upload
// lands (offline.md Phase 3).
// lands.
//
// A write that has already drained is the same story one step later:
// the drain brought the tree level with the revision it sealed, so
@@ -233,6 +232,48 @@ fn acknowledge_applied_event(
Ok(id)
}
/// How many applied node-update ids [`RecentEvents`] remembers. Far more than a
/// replay can reach back: one comes from a batch whose last event was not its
/// newest, so it repeats at most what that batch held.
const RECENT_EVENTS: usize = 4096;
/// The node updates applied lately, by event id, so one that comes round again
/// is skipped rather than applied twice.
///
/// The feed can deliver the same event twice in a batch, and a batch whose
/// events come out of order leaves the cursor short of its newest event, so the
/// next poll replays what lay past it. An update applied twice is not harmless:
/// the first copy of our own change claims the echo [`Core::note_self_change`]
/// recorded, and the second, unclaimed, reads as someone else's edit and evicts
/// the bytes a partial write is about to gap-fill from (docs/BUGS.md B123).
///
/// Only node updates: a delete is idempotent, and a continuity loss names the
/// cursor it lost, which is an id already applied.
#[derive(Default)]
struct RecentEvents {
ids: HashSet<DriveEventId>,
order: VecDeque<DriveEventId>,
}
impl RecentEvents {
/// Record `event` as applied, reporting whether it was already.
fn repeat(&mut self, event: &DriveEvent) -> bool {
let DriveEvent::NodeUpdated { id, .. } = event else {
return false;
};
if !self.ids.insert(id.clone()) {
return true;
}
self.order.push_back(id.clone());
if self.order.len() > RECENT_EVENTS
&& let Some(old) = self.order.pop_front()
{
self.ids.remove(&old);
}
false
}
}
/// The folders one batch of events changed, collected so the listings are
/// dropped once at the end of the batch rather than once per event.
///
@@ -320,10 +361,10 @@ fn flush_dirty_parents(core: &Core, dirty: &DirtyParents) {
/// only on fatal error.
///
/// Takes the whole `Core` rather than the primary mount's pieces: an event has
/// to be applied to every mounted inode space, and the registry that enumerates
/// them lives on the `Core` (see [`apply_event`]).
/// to reach every mounted session, and the registry that enumerates them lives
/// on the `Core` (see [`apply_event`]).
pub(super) async fn run_event_sync(
client: ProtonDriveClient,
client: Arc<dyn DriveApi>,
scope: DriveEventScopeId,
core: Core,
) {
@@ -335,7 +376,7 @@ pub(super) async fn run_event_sync(
// server head; persist it so the next restart resumes instead of
// reseeding (which would skip everything that changed offline).
// Seeding needs the network, and this task also runs on mounts that
// started offline (offline.md Phase 1) — so retry rather than giving up,
// started offline — so retry rather than giving up,
// which used to disable live sync for the life of the daemon.
Ok(None) => {
// Nothing says what changed before this cursor, so a persisted SDK
@@ -362,6 +403,12 @@ pub(super) async fn run_event_sync(
}
break Some(c.clone());
}
// Refused for good: retrying would be refused the same way
// for the life of the daemon. The next login restarts it.
Err(e) if session_revoked(&e) => {
core.mark_session_expired("seed event cursor", &e);
return;
}
Err(e) => {
warn!(error = %e, ?delay, "seed event cursor failed; retrying");
tokio::time::sleep(delay).await;
@@ -377,10 +424,12 @@ pub(super) async fn run_event_sync(
};
info!(?cursor, "event sync started");
let mut recent = RecentEvents::default();
loop {
tokio::time::sleep(POLL_INTERVAL).await;
let events = match client.enumerate_events(&scope, cursor.as_ref()).await {
core.next_poll(POLL_INTERVAL).await;
let events = match link::bounded(client.enumerate_events(&scope, cursor.as_ref())).await {
Ok(events) => events,
Err(e) if core.lost_link(&e, "poll events") => continue,
Err(e) => {
warn!(error = %e, "event poll failed; retrying after interval");
continue;
@@ -396,6 +445,11 @@ pub(super) async fn run_event_sync(
// did apply.
let mut dirty = DirtyParents::default();
for event in &events {
if recent.repeat(event) {
debug!(id = %event.id().as_str(), "skipping an event already applied");
cursor = Some(event.id().clone());
continue;
}
// Converge the SDK's own caches (folder keys, entity cache) on the
// server before applying the event to our tree. Without this, a node
// re-keyed/moved by another client keeps a stale key in the SDK for
@@ -422,6 +476,175 @@ pub(super) async fn run_event_sync(
cursor = Some(applied);
}
flush_dirty_parents(&core, &dirty);
// Another device changed the tree: whatever a front-end lists may have
// moved, including what is shared and what is in the trash.
core.events
.publish(&[Topic::Files, Topic::Trash, Topic::Shares, Topic::Conflicts]);
}
}
/// The state key holding the photos volume's event cursor. Separate from the
/// Drive cursor because the two volumes have their own event streams, and one
/// cursor cannot address both.
const PHOTOS_EVENT_CURSOR: &str = "photos_event_cursor";
/// Poll the photos volume's event stream, so a photo deleted or added on another
/// device shows up in the gallery within one poll instead of on the timeline's
/// freshness window.
///
/// Deliberately much smaller than [`run_event_sync`]: the photos volume is not
/// part of the FUSE mount, so there is no inode space to converge and nothing to
/// invalidate in the kernel. A trash or a delete removes the rows behind the
/// gallery; anything else only drops the timeline's freshness stamp, and the
/// next read re-enumerates. Getting that wrong costs a stale tile, not a wrong
/// file.
pub(super) async fn run_photos_event_sync(core: Core) {
let photos = core.photos();
// The photos volume only exists once the user has used Photos at all, and
// the daemon may well start before the network does.
let scope = loop {
match photos.get_photos_root().await {
Ok(Some(root)) => break root.tree_event_scope_id(),
Ok(None) => {
debug!("no photos volume; photo event sync not started");
return;
}
Err(error) if session_revoked(&error) => {
core.mark_session_expired("read the photos root", &error);
return;
}
Err(error) => {
warn!(%error, "reading the photos root failed; retrying");
tokio::time::sleep(ONLINE_PROBE_MAX).await;
}
}
};
let mut cursor: Option<DriveEventId> = core
.db
.state_str(PHOTOS_EVENT_CURSOR)
.ok()
.flatten()
.map(DriveEventId::from);
info!(?cursor, "photo event sync started");
loop {
core.next_poll(POLL_INTERVAL).await;
let events = match link::bounded(photos.enumerate_events(&scope, cursor.as_ref())).await {
Ok(events) => events,
Err(error) if core.lost_link(&error, "poll photo events") => continue,
Err(error) => {
warn!(%error, "photo event poll failed; retrying after interval");
continue;
}
};
let Some(head) = events.last().map(|event| event.id().clone()) else {
continue;
};
apply_photo_events(&core, &events);
core.events.publish(&[Topic::Photos]);
// One cursor write per batch, after the batch is applied: a crash in
// between replays events whose effects are idempotent.
if let Err(error) = core.db.set_state_str(PHOTOS_EVENT_CURSOR, head.as_str()) {
warn!(%error, "persisting the photo event cursor failed");
continue;
}
cursor = Some(head);
}
}
/// What one batch of photos-volume events asks the gallery's tables to do.
#[derive(Debug, Default, PartialEq)]
pub(super) struct PhotoEventPlan {
/// Photos that left the volume, by delete or by trash.
pub(super) gone: Vec<String>,
/// Photos whose node changed, so their stored metadata is no longer trusted.
pub(super) touched: Vec<String>,
/// Whether the whole timeline has to be read again.
pub(super) full: bool,
}
impl PhotoEventPlan {
fn is_empty(&self) -> bool {
self.gone.is_empty() && self.touched.is_empty() && !self.full
}
}
/// Read one batch of photos-volume events. Pure, so the classification is
/// testable without a volume behind it.
///
/// `is_echo` claims an event as this daemon's own write. The feed replays our
/// writes back at us and a `NodeUpdated` carries no revision id, so without this
/// every favorite the user toggles here would mark its own photo stale and buy
/// a resolve of a node we just wrote.
fn classify_photo_events(
events: &[DriveEvent],
mut is_echo: impl FnMut(&NodeUid) -> bool,
) -> PhotoEventPlan {
let mut plan = PhotoEventPlan::default();
for event in events {
match event {
DriveEvent::NodeDeleted { node_uid, .. } => plan.gone.push(node_uid.to_string()),
DriveEvent::NodeUpdated {
node_uid,
is_trashed,
..
} => {
if *is_trashed {
plan.gone.push(node_uid.to_string());
} else if !is_echo(node_uid) {
// An untrash, a rename, a favorite toggled on the phone: the
// event says which node changed but never what about it, so
// the uid is marked stale and the next refresh reads it.
plan.touched.push(node_uid.to_string());
}
}
// The daemon cannot know what it missed here, so everything it
// thinks it knows about every photo is dropped.
DriveEvent::ContinuityLost { .. } | DriveEvent::ScopeAccessLost { .. } => {
plan.full = true;
}
DriveEvent::CursorAdvanced { .. } | DriveEvent::SharedWithMeUpdated { .. } => {}
}
}
plan
}
/// Apply one batch of photos-volume events to the gallery's tables.
fn apply_photo_events(core: &Core, events: &[DriveEvent]) {
let plan = classify_photo_events(events, |uid| core.take_self_change(uid));
let PhotoEventPlan {
gone,
touched,
full,
} = &plan;
if !gone.is_empty() {
match core.db.photos_delete(gone) {
Ok(removed) if removed > 0 => {
debug!(removed, "photos removed by a remote event");
}
Ok(_) => {}
Err(error) => warn!(%error, "removing remotely deleted photos failed"),
}
}
// A stale mark is what makes the next refresh cheap: it reads these photos'
// nodes and carries every other photo's metadata as it stands.
if !touched.is_empty() {
match core.db.photos_mark_unresolved(touched) {
Ok(marked) if marked > 0 => debug!(marked, "photos changed by a remote event"),
Ok(_) => {}
Err(error) => warn!(%error, "marking remotely changed photos failed"),
}
}
if *full {
match core.db.photos_mark_all_unresolved() {
Ok(marked) => debug!(marked, "the photo event stream lost continuity"),
Err(error) => warn!(%error, "marking the whole timeline unresolved failed"),
}
}
if !plan.is_empty() {
core.invalidate_photos();
core.invalidate_albums();
}
}
@@ -448,7 +671,7 @@ pub(super) fn run_local_index(
scan_local_once(&db, &indexing, &transfers, &mountpoint);
}
// Interruptible, so teardown joins this thread instead of leaving it to
// keep rewriting the index for a mount that is gone (bugs.md B44).
// keep rewriting the index for a mount that is gone (`docs/BUGS.md` B44).
if !shutdown.sleep(LOCAL_INDEX_CHECK) {
return;
}
@@ -487,24 +710,37 @@ fn scan_local_once(
}
};
let excludes = localindex::default_excludes(mountpoint, &dirs.state_dir(), &dirs.cache_dir());
let mut excludes =
localindex::default_excludes(mountpoint, &dirs.state_dir(), &dirs.cache_dir());
// Resolved per scan rather than once at startup: mounts come and go, and a
// mount that appeared since the daemon started is exactly the one most
// likely to be a half-alive network filesystem (see `nested_mount_points`).
excludes.extend(localindex::nested_mount_points(&home));
indexing.store(true, Ordering::Relaxed);
let started = Instant::now();
// The walk has no idea how many files it will find, so the job counts what it
// has seen and stays indeterminate.
let job = transfers.begin_job("Indexing this computer");
let job = transfers.begin_background_job("Indexing this computer");
job.detail("Scanning your files");
let mut failed = false;
let walked = localindex::scan(&[home], &excludes, |batch| {
if let Err(e) = db.local_upsert_batch(generation, &batch) {
warn!(error = %e, "local index: batch write failed");
failed = true;
}
});
// Prune what this scan did not see and rebuild the FTS index over the rest,
// even if some batches failed — a partial index still beats none.
// Prune what this scan did not see, unless it did not see everything:
// a path it never reached, or one whose batch failed to write, is not a
// path that is gone (`docs/BUGS.md` B217).
job.detail("Building the search index");
match db.local_finish_scan(generation, now_secs()) {
let finished = if failed || walked >= localindex::MAX_ENTRIES {
db.local_finish_partial_scan(now_secs())
} else {
db.local_finish_scan(generation, now_secs())
};
match finished {
Ok(indexed) => info!(
walked,
indexed,
@@ -541,9 +777,48 @@ mod tests {
photo: None,
album: None,
verification: Default::default(),
direct_role: None,
share_id: None,
}
}
#[test]
fn a_node_update_seen_again_is_skipped_but_other_events_are_not() {
let update = |id: &str| DriveEvent::NodeUpdated {
id: DriveEventId::from(id),
node_uid: uid("file"),
parent_node_uid: None,
is_trashed: false,
is_shared: false,
};
let mut recent = RecentEvents::default();
assert!(!recent.repeat(&update("e1")));
assert!(!recent.repeat(&update("e2")));
assert!(recent.repeat(&update("e1")), "a duplicate or replay");
let lost = DriveEvent::ContinuityLost {
id: DriveEventId::from("e2"),
};
assert!(!recent.repeat(&lost), "names the cursor, which was applied");
}
#[test]
fn recent_events_forget_the_oldest_past_their_bound() {
let update = |n: usize| DriveEvent::NodeUpdated {
id: DriveEventId::from(format!("e{n}")),
node_uid: uid("file"),
parent_node_uid: None,
is_trashed: false,
is_shared: false,
};
let mut recent = RecentEvents::default();
for n in 0..=RECENT_EVENTS {
assert!(!recent.repeat(&update(n)));
}
assert_eq!(recent.ids.len(), RECENT_EVENTS);
assert!(!recent.repeat(&update(0)), "the oldest was forgotten");
assert!(recent.repeat(&update(RECENT_EVENTS)));
}
#[test]
fn foreign_delete_invalidates_an_earlier_refresh_generation() {
let own_volume = VolumeId::from("own");
@@ -787,4 +1062,96 @@ mod tests {
assert!(acknowledge_applied_event(&db, &event, applied).is_err());
assert!(db.get_event_cursor().unwrap().is_none());
}
/// A photo trashed or deleted elsewhere leaves the gallery; one that only
/// changed is marked stale, so the next refresh reads that node and no other.
#[test]
fn a_photo_event_names_what_it_changed() {
let plan = classify_photo_events(
&[
DriveEvent::NodeDeleted {
id: DriveEventId::from("e1"),
node_uid: uid("deleted"),
parent_node_uid: None,
},
DriveEvent::NodeUpdated {
id: DriveEventId::from("e2"),
node_uid: uid("trashed"),
parent_node_uid: None,
is_trashed: true,
is_shared: false,
},
DriveEvent::NodeUpdated {
id: DriveEventId::from("e3"),
node_uid: uid("favorited"),
parent_node_uid: None,
is_trashed: false,
is_shared: false,
},
],
|_| false,
);
assert_eq!(
plan,
PhotoEventPlan {
gone: vec![uid("deleted").to_string(), uid("trashed").to_string()],
touched: vec![uid("favorited").to_string()],
full: false,
}
);
}
/// The feed replays this daemon's own writes. Applying one would cost a
/// resolve of a node whose changed field the daemon just wrote itself.
#[test]
fn our_own_photo_write_is_not_a_remote_change() {
let plan = classify_photo_events(
&[DriveEvent::NodeUpdated {
id: DriveEventId::from("e1"),
node_uid: uid("ours"),
parent_node_uid: None,
is_trashed: false,
is_shared: false,
}],
|claimed| *claimed == uid("ours"),
);
assert_eq!(plan, PhotoEventPlan::default());
}
/// A gap in the stream means the daemon cannot know what it missed, so it
/// keeps nothing.
#[test]
fn a_lost_photo_event_stream_asks_for_everything() {
for event in [
DriveEvent::ContinuityLost {
id: DriveEventId::from("e1"),
},
DriveEvent::ScopeAccessLost {
id: DriveEventId::from("e1"),
},
] {
let plan = classify_photo_events(std::slice::from_ref(&event), |_| false);
assert!(plan.full, "{event:?}");
assert!(plan.gone.is_empty());
assert!(plan.touched.is_empty());
}
}
/// Bookkeeping events change nothing about the gallery, and must not buy a
/// refresh of it.
#[test]
fn photo_bookkeeping_events_ask_for_nothing() {
let plan = classify_photo_events(
&[
DriveEvent::CursorAdvanced {
id: DriveEventId::from("e1"),
},
DriveEvent::SharedWithMeUpdated {
id: DriveEventId::from("e2"),
},
],
|_| false,
);
assert_eq!(plan, PhotoEventPlan::default());
}
}
+179
View File
@@ -0,0 +1,179 @@
//! Listing and resolving `(sync-conflict …)` copies for the user.
//!
//! The sweep ([`crate::sweep`]) removes copies it can prove identical and flags
//! the rest once in the activity feed. That leaves divergent copies sitting in
//! folders with nothing but a name to find them by. This module lists them with
//! the file they are a copy of, and resolves one the way the user chooses:
//!
//! - **Keep the original**: trash the copy.
//! - **Keep the copy**: trash the original, then give the copy its name.
//! - **Keep both**: rename the copy to a name of the user's choosing.
//!
//! Every resolution goes through the Proton trash, never a delete, so a wrong
//! choice is undone by restoring from Trash. Resolution refuses to touch a file
//! that is open, being written, or still owed an upload, for the same reason the
//! sweep does: the node is about to change under the decision.
//!
//! Copies under My Files are named by their mountpoint-relative path. Copies
//! inside a synced folder are named by their absolute local path, so a resolve
//! request routes to the on-demand mount that serves them. A copy inside a
//! mirror folder is listed too, but it is a local file first: the sync engine
//! owns it, so it is resolved by editing the folder, not through here.
use super::*;
use pdfs_core::control::{ConflictInfo, ConflictKeep};
use sweep::conflict_base_name;
impl Core {
/// Where the user finds a conflict copy: its mountpoint-relative path under
/// My Files, or its absolute local path inside a synced folder. `None` for a
/// node no location of this machine shows, such as one in another
/// computer's device tree.
///
/// The sweep only flags what this can name, and the listing uses it too. A
/// flagged copy the listing cannot see reads as resolved in the activity
/// feed while it still sits in the folder (`docs/BUGS.md` B99).
pub(crate) fn conflict_path(&self, roots: &SearchRoots, uid: &NodeUid) -> Option<String> {
let uid = uid.to_string();
if let Ok(Some(path)) = self
.db
.path_relative_to(&self.primary_root_uid.to_string(), &uid)
{
return Some(path);
}
let drive_path = self.db.node_path(&uid).ok().flatten()?;
roots.resolve(&drive_path)
}
/// Every conflict copy this machine shows, with the file it is a copy of.
pub(crate) fn list_conflicts(&self) -> CoreResult<Vec<ConflictInfo>> {
let stored = self
.db
.load_all()
.map_err(|e| CoreError::internal(format!("listing conflicts: {e:?}")))?;
let nodes: Vec<Node> = stored
.into_iter()
.map(|stored| stored.node)
.filter(|node| !node.trashed && !node.is_folder() && self.is_own_or_virtual(&node.uid))
.collect();
let mut by_parent_name: HashMap<(Option<&NodeUid>, &str), &Node> = HashMap::new();
for node in &nodes {
by_parent_name.insert((node.parent_uid.as_ref(), node.name.as_str()), node);
}
let roots = self.search_roots();
let mut items = Vec::new();
for node in &nodes {
let Some(base) = conflict_base_name(&node.name) else {
continue;
};
let Some(path) = self.conflict_path(&roots, &node.uid) else {
continue;
};
let original = by_parent_name
.get(&(node.parent_uid.as_ref(), base.as_str()))
.copied();
items.push(ConflictInfo {
original_path: sibling_path(&path, &base),
original_exists: original.is_some(),
size: node_size(node),
modified: node.modification_time,
original_size: original.map(node_size),
original_modified: original.map(|o| o.modification_time),
identical: original.is_some_and(|o| {
node_size(o) == node_size(node)
&& node_content_sha1(node)
.is_some_and(|sha| node_content_sha1(o).as_deref() == Some(&sha))
}),
path,
});
}
items.sort_by(|a, b| a.path.cmp(&b.path));
Ok(items)
}
/// Resolve the conflict copy at mountpoint-relative `copy`. Returns a line
/// for the user saying what happened.
pub(crate) fn resolve_conflict(&self, copy: &Path, keep: &ConflictKeep) -> CoreResult<String> {
let copy_name = copy
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| CoreError::invalid("the conflict copy has no file name"))?;
let base = conflict_base_name(copy_name)
.ok_or_else(|| CoreError::invalid(format!("{copy_name} is not a conflict copy")))?;
let original = copy.with_file_name(&base);
self.require_settled(copy)?;
let message = match keep {
ConflictKeep::Original => {
self.delete(copy)?;
self.log_activity(ActivityKind::Trash, copy_name, format!("kept {base}"), true);
format!("kept {base}; the copy is in Trash")
}
ConflictKeep::Copy => {
self.require_settled(&original)?;
self.delete(&original)?;
// The original is already in Trash. A failed rename leaves the
// copy under its conflict name, which loses nothing — say so
// rather than leave the user guessing where their file went.
self.rename(copy, &base).map_err(|e| {
e.context(&format!(
"{base} was moved to Trash, but the copy kept its conflict name"
))
})?;
self.log_activity(
ActivityKind::Rename,
&base,
format!("kept the conflict copy {copy_name}"),
true,
);
format!("kept the copy as {base}; the previous version is in Trash")
}
ConflictKeep::Both { name } => {
let name = name.trim();
if name == base || name == copy_name {
return Err(CoreError::invalid("choose a name that no other file has"));
}
self.rename(copy, name)?;
self.log_activity(ActivityKind::Rename, name, format!("was {copy_name}"), true);
format!("kept both; the copy is now {name}")
}
};
Ok(message)
}
/// Refuse to resolve around a file that is open, being written, or still
/// owed an upload: the decision would be about content that is changing.
fn require_settled(&self, rel: &Path) -> CoreResult<()> {
let (_, uid) = self.resolve(rel)?;
let queued = self.db.has_any_op(&uid.to_string()).unwrap_or(true);
if queued || self.is_busy(&uid) {
return Err(CoreError::conflict(format!(
"{} is still being written or uploaded; try again once it has synced",
rel.display()
)));
}
self.conflict_notified.lock().remove(&uid);
Ok(())
}
}
/// The path of `name` next to the file at `path`.
fn sibling_path(path: &str, name: &str) -> String {
match path.rsplit_once('/') {
Some((dir, _)) => format!("{dir}/{name}"),
None => name.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_original_sits_next_to_its_copy() {
assert_eq!(
sibling_path("a/b/f (sync-conflict 1).txt", "f.txt"),
"a/b/f.txt"
);
assert_eq!(sibling_path("f (sync-conflict 1).txt", "f.txt"), "f.txt");
}
}
File diff suppressed because it is too large. Load diff
+319 -56
View File
@@ -1,5 +1,5 @@
//! This machine as a Proton Drive Device, and the local folders it keeps in
//! step with the remote (devices.md).
//! step with the remote.
//!
//! A sync folder runs in one of two modes. `mirror` keeps a real local tree that
//! the engine in [`super::sync`] reconciles both ways; `ondemand` evicts that
@@ -19,6 +19,7 @@ use pdfs_core::control::{
};
use pdfs_core::db::{StoredDevice, StoredSyncFolder};
use pdfs_core::mounts::MountMode;
use pdfs_core::profile::PROFILE_DIR_NAME;
use pdfs_core::{CoreError, CoreResult};
use proton_drive_rs::proton_sdk::ids::{DeviceUid, NodeUid};
use proton_drive_rs::{DeviceType, Node};
@@ -27,7 +28,7 @@ use tracing::{info, warn};
use super::sync::{self, base_name};
use super::{
Core, SecondaryInsertRejection, SecondaryMount, State, SwitchBlocked, clear_stale_mount,
Core, SecondaryInsertRejection, SecondaryMount, SwitchBlocked, clear_stale_mount,
device_type_str, dir_is_empty, evict_dir_contents, fuse_connection_id, is_stale_mount,
now_secs, parse_uid, spawn_session, sync_folder_info, this_hostname,
};
@@ -104,6 +105,15 @@ pub(crate) fn adopted_device_uid() -> Option<String> {
(!uid.is_empty()).then_some(uid)
}
/// The paths no sync root may overlap, with the mountpoint as the config says
/// now.
pub(crate) fn sync_root_reserved() -> pdfs_core::syncroot::Reserved {
let config = pdfs_core::config::AppDirs::new()
.map(|dirs| dirs.load_config())
.unwrap_or_default();
pdfs_core::syncroot::Reserved::current(&config)
}
/// Pin (or, with `None`, unpin) the adopted device uid in `config.json`.
pub(crate) fn set_adopted_device_uid(uid: Option<&str>) -> CoreResult<()> {
let dirs = pdfs_core::config::AppDirs::new()
@@ -114,6 +124,22 @@ pub(crate) fn set_adopted_device_uid(uid: Option<&str>) -> CoreResult<()> {
.map_err(|e| CoreError::internal(format!("write config: {e}")))
}
/// The name a folder moved in from `device` takes when `name` is already
/// taken under this device's root, or `None` when it can keep its own.
fn migrated_name(name: &str, device: &str, taken: &[String]) -> Option<String> {
let free = |candidate: &str| !taken.iter().any(|t| t == candidate);
if free(name) {
return None;
}
let base = format!("{name} ({device})");
if free(&base) {
return Some(base);
}
(2..)
.map(|n| format!("{base} {n}"))
.find(|candidate| free(candidate))
}
impl Core {
// ---- devices ----------------------------------------------------------
@@ -128,6 +154,7 @@ impl Core {
location.mounted = self.states.is_mounted_at(Path::new(&location.local_path));
if let MountKind::Device { sync_folder_id } = &location.kind {
location.progress = progress.get(sync_folder_id).cloned();
location.paused = self.sync_folder_paused(*sync_folder_id);
}
}
Ok(locations)
@@ -139,7 +166,7 @@ impl Core {
pub(crate) fn list_devices(&self) -> CoreResult<Vec<DeviceInfo>> {
let devices = self
.rt
.block_on(self.client.enumerate_devices())
.block_on(self.drive.enumerate_devices())
.map_err(|e| CoreError::from_api(&e, "list devices"))?;
// No cached device row yet means this machine syncs nothing, so none of
// the listed devices is ours.
@@ -183,8 +210,8 @@ impl Core {
}
/// Adopt an existing device as *this* machine's, pinning it in `config.json`
/// so hostname changes and reinstalls stop registering duplicates
/// (features.md 5.1). `None` clears the pin and returns to hostname matching.
/// so hostname changes and reinstalls stop registering duplicates.
/// `None` clears the pin and returns to hostname matching.
///
/// Refuses a uid that is not in the account: the whole point of the pin is
/// that it is authoritative, so accepting a bad one would defer the failure
@@ -196,7 +223,7 @@ impl Core {
};
let remote = self
.rt
.block_on(self.client.enumerate_devices())
.block_on(self.drive.enumerate_devices())
.map_err(|e| CoreError::from_api(&e, "enumerate devices"))?;
let dev = remote
.into_iter()
@@ -226,7 +253,141 @@ impl Core {
Ok(format!("adopted device {name}"))
}
// ---- device folder sync (devices.md, Phase 1) -------------------------
/// The device a restore reads from: this machine's own for `None`, else
/// the registered device with that uid.
///
/// Another device is looked up without touching the cached device row —
/// restoring its folders here must not make this machine back up as it.
/// Its folders are then attached with that device's share id, so they sync
/// against the other computer's tree.
pub(crate) fn restore_source(&self, device: Option<&str>) -> CoreResult<StoredDevice> {
let Some(uid) = device else {
return self.ensure_device();
};
let remote = self
.rt
.block_on(self.drive.enumerate_devices())
.map_err(|e| CoreError::from_api(&e, "enumerate devices"))?;
let d = remote
.into_iter()
.find(|d| d.uid.to_string() == uid)
.ok_or_else(|| CoreError::not_found(format!("no device {uid} in this account")))?;
Ok(StoredDevice {
uid: d.uid.to_string(),
share_id: d.share_id.to_string(),
root_uid: d.root_folder_uid.to_string(),
name: d
.name
.as_deref()
.ok()
.unwrap_or("(unnamed device)")
.to_string(),
created: d.creation_time,
})
}
/// Move every folder another device backs up into this machine's own
/// device, so this machine carries on backing them up as itself. The other
/// device stays registered, without those folders.
///
/// Device roots all live on the main volume, so each folder is one
/// server-side move: nothing is downloaded or uploaded, and no quota is
/// used twice. A folder whose name is taken here keeps its content under
/// "<name> (<device>)". A failed folder does not stop the others, and a
/// second run picks up whatever is still left on the other device.
///
/// Nothing is synced to this disk here. The moved folders are now this
/// device's, so the ordinary restore offers them. Only folders that were
/// already synced here from the other device are touched: their uid
/// survives the move but their share does not.
pub(crate) fn migrate_device(&self, uid: &str) -> CoreResult<String> {
let source = self.restore_source(Some(uid))?;
let target = self.ensure_device()?;
if source.uid == target.uid {
return Err(CoreError::invalid(format!(
"{} is this computer's own device",
source.name
)));
}
let bad_root = |d: &StoredDevice| {
parse_uid(&d.root_uid)
.ok_or_else(|| CoreError::internal(format!("bad device root uid: {}", d.root_uid)))
};
let (source_root, target_root) = (bad_root(&source)?, bad_root(&target)?);
if source_root.volume_id != target_root.volume_id {
return Err(CoreError::invalid(
"the two devices are on different Proton Drive volumes; copy it instead",
));
}
let mut taken: Vec<String> = self
.device_root_nodes(&target_root)?
.into_iter()
.filter(|n| !n.trashed)
.map(|n| n.name)
.collect();
let synced = self
.db
.sync_folder_list()
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
let mut moved = 0usize;
let mut renamed: Vec<String> = Vec::new();
let mut left: Vec<String> = Vec::new();
let mut failed: Vec<String> = Vec::new();
for node in self.device_root_nodes(&source_root)? {
if node.trashed || node.name == PROFILE_DIR_NAME {
continue;
}
// A device root only ever holds folders (B68); anything else was
// put there by hand and is left for the user to deal with.
if !node.is_folder() {
left.push(node.name);
continue;
}
let new_name = migrated_name(&node.name, &source.name, &taken);
if let Err(e) = self.move_rename_remote(&node.uid, &target_root, new_name.as_deref()) {
warn!(name = %node.name, error = %e, "migrate: could not move folder");
failed.push(format!("{}: {e}", node.name));
continue;
}
let landed = new_name.clone().unwrap_or_else(|| node.name.clone());
if new_name.is_some() {
renamed.push(format!("{} -> {landed}", node.name));
}
taken.push(landed);
moved += 1;
let uid = node.uid.to_string();
for folder in synced.iter().filter(|f| f.remote_uid == uid) {
if let Err(e) = self.db.sync_folder_set_share(folder.id, &target.share_id) {
warn!(id = folder.id, error = ?e, "migrate: could not re-point sync folder");
}
self.sync_now(Some(folder.id));
}
}
self.invalidate_children_of(&source_root);
self.invalidate_children_of(&target_root);
let mut message = format!("moved {moved} folder(s) from {}", source.name);
if !renamed.is_empty() {
message.push_str(&format!("; renamed: {}", renamed.join(", ")));
}
if !left.is_empty() {
message.push_str(&format!("; left behind: {}", left.join(", ")));
}
if !failed.is_empty() {
message.push_str(&format!("; failed: {}", failed.join(", ")));
}
info!(from = %source.name, to = %target.name, moved, failed = failed.len(),
"device migrated");
if moved == 0 && !failed.is_empty() {
return Err(CoreError::conflict(message));
}
Ok(message)
}
// ---- device folder sync -------------------------
/// Auto-register (or recover) this machine as a Proton Drive Device, caching
/// it so restarts reuse the same device. Recovery matches an existing remote
@@ -236,15 +397,14 @@ impl Core {
/// Resolution order: the *adopted* uid pinned in `config.json`, then the
/// cached DB row (validated remotely), then a hostname match, then create.
/// The pin exists because the hostname heuristic silently registers a second
/// device after a rename or reinstall, orphaning the first one's folders
/// (features.md 5.1).
/// device after a rename or reinstall, orphaning the first one's folders.
pub(crate) fn ensure_device(&self) -> CoreResult<StoredDevice> {
let name = this_hostname();
// Enumerate the remote devices once: used to validate the pin, validate
// any cached record, and recover an existing device by name.
let remote = self
.rt
.block_on(self.client.enumerate_devices())
.block_on(self.drive.enumerate_devices())
.map_err(|e| CoreError::from_api(&e, "enumerate devices"))?;
// An adopted uid is an explicit instruction, so it outranks everything
@@ -303,7 +463,7 @@ impl Core {
None => {
let d = self
.rt
.block_on(self.client.create_device(&name, DeviceType::Linux))
.block_on(self.drive.create_device(&name, DeviceType::Linux))
.map_err(|e| CoreError::from_api(&e, "create device"))?;
StoredDevice {
uid: d.uid.to_string(),
@@ -327,23 +487,27 @@ impl Core {
root_uid: &NodeUid,
name: &str,
) -> CoreResult<Option<NodeUid>> {
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"))?;
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
Ok(self
.device_root_nodes(root_uid)?
.into_iter()
.find(|n| n.is_folder() && !n.trashed && n.name == name)
.map(|n| n.uid))
}
/// Everything directly under a device root, trashed nodes included.
fn device_root_nodes(&self, root_uid: &NodeUid) -> CoreResult<Vec<Node>> {
let uids = self
.rt
.block_on(self.drive.enumerate_folder_children_node_uids(root_uid))
.map_err(|e| CoreError::from_api(&e, "list device root"))?;
if uids.is_empty() {
return Ok(Vec::new());
}
self.rt
.block_on(self.drive.enumerate_nodes(&uids))
.map_err(|e| CoreError::from_api(&e, "resolve device root children"))
}
/// Add a local folder to this device's sync set: register the device if
/// needed, create a matching folder under the device root, upload the local
/// tree into it once, and record the mapping. Phase 1 is a one-shot upload —
@@ -362,18 +526,20 @@ impl Core {
.map_err(|e| CoreError::internal(format!("canonicalize {}: {e}", local.display())))?;
let local_str = local.to_string_lossy().to_string();
// Reject duplicates up front for a clear error (UNIQUE would also catch it).
if self
// `/`, the home folder, the mount, this app's own data, and a folder
// inside or around another root are refused (`syncroot::problem`). A
// duplicate is one of those, refused here for a clear error before
// the UNIQUE constraint would catch it.
let others = self
.db
.sync_folder_list()
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
.iter()
.any(|f| f.local_path == local_str)
{
return Err(CoreError::invalid(format!(
"{} is already synced",
local.display()
)));
.map(|f| PathBuf::from(&f.local_path))
.collect::<Vec<_>>();
let reserved = sync_root_reserved();
if let Some(problem) = pdfs_core::syncroot::problem(&local, &others, &reserved) {
return Err(CoreError::invalid(problem));
}
let name = local
@@ -412,10 +578,7 @@ impl Core {
}
None => self
.rt
.block_on(
self.client
.create_folder(&root_uid, &name, Some(now_secs())),
)
.block_on(self.drive.create_folder(&root_uid, &name, Some(now_secs())))
.map_err(|e| CoreError::from_api(&e, &format!("create device folder {name}")))?,
};
@@ -448,7 +611,8 @@ impl Core {
.into_iter()
.map(|f| {
let live = progress.get(&f.id).cloned();
sync_folder_info(f, live)
let paused = self.sync_folder_paused(f.id);
sync_folder_info(f, live, paused)
})
.collect())
}
@@ -504,12 +668,16 @@ impl Core {
} else {
p.total.max(p.done) as u64
},
// Most passes find nothing; only one that queues work is
// the drive syncing.
background: true,
},
SyncPhase::Applying => JobItem {
title: format!("Syncing {folder}"),
detail: p.current.clone(),
done: p.done as u64,
total: p.total.max(p.done) as u64,
background: false,
},
});
}
@@ -561,13 +729,14 @@ impl Core {
return Err(CoreError::invalid(format!("no synced folder with id {id}")));
}
drop(_guard);
self.clear_sync_folder_paused(id);
// Stop watching the folder we just dropped.
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
if delete_remote
&& let Some(uid) = parse_uid(&folder.remote_uid)
&& let Err(e) = self
.rt
.block_on(self.client.trash_nodes(&[uid]))
.block_on(self.drive.trash_nodes(&[uid]))
.and_then(batch::into_unit)
{
warn!(id, error = %e, "delete remote device folder failed");
@@ -583,23 +752,16 @@ impl Core {
};
}
/// A sibling Core that shares this one's client/rt/cache/db (and transfer,
/// activity, mount registries) but gets a **fresh, empty `State`** — its own
/// inode space starting at [`ROOT_INO`]. Used to root a secondary FUSE session
/// at an `ondemand` sync folder without colliding with the main mount's inodes
/// (devices.md Phase 3).
/// A sibling Core for a secondary FUSE session rooted at an `ondemand` sync
/// folder. It shares everything with this one,
/// the tree included, except what belongs to a session: its root and its
/// kernel channel, both filled in when the session is built.
pub(crate) fn fork_state(&self) -> Core {
let mut fork = self.clone();
fork.primary = false;
let share_access = self.state().share_access.clone();
fork.state = Arc::new(Mutex::new(State::new(self.db.clone(), share_access, 2)));
// A fresh inode space needs a fresh notification channel: this fork's
// session is the only one that knows these inodes, so it must be the one
// notified about them. Filled in by `spawn_ondemand_mount`.
fork.root = Arc::new(OnceLock::new());
fork.notifier = Arc::new(OnceLock::new());
fork.session_live = Arc::new(std::sync::atomic::AtomicBool::new(false));
// Size upgrades are keyed by inode, which is per-fork too.
fork.size_upgrades = Arc::new(Mutex::new(HashMap::new()));
fork
}
@@ -625,6 +787,13 @@ impl Core {
.ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?;
let current_mode = ApplicableMountMode::parse(&folder.mode)
.map_err(|error| CoreError::invalid(format!("unsupported current mode: {error}")))?;
// A switch waits on the folder's reconcile, which a pause skips; refuse
// it up front rather than queue it indefinitely.
if current_mode != target_mode && self.sync_folder_paused(id) {
return Err(CoreError::invalid(
"this folder is paused; resume it before changing its mode",
));
}
if current_mode == target_mode {
let mode_str = target_mode.as_str();
let lock = self.sync_lock(id);
@@ -810,12 +979,35 @@ impl Core {
if folder.state != "idle" {
return Err(SwitchBlocked::NotNow);
}
// `idle` only says the last pass applied everything it tried.
// It never tries an ignored path, a symlink or a folder it
// cannot read, it defers a file open for writing, and a file can
// change after it ends. The eviction below deletes all of those
// for good, so it waits until nothing here is missing from Drive
// (B198).
let baseline = self
.db
.sync_entries(id)
.map_err(|e| SwitchBlocked::Failed(format!("db: {e:?}")))?;
let rules = self.ignore_rules(&local);
// Emptied before any pass saw it, the folder is still `idle`, and
// the check below would refuse the switch for good. The push pass
// of a queued switch forgets those paths instead (`docs/BUGS.md`
// B255).
if sync::mirror_tree_gone(&local, &baseline, &rules) {
return Err(SwitchBlocked::NotNow);
}
if let Some(reason) = sync::mirror_tree_unsynced(&local, &baseline, &rules) {
return Err(SwitchBlocked::Failed(format!(
"going online-only would delete what is only on this computer: {reason}"
)));
}
let root_uid = parse_uid(&folder.remote_uid).ok_or_else(|| {
SwitchBlocked::Failed(format!("bad remote uid: {}", folder.remote_uid))
})?;
let root = self
.rt
.block_on(self.client.enumerate_nodes(std::slice::from_ref(&root_uid)))
.block_on(self.drive.enumerate_nodes(std::slice::from_ref(&root_uid)))
.map_err(|e| SwitchBlocked::Failed(format!("fetch remote root: {e}")))?
.into_iter()
.next()
@@ -894,8 +1086,52 @@ impl Core {
}
}
/// Spawn a secondary FUSE session rooted at `root` over `local` on a forked
/// inode space. Clears any stale kernel mount first (a crashed run can leave
/// Forget what a mirror folder synced before, so its next pass compares the
/// two sides as on the first sync: what one side has alone is copied to the
/// other, and a file that differs is kept twice as a conflict. Nothing is
/// deleted on either side.
///
/// This is the way out for a folder whose passes refuse to run because its
/// whole local copy, or its whole copy on Drive, is gone (#46). The baseline
/// is what makes that read as "delete everything", and it is what this
/// drops. A queued switch to on-demand is dropped with it: the push pass
/// that switch runs reads an unrecorded local file as new and would upload
/// a second copy of every file Drive already has.
pub(crate) fn reset_sync_folder(&self, id: i64) -> CoreResult<String> {
let lock = self.sync_lock(id);
let Some(_guard) = lock.try_lock() else {
return Err(CoreError::conflict(
"this folder is syncing; try again when the pass ends",
));
};
let folder = self
.db
.sync_folder_get(id)
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
.ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?;
if folder.mode != "mirror" {
return Err(CoreError::invalid(
"an online-only folder keeps nothing on this computer to sync from scratch",
));
}
self.db
.sync_entries_clear(id)
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
if folder.pending_mode.as_deref() == Some("ondemand") {
self.db
.sync_folder_set_pending_mode(id, None)
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
}
let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id));
info!(id, path = %folder.local_path, "sync folder reset");
Ok(format!(
"{} will sync from scratch",
base_name(&folder.local_path)
))
}
/// Spawn a secondary FUSE session rooted at `root` over `local` from a
/// [`Core::fork_state`] sibling. Clears any stale kernel mount first (a crashed run can leave
/// one, which would fail the fresh mount with EBUSY).
pub(crate) fn spawn_ondemand_mount(
&self,
@@ -953,8 +1189,7 @@ impl Core {
/// restart (their local dirs are empty on disk — the files live in the cloud).
/// Best-effort per folder: a missing local path or a failed remote fetch marks
/// the folder `error` and moves on rather than aborting the rest. Runs on its
/// own thread from `mount` so the network fetches never block startup
/// (devices.md Phase 4).
/// own thread from `mount` so the network fetches never block startup.
pub(crate) fn restore_ondemand_mounts(&self) {
let folders = match self.db.sync_folder_list() {
Ok(f) => f,
@@ -974,7 +1209,7 @@ impl Core {
};
let root = match self
.rt
.block_on(self.client.enumerate_nodes(std::slice::from_ref(&root_uid)))
.block_on(self.drive.enumerate_nodes(std::slice::from_ref(&root_uid)))
{
Ok(v) => match v.into_iter().next() {
Some(n) => n,
@@ -1076,10 +1311,38 @@ mod tests {
use pdfs_core::db::StoredSyncFolder;
use super::{
ApplicableMountMode, commit_after_teardown, pending_intent_matches,
ApplicableMountMode, commit_after_teardown, migrated_name, pending_intent_matches,
restore_snapshot_matches, select_mode_transition,
};
#[test]
fn migrated_folder_keeps_its_name_when_it_is_free() {
let taken = vec!["Pictures".to_string()];
assert_eq!(migrated_name("Documents", "DESKTOP-1", &taken), None);
}
#[test]
fn migrated_folder_is_named_after_its_device_on_a_clash() {
let taken = vec!["Documents".to_string()];
assert_eq!(
migrated_name("Documents", "DESKTOP-1", &taken).as_deref(),
Some("Documents (DESKTOP-1)")
);
}
#[test]
fn migrated_folder_is_numbered_when_the_device_name_is_taken_too() {
let taken = vec![
"Documents".to_string(),
"Documents (DESKTOP-1)".to_string(),
"Documents (DESKTOP-1) 2".to_string(),
];
assert_eq!(
migrated_name("Documents", "DESKTOP-1", &taken).as_deref(),
Some("Documents (DESKTOP-1) 3")
);
}
#[test]
fn unknown_mode_cannot_select_a_destructive_transition() {
let mut unmounts = 0;
+255
View File
@@ -0,0 +1,255 @@
//! 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())
};
let name = name.unwrap_or("unknown");
// A bare `Refresh` says nothing about which view wedged, and the three
// scopes cost wildly different amounts of work: a directory refresh is two
// cheap calls, a photos refresh can pull a whole library. The scope is one
// more peek into the same line.
if name == "Refresh"
&& let Some(scope) = tagged_name_after(line, "\"scope\"")
{
return format!("Refresh({scope})");
}
name.to_string()
}
/// The variant name serde wrote for the value of `key`, whether that value is a
/// bare string (a unit variant) or an object whose single key is the name.
fn tagged_name_after(line: &str, key: &str) -> Option<String> {
let rest = line.split_once(key)?.1.trim_start();
let rest = rest.strip_prefix(':')?.trim_start();
let rest = rest.strip_prefix('{').map_or(rest, str::trim_start);
rest.strip_prefix('"')?
.split('"')
.next()
.map(str::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(),
sync: Some(self.sync_engine.report()),
}
}
/// 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 stuck `Refresh` is only actionable if the report says which view it
/// was refreshing, so the scope rides along with the name.
#[test]
fn a_refresh_reports_its_scope() {
assert_eq!(
request_kind("{\"Refresh\":{\"scope\":{\"Photos\":{\"full\":true}}}}"),
"Refresh(Photos)"
);
assert_eq!(
request_kind("{\"Refresh\":{\"scope\":{\"Dir\":{\"path\":\"a\"}}}}"),
"Refresh(Dir)"
);
assert_eq!(
request_kind("{\"Refresh\":{\"scope\":\"Trash\"}}"),
"Refresh(Trash)"
);
// A shape this does not recognise still names the request.
assert_eq!(request_kind("{\"Refresh\":{}}"), "Refresh");
}
/// 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
+524
View File
@@ -0,0 +1,524 @@
//! The calls the sync core makes to Drive, behind a trait.
//!
//! `Core` used to hold a concrete [`ProtonDriveClient`], so no test could make
//! Drive answer late, answer stale, or not answer at all, and the interleavings
//! behind most recent bugs could only be found on a real account over a slow
//! link. [`DriveApi`] is the part of the
//! client the mount, the drain, the event feed and the mirror engine use; the
//! real client implements it by forwarding, and the simulation tests implement
//! it with an in-memory Drive that injects Drive's known faults.
//!
//! Sharing, Photos, invitations, public links and the rest of the device calls
//! stay on the concrete client (`Core::client`) until a test needs them faked.
//! Listing and registering devices are here, so a simulated daemon can sync a
//! local folder.
//!
//! The method names match the client's own, so a call site reads the same
//! whichever it goes through. The client's generic methods (a reader or writer
//! of any type) cannot be part of an object-safe trait, so the trait takes them
//! as trait objects and `impl dyn DriveApi` puts the generic signatures back.
use std::io::{Read, Write};
use std::sync::Arc;
use async_trait::async_trait;
use futures::StreamExt as _;
use futures::stream::BoxStream;
use proton_drive_rs::proton_sdk::account::Quota;
use proton_drive_rs::proton_sdk::error::Result;
use proton_drive_rs::proton_sdk::ids::{DriveEventId, NodeUid};
use proton_drive_rs::{
AvailableNameTarget, Device, DeviceType, DriveEvent, DriveEventScopeId, Node, NodeMoveItem,
ProtonDriveClient, RevisionReader, Thumbnail,
};
/// One outcome per node of a batch request; see [`ProtonDriveClient::trash_nodes`].
pub(crate) type NodeOutcomes = Vec<(NodeUid, Result<()>)>;
/// The outcomes of a streaming batch request, one item per node as it lands.
pub(crate) type OutcomeStream<'a> = BoxStream<'a, Result<(NodeUid, Result<()>)>>;
/// Drive as the sync core sees it. See the module documentation.
#[async_trait]
pub(crate) trait DriveApi: Send + Sync {
async fn get_my_files_folder(&self) -> Result<Node>;
async fn get_node(&self, uid: &NodeUid) -> Result<Option<Node>>;
async fn enumerate_nodes(&self, uids: &[NodeUid]) -> Result<Vec<Node>>;
/// As [`DriveApi::enumerate_nodes`], but files carry no claimed size or
/// modification time; see [`ProtonDriveClient::enumerate_nodes_light`].
async fn enumerate_nodes_light(&self, uids: &[NodeUid]) -> Result<Vec<Node>>;
async fn enumerate_folder_children_node_uids(
&self,
folder_uid: &NodeUid,
) -> Result<Vec<NodeUid>>;
async fn enumerate_events(
&self,
scope: &DriveEventScopeId,
cursor: Option<&DriveEventId>,
) -> Result<Vec<DriveEvent>>;
async fn invalidate_caches_for_event(&self, event: &DriveEvent) -> Result<()>;
async fn create_folder(
&self,
parent_uid: &NodeUid,
name: &str,
last_modification_time: Option<i64>,
) -> Result<NodeUid>;
async fn upload_file(
&self,
parent_uid: &NodeUid,
name: &str,
media_type: &str,
contents: &[u8],
) -> Result<NodeUid>;
#[allow(clippy::too_many_arguments)]
async fn upload_file_from_dyn(
&self,
parent_uid: &NodeUid,
name: &str,
media_type: &str,
reader: &mut (dyn Read + Send),
intended_size: i64,
thumbnails: Vec<Thumbnail>,
last_modification_time: Option<i64>,
aead: bool,
) -> Result<NodeUid>;
#[allow(clippy::too_many_arguments)]
async fn upload_file_replacing_draft_from_dyn(
&self,
parent_uid: &NodeUid,
name: &str,
media_type: &str,
reader: &mut (dyn Read + Send),
intended_size: i64,
thumbnails: Vec<Thumbnail>,
last_modification_time: Option<i64>,
aead: bool,
) -> Result<NodeUid>;
async fn upload_new_revision_from_dyn(
&self,
file_uid: &NodeUid,
reader: &mut (dyn Read + Send),
intended_size: i64,
thumbnails: Vec<Thumbnail>,
last_modification_time: Option<i64>,
) -> Result<()>;
async fn open_revision(&self, uid: &NodeUid) -> Result<Arc<dyn RevisionRead>>;
async fn download_file_to_dyn(
&self,
uid: &NodeUid,
output: &mut (dyn Write + Send),
) -> Result<()>;
async fn download_revision_to_dyn(
&self,
file_uid: &NodeUid,
revision_id: &str,
writer: &mut (dyn Write + Send),
) -> Result<()>;
async fn rename_node(
&self,
uid: &NodeUid,
new_name: &str,
new_media_type: Option<&str>,
) -> Result<()>;
async fn move_node(&self, uid: &NodeUid, new_parent: &NodeUid) -> Result<()>;
/// See [`ProtonDriveClient::copy_node`].
async fn copy_node(
&self,
uid: &NodeUid,
new_parent: &NodeUid,
name: Option<&str>,
) -> Result<NodeUid>;
/// See [`ProtonDriveClient::get_available_name`].
async fn get_available_name(
&self,
parent_uid: &NodeUid,
name: &str,
target: AvailableNameTarget,
) -> Result<String>;
fn move_nodes_streaming<'a>(
&'a self,
items: Vec<NodeMoveItem>,
new_parent: NodeUid,
) -> OutcomeStream<'a>;
async fn trash_nodes(&self, uids: &[NodeUid]) -> Result<NodeOutcomes>;
async fn restore_nodes(&self, uids: &[NodeUid]) -> Result<NodeOutcomes>;
fn restore_nodes_streaming<'a>(&'a self, uids: &[NodeUid]) -> OutcomeStream<'a>;
fn delete_nodes_streaming<'a>(&'a self, uids: &[NodeUid]) -> OutcomeStream<'a>;
async fn enumerate_trash_node_uids(&self) -> Result<Vec<NodeUid>>;
/// The account's storage, across every Proton product.
async fn quota(&self) -> Result<Quota>;
/// Every computer that backs up to the account.
async fn enumerate_devices(&self) -> Result<Vec<Device>>;
/// Register a computer, with a root folder of its own named `name`.
async fn create_device(&self, name: &str, device_type: DeviceType) -> Result<Device>;
/// Called by the drain once it has let go of the blob of an upload to
/// `uid` that landed, or retired the create that made `uid`, before the
/// tree has the node, so a simulation can hold it there. Blocks the drain
/// worker it is called on.
#[cfg(test)]
fn landing(&self, _uid: &NodeUid) {}
}
/// The generic signatures of the client, over the object-safe ones above.
impl dyn DriveApi {
#[allow(clippy::too_many_arguments)]
pub(crate) async fn upload_file_from<R: Read + Send>(
&self,
parent_uid: &NodeUid,
name: &str,
media_type: &str,
mut reader: R,
intended_size: i64,
thumbnails: Vec<Thumbnail>,
last_modification_time: Option<i64>,
aead: bool,
) -> Result<NodeUid> {
self.upload_file_from_dyn(
parent_uid,
name,
media_type,
&mut reader,
intended_size,
thumbnails,
last_modification_time,
aead,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn upload_file_replacing_draft_from<R: Read + Send>(
&self,
parent_uid: &NodeUid,
name: &str,
media_type: &str,
mut reader: R,
intended_size: i64,
thumbnails: Vec<Thumbnail>,
last_modification_time: Option<i64>,
aead: bool,
) -> Result<NodeUid> {
self.upload_file_replacing_draft_from_dyn(
parent_uid,
name,
media_type,
&mut reader,
intended_size,
thumbnails,
last_modification_time,
aead,
)
.await
}
pub(crate) async fn upload_new_revision_from<R: Read + Send>(
&self,
file_uid: &NodeUid,
mut reader: R,
intended_size: i64,
thumbnails: Vec<Thumbnail>,
last_modification_time: Option<i64>,
) -> Result<()> {
self.upload_new_revision_from_dyn(
file_uid,
&mut reader,
intended_size,
thumbnails,
last_modification_time,
)
.await
}
pub(crate) async fn download_file_to<W: Write + Send>(
&self,
uid: &NodeUid,
output: &mut W,
) -> Result<()> {
self.download_file_to_dyn(uid, output).await
}
pub(crate) async fn download_revision_to<W: Write + Send>(
&self,
file_uid: &NodeUid,
revision_id: &str,
writer: &mut W,
) -> Result<()> {
self.download_revision_to_dyn(file_uid, revision_id, writer)
.await
}
}
/// An open revision that block reads are served from; see [`RevisionReader`].
#[async_trait]
pub(crate) trait RevisionRead: Send + Sync {
/// Plaintext size of the revision.
fn size(&self) -> u64;
/// Plaintext size of each block, in block order.
fn block_sizes(&self) -> &[u64];
async fn read_at(&self, offset: u64, length: u64) -> Result<Vec<u8>>;
}
#[async_trait]
impl RevisionRead for RevisionReader {
fn size(&self) -> u64 {
RevisionReader::size(self)
}
fn block_sizes(&self) -> &[u64] {
RevisionReader::block_sizes(self)
}
async fn read_at(&self, offset: u64, length: u64) -> Result<Vec<u8>> {
RevisionReader::read_at(self, offset, length).await
}
}
#[async_trait]
impl DriveApi for ProtonDriveClient {
async fn get_my_files_folder(&self) -> Result<Node> {
ProtonDriveClient::get_my_files_folder(self).await
}
async fn get_node(&self, uid: &NodeUid) -> Result<Option<Node>> {
ProtonDriveClient::get_node(self, uid).await
}
async fn enumerate_nodes(&self, uids: &[NodeUid]) -> Result<Vec<Node>> {
ProtonDriveClient::enumerate_nodes(self, uids).await
}
async fn enumerate_nodes_light(&self, uids: &[NodeUid]) -> Result<Vec<Node>> {
ProtonDriveClient::enumerate_nodes_light(self, uids).await
}
async fn enumerate_folder_children_node_uids(
&self,
folder_uid: &NodeUid,
) -> Result<Vec<NodeUid>> {
ProtonDriveClient::enumerate_folder_children_node_uids(self, folder_uid).await
}
async fn enumerate_events(
&self,
scope: &DriveEventScopeId,
cursor: Option<&DriveEventId>,
) -> Result<Vec<DriveEvent>> {
ProtonDriveClient::enumerate_events(self, scope, cursor).await
}
async fn invalidate_caches_for_event(&self, event: &DriveEvent) -> Result<()> {
ProtonDriveClient::invalidate_caches_for_event(self, event).await
}
async fn create_folder(
&self,
parent_uid: &NodeUid,
name: &str,
last_modification_time: Option<i64>,
) -> Result<NodeUid> {
ProtonDriveClient::create_folder(self, parent_uid, name, last_modification_time).await
}
async fn upload_file(
&self,
parent_uid: &NodeUid,
name: &str,
media_type: &str,
contents: &[u8],
) -> Result<NodeUid> {
ProtonDriveClient::upload_file(self, parent_uid, name, media_type, contents).await
}
async fn upload_file_from_dyn(
&self,
parent_uid: &NodeUid,
name: &str,
media_type: &str,
reader: &mut (dyn Read + Send),
intended_size: i64,
thumbnails: Vec<Thumbnail>,
last_modification_time: Option<i64>,
aead: bool,
) -> Result<NodeUid> {
ProtonDriveClient::upload_file_from(
self,
parent_uid,
name,
media_type,
reader,
intended_size,
thumbnails,
last_modification_time,
aead,
)
.await
}
async fn upload_file_replacing_draft_from_dyn(
&self,
parent_uid: &NodeUid,
name: &str,
media_type: &str,
reader: &mut (dyn Read + Send),
intended_size: i64,
thumbnails: Vec<Thumbnail>,
last_modification_time: Option<i64>,
aead: bool,
) -> Result<NodeUid> {
ProtonDriveClient::upload_file_replacing_draft_from(
self,
parent_uid,
name,
media_type,
reader,
intended_size,
thumbnails,
last_modification_time,
aead,
)
.await
}
async fn upload_new_revision_from_dyn(
&self,
file_uid: &NodeUid,
reader: &mut (dyn Read + Send),
intended_size: i64,
thumbnails: Vec<Thumbnail>,
last_modification_time: Option<i64>,
) -> Result<()> {
ProtonDriveClient::upload_new_revision_from(
self,
file_uid,
reader,
intended_size,
thumbnails,
last_modification_time,
)
.await
}
async fn open_revision(&self, uid: &NodeUid) -> Result<Arc<dyn RevisionRead>> {
let reader = ProtonDriveClient::open_revision(self, uid).await?;
Ok(Arc::new(reader))
}
async fn download_file_to_dyn(
&self,
uid: &NodeUid,
output: &mut (dyn Write + Send),
) -> Result<()> {
ProtonDriveClient::download_file_to(self, uid, &mut &mut *output).await
}
async fn download_revision_to_dyn(
&self,
file_uid: &NodeUid,
revision_id: &str,
writer: &mut (dyn Write + Send),
) -> Result<()> {
ProtonDriveClient::download_revision_to(self, file_uid, revision_id, &mut &mut *writer)
.await
}
async fn rename_node(
&self,
uid: &NodeUid,
new_name: &str,
new_media_type: Option<&str>,
) -> Result<()> {
ProtonDriveClient::rename_node(self, uid, new_name, new_media_type).await
}
async fn move_node(&self, uid: &NodeUid, new_parent: &NodeUid) -> Result<()> {
ProtonDriveClient::move_node(self, uid, new_parent).await
}
async fn copy_node(
&self,
uid: &NodeUid,
new_parent: &NodeUid,
name: Option<&str>,
) -> Result<NodeUid> {
ProtonDriveClient::copy_node(self, uid, new_parent, name).await
}
async fn get_available_name(
&self,
parent_uid: &NodeUid,
name: &str,
target: AvailableNameTarget,
) -> Result<String> {
ProtonDriveClient::get_available_name(self, parent_uid, name, target).await
}
fn move_nodes_streaming<'a>(
&'a self,
items: Vec<NodeMoveItem>,
new_parent: NodeUid,
) -> OutcomeStream<'a> {
ProtonDriveClient::move_nodes_streaming(self, items, new_parent).boxed()
}
async fn trash_nodes(&self, uids: &[NodeUid]) -> Result<NodeOutcomes> {
ProtonDriveClient::trash_nodes(self, uids).await
}
async fn restore_nodes(&self, uids: &[NodeUid]) -> Result<NodeOutcomes> {
ProtonDriveClient::restore_nodes(self, uids).await
}
fn restore_nodes_streaming<'a>(&'a self, uids: &[NodeUid]) -> OutcomeStream<'a> {
ProtonDriveClient::restore_nodes_streaming(self, uids).boxed()
}
fn delete_nodes_streaming<'a>(&'a self, uids: &[NodeUid]) -> OutcomeStream<'a> {
ProtonDriveClient::delete_nodes_streaming(self, uids).boxed()
}
async fn enumerate_trash_node_uids(&self) -> Result<Vec<NodeUid>> {
ProtonDriveClient::enumerate_trash_node_uids(self).await
}
async fn quota(&self) -> Result<Quota> {
ProtonDriveClient::quota(self).await
}
async fn enumerate_devices(&self) -> Result<Vec<Device>> {
ProtonDriveClient::enumerate_devices(self).await
}
async fn create_device(&self, name: &str, device_type: DeviceType) -> Result<Device> {
ProtonDriveClient::create_device(self, name, device_type).await
}
}
+338
View File
@@ -0,0 +1,338 @@
//! Push notifications for front-ends: the daemon's side of
//! [`Request::Subscribe`](pdfs_core::control::Request::Subscribe).
//!
//! Front-ends used to poll. The app asked for the status every two seconds, the
//! tray every three, and pages re-read themselves on a timer, whether or not
//! anything had changed. Now each subscription owns a [`Mailbox`] in the
//! [`EventHub`]. Code that changes something publishes a [`Topic`], and
//! [`run_event_sampler`] watches the few things that change without passing
//! through one place (the status, transfers, sync progress) and publishes them
//! when they differ.
use std::collections::{BTreeSet, HashMap};
use std::sync::Arc;
use std::time::Duration;
use parking_lot::{Condvar, Mutex, MutexGuard};
use pdfs_core::control::{Event, JobItem, Response as CtlResponse, Topic, TransferItem};
use tracing::warn;
use super::Core;
/// Most subscriptions served at once. Each holds a control handler for as long
/// as it lasts; the app and the tray need one apiece, and the cap keeps a
/// misbehaving client from spending the handlers that requests need.
const MAX_SUBSCRIBERS: usize = 8;
/// How long a subscription waits after a change before it sends, so a burst
/// (a folder of files trashed at once) goes out as one event.
const COALESCE: Duration = Duration::from_millis(150);
/// How often [`run_event_sampler`] looks at transfers. Status and sync
/// progress are looked at every other time.
const SAMPLE_INTERVAL: Duration = Duration::from_millis(500);
/// Every open subscription's mailbox. Shared by every clone of [`Core`],
/// on-demand forks included, so a change in any mount reaches every client.
#[derive(Default)]
pub(crate) struct EventHub {
mailboxes: Mutex<Vec<Arc<Mailbox>>>,
}
/// What one subscription has yet to send.
#[derive(Default)]
pub(crate) struct Mailbox {
inbox: Mutex<Inbox>,
ready: Condvar,
}
#[derive(Default)]
struct Inbox {
topics: BTreeSet<Topic>,
/// The newest [`Event::Status`] line. A newer one replaces it unsent: a
/// client wants the current status, not every status in between.
status: Option<Arc<str>>,
/// The newest [`Event::Transfers`] line, replaced the same way.
transfers: Option<Arc<str>>,
}
impl Inbox {
fn is_empty(&self) -> bool {
self.topics.is_empty() && self.status.is_none() && self.transfers.is_none()
}
}
/// `event` as one line of the wire format, newline included.
pub(crate) fn event_line(event: &Event) -> Option<Arc<str>> {
match serde_json::to_string(event) {
Ok(mut line) => {
line.push('\n');
Some(line.into())
}
Err(e) => {
warn!(error = %e, "events: serialize event failed");
None
}
}
}
impl EventHub {
/// Tell every subscriber that `topics` changed.
pub(crate) fn publish(&self, topics: &[Topic]) {
self.deliver(|inbox| inbox.topics.extend(topics.iter().copied()));
}
/// Hand every subscriber a fresh status.
fn publish_status(&self, line: Arc<str>) {
self.deliver(|inbox| inbox.status = Some(line.clone()));
}
/// Hand every subscriber a fresh transfers snapshot.
fn publish_transfers(&self, line: Arc<str>) {
self.deliver(|inbox| inbox.transfers = Some(line.clone()));
}
fn deliver(&self, put: impl Fn(&mut Inbox)) {
for mailbox in self.mailboxes.lock().iter() {
put(&mut mailbox.inbox.lock());
mailbox.ready.notify_one();
}
}
/// Open a mailbox, or `None` at [`MAX_SUBSCRIBERS`].
pub(crate) fn subscribe(&self) -> Option<Arc<Mailbox>> {
let mut mailboxes = self.mailboxes.lock();
if mailboxes.len() >= MAX_SUBSCRIBERS {
return None;
}
let mailbox = Arc::new(Mailbox::default());
mailboxes.push(mailbox.clone());
Some(mailbox)
}
pub(crate) fn unsubscribe(&self, mailbox: &Arc<Mailbox>) {
self.mailboxes
.lock()
.retain(|other| !Arc::ptr_eq(other, mailbox));
}
fn has_subscribers(&self) -> bool {
!self.mailboxes.lock().is_empty()
}
}
impl Mailbox {
/// Wait up to `timeout` for something to send, then take it as wire lines:
/// the status first, then transfers, then the changed topics. Empty when
/// nothing arrived in time.
pub(crate) fn take(&self, timeout: Duration) -> Vec<Arc<str>> {
let mut inbox = self.inbox.lock();
if inbox.is_empty() {
self.ready.wait_for(&mut inbox, timeout);
if inbox.is_empty() {
return Vec::new();
}
// Let the rest of a burst arrive before sending the first of it.
MutexGuard::unlocked(&mut inbox, || std::thread::sleep(COALESCE));
}
let mut lines = Vec::new();
lines.extend(inbox.status.take());
lines.extend(inbox.transfers.take());
if !inbox.topics.is_empty() {
let topics = std::mem::take(&mut inbox.topics).into_iter().collect();
lines.extend(event_line(&Event::Changed { topics }));
}
lines
}
}
/// The [`Event::Transfers`] line for a snapshot.
pub(crate) fn transfers_line(items: Vec<TransferItem>, jobs: Vec<JobItem>) -> Option<Arc<str>> {
event_line(&Event::Transfers { items, jobs })
}
/// Watch what changes without a single place to publish it from, and publish it
/// when it differs from the last look: the status (queue counts, cache use,
/// online, pause), the transfers snapshot, the synced folders with their
/// live progress, and which nodes have a sync issue. Looks only while someone
/// is subscribed.
pub(crate) fn run_event_sampler(core: Core, username: String, mountpoint: std::path::PathBuf) {
let mut transfers: Option<Arc<str>> = None;
let mut status: Option<String> = None;
let mut queue: Option<String> = None;
let mut locations: Option<String> = None;
let mut issues: Option<HashMap<i64, String>> = None;
let mut tick = 0u32;
while core.shutdown.sleep(SAMPLE_INTERVAL) {
if !core.events.has_subscribers() {
// A new subscription starts from a snapshot of its own, so there is
// nothing to compare against until then.
transfers = None;
status = None;
queue = None;
locations = None;
issues = None;
continue;
}
let line = transfers_line(core.transfers.snapshot(), core.jobs_snapshot());
if line.is_some() && line != transfers {
transfers = line.clone();
if let Some(line) = line {
core.events.publish_transfers(line);
}
}
tick = tick.wrapping_add(1);
if !tick.is_multiple_of(2) {
continue;
}
let now = super::control::status_response(&core, &username, &mountpoint);
let (key, queue_key) = status_keys(&now);
if status.as_ref().is_some_and(|last| *last != key)
&& let Some(line) = event_line(&Event::Status(Box::new(now)))
{
core.events.publish_status(line);
}
status = Some(key);
if queue.as_ref().is_some_and(|last| *last != queue_key) {
core.events.publish(&[Topic::Queue]);
}
queue = Some(queue_key);
// An issue shows on its node in the file browser: raised, cleared,
// landed or discarded, the listing is out of date.
if let Ok(now) = core.db.node_issues() {
if issues.as_ref().is_some_and(|last| *last != now) {
core.events.publish(&[Topic::Files]);
}
issues = Some(now);
}
if let Ok(now) = core.list_locations() {
let key = serde_json::to_string(&now).unwrap_or_default();
if locations.as_ref().is_some_and(|last| *last != key) {
core.events.publish(&[Topic::Locations]);
}
locations = Some(key);
}
}
}
/// What makes two statuses different enough to send, and what makes their
/// queues different. The age of the oldest staged write is left out of both:
/// it grows every second while anything is staged, which is not news.
fn status_keys(status: &CtlResponse) -> (String, String) {
let CtlResponse::Status {
parked_uploads,
failing_ops,
failing_error,
staged_bytes,
paused,
paused_until,
username,
mountpoint,
pinned,
used,
budget,
online,
session_expired,
tokens_unsaved,
pending_uploads,
pending_changes,
..
} = status
else {
return (String::new(), String::new());
};
let queue = format!(
"{pending_uploads}/{pending_changes}/{parked_uploads}/{failing_ops}/{failing_error:?}"
);
let key = format!(
"{queue}/{staged_bytes}/{paused}/{paused_until:?}/{username}/{mountpoint}/{pinned}/\
{used}/{budget}/{online}/{session_expired}/{tokens_unsaved}"
);
(key, queue)
}
#[cfg(test)]
mod tests {
use super::*;
fn changed(line: &str) -> Vec<Topic> {
match serde_json::from_str::<Event>(line.trim()) {
Ok(Event::Changed { topics }) => topics,
other => panic!("not a change event: {other:?}"),
}
}
#[test]
fn a_burst_of_changes_arrives_as_one_event() {
let hub = EventHub::default();
let mailbox = hub.subscribe().expect("room for a subscriber");
hub.publish(&[Topic::Trash, Topic::Files]);
hub.publish(&[Topic::Files]);
let lines = mailbox.take(Duration::from_secs(1));
assert_eq!(lines.len(), 1);
assert_eq!(changed(&lines[0]), vec![Topic::Files, Topic::Trash]);
assert!(mailbox.take(Duration::from_millis(10)).is_empty());
}
#[test]
fn a_newer_status_replaces_one_not_yet_sent() {
let hub = EventHub::default();
let mailbox = hub.subscribe().expect("room for a subscriber");
hub.publish_status("first\n".into());
hub.publish_status("second\n".into());
assert_eq!(
mailbox.take(Duration::from_secs(1)),
vec![Arc::<str>::from("second\n")]
);
}
#[test]
fn a_closed_subscription_gets_nothing_more() {
let hub = EventHub::default();
let mailbox = hub.subscribe().expect("room for a subscriber");
hub.unsubscribe(&mailbox);
hub.publish(&[Topic::Files]);
assert!(mailbox.take(Duration::from_millis(10)).is_empty());
assert!(!hub.has_subscribers());
}
#[test]
fn subscriptions_are_capped() {
let hub = EventHub::default();
let open: Vec<_> = (0..MAX_SUBSCRIBERS)
.map(|_| hub.subscribe().expect("room for a subscriber"))
.collect();
assert!(hub.subscribe().is_none());
hub.unsubscribe(&open[0]);
assert!(hub.subscribe().is_some());
}
#[test]
fn the_age_of_a_staged_write_is_not_a_change() {
let status = |staged_oldest_secs| CtlResponse::Status {
parked_uploads: 0,
failing_ops: 0,
failing_error: None,
staged_bytes: 10,
staged_oldest_secs,
paused: false,
paused_until: None,
username: "u".into(),
mountpoint: "/m".into(),
pinned: 0,
used: 0,
budget: 0,
pins: Vec::new(),
online: true,
session_expired: false,
tokens_unsaved: false,
version: "3.3.0".into(),
pending_uploads: 1,
pending_changes: 0,
};
assert_eq!(status_keys(&status(5)), status_keys(&status(6)));
}
}
File diff suppressed because it is too large. Load diff
+3460 -954
View File
File diff suppressed because it is too large. Load diff
+477
View File
@@ -0,0 +1,477 @@
//! Noticing that the network went away mid-session, and that it came back.
//!
//! The mount used to learn it was offline only at startup: a daemon that had
//! connected once kept `online` true for the rest of its life, so a Wi-Fi drop
//! turned every create, mkdir, delete and rename into a two-minute wait for an
//! `EIO`, while the queue that exists for exactly this sat unused. Now any
//! remote call a handler waits on reports a failure to reach Proton through
//! [`Core::lost_link`], which flips the mount offline; the handler then takes its
//! queued path, and the probe thread here flips it back when Proton answers.
use std::future::Future;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use parking_lot::{Condvar, Mutex};
use pdfs_core::error::session_revoked;
use proton_drive_rs::proton_sdk::api::ResponseCode;
use proton_drive_rs::proton_sdk::error::{ProtonApiError, ProtonError};
use tracing::{debug, info, warn};
use crate::{Core, ONLINE_PROBE_MAX, ONLINE_PROBE_MIN};
/// How long a remote call a FUSE handler is waiting on may take before the link
/// is treated as down.
///
/// The SDK's own budget for one request is four attempts of up to 30 seconds
/// each, so a dead link held the caller for about two minutes. A metadata call
/// that has had no answer in 20 seconds is not going to be one the user wants to
/// keep waiting for; queueing the change is the better answer.
pub(crate) const INTERACTIVE_CALL_TIMEOUT: Duration = Duration::from_secs(20);
/// How long a mount thread waits on a remote read that answers a batch: a
/// chunk of file sizes, or the list of shares. Longer than an interactive call,
/// since its answer grows with the batch, but well short of the SDK's own retry
/// budget, which held a worker for minutes on a stalled link
/// (`docs/BUGS.md` B220).
pub(crate) const BATCH_CALL_TIMEOUT: Duration = Duration::from_secs(60);
/// How long one probe may wait for an answer. A probe is a single small request,
/// and a probe stuck in the SDK's retry budget would delay noticing the link is
/// back by as long as it waits.
const PROBE_TIMEOUT: Duration = Duration::from_secs(10);
/// First delay between probes after a drop seen mid-session, and the ceiling it
/// doubles to while the outage is young. A blip should end in seconds, not in
/// the minutes [`ONLINE_PROBE_MAX`] allows.
const PROBE_AFTER_DROP: Duration = Duration::from_secs(2);
const PROBE_YOUNG_MAX: Duration = Duration::from_secs(30);
/// How long an outage counts as young. Past it the probe backs off to
/// [`ONLINE_PROBE_MAX`], because a laptop can sit offline for days and each
/// probe is a real API round trip.
const YOUNG_OUTAGE: Duration = Duration::from_secs(10 * 60);
/// How often the idle probe thread checks for teardown while the link is up.
const IDLE_CHECK: Duration = Duration::from_secs(1);
/// The shared half of the online flag: when the link went down, and a condvar
/// that fires on every change so waiters need not poll.
#[derive(Default)]
pub(crate) struct Link {
lost_at: Mutex<Option<Instant>>,
changed: Condvar,
/// Folders listed from the DB alone while offline, to relist once back.
pub(crate) stale_listings: Mutex<std::collections::HashSet<u64>>,
/// Proton refused the session itself ([`session_revoked`]). The mount is
/// offline then, but no probe brings it back: only a new login does, and
/// that restarts the daemon. Read by front-ends through `Response::Status`,
/// so they can ask the user to sign in again rather than say "offline".
pub(crate) session_expired: AtomicBool,
/// Proton refused the session, but only because another process rotated
/// it (`pdfs unlock`): the keyring holds the live tokens. The mount loop
/// ends the mount for the daemon to mount again from the keyring.
pub(crate) resume_stored: AtomicBool,
}
impl Link {
/// The link of a mount that comes up with Proton refusing its session, or
/// not.
pub(crate) fn starting(session_expired: bool) -> Self {
Self {
session_expired: AtomicBool::new(session_expired),
..Default::default()
}
}
}
/// Whether a failed call failed because Proton could not be reached, rather
/// than because it answered and refused.
///
/// A transport error is the network. So is a request timeout: the SDK reports
/// Proton's own "try again" codes that way, and [`no_answer`] reports our
/// deadline that way. Anything else Proton actually said, and says again on a
/// retry, so it must reach the caller rather than be queued.
pub(crate) fn is_network_error(e: &ProtonError) -> bool {
match e {
ProtonError::Transport(_) => true,
ProtonError::Api(api) => {
matches!(
api.code,
ResponseCode::RequestTimeout | ResponseCode::Timeout
)
}
_ => false,
}
}
/// The error a call reports when it runs past `after` without an answer.
pub(crate) fn no_answer(after: Duration) -> ProtonError {
ProtonError::Api(ProtonApiError {
code: ResponseCode::RequestTimeout,
http_status: 408,
message: format!("no answer within {}s", after.as_secs()),
details: None,
})
}
/// The slowest upload rate an upload's deadline allows for, past the
/// metadata budget it starts with. Far below any link that works, so only a
/// transfer that has stopped moving runs into it.
const SLOWEST_UPLOAD: u64 = 32 * 1024;
/// How many uploads can share the upload cap at once: one per drain worker
/// and one per mirror sync task.
const UPLOAD_SLOTS: u64 = (crate::DRAIN_WORKERS + crate::sync::SYNC_CONCURRENCY) as u64;
/// How long an upload of `len` bytes may take before the link is treated as
/// down: the budget of a metadata call, plus the bytes at [`SLOWEST_UPLOAD`].
///
/// An upload cap of `cap` bytes per second (`0` for none) is shared by every
/// upload running, so each may get as little as its share of it. Below
/// [`SLOWEST_UPLOAD`] a share counts as the slowest rate instead: measured
/// against the fixed rate, a large file under a low cap ran out of time on
/// every attempt, took the mount offline each time, and started over from
/// its first byte.
pub(crate) fn upload_deadline(len: u64, cap: u64) -> Duration {
let slowest = match cap {
0 => SLOWEST_UPLOAD,
cap => SLOWEST_UPLOAD.min((cap / UPLOAD_SLOTS).max(1)),
};
INTERACTIVE_CALL_TIMEOUT + Duration::from_secs(len / slowest)
}
/// Run a remote call, giving up after [`INTERACTIVE_CALL_TIMEOUT`].
pub(crate) async fn bounded<T>(
call: impl Future<Output = Result<T, ProtonError>>,
) -> Result<T, ProtonError> {
bounded_by(INTERACTIVE_CALL_TIMEOUT, call).await
}
/// Run a remote call, giving up after `limit`.
pub(crate) async fn bounded_by<T>(
limit: Duration,
call: impl Future<Output = Result<T, ProtonError>>,
) -> Result<T, ProtonError> {
match tokio::time::timeout(limit, call).await {
Ok(result) => result,
Err(_) => Err(no_answer(limit)),
}
}
/// The next delay between probes, given the current one and how long the
/// outage has lasted.
fn next_probe_delay(delay: Duration, outage: Duration) -> Duration {
let cap = if outage < YOUNG_OUTAGE {
PROBE_YOUNG_MAX
} else {
ONLINE_PROBE_MAX
};
(delay * 2).min(cap)
}
impl Core {
/// Whether the mount currently believes Proton is reachable.
pub(crate) fn is_online(&self) -> bool {
self.online.load(Ordering::Relaxed)
}
/// Whether a namespace change through the mount goes to Drive inside the
/// syscall. Only with [`AppConfig::local_first`] off, and only online;
/// otherwise it is queued and the drain sends it.
///
/// [`AppConfig::local_first`]: pdfs_core::config::AppConfig::local_first
pub(crate) fn sends_inline(&self) -> bool {
!self.local_first && self.is_online()
}
/// Run a remote call a caller is waiting on, giving up after
/// [`INTERACTIVE_CALL_TIMEOUT`]. The timer is built inside the runtime: a
/// FUSE worker has no reactor of its own.
pub(crate) fn block_on_bounded<T>(
&self,
call: impl Future<Output = Result<T, ProtonError>>,
) -> Result<T, ProtonError> {
self.rt.block_on(bounded(call))
}
/// Run a remote call, giving up after `limit`: an upload, whose budget
/// grows with its size ([`upload_deadline`]).
pub(crate) fn block_on_within<T>(
&self,
limit: Duration,
call: impl Future<Output = Result<T, ProtonError>>,
) -> Result<T, ProtonError> {
self.rt.block_on(bounded_by(limit, call))
}
/// Whether Proton refused the session itself, so nothing reaches it until
/// the user signs in again.
pub(crate) fn session_expired(&self) -> bool {
self.link.session_expired.load(Ordering::Relaxed)
}
/// Report a failed remote call. Returns whether it failed for want of a
/// network, or of a session, in which case the mount is now offline and the
/// caller should take its queued path instead of failing.
pub(crate) fn lost_link(&self, e: &ProtonError, what: &str) -> bool {
if session_revoked(e) {
self.mark_session_expired(what, e);
return true;
}
if !is_network_error(e) {
return false;
}
self.mark_offline(what, e);
true
}
/// Flip the mount offline for good: Proton refused the session, and only a
/// new login brings it back. Changes stay queued for the daemon that login
/// starts. Only the first caller logs it.
///
/// Unless the keyring holds a newer session than the refused one: then
/// the mount ends, for the daemon to mount again on that one.
pub(crate) fn mark_session_expired(&self, what: &str, error: &dyn std::fmt::Display) {
if self.link.resume_stored.load(Ordering::Relaxed) {
return self.mark_offline(what, error);
}
if pdfs_core::auth::stored_session_is_newer() {
info!(during = what, %error,
"Proton refused a session another process has since refreshed; resuming it");
self.link.resume_stored.store(true, Ordering::Relaxed);
return self.mark_offline(what, error);
}
if !self.link.session_expired.swap(true, Ordering::Relaxed) {
warn!(during = what, %error,
"Proton refused the session; run `pdfs login` to sign in again");
}
self.mark_offline(what, error);
}
/// Flip the mount offline and wake the probe. Only the first caller of an
/// outage logs it; the rest are the same outage.
pub(crate) fn mark_offline(&self, what: &str, error: &dyn std::fmt::Display) {
let mut lost_at = self.link.lost_at.lock();
if self.online.swap(false, Ordering::Relaxed) {
*lost_at = Some(Instant::now());
warn!(during = what, %error,
"lost the connection to Proton; queueing changes until it returns");
self.link.changed.notify_all();
}
}
/// Flip the mount back online, wake everything waiting for it, and let the
/// queue drain.
fn mark_online(&self) {
{
let mut lost_at = self.link.lost_at.lock();
self.online.store(true, Ordering::Relaxed);
match lost_at.take() {
Some(since) => {
info!(
offline_secs = since.elapsed().as_secs(),
"connection to Proton is back"
)
}
None => info!("back online"),
}
self.link.changed.notify_all();
}
self.relist_offline_listings();
// Anything written while offline is queued and waiting on exactly this,
// including ops that failed on the way down and are sitting out a
// backoff sized for a server that keeps refusing. Ops Drive refused
// keep theirs: the link was not why.
if let Err(e) = self.db.retry_unrefused_ops_now(crate::now_millis()) {
warn!(error = %e, "moving backed-off ops up after a reconnect failed");
}
self.wake_drain();
}
/// Wait up to `budget` for the link to come back. Returns whether it is up.
pub(crate) fn wait_online(&self, budget: Duration) -> bool {
let deadline = Instant::now() + budget;
let mut lost_at = self.link.lost_at.lock();
while !self.is_online() {
if self.shutdown.is_stopping()
|| self
.link
.changed
.wait_until(&mut lost_at, deadline)
.timed_out()
{
return self.is_online();
}
}
true
}
/// Wait out `interval` before a background poll of Proton, and while the
/// link is down, until it is back. Returns as soon as it comes back, so the
/// poll catches up on what changed during the outage at once instead of
/// one interval later. A poll into a dead link would only wait out the
/// SDK's retries and fail.
pub(crate) async fn next_poll(&self, interval: Duration) {
let due = Instant::now() + interval;
let mut was_offline = false;
loop {
if !self.is_online() {
was_offline = true;
} else if was_offline || Instant::now() >= due {
return;
}
tokio::time::sleep(IDLE_CHECK).await;
}
}
/// Watch the link for the life of the mount: sleep while it is up, and
/// probe it back while it is down.
///
/// A mount that started from the cache is down from the first moment; one
/// that started online goes down when [`Core::mark_offline`] says so.
pub(crate) fn run_online_probe(&self) {
// Only an offline start never had a chance to repair its share id.
let mut repair_share_id = !self.is_online();
loop {
{
let mut lost_at = self.link.lost_at.lock();
while self.is_online() {
if self.shutdown.is_stopping() {
return;
}
self.link.changed.wait_for(&mut lost_at, IDLE_CHECK);
}
}
if !self.probe_until_online() {
return;
}
if std::mem::take(&mut repair_share_id) {
self.repair_primary_share_id(&self.primary_root_uid);
}
}
}
/// Probe until Proton answers, then flip the mount online. Returns `false`
/// if the daemon is stopping.
///
/// A refused session is not probed: every probe would be refused the same
/// way, forever (issue #35). The thread waits for the stop instead.
fn probe_until_online(&self) -> bool {
let started = self.link.lost_at.lock().unwrap_or_else(Instant::now);
let mut delay = if started.elapsed() < YOUNG_OUTAGE {
PROBE_AFTER_DROP
} else {
ONLINE_PROBE_MIN
};
loop {
if !self.shutdown.sleep(delay) {
return false;
}
if self.session_expired() {
delay = ONLINE_PROBE_MAX;
continue;
}
let probe = self.rt.block_on(async {
match tokio::time::timeout(PROBE_TIMEOUT, self.drive.get_my_files_folder()).await {
Ok(result) => result,
Err(_) => Err(no_answer(PROBE_TIMEOUT)),
}
});
match probe {
Ok(root) => {
{
let root_ino = self.root_ino();
let mut st = self.state();
if let Some(e) = st.entries.get_mut(&root_ino) {
e.node = root.clone();
}
}
if let Err(e) = self.db.upsert_node(&root) {
warn!(error = %e, "refresh root after reconnect failed");
}
self.mark_online();
return true;
}
Err(e) if session_revoked(&e) => self.mark_session_expired("online probe", &e),
Err(e) => {
debug!(error = %e, ?delay, "online probe failed; still offline");
delay = next_probe_delay(delay, started.elapsed());
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn api(code: ResponseCode) -> ProtonError {
ProtonError::Api(ProtonApiError {
code,
http_status: 422,
message: String::new(),
details: None,
})
}
#[test]
fn only_an_unanswered_call_counts_as_a_lost_link() {
assert!(is_network_error(&no_answer(INTERACTIVE_CALL_TIMEOUT)));
assert!(is_network_error(&api(ResponseCode::Timeout)));
assert!(!is_network_error(&api(ResponseCode::AlreadyExists)));
assert!(!is_network_error(&api(ResponseCode::InvalidRequirements)));
assert!(!is_network_error(&ProtonError::invalid_operation("bug")));
}
#[test]
fn an_upload_gets_longer_the_bigger_it_is() {
assert_eq!(upload_deadline(0, 0), INTERACTIVE_CALL_TIMEOUT);
assert_eq!(
upload_deadline(1 << 30, 0),
INTERACTIVE_CALL_TIMEOUT + Duration::from_secs(32_768)
);
}
#[test]
fn an_upload_under_a_low_cap_gets_the_time_its_share_of_the_cap_needs() {
// A generous cap leaves the deadline as it was.
assert_eq!(
upload_deadline(1 << 30, 100 << 20),
upload_deadline(1 << 30, 0)
);
// 512 KiB/s shared by every upload slot is below the slowest rate.
let share = (512 << 10) / UPLOAD_SLOTS;
assert!(share < SLOWEST_UPLOAD);
assert_eq!(
upload_deadline(1 << 30, 512 << 10),
INTERACTIVE_CALL_TIMEOUT + Duration::from_secs((1 << 30) / share)
);
// A cap below one byte per slot still gives a finite deadline.
assert!(upload_deadline(1 << 20, 1) > upload_deadline(1 << 20, 512 << 10));
}
#[test]
fn a_call_past_its_deadline_reads_as_a_lost_link() {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.unwrap();
let limit = Duration::from_millis(20);
let result: Result<(), _> = rt.block_on(bounded_by(limit, std::future::pending()));
assert!(result.is_err_and(|e| is_network_error(&e)));
}
#[test]
fn a_young_outage_is_probed_often_and_an_old_one_rarely() {
let young = Duration::from_secs(60);
let old = YOUNG_OUTAGE + Duration::from_secs(1);
assert_eq!(
next_probe_delay(PROBE_AFTER_DROP, young),
2 * PROBE_AFTER_DROP
);
assert_eq!(next_probe_delay(PROBE_YOUNG_MAX, young), PROBE_YOUNG_MAX);
assert_eq!(next_probe_delay(PROBE_YOUNG_MAX, old), 2 * PROBE_YOUNG_MAX);
assert_eq!(next_probe_delay(ONLINE_PROBE_MAX, old), ONLINE_PROBE_MAX);
}
}
+396 -110
View File
@@ -12,6 +12,10 @@ pub enum MountOutcome {
/// The kernel mount ended on its own (e.g. an external `fusermount -u`).
/// The caller may want to remount.
Unmounted,
/// Proton refused the session, and the keyring holds a newer one: another
/// process (`pdfs unlock`) refreshed it, or a new login replaced it. We
/// unmounted ourselves; the caller should mount again, which resumes it.
ResumeStored,
}
/// Whether `path` is a mountpoint whose FUSE connection is dead — the state a
@@ -193,7 +197,7 @@ pub(super) fn clear_stale_mount(mountpoint: &Path) {
}
/// `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).
/// recover it from `nodes` without the API.
const ROOT_UID_KEY: &str = "root_uid";
/// The My Files root, and whether we got it from the API (`true`) or from the
@@ -207,33 +211,63 @@ const ROOT_UID_KEY: &str = "root_uid";
/// 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,
drive: &dyn DriveApi,
rt: &tokio::runtime::Handle,
db: &Db,
) -> std::io::Result<(Node, bool)> {
let err = match rt.block_on(client.get_my_files_folder()) {
) -> std::io::Result<(Node, Start)> {
let err = match rt.block_on(drive.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));
return Ok((root, Start::Online));
}
Err(e) => e,
};
// Still mounted from the cache, so cached files stay readable and changes
// queue for the daemon the next login starts.
let start = if pdfs_core::error::session_revoked(&err) {
Start::SessionExpired
} else {
Start::Offline
};
if start == Start::SessionExpired && pdfs_core::auth::stored_session_is_newer() {
return Err(std::io::Error::other(format!(
"Proton refused a session another process has since refreshed ({err}); resuming it"
)));
}
let cached = db
.state_str(ROOT_UID_KEY)
.ok()
.flatten()
.and_then(|uid| db.node_by_uid(&uid).ok().flatten());
match cached {
Some(root) if start == Start::SessionExpired => {
warn!(error = %err,
"Proton refused the session; mounting from cache until `pdfs login`");
Ok((root, start))
}
Some(root) => {
warn!(error = %err, "fetch My Files root failed; mounting from cache (offline)");
Ok((root, false))
Ok((root, start))
}
None if start == Start::SessionExpired => Err(std::io::Error::other(format!(
"Proton refused the session ({err}); run `pdfs login`"
))),
None => Err(std::io::Error::other(format!("fetch My Files root: {err}"))),
}
}
/// How a mount comes up, from what fetching its root said.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Start {
Online,
/// Proton could not be reached: serve the cache and probe for the link.
Offline,
/// Proton refused the session: serve the cache until a new login.
SessionExpired,
}
/// Per-mount settings resolved from [`pdfs_core::config::AppConfig`] by the
/// caller, so `mount` itself never reads config or the environment.
pub struct MountOptions {
@@ -241,6 +275,12 @@ pub struct MountOptions {
pub username: String,
/// What the background conflict sweep may do. See `docs/BUGS.md` B71.
pub sweep_mode: SweepMode,
/// Upload and download caps in bytes per second (`0` = no cap).
pub upload_limit: u64,
pub download_limit: u64,
/// Namespace changes through the mount are queued, not sent in the
/// syscall. See [`pdfs_core::config::AppConfig::local_first`].
pub local_first: bool,
}
/// Spawn one FUSE session rooted at an arbitrary remote node.
@@ -273,13 +313,14 @@ pub(super) fn spawn_session(
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.
// Only My Files rehydrates the daemon-wide node cache, once. A
// secondary mount shares the tree it fills.
core.hydrate();
}
let session = Session::new(fs, mountpoint, &config)?.spawn()?;
let _ = core.notifier.set(session.notifier());
let _ = core
.notifier
.set(KernelChannel::new(session.notifier(), core.root_ino()));
core.session_live.store(true, Ordering::Release);
Ok(session)
}
@@ -336,12 +377,58 @@ pub fn mount(
control_socket: &Path,
db: Arc<Db>,
options: MountOptions,
) -> std::io::Result<MountOutcome> {
let drive = Arc::new(client.clone());
mount_with(
client,
drive,
rt,
mountpoint,
cache,
control_socket,
db,
options,
Host::Daemon,
)
}
/// What a mount runs in.
pub(crate) enum Host {
/// The daemon: stopped by SIGTERM or SIGINT, and indexing the user's home
/// for the launcher prompt.
Daemon,
/// One of several simulated daemons in a test process (`sim`): stopped by
/// a message on this channel, and with no home to index.
#[cfg(test)]
Simulation(std::sync::mpsc::Receiver<()>),
}
/// [`mount`], reaching Drive through `drive` and hosted by `host`; `client`
/// serves only what [`DriveApi`] does not cover.
#[allow(clippy::too_many_arguments)]
pub(crate) fn mount_with(
client: ProtonDriveClient,
drive: Arc<dyn DriveApi>,
rt: tokio::runtime::Handle,
mountpoint: &Path,
cache: ContentCache,
control_socket: &Path,
db: Arc<Db>,
options: MountOptions,
host: Host,
) -> std::io::Result<MountOutcome> {
let MountOptions {
username,
sweep_mode,
upload_limit,
download_limit,
local_first,
} = options;
let (root, online) = fetch_or_recall_root(&client, &rt, &db)?;
// 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, start) = fetch_or_recall_root(drive.as_ref(), &rt, &db)?;
let online = start == Start::Online;
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}")))?;
@@ -349,18 +436,21 @@ pub fn mount(
.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
// The folder-sync engine 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 paused_until = pause::load_paused_until(&db);
let core = Core {
client: client.clone(),
drive,
client,
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))),
state: Arc::new(Mutex::new(State::new(db.clone(), share_access))),
root: Arc::new(OnceLock::new()),
cache: Arc::new(cache),
readers: Arc::new(Mutex::new(HashMap::new())),
block_ring: Arc::new(Mutex::new(BlockRing::default())),
@@ -374,38 +464,60 @@ pub fn mount(
shared_generation: Arc::new(AtomicU64::new(0)),
shared_refresh_deadlines: Arc::new(Mutex::new(SharedRefreshDeadlines::default())),
online: Arc::new(AtomicBool::new(online)),
link: Arc::new(link::Link::starting(start == Start::SessionExpired)),
sync_paused_until: Arc::new(AtomicI64::new(paused_until)),
pending: Arc::new(Mutex::new(HashMap::new())),
hidden: Arc::new(Mutex::new(HashSet::new())),
creating: Arc::new(Mutex::new(HashMap::new())),
sealed_unread: Arc::new(Mutex::new(HashMap::new())),
landed: Arc::new(Mutex::new(HashMap::new())),
unadopted: Arc::new(Mutex::new(HashMap::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())),
landing_uploads: Arc::new(AtomicU64::new(0)),
timeline_refreshing: Arc::new(AtomicBool::new(false)),
timeline_progress: Arc::new(Mutex::new(None)),
similar_hashing: 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())),
local_first,
self_changes: Arc::new(Mutex::new(HashMap::new())),
thumb_gen: Arc::new(Mutex::new(HashSet::new())),
no_thumbnail: Arc::new(Mutex::new(HashMap::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)),
quota_refreshing: Arc::new(AtomicBool::new(false)),
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(),
events: Arc::new(Default::default()),
indexing: Arc::new(AtomicBool::new(false)),
sync_progress: Arc::new(Mutex::new(HashMap::new())),
sync_engine: Arc::default(),
sync_tx,
mounts: Arc::new(Mutex::new(SecondaryMountRegistry::default())),
sync_locks: Arc::new(Mutex::new(HashMap::new())),
sync_unscanned: 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`.
// every mount's session through this registry.
core.register_state(mountpoint);
core.transfers.set_limits(upload_limit, download_limit);
// 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.
@@ -426,20 +538,23 @@ pub fn mount(
// 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).
// and sweeps all operating on a mount that no longer exists (`docs/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))?,
);
let drain_core = core.clone();
let spawned = std::thread::Builder::new()
.name(format!("pdfs-drain-{worker}"))
.spawn(move || drain_core.run_pending_drain(worker == 0));
keep_or_abandon(&core, control_socket, &mut workers, spawned)?;
}
// 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));
// A timed pause persisted by an earlier run still has to end on time.
if pause::paused_at(paused_until, now_secs()) && paused_until != pause::PAUSED_INDEFINITELY {
core.arm_pause_timer(paused_until);
}
// Reconcile leftover `(sync-conflict …)` copies: drop the ones that turned
// out identical to the live file, surface the ones that genuinely diverge.
@@ -450,38 +565,36 @@ pub fn mount(
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())?,
);
let sweep_core = core.clone();
let spawned = std::thread::Builder::new()
.name("pdfs-conflict-sweep".into())
.spawn(move || sweep_core.run_conflict_sweep_loop());
keep_or_abandon(&core, control_socket, &mut workers, spawned)?;
}
// 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())?,
);
// Watch the link for the life of the mount: a mount from the cache waits for
// the network to come back, and an online one can lose it at any moment and
// must notice it coming back just the same.
{
let probe_core = core.clone();
let spawned = std::thread::Builder::new()
.name("pdfs-online-probe".into())
.spawn(move || probe_core.run_online_probe());
keep_or_abandon(&core, control_socket, &mut workers, spawned)?;
}
// 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.
{
if matches!(host, Host::Daemon) {
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))?,
);
let spawned = std::thread::Builder::new()
.name("pdfs-localindex".into())
.spawn(move || run_local_index(db, indexing, transfers, mountpoint, shutdown));
keep_or_abandon(&core, control_socket, &mut workers, spawned)?;
}
// Bind the control socket before the FUSE session takes over the thread. A
@@ -490,12 +603,18 @@ pub fn mount(
let old_umask = unsafe { libc::umask(0o77) };
let listener_res = UnixListener::bind(control_socket);
unsafe { libc::umask(old_umask) };
let listener = listener_res?;
let listener = match listener_res {
Ok(listener) => listener,
Err(error) => {
abandon_workers(&core, control_socket, workers);
return Err(error);
}
};
// 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);
abandon_workers(&core, control_socket, workers);
return Err(std::io::Error::other(format!(
"control socket permissions: {e}"
)));
@@ -506,7 +625,7 @@ pub fn mount(
let bg = match spawn_session(&core, mountpoint, root) {
Ok(session) => session,
Err(error) => {
let _ = std::fs::remove_file(control_socket);
abandon_workers(&core, control_socket, workers);
return Err(error);
}
};
@@ -526,7 +645,7 @@ pub fn mount(
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);
abandon_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() {
@@ -543,6 +662,46 @@ pub fn mount(
}
}
}
// Publishes what changes without one place to publish it from (status,
// transfers, sync progress) to the front-ends subscribed on that socket.
{
let sampler_core = core.clone();
let username = username.clone();
let mountpoint = mountpoint.to_path_buf();
match std::thread::Builder::new()
.name("pdfs-events".into())
.spawn(move || events::run_event_sampler(sampler_core, username, mountpoint))
{
Ok(handle) => workers.push(handle),
// Not fatal: front-ends still get every published change, only
// not the sampled ones.
Err(error) => warn!(error = %error, "could not start the event sampler"),
}
}
// 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. Whatever
// failure an earlier attempt recorded for the front ends is over.
systemd::ready();
if let Some(state_dir) = control_socket.parent() {
pdfs_core::mountfail::clear(state_dir);
}
// 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.
@@ -559,8 +718,13 @@ pub fn mount(
}
}
};
rt.spawn(run_event_sync(client, scope, core.clone()));
if online {
rt.spawn(run_event_sync(core.drive.clone(), 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()));
// Not in a simulation: `client` there reaches no API, and the thread, still
// waiting on it after the stop, held the database a test opens next.
if online && matches!(host, Host::Daemon) {
let repair_core = core.clone();
let root_uid = core.primary_root_uid.clone();
if let Err(error) = std::thread::Builder::new()
@@ -575,6 +739,82 @@ pub fn mount(
// 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_rx = match host {
Host::Daemon => signal_stop(&rt),
#[cfg(test)]
Host::Simulation(rx) => rx,
};
// 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 core.link.resume_stored.load(Ordering::Relaxed) =>
{
info!("unmounting to resume the stored session");
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::ResumeStored;
}
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.
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");
}
}
core.queue_unreleased_writes();
// 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)
}
/// A channel that receives once SIGTERM or SIGINT arrives.
fn signal_stop(rt: &tokio::runtime::Handle) -> std::sync::mpsc::Receiver<()> {
let (sig_tx, sig_rx) = std::sync::mpsc::sync_channel::<()>(1);
rt.spawn(async move {
let mut sigterm =
@@ -599,76 +839,70 @@ pub fn mount(
}
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)
sig_rx
}
/// How long teardown waits for the background workers and then the FUSE worker
/// pool before proceeding without them. 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).
/// did not, and started a second full set on top of the first (`docs/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.
/// than abandoning the user's bytes mid-flight. Up to [`WORKER_JOIN_DEADLINE`]:
/// past it, systemd kills the daemon anyway, and a queued op is sent again.
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();
halt_workers(core, control_socket, workers);
}
/// Stop the threads a mount attempt started before it failed.
///
/// The early returns used to leave them running: every retry started another
/// set beside the last one's, and their `Core` kept the database open, so the
/// next attempt could fail on its lock instead of the real error
/// (`docs/BUGS.md` B264). Unlike [`stop_workers`] this does not tell systemd
/// the daemon is stopping, because it is about to try again, and a manager
/// told so would wait for the process to exit and then kill it.
fn abandon_workers(core: &Core, control_socket: &Path, workers: Vec<std::thread::JoinHandle<()>>) {
halt_workers(core, control_socket, workers);
}
/// Keep a worker the mount just spawned, or, if the spawn failed, stop the
/// ones already running and hand the error back.
fn keep_or_abandon(
core: &Core,
control_socket: &Path,
workers: &mut Vec<std::thread::JoinHandle<()>>,
spawned: std::io::Result<std::thread::JoinHandle<()>>,
) -> std::io::Result<()> {
match spawned {
Ok(handle) => {
workers.push(handle);
Ok(())
}
Err(error) => {
abandon_workers(core, control_socket, std::mem::take(workers));
Err(error)
}
}
}
/// Signal every worker to stop, then join them up to [`WORKER_JOIN_DEADLINE`].
fn halt_workers(core: &Core, control_socket: &Path, workers: Vec<std::thread::JoinHandle<()>>) {
core.shutdown.stop();
core.wake_drain();
let _ = core.sync_tx.send(sync::SyncMsg::Stop);
@@ -678,19 +912,71 @@ fn stop_workers(core: &Core, control_socket: &Path, workers: Vec<std::thread::Jo
// unlink cannot put it back to sleep.
let _ = std::os::unix::net::UnixStream::connect(control_socket);
let _ = std::fs::remove_file(control_socket);
let deadline = Instant::now() + WORKER_JOIN_DEADLINE;
let running = join_until(workers, deadline);
if !running.is_empty() {
warn!(
running = running.join(","),
"background workers did not finish in time; shutting down around them"
);
}
// 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(deadline.saturating_duration_since(Instant::now()));
debug!("background workers stopped");
}
/// Join `workers` until `deadline`, and name the ones still running then.
///
/// A stop used to wait for each one however long it took. The sync engine's
/// mirror pass waits on Drive with no deadline, and a stop that waited for it
/// was killed by systemd (`docs/BUGS.md` B165).
fn join_until(workers: Vec<std::thread::JoinHandle<()>>, deadline: Instant) -> Vec<String> {
let mut running = Vec::new();
for worker in workers {
if worker.join().is_err() {
while !worker.is_finished() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(10));
}
if !worker.is_finished() {
running.push(worker.thread().name().unwrap_or("unnamed").to_string());
} else if worker.join().is_err() {
warn!("a background worker panicked before shutdown");
}
}
debug!("background workers stopped");
running
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use super::{SecondaryInsertRejection, SecondaryMountRegistry, teardown_session};
use super::{SecondaryInsertRejection, SecondaryMountRegistry, join_until, teardown_session};
#[test]
fn a_stop_names_the_workers_still_running_at_its_deadline() {
let (release, wait) = std::sync::mpsc::channel::<()>();
let done = std::thread::Builder::new()
.name("done".into())
.spawn(|| {})
.unwrap();
let stuck = std::thread::Builder::new()
.name("stuck".into())
.spawn(move || {
let _ = wait.recv();
})
.unwrap();
let running = join_until(
vec![done, stuck],
Instant::now() + Duration::from_millis(100),
);
assert_eq!(running, ["stuck"]);
drop(release);
}
#[test]
fn session_is_unroutable_before_teardown_starts() {
+235
View File
@@ -0,0 +1,235 @@
//! Pausing sync: the user's "stop uploading for a while".
//!
//! A pause holds back the two things that push local state to the remote — the
//! drain worker's queued uploads and changes, and the mirror-folder reconcile.
//! Nothing else stops: reads through the mount keep hydrating on demand (a read
//! is not a sync), writes keep being accepted and staged, and the watcher and
//! poll keep noticing changes. Resuming lets all of it catch up at once.
//!
//! The pause is persisted in the database, so a restart does not quietly undo
//! it. A timed pause ends by itself; [`Core::set_sync_paused`] arms a timer for
//! that, and [`Core::sync_paused`] compares against the clock anyway, so a
//! deadline that passed while the daemon was down is already over on mount.
//!
//! A single mirror folder can be paused on its own as well: its reconcile is
//! skipped, and with it any mode switch queued behind that reconcile, until the
//! folder is resumed. On-demand folders cannot be paused alone — their writes
//! go through the shared queue, which only the global pause holds back.
use super::*;
/// The `state` key holding the resume time, in Unix seconds.
const PAUSED_UNTIL_KEY: &str = "sync_paused_until";
/// The `state` key prefix marking one synced folder paused; its id follows.
const FOLDER_PAUSED_PREFIX: &str = "sync_folder_paused:";
fn folder_paused_key(id: i64) -> String {
format!("{FOLDER_PAUSED_PREFIX}{id}")
}
/// Whether synced folder `id` is paused on its own. A row that cannot be read
/// counts as running, like the global pause.
fn load_folder_paused(db: &Db, id: i64) -> bool {
match db.state_i64(&folder_paused_key(id)) {
Ok(value) => value.is_some_and(|v| v != 0),
Err(error) => {
warn!(id, %error, "reading a folder pause failed; treating it as running");
false
}
}
}
/// Persist synced folder `id`'s own pause, or clear it.
fn store_folder_paused(db: &Db, id: i64, paused: bool) -> pdfs_core::Result<()> {
match paused {
true => db.set_state_i64(&folder_paused_key(id), 1),
false => db.clear_state(&folder_paused_key(id)),
}
}
/// A pause with no end time: it lasts until the user resumes.
pub(crate) const PAUSED_INDEFINITELY: i64 = i64::MAX;
/// The persisted pause, or `0` when there is none (or it cannot be read — a
/// broken state row must not keep sync paused forever).
pub(crate) fn load_paused_until(db: &Db) -> i64 {
match db.state_i64(PAUSED_UNTIL_KEY) {
Ok(Some(until)) => until,
Ok(None) => 0,
Err(error) => {
warn!(%error, "reading the sync pause failed; treating sync as running");
0
}
}
}
/// Whether a pause lasting until `until` is still in force at `now` (both Unix
/// seconds). `0` is "not paused".
pub(crate) fn paused_at(until: i64, now: i64) -> bool {
until != 0 && now < until
}
impl Core {
/// True while the user has sync paused.
pub(crate) fn sync_paused(&self) -> bool {
paused_at(self.sync_paused_until.load(Ordering::Relaxed), now_secs())
}
/// The pause's end, for status reporting: `None` when not paused,
/// `Some(None)` when paused until resumed, `Some(Some(t))` when it ends at
/// Unix second `t`.
pub(crate) fn sync_pause(&self) -> Option<Option<i64>> {
let until = self.sync_paused_until.load(Ordering::Relaxed);
match paused_at(until, now_secs()) {
false => None,
true if until == PAUSED_INDEFINITELY => Some(None),
true => Some(Some(until)),
}
}
/// Pause sync until Unix second `until` (`None`: until resumed), or resume
/// it when `paused` is false.
pub(crate) fn set_sync_paused(
&self,
paused: bool,
until: Option<i64>,
) -> Result<(), CoreError> {
let value = match (paused, until) {
(false, _) => 0,
(true, None) => PAUSED_INDEFINITELY,
(true, Some(until)) if until <= now_secs() => {
return Err(CoreError::invalid("the pause would already be over"));
}
(true, Some(until)) => until,
};
let persisted = match value {
0 => self.db.clear_state(PAUSED_UNTIL_KEY),
value => self.db.set_state_i64(PAUSED_UNTIL_KEY, value),
};
if let Err(error) = persisted {
return Err(CoreError::internal(format!(
"saving the sync pause: {error}"
)));
}
self.sync_paused_until.store(value, Ordering::Relaxed);
match value {
0 => {
info!("sync resumed");
self.resume_sync_work();
}
PAUSED_INDEFINITELY => info!("sync paused until resumed"),
until => {
info!(until, "sync paused");
self.arm_pause_timer(until);
}
}
Ok(())
}
/// True while the user has this one synced folder paused. A row that cannot
/// be read counts as running, like the global pause.
pub(crate) fn sync_folder_paused(&self, id: i64) -> bool {
load_folder_paused(&self.db, id)
}
/// Pause or resume one mirror folder's reconcile. Resuming reconciles it
/// straight away to catch up on what the watcher saw meanwhile.
pub(crate) fn set_sync_folder_paused(&self, id: i64, paused: bool) -> Result<(), CoreError> {
let folder = self
.db
.sync_folder_get(id)
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
.ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?;
if paused && folder.mode != "mirror" {
return Err(CoreError::invalid(
"only a mirrored folder can be paused on its own; an on-demand folder uploads \
through the shared queue, so pause all sync instead",
));
}
if let Err(error) = store_folder_paused(&self.db, id, paused) {
return Err(CoreError::internal(format!(
"saving the folder pause: {error}"
)));
}
if paused {
info!(id, path = %folder.local_path, "folder sync paused");
} else {
info!(id, path = %folder.local_path, "folder sync resumed");
self.sync_now(Some(id));
}
Ok(())
}
/// Forget a removed folder's pause, so a later folder reusing its id does
/// not start out paused.
pub(crate) fn clear_sync_folder_paused(&self, id: i64) {
if let Err(error) = store_folder_paused(&self.db, id, false) {
warn!(id, %error, "clearing a removed folder's pause failed");
}
}
/// Wake everything a pause held back: the drain worker, and a full
/// reconcile to pick up whatever the watcher saw meanwhile.
fn resume_sync_work(&self) {
self.wake_drain();
self.sync_now(None);
}
/// End a timed pause when its time comes. A later pause or resume replaces
/// the stored deadline, which the timer checks before acting, so a stale
/// timer does nothing.
pub(crate) fn arm_pause_timer(&self, until: i64) {
let core = self.clone();
let wait = Duration::from_secs(until.saturating_sub(now_secs()).max(0) as u64);
let spawned = std::thread::Builder::new()
.name("pdfs-pause".into())
.spawn(move || {
if !core.shutdown.sleep(wait) {
return;
}
if core
.sync_paused_until
.compare_exchange(until, 0, Ordering::Relaxed, Ordering::Relaxed)
.is_ok()
{
if let Err(error) = core.db.clear_state(PAUSED_UNTIL_KEY) {
warn!(%error, "clearing an expired sync pause failed");
}
info!("sync pause ended");
core.resume_sync_work();
}
});
if let Err(error) = spawned {
// The drain and the reconcile still read the clock, so the pause
// still ends — just at the next idle poll instead of on time.
warn!(%error, "starting the sync pause timer failed");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_pause_is_in_force_until_its_deadline() {
assert!(!paused_at(0, 100));
assert!(paused_at(200, 100));
assert!(!paused_at(200, 200));
assert!(!paused_at(200, 300));
assert!(paused_at(PAUSED_INDEFINITELY, i64::MAX - 1));
}
#[test]
fn a_folder_pause_is_kept_per_folder_until_cleared() {
let db = Db::open(std::path::Path::new(":memory:")).unwrap();
assert!(!load_folder_paused(&db, 3));
store_folder_paused(&db, 3, true).unwrap();
assert!(load_folder_paused(&db, 3));
assert!(!load_folder_paused(&db, 4));
assert!(!load_folder_paused(&db, 33));
store_folder_paused(&db, 3, false).unwrap();
assert!(!load_folder_paused(&db, 3));
}
}
File diff suppressed because it is too large. Load diff
+190 -70
View File
@@ -1,5 +1,5 @@
//! 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 restore that rebuilds a machine from one.
//!
//! 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:
@@ -15,6 +15,7 @@ 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,
is_profile_file_name, profile_file_name, saved_folder,
};
use pdfs_core::{CoreError, CoreResult};
use proton_drive_rs::proton_sdk::ids::NodeUid;
@@ -66,9 +67,14 @@ impl Core {
});
}
/// Serialize this machine's arrangement and upload it to `profile.json`
/// inside the device's `.proton-drive-linux` folder, replacing any existing
/// one.
/// Serialize this machine's arrangement and upload it to its own
/// `profile-<machine id>.json` inside the device's `.proton-drive-linux`
/// folder, replacing any existing one.
///
/// Each machine writes its own file because several computers may sync
/// into one device. They used to share `profile.json`, and each save
/// replaced the other computers' folders with its own (B252). That file is
/// left as it is, so a client before 3.5.0 still finds it.
///
/// 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
@@ -85,7 +91,11 @@ impl Core {
CoreError::internal(format!("bad device root uid: {}", device.root_uid))
})?;
let profile = self.build_profile(&device.uid)?;
let machine_id = pdfs_core::config::AppDirs::new()
.and_then(|dirs| dirs.machine_id())
.map_err(|e| CoreError::internal(format!("machine id: {e}")))?;
let file_name = profile_file_name(&machine_id);
let profile = self.build_profile(&device.uid, &machine_id)?;
let bytes = profile.to_bytes().map_err(CoreError::internal)?;
let len = bytes.len() as i64;
@@ -97,11 +107,11 @@ impl Core {
// 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)?;
let existing = self.find_device_child_file(&dir_uid, &file_name)?;
match existing {
Some(uid) => self
.rt
.block_on(self.client.upload_new_revision_from(
.block_on(self.drive.upload_new_revision_from(
&uid,
Cursor::new(bytes),
len,
@@ -112,9 +122,9 @@ impl Core {
.map_err(|e| CoreError::from_api(&e, "upload profile revision"))?,
None => {
self.rt
.block_on(self.client.upload_file_replacing_draft_from(
.block_on(self.drive.upload_file_replacing_draft_from(
&dir_uid,
PROFILE_FILE_NAME,
&file_name,
"application/json",
Cursor::new(bytes),
len,
@@ -130,7 +140,7 @@ impl Core {
}
/// Snapshot the local arrangement into a [`Profile`].
fn build_profile(&self, device_uid: &str) -> CoreResult<Profile> {
fn build_profile(&self, device_uid: &str, machine_id: &str) -> CoreResult<Profile> {
let folders = self
.db
.sync_folder_list()
@@ -162,6 +172,7 @@ impl Core {
version: PROFILE_VERSION,
device_uid: device_uid.to_string(),
hostname: this_hostname(),
machine_id: Some(machine_id.to_string()),
saved_at: now_secs(),
folders,
pins,
@@ -182,14 +193,14 @@ impl Core {
) -> CoreResult<Option<NodeUid>> {
let uids = self
.rt
.block_on(self.client.enumerate_folder_children_node_uids(parent_uid))
.block_on(self.drive.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))
.block_on(self.drive.enumerate_nodes(&uids))
.map_err(|e| CoreError::from_api(&e, "resolve device root children"))?;
Ok(nodes
.into_iter()
@@ -197,6 +208,31 @@ impl Core {
.map(|n| n.uid))
}
/// The untrashed files directly under `parent_uid` whose name passes
/// `keep`.
fn device_child_files(
&self,
parent_uid: &NodeUid,
keep: impl Fn(&str) -> bool,
) -> CoreResult<Vec<NodeUid>> {
let uids = self
.rt
.block_on(self.drive.enumerate_folder_children_node_uids(parent_uid))
.map_err(|e| CoreError::from_api(&e, "list device root"))?;
if uids.is_empty() {
return Ok(Vec::new());
}
let nodes = self
.rt
.block_on(self.drive.enumerate_nodes(&uids))
.map_err(|e| CoreError::from_api(&e, "resolve device root children"))?;
Ok(nodes
.into_iter()
.filter(|n| !n.is_folder() && !n.trashed && keep(&n.name))
.map(|n| n.uid)
.collect())
}
/// The device's `.proton-drive-linux` folder, creating it if this is the
/// first save on this device.
///
@@ -210,7 +246,7 @@ impl Core {
let uid = self
.rt
.block_on(
self.client
self.drive
.create_folder(root_uid, PROFILE_DIR_NAME, Some(now_secs())),
)
.map_err(|e| CoreError::from_api(&e, "create profile folder"))?;
@@ -218,68 +254,84 @@ impl Core {
Ok(uid)
}
/// Download and parse this device's profile, if there is one.
/// Download and parse the profiles of every computer that syncs this
/// device.
///
/// 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,
/// Reads every profile in `.proton-drive-linux/`: one per machine, and the
/// shared `profile.json` of clients before 3.5.0. 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. A profile that cannot be read is left out and
/// logged; the others still count.
fn load_profiles(&self, root_uid: &NodeUid) -> CoreResult<Vec<Profile>> {
let mut uids = match self.find_device_child_folder(root_uid, PROFILE_DIR_NAME)? {
Some(dir_uid) => self.device_child_files(&dir_uid, is_profile_file_name)?,
None => Vec::new(),
};
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
if uids.is_empty() {
uids = self.device_child_files(root_uid, |name| name == PROFILE_FILE_NAME)?;
if !uids.is_empty() {
info!("reading legacy profile from the device root");
}
}
let mut profiles = Vec::new();
for uid in uids {
let mut buf: Vec<u8> = Vec::new();
let parsed = self
.rt
.block_on(self.drive.download_file_to(&uid, &mut buf))
.map_err(|e| CoreError::from_api(&e, "download profile"))
.and_then(|()| Profile::parse(&buf).map_err(CoreError::invalid));
match parsed {
Ok(profile) => profiles.push(profile),
Err(e) => {
warn!(%uid, error = %e.message, "could not read a profile; leaving it out")
}
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)
}
}
Ok(profiles)
}
// ---- restore (features.md 5.2) ----------------------------------------
// ---- restore ----------------------------------------
/// The folders under this machine's device that could be synced here, each
/// carrying a proposed local path.
/// The folders under a device that could be synced here, each carrying a
/// proposed local path. `device` names another registered device by uid;
/// `None` is this machine's own.
///
/// 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()?;
pub(crate) fn list_restorable_folders(
&self,
device: Option<&str>,
) -> CoreResult<Vec<RestorableFolder>> {
let device = self.restore_source(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) {
let profiles = match self.load_profiles(&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
warn!(error = %e.message, "could not read profiles; proposing defaults");
Vec::new()
}
};
let hostname = this_hostname();
let uids = self
.rt
.block_on(self.client.enumerate_folder_children_node_uids(&root_uid))
.block_on(self.drive.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))
.block_on(self.drive.enumerate_nodes(&uids))
.map_err(|e| CoreError::from_api(&e, "resolve device root children"))?
};
@@ -300,11 +352,12 @@ impl Core {
// 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)
// The name becomes a folder under the home folder: one with a `/`
// or a `..` in it would put the restore somewhere else.
.filter(|n| crate::remote_name_is_safe(&n.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 saved = saved_folder(&profiles, &uid, &hostname);
let proposed = saved
.map(|f| PathBuf::from(&f.local_path))
.filter(|p| path_is_usable_here(p))
@@ -334,33 +387,40 @@ impl Core {
pub(crate) fn restore_sync_folders(
&self,
items: &[pdfs_core::control::RestoreItem],
) -> CoreResult<String> {
device: Option<&str>,
) -> CoreResult<RestoreOutcome> {
if items.is_empty() {
return Err(CoreError::invalid("nothing to restore"));
}
let device = self.ensure_device()?;
let device = self.restore_source(device)?;
let existing = self
.db
.sync_folder_list()
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
let reserved = crate::devices::sync_root_reserved();
let mut roots: Vec<PathBuf> = existing
.iter()
.map(|f| PathBuf::from(&f.local_path))
.collect();
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;
}
// The path is proposed from the profile another machine wrote, and
// may hold a folder name from Drive: checked like any new root.
let local = match pdfs_core::syncroot::resolve(Path::new(&item.local_path)) {
Ok(local) => local,
Err(e) => {
skipped.push(format!("{}: {e}", 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));
if let Some(problem) = pdfs_core::syncroot::problem(&local, &roots, &reserved) {
skipped.push(format!("{}: {problem}", item.local_path));
continue;
}
if let Err(e) = std::fs::create_dir_all(&local) {
@@ -374,10 +434,19 @@ impl Core {
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:?}")))?;
let id = match self.db.sync_folder_add(
&local.to_string_lossy(),
&item.remote_uid,
&device.share_id,
) {
Ok(id) => id,
Err(e) => {
// The folders restored before this one still need their
// watches.
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
return Err(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.
@@ -385,16 +454,44 @@ impl Core {
let _ = self.request_sync_folder_mode(id, "ondemand");
}
let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id));
roots.push(local);
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(", ")));
// The reply is gone once the app has shown it; the log keeps why.
for reason in &skipped {
info!(reason, "restore skipped a folder");
}
info!(restored, skipped = skipped.len(), "restore complete");
Ok(message)
Ok(RestoreOutcome { restored, skipped })
}
}
/// What a restore did: how many folders it attached, and why it left out the
/// others.
pub(crate) struct RestoreOutcome {
pub(crate) restored: usize,
pub(crate) skipped: Vec<String>,
}
impl RestoreOutcome {
/// The activity entry's headline.
pub(crate) fn summary(&self) -> String {
match (self.restored, self.skipped.len()) {
(n, 0) => format!("restored {n} folder(s)"),
(0, k) => format!("restored no folders; skipped {k}"),
(n, k) => format!("restored {n} folder(s); skipped {k}"),
}
}
/// Whether the restore did all it was asked. A skipped folder is a
/// failure the user has to see: the reply went out as "restoring folders"
/// before any work, and a restore that skipped every folder used to be
/// logged as a success, so nothing anywhere said that nothing would
/// download (`docs/BUGS.md` B269).
pub(crate) fn complete(&self) -> bool {
self.skipped.is_empty()
}
}
@@ -477,7 +574,7 @@ mod tests {
#[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(");
let load = function_source(source, "fn load_profiles(");
assert!(load.contains("PROFILE_DIR_NAME"), "prefers the folder");
assert!(
load.find("PROFILE_DIR_NAME").unwrap()
@@ -486,6 +583,16 @@ mod tests {
);
}
/// Several computers may share a device, so each saves to a file named
/// after its own machine id and never to the shared `profile.json`
/// (B252).
#[test]
fn each_machine_saves_its_own_profile() {
let save = function_source(include_str!("profile.rs"), "fn save_profile(");
assert!(save.contains("profile_file_name(&machine_id)"));
assert!(!save.contains("PROFILE_FILE_NAME"));
}
/// 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]
@@ -499,6 +606,19 @@ mod tests {
assert_before(add, "PROFILE_DIR_NAME", "ensure_device()");
}
/// Restoring another computer's folders reads that device, but must not
/// rewrite the cached device row: this machine would then back up as the
/// other computer.
#[test]
fn restoring_from_another_device_keeps_this_machines_identity() {
let source = function_source(include_str!("devices.rs"), "fn restore_source(");
assert!(source.contains("ensure_device()"), "None is this machine");
assert!(
!source.contains("device_set"),
"the cached row is untouched"
);
}
fn function_source<'a>(source: &'a str, signature: &str) -> &'a str {
let start = source.find(signature).expect("function signature exists");
let body = &source[start..];
+302
View File
@@ -0,0 +1,302 @@
//! The user's view of the pending-op queue: what has not reached the remote
//! yet, why, and a way to stop waiting out a backoff.
//!
//! Read-only apart from "retry now", which only moves a row's next-attempt time,
//! "export", which copies a staged blob out, and "discard". Only a discard the
//! user asks for drops a queued op: its staged blob is the only copy of the
//! user's write (see `docs/RECOVERY.md`), so it is the user's call, made after
//! an export or for a change with no content.
use super::*;
use std::io::Write;
use pdfs_core::control::{PendingOpInfo, SyncIssue};
use pdfs_core::db::{FAILING_ATTEMPTS, PARK_UNTIL};
impl Core {
/// Every queued op, oldest first, with a path a person can read.
pub(crate) fn pending_op_infos(&self) -> CoreResult<Vec<PendingOpInfo>> {
let ops = self
.db
.pending_ops()
.map_err(|e| CoreError::internal(format!("listing the queue: {e}")))?;
let issues = self
.db
.op_issues()
.map_err(|e| CoreError::internal(format!("listing sync issues: {e}")))?;
Ok(ops
.into_iter()
.map(|op| {
let parked = op.next_attempt_at >= PARK_UNTIL;
let exportable = exportable(&op).is_some();
let path = self
.db
.node_path(&op.uid)
.ok()
.flatten()
.filter(|path| !path.is_empty())
.or_else(|| op.name.clone())
.unwrap_or_else(|| op.uid.clone());
PendingOpInfo {
id: op.id,
kind: op.kind,
path,
attempts: op.attempts,
last_error: op.last_error,
queued_at: op.created_at / 1000,
next_attempt_at: (!parked).then_some(op.next_attempt_at / 1000),
parked,
failing: op.attempts >= FAILING_ATTEMPTS || issues.contains_key(&op.id),
issue: issues.get(&op.id).map(|issue| SyncIssue::parse(issue)),
exportable,
}
})
.collect())
}
/// Retry one backed-off op now, or every failed one when `id` is `None`.
/// Returns how many ops were moved up.
pub(crate) fn retry_pending_ops(&self, id: Option<i64>) -> CoreResult<usize> {
let now = now_millis();
let moved = match id {
Some(id) => self.db.retry_op_now(id, now).map(usize::from),
None => self.db.retry_failed_ops_now(now),
}
.map_err(|e| CoreError::internal(format!("retrying the queue: {e}")))?;
if moved > 0 {
self.wake_drain();
}
Ok(moved)
}
/// Copy the content of queued op `id` to `dest`, which must not exist yet.
/// The op stays queued. Returns the bytes written.
pub(crate) fn export_pending_op(&self, id: i64, dest: &Path) -> CoreResult<u64> {
if !dest.is_absolute() {
return Err(CoreError::invalid(format!(
"destination must be an absolute path: {}",
dest.display()
)));
}
let ops = self
.db
.pending_ops()
.map_err(|e| CoreError::internal(format!("listing the queue: {e}")))?;
let op = ops
.iter()
.find(|op| op.id == id)
.ok_or_else(|| CoreError::not_found(format!("no queued change {id}")))?;
let blob = exportable(op).ok_or_else(|| {
CoreError::invalid(format!("queued change {id} carries no file content"))
})?;
let written = copy_to_new_file(blob, dest)?;
info!(id, dest = %dest.display(), written, "exported a queued change's content");
Ok(written)
}
/// Drop queued op `id` and undo the local change it stood for. Returns the
/// node's path.
///
/// A node only made here goes, as if it had been deleted before it was
/// sent. A node Drive has goes back to what Drive has: every change queued
/// on it goes, since each was made on top of the one before. What is queued
/// below it stays.
pub(crate) fn discard_pending_op(&self, id: i64) -> CoreResult<String> {
let ops = self
.db
.pending_ops()
.map_err(|e| CoreError::internal(format!("listing the queue: {e}")))?;
let op = ops
.into_iter()
.find(|op| op.id == id)
.ok_or_else(|| CoreError::not_found(format!("no queued change {id}")))?;
let uid = parse_node_uid(&op.uid)
.ok_or_else(|| CoreError::internal(format!("queued change {id} names {}", op.uid)))?;
let path = self
.db
.node_path(&op.uid)
.ok()
.flatten()
.filter(|path| !path.is_empty())
.or(op.name)
.unwrap_or_else(|| op.uid.clone());
if self.is_open_anywhere(&uid) {
return Err(CoreError::conflict(format!(
"{path} is open; close it first"
)));
}
if is_local_uid(&uid) {
self.discard_local_node(&uid, &path)?;
} else {
self.discard_remote_change(&uid, &path)?;
}
info!(id, %uid, path, "discarded a queued change");
Ok(path)
}
/// Forget a node made here, and the ops that would have sent it.
fn discard_local_node(&self, uid: &NodeUid, path: &str) -> CoreResult<()> {
// Its children's ops go with its own, and their content with them.
if self
.db
.has_children(uid)
.map_err(|e| CoreError::internal(format!("listing {path}: {e}")))?
{
return Err(CoreError::conflict(format!(
"{path} is not empty; discard what is in it first"
)));
}
self.discard_queued_ops(uid)
.map_err(|_| CoreError::internal(format!("dropping the changes to {path}")))?;
self.for_each_mount(|st, notify| {
background::forget_and_notify(st, notify, uid);
});
self.cache.evict(uid);
self.evict_reader(uid);
Ok(())
}
/// Put a node Drive has back as Drive has it.
fn discard_remote_change(&self, uid: &NodeUid, path: &str) -> CoreResult<()> {
// Asked first: the change is only dropped once what replaces it is known.
let node = self
.fetch_node_remote(uid)
.map_err(|e| CoreError::from_api(&e, "reading the node from Drive"))?
.filter(|node| !node.trashed);
self.cancel_upload(uid);
let blobs = self
.db
.discard_ops_on_node(&uid.to_string())
.map_err(|e| CoreError::internal(format!("dropping the changes to {path}: {e}")))?
.ok_or_else(|| {
CoreError::conflict(format!("{path} is being sent right now; try again"))
})?;
self.release_dropped_ops(uid, blobs);
self.hidden.lock().remove(uid);
match node {
Some(node) => self.for_each_mount(|st, notify| {
put_back(st, notify, node);
}),
None => self.for_each_mount(|st, notify| {
background::forget_and_notify(st, notify, uid);
}),
}
self.cache.evict(uid);
self.evict_reader(uid);
Ok(())
}
}
/// Show `node` as Drive has it, where Drive has it, under the inode it has.
fn put_back(st: &mut State, notify: &mut NotifyBatch, node: Node) {
let parent = node.parent_uid.as_ref().and_then(|parent| {
st.by_uid
.get(parent)
.copied()
.map(|ino| (ino, parent.clone()))
});
let here = st.by_uid.get(&node.uid).and_then(|ino| {
st.entries
.get(ino)
.map(|entry| (*ino, entry.parent, entry.node.name.clone()))
});
match (here, parent) {
(Some((ino, from, name)), Some((to, to_uid))) => {
st.relocate(ino, from, to, &to_uid, &node.name);
st.write_through(node.clone());
st.intern_mem(to, node);
notify.inval_entry(from, name);
notify.inval_inode(ino);
}
(here, parent) => {
if let Some((_, from, name)) = here {
st.forget_mem(&node.uid);
st.invalidate_listing(from);
notify.inval_entry(from, name);
}
if let Some((to, _)) = parent {
st.invalidate_listing(to);
notify.inval_entry(to, node.name.clone());
}
st.write_through(node);
}
}
}
/// Copy `blob` to `dest`, which must not exist: never replace something the
/// user already has.
fn copy_to_new_file(blob: &Path, dest: &Path) -> CoreResult<u64> {
let mut source = File::open(blob)
.map_err(|e| CoreError::internal(format!("open {}: {e}", blob.display())))?;
let mut out = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(dest)
.map_err(|e| match e.kind() {
std::io::ErrorKind::AlreadyExists => {
CoreError::invalid(format!("{} already exists", dest.display()))
}
_ => CoreError::internal(format!("create {}: {e}", dest.display())),
})?;
std::io::copy(&mut source, &mut out)
.and_then(|written| out.flush().and(out.sync_all()).map(|()| written))
.map_err(|e| CoreError::internal(format!("write {}: {e}", dest.display())))
}
/// The staged blob of an op that carries file content.
fn exportable(op: &PendingOp) -> Option<&Path> {
matches!(op.kind.as_str(), OP_CREATE | OP_REVISION)
.then_some(op.blob_path.as_deref())
.flatten()
.map(Path::new)
}
#[cfg(test)]
mod tests {
use super::*;
fn op(kind: &str, blob: Option<&str>) -> PendingOp {
PendingOp {
id: 1,
kind: kind.to_string(),
uid: "vol~a".to_string(),
parent_uid: None,
name: None,
blob_path: blob.map(str::to_string),
meta_json: None,
created_at: 0,
attempts: 0,
last_error: None,
next_attempt_at: 0,
}
}
#[test]
fn only_a_change_that_carries_file_content_can_be_exported() {
assert!(exportable(&op(OP_CREATE, Some("/staging/a"))).is_some());
assert!(exportable(&op(OP_REVISION, Some("/staging/a"))).is_some());
assert!(exportable(&op(OP_CREATE, None)).is_none());
assert!(exportable(&op(OP_RENAME, None)).is_none());
assert!(exportable(&op(OP_TRASH, None)).is_none());
}
#[test]
fn an_export_copies_the_bytes_and_never_replaces_a_file() {
let dir = std::env::temp_dir().join(format!("pdfs-export-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let blob = dir.join("blob");
std::fs::write(&blob, b"queued bytes").unwrap();
let dest = dir.join("copy.txt");
assert_eq!(copy_to_new_file(&blob, &dest).unwrap(), 12);
assert_eq!(std::fs::read(&dest).unwrap(), b"queued bytes");
std::fs::write(&dest, b"the user's own").unwrap();
assert!(copy_to_new_file(&blob, &dest).is_err());
assert_eq!(std::fs::read(&dest).unwrap(), b"the user's own");
// The staged blob is the only copy of the change: an export leaves it.
assert_eq!(std::fs::read(&blob).unwrap(), b"queued bytes");
let _ = std::fs::remove_dir_all(&dir);
}
}
+308 -69
View File
@@ -36,6 +36,21 @@ pub(super) const REPAIR_MAX: u64 = 512 * 1024 * 1024;
/// demand read in the SDK's flat block semaphore.
pub(super) const PREFETCH_BUDGET: usize = 8;
/// How long one read may wait on the network before it fails with `EIO`.
///
/// The SDK bounds each request, not a read: a block that times out is retried,
/// and a read can need several blocks, a block table and a repair. On a link
/// that stalled without dropping, one `read` held a FUSE worker for 34 minutes
/// and the application behind it with it (`docs/BUGS.md` B107). An error the
/// caller can retry beats a process stuck in `D` state.
pub(super) const READ_FETCH_TIMEOUT: Duration = Duration::from_secs(120);
/// How long a read of blocks nothing has cached waits for a lost link to come
/// back before failing. Long enough to ride out a Wi-Fi blip, short enough
/// that a program reading while the laptop is really offline gets its `EIO`
/// before the user gives up on it.
const READ_OFFLINE_WAIT: Duration = Duration::from_secs(30);
/// Cap on tracked sequential streams. Only reads in progress matter, so the map
/// is cleared wholesale rather than aged — losing the ramp costs one read.
const MAX_PREFETCH_STREAMS: usize = 256;
@@ -55,7 +70,7 @@ const MAX_OPEN_READERS: usize = 64;
/// `(mtime, size)` — the same validity pair the content cache uses (a new
/// revision bumps mtime). On a mismatch the reader is dropped and reopened.
pub(super) struct CachedReader {
reader: Arc<RevisionReader>,
reader: Arc<dyn RevisionRead>,
mtime: i64,
size: u64,
/// For LRU eviction.
@@ -86,7 +101,7 @@ pub(super) struct PendingOpen {
/// must not join this open — it would get a reader for the wrong bytes.
mtime: i64,
size: u64,
rx: tokio::sync::watch::Receiver<Option<Result<Arc<RevisionReader>, Errno>>>,
rx: tokio::sync::watch::Receiver<Option<Result<Arc<dyn RevisionRead>, Errno>>>,
}
/// Distinguishes `PendingOpen`s. Only uniqueness matters, so a plain counter
@@ -298,16 +313,16 @@ impl Core {
uid: &NodeUid,
mtime: i64,
fsize: u64,
) -> Result<Arc<RevisionReader>, Errno> {
) -> Result<Arc<dyn RevisionRead>, Errno> {
loop {
// Decide under the lock, act outside it — network I/O must never run
// under a std `Mutex`, and an await must never hold one.
enum Act {
Hit(Arc<RevisionReader>),
Join(tokio::sync::watch::Receiver<Option<Result<Arc<RevisionReader>, Errno>>>),
Hit(Arc<dyn RevisionRead>),
Join(tokio::sync::watch::Receiver<Option<Result<Arc<dyn RevisionRead>, Errno>>>),
Lead(
u64,
tokio::sync::watch::Sender<Option<Result<Arc<RevisionReader>, Errno>>>,
tokio::sync::watch::Sender<Option<Result<Arc<dyn RevisionRead>, Errno>>>,
),
}
let act = {
@@ -361,9 +376,10 @@ impl Core {
}
Act::Lead(id, tx) => {
debug!(%uid, mtime, fsize, "opening revision");
let result = match self.client.open_revision(uid).await {
Ok(reader) => Ok(Arc::new(reader)),
let result = match self.drive.open_revision(uid).await {
Ok(reader) => Ok(reader),
Err(e) => {
self.lost_link(&e, "open a revision");
warn!(%uid, error = %e, "open_revision failed");
Err(Errno::EIO)
}
@@ -448,7 +464,7 @@ impl Core {
/// it, and the [`BLOCK_SIZE`] assumption when it has not — which is every
/// first read, because learning it means opening a `RevisionReader`, and
/// opening one per read is precisely the per-file key derivation the block
/// cache exists to avoid (bugs.md B12, B85).
/// cache exists to avoid (`docs/BUGS.md` B12, B85).
///
/// Being wrong here is not a correctness problem: `read_at` plans over the
/// real sizes and clamps to what was asked for either way. It costs a
@@ -477,15 +493,20 @@ impl Core {
) -> Result<Vec<u8>, Errno> {
// A queued write has not reached the remote yet, so the remote's current
// revision is stale and the staged blob is the truth. Serve from it until
// the drain worker lands the upload (offline.md Phase 3).
// the drain worker lands the upload.
if let Some(pending) = self.pending.lock().get(uid).cloned() {
return self.read_pending(&pending, offset, len);
}
// A node created offline and never written has no blob and no remote: it
// is an empty file, and asking the API about a `local~` uid would only
// earn a 404 (offline.md Phase 3b).
// earn a 404. Unless its create landed since the
// read looked: the landing moved the queued write to the uid Drive
// gave it, and the row says which (`docs/BUGS.md` B179).
if is_local_uid(uid) {
return Ok(Vec::new());
return match self.landed_stand_in_uid(uid) {
Some(real) => self.read_range(&real, mtime, fsize, offset, len, cache_blocks),
None => Ok(Vec::new()),
};
}
self.read_range_remote(uid, mtime, fsize, offset, len, cache_blocks)
}
@@ -552,7 +573,7 @@ impl Core {
/// leader's claim is gone by then, so this cannot recurse.
async fn fetch_block(
&self,
reader: &Arc<RevisionReader>,
reader: &Arc<dyn RevisionRead>,
uid: &NodeUid,
mtime: i64,
fsize: u64,
@@ -615,10 +636,10 @@ impl Core {
///
/// A block that comes back shorter than the file's size says it must be is
/// never served, cached or promoted: it is repaired from the whole-file
/// download, or the read fails. See [`Core::repair_block`] (bugs.md B84).
/// download, or the read fails. See [`Core::repair_block`] (`docs/BUGS.md` B84).
async fn read_block(
&self,
reader: &Arc<RevisionReader>,
reader: &Arc<dyn RevisionRead>,
uid: &NodeUid,
mtime: i64,
fsize: u64,
@@ -630,7 +651,7 @@ impl Core {
// file's boundaries are wrong, and the failure is not confined to short
// reads: sizes that *overstate* a block shift every block after it and
// return full-length reads of the wrong bytes. So a disagreement of any
// kind takes the file off the range path entirely (bugs.md B84).
// kind takes the file off the range path entirely (`docs/BUGS.md` B84).
let bytes = if reader.size() != fsize {
warn!(
%uid, bidx = span.idx, fsize,
@@ -643,12 +664,13 @@ impl Core {
// Learned here rather than on a path of its own: this is where a
// reader is open anyway, and recording the real geometry is what
// lets the *next* read plan its blocks on the revision's own
// boundaries instead of the 4 MiB fallback (bugs.md B85).
// boundaries instead of the 4 MiB fallback (`docs/BUGS.md` B85).
if cache_blocks {
self.cache
.store_block_geometry(uid, mtime, fsize, reader.block_sizes());
}
let bytes = reader.read_at(span.start, span.len).await.map_err(|e| {
self.lost_link(&e, "read a block");
warn!(%uid, start = span.start, len = span.len, error = %e, "block read failed");
Errno::EIO
})?;
@@ -684,7 +706,7 @@ impl Core {
/// The range path derives every block boundary from the revision's recorded
/// block sizes. When those understate the file, reads come back short with
/// no error anywhere, and the caller hands userspace a truncated file that
/// looks like a clean EOF (bugs.md B84). `download_file_to` does not do that
/// looks like a clean EOF (`docs/BUGS.md` B84). `download_file_to` does not do that
/// arithmetic — it streams the block list — so it returns the whole file
/// where the range path cannot, which is why pinning was the workaround.
/// This does the same thing on the user's behalf, once, and the resulting
@@ -761,7 +783,7 @@ impl Core {
error!(%uid, %error, "creating a scratch file for a repair download failed");
Errno::EIO
})?;
let outcome = self.client.download_file_to(uid, &mut file).await;
let outcome = self.drive.download_file_to(uid, &mut file).await;
let stored = (|| {
outcome.map_err(|error| {
warn!(%uid, %error, "repair download failed");
@@ -896,7 +918,7 @@ impl Core {
let end = offset.saturating_add(len).min(fsize);
let mut out = Vec::with_capacity((end - offset) as usize);
// On the revision's own boundaries where they are known, and on the
// 4 MiB fallback where they are not (bugs.md B85).
// 4 MiB fallback where they are not (`docs/BUGS.md` B85).
let wanted = self.block_geometry(uid, mtime, fsize).spans(offset, end);
let (Some(&head), Some(&tail)) = (wanted.first(), wanted.last()) else {
return Ok(out);
@@ -939,28 +961,67 @@ impl Core {
}
if !misses.is_empty() {
let fetched = self.rt.block_on(async {
// Resolve the file's keys and block table once, then read every
// missing block through the shared reader. Previously each block
// called `download_range`, which redid that resolution per block.
let reader = self.revision_reader(uid, mtime, fsize).await?;
// One deadline for the whole read, including any wait for a lost
// link. A fetch that failed because the link went down is tried
// once more when it is back, so a blip is a slow read, not an `EIO`.
let deadline = Instant::now() + READ_FETCH_TIMEOUT;
let mut retried = false;
let fetched = loop {
if !self.is_online() {
let wait =
READ_OFFLINE_WAIT.min(deadline.saturating_duration_since(Instant::now()));
if !self.wait_online(wait) {
debug!(%uid, offset, len, "read of uncached blocks while offline; failing it");
return Err(Errno::EIO);
}
}
let fetch = async {
// Resolve the file's keys and block table once, then read every
// missing block through the shared reader. Previously each block
// called `download_range`, which redid that resolution per block.
let reader = self.revision_reader(uid, mtime, fsize).await?;
let mut set = tokio::task::JoinSet::new();
for &span in &misses {
let (core, reader, uid) = (self.clone(), reader.clone(), uid.clone());
set.spawn(async move {
core.fetch_block(&reader, &uid, mtime, fsize, span, cache_blocks)
.await
.map(|bytes| (span.idx, bytes))
});
let mut set = tokio::task::JoinSet::new();
for &span in &misses {
let (core, reader, uid) = (self.clone(), reader.clone(), uid.clone());
set.spawn(async move {
core.fetch_block(&reader, &uid, mtime, fsize, span, cache_blocks)
.await
.map(|bytes| (span.idx, bytes))
});
}
let mut out = Vec::with_capacity(misses.len());
while let Some(joined) = set.join_next().await {
// A join error means the task panicked; surface it as EIO.
out.push(joined.map_err(|_| Errno::EIO)??);
}
Ok::<_, Errno>(out)
};
// Dropping the fetch on the deadline aborts the block tasks with
// it; blocks that already landed stay cached for the retry. The
// timer is built inside the runtime: a FUSE worker has no reactor
// of its own, and building it out here panicked every remote read.
let budget = deadline.saturating_duration_since(Instant::now());
match self
.rt
.block_on(async { tokio::time::timeout(budget, fetch).await })
{
Ok(Ok(fetched)) => break fetched,
// The fetch saw the link go down; the top of the loop waits
// for it within what is left of the deadline.
Ok(Err(_)) if !retried && !self.is_online() => retried = true,
Ok(Err(e)) => return Err(e),
Err(_) => {
warn!(
%uid, offset, len, secs = READ_FETCH_TIMEOUT.as_secs(),
"read got no answer from the network in time; failing it"
);
self.evict_reader(uid);
self.mark_offline("read", &crate::link::no_answer(READ_FETCH_TIMEOUT));
return Err(Errno::EIO);
}
}
let mut out = Vec::with_capacity(misses.len());
while let Some(joined) = set.join_next().await {
// A join error means the task panicked; surface it as EIO.
out.push(joined.map_err(|_| Errno::EIO)??);
}
Ok::<_, Errno>(out)
})?;
};
for (bidx, bytes) in fetched {
blocks[(bidx - first) as usize] = Some(bytes);
}
@@ -977,7 +1038,7 @@ impl Core {
// — the cache validates it on read, the network path in `read_block`
// — so this refuses rather than quietly contributing fewer bytes
// than the range covers, which is what turned a bad block into a
// truncated file (bugs.md B84).
// truncated file (`docs/BUGS.md` B84).
if block.len() as u64 != span.len {
error!(
%uid, bidx = span.idx, len = block.len(), expected = span.len, fsize,
@@ -1009,37 +1070,121 @@ impl Core {
offset: u64,
size: u64,
) -> Result<Vec<u8>, Errno> {
if offset >= len || size == 0 {
return Ok(Vec::new());
let read = assemble_open_read(file, len, uid, base_size, written, offset, size, |s, n| {
self.read_range(uid, base_mtime, base_size, s, n, true)
.map(Some)
})?;
Ok(read.unwrap_or_default())
}
/// [`Core::serve_open_read`] from bytes already on this machine only:
/// `Ok(None)` the moment a range of the base would have to be downloaded.
///
/// The dispatch loop answers a read on a write handle with this, and hands
/// it to a worker only when it says `None`. Fetching the base there held
/// every request of the mount for as long as the download took, up to the
/// read deadline (`docs/BUGS.md` B227).
#[allow(clippy::too_many_arguments)]
pub(super) fn serve_open_read_local(
&self,
file: &Arc<File>,
len: u64,
uid: &NodeUid,
base_mtime: i64,
base_size: u64,
written: &Intervals,
offset: u64,
size: u64,
) -> Result<Option<Vec<u8>>, Errno> {
assemble_open_read(file, len, uid, base_size, written, offset, size, |s, n| {
Ok(self.cached_range(uid, base_mtime, base_size, s, n))
})
}
/// Bytes `[offset, offset + size)` of file `ino` as `read` would serve
/// them, but only from this machine: an open write handle, a queued write,
/// or the caches. `Ok(None)` the moment any of it would have to be
/// downloaded, and for a size still provisional (B100), which only the
/// network settles. Short or empty past the end of the file.
///
/// `copy_file_range` answers with this and leaves anything else to the
/// kernel's read/write copy.
pub(super) fn read_local(
&self,
ino: u64,
offset: u64,
size: u64,
) -> Result<Option<Vec<u8>>, Errno> {
let handle = self.state().active_writes.get(&ino).map(|h| {
(
h.file.clone(),
h.len,
h.uid.clone(),
h.base_mtime,
h.base_size,
h.written.clone(),
)
});
if let Some((file, len, uid, base_mtime, base_size, written)) = handle {
return self.serve_open_read_local(
&file, len, &uid, base_mtime, base_size, &written, offset, size,
);
}
let end = offset.saturating_add(size).min(len);
let mut out = Vec::with_capacity((end - offset) as usize);
for (s, e, authored) in written.segments(offset, end) {
if authored {
let mut buf = vec![0u8; (e - s) as usize];
file.read_exact_at(&mut buf, s).map_err(|err| {
warn!(%uid, error = %err, "scratch read failed");
Errno::EIO
})?;
out.extend_from_slice(&buf);
} else {
let bend = e.min(base_size);
if s < bend {
out.extend_from_slice(&self.read_range(
uid,
base_mtime,
base_size,
s,
bend - s,
true,
)?);
}
// Anything past the base is a hole: zero-fill.
let zeros = e.saturating_sub(s.max(base_size));
out.resize(out.len() + zeros as usize, 0);
let (uid, mtime, fsize, provisional) = {
let st = self.state();
match st.entries.get(&ino) {
Some(e) if e.node.is_file() => (
e.uid.clone(),
e.node.modification_time,
node_size(&e.node),
matches!(
&e.node.kind,
NodeKind::File {
claimed_size: None,
..
}
),
),
Some(_) => return Err(Errno::EISDIR),
None => return Err(Errno::ENOENT),
}
};
let uid = match is_local_uid(&uid) {
true => self.landed_stand_in_uid(&uid).unwrap_or(uid),
false => uid,
};
if let Some(pending) = self.pending.lock().get(&uid).cloned() {
let m = &pending.meta;
let file = File::open(&pending.path).map_err(|e| {
error!(%uid, path = %pending.path.display(), error = %e, "open staged blob failed");
Errno::EIO
})?;
let mut written = Intervals::default();
for &(s, e) in &m.authored {
written.add(s, e);
}
return self.serve_open_read_local(
&Arc::new(file),
m.len,
&uid,
m.base_mtime,
m.base_size,
&written,
offset,
size,
);
}
Ok(out)
// Created here and never written: an empty file.
if is_local_uid(&uid) {
return Ok(Some(Vec::new()));
}
if provisional {
return Ok(None);
}
if offset >= fsize || size == 0 {
return Ok(Some(Vec::new()));
}
Ok(self.cached_range(&uid, mtime, fsize, offset, size.min(fsize - offset)))
}
/// The largest amount [`Core::fill_gaps_cached`] will copy out of the local
@@ -1181,6 +1326,49 @@ impl Core {
}
}
/// The bytes `[offset, offset + size)` of a file open for writing: authored
/// ranges from its scratch `file`, the rest from its base through `base`,
/// and zeros past the base. `Ok(None)` when `base` says it cannot answer.
#[allow(clippy::too_many_arguments)]
fn assemble_open_read(
file: &File,
len: u64,
uid: &NodeUid,
base_size: u64,
written: &Intervals,
offset: u64,
size: u64,
mut base: impl FnMut(u64, u64) -> Result<Option<Vec<u8>>, Errno>,
) -> Result<Option<Vec<u8>>, Errno> {
if offset >= len || size == 0 {
return Ok(Some(Vec::new()));
}
let end = offset.saturating_add(size).min(len);
let mut out = Vec::with_capacity((end - offset) as usize);
for (s, e, authored) in written.segments(offset, end) {
if authored {
let mut buf = vec![0u8; (e - s) as usize];
file.read_exact_at(&mut buf, s).map_err(|err| {
warn!(%uid, error = %err, "scratch read failed");
Errno::EIO
})?;
out.extend_from_slice(&buf);
} else {
let bend = e.min(base_size);
if s < bend {
match base(s, bend - s)? {
Some(bytes) => out.extend_from_slice(&bytes),
None => return Ok(None),
}
}
// Anything past the base is a hole: zero-fill.
let zeros = e.saturating_sub(s.max(base_size));
out.resize(out.len() + zeros as usize, 0);
}
}
Ok(Some(out))
}
#[cfg(test)]
mod block_ring_tests {
use super::*;
@@ -1333,3 +1521,54 @@ mod prefetch_tests {
assert!(prefetch.streams.lock().len() <= MAX_PREFETCH_STREAMS + 1);
}
}
#[cfg(test)]
mod open_read_tests {
use super::*;
use proton_drive_rs::proton_sdk::ids::{LinkId, VolumeId};
fn scratch(bytes: &[u8]) -> (File, PathBuf) {
let path = std::env::temp_dir().join(format!(
"pdfs-open-read-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let mut file = File::options()
.create_new(true)
.read(true)
.write(true)
.open(&path)
.unwrap();
file.write_all(bytes).unwrap();
(file, path)
}
/// The dispatch loop answers from what is on this machine and says so
/// when the base would have to be downloaded, for a worker to do it
/// (`docs/BUGS.md` B227).
#[test]
fn a_read_needing_the_base_says_so_instead_of_fetching() {
let uid = NodeUid::new(VolumeId::from("v"), LinkId::from("f"));
let (file, path) = scratch(b"ABCDEFGH");
let mut written = Intervals::default();
written.add(0, 4);
let authored = assemble_open_read(&file, 8, &uid, 8, &written, 0, 4, |_, _| {
panic!("an authored range never asks the base")
});
assert_eq!(authored.unwrap().as_deref(), Some(&b"ABCD"[..]));
let missing = assemble_open_read(&file, 8, &uid, 8, &written, 2, 6, |_, _| Ok(None));
assert_eq!(missing.unwrap(), None);
let base = assemble_open_read(&file, 10, &uid, 8, &written, 2, 8, |s, n| {
assert_eq!((s, n), (4, 4));
Ok(Some(b"efgh".to_vec()))
});
assert_eq!(base.unwrap().as_deref(), Some(&b"CDefgh\0\0"[..]));
let _ = std::fs::remove_file(path);
}
}
+337
View File
@@ -0,0 +1,337 @@
//! 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 favorites 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 thumbnails = self.upload_thumbnails(&path, &candidate.name);
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,
thumbnails,
metadata,
false,
));
drop(guard);
let new_uid = uploaded.map_err(|e| CoreError::from_api(&e, "re-upload photo"))?;
// Favorite 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-favorite 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.drive.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 = Some(match &outcome {
Ok(summary) => summary.clone(),
Err(e) => RedateSummary {
error: Some(e.to_string()),
dry_run,
..Default::default()
},
});
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);
}
}
+638
View File
@@ -0,0 +1,638 @@
//! Moving a file or folder between two locations without moving its content.
//!
//! `~/ProtonDrive` and every on-demand synced folder are separate FUSE
//! sessions, and a mirror folder is a plain local directory. The kernel refuses
//! `rename(2)` between two mounts with `EXDEV` before the daemon hears of it, so
//! `mv` falls back to copy and delete: every file is downloaded, uploaded again,
//! and the original is trashed.
//!
//! On Proton Drive the same move is one metadata change, because My files and
//! this computer's device folders all live on the main volume. So a move
//! request that names two absolute paths is resolved here, against every
//! location this daemon knows, and done as a server-side move. The local side
//! is then brought level: a mount forgets the node and re-lists, and a mirror
//! folder either renames its copy along or drops it once Drive has it.
use std::collections::HashMap;
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use parking_lot::Mutex;
use pdfs_core::db::{StoredSyncEntry, StoredSyncFolder};
use pdfs_core::{CoreError, CoreResult};
use proton_drive_rs::NodeKind;
use proton_drive_rs::proton_sdk::ids::NodeUid;
use tracing::{info, warn};
use super::state::State;
use super::sync::mirror_subtree_unsynced;
use super::{Core, is_local_uid, parse_uid};
/// How long a move waits for a mirror folder's sync pass to finish before it
/// reports the folder busy. A pass can run for hours on a first sync, so the
/// move does not wait it out.
const MIRROR_LOCK_WAIT: Duration = Duration::from_secs(5);
/// Where one side of a move lives.
enum Place {
/// My files or an on-demand folder: a live FUSE mount. `core` is rooted at
/// that mount and `rel` is relative to its mountpoint.
Mounted { core: Box<Core>, rel: PathBuf },
/// A mirror folder: a local directory kept in step by the sync engine.
/// `rel` is `/`-separated, relative to the folder, as the baseline keys it.
Mirror {
folder: StoredSyncFolder,
rel: String,
},
}
impl Place {
fn mirror_id(&self) -> Option<i64> {
match self {
Place::Mirror { folder, .. } => Some(folder.id),
Place::Mounted { .. } => None,
}
}
fn is_root(&self) -> bool {
match self {
Place::Mounted { rel, .. } => rel.as_os_str().is_empty(),
Place::Mirror { rel, .. } => rel.is_empty(),
}
}
}
/// The mirror folder that holds `abs`, and `abs` relative to it. The deepest
/// folder wins, although folders are not expected to nest.
fn mirror_covering(
folders: Vec<StoredSyncFolder>,
abs: &Path,
) -> Option<(StoredSyncFolder, String)> {
let folder = folders
.into_iter()
.filter(|f| f.mode == "mirror" && abs.starts_with(&f.local_path))
.max_by_key(|f| Path::new(&f.local_path).components().count())?;
let rel = abs.strip_prefix(&folder.local_path).ok()?;
let parts = rel
.components()
.map(|c| match c {
Component::Normal(part) => part.to_str(),
_ => None,
})
.collect::<Option<Vec<_>>>()?;
Some((folder, parts.join("/")))
}
/// `name` inside the folder at `parent`, in baseline form.
fn join_rel(parent: &str, name: &str) -> String {
if parent.is_empty() {
name.to_string()
} else {
format!("{parent}/{name}")
}
}
/// Rename `from` to `to`, failing with `EEXIST` rather than replacing
/// whatever appeared at `to` since it was checked. A filesystem that cannot
/// promise that gets a plain rename after one more look.
fn rename_noreplace(from: &Path, to: &Path) -> std::io::Result<()> {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
let cstr = |p: &Path| {
CString::new(p.as_os_str().as_bytes())
.map_err(|_| std::io::Error::from_raw_os_error(libc::EINVAL))
};
let (c_from, c_to) = (cstr(from)?, cstr(to)?);
// SAFETY: both are valid NUL-terminated paths that outlive the call.
let rc = unsafe {
libc::syscall(
libc::SYS_renameat2,
libc::AT_FDCWD,
c_from.as_ptr(),
libc::AT_FDCWD,
c_to.as_ptr(),
libc::RENAME_NOREPLACE,
)
};
if rc == 0 {
return Ok(());
}
let err = std::io::Error::last_os_error();
match err.raw_os_error() {
Some(libc::EINVAL | libc::ENOSYS) => {
if std::fs::symlink_metadata(to).is_ok() {
return Err(std::io::Error::from_raw_os_error(libc::EEXIST));
}
std::fs::rename(from, to)
}
_ => Err(err),
}
}
/// Whether inode `ino` is `root` or lies under it in the tree. Bounded, so a
/// parent cycle in corrupt data cannot hang the caller.
pub(crate) fn ino_is_under(st: &State, mut ino: u64, root: u64) -> bool {
for _ in 0..MAX_TREE_DEPTH {
if ino == root {
return true;
}
match st.entries.get(&ino) {
Some(entry) if entry.parent != ino => ino = entry.parent,
_ => return false,
}
}
false
}
/// How deep [`ino_is_under`] walks before it gives up.
const MAX_TREE_DEPTH: usize = 1024;
/// Whether `uid` is `root` or lies under it, as far as the nodes table knows.
fn under(core: &Core, root: &NodeUid, uid: &str) -> bool {
let root = root.to_string();
uid == root || matches!(core.db.path_relative_to(&root, uid), Ok(Some(_)))
}
impl Core {
/// Move the file or folder at `src` into the folder at `dest_parent`. Both
/// are absolute local paths and may be in different locations: My files,
/// an on-demand folder, or a mirror folder. Returns the moved name.
///
/// Within one mount this is [`Core::move_to`]. Across locations it
/// is a server-side move followed by local bookkeeping; see the module
/// documentation. Anything the move could lose is refused up front.
pub(crate) fn move_between(&self, src: &Path, dest_parent: &Path) -> CoreResult<String> {
let source = self.locate(src)?;
let dest = self.locate(dest_parent)?;
if let (
Place::Mounted { core, rel },
Place::Mounted {
core: dest_core,
rel: dest_rel,
},
) = (&source, &dest)
&& Arc::ptr_eq(&core.root, &dest_core.root)
{
return core.move_to(rel, dest_rel);
}
if source.is_root() {
return Err(CoreError::invalid(format!(
"{} is the root of a synced location and cannot be moved",
src.display()
)));
}
if dest_parent.starts_with(src) {
return Err(CoreError::invalid("cannot move a folder into itself"));
}
let name = src
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| CoreError::invalid(format!("{} has no usable name", src.display())))?
.to_string();
// Hold every mirror folder involved, lowest id first, so no sync pass
// sees the move half done and two moves cannot deadlock.
let mut ids: Vec<i64> = [source.mirror_id(), dest.mirror_id()]
.into_iter()
.flatten()
.collect();
ids.sort_unstable();
ids.dedup();
let locks: Vec<Arc<Mutex<()>>> = ids.iter().map(|&id| self.sync_lock(id)).collect();
let mut guards = Vec::with_capacity(locks.len());
for lock in &locks {
guards.push(lock.try_lock_for(MIRROR_LOCK_WAIT).ok_or_else(|| {
CoreError::conflict("a synced folder is busy syncing; try again when it is done")
})?);
}
let mut baselines: HashMap<i64, HashMap<String, StoredSyncEntry>> = HashMap::new();
for &id in &ids {
let baseline = self
.db
.sync_entries(id)
.map_err(|e| CoreError::internal(format!("db: {e}")))?;
baselines.insert(id, baseline);
}
let src_uid = self.source_uid(&source, &baselines)?;
let dest_uid = self.dest_uid(&dest, &baselines, &name)?;
if src_uid.volume_id != dest_uid.volume_id {
return Err(CoreError::invalid(
"the two locations are on different Proton Drive volumes; copy it instead",
));
}
if under(self, &src_uid, &dest_uid.to_string()) {
return Err(CoreError::invalid("cannot move a folder into itself"));
}
// Mirror to mirror renames the local copy along, so it needs nothing
// proven about it. Another filesystem makes that impossible, and the
// pair then drops the source copy like any other move out of a mirror.
let mut renamed = None;
if let (
Place::Mirror { folder, rel },
Place::Mirror {
folder: dest_folder,
rel: dest_rel,
},
) = (&source, &dest)
{
let from = src.to_path_buf();
let to = dest_parent.join(&name);
match rename_noreplace(&from, &to) {
Ok(()) => renamed = Some((from, to)),
Err(e) if e.raw_os_error() == Some(libc::EXDEV) => {
info!(from = %folder.local_path, to = %dest_folder.local_path,
"move: mirror folders are on different filesystems");
}
Err(e) if e.raw_os_error() == Some(libc::EEXIST) => {
return Err(CoreError::conflict(format!(
"{name} already exists in {}",
dest_parent.display()
)));
}
Err(e) => {
return Err(CoreError::internal(format!(
"could not move {} to {}: {e}",
rel,
join_rel(dest_rel, &name)
)));
}
}
}
if renamed.is_none()
&& let Place::Mirror { folder, rel } = &source
&& let Some(reason) =
mirror_subtree_unsynced(Path::new(&folder.local_path), rel, &baselines[&folder.id])
{
return Err(CoreError::conflict(format!(
"{name} is not fully synced yet ({reason}); wait for the sync to finish"
)));
}
if let Err(e) = self.move_remote(&src_uid, &dest_uid) {
if let Some((from, to)) = &renamed
&& let Err(back) = std::fs::rename(to, from)
{
warn!(from = %to.display(), to = %from.display(), error = %back,
"move: could not put the local copy back after the move failed");
}
return Err(CoreError::from_api(&e, "move"));
}
self.settle_source(
&source,
&src_uid,
renamed.is_some(),
&dest,
&dest_uid,
&name,
src,
);
self.settle_dest(&dest, &dest_uid, &name);
drop(guards);
if renamed.is_none()
&& let Place::Mirror { folder, .. } = &dest
{
// Nothing local exists yet; the folder's next pass downloads it.
self.sync_now(Some(folder.id));
}
Ok(name)
}
/// Which location `abs` is in. A mount is looked for first: a mirror folder
/// cannot live inside a FUSE mount, and a mount is what the kernel sees.
fn locate(&self, abs: &Path) -> CoreResult<Place> {
if let Some((core, rel)) = self.rooted_at(abs) {
if !rel
.components()
.all(|c| matches!(c, Component::Normal(_) | Component::CurDir))
{
return Err(CoreError::invalid(format!(
"{} escapes its location",
abs.display()
)));
}
return Ok(Place::Mounted {
core: Box::new(core),
rel,
});
}
let folders = self
.db
.sync_folder_list()
.map_err(|e| CoreError::internal(format!("db: {e}")))?;
mirror_covering(folders, abs)
.map(|(folder, rel)| Place::Mirror { folder, rel })
.ok_or_else(|| {
CoreError::invalid(format!(
"{} is not in a Proton Drive location",
abs.display()
))
})
}
/// The node being moved, once everything that must hold before it leaves
/// its location has been checked.
fn source_uid(
&self,
source: &Place,
baselines: &HashMap<i64, HashMap<String, StoredSyncEntry>>,
) -> CoreResult<NodeUid> {
match source {
Place::Mounted { core, rel } => {
let (ino, uid) = core.resolve(rel)?;
if is_local_uid(&uid) {
return Err(CoreError::conflict(
"it is not uploaded yet; wait for the upload to finish",
));
}
let old_parent = core.source_parent_uid(ino, rel)?;
for authority in [&uid, &old_parent] {
core.require_uid_writable(authority)
.map_err(|e| core.errno_error(e, "move access"))?;
}
if self.has_pending_work_under(ino, &uid)? {
return Err(CoreError::conflict(
"it has changes that are not uploaded yet; wait for the upload to finish",
));
}
Ok(uid)
}
Place::Mirror { folder, rel } => baselines[&folder.id]
.get(rel)
.and_then(|entry| entry.remote_uid.as_deref())
.and_then(parse_uid)
.ok_or_else(|| {
CoreError::conflict(
"it is not on Proton Drive yet; wait for the sync to finish",
)
}),
}
}
/// The folder the node moves into. Refuses one that already holds `name`
/// on this disk; Drive refuses a clash on its side by itself.
fn dest_uid(
&self,
dest: &Place,
baselines: &HashMap<i64, HashMap<String, StoredSyncEntry>>,
name: &str,
) -> CoreResult<NodeUid> {
let uid = match dest {
Place::Mounted { core, rel } => {
let (ino, uid) = core.resolve(rel)?;
let is_folder = core
.state()
.entries
.get(&ino)
.is_some_and(|entry| matches!(entry.node.kind, NodeKind::Folder));
if !is_folder {
return Err(CoreError::invalid("the destination is not a folder"));
}
core.require_uid_writable(&uid)
.map_err(|e| core.errno_error(e, "move access"))?;
uid
}
Place::Mirror { folder, rel } => {
let local = Path::new(&folder.local_path).join(rel);
if !local.is_dir() {
return Err(CoreError::invalid("the destination is not a folder"));
}
if std::fs::symlink_metadata(local.join(name)).is_ok() {
return Err(CoreError::conflict(format!(
"{name} already exists in the destination"
)));
}
let uid = if rel.is_empty() {
Some(folder.remote_uid.as_str())
} else {
baselines[&folder.id]
.get(rel)
.and_then(|entry| entry.remote_uid.as_deref())
};
uid.and_then(parse_uid).ok_or_else(|| {
CoreError::conflict(
"the destination is not on Proton Drive yet; wait for the sync to finish",
)
})?
}
};
if is_local_uid(&uid) {
return Err(CoreError::conflict(
"the destination is not uploaded yet; wait for the upload to finish",
));
}
Ok(uid)
}
/// Whether the queue owes Drive anything for `uid` (inode `ino`) or a node
/// under it, or a file under it is open for writing. Moving such a node away
/// would strand the pending change against a path that no longer leads to
/// it.
fn has_pending_work_under(&self, ino: u64, uid: &NodeUid) -> CoreResult<bool> {
let ops = self
.db
.pending_ops()
.map_err(|e| CoreError::internal(format!("db: {e}")))?;
if ops.iter().any(|op| {
under(self, uid, &op.uid)
|| op
.parent_uid
.as_deref()
.is_some_and(|p| under(self, uid, p))
}) {
return Ok(true);
}
// Open handles are placed in the tree by inode, not by uid in the nodes
// table. A file opened just after its create landed holds the
// placeholder uid until the tree catches up, and the nodes table no
// longer has that uid, so the move went ahead under an open write.
let mut writing = false;
self.for_each_state(|st| {
writing = st
.active_writes
.keys()
.chain(&st.releasing)
.any(|&open| ino_is_under(st, open, ino));
});
Ok(writing)
}
/// Bring the source location level with a move Drive has made.
#[allow(clippy::too_many_arguments)]
fn settle_source(
&self,
source: &Place,
uid: &NodeUid,
renamed: bool,
dest: &Place,
dest_uid: &NodeUid,
name: &str,
src: &Path,
) {
match source {
Place::Mounted { core, rel } => {
// Moved everywhere, so no mount keeps serving it under the old
// path and its row stays (`docs/BUGS.md` B117). The content
// cache is keyed by uid and stays valid.
self.relocate_everywhere(uid, dest_uid, name);
core.invalidate_parent_listing(rel);
}
Place::Mirror { folder, rel } if renamed => {
let Place::Mirror {
folder: dest_folder,
rel: dest_rel,
} = dest
else {
unreachable!("a local rename needs a mirror destination");
};
if let Err(e) = self.db.sync_entries_move(
folder.id,
rel,
dest_folder.id,
&join_rel(dest_rel, name),
) {
// The next passes see a copy with no baseline on one side
// and a baseline with no copy on the other. That uploads
// the files again and trashes the moved node, but loses
// nothing.
warn!(error = ?e, "move: could not carry the sync baseline along");
}
}
Place::Mirror { folder, rel } => {
// Drive had all of it when the move was checked, so the local
// copy is only a stale duplicate now. Something written into it
// since would go with it, so look again first; if anything is
// new, the copy stays and the next pass uploads it as new.
let changed = match self.db.sync_entries(folder.id) {
Ok(baseline) => {
mirror_subtree_unsynced(Path::new(&folder.local_path), rel, &baseline)
}
Err(e) => Some(format!("db: {e}")),
};
if let Err(e) = self.db.sync_entries_remove_subtree(folder.id, rel) {
warn!(error = ?e, "move: could not drop the sync baseline");
}
if let Some(reason) = changed {
warn!(path = %src.display(), reason,
"move: the local copy changed during the move; keeping it");
return;
}
let removed = if src.is_dir() {
std::fs::remove_dir_all(src)
} else {
std::fs::remove_file(src)
};
if let Err(e) = removed {
warn!(path = %src.display(), error = %e,
"move: could not remove the local copy after the move");
}
}
}
}
/// Bring the destination location level with a move Drive has made.
fn settle_dest(&self, dest: &Place, uid: &NodeUid, name: &str) {
if let Place::Mounted { .. } = dest {
self.invalidate_children_of(uid);
// A lookup that missed before the move may be cached as absent.
self.for_each_mount(|st, notify| {
if let Some(&ino) = st.by_uid.get(uid) {
notify.inval_entry(ino, name.to_string());
}
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn folder(id: i64, local_path: &str, mode: &str) -> StoredSyncFolder {
StoredSyncFolder {
id,
local_path: local_path.to_string(),
remote_uid: format!("vol~root{id}"),
remote_share_id: String::new(),
mode: mode.to_string(),
pending_mode: None,
state: "idle".to_string(),
last_sync: 0,
}
}
#[test]
fn mirror_covering_finds_the_mirror_folder_and_the_path_inside_it() {
let folders = vec![folder(1, "/home/u/Docs", "mirror")];
let (found, rel) = mirror_covering(folders, Path::new("/home/u/Docs/a/b.txt")).unwrap();
assert_eq!(found.id, 1);
assert_eq!(rel, "a/b.txt");
}
#[test]
fn mirror_covering_gives_an_empty_path_for_the_folder_itself() {
let folders = vec![folder(1, "/home/u/Docs", "mirror")];
let (_, rel) = mirror_covering(folders, Path::new("/home/u/Docs")).unwrap();
assert_eq!(rel, "");
}
#[test]
fn mirror_covering_prefers_the_deepest_folder() {
let folders = vec![
folder(1, "/home/u/Docs", "mirror"),
folder(2, "/home/u/Docs/Work", "mirror"),
];
let (found, rel) = mirror_covering(folders, Path::new("/home/u/Docs/Work/x")).unwrap();
assert_eq!(found.id, 2);
assert_eq!(rel, "x");
}
#[test]
fn mirror_covering_skips_on_demand_folders_and_name_prefixed_siblings() {
let folders = || {
vec![
folder(1, "/home/u/Docs", "mirror"),
folder(2, "/home/u/Media", "ondemand"),
]
};
assert!(mirror_covering(folders(), Path::new("/home/u/Media/x")).is_none());
assert!(mirror_covering(folders(), Path::new("/home/u/Docs2/x")).is_none());
}
#[test]
fn mirror_covering_refuses_a_path_that_climbs_out() {
let folders = vec![folder(1, "/home/u/Docs", "mirror")];
assert!(mirror_covering(folders, Path::new("/home/u/Docs/../etc")).is_none());
}
#[test]
fn join_rel_names_a_child_of_the_folder_root_without_a_slash() {
assert_eq!(join_rel("", "a"), "a");
assert_eq!(join_rel("x/y", "a"), "x/y/a");
}
#[test]
fn a_local_rename_never_replaces_what_is_already_there() {
let dir = std::env::temp_dir().join(format!("pdfs-relocate-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let (a, b, c) = (dir.join("a"), dir.join("b"), dir.join("c"));
std::fs::write(&a, b"moved").unwrap();
std::fs::write(&b, b"kept").unwrap();
let err = rename_noreplace(&a, &b).unwrap_err();
assert_eq!(err.raw_os_error(), Some(libc::EEXIST));
assert_eq!(std::fs::read(&b).unwrap(), b"kept");
assert!(a.exists());
rename_noreplace(&a, &c).unwrap();
assert!(!a.exists());
assert_eq!(std::fs::read(&c).unwrap(), b"moved");
std::fs::remove_dir_all(&dir).unwrap();
}
}
+1 -1
View File
@@ -232,7 +232,7 @@ impl Core {
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))
.block_on(self.drive.download_revision_to(uid, revision_id, &mut out))
.map_err(|e| CoreError::from_api(&e, "download revision"))
.and_then(|()| {
use std::io::Write as _;
+295 -41
View File
@@ -9,18 +9,20 @@
use std::path::Path;
use pdfs_core::control::{
BookmarkInfo, DirEntry, InvitationInfo, PublicLinkInfo, ShareEntry, ShareEntryKind, SharedItem,
BookmarkInfo, DirEntry, InvitationInfo, LinkChange, 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};
use super::{Core, node_size, parse_uid, public_link_info, role_from_str, role_to_str};
/// A node from a *shared* listing as a [`DirEntry`].
///
@@ -47,16 +49,35 @@ fn shared_entry_of(core: &Core, n: Node) -> DirEntry {
.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,
pinned_by_folder: 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,
trashed_at: 0,
trashed_from: None,
issue: None,
}
}
@@ -347,6 +368,89 @@ impl Core {
Ok(public_link_info(link))
}
/// Change the public link `id` on the node at `rel`, keeping its URL.
pub(crate) fn update_public_link(
&self,
rel: &Path,
id: &str,
role: Option<&str>,
password: LinkChange<String>,
expires: LinkChange<i64>,
) -> CoreResult<PublicLinkInfo> {
let (_ino, uid) = self.resolve(rel)?;
self.update_public_link_for_uid(&uid, id, role, password, expires)
}
/// [`Core::update_public_link`] for a node addressed through any daemon
/// location.
pub(crate) fn update_public_link_by_uid(
&self,
uid: &str,
id: &str,
role: Option<&str>,
password: LinkChange<String>,
expires: LinkChange<i64>,
) -> CoreResult<PublicLinkInfo> {
let uid = self.resolve_anywhere(uid)?;
self.update_public_link_for_uid(&uid, id, role, password, expires)
}
/// The SDK sets all three options on every update, so each `Keep` is
/// filled in from the link as it stands.
fn update_public_link_for_uid(
&self,
uid: &NodeUid,
id: &str,
role: Option<&str>,
password: LinkChange<String>,
expires: LinkChange<i64>,
) -> CoreResult<PublicLinkInfo> {
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"))?;
let role = match role {
Some(role) => role_from_str(role)?,
None => link.role,
};
let unchanged = role == link.role
&& matches!(password, LinkChange::Keep)
&& matches!(expires, LinkChange::Keep);
if unchanged {
return Ok(public_link_info(link));
}
let password = match password {
LinkChange::Keep if link.has_custom_password => {
// Sending `None` here would quietly remove the password.
Some(link.custom_password.clone().ok_or_else(|| {
CoreError::conflict(
"the link's password could not be read; set a new one or remove it",
)
})?)
}
LinkChange::Keep | LinkChange::Remove => None,
LinkChange::Set(password) if password.is_empty() => {
return Err(CoreError::invalid("the new password is empty"));
}
LinkChange::Set(password) => Some(password),
};
let expires = match expires {
LinkChange::Keep => link.expiration_time,
LinkChange::Remove => None,
LinkChange::Set(at) => Some(at),
};
let link = self
.rt
.block_on(
self.client
.update_public_link(uid, role, password.as_deref(), expires),
)
.map_err(|e| CoreError::from_api(&e, "update 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)?;
@@ -372,6 +476,66 @@ impl Core {
.map_err(|e| CoreError::from_api(&e, "remove public link"))
}
/// Stop sharing the node at `rel`: remove its public link, then every
/// external invitation, Proton invitation and member. The SDK has no call
/// that deletes a share outright, so this composes the per-entry ones.
pub(crate) fn stop_sharing(&self, rel: &Path) -> CoreResult<StopSharingOutcome> {
let (_ino, uid) = self.resolve(rel)?;
self.stop_sharing_for_uid(&uid)
}
/// [`Core::stop_sharing`] for a node addressed through any daemon location.
pub(crate) fn stop_sharing_by_uid(&self, uid: &str) -> CoreResult<StopSharingOutcome> {
let uid = self.resolve_anywhere(uid)?;
self.stop_sharing_for_uid(&uid)
}
/// Each step runs even when an earlier one failed, so a single stuck
/// member does not leave the public link up. The link goes first: it is the
/// widest exposure. Only a failure to list what is there fails the whole
/// request; a failed removal is counted in the outcome.
fn stop_sharing_for_uid(&self, uid: &NodeUid) -> CoreResult<StopSharingOutcome> {
let mut outcome = StopSharingOutcome::default();
let link = self
.rt
.block_on(self.client.get_public_link(uid))
.map_err(|e| CoreError::from_api(&e, "get public link"))?;
if let Some(link) = link {
let result = self.rt.block_on(self.client.remove_public_link(&link));
outcome.record("the public link", result);
}
let external = self
.rt
.block_on(self.client.list_external_invitations(uid))
.map_err(|e| CoreError::from_api(&e, "list external invitations"))?;
for ext in external {
let result = self
.rt
.block_on(self.client.delete_external_invitation(&ext));
outcome.record(&ext.invitee_email, result);
}
let invitations = self
.rt
.block_on(self.client.list_share_invitations(uid))
.map_err(|e| CoreError::from_api(&e, "list invitations"))?;
for inv in invitations {
let result = self.rt.block_on(self.client.delete_invitation(&inv));
outcome.record(&inv.invitee_email, result);
}
let members = self
.rt
.block_on(self.client.list_share_members(uid))
.map_err(|e| CoreError::from_api(&e, "list members"))?;
for member in members {
let result = self.rt.block_on(self.client.remove_member(&member));
outcome.record(&member.email, result);
}
if !outcome.failed.is_empty() {
debug!(%uid, failed = ?outcome.failed, "stop sharing: some entries remain");
}
Ok(outcome)
}
// ---- shared with me ---------------------------------------------------
/// List nodes shared with me that I have accepted.
@@ -385,7 +549,7 @@ impl Core {
}
let nodes = self
.rt
.block_on(self.client.enumerate_nodes(&uids))
.block_on(self.drive.enumerate_nodes(&uids))
.map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?;
Ok(nodes
.into_iter()
@@ -403,14 +567,14 @@ impl Core {
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))
.block_on(self.drive.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))
.block_on(self.drive.enumerate_nodes(&child_uids))
.map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?;
Ok(nodes
.into_iter()
@@ -431,7 +595,7 @@ impl Core {
parse_uid(uid).ok_or_else(|| CoreError::invalid(format!("invalid uid: {uid}")))?;
let node = self
.rt
.block_on(self.client.get_node(&uid))
.block_on(self.drive.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() {
@@ -532,10 +696,10 @@ impl Core {
/// 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"))?;
let q = self.block_on_bounded(self.drive.quota()).map_err(|e| {
self.lost_link(&e, "account quota");
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))
}
@@ -554,11 +718,22 @@ impl Core {
{
return Some((max_space, used_space));
}
match self.account_quota() {
let last = || self.quota.lock().map(|(_, max, used)| (max, used));
if !self.is_online()
|| self
.quota_refreshing
.swap(true, std::sync::atomic::Ordering::AcqRel)
{
return last();
}
let refreshed = self.account_quota();
self.quota_refreshing
.store(false, std::sync::atomic::Ordering::Release);
match refreshed {
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))
last()
}
}
}
@@ -570,6 +745,12 @@ impl Core {
/// 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.
///
/// A share with no member, no invitation and no link is left out: it is
/// shared with no one. Stop Sharing leaves exactly that behind, since the SDK
/// cannot delete a share, and Drive keeps listing it until its backend gets
/// round to removing it, minutes later. A share whose details could not all
/// be read stays listed, so a failed lookup never hides a live share.
pub(crate) fn list_shared_by_me(&self) -> CoreResult<Vec<SharedItem>> {
let uids = self
.rt
@@ -580,7 +761,7 @@ impl Core {
}
let nodes = self
.rt
.block_on(self.client.enumerate_nodes(&uids))
.block_on(self.drive.enumerate_nodes(&uids))
.map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?;
let mut items = Vec::with_capacity(nodes.len());
for n in nodes {
@@ -588,27 +769,30 @@ impl Core {
let members = self
.rt
.block_on(self.client.list_share_members(&uid))
.map(|m| m.len())
.unwrap_or(0);
.map(|m| m.len());
let proton_invites = self
.rt
.block_on(self.client.list_share_invitations(&uid))
.map(|i| i.len())
.unwrap_or(0);
.map(|i| i.len());
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);
.map(|i| i.len());
let link = self.rt.block_on(self.client.get_public_link(&uid));
if let (Ok(0), Ok(0), Ok(0), Ok(None)) =
(&members, &proton_invites, &external_invites, &link)
{
debug!(%uid, "shared by me: skipping an emptied share");
continue;
}
let members = members.unwrap_or(0);
let proton_invites = proton_invites.unwrap_or(0);
let external_invites = external_invites.unwrap_or(0);
let link = link.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,
@@ -624,33 +808,103 @@ impl Core {
/// 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
// Relative to whichever root the node sits under: a node browsed
// through a sync folder is under that fork's root, not My Files
// (`docs/BUGS.md` B74).
walk_to_root(&self.state(), uid)
}
}
/// 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 —
/// What [`Core::stop_sharing`] managed to remove.
#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) struct StopSharingOutcome {
/// Entries removed (the link, invitations and members alike).
pub(crate) removed: usize,
/// What could not be removed, by email (or "the public link"), with why.
pub(crate) failed: Vec<(String, String)>,
}
impl StopSharingOutcome {
fn record<E: std::fmt::Display>(&mut self, what: &str, result: Result<(), E>) {
match result {
Ok(()) => self.removed += 1,
Err(e) => self.failed.push((what.to_string(), e.to_string())),
}
}
/// The reply for the control socket: a sentence for a full success, an
/// error naming what is left otherwise.
pub(crate) fn into_result(self) -> Result<String, String> {
if self.failed.is_empty() {
return Ok(match self.removed {
0 => "it was not shared".to_string(),
1 => "stopped sharing: removed 1 entry".to_string(),
n => format!("stopped sharing: removed {n} entries"),
});
}
let left = self
.failed
.iter()
.map(|(what, why)| format!("{what} ({why})"))
.collect::<Vec<_>>()
.join(", ");
Err(format!(
"removed {} of {}; still shared: {left}",
self.removed,
self.removed + self.failed.len()
))
}
}
/// Walk the tree from `uid` up to the root it sits under, building the path.
/// `None` if the node has never been interned, 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 {
while !st.roots.contains(&ino) {
let entry = st.entries.get(&ino)?;
// An orphan is its own parent without being a root.
if entry.parent == ino {
return None;
}
parts.push(entry.node.name.clone());
ino = entry.parent;
}
parts.reverse();
Some(parts.join("/"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stopping_a_share_that_is_gone_says_so() {
let outcome = StopSharingOutcome::default();
assert_eq!(outcome.into_result().unwrap(), "it was not shared");
}
#[test]
fn a_failed_removal_names_what_is_still_shared() {
let mut outcome = StopSharingOutcome::default();
outcome.record::<String>("the public link", Ok(()));
outcome.record("bob@example.com", Err("forbidden"));
outcome.record::<String>("carol@example.com", Ok(()));
assert_eq!(
outcome.into_result().unwrap_err(),
"removed 2 of 3; still shared: bob@example.com (forbidden)"
);
}
#[test]
fn a_full_removal_counts_the_entries() {
let mut outcome = StopSharingOutcome::default();
outcome.record::<String>("the public link", Ok(()));
outcome.record::<String>("bob@example.com", Ok(()));
assert_eq!(
outcome.into_result().unwrap(),
"stopped sharing: removed 2 entries"
);
}
}
+1 -1
View File
@@ -6,7 +6,7 @@
//! 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).
//! longer exists (`docs/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
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+9
View File
@@ -0,0 +1,9 @@
//! The simulation tests of `docs/TESTING.md`: a fake Drive with
//! the faults the real one has, and the tools to drive daemons against it.
pub(crate) mod daemon;
pub(crate) mod fake_drive;
pub(crate) mod model;
pub(crate) mod rng;
pub(crate) mod run;
pub(crate) mod watchdog;
+445
View File
@@ -0,0 +1,445 @@
//! The reference file system of the simulation (`docs/TESTING.md`,
//! "Simulation runs", property 1): what each syscall a simulated client makes should
//! answer, and the tree it should leave, on a POSIX file system.
//!
//! A [`Model`] covers one client's own folder; paths are relative to it. The
//! same type is used for the trees read back from a mount and from Drive.
use std::collections::BTreeMap;
use super::rng::Rng;
/// A tree by path: `None` is a folder, `Some` a file's content.
pub(crate) type Tree = BTreeMap<String, Option<Vec<u8>>>;
/// One file system call, or the few a program makes in a row.
#[derive(Clone, Debug)]
pub(crate) enum FsOp {
/// `open(O_CREAT | O_TRUNC)`, write `data`, close.
Create {
path: String,
data: Vec<u8>,
},
/// Open an existing file, write `data` at `offset` (past the end leaves
/// a hole), close.
Write {
path: String,
offset: u64,
data: Vec<u8>,
},
/// Open an existing file and `ftruncate` it to `len`.
Truncate {
path: String,
len: u64,
},
Mkdir {
path: String,
},
Rename {
from: String,
to: String,
},
Unlink {
path: String,
},
/// Open a file, unlink it, append `data`, read it all back, close.
UnlinkOpen {
path: String,
data: Vec<u8>,
},
Rmdir {
path: String,
},
}
impl FsOp {
/// The paths the op names, for the log.
pub(crate) fn describe(&self) -> String {
match self {
FsOp::Create { path, data } => format!("create {path} ({} bytes)", data.len()),
FsOp::Write { path, offset, data } => {
format!("write {path} at {offset} ({} bytes)", data.len())
}
FsOp::Truncate { path, len } => format!("truncate {path} to {len}"),
FsOp::Mkdir { path } => format!("mkdir {path}"),
FsOp::Rename { from, to } => format!("rename {from} -> {to}"),
FsOp::Unlink { path } => format!("unlink {path}"),
FsOp::UnlinkOpen { path, data } => {
format!("unlink {path} while open, then append {} bytes", data.len())
}
FsOp::Rmdir { path } => format!("rmdir {path}"),
}
}
}
/// The tree one client should have, and what its syscalls should answer.
#[derive(Clone, Debug, Default)]
pub(crate) struct Model {
tree: Tree,
}
/// Names are drawn from small pools, so ops collide: a create over an existing
/// file, a rename over another, a mkdir of a name that is taken.
const FILE_NAMES: [&str; 6] = ["a.txt", "b.txt", "c.bin", "d", "e.md", "f.txt"];
const DIR_NAMES: [&str; 3] = ["x", "y", "z"];
impl Model {
pub(crate) fn tree(&self) -> &Tree {
&self.tree
}
pub(crate) fn content(&self, path: &str) -> Option<&Vec<u8>> {
self.tree.get(path)?.as_ref()
}
/// What `op` should answer on a POSIX file system, applying it on success.
pub(crate) fn apply(&mut self, op: &FsOp) -> Result<(), i32> {
match op {
FsOp::Create { path, data } => {
self.check_parent(path)?;
match self.tree.get(path) {
Some(None) => Err(libc::EISDIR),
_ => {
self.tree.insert(path.clone(), Some(data.clone()));
Ok(())
}
}
}
FsOp::Write { path, offset, data } => {
let content = self.file_mut(path)?;
// Writing nothing changes nothing, even past the end.
if data.is_empty() {
return Ok(());
}
let end = *offset as usize + data.len();
if content.len() < end {
content.resize(end, 0);
}
content[*offset as usize..end].copy_from_slice(data);
Ok(())
}
FsOp::Truncate { path, len } => {
self.file_mut(path)?.resize(*len as usize, 0);
Ok(())
}
FsOp::Mkdir { path } => {
self.check_parent(path)?;
if self.tree.contains_key(path) {
return Err(libc::EEXIST);
}
self.tree.insert(path.clone(), None);
Ok(())
}
FsOp::Rename { from, to } => self.rename(from, to),
FsOp::Unlink { path } | FsOp::UnlinkOpen { path, .. } => {
self.check_parent(path)?;
match self.tree.get(path) {
None => Err(libc::ENOENT),
Some(None) => Err(libc::EISDIR),
Some(Some(_)) => {
self.tree.remove(path);
Ok(())
}
}
}
FsOp::Rmdir { path } => {
self.check_parent(path)?;
match self.tree.get(path) {
None => Err(libc::ENOENT),
Some(Some(_)) => Err(libc::ENOTDIR),
Some(None) if self.has_children(path) => Err(libc::ENOTEMPTY),
Some(None) => {
self.tree.remove(path);
Ok(())
}
}
}
}
}
fn rename(&mut self, from: &str, to: &str) -> Result<(), i32> {
self.check_parent(from)?;
let Some(moving) = self.tree.get(from).cloned() else {
return Err(libc::ENOENT);
};
self.check_parent(to)?;
if from == to {
return Ok(());
}
if is_below(to, from) {
return Err(libc::EINVAL);
}
match (&moving, self.tree.get(to)) {
(None, Some(Some(_))) => return Err(libc::ENOTDIR),
(Some(_), Some(None)) => return Err(libc::EISDIR),
(None, Some(None)) if self.has_children(to) => return Err(libc::ENOTEMPTY),
_ => {}
}
let below: Vec<(String, Option<Vec<u8>>)> = self
.tree
.iter()
.filter(|(path, _)| is_below(path, from))
.map(|(path, entry)| (path.clone(), entry.clone()))
.collect();
self.tree.remove(from);
self.tree.insert(to.to_owned(), moving);
for (path, entry) in below {
self.tree.remove(&path);
self.tree
.insert(format!("{to}{}", &path[from.len()..]), entry);
}
Ok(())
}
fn file_mut(&mut self, path: &str) -> Result<&mut Vec<u8>, i32> {
self.check_parent(path)?;
match self.tree.get_mut(path) {
None => Err(libc::ENOENT),
Some(None) => Err(libc::EISDIR),
Some(Some(content)) => Ok(content),
}
}
fn check_parent(&self, path: &str) -> Result<(), i32> {
let mut at = String::new();
let mut names = path.split('/').peekable();
while let Some(name) = names.next() {
if names.peek().is_none() {
break;
}
if !at.is_empty() {
at.push('/');
}
at.push_str(name);
match self.tree.get(&at) {
None => return Err(libc::ENOENT),
Some(Some(_)) => return Err(libc::ENOTDIR),
Some(None) => {}
}
}
Ok(())
}
fn has_children(&self, path: &str) -> bool {
self.tree.keys().any(|other| is_below(other, path))
}
pub(crate) fn files(&self) -> Vec<&String> {
self.tree
.iter()
.filter(|(_, entry)| entry.is_some())
.map(|(path, _)| path)
.collect()
}
/// Folders, the client's own root (`""`) first.
fn dirs(&self) -> Vec<String> {
std::iter::once(String::new())
.chain(
self.tree
.iter()
.filter(|(_, entry)| entry.is_none())
.map(|(path, _)| path.clone()),
)
.collect()
}
/// A random op, mostly one that should succeed.
pub(crate) fn next_op(&self, rng: &mut Rng) -> FsOp {
let dirs = self.dirs();
let files = self.files();
let in_dir = |rng: &mut Rng, names: &[&str]| {
let parent = rng.pick(&dirs).expect("the root is always there");
join(parent, rng.pick(names).expect("names"))
};
let a_file = |rng: &mut Rng| rng.pick(&files).map(|path| (*path).clone());
let roll = rng.below(100);
let op = match roll {
0..30 => Some(FsOp::Create {
path: in_dir(rng, &FILE_NAMES),
data: data(rng),
}),
30..45 => a_file(rng).map(|path| {
let len = self.content(&path).map_or(0, Vec::len) as u64;
FsOp::Write {
offset: rng.below(len + 8),
path,
data: data(rng),
}
}),
45..50 => a_file(rng).map(|path| FsOp::Truncate {
len: rng.below(32),
path,
}),
50..60 => Some(FsOp::Mkdir {
path: in_dir(rng, &DIR_NAMES),
}),
// A rename in place, over another file as often as not.
60..72 => a_file(rng).map(|from| FsOp::Rename {
to: join(parent_of(&from), rng.pick(&FILE_NAMES).expect("names")),
from,
}),
// A move to another folder, renamed or not.
72..82 => a_file(rng).map(|from| {
let name = if rng.chance(0.5) {
name_of(&from)
} else {
rng.pick(&FILE_NAMES).expect("names")
};
let parent = rng.pick(&dirs).expect("the root is always there");
FsOp::Rename {
to: join(parent, name),
from,
}
}),
// A folder renamed or moved, with what is in it.
82..86 => {
let from = rng.pick(&dirs[1..]).cloned();
from.map(|from| FsOp::Rename {
to: in_dir(rng, &DIR_NAMES),
from,
})
}
86..94 => a_file(rng).map(|path| FsOp::Unlink { path }),
94..97 => a_file(rng).map(|path| FsOp::UnlinkOpen {
path,
data: data(rng),
}),
_ => rng
.pick(&dirs[1..])
.cloned()
.map(|path| FsOp::Rmdir { path }),
};
op.unwrap_or_else(|| FsOp::Create {
path: in_dir(rng, &FILE_NAMES),
data: data(rng),
})
}
}
/// Content for a write: short and recognisable, now and then empty.
pub(crate) fn data(rng: &mut Rng) -> Vec<u8> {
let len = if rng.chance(0.1) {
0
} else {
rng.between(1, 48)
};
let tag = rng.next_u64();
(0..len).map(|i| b'a' + ((tag + i) % 26) as u8).collect()
}
pub(crate) fn join(parent: &str, name: &str) -> String {
if parent.is_empty() {
name.to_owned()
} else {
format!("{parent}/{name}")
}
}
pub(crate) fn parent_of(path: &str) -> &str {
path.rsplit_once('/').map_or("", |(parent, _)| parent)
}
pub(crate) fn name_of(path: &str) -> &str {
path.rsplit_once('/').map_or(path, |(_, name)| name)
}
fn is_below(path: &str, ancestor: &str) -> bool {
path.len() > ancestor.len() + 1
&& path.starts_with(ancestor)
&& path.as_bytes()[ancestor.len()] == b'/'
}
#[cfg(test)]
mod tests {
use super::*;
fn create(path: &str, data: &[u8]) -> FsOp {
FsOp::Create {
path: path.into(),
data: data.to_vec(),
}
}
#[test]
fn a_rename_over_a_file_replaces_it_and_a_folder_moves_with_its_contents() {
let mut model = Model::default();
model.apply(&FsOp::Mkdir { path: "x".into() }).unwrap();
model.apply(&create("x/a", b"1")).unwrap();
model.apply(&create("b", b"2")).unwrap();
model
.apply(&FsOp::Rename {
from: "b".into(),
to: "x/a".into(),
})
.unwrap();
assert_eq!(model.content("x/a"), Some(&b"2".to_vec()));
model
.apply(&FsOp::Rename {
from: "x".into(),
to: "y".into(),
})
.unwrap();
assert_eq!(model.tree().keys().collect::<Vec<_>>(), vec!["y", "y/a"]);
}
#[test]
fn calls_fail_the_way_posix_says() {
let mut model = Model::default();
model.apply(&FsOp::Mkdir { path: "x".into() }).unwrap();
model.apply(&create("x/a", b"1")).unwrap();
assert_eq!(
model.apply(&FsOp::Mkdir { path: "x".into() }),
Err(libc::EEXIST)
);
assert_eq!(model.apply(&create("x", b"")), Err(libc::EISDIR));
assert_eq!(model.apply(&create("x/a/b", b"")), Err(libc::ENOTDIR));
assert_eq!(model.apply(&create("q/a", b"")), Err(libc::ENOENT));
assert_eq!(
model.apply(&FsOp::Rmdir { path: "x".into() }),
Err(libc::ENOTEMPTY)
);
assert_eq!(
model.apply(&FsOp::Unlink { path: "x".into() }),
Err(libc::EISDIR)
);
let into_itself = FsOp::Rename {
from: "x".into(),
to: "x/y".into(),
};
assert_eq!(model.apply(&into_itself), Err(libc::EINVAL));
}
#[test]
fn a_write_past_the_end_leaves_a_hole() {
let mut model = Model::default();
model.apply(&create("a", b"ab")).unwrap();
let write = FsOp::Write {
path: "a".into(),
offset: 4,
data: b"z".to_vec(),
};
model.apply(&write).unwrap();
assert_eq!(model.content("a"), Some(&b"ab\0\0z".to_vec()));
let nothing = FsOp::Write {
path: "a".into(),
offset: 9,
data: Vec::new(),
};
model.apply(&nothing).unwrap();
assert_eq!(model.content("a"), Some(&b"ab\0\0z".to_vec()));
}
#[test]
fn generated_ops_mostly_succeed() {
let mut rng = Rng::new(9);
let mut model = Model::default();
let ok = (0..500)
.filter(|_| {
let op = model.next_op(&mut rng);
model.apply(&op).is_ok()
})
.count();
assert!(ok > 350, "{ok} of 500 succeeded");
}
}
+79
View File
@@ -0,0 +1,79 @@
//! A small seeded random number generator, so a failing simulation is a seed
//! that can be replayed. SplitMix64: fast, good enough for choosing operations
//! and faults, and no dependency.
#[derive(Clone, Debug)]
pub(crate) struct Rng(u64);
impl Rng {
pub(crate) fn new(seed: u64) -> Self {
Self(seed)
}
pub(crate) fn next_u64(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
/// A value in `0..n`; `0` when `n` is `0`.
pub(crate) fn below(&mut self, n: u64) -> u64 {
if n == 0 { 0 } else { self.next_u64() % n }
}
/// A value in `lo..=hi`.
pub(crate) fn between(&mut self, lo: u64, hi: u64) -> u64 {
lo + self.below(hi.saturating_sub(lo) + 1)
}
/// True with probability `p`.
pub(crate) fn chance(&mut self, p: f64) -> bool {
p > 0.0 && (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64 <= p
}
pub(crate) fn pick<'a, T>(&mut self, items: &'a [T]) -> Option<&'a T> {
items.get(self.below(items.len() as u64) as usize)
}
/// A child generator, so one consumer's draws do not shift another's.
pub(crate) fn fork(&mut self) -> Self {
Self::new(self.next_u64())
}
}
#[cfg(test)]
mod tests {
use super::Rng;
#[test]
fn a_seed_replays_the_same_sequence() {
let (mut a, mut b) = (Rng::new(7), Rng::new(7));
for _ in 0..100 {
assert_eq!(a.next_u64(), b.next_u64());
}
assert_ne!(Rng::new(7).next_u64(), Rng::new(8).next_u64());
}
#[test]
fn bounded_draws_stay_in_range() {
let mut rng = Rng::new(1);
for _ in 0..1000 {
assert!(rng.below(5) < 5);
assert!((3..=9).contains(&rng.between(3, 9)));
}
assert_eq!(rng.below(0), 0);
assert!(!rng.chance(0.0));
assert!(rng.chance(1.0));
assert!([1, 2, 3].contains(rng.pick(&[1, 2, 3]).unwrap()));
assert_eq!(rng.pick::<u8>(&[]), None);
}
#[test]
fn a_fork_draws_apart_from_its_parent() {
let mut parent = Rng::new(3);
let mut child = parent.fork();
assert_ne!(parent.next_u64(), child.next_u64());
}
}
+766
View File
@@ -0,0 +1,766 @@
//! The simulation runs described in `docs/TESTING.md`.
//!
//! A run starts one or more daemons on a shared [`FakeDrive`], each owning a
//! folder `c<n>` that only it writes, so there is one writer per file. A seed
//! draws a sequence of steps: syscalls through a client's mount, the link of a
//! client going down or up, a client restarting, a pause, a settle (every
//! queue must drain within a budget), or a file deleted while the drain holds
//! a write to it. Every syscall is checked against the
//! client's [`Model`] as it happens. After the sequence every link comes up
//! and the run checks:
//!
//! 1. **No loss** and **POSIX**: Drive's copy of each client's folder is what
//! its model says.
//! 2. **No false conflicts**: no conflict copy anywhere, and the drain never
//! takes a client's own delete for another device's.
//! 3. **Convergence**: each mount shows Drive's tree.
//! 4. **Liveness**: no syscall took longer than its budget, settles drained in
//! theirs, and every queue drained once the links were up. A call that
//! never returns is caught by the [`Watchdog`], which keeps the stacks.
//!
//! A restart here is a stop and a start: the next run finds what the last
//! one wrote down, but in-flight work is let finish rather than cut. A kill
//! at an arbitrary point needs the daemon in a process of its own.
//!
//! Any of these fails the seed. No open bug is tolerated: the matchers that
//! counted the ones still open instead (B125 to B134) went once a run on
//! 812eb3d passed every profile without them.
//!
//! The runs are ignored by default because they mount FUSE; run them with
//! `cargo test -p pdfs-fuse --lib sim::run -- --ignored --test-threads=1`.
//! `PDFS_SIM_SEEDS=n` sets how many seeds each runs, `PDFS_SIM_SEED=s` replays
//! one, and `RUST_LOG=pdfs_fuse=debug` with `--nocapture` shows the daemons'
//! logs.
use std::fs::OpenOptions;
use std::io::{self, Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use super::daemon::{Daemon, scratch, take_logged, wait_until};
use super::fake_drive::{Entry, FakeDrive, Faults};
use super::model::{FsOp, Model, Tree, data, join};
use super::rng::Rng;
use super::watchdog::Watchdog;
/// What a run looks like.
pub(crate) struct Profile {
pub(crate) name: &'static str,
pub(crate) clients: usize,
pub(crate) steps: usize,
pub(crate) faults: fn() -> Faults,
/// Whether links go down and come back during the sequence.
pub(crate) link_flaps: bool,
pub(crate) restarts: bool,
/// Whether files are deleted while the drain holds a write to them.
pub(crate) deletes_mid_upload: bool,
/// How long a settle may take; `None` leaves settles out.
pub(crate) settle_budget: Option<Duration>,
/// How long one syscall may take.
pub(crate) syscall_budget: Duration,
/// How long the queues may take to drain at the end.
pub(crate) drain_budget: Duration,
}
/// How long the mounts may take to show Drive's tree at the end: one event
/// poll (`POLL_INTERVAL`) and change.
const CONVERGE_BUDGET: Duration = Duration::from_secs(30);
/// How long the drain may take to pick up a write: its debounce, and then
/// some.
const PICKUP_BUDGET: Duration = Duration::from_secs(8);
/// Steps shown before a failure. All of them are written to `steps.log` in
/// the state the failure keeps.
const LOG_TAIL: usize = 40;
/// How long past its own budget a step, or the checks after the last one, may
/// run before the watchdog takes it for hung. Longer than a restart may wait
/// for the last run and the mount.
const STALL: Duration = Duration::from_secs(120);
struct Client {
daemon: Option<Daemon>,
model: Model,
online: bool,
}
impl Client {
fn daemon(&self) -> &Daemon {
self.daemon
.as_ref()
.expect("a client always has a daemon between steps")
}
fn root(&self, index: usize) -> PathBuf {
self.daemon().mountpoint.join(owned_folder(index))
}
}
struct Run {
seed: u64,
profile: &'static Profile,
drive: std::sync::Arc<FakeDrive>,
clients: Vec<Client>,
log: Vec<String>,
started: Instant,
/// What the daemons logged at `INFO` and above so far.
logged: Vec<String>,
watchdog: Watchdog,
}
/// Run `profile` on `seed`. It answers a report of what went wrong, if
/// anything did.
pub(crate) fn run(seed: u64, profile: &'static Profile) -> Result<(), String> {
let dir = scratch(&format!("{}-{seed}", profile.name));
let mut rng = Rng::new(seed);
let drive = FakeDrive::new();
let mut run = Run {
seed,
profile,
drive: drive.clone(),
clients: Vec::new(),
log: Vec::new(),
started: Instant::now(),
logged: Vec::new(),
watchdog: Watchdog::new(&dir),
};
let outcome = run.execute(&dir, &mut rng);
run.watchdog.stop();
// A hang is what failed, whatever the aborted call answered.
let outcome = match run.watchdog.fired() {
Some(hung) => Err(format!("liveness: {}", hung.what)),
None => outcome,
};
for client in &mut run.clients {
if let Some(daemon) = client.daemon.take() {
let _ = daemon.stop();
}
}
match outcome {
Ok(()) => {
let _ = std::fs::remove_dir_all(&dir);
Ok(())
}
Err(failure) => Err(run.report(&failure, &dir)),
}
}
impl Run {
fn execute(&mut self, dir: &Path, rng: &mut Rng) -> Result<(), String> {
for index in 0..self.profile.clients {
let client = self.drive.client(rng.next_u64(), (self.profile.faults)());
let daemon = Daemon::start(&dir.join(format!("client{index}")), client)
.map_err(|error| format!("client {index} did not mount: {error}"))?;
let folder = daemon.mountpoint.join(owned_folder(index));
std::fs::create_dir(&folder)
.map_err(|error| format!("client {index}: mkdir {}: {error}", folder.display()))?;
self.clients.push(Client {
daemon: Some(daemon),
model: Model::default(),
online: true,
});
}
for step in 0..self.profile.steps {
let _watching = self.watchdog.arm(self.step_limit(), format!("step {step}"));
self.step(rng)?;
}
let _watching = self.watchdog.arm(
self.profile.drain_budget + STALL,
"the checks after the last step",
);
self.finish()
}
/// How long a step may run before the watchdog takes it for hung.
fn step_limit(&self) -> Duration {
let budget = self.profile.settle_budget.unwrap_or_default();
budget.max(self.profile.syscall_budget) + STALL
}
fn step(&mut self, rng: &mut Rng) -> Result<(), String> {
let index = rng.below(self.clients.len() as u64) as usize;
let roll = rng.below(100);
match roll {
0..4 if self.profile.link_flaps => {
let client = &mut self.clients[index];
client.online = !client.online;
client.daemon().client.set_online(client.online);
let state = if client.online { "up" } else { "down" };
self.note(format!("client {index}: link {state}"));
}
4..6 if self.profile.restarts => {
self.note(format!("client {index}: restart"));
let daemon = self.clients[index].daemon.take().expect("daemon");
let daemon = daemon
.restart()
.map_err(|error| format!("client {index} did not come back: {error}"))?;
self.clients[index].daemon = Some(daemon);
}
6..12 => {
let ms = rng.below(400);
self.note(format!("pause {ms} ms"));
std::thread::sleep(Duration::from_millis(ms));
}
12..15 => {
if let Some(budget) = self.profile.settle_budget
&& self.clients.iter().all(|client| client.online)
{
self.settle(budget)?;
}
}
15..25 if self.profile.deletes_mid_upload => self.delete_mid_upload(index, rng)?,
_ => {
let op = self.clients[index].model.next_op(rng);
self.syscall(index, op)?;
}
}
Ok(())
}
/// Wait until every queue is empty and Drive holds what the models say.
fn settle(&mut self, budget: Duration) -> Result<(), String> {
self.note("settle".into());
let start = Instant::now();
// What the drain logs meanwhile is what explains a known bug's damage.
if !wait_until(budget, || {
self.logged.extend(take_logged());
self.landed()
}) {
return Err(format!(
"liveness: the queues did not drain within {budget:?}\n{}",
self.difference()
));
}
self.note(format!("settled in {:?}", start.elapsed()));
Ok(())
}
/// Write a file Drive holds and delete it once the drain has picked the
/// write up. The drain's read of the node is held until the delete has
/// landed and a read shows it, so the drain finds the file trashed while
/// it holds a write that is no longer queued (B105). Half the time the
/// file is made again meanwhile, which queues a write under the op id the
/// delete freed (B133).
fn delete_mid_upload(&mut self, index: usize, rng: &mut Rng) -> Result<(), String> {
let file = rng
.pick(&self.clients[index].model.files())
.map(|path| (*path).clone());
let (Some(path), Some(budget), true) = (
file,
self.profile.settle_budget,
self.clients.iter().all(|client| client.online),
) else {
let op = self.clients[index].model.next_op(rng);
return self.syscall(index, op);
};
self.settle(budget)?;
let Some(uid) = self.drive.lookup(&join(&owned_folder(index), &path)) else {
return Err(format!(
"no loss: client {index}'s {path} is not on Drive after a settle"
));
};
let held = self.clients[index].daemon().client.hold_next_read(&uid);
self.note(format!("client {index}: hold the next read of {path}"));
let mut bytes = data(rng);
bytes.push(b'.');
self.syscall(
index,
FsOp::Write {
path: path.clone(),
offset: 0,
data: bytes,
},
)?;
if !wait_until(PICKUP_BUDGET, || held.reached()) {
self.note(format!("client {index}: nothing read {path} back"));
return Ok(());
}
self.syscall(index, FsOp::Unlink { path: path.clone() })?;
if rng.chance(0.5) {
self.syscall(
index,
FsOp::Create {
path: path.clone(),
data: data(rng),
},
)?;
}
let faults = (self.profile.faults)();
let latency = Duration::from_millis(faults.latency_ms.1);
std::thread::sleep(faults.listing_lag + latency);
drop(held);
self.note(format!("client {index}: the read of {path} goes on"));
// Nothing else is queued before the drain has answered.
std::thread::sleep(2 * latency + Duration::from_millis(50));
Ok(())
}
fn syscall(&mut self, index: usize, op: FsOp) -> Result<(), String> {
let client = &mut self.clients[index];
let root = client.root(index);
let before = client.model.content(target(&op)).cloned();
let expected = client.model.apply(&op);
self.logged.extend(take_logged());
let start = Instant::now();
let actual = perform(&root, &op, before.as_deref());
let took = start.elapsed();
self.note(format!(
"client {index}: {} = {} in {took:?}",
op.describe(),
outcome(&actual)
));
let actual = actual?;
self.logged.extend(take_logged());
if actual != expected {
return Err(format!(
"posix: client {index}: {} answered {}, expected {}",
op.describe(),
errno(actual),
errno(expected)
));
}
if took > self.profile.syscall_budget {
return Err(format!(
"liveness: client {index}: {} took {took:?}, over {:?}",
op.describe(),
self.profile.syscall_budget
));
}
Ok(())
}
fn finish(&mut self) -> Result<(), String> {
for client in &mut self.clients {
client.online = true;
client.daemon().client.set_online(true);
}
self.note("every link up; waiting for the queues".into());
if !wait_until(self.profile.drain_budget, || {
self.logged.extend(take_logged());
self.landed()
}) {
return Err(format!(
"no loss: Drive does not hold what the clients wrote, {:?} after the links came up\n{}",
self.profile.drain_budget,
self.difference()
));
}
// Each client's folder is its own, so a file of it gone from Drive was
// deleted by the client.
if let Some(line) = self.logged.iter().find(|line| is_own_delete_kept(line)) {
return Err(format!(
"no false conflicts: the drain took a client's own delete for another device's: {line}"
));
}
let drive = self.drive_tree();
let conflicts: Vec<String> = drive
.into_keys()
.filter(|path| is_conflict_copy(path))
.collect();
if !conflicts.is_empty() {
return Err(format!("no false conflicts: {conflicts:?}"));
}
for index in 0..self.clients.len() {
let mut last = Tree::new();
let converged = wait_until(CONVERGE_BUDGET, || {
last = read_tree(&self.clients[index].daemon().mountpoint);
last == self.drive_tree()
});
if !converged {
return Err(format!(
"convergence: client {index}'s mount differs from Drive\n{}",
diff(&last, &self.drive_tree(), "mount", "Drive")
));
}
}
Ok(())
}
/// Whether every client's queue is empty and Drive holds what its model
/// says.
fn landed(&self) -> bool {
let drive = self.drive_tree();
self.clients.iter().enumerate().all(|(index, client)| {
client
.daemon()
.pending()
.is_ok_and(|items| items.is_empty())
&& subtree(&drive, &owned_folder(index)) == *client.model.tree()
})
}
fn difference(&self) -> String {
let drive = self.drive_tree();
let mut out = String::new();
for (index, client) in self.clients.iter().enumerate() {
let pending = client.daemon().pending().unwrap_or_default();
for item in pending {
out.push_str(&format!(
" client {index} still owes: {} {} (attempts {}, last error {:?})\n",
item.kind, item.path, item.attempts, item.last_error
));
}
out.push_str(&diff(
client.model.tree(),
&subtree(&drive, &owned_folder(index)),
&format!("client {index}'s model"),
"Drive",
));
}
out
}
fn drive_tree(&self) -> Tree {
self.drive
.tree()
.into_iter()
.map(|(path, entry)| {
let content = match entry {
Entry::Folder => None,
Entry::File(content) => Some(content.as_ref().clone()),
};
(path, content)
})
.collect()
}
fn note(&mut self, line: String) {
let line = format!("{:>8.3}s {line}", self.started.elapsed().as_secs_f64());
self.log.push(line);
}
fn report(&self, failure: &str, dir: &Path) -> String {
let _ = std::fs::write(dir.join("steps.log"), self.log.join("\n") + "\n");
let tail = &self.log[self.log.len().saturating_sub(LOG_TAIL)..];
format!(
"{} seed {} failed: {failure}\nreplay with PDFS_SIM_SEED={}; state kept in {}\nlast steps:\n{}",
self.profile.name,
self.seed,
self.seed,
dir.display(),
tail.join("\n")
)
}
}
/// Make `op` under `root`. The outer error is a check that failed on the
/// way (the content an open file reads back), the inner one the errno.
fn perform(root: &Path, op: &FsOp, before: Option<&[u8]>) -> Result<Result<(), i32>, String> {
let at = |path: &str| root.join(path);
let result: io::Result<()> = match op {
FsOp::Create { path, data } => std::fs::write(at(path), data),
FsOp::Write { path, offset, data } => OpenOptions::new()
.write(true)
.open(at(path))
.and_then(|mut file| {
file.seek(SeekFrom::Start(*offset))?;
file.write_all(data)
}),
FsOp::Truncate { path, len } => OpenOptions::new()
.write(true)
.open(at(path))
.and_then(|file| file.set_len(*len)),
FsOp::Mkdir { path } => std::fs::create_dir(at(path)),
FsOp::Rename { from, to } => std::fs::rename(at(from), at(to)),
FsOp::Unlink { path } => std::fs::remove_file(at(path)),
FsOp::Rmdir { path } => std::fs::remove_dir(at(path)),
FsOp::UnlinkOpen { path, data } => {
let mut read = Vec::new();
let result = OpenOptions::new()
.read(true)
.write(true)
.open(at(path))
.and_then(|mut file| {
std::fs::remove_file(at(path))?;
file.seek(SeekFrom::End(0))?;
file.write_all(data)?;
file.seek(SeekFrom::Start(0))?;
file.read_to_end(&mut read).map(|_| ())
});
if result.is_ok() {
let expected = [before.unwrap_or_default(), data].concat();
if read != expected {
return Err(format!(
"posix: {} read back {:?}, expected {:?}",
op.describe(),
String::from_utf8_lossy(&read),
String::from_utf8_lossy(&expected)
));
}
}
result
}
};
Ok(result.map_err(|error| error.raw_os_error().unwrap_or(libc::EIO)))
}
fn target(op: &FsOp) -> &str {
match op {
FsOp::Create { path, .. }
| FsOp::Write { path, .. }
| FsOp::Truncate { path, .. }
| FsOp::Mkdir { path }
| FsOp::Unlink { path }
| FsOp::UnlinkOpen { path, .. }
| FsOp::Rmdir { path } => path,
FsOp::Rename { from, .. } => from,
}
}
/// The tree a mount shows, read through it, without the folders it makes up
/// that are not on Drive.
fn read_tree(mountpoint: &Path) -> Tree {
fn walk(dir: &Path, prefix: &str, tree: &mut Tree) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
if prefix.is_empty() && name == crate::r#virtual::SHARED_WITH_ME_BASE {
continue;
}
let path = super::model::join(prefix, &name);
if entry.file_type().is_ok_and(|kind| kind.is_dir()) {
tree.insert(path.clone(), None);
walk(&entry.path(), &path, tree);
} else {
tree.insert(path, std::fs::read(entry.path()).ok());
}
}
}
let mut tree = Tree::new();
walk(mountpoint, "", &mut tree);
tree
}
/// The part of `tree` below `folder`, relative to it.
fn subtree(tree: &Tree, folder: &str) -> Tree {
let prefix = format!("{folder}/");
tree.iter()
.filter_map(|(path, entry)| Some((path.strip_prefix(&prefix)?.to_owned(), entry.clone())))
.collect()
}
fn owned_folder(index: usize) -> String {
format!("c{index}")
}
/// Whether `line` is the drain keeping a queued write as a conflict copy
/// because Drive no longer holds the file, or holds it in the trash.
fn is_own_delete_kept(line: &str) -> bool {
line.starts_with("queued write conflicts; keeping a conflict copy")
&& (line.contains(" reason=\"the file was trashed remotely\"")
|| line.contains(" reason=\"the file no longer exists remotely\""))
}
fn is_conflict_copy(path: &str) -> bool {
path.split('/')
.any(|name| name.contains("(sync-conflict") || name.starts_with("recovered-"))
}
fn diff(left: &Tree, right: &Tree, left_name: &str, right_name: &str) -> String {
let show = |entry: &Option<Vec<u8>>| match entry {
None => "folder".to_owned(),
Some(content) => format!("{:?}", String::from_utf8_lossy(content)),
};
let mut out = String::new();
for (path, entry) in left {
match right.get(path) {
None => out.push_str(&format!(
" {path}: only in {left_name} ({})\n",
show(entry)
)),
Some(other) if other != entry => out.push_str(&format!(
" {path}: {left_name} has {}, {right_name} has {}\n",
show(entry),
show(other)
)),
Some(_) => {}
}
}
for (path, entry) in right {
if !left.contains_key(path) {
out.push_str(&format!(
" {path}: only in {right_name} ({})\n",
show(entry)
));
}
}
out
}
fn errno(outcome: Result<(), i32>) -> String {
match outcome {
Ok(()) => "ok".into(),
Err(code) => io::Error::from_raw_os_error(code).to_string(),
}
}
fn outcome(actual: &Result<Result<(), i32>, String>) -> String {
match actual {
Ok(result) => errno(*result),
Err(check) => check.clone(),
}
}
/// Run `profile` over the seeds the environment asks for: `PDFS_SIM_SEED`
/// alone, or `PDFS_SIM_SEEDS` of them (default `default_seeds`) from 1.
pub(crate) fn run_seeds(profile: &'static Profile, default_seeds: u64) {
let seeds: Vec<u64> = match std::env::var("PDFS_SIM_SEED") {
Ok(seed) => vec![seed.parse().expect("PDFS_SIM_SEED is a number")],
Err(_) => {
let count = std::env::var("PDFS_SIM_SEEDS")
.ok()
.and_then(|count| count.parse().ok())
.unwrap_or(default_seeds);
(1..=count).collect()
}
};
let failures: Vec<String> = seeds
.into_iter()
.filter_map(|seed| run(seed, profile).err())
.collect();
assert!(failures.is_empty(), "\n{}", failures.join("\n\n"));
}
#[cfg(test)]
mod tests {
use super::*;
static ONE_CLIENT_LAN: Profile = Profile {
name: "one-client-lan",
clients: 1,
steps: 80,
faults: Faults::lan,
link_flaps: false,
restarts: false,
deletes_mid_upload: false,
settle_budget: Some(Duration::from_secs(4)),
syscall_budget: Duration::from_secs(2),
drain_budget: Duration::from_secs(20),
};
static ONE_CLIENT_DELETES: Profile = Profile {
name: "one-client-deletes",
clients: 1,
steps: 30,
faults: Faults::lan,
link_flaps: false,
restarts: false,
deletes_mid_upload: true,
settle_budget: Some(Duration::from_secs(4)),
syscall_budget: Duration::from_secs(2),
drain_budget: Duration::from_secs(20),
};
static ONE_CLIENT_ECHOES: Profile = Profile {
name: "one-client-echoes",
clients: 1,
steps: 60,
faults: Faults::echoes,
link_flaps: false,
restarts: false,
deletes_mid_upload: false,
settle_budget: Some(Duration::from_secs(6)),
syscall_budget: Duration::from_secs(2),
drain_budget: Duration::from_secs(20),
};
static ONE_CLIENT_FLAKY: Profile = Profile {
name: "one-client-flaky",
clients: 1,
steps: 80,
faults: Faults::wifi,
link_flaps: true,
restarts: true,
deletes_mid_upload: false,
settle_budget: None,
syscall_budget: Duration::from_secs(10),
drain_budget: Duration::from_secs(120),
};
/// Wi-Fi's latency, lag and lost replies without the outages: the queue
/// has to drain within a settle, not merely by the end.
static ONE_CLIENT_WIFI: Profile = Profile {
name: "one-client-wifi",
clients: 1,
steps: 80,
faults: Faults::wifi,
link_flaps: false,
restarts: false,
deletes_mid_upload: false,
settle_budget: Some(Duration::from_secs(30)),
syscall_budget: Duration::from_secs(10),
drain_budget: Duration::from_secs(120),
};
static THREE_CLIENTS: Profile = Profile {
name: "three-clients",
clients: 3,
steps: 90,
faults: Faults::lan,
link_flaps: true,
restarts: true,
deletes_mid_upload: false,
settle_budget: Some(Duration::from_secs(4)),
syscall_budget: Duration::from_secs(2),
drain_budget: Duration::from_secs(60),
};
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse --lib sim::run -- --ignored`"]
fn one_client_on_a_good_link() {
run_seeds(&ONE_CLIENT_LAN, 12);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse --lib sim::run -- --ignored`"]
fn one_client_deleting_files_as_they_upload() {
run_seeds(&ONE_CLIENT_DELETES, 3);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse --lib sim::run -- --ignored`"]
fn one_client_with_stale_hashes_and_reordered_echoes() {
run_seeds(&ONE_CLIENT_ECHOES, 6);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse --lib sim::run -- --ignored`"]
fn one_client_on_a_flaky_link() {
run_seeds(&ONE_CLIENT_FLAKY, 4);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse --lib sim::run -- --ignored`"]
fn one_client_on_a_slow_link() {
run_seeds(&ONE_CLIENT_WIFI, 4);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse --lib sim::run -- --ignored`"]
fn three_clients_one_writer_each() {
run_seeds(&THREE_CLIENTS, 3);
}
#[test]
fn conflict_copies_are_recognised_by_name() {
assert!(is_conflict_copy("c0/x/a (sync-conflict 1790000000).txt"));
assert!(is_conflict_copy("recovered-XrPUSW5W"));
assert!(!is_conflict_copy("c0/x/a.txt"));
}
#[test]
fn a_write_kept_because_its_file_is_gone_is_recognised() {
let kept = "queued write conflicts; keeping a conflict copy uid=simvol~L10 \
name=\"f.txt\" reason=\"the file was trashed remotely\"";
assert!(is_own_delete_kept(kept));
assert!(is_own_delete_kept(
&kept.replace("was trashed", "no longer exists")
));
assert!(!is_own_delete_kept(
&kept.replace("the file was trashed remotely", "revision changed")
));
}
}
+322
View File
@@ -0,0 +1,322 @@
//! Turns a call through a mount that never returns into a failed seed.
//!
//! A deadlock in a daemon leaves the run's own thread blocked in the kernel,
//! where no budget check is reached, and a CI job then waits for its timeout.
//! The watchdog runs beside the run. When what it was armed for outlives its
//! limit, it aborts the FUSE connections of the run's mounts, so the blocked
//! call fails and the run reports the hang, and writes where every thread was
//! to `stacks.txt`.
//!
//! The stacks are taken in the process: no tracer can stop a thread that
//! waits on a FUSE request, so `gdb` and `eu-stack` wait as long as the hang.
//! Each thread is sent [`stack_signal`] and captures its own backtrace in the
//! handler. A thread in the kernel takes the signal only once it leaves, so
//! the mounts are aborted after the others have answered, and its backtrace
//! is the call it was blocked in. Its kernel wait channel is recorded first.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
use parking_lot::Mutex;
use super::daemon::wait_until;
/// How long the threads that are not blocked in the kernel have to answer,
/// and the blocked ones once the mounts are aborted.
const ANSWER: Duration = Duration::from_secs(5);
pub(crate) struct Watchdog {
shared: Arc<Shared>,
thread: Option<JoinHandle<()>>,
}
struct Shared {
/// The run's directory: its mounts are below it, and the stacks go in it.
dir: PathBuf,
armed: Mutex<Option<Armed>>,
/// What hung, once something has.
fired: Mutex<Option<Hung>>,
stop: AtomicBool,
}
struct Armed {
what: String,
limit: Duration,
since: Instant,
}
/// What hung, and where every thread was then.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Hung {
pub(crate) what: String,
pub(crate) stacks: String,
}
/// Disarms the watchdog when dropped.
pub(crate) struct Watching(Arc<Shared>);
impl Watchdog {
pub(crate) fn new(dir: &Path) -> Self {
let shared = Arc::new(Shared {
dir: dir.to_path_buf(),
armed: Mutex::new(None),
fired: Mutex::new(None),
stop: AtomicBool::new(false),
});
let thread = {
let shared = shared.clone();
std::thread::Builder::new()
.name("sim-watchdog".into())
.spawn(move || watch(&shared))
.expect("spawn the simulation watchdog")
};
Self {
shared,
thread: Some(thread),
}
}
/// Watch `what` until the guard is dropped, and abort the mounts if it is
/// still going after `limit`.
pub(crate) fn arm(&self, limit: Duration, what: impl Into<String>) -> Watching {
*self.shared.armed.lock() = Some(Armed {
what: what.into(),
limit,
since: Instant::now(),
});
Watching(self.shared.clone())
}
/// What hung, if something did. Call [`Watchdog::stop`] first, or the
/// stacks may still be on their way.
pub(crate) fn fired(&self) -> Option<Hung> {
self.shared.fired.lock().clone()
}
/// Stop watching, once the stacks of a hang that was caught are taken.
pub(crate) fn stop(&mut self) {
self.shared.stop.store(true, Ordering::Relaxed);
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
}
}
impl Drop for Watching {
fn drop(&mut self) {
*self.0.armed.lock() = None;
}
}
impl Drop for Watchdog {
fn drop(&mut self) {
self.stop();
}
}
fn watch(shared: &Shared) {
while !shared.stop.load(Ordering::Relaxed) {
std::thread::sleep(Duration::from_millis(200));
let hung = shared
.armed
.lock()
.as_ref()
.filter(|armed| armed.since.elapsed() > armed.limit)
.map(|armed| format!("{} has not returned after {:?}", armed.what, armed.limit));
let Some(hung) = hung else { continue };
*shared.armed.lock() = None;
let path = shared.dir.join("stacks.txt");
// Before anything is let go: the run reports the hang, not whatever
// the aborted call answers.
*shared.fired.lock() = Some(Hung {
what: format!("{hung}; the stacks are in {}", path.display()),
stacks: String::new(),
});
let stacks = take_stacks(|| abort_mounts_below(&shared.dir));
let what = match std::fs::write(&path, &stacks) {
Ok(()) => format!("{hung}; the stacks are in {}", path.display()),
Err(error) => format!("{hung}; the stacks could not be kept: {error}"),
};
*shared.fired.lock() = Some(Hung { what, stacks });
}
}
/// One thread of this process as the kernel sees it.
struct Task {
tid: i32,
name: String,
/// `D` while it waits in the kernel uninterruptibly.
state: String,
wchan: String,
}
/// Where every other thread of this process is, with `release` called once
/// those not blocked in the kernel have answered.
fn take_stacks(release: impl FnOnce()) -> String {
let own = gettid();
let tasks = tasks();
STACKS.lock().clear();
install_stack_handler();
for task in &tasks {
if task.tid != own {
// SAFETY: tgkill only sends a signal, whose handler is installed.
unsafe {
libc::syscall(
libc::SYS_tgkill,
std::process::id() as i32,
task.tid,
stack_signal(),
)
};
}
}
let answered = |blocked: bool| {
let stacks = STACKS.lock();
tasks
.iter()
.filter(|task| task.tid != own && (blocked || task.state != "D"))
.all(|task| stacks.contains_key(&task.tid))
};
wait_until(ANSWER, || answered(false));
release();
wait_until(ANSWER, || answered(true));
let stacks = std::mem::take(&mut *STACKS.lock());
let mut out = String::new();
for task in tasks.iter().filter(|task| task.tid != own) {
out.push_str(&format!(
"thread {} {:?}, state {}, waiting in {}\n",
task.tid, task.name, task.state, task.wchan
));
match stacks.get(&task.tid) {
Some(stack) => out.push_str(&format!("{stack}\n")),
None => out.push_str(" (no answer)\n\n"),
}
}
out
}
fn tasks() -> Vec<Task> {
let Ok(entries) = std::fs::read_dir("/proc/self/task") else {
return Vec::new();
};
let read = |dir: &Path, file: &str| {
std::fs::read_to_string(dir.join(file))
.unwrap_or_default()
.trim()
.to_owned()
};
entries
.flatten()
.filter_map(|entry| {
let tid = entry.file_name().to_str()?.parse().ok()?;
let dir = entry.path();
// The state follows the name in parentheses, which may hold spaces.
let stat = read(&dir, "stat");
let state = stat.rsplit_once(") ")?.1.split(' ').next()?.to_owned();
Some(Task {
tid,
name: read(&dir, "comm"),
state,
wchan: read(&dir, "wchan"),
})
})
.collect()
}
/// The backtraces the threads captured, by thread id.
static STACKS: Mutex<BTreeMap<i32, std::backtrace::Backtrace>> = Mutex::new(BTreeMap::new());
/// The signal that asks a thread for its stack: one of the real-time signals
/// that nothing in the daemon uses.
fn stack_signal() -> i32 {
libc::SIGRTMIN() + 7
}
fn install_stack_handler() {
static INSTALLED: std::sync::Once = std::sync::Once::new();
extern "C" fn handler(_: i32) {
// Not async-signal-safe: the capture allocates. The process is being
// taken down for a hang anyway, and a thread interrupted inside the
// allocator only fails to answer.
let stack = std::backtrace::Backtrace::force_capture();
STACKS.lock().insert(gettid(), stack);
}
INSTALLED.call_once(|| {
// SAFETY: a zeroed sigaction with a handler and SA_RESTART is valid,
// and the handler only touches `STACKS`.
unsafe {
let mut action: libc::sigaction = std::mem::zeroed();
action.sa_sigaction = handler as extern "C" fn(i32) as usize;
action.sa_flags = libc::SA_RESTART;
libc::sigemptyset(&mut action.sa_mask);
libc::sigaction(stack_signal(), &action, std::ptr::null_mut());
}
});
}
fn gettid() -> i32 {
// SAFETY: gettid has no arguments and cannot fail.
unsafe { libc::gettid() }
}
/// Abort the FUSE connection of every mount below `dir`. Its connection is
/// named by the mount's device minor, and the mount's owner may abort it.
fn abort_mounts_below(dir: &Path) {
let Ok(mountinfo) = std::fs::read_to_string("/proc/self/mountinfo") else {
return;
};
for line in mountinfo.lines() {
let fields: Vec<&str> = line.split(' ').collect();
let (Some(device), Some(mountpoint)) = (fields.get(2), fields.get(4)) else {
continue;
};
if !Path::new(mountpoint).starts_with(dir) {
continue;
}
if let Some((_, minor)) = device.split_once(':') {
let _ = std::fs::write(format!("/sys/fs/fuse/connections/{minor}/abort"), "1");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sim::daemon::scratch;
#[test]
#[ignore = "signals every thread of the test process: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_call_past_its_limit_is_reported_with_the_stacks() {
let dir = scratch("watchdog");
let mut watchdog = Watchdog::new(&dir);
{
let _watching = watchdog.arm(Duration::from_millis(100), "a nap");
std::thread::sleep(Duration::from_secs(1));
}
watchdog.stop();
let hung = watchdog.fired().expect("the watchdog fired");
assert!(
hung.what.starts_with("a nap has not returned after"),
"{hung:?}"
);
assert!(hung.stacks.contains("a_call_past_its_limit"), "{hung:?}");
let kept = std::fs::read_to_string(dir.join("stacks.txt")).unwrap_or_default();
assert_eq!(kept, hung.stacks);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_call_within_its_limit_is_not() {
let dir = scratch("watchdog-quiet");
let watchdog = Watchdog::new(&dir);
drop(watchdog.arm(Duration::from_millis(100), "a quick call"));
std::thread::sleep(Duration::from_millis(500));
assert_eq!(watchdog.fired(), None);
drop(watchdog);
let _ = std::fs::remove_dir_all(&dir);
}
}
+555 -41
View File
@@ -128,7 +128,62 @@ pub(crate) struct WriteHandle {
pub(crate) open_count: usize,
}
/// A released write whose upload has not happened yet (offline.md Phase 3).
impl WriteHandle {
/// A handle on a file `create` just made: an empty base, so everything
/// written through it is authored.
///
/// The base is `node` as it actually exists — the empty file the server just
/// minted, or a queued create's placeholder — never the local clock or a
/// blank. `queue_revision` turns it into `StagedWrite::based_on` and the
/// drain compares the remote against that. A `now_secs()` mtime differs from
/// the server's stamp by the create round-trip. A missing revision id drops
/// the check to `(mtime, size)`, and moving or renaming the file advances
/// its mtime on the server. Either way the first write to a brand-new file
/// lands as a conflict copy of its own empty create (`docs/BUGS.md` B113).
pub(crate) fn created(ino: u64, node: &Node, file: File, path: PathBuf) -> Self {
Self {
ino,
uid: node.uid.clone(),
file: Arc::new(file),
path,
written: Intervals::default(),
len: 0,
base_size: 0,
base_mtime: node.modification_time,
base_revision_id: crate::node_revision_id(node),
dirty: false,
open_count: 0,
}
}
/// Write over `node` from now on: a revision this daemon has just put on the
/// server under the handle, by sealing an upload or by landing the queued
/// create the handle was opened on. The base the handle opened over is gone,
/// and keeping it would make the drain read our own revision as another
/// device's edit.
pub(crate) fn rebase_onto(&mut self, node: &Node) {
self.base_mtime = node.modification_time;
self.base_size = crate::node_size(node);
self.base_revision_id = crate::node_revision_id(node);
}
/// Account for `[offset, end)` having been written through this handle.
///
/// A write past the end leaves a hole that reads as zeros, exactly like a
/// grow through `setattr`, so the hole is authored too. Left unauthored, a
/// hole below the base size was refilled from the base at commit: a file
/// shrunk and then written past its new end got its old bytes back.
pub(crate) fn record_write(&mut self, offset: u64, end: u64) {
if offset > self.len {
self.written.add(self.len, offset);
}
self.written.add(offset, end);
self.len = self.len.max(end);
self.dirty = true;
}
}
/// A released write whose upload has not happened yet.
///
/// The bytes live in the content cache's staging dir and the intent lives in the
/// `pending_op` table; this pairs them in memory so a read can be served without
@@ -146,8 +201,54 @@ pub(crate) struct PendingRevision {
/// time so this module stays free of `fuser`.
pub(crate) type DirListing = Vec<(u64, bool, String)>;
/// Where [`State::next_ino`] starts: far above any local id a database hands
/// out, so a fallback inode never names a node that has a row.
pub(crate) const FALLBACK_INO: u64 = 1 << 48;
/// The inode of the node with local id `lid` ([`Db::lids_for`]). One above the
/// lid, so no node takes [`crate::ROOT_INO`], which each session uses for its
/// own root. The same in every mount and after a restart.
pub(crate) fn lid_ino(lid: i64) -> u64 {
lid.unsigned_abs() + crate::ROOT_INO
}
/// The inodes of the stored nodes among `nodes`, from their local ids, for
/// [`State::intern_batch_with_inos`]. Called before the state lock is taken;
/// a node that already has a row keeps its id. Empty when the database fails,
/// which leaves the allocation to the lock holder, as before.
pub(crate) fn inos_for(db: &Db, nodes: &[Node]) -> HashMap<NodeUid, u64> {
let stored: Vec<&Node> = nodes
.iter()
.filter(|node| !crate::r#virtual::is_virtual_uid(&node.uid))
.collect();
if stored.is_empty() {
return HashMap::new();
}
match db.lids_for(&stored) {
Ok(lids) => stored
.iter()
.zip(lids)
.map(|(node, lid)| (node.uid.clone(), lid_ino(lid)))
.collect(),
Err(error) => {
warn!(%error, "no local ids before the listing; allocating them under the lock");
HashMap::new()
}
}
}
/// The local id of the node at inode `ino`, the inverse of [`lid_ino`]:
/// `None` for the root and a fallback inode.
pub(crate) fn ino_lid(ino: u64) -> Option<i64> {
(ino > crate::ROOT_INO && ino < FALLBACK_INO).then(|| (ino - crate::ROOT_INO) as i64)
}
/// Mutable inode bookkeeping, guarded by a mutex because fuser drives the
/// `Filesystem` trait through `&self`.
///
/// One tree for every mount of the daemon. Each FUSE session sees it from its
/// own root ([`State::roots`]) and calls that root inode 1; `ProtonFs`
/// translates at the kernel boundary.
pub(crate) struct State {
/// inode -> node metadata.
pub(crate) entries: HashMap<u64, Entry>,
@@ -156,9 +257,20 @@ pub(crate) struct State {
/// Cached directory listings: parent inode -> child inodes. Presence of a
/// key means the directory has been enumerated.
pub(crate) children: HashMap<u64, Vec<u64>>,
/// The inodes some session has been rooted at ([`State::install_root`]).
/// A root is its own parent. Kept after its session unmounts: the node is
/// still one of ours.
pub(crate) roots: HashSet<u64>,
/// The next inode for a node the database could not give a local id, from
/// a range lids never reach ([`FALLBACK_INO`]).
pub(crate) next_ino: u64,
/// Shared write state keyed by inode. Concurrent writers share the scratch file.
pub(crate) active_writes: HashMap<u64, WriteHandle>,
/// Inodes whose last write handle `release` has taken out of
/// [`State::active_writes`] and is still queueing. `close(2)` returns
/// before that, and in between the edit is in neither the handles nor the
/// queue, so a check for changes not on Drive yet looks here too.
pub(crate) releasing: HashSet<u64>,
/// Maps file handle id (fh) to inode (ino). Read-only opens use fh 0 and
/// have no entry here.
pub(crate) handles: HashMap<u64, u64>,
@@ -171,13 +283,13 @@ pub(crate) struct State {
/// two pages shifted every later entry, and the caller silently skipped or
/// repeated whatever crossed the page boundary. Each entry is
/// `(ino, is_dir, name)` — the kernel type is mapped at reply time so this
/// module stays free of `fuser` (bugs.md B43).
/// module stays free of `fuser` (`docs/BUGS.md` B43).
pub(crate) dir_snapshots: HashMap<u64, Arc<DirListing>>,
/// Resident inode attributes whose effective access changed during an
/// intern/root refresh. Core drains this set and notifies the matching
/// kernel only after releasing the State lock.
pub(crate) access_changes: HashSet<u64>,
/// Persisted shared-root authority, loaded once per inode space and updated
/// Persisted shared-root authority, loaded once and updated
/// on every explicit role/tombstone change. Descendant inheritance reads
/// this map instead of issuing one SQLite query per interned inode.
pub(crate) share_access: HashMap<NodeUid, Access>,
@@ -245,15 +357,6 @@ pub(crate) struct StateGuard<'a> {
db: &'a Arc<Db>,
}
/// Take a mount's inode lock through a [`StateGuard`], for the loops that walk
/// every live mount rather than this `Core`'s own.
pub(crate) fn lock_state<'a>(
state: &'a parking_lot::Mutex<State>,
db: &'a Arc<Db>,
) -> StateGuard<'a> {
StateGuard::new(state.lock(), db)
}
impl<'a> StateGuard<'a> {
pub(crate) fn new(guard: parking_lot::MutexGuard<'a, State>, db: &'a Arc<Db>) -> Self {
Self {
@@ -294,9 +397,6 @@ impl Drop for StateGuard<'_> {
impl State {
/// A fresh, empty inode space over `db`.
///
/// `next_ino` differs by caller: a real mount hands out [`crate::ROOT_INO`]
/// to its root and starts allocating above it, while a bare state under test
/// has no root and starts at 1.
/// Apply the write-throughs this state owes SQLite, here and now.
///
/// Production code never calls this: [`StateGuard`] releases the inode lock
@@ -323,13 +423,17 @@ impl State {
self.db.clone()
}
pub(crate) fn new(db: Arc<Db>, share_access: HashMap<NodeUid, Access>, next_ino: u64) -> Self {
/// A fresh, empty tree over `db`. A node's inode comes from its local id
/// ([`lid_ino`]).
pub(crate) fn new(db: Arc<Db>, share_access: HashMap<NodeUid, Access>) -> Self {
Self {
entries: HashMap::new(),
by_uid: HashMap::new(),
children: HashMap::new(),
next_ino,
roots: HashSet::new(),
next_ino: FALLBACK_INO,
active_writes: HashMap::new(),
releasing: HashSet::new(),
handles: HashMap::new(),
next_fh: 1,
dir_snapshots: HashMap::new(),
@@ -339,18 +443,55 @@ impl State {
outbox: Vec::new(),
}
}
/// Whether `uid` names an owned node currently reachable from this mount's
/// root. Resident entries can outlive their dentries for open-handle and
/// access-revocation semantics, so `by_uid` membership alone is not proof
/// that a control-socket operation may address the node.
pub(crate) fn owns_visible_uid(&self, uid: &NodeUid) -> bool {
let Some(root) = self.entries.get(&crate::ROOT_INO) else {
return false;
};
if uid.volume_id != root.uid.volume_id {
return false;
/// Make `root` the root of a session and return its inode, which comes
/// from its local id like any other node's.
///
/// The root is always owned. It must never be classified as a shared root,
/// even when the API returns membership data (which happens when the owner
/// shares the folder with someone else), so a stale share row a downgrade
/// may have left for it is dropped. A node already in the tree keeps its
/// inode and its lookups, and stops naming a parent: a session's `..` at
/// its root is the root.
pub(crate) fn install_root(&mut self, root: Node) -> u64 {
if let Err(e) = self.db.upsert_node(&root) {
warn!(uid = %root.uid, error = %e, "db upsert root failed");
}
self.share_access.remove(&root.uid);
let _ = self.db.delete_share_access(&root.uid);
let ino = match self.by_uid.get(&root.uid) {
Some(&ino) => ino,
None => self.new_inos(&[&root])[&root.uid],
};
if let Some(old) = self.entries.get(&ino).map(|e| e.parent)
&& old != ino
&& let Some(kids) = self.children.get_mut(&old)
{
kids.retain(|&k| k != ino);
}
self.by_uid.insert(root.uid.clone(), ino);
let lookup_count = self.entries.get(&ino).map_or(1, |e| e.lookup_count.max(1));
self.entries.insert(
ino,
Entry {
uid: root.uid.clone(),
parent: ino,
node: root,
access: Access::Owner,
lookup_count,
open_count: 0,
unlinked: false,
},
);
self.roots.insert(ino);
ino
}
/// Whether `uid` names an owned node currently reachable from one of
/// `roots`, the roots of the sessions that are mounted. Resident entries
/// can outlive their dentries for open-handle and access-revocation
/// semantics, so `by_uid` membership alone is not proof that a
/// control-socket operation may address the node.
pub(crate) fn owns_visible_uid(&self, uid: &NodeUid, roots: &[u64]) -> bool {
let Some(&target) = self.by_uid.get(uid) else {
return false;
};
@@ -363,7 +504,9 @@ impl State {
let Some(entry) = self.entries.get(&current) else {
return false;
};
if entry.uid.volume_id != root.uid.volume_id
// The node's root scopes it to its own volume, excluding foreign
// shared-with-me residents from uid-addressed sharing.
if entry.uid.volume_id != uid.volume_id
|| entry.unlinked
|| entry.node.trashed
|| entry.uid != entry.node.uid
@@ -371,7 +514,7 @@ impl State {
{
return false;
}
if current == crate::ROOT_INO {
if roots.contains(&current) {
return true;
}
@@ -413,15 +556,17 @@ impl State {
self.access_changes.drain().collect()
}
/// Whether `uid` lives on the volume this mount is rooted in.
/// Whether `uid` lives on a volume some mount is rooted in.
///
/// Used to decide how a node with no resolvable parent is classified:
/// our own volume fails open (it is owned content), a foreign volume
/// fails closed (it can only be there because of a share).
pub(crate) fn is_own_volume(&self, uid: &NodeUid) -> bool {
self.entries
.get(&crate::ROOT_INO)
.is_some_and(|root| root.uid.volume_id == uid.volume_id)
self.roots.iter().any(|ino| {
self.entries
.get(ino)
.is_some_and(|root| root.uid.volume_id == uid.volume_id)
})
}
fn stored_share_access(&self, uid: &NodeUid) -> Option<Access> {
@@ -770,11 +915,59 @@ impl State {
}
}
pub(crate) fn intern(&mut self, parent: u64, node: Node) -> u64 {
/// Write `node` through as it is, in order with the tree's other changes.
pub(crate) fn write_through(&mut self, node: Node) {
self.outbox.push(DbWrite::Upsert(vec![node]));
}
pub(crate) fn intern(&mut self, parent: u64, mut node: Node) -> u64 {
self.keep_revision_mtime(&mut node);
self.outbox.push(DbWrite::Upsert(vec![node.clone()]));
self.intern_mem(parent, node)
}
/// Keep what is already known of a file whose content has not changed.
///
/// Drive stamps a link's `ModifyTime` on a rename or a move as well, while
/// POSIX leaves a file's mtime alone there. The content cache is keyed by
/// it, too: taking the new stamp made the cached bytes unreachable, so the
/// next partial write could not fill its gaps (docs/BUGS.md B123). The
/// active revision id says whether the content is still the same, and so
/// whether the size and digest a listing left out are still the ones known.
fn keep_revision_mtime(&self, node: &mut Node) {
let Some(entry) = self
.by_uid
.get(&node.uid)
.and_then(|ino| self.entries.get(ino))
else {
return;
};
if let (
NodeKind::File {
active_revision_id: Some(now),
claimed_size,
content_sha1,
..
},
NodeKind::File {
active_revision_id: Some(was),
claimed_size: size_was,
content_sha1: sha1_was,
..
},
) = (&mut node.kind, &entry.node.kind)
&& now == was
{
node.modification_time = entry.node.modification_time;
if claimed_size.is_none() {
*claimed_size = *size_was;
}
if content_sha1.is_none() {
content_sha1.clone_from(sha1_was);
}
}
}
/// Allocate (or reuse) a stable inode for a node, updating the hot-cache maps
/// only. Every caller owes the DB a write-through — see the callers below;
/// the split exists so a batch can pay for one transaction instead of `n`.
@@ -784,11 +977,24 @@ impl State {
}
fn intern_mem_with_access(&mut self, parent: u64, node: Node, access: Access) -> u64 {
self.intern_mem_at(parent, node, access, None)
}
/// [`State::intern_mem_with_access`], with the inode a new entry takes when
/// the caller already has it ([`State::new_inos`]).
fn intern_mem_at(
&mut self,
parent: u64,
mut node: Node,
access: Access,
ino: Option<u64>,
) -> u64 {
if let Some(&ino) = self.by_uid.get(&node.uid) {
let changed = self
.entries
.get(&ino)
.is_some_and(|entry| entry.access != access);
self.keep_open_write_size(ino, &mut node);
if let Some(e) = self.entries.get_mut(&ino) {
e.node = node;
e.parent = parent;
@@ -801,8 +1007,13 @@ impl State {
}
return ino;
}
let ino = self.next_ino;
self.next_ino += 1;
let ino = match ino {
Some(ino) => ino,
None => self
.new_inos(&[&node])
.remove(&node.uid)
.unwrap_or_default(),
};
self.by_uid.insert(node.uid.clone(), ino);
self.entries.insert(
ino,
@@ -819,6 +1030,41 @@ impl State {
ino
}
/// The inodes for nodes that have no entry yet, from their local ids, in
/// one transaction. Should the database fail, they come from
/// [`State::next_ino`] instead, so the mount keeps working, with inodes
/// that last only this run. So do the inodes of virtual folders, which are
/// never stored.
fn new_inos(&mut self, nodes: &[&Node]) -> HashMap<NodeUid, u64> {
let (virtual_nodes, stored): (Vec<&Node>, Vec<&Node>) = nodes
.iter()
.partition(|node| crate::r#virtual::is_virtual_uid(&node.uid));
let mut inos = match self.db.lids_for(&stored) {
Ok(lids) => stored
.iter()
.zip(lids)
.map(|(node, lid)| (node.uid.clone(), lid_ino(lid)))
.collect(),
Err(error) => {
warn!(%error, "no local ids for new inodes; numbering them for this run");
self.run_inos(&stored)
}
};
inos.extend(self.run_inos(&virtual_nodes));
inos
}
fn run_inos(&mut self, nodes: &[&Node]) -> HashMap<NodeUid, u64> {
nodes
.iter()
.map(|node| {
let ino = self.next_ino;
self.next_ino += 1;
(node.uid.clone(), ino)
})
.collect()
}
/// Decrement lookup count for an inode and prune if lookup_count == 0 && open_count == 0 && unlinked.
pub(crate) fn forget_lookup(&mut self, ino: u64, nlookup: u64) -> Option<(u64, String)> {
if let Some(entry) = self.entries.get_mut(&ino) {
@@ -833,6 +1079,23 @@ impl State {
/// Allocate (or reuse) a stable inode for a node that came *from* the
/// database, which is why nothing is written back.
/// Carry the size and mtime of a file open for writing over onto `node`,
/// which is about to replace its entry.
///
/// The open handle is ahead of anything Drive can report until release
/// queues it. Taking the remote's size shrank a SQLite database to its empty
/// create while SQLite held it open, and the kernel then capped every read
/// at that size (docs/BUGS.md B109).
pub(crate) fn keep_open_write_size(&self, ino: u64, node: &mut Node) {
let (Some(aw), Some(entry)) = (self.active_writes.get(&ino), self.entries.get(&ino)) else {
return;
};
if let NodeKind::File { claimed_size, .. } = &mut node.kind {
*claimed_size = Some(aw.len as i64);
node.modification_time = entry.node.modification_time;
}
}
pub(crate) fn intern_from_db(&mut self, parent: u64, node: Node) -> u64 {
self.intern_mem(parent, node)
}
@@ -842,14 +1105,51 @@ impl State {
///
/// This is what keeps `ls` on a large folder quick: one commit for the
/// listing, rather than one autocommit — and one fsync — per child.
#[cfg(test)]
pub(crate) fn intern_batch(&mut self, parent: u64, nodes: Vec<Node>) -> Vec<u64> {
self.intern_batch_with_inos(parent, nodes, HashMap::new())
}
/// [`State::intern_batch`], with inodes for new entries that the caller
/// allocated before it took the lock, from [`inos_for`]. Allocating them
/// is a write transaction, and under the lock every inline handler of
/// every mount waited for it (`docs/BUGS.md` B221). A node missing from
/// `inos` still gets one here.
pub(crate) fn intern_batch_with_inos(
&mut self,
parent: u64,
mut nodes: Vec<Node>,
mut inos: HashMap<NodeUid, u64>,
) -> Vec<u64> {
for node in &mut nodes {
self.keep_revision_mtime(node);
}
self.outbox.push(DbWrite::Upsert(nodes.clone()));
let fresh: Vec<&Node> = nodes
.iter()
.filter(|node| !self.by_uid.contains_key(&node.uid) && !inos.contains_key(&node.uid))
.collect();
if !fresh.is_empty() {
let allocated = self.new_inos(&fresh);
inos.extend(allocated);
}
nodes
.into_iter()
.map(|node| self.intern_mem(parent, node))
.map(|node| {
let access = self.access_for_node(parent, &node);
let ino = inos.remove(&node.uid);
self.intern_mem_at(parent, node, access, ino)
})
.collect()
}
/// Record `uid`'s listing as complete once the lock is released, after the
/// rows of its children: written the other way round, a crash between the
/// two left a folder marked complete with no children to show.
pub(crate) fn record_listed(&mut self, uid: NodeUid) {
self.outbox.push(DbWrite::SetListed(uid, true));
}
/// Check if a directory inode has any child nodes in memory or in the database.
pub(crate) fn has_children(&self, parent: u64) -> bool {
// A resident listing is authoritative even when empty. Falling through
@@ -950,7 +1250,7 @@ impl State {
/// re-enumerate, which is the cheaper way to stay honest about what the
/// server did. A queued rename cannot: re-enumerating needs the network, and
/// the server has not been told yet in any case — so this *is* the tree's
/// new truth until the op drains (offline.md Phase 3b).
/// new truth until the op drains.
pub(crate) fn rename_in_place(
&mut self,
ino: u64,
@@ -1069,7 +1369,7 @@ impl State {
/// created offline, nothing at all), so this row is the only record that the
/// file is as long as the caller was told it is. Without it a restart serves
/// the stale size and the file reads as truncated — or empty — while its
/// bytes sit safely in staging (offline.md Phase 3).
/// bytes sit safely in staging.
/// Returns the node to persist rather than queueing it in the outbox, and is
/// the one mutation here whose write-through the caller must do itself:
/// failure has to be reported, not logged. Dropping the row silently means
@@ -1088,6 +1388,13 @@ mod tests {
use proton_drive_rs::ShareMembership;
use proton_drive_rs::proton_sdk::ids::{LinkId, ShareId, ShareMembershipId, VolumeId};
#[test]
fn an_inode_gives_back_the_lid_it_was_made_from() {
assert_eq!(ino_lid(lid_ino(7)), Some(7));
assert_eq!(ino_lid(crate::ROOT_INO), None);
assert_eq!(ino_lid(FALLBACK_INO + 3), None);
}
fn uid(link: &str) -> NodeUid {
NodeUid::new(VolumeId::from("vol"), LinkId::from(link))
}
@@ -1120,6 +1427,8 @@ mod tests {
photo: None,
album: None,
verification: Default::default(),
direct_role: None,
share_id: None,
}
}
@@ -1128,6 +1437,9 @@ mod tests {
share_id: ShareId::from("share"),
membership_id: ShareMembershipId::from("membership"),
permissions,
invite_time: None,
inviter_email: None,
inviter_verification: None,
}
}
@@ -1159,7 +1471,7 @@ mod tests {
let dir = TempDir::new();
let db = Db::open(&dir.0.join("cache.db")).unwrap();
let share_access = db.all_share_access().unwrap();
let st = State::new(Arc::new(db), share_access, 1);
let st = State::new(Arc::new(db), share_access);
(st, dir)
}
@@ -1177,6 +1489,74 @@ mod tests {
(st, dir, src, dst)
}
/// A node's inode comes from its row's local id, so it is the same in
/// every mount, after the hot cache drops it, and after a restart.
#[test]
fn a_node_keeps_its_inode_across_mounts_evictions_and_restarts() {
let (mut st, _dir) = state();
let inos = st.intern_batch(
0,
vec![
node("a", "root", "a.txt", false),
node("b", "root", "b.txt", false),
],
);
st.flush_outbox();
let db = st.db.clone();
assert_ne!(inos[0], inos[1]);
assert!(!inos.contains(&crate::ROOT_INO));
let mut other = State::new(db.clone(), HashMap::new());
assert_eq!(other.intern(0, node("b", "root", "b.txt", false)), inos[1]);
st.forget_mem(&uid("a"));
assert_eq!(st.intern(0, node("a", "root", "a.txt", false)), inos[0]);
let mut restarted = State::new(db, HashMap::new());
assert_eq!(
restarted.intern_batch(0, vec![node("a", "root", "a.txt", false)]),
[inos[0]]
);
}
/// A listing's inodes are allocated before the lock is taken, and are the
/// ones a node would get under it (`docs/BUGS.md` B221).
#[test]
fn inodes_allocated_before_the_lock_are_the_ones_the_node_keeps() {
let (mut st, _dir) = state();
let db = st.db.clone();
let listing = vec![
node("a", "root", "a.txt", false),
node("b", "root", "b.txt", false),
];
let early = inos_for(&db, &listing);
assert_eq!(early.len(), 2);
let inos = st.intern_batch_with_inos(0, listing, early.clone());
assert_eq!(inos, [early[&uid("a")], early[&uid("b")]]);
st.flush_outbox();
let mut restarted = State::new(db, HashMap::new());
assert_eq!(
restarted.intern(0, node("b", "root", "b.txt", false)),
early[&uid("b")]
);
}
/// The listing is recorded complete with its children's rows, when the
/// lock goes, never ahead of them.
#[test]
fn a_listing_is_recorded_complete_after_its_children() {
let (mut st, _dir) = state();
let root = st.intern(0, node("root", "none", "root", true));
st.flush_outbox();
st.intern_batch(root, vec![node("a", "root", "a.txt", false)]);
st.record_listed(uid("root"));
assert!(st.db.children_if_listed(&uid("root")).unwrap().is_none());
st.flush_outbox();
let kids = st.db.children_if_listed(&uid("root")).unwrap().unwrap();
assert_eq!(kids.len(), 1);
}
/// Audit A5. Moving a file out of a folder leaves that folder's DB row
/// claiming a complete listing, so once the hot cache drops it, remote
/// changes under it stop being seen. `relocate` has to clear both sides.
@@ -1242,7 +1622,7 @@ mod tests {
let root = uid("root").to_string();
let state = parking_lot::Mutex::new(st);
{
let mut guard = lock_state(&state, &db);
let mut guard = StateGuard::new(state.lock(), &db);
guard.intern(0, node("root", "none", "My Files", true));
assert!(
db.node_by_uid(&root).unwrap().is_none(),
@@ -1485,6 +1865,7 @@ mod tests {
})
.collect();
st.next_ino = crate::ROOT_INO + nodes.len() as u64;
st.roots.insert(crate::ROOT_INO);
// Reverse order so entries are inserted before their parents, matching
// the hydration path's non-topological materialization.
for (i, node) in nodes.iter().enumerate().rev() {
@@ -1858,4 +2239,137 @@ mod tests {
let shared = st.intern(owned_root, shared);
assert_eq!(st.entries[&shared].access, Access::Viewer);
}
fn at_revision(mut node: Node, revision: &str, mtime: i64) -> Node {
node.modification_time = mtime;
if let NodeKind::File {
active_revision_id, ..
} = &mut node.kind
{
*active_revision_id = Some(revision.into());
}
node
}
#[test]
fn a_renamed_file_keeps_its_mtime_until_its_content_changes() {
let (mut st, _dir) = state();
let root = st.intern(0, node("root", "none", "My Files", true));
let mut file = node("file", "root", "a.txt", false);
if let NodeKind::File {
claimed_size,
total_size_on_storage,
..
} = &mut file.kind
{
*claimed_size = Some(7);
*total_size_on_storage = 519;
}
let ino = st.intern(root, at_revision(file.clone(), "r1", 100));
// Drive stamps the link on a rename; the content is still r1.
let mut renamed = at_revision(file.clone(), "r1", 160);
renamed.name = "b.txt".into();
st.intern_batch(root, vec![renamed]);
assert_eq!(st.entries[&ino].node.modification_time, 100);
assert_eq!(st.entries[&ino].node.name, "b.txt");
// A listing that leaves the size out still describes revision r1.
let mut bare = at_revision(file.clone(), "r1", 170);
assert_eq!(crate::node_size(&st.entries[&ino].node), 7);
if let NodeKind::File { claimed_size, .. } = &mut bare.kind {
*claimed_size = None;
}
st.intern(root, bare);
assert_eq!(st.entries[&ino].node.modification_time, 100);
assert_eq!(crate::node_size(&st.entries[&ino].node), 7);
st.intern(root, at_revision(file, "r2", 200));
assert_eq!(st.entries[&ino].node.modification_time, 200);
}
fn handle(len: u64, base_size: u64) -> WriteHandle {
let path = std::env::temp_dir().join(format!("pdfs-handle-test-{}", std::process::id()));
let file = File::create(&path).unwrap();
let _ = std::fs::remove_file(&path);
WriteHandle {
ino: 2,
uid: uid("file"),
file: Arc::new(file),
path,
written: Intervals::default(),
len,
base_size,
base_mtime: 0,
base_revision_id: None,
dirty: false,
open_count: 1,
}
}
/// A file created offline is written while its queued create lands. The
/// placeholder had no revision, so the handle takes the empty file the
/// create made as its base (docs/BUGS.md B113).
#[test]
fn a_handle_on_a_placeholder_is_based_on_the_create_that_landed() {
let mut h = handle(0, 0);
assert_eq!(h.base_revision_id, None);
let mut landed = node("real", "root", "f.mkv", false);
landed.modification_time = 160;
if let NodeKind::File {
active_revision_id, ..
} = &mut landed.kind
{
*active_revision_id = Some("empty".into());
}
h.rebase_onto(&landed);
assert_eq!(h.base_mtime, 160);
assert_eq!(h.base_size, 0);
assert_eq!(h.base_revision_id.as_deref(), Some("empty"));
}
#[test]
fn a_listing_does_not_shrink_a_file_open_for_writing() {
let (mut st, _dir) = state();
let ino = st.intern(0, node("file", "root", "workload.db", false));
let mut h = handle(16384, 0);
h.ino = ino;
st.active_writes.insert(ino, h);
st.set_size(ino, 16384);
st.touch_mtime(ino, 500);
// The remote still holds the empty file the create sealed.
st.intern(0, node("file", "root", "workload.db", false));
assert_eq!(crate::node_size(&st.entries[&ino].node), 16384);
assert_eq!(st.entries[&ino].node.modification_time, 500);
// Once the handle is gone the remote is authoritative again.
st.active_writes.remove(&ino);
st.intern(0, node("file", "root", "workload.db", false));
assert_eq!(crate::node_size(&st.entries[&ino].node), 0);
}
#[test]
fn a_write_past_the_end_authors_the_hole_it_leaves() {
// Opened over a 100-byte base, shrunk to 10, then written at 50: bytes
// 10..50 are zeros now, not the base's bytes 10..50.
let mut h = handle(100, 100);
h.written.clip(10);
h.len = 10;
h.record_write(50, 60);
assert_eq!(h.len, 60);
assert_eq!(
h.written.segments(0, 60),
vec![(0, 10, false), (10, 60, true)],
"only the head still comes from the base"
);
}
#[test]
fn a_write_inside_the_file_leaves_the_rest_to_the_base() {
let mut h = handle(100, 100);
h.record_write(40, 50);
assert_eq!(h.len, 100);
assert_eq!(
h.written.segments(0, 100),
vec![(0, 40, false), (40, 50, true), (50, 100, false)]
);
}
}
+295
View File
@@ -0,0 +1,295 @@
//! 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 long a sync pass may go without reporting progress before it is called
/// out. Much longer than [`STALL_AFTER`]: one large file in a pass reports
/// nothing until its whole transfer is done.
const SYNC_STALL_AFTER: Duration = Duration::from_secs(900);
/// 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"
);
}
// The engine is one thread for every synced folder, so a dead or stuck one
// stops all of them while the worker pool looks idle (B259).
if let Some(sync) = &report.sync {
if !sync.running {
stalled = true;
warn!(
"the sync engine is not running; synced folders do not sync until the daemon restarts"
);
} else if let Some(folder) = sync.pass_folder
&& sync.pass_idle_secs >= SYNC_STALL_AFTER.as_secs()
{
stalled = true;
warn!(
folder,
age_secs = sync.pass_age_secs,
idle_secs = sync.pass_idle_secs,
"a sync pass has not reported progress for a long time"
);
}
}
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, SyncEngineState, 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)));
}
fn with_sync(sync: SyncEngineState) -> Diagnostics {
Diagnostics {
sync: Some(sync),
..Default::default()
}
}
/// A sync engine that has ended stops every synced folder while the
/// workers look idle, which is what issue #53 looked like (B259).
#[test]
fn a_stopped_sync_engine_is_a_stall() {
assert!(report_stalls(
&with_sync(SyncEngineState::default()),
(0, 0)
));
}
/// A pass that keeps reporting progress is working, however long it runs;
/// one that has gone quiet past the threshold is not.
#[test]
fn only_a_quiet_sync_pass_is_a_stall() {
let busy = SyncEngineState {
running: true,
pass_folder: Some(3),
pass_age_secs: SYNC_STALL_AFTER.as_secs() * 4,
pass_idle_secs: 5,
panics: 0,
};
assert!(!report_stalls(&with_sync(busy.clone()), (0, 0)));
let quiet = SyncEngineState {
pass_idle_secs: SYNC_STALL_AFTER.as_secs(),
..busy
};
assert!(report_stalls(&with_sync(quiet), (0, 0)));
let idle = SyncEngineState {
running: true,
..Default::default()
};
assert!(!report_stalls(&with_sync(idle), (0, 0)));
}
}
+68 -14
View File
@@ -37,10 +37,12 @@ const CONFLICT_SWEEP_INTERVAL: Duration = Duration::from_secs(300);
/// 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.
/// Strip a ` (sync-conflict <label> <stamp>)` (optionally `-<n>`) marker from
/// `name`, returning the original file name it was a copy of. The label names
/// the computer that made the copy (B251); copies made before it have the bare
/// stamp. `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)?;
@@ -48,6 +50,20 @@ pub(crate) fn conflict_base_name(name: &str) -> Option<String> {
let close_rel = name[after..].find(')')?;
let close = after + close_rel;
let inner = &name[after..close];
// A label is one token of the characters `conflict_label_of` keeps.
let inner = match inner.rsplit_once(' ') {
Some((label, token)) => {
let label_ok = !label.is_empty()
&& label
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'));
if !label_ok {
return None;
}
token
}
None => inner,
};
// 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, '-');
@@ -68,7 +84,7 @@ impl Core {
/// 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).
// five-minute interval to join this thread (`docs/BUGS.md` B44).
if !self.shutdown.sleep(CONFLICT_SWEEP_WARMUP) {
return;
}
@@ -109,6 +125,7 @@ impl Core {
by_parent_name.insert((parent, node.name.as_str()), node);
}
let roots = self.search_roots();
let online = self.online.load(std::sync::atomic::Ordering::Relaxed);
for node in &nodes {
if node.trashed || node.is_folder() {
@@ -132,7 +149,16 @@ impl Core {
self.remove_conflict_copy(node, &base);
}
}
SweepAction::Flag(detail) => self.flag_conflict(&node.uid, &node.name, detail),
// Only flag what the conflict listing can show. Anything else
// would sit in the feed as "resolved" with no way to act on
// it (`docs/BUGS.md` B99).
SweepAction::Flag(detail) => {
if self.conflict_path(&roots, &node.uid).is_some() {
self.flag_conflict(&node.uid, &node.name, detail);
} else {
debug!(uid = %node.uid, name = %node.name, "conflict sweep: copy is outside every location; not flagged");
}
}
}
}
}
@@ -155,7 +181,7 @@ impl Core {
}
match self
.rt
.block_on(self.client.trash_nodes(std::slice::from_ref(uid)))
.block_on(self.drive.trash_nodes(std::slice::from_ref(uid)))
.and_then(batch::into_unit)
{
Ok(()) => {}
@@ -165,10 +191,9 @@ impl Core {
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).
// Leaving a conflict copy hooked up in the tree keeps a node we have
// just trashed remotely visible and readable in whichever mount shows
// it (`docs/BUGS.md` B74).
self.for_each_state(|st| {
st.forget_or_unlink(uid);
});
@@ -261,7 +286,7 @@ impl Core {
/// 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 {
pub(crate) 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 {
@@ -273,12 +298,21 @@ impl Core {
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.
/// Surface a conflict copy that needs the user's attention, once. The
/// in-memory set spares the database each pass; the activity log itself is
/// what keeps a restart from logging the same copy again (`docs/BUGS.md`
/// B99). A copy's name carries its own timestamp, so the name identifies it.
fn flag_conflict(&self, uid: &NodeUid, name: &str, detail: String) {
if !self.conflict_notified.lock().insert(uid.clone()) {
return;
}
match self.db.activity_has(ActivityKind::Conflict, name) {
Ok(false) => {}
Ok(true) => return,
// Cannot tell: a repeat entry is harmless, a missing one hides a
// conflict.
Err(e) => warn!(%uid, name, error = ?e, "conflict sweep: activity check failed"),
}
debug!(%uid, name, detail, "conflict sweep: flagged divergent conflict copy");
self.log_activity(ActivityKind::Conflict, name, detail, false);
}
@@ -368,6 +402,8 @@ mod tests {
photo: None,
album: None,
verification: Default::default(),
direct_role: None,
share_id: None,
}
}
@@ -466,6 +502,24 @@ mod tests {
conflict_base_name("notes (sync-conflict 1700-3).txt").as_deref(),
Some("notes.txt")
);
// Named after the computer that made it (B251).
assert_eq!(
conflict_base_name("notes (sync-conflict laptop 1700).txt").as_deref(),
Some("notes.txt")
);
assert_eq!(
conflict_base_name("notes (sync-conflict my-pc.local 1700-2).txt").as_deref(),
Some("notes.txt")
);
assert_eq!(
conflict_base_name(&crate::conflict_name("notes.txt", 1700)).as_deref(),
Some("notes.txt")
);
assert_eq!(
conflict_base_name("x (sync-conflict two words 1).txt"),
None
);
assert_eq!(conflict_base_name("x (sync-conflict 1).txt"), None);
// Not a conflict copy.
assert_eq!(conflict_base_name("report.xml"), None);
// Looks close but the token is not a stamp.
+1673 -273
View File
File diff suppressed because it is too large. Load diff
Loaded 100 of 272 files, more files were not shown because too many files have changed in this diff. Show more