Compare commits

..
77 Commits
Author SHA1 Message Date
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
130 changed files with 20758 additions and 13144 deletions

No files matched your search

+1 -1
View File
@@ -64,7 +64,7 @@ jobs:
# Real Proton credentials and /dev/fuse deliberately do not run on pull # Real Proton credentials and /dev/fuse deliberately do not run on pull
# requests. Follow docs/TESTING.md on a dedicated test-account runner. # requests. Follow docs/TESTING.md on a dedicated test-account runner.
# The seeded simulation runs (docs/MILESTONE-3.0.0.md §8.2): real FUSE # The seeded simulation runs (docs/TESTING.md): real FUSE
# daemons against an in-memory Drive, so they need /dev/fuse but no account. # daemons against an in-memory Drive, so they need /dev/fuse but no account.
simulation: simulation:
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
+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
+50 -1
View File
@@ -153,6 +153,9 @@ jobs:
cp LICENSE deb/usr/share/licenses/proton-drive-linux/ cp LICENSE deb/usr/share/licenses/proton-drive-linux/
po/build.sh deb/usr/share/locale 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 # Calculate Installed-Size in KB for the control file
INSTALLED_SIZE=$(du -sk deb | cut -f1) INSTALLED_SIZE=$(du -sk deb | cut -f1)
@@ -230,21 +233,67 @@ jobs:
*.rpm *.rpm
if-no-files-found: error if-no-files-found: error
# 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: publish:
name: Create GitHub Release name: Create GitHub Release
needs: [checks, build-release] needs: [checks, build-release, nix]
if: startsWith(github.ref, 'refs/tags/') if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- name: Checkout
uses: actions/checkout@v7
- name: Download packages - name: Download packages
uses: actions/download-artifact@v8 uses: actions/download-artifact@v8
with: with:
name: proton-drive-linux-builds 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 - name: Create GitHub Release
uses: softprops/action-gh-release@v3 uses: softprops/action-gh-release@v3
with: with:
body_path: release-notes.md
files: | files: |
*.deb *.deb
*.tar.gz *.tar.gz
+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."
+2
View File
@@ -3,3 +3,5 @@
*.pdb *.pdb
.env .env
/rpmbuild /rpmbuild
/result
/result-*
+3 -3
View File
@@ -39,9 +39,9 @@ cargo test --workspace --locked
scripts/fuse-acceptance.sh --offline-only scripts/fuse-acceptance.sh --offline-only
``` ```
Changes to the FUSE layer, sync, or the pending-operation queue should also be exercised against a Changes to the FUSE layer, sync, or the queue should also pass the simulation runs and be
real mount before review — see [`docs/TESTING.md`](docs/TESTING.md). Live runs are destructive: exercised against a real mount before review — see [`docs/TESTING.md`](docs/TESTING.md). Live
use a dedicated test account, never one holding data you cannot lose. runs are destructive: use a dedicated test account, never one holding data you cannot lose.
## Making changes ## Making changes
Generated
+14 -10
View File
@@ -3474,7 +3474,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
[[package]] [[package]]
name = "pdfs-cli" name = "pdfs-cli"
version = "3.0.0" version = "3.4.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
@@ -3485,13 +3485,15 @@ dependencies = [
"tokio", "tokio",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"zeroize",
] ]
[[package]] [[package]]
name = "pdfs-core" name = "pdfs-core"
version = "3.0.0" version = "3.4.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"base64 0.23.1",
"dbus-secret-service-keyring-store", "dbus-secret-service-keyring-store",
"directories", "directories",
"ignore", "ignore",
@@ -3508,12 +3510,13 @@ dependencies = [
"thiserror 2.0.20", "thiserror 2.0.20",
"tokio", "tokio",
"tracing", "tracing",
"zeroize",
"zip", "zip",
] ]
[[package]] [[package]]
name = "pdfs-fuse" name = "pdfs-fuse"
version = "3.0.0" version = "3.4.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"base64 0.23.1", "base64 0.23.1",
@@ -3535,7 +3538,7 @@ dependencies = [
[[package]] [[package]]
name = "pdfs-gui" name = "pdfs-gui"
version = "3.0.0" version = "3.4.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
@@ -3572,9 +3575,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]] [[package]]
name = "pgp" name = "pgp"
version = "0.20.0" version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1cfa4743b28656065ff4c0ba09e46b357a65e8c00fc2341e89084b82f87cbdf1" checksum = "ae70f4d9325a391db30d115d6191a7bb67cec856aa0bba131290f0c4e09a532a"
dependencies = [ dependencies = [
"aead 0.5.2", "aead 0.5.2",
"aes 0.8.4", "aes 0.8.4",
@@ -3775,9 +3778,9 @@ dependencies = [
[[package]] [[package]]
name = "proton-drive-rs" name = "proton-drive-rs"
version = "0.7.2" version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6ce113d234474821d34787436ee80e1458e3d8a8f97212219615ed315eb70be" checksum = "1e789cab811c5e73e47fd97810b78a513cb254f4dd763b4eb3cad680d6dd31da"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"base64 0.23.1", "base64 0.23.1",
@@ -3802,9 +3805,9 @@ dependencies = [
[[package]] [[package]]
name = "proton-sdk" name = "proton-sdk"
version = "0.7.2" version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a33c67df9694f90ce60e5b34984275956244138511b4640ca0d100e1dac8b3d" checksum = "ace260acbf88c26f5bd4aef8c8c9898c322163c56620bdef3b6dd7742bec7619"
dependencies = [ dependencies = [
"aes-gcm 0.11.1", "aes-gcm 0.11.1",
"async-trait", "async-trait",
@@ -3831,6 +3834,7 @@ dependencies = [
"thiserror 2.0.20", "thiserror 2.0.20",
"tokio", "tokio",
"tracing", "tracing",
"zeroize",
] ]
[[package]] [[package]]
+4 -3
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"] members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
[workspace.package] [workspace.package]
version = "3.0.0" version = "3.4.0"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
authors = ["Nils Pukropp <contact@narl.io>"] authors = ["Nils Pukropp <contact@narl.io>"]
@@ -15,8 +15,8 @@ keywords = ["proton", "drive", "fuse", "sync", "linux"]
categories = ["filesystem", "command-line-utilities"] categories = ["filesystem", "command-line-utilities"]
[workspace.dependencies] [workspace.dependencies]
proton-sdk = { version = "0.7.2" } proton-sdk = { version = "0.8.2" }
proton-drive-rs = { version = "0.7.2" } proton-drive-rs = { version = "0.8.2" }
# Internal crates. # Internal crates.
pdfs-core = { path = "crates/pdfs-core" } pdfs-core = { path = "crates/pdfs-core" }
@@ -26,6 +26,7 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs",
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
base64 = "0.23" base64 = "0.23"
zeroize = "1"
thiserror = "2" thiserror = "2"
anyhow = "1" anyhow = "1"
# Non-poisoning mutexes. A handler that panics while holding a lock must not # Non-poisoning mutexes. A handler that panics while holding a lock must not
+11 -1
View File
@@ -15,6 +15,7 @@ scriptable CLI.
[![AUR](https://img.shields.io/aur/version/proton-drive-for-linux)](https://aur.archlinux.org/packages/proton-drive-for-linux) [![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) [![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) [![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"> <img src="images/files.png" alt="My files in the Proton Drive app" width="820">
@@ -75,6 +76,7 @@ scriptable CLI.
| Debian, Ubuntu 24.04 or newer | `.deb` from the [latest release](https://github.com/narrrl/proton-drive-linux/releases/latest) | | Debian, Ubuntu 24.04 or newer | `.deb` from the [latest release](https://github.com/narrrl/proton-drive-linux/releases/latest) |
| Fedora 44 or newer | `.rpm` from the [latest release](https://github.com/narrrl/proton-drive-linux/releases/latest) | | Fedora 44 or newer | `.rpm` from the [latest release](https://github.com/narrrl/proton-drive-linux/releases/latest) |
| Arch Linux | [`proton-drive-for-linux`](https://aur.archlinux.org/packages/proton-drive-for-linux) (also `-bin` and `-git`) in the AUR | | Arch Linux | [`proton-drive-for-linux`](https://aur.archlinux.org/packages/proton-drive-for-linux) (also `-bin` and `-git`) in the AUR |
| NixOS | The flake in this repository: see [Install → NixOS](docs/INSTALL.md#nixos-flake) |
| Anything else | [Build from source](docs/INSTALL.md#build-from-source) | | Anything else | [Build from source](docs/INSTALL.md#build-from-source) |
```bash ```bash
@@ -107,6 +109,7 @@ video thumbnails), headless use and uninstalling.
| [Configuration](docs/CONFIGURATION.md) | `config.json`, file locations, environment variables, the service | | [Configuration](docs/CONFIGURATION.md) | `config.json`, file locations, environment variables, the service |
| [Troubleshooting](docs/TROUBLESHOOTING.md) | Diagnosing problems and fixing common ones | | [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 | | [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 | | [Changelog](docs/CHANGELOG.md) | What changed in each release |
For contributors: [development guide](docs/DEVELOPMENT.md), [architecture](docs/ARCHITECTURE.md), For contributors: [development guide](docs/DEVELOPMENT.md), [architecture](docs/ARCHITECTURE.md),
@@ -148,7 +151,7 @@ keys before it leaves the machine, through a Rust implementation of Proton's Dri
Files you open are decrypted into a local cache so programs can read them. What that means for 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 the security of the machine is spelled out in the
[threat model](docs/ARCHITECTURE.md#8-threat-model-what-this-client-writes-to-disk-in-plaintext). [threat model](docs/ARCHITECTURE.md#10-threat-model-what-this-client-writes-to-disk-in-plaintext).
## Contributing ## Contributing
@@ -156,6 +159,13 @@ Bug reports, translations and patches are welcome. See [CONTRIBUTING.md](CONTRIB
workflow and the checks CI runs, and [SECURITY.md](SECURITY.md) to report a vulnerability workflow and the checks CI runs, and [SECURITY.md](SECURITY.md) to report a vulnerability
privately. privately.
## Support
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).
<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 ## License
Released under the [MIT License](LICENSE). Released under the [MIT License](LICENSE).
+4 -1
View File
@@ -4,6 +4,9 @@
Fixes land on the latest release. Please reproduce on the most recent version before reporting. 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 ## Reporting a vulnerability
**Do not open a public issue.** Report privately through GitHub's **Do not open a public issue.** Report privately through GitHub's
@@ -31,7 +34,7 @@ the interesting boundaries are:
or the wrong key material to Proton. or the wrong key material to Proton.
What the client deliberately keeps in plaintext on disk, and why, is documented in the What the client deliberately keeps in plaintext on disk, and why, is documented in the
[threat model](docs/ARCHITECTURE.md#8-threat-model-what-this-client-writes-to-disk-in-plaintext). [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 Behaviour described there is known and not a vulnerability by itself; a way to make it worse
than described is. than described is.
+1
View File
@@ -23,3 +23,4 @@ tracing-subscriber.workspace = true
clap.workspace = true clap.workspace = true
rpassword.workspace = true rpassword.workspace = true
serde_json.workspace = true serde_json.workspace = true
zeroize.workspace = true
+279 -28
View File
@@ -15,7 +15,7 @@ use pdfs_core::config::AppDirs;
use pdfs_core::control::{ use pdfs_core::control::{
ConflictKeep, ErrorKind, MountKind, RefreshScope, Request as CtlRequest, ConflictKeep, ErrorKind, MountKind, RefreshScope, Request as CtlRequest,
Response as CtlResponse, RestoreItem as CtlRestoreItem, ShareEntryKind, SyncIssue, SyncPhase, Response as CtlResponse, RestoreItem as CtlRestoreItem, ShareEntryKind, SyncIssue, SyncPhase,
pending_summary, daemon_outdated, pending_summary,
}; };
use pdfs_core::db::Db; use pdfs_core::db::Db;
use pdfs_core::service; use pdfs_core::service;
@@ -135,6 +135,24 @@ enum Command {
/// Account email; prompted if omitted. /// Account email; prompted if omitted.
#[arg(short, long)] #[arg(short, long)]
username: Option<String>, username: Option<String>,
/// For a two-password account, keep the mailbox password's unlock in the
/// system keyring so the daemon starts without asking after every login.
/// Without it the unlock lives in kernel memory for this session only.
#[arg(long)]
remember: bool,
},
/// Enter the mailbox password of a two-password account so the daemon can
/// unlock your files. Needed once per login session.
Unlock {
/// Keep the unlock in the system keyring instead of kernel memory only.
#[arg(long)]
remember: bool,
},
/// Forget the unlock held in kernel memory; the account is locked again.
Lock {
/// Also remove an unlock kept in the keyring with `--remember`.
#[arg(long)]
forget: bool,
}, },
/// Forget the stored session. /// Forget the stored session.
Logout, Logout,
@@ -154,17 +172,19 @@ enum Command {
/// Mountpoint; defaults to ~/ProtonDrive. /// Mountpoint; defaults to ~/ProtonDrive.
mountpoint: Option<PathBuf>, mountpoint: Option<PathBuf>,
}, },
/// Keep a file on this device (download + cache it). Needs a running mount. /// Keep a file or folder on this device (download + cache it). A folder's
/// files download in the background; `pdfs transfers` shows the progress.
/// Needs a running mount.
Pin { Pin {
/// File path, inside the mountpoint or relative to it. /// File or folder path, inside the mountpoint or relative to it.
path: PathBuf, path: PathBuf,
}, },
/// Stop keeping a file on this device, evicting its cached content. /// Stop keeping a file or folder on this device, evicting its cached content.
Unpin { Unpin {
/// File path, inside the mountpoint or relative to it. /// File or folder path, inside the mountpoint or relative to it.
path: PathBuf, path: PathBuf,
}, },
/// List files pinned to this device. /// List files and folders pinned to this device.
Pins, Pins,
/// List a directory via the running daemon (in-app browser backend). /// List a directory via the running daemon (in-app browser backend).
Ls { Ls {
@@ -610,6 +630,15 @@ enum DeviceCmd {
#[arg(long, conflicts_with = "uid")] #[arg(long, conflicts_with = "uid")]
clear: bool, clear: bool,
}, },
/// Move another computer's folders into this computer's backup and sync
/// them here. The other computer stays registered, without those folders.
Migrate {
/// Device uid (from `devices list`).
uid: String,
/// Skip the confirmation and accept every proposed local path.
#[arg(long)]
yes: bool,
},
} }
#[derive(Subcommand)] #[derive(Subcommand)]
@@ -803,7 +832,9 @@ fn main() -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
JSON_OUTPUT.store(cli.json, std::sync::atomic::Ordering::Relaxed); JSON_OUTPUT.store(cli.json, std::sync::atomic::Ordering::Relaxed);
match cli.command { match cli.command {
Command::Login { username } => cmd_login(username), Command::Login { username, remember } => cmd_login(username, remember),
Command::Unlock { remember } => cmd_unlock(remember),
Command::Lock { forget } => cmd_lock(forget),
Command::Logout => cmd_logout(), Command::Logout => cmd_logout(),
Command::Status => cmd_status(), Command::Status => cmd_status(),
Command::Locations => cmd_locations(), Command::Locations => cmd_locations(),
@@ -947,6 +978,24 @@ fn cmd_devices(action: DeviceCmd) -> Result<()> {
let uid = if clear { None } else { uid }; let uid = if clear { None } else { uid };
ok_or_bail(control_request(CtlRequest::AdoptDevice { uid })?)? ok_or_bail(control_request(CtlRequest::AdoptDevice { uid })?)?
} }
DeviceCmd::Migrate { uid, yes } => {
if !yes {
println!(
"Every folder that device backs up moves into this computer's backup.\n\
The device stays registered, without those folders. Stop Proton Drive\n\
on that computer first, or it sees its folders disappear."
);
confirm("Move them here?")?;
}
let response = control_request(CtlRequest::MigrateDevice { uid })?;
// A script gets the one reply and runs `sync restore` itself.
if emit_json(&response)? {
return Ok(());
}
ok_or_bail(response)?;
// The folders are this device's now, so the plain restore offers them.
cmd_sync_restore(yes, None)?
}
} }
Ok(()) Ok(())
} }
@@ -1781,7 +1830,7 @@ fn prompt_line(label: &str) -> Result<String> {
Ok(line.trim().to_owned()) Ok(line.trim().to_owned())
} }
fn cmd_login(username: Option<String>) -> Result<()> { fn cmd_login(username: Option<String>, remember: bool) -> Result<()> {
let username = match username { let username = match username {
Some(u) => u, Some(u) => u,
None => prompt_line("Proton email")?, None => prompt_line("Proton email")?,
@@ -1793,20 +1842,32 @@ fn cmd_login(username: Option<String>) -> Result<()> {
prompt_line("2FA code").map_err(|e| pdfs_core::Error::Other(format!("read 2FA code: {e}"))) prompt_line("2FA code").map_err(|e| pdfs_core::Error::Other(format!("read 2FA code: {e}")))
}; };
let rt = tokio::runtime::Runtime::new()?; // Only a two-password account is ever asked for this.
// Solving a CAPTCHA needs a browser engine, which the CLI has no business let get_mailbox = |attempt: u32| prompt_mailbox(attempt, remember);
// carrying. Point at the app that does rather than failing with the raw API
// message, which reads as "your login is broken". // Proton may ask for a CAPTCHA first, usually on a VPN. The CLI has no
rt.block_on(auth::login(&username, &password, get_totp)) // browser engine, so the user solves the page in their own browser and says
.map_err(|e| match e { // when they are done; the retry then carries the challenge token.
pdfs_core::Error::HumanVerificationRequired(_) => anyhow::anyhow!( let get_hv = |hv: pdfs_core::proton_sdk::api::HumanVerification| {
println!(
"Proton is asking for a CAPTCHA before it will accept this sign-in.\n\ "Proton is asking for a CAPTCHA before it will accept this sign-in.\n\
Run `pdfs-app` and sign in there — it can show the verification page.\n\ Open this page in your browser and complete it:\n\n {}\n",
(This is usually triggered by a VPN or an unfamiliar IP; signing in from \ auth::browser_verification_url(&hv)
your usual network often avoids it.)" );
), prompt_line("Press Enter once the page says you are verified")
other => anyhow::Error::new(other).context("login failed"), .map_err(|e| pdfs_core::Error::Other(format!("read verification answer: {e}")))?;
})?; Ok(auth::browser_verified(&hv))
};
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(auth::login_interactive(
&username,
&password,
get_totp,
get_mailbox,
get_hv,
))
.context("login failed")?;
// A daemon that is already up is parked in `wait_for_session`, sleeping // A daemon that is already up is parked in `wait_for_session`, sleeping
// between keyring checks; restart it so the mount appears now instead of // between keyring checks; restart it so the mount appears now instead of
@@ -1818,9 +1879,88 @@ fn cmd_login(username: Option<String>) -> Result<()> {
} }
println!("Logged in as {username}. Session stored in the system keyring."); println!("Logged in as {username}. Session stored in the system keyring.");
report_key_state();
Ok(()) Ok(())
} }
/// Ask for the mailbox password of a two-password account, without echo.
fn prompt_mailbox(attempt: u32, remember: bool) -> pdfs_core::Result<auth::Mailbox> {
if attempt == 0 {
eprintln!("This account has a separate mailbox password.");
} else {
eprintln!("That mailbox password did not unlock the account. Try again.");
}
let password = rpassword::prompt_password("Mailbox password: ")
.map_err(|e| pdfs_core::Error::Other(format!("read mailbox password: {e}")))?;
Ok(auth::Mailbox {
password: zeroize::Zeroizing::new(password),
remember,
})
}
/// Say where the account's keys are held, and what that means for the next login.
fn report_key_state() {
match auth::key_state() {
Ok(auth::KeyState::Session) => println!(
"Unlocked for this login session only. After you log out or reboot, run `pdfs unlock`."
),
Ok(auth::KeyState::Locked) => println!("The account is locked. Run `pdfs unlock`."),
_ => {}
}
}
fn cmd_unlock(remember: bool) -> Result<()> {
auth::load().context("not logged in (run `pdfs login` first)")?;
let rt = tokio::runtime::Runtime::new()?;
// Bounded like the login prompt: three wrong answers end the command.
let mut attempt = 0;
loop {
let mailbox = prompt_mailbox(attempt, remember)?;
match rt.block_on(auth::unlock(&mailbox.password, remember)) {
Ok(()) => break,
Err(pdfs_core::Error::WrongMailboxPassword) if attempt < 2 => attempt += 1,
Err(e) => return Err(anyhow::Error::new(e).context("unlock failed")),
}
}
// A daemon parked on the locked account would otherwise notice on its next
// poll; restarting it makes the mount appear now, as after `pdfs login`.
if service::is_active() {
service::restart();
}
println!("Unlocked.");
report_key_state();
Ok(())
}
fn cmd_lock(forget: bool) -> Result<()> {
auth::lock(forget)?;
println!(
"Locked. {}",
if forget {
"Unlock removed from memory and keyring."
} else {
"Run `pdfs unlock` to unlock again."
}
);
// The running daemon keeps its keys until it restarts; say so, since
// "locked" would otherwise suggest the mount stopped.
if service::is_active() {
println!(
"The running daemon keeps its keys until it restarts: `systemctl --user restart proton-drive`."
);
}
Ok(())
}
/// `stored`, `session` or `locked`: where the account's keys are held.
fn key_state_name() -> &'static str {
match auth::key_state() {
Ok(auth::KeyState::Stored) => "stored",
Ok(auth::KeyState::Session) => "session",
_ => "locked",
}
}
fn cmd_logout() -> Result<()> { fn cmd_logout() -> Result<()> {
auth::logout()?; auth::logout()?;
println!("Stored session cleared."); println!("Stored session cleared.");
@@ -1848,6 +1988,7 @@ fn cmd_status() -> Result<()> {
"username": session.as_ref().map(|s| s.username.clone()), "username": session.as_ref().map(|s| s.username.clone()),
"user_id": session.as_ref().map(|s| s.user_id.clone()), "user_id": session.as_ref().map(|s| s.user_id.clone()),
"scopes": session.as_ref().map(|s| s.scopes.clone()), "scopes": session.as_ref().map(|s| s.scopes.clone()),
"keys": session.as_ref().map(|_| key_state_name()),
"mount": mount, "mount": mount,
}); });
println!("{}", serde_json::to_string_pretty(&out)?); println!("{}", serde_json::to_string_pretty(&out)?);
@@ -1858,6 +1999,11 @@ fn cmd_status() -> Result<()> {
Ok(s) => { Ok(s) => {
println!("Logged in as {} (user {})", s.username, s.user_id); println!("Logged in as {} (user {})", s.username, s.user_id);
println!("Scopes: {}", s.scopes.join(", ")); println!("Scopes: {}", s.scopes.join(", "));
match key_state_name() {
"locked" => println!("Keys: locked — run `pdfs unlock`"),
"session" => println!("Keys: unlocked for this login session"),
_ => {}
}
} }
Err(pdfs_core::Error::NotLoggedIn) => { Err(pdfs_core::Error::NotLoggedIn) => {
println!("Not logged in. Run `pdfs login`."); println!("Not logged in. Run `pdfs login`.");
@@ -1880,9 +2026,13 @@ fn cmd_status() -> Result<()> {
staged_oldest_secs, staged_oldest_secs,
paused, paused,
paused_until, paused_until,
session_expired,
tokens_unsaved,
version,
.. ..
}) => { }) => {
let state = match (online, paused) { let state = match (online, paused) {
_ if session_expired => ", session expired: run `pdfs login`",
(_, true) => ", sync paused", (_, true) => ", sync paused",
(false, false) => ", offline", (false, false) => ", offline",
(true, false) => "", (true, false) => "",
@@ -1915,6 +2065,14 @@ fn cmd_status() -> Result<()> {
failing_error.as_deref().unwrap_or("no error recorded"), failing_error.as_deref().unwrap_or("no error recorded"),
); );
} }
if tokens_unsaved {
println!(
"Keyring refuses the refreshed sign-in; unlock it, or a reboot needs `pdfs login`"
);
}
if daemon_outdated(&version) {
println!("Daemon {}", outdated_daemon_note(&version));
}
} }
Ok(other) => println!("Mount: unexpected response {other:?}"), Ok(other) => println!("Mount: unexpected response {other:?}"),
Err(_) => println!("Mount: not running."), Err(_) => println!("Mount: not running."),
@@ -1922,6 +2080,20 @@ fn cmd_status() -> Result<()> {
Ok(()) Ok(())
} }
/// What `pdfs status` and `pdfs diagnose` say about a daemon still running
/// an older release than this CLI.
fn outdated_daemon_note(version: &str) -> String {
let running = if version.is_empty() {
"an older release".to_string()
} else {
version.to_string()
};
format!(
"still runs {running}, this is {}; run `systemctl --user restart proton-drive` to finish the update",
env!("CARGO_PKG_VERSION"),
)
}
fn cmd_locations() -> Result<()> { fn cmd_locations() -> Result<()> {
let response = control_request(CtlRequest::ListLocations)?; let response = control_request(CtlRequest::ListLocations)?;
if emit_json(&response)? { if emit_json(&response)? {
@@ -1989,7 +2161,7 @@ fn login_poll_delay(attempts: u32) -> std::time::Duration {
std::time::Duration::from_secs(BASE_SECS.saturating_mul(factor).min(MAX_SECS)) std::time::Duration::from_secs(BASE_SECS.saturating_mul(factor).min(MAX_SECS))
} }
/// Block until a session is stored, backing off between checks. /// Block until a session is stored and unlocked, backing off between checks.
/// ///
/// `load` and `sleep` are parameters rather than direct calls so the backoff /// `load` and `sleep` are parameters rather than direct calls so the backoff
/// schedule can be tested without a keyring and without real time passing. /// schedule can be tested without a keyring and without real time passing.
@@ -2014,6 +2186,16 @@ where
sleep(delay); sleep(delay);
attempts = attempts.saturating_add(1); attempts = attempts.saturating_add(1);
} }
Err(pdfs_core::Error::Locked) => {
let delay = login_poll_delay(attempts);
if attempts == 0 {
tracing::info!("account is locked; waiting for `pdfs unlock`…");
} else {
tracing::debug!(delay_secs = delay.as_secs(), "account still locked");
}
sleep(delay);
attempts = attempts.saturating_add(1);
}
Err(e) => return Err(e.into()), Err(e) => return Err(e.into()),
} }
} }
@@ -2024,25 +2206,31 @@ where
/// an external unmount triggers a remount; errors back off and retry. This is /// an external unmount triggers a remount; errors back off and retry. This is
/// the entry point for the systemd user service. /// the entry point for the systemd user service.
fn cmd_daemon(mountpoint: Option<PathBuf>) -> Result<()> { fn cmd_daemon(mountpoint: Option<PathBuf>) -> Result<()> {
// The daemon holds the account's key passphrases in memory; keep them out
// of core dumps and away from same-user ptrace.
pdfs_core::kernelkey::forbid_dumps();
loop { loop {
// Wait until a session is stored. The GUI enables this service on login, // Wait until a session is stored. The GUI enables this service on login,
// but the service may also start at boot before the user has logged in. // but the service may also start at boot before the user has logged in.
wait_for_session(|| auth::load().map(|_| ()), std::thread::sleep)?; wait_for_session(auth::ready, pdfs_fuse::idle_sleep)?;
match mount_once(mountpoint.clone()) { match mount_once(mountpoint.clone()) {
Ok(pdfs_fuse::MountOutcome::Shutdown) => { Ok(pdfs_fuse::MountOutcome::Shutdown) => {
tracing::info!("daemon stopping"); tracing::info!("daemon stopping");
return Ok(()); return Ok(());
} }
Ok(pdfs_fuse::MountOutcome::ResumeStored) => {
tracing::info!("resuming the session stored in the keyring");
}
Ok(pdfs_fuse::MountOutcome::Unmounted) => { Ok(pdfs_fuse::MountOutcome::Unmounted) => {
tracing::warn!("mount ended externally; remounting in 2s"); tracing::warn!("mount ended externally; remounting in 2s");
std::thread::sleep(std::time::Duration::from_secs(2)); pdfs_fuse::idle_sleep(std::time::Duration::from_secs(2));
} }
Err(e) => { Err(e) => {
// `{:#}` so the whole anyhow chain lands in the log — the top // `{:#}` so the whole anyhow chain lands in the log — the top
// context alone ("mount failed") never says *why*. // context alone ("mount failed") never says *why*.
tracing::error!(error = format!("{e:#}"), "mount failed; retrying in 5s"); tracing::error!(error = format!("{e:#}"), "mount failed; retrying in 5s");
std::thread::sleep(std::time::Duration::from_secs(5)); pdfs_fuse::idle_sleep(std::time::Duration::from_secs(5));
} }
} }
} }
@@ -2104,9 +2292,14 @@ fn mount_once(mountpoint: Option<PathBuf>) -> Result<pdfs_fuse::MountOutcome> {
}, },
); );
// Clean unmount check (as a best-effort final backup). // Last chance for a rotation the keyring refused when it happened. Only
if let Err(e) = rt.block_on(auth::persist(&session)) { // that: by now the keyring may hold a newer login, the one this stop was
tracing::debug!(error = %e, "no new tokens to persist on shutdown"); // restarted for, and the tokens of this session would overwrite it.
if let Err(e) = auth::persist(&session) {
tracing::error!(
error = %e,
"could not save the refreshed session tokens; the next start may need `pdfs login`"
);
} }
// Dropping the runtime waits for every blocking task, however long; a call // Dropping the runtime waits for every blocking task, however long; a call
// still stuck on Drive would hold the stop until systemd kills it (B165). // still stuck on Drive would hold the stop until systemd kills it (B165).
@@ -3224,6 +3417,19 @@ fn cmd_diagnose() -> Result<()> {
match pdfs_core::auth::load() { match pdfs_core::auth::load() {
Ok(session) => { Ok(session) => {
report.finding(DiagnoseLevel::Ok, " keyring session", session.username); report.finding(DiagnoseLevel::Ok, " keyring session", session.username);
match key_state_name() {
"locked" => report.finding(
DiagnoseLevel::Fail,
" account keys",
"locked — run `pdfs unlock` (the daemon waits for it)",
),
"session" => report.finding(
DiagnoseLevel::Ok,
" account keys",
"unlocked for this login session",
),
_ => report.finding(DiagnoseLevel::Ok, " account keys", "stored in the keyring"),
}
} }
Err(e) => { Err(e) => {
// A locked keyring and an absent login look the same from here, so // A locked keyring and an absent login look the same from here, so
@@ -3254,11 +3460,21 @@ fn cmd_diagnose() -> Result<()> {
Ok(CtlResponse::Status { Ok(CtlResponse::Status {
mountpoint, mountpoint,
online, online,
session_expired,
tokens_unsaved,
pending_uploads, pending_uploads,
pending_changes, pending_changes,
version,
.. ..
}) => { }) => {
report.finding(DiagnoseLevel::Ok, " daemon responding", ""); report.finding(DiagnoseLevel::Ok, " daemon responding", "");
if daemon_outdated(&version) {
report.finding(
DiagnoseLevel::Warn,
" daemon version",
outdated_daemon_note(&version),
);
}
let live_type = mounted_fs_type(Path::new(&mountpoint)); let live_type = mounted_fs_type(Path::new(&mountpoint));
let is_fuse = live_type let is_fuse = live_type
.as_deref() .as_deref()
@@ -3275,6 +3491,24 @@ fn cmd_diagnose() -> Result<()> {
|kind| format!("{mountpoint} ({kind})"), |kind| format!("{mountpoint} ({kind})"),
), ),
); );
// The keyring check above read the blob, which still holds the
// tokens the daemon could not replace.
if tokens_unsaved {
report.finding(
DiagnoseLevel::Warn,
" session tokens",
"the keyring refuses the refreshed ones; unlock it, or a reboot needs `pdfs login`",
);
}
// A refused session reads as offline too, but no connection will
// bring it back, and the keyring check above cannot tell.
if session_expired {
report.finding(
DiagnoseLevel::Fail,
" session",
"Proton refused it (expired or revoked) — run `pdfs login`",
);
} else {
report.finding( report.finding(
if online { if online {
DiagnoseLevel::Ok DiagnoseLevel::Ok
@@ -3288,6 +3522,7 @@ fn cmd_diagnose() -> Result<()> {
"offline; cached data remains available" "offline; cached data remains available"
}, },
); );
}
let pending = pending_uploads + pending_changes; let pending = pending_uploads + pending_changes;
report.finding( report.finding(
if pending == 0 { if pending == 0 {
@@ -3522,6 +3757,21 @@ mod login_wait_tests {
); );
} }
#[test]
fn a_locked_account_is_waited_on_until_it_is_unlocked() {
let mut checks = vec![
Ok(()),
Err(pdfs_core::Error::Locked),
Err(pdfs_core::Error::NotLoggedIn),
Err(pdfs_core::Error::Locked),
];
let mut slept = 0;
wait_for_session(|| checks.pop().unwrap(), |_| slept += 1).unwrap();
assert_eq!(slept, 3, "locked and signed-out are both waited out");
}
#[test] #[test]
fn a_stored_session_is_not_waited_for_at_all() { fn a_stored_session_is_not_waited_for_at_all() {
let mut slept = 0; let mut slept = 0;
@@ -3545,6 +3795,7 @@ mod login_wait_tests {
size: 1, size: 1,
modified: 0, modified: 0,
pinned: false, pinned: false,
pinned_by_folder: false,
cached: false, cached: false,
uid: "vol~link".into(), uid: "vol~link".into(),
path: String::new(), path: String::new(),
+2
View File
@@ -28,3 +28,5 @@ dbus-secret-service-keyring-store.workspace = true
parking_lot.workspace = true parking_lot.workspace = true
async-trait.workspace = true async-trait.workspace = true
libc.workspace = true libc.workspace = true
zeroize.workspace = true
base64.workspace = true
+1018 -120
View File
File diff suppressed because it is too large. Load diff
+25 -43
View File
@@ -91,7 +91,7 @@ const TOUCH_FLUSH_AT: usize = 256;
/// ///
/// Public because it is the *contract* a block has to satisfy, not a detail of /// 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 /// 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 { pub fn block_len(size: u64, idx: u64) -> u64 {
let start = idx.saturating_mul(BLOCK_SIZE); let start = idx.saturating_mul(BLOCK_SIZE);
size.saturating_sub(start).min(BLOCK_SIZE) size.saturating_sub(start).min(BLOCK_SIZE)
@@ -311,8 +311,7 @@ struct PinFile {
pins: BTreeMap<String, Pin>, pins: BTreeMap<String, Pin>,
} }
/// What a staged file actually contains, written beside it as `<name>.json` /// What a staged file actually contains, written beside it as `<name>.json`.
/// (offline.md Phase 2).
/// ///
/// A staged file is **not** necessarily valid whole-file content, and that is /// 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 /// the whole reason this exists. A partial overwrite commits by filling the
@@ -439,9 +438,9 @@ pub struct ContentCache {
/// Subdirectory for write-handle scratch files (disk-backed write buffers). /// Subdirectory for write-handle scratch files (disk-backed write buffers).
/// Emptied on open so a crashed run leaves no orphans. /// Emptied on open so a crashed run leaves no orphans.
scratch_dir: PathBuf, scratch_dir: PathBuf,
/// Subdirectory holding the bytes of writes that have not been uploaded yet /// Subdirectory holding the bytes of writes that have not been uploaded yet.
/// (offline.md Phase 2/3) — every released write passes through here, since /// Every released write passes through here, since the upload is a queued
/// the upload is a queued op performed later. Unlike `scratch_dir` this is /// op performed later. Unlike `scratch_dir` this is
/// **never** emptied on open: these are the only copy of content the user /// **never** emptied on open: these are the only copy of content the user
/// authored, and the whole point is that they outlive the daemon. /// authored, and the whole point is that they outlive the daemon.
staging_dir: PathBuf, staging_dir: PathBuf,
@@ -476,7 +475,7 @@ pub struct ContentCache {
thumb_bytes: AtomicU64, thumb_bytes: AtomicU64,
/// Unified metadata DB. Its `cache_entries` table is the LRU index: every /// 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 /// 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>, db: Arc<Db>,
/// LRU touches not yet written to `cache_entries`, keyed by cache key. /// LRU touches not yet written to `cache_entries`, keyed by cache key.
/// ///
@@ -989,7 +988,7 @@ impl ContentCache {
/// a miss ([`cached_block`](Self::cached_block) checks), so a bad block /// a miss ([`cached_block`](Self::cached_block) checks), so a bad block
/// cannot escape this cache even though this does not check. Validating a /// 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 /// 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( pub fn store_block(
&self, &self,
uid: &NodeUid, uid: &NodeUid,
@@ -1491,9 +1490,8 @@ impl ContentCache {
/// This is what makes a write survive its upload: the caller is releasing a /// 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 /// 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 /// on the remote, staging holds the only copy. Every dirty handle goes
/// through here — the upload is a queued op performed later (offline.md /// through here — the upload is a queued op performed later, and a staged
/// Phase 3), and a staged file is also what a human can recover from if the /// file is also what a human can recover from if the queue never drains.
/// queue never drains.
/// ///
/// Falls back to a copy when the rename crosses a filesystem boundary; a /// 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 /// failure here means we could not save the bytes at all, so it is reported
@@ -2311,42 +2309,26 @@ mod tests {
/// call also needs. /// call also needs.
#[test] #[test]
fn budget_check_is_free_when_under_budget() { fn budget_check_is_free_when_under_budget() {
use std::time::Instant;
// Cap far above what we store, so every store stays under budget. // Cap far above what we store, so every store stays under budget.
let (c, _d) = cache_capped(1 << 30); let (c, _d) = cache_capped(1 << 30);
let payload = vec![0u8; 4096]; let payload = vec![0u8; 4096];
for i in 0..8u64 {
// Cost of a store against a near-empty index...
let t = Instant::now();
for i in 0..500u64 {
c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, i), &payload) c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, i), &payload)
.unwrap(); .unwrap();
} }
let empty = t.elapsed() / 500;
// ...and against one with a few thousand entries in it. // Plant an index row far over the cap behind the running total's back,
for i in 500..2500u64 { // and older than every real block. Any budget check that consults the
c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, i), &payload) // index would see the pool over budget, evict, and reseed the total.
.unwrap(); c.db.cache_touch("planted", KIND_BLOCK, 1 << 40, 0).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;
println!("B4: store_block under budget — {empty:?} at ~0 entries, {full:?} at 2500"); c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, 8), &payload)
// A ratio, not a wall-clock bound: the claim is that the under-budget .unwrap();
// 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 // Not a timing ratio: that measured the CI runner's load and flaked.
// runs alongside the rest of the suite. assert_eq!(c.block_bytes.load(Ordering::Relaxed), 9 * 4096);
assert!( let entries = c.db.cache_entries_by_kind(KIND_BLOCK).unwrap();
full < empty * 3, assert_eq!(entries.len(), 10, "under-budget store evicted: {entries:?}");
"under-budget store should not scale with the index: \
{empty:?} at ~0 entries vs {full:?} at 2500"
);
} }
/// The running totals are a fast path, not a second source of truth: after /// The running totals are a fast path, not a second source of truth: after
@@ -2707,7 +2689,7 @@ mod tests {
} }
/// The geometry the whole read path validates against, here and over the /// 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] #[test]
fn block_len_is_full_blocks_then_the_remainder() { fn block_len_is_full_blocks_then_the_remainder() {
let size = BLOCK_SIZE * 2 + 17; let size = BLOCK_SIZE * 2 + 17;
@@ -2787,7 +2769,7 @@ mod tests {
/// A cached block is addressed by the range it covers, not by its index /// 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 /// 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 /// blocks the uniform fallback cached, rather than serve their bytes at the
/// wrong offset (bugs.md B85). /// wrong offset (`docs/BUGS.md` B85).
#[test] #[test]
fn a_cached_block_is_not_served_to_a_different_geometry() { fn a_cached_block_is_not_served_to_a_different_geometry() {
let (c, _d) = cache(); let (c, _d) = cache();
@@ -2854,7 +2836,7 @@ mod tests {
/// A block of the wrong length cannot escape the cache even though the /// 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 /// 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] #[test]
fn a_block_of_the_wrong_length_reads_as_a_miss() { fn a_block_of_the_wrong_length_reads_as_a_miss() {
let (c, _d) = cache(); let (c, _d) = cache();
@@ -2919,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 scratch file moves to staging intact, and staging survives a reopen
/// the way scratch deliberately does not. /// the way scratch deliberately does not.
#[test] #[test]
+110 -38
View File
@@ -3,7 +3,7 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use directories::ProjectDirs; use directories::ProjectDirs;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Deserializer, Serialize};
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::menu::PromptConfig; use crate::menu::PromptConfig;
@@ -12,8 +12,19 @@ use crate::syncignore::DEFAULT_IGNORE_PATTERNS;
/// Proton requires `external-drive-{name}@{semver}-{channel}` (channel ∈ /// Proton requires `external-drive-{name}@{semver}-{channel}` (channel ∈
/// stable/beta/alpha); a malformed value trips the 422 anti-abuse path. /// stable/beta/alpha); a malformed value trips the 422 anti-abuse path.
pub const APP_VERSION: &str = "external-drive-linux@0.1.0-alpha"; /// Follows the release so Proton can tell releases apart and retire old ones
pub const USER_AGENT: &str = "proton-drive-linux/0.1.0"; /// 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. /// Keyring service name; one entry per credential kind keyed by username.
pub const KEYRING_SERVICE: &str = "proton-drive-linux"; pub const KEYRING_SERVICE: &str = "proton-drive-linux";
@@ -76,10 +87,24 @@ fn resolve_sweep_mode(env: Option<&str>, stored: Option<SweepMode>) -> SweepMode
} }
/// Configuration structure allowing the user to customize client identification headers. /// 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 struct AppConfig {
pub app_version: String, /// Override for [`APP_VERSION`]. `None` means "use the release's own";
pub user_agent: String, /// 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). /// Soft cap on the on-disk content cache, in bytes (`0` = unlimited).
/// `None` means "use [`DEFAULT_CACHE_BUDGET_BYTES`]"; the Settings page /// `None` means "use [`DEFAULT_CACHE_BUDGET_BYTES`]"; the Settings page
/// writes an explicit value here. Defaulted for configs predating the field. /// writes an explicit value here. Defaulted for configs predating the field.
@@ -157,7 +182,7 @@ pub struct AppConfig {
/// Record a new folder, a new file, a rename or a delete through the mount /// 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 /// 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 /// syscall. `None` means on. `false` brings back the old path for one
/// release (docs/MILESTONE-3.0.0.md §10). Defaulted for configs predating /// release (docs/ROADMAP.md). Defaulted for configs predating
/// the field. /// the field.
#[serde(default)] #[serde(default)]
pub local_first: Option<bool>, pub local_first: Option<bool>,
@@ -216,31 +241,28 @@ impl FileSort {
} }
} }
impl Default for AppConfig { /// Reads an `app_version` or `user_agent` override, dropping the fixed
fn default() -> Self { /// identity older releases stored as the default.
Self { fn identity_override<'de, D: Deserializer<'de>>(
app_version: APP_VERSION.to_string(), deserializer: D,
user_agent: USER_AGENT.to_string(), ) -> std::result::Result<Option<String>, D::Error> {
cache_budget: None, let value = Option::<String>::deserialize(deserializer)?;
upload_limit: None, Ok(value.filter(|v| v != LEGACY_APP_VERSION && v != LEGACY_USER_AGENT))
download_limit: None,
mountpoint: None,
ignore_patterns: None,
device_uid: None,
conflict_sweep: None,
open_with: None,
prompt: None,
proton_theme: None,
files_view: FilesView::default(),
tray_hidden: false,
language: None,
online_map: false,
local_first: None,
}
}
} }
impl AppConfig { 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 /// The effective cache budget in bytes: the user's explicit choice, or
/// [`DEFAULT_CACHE_BUDGET_BYTES`] when unset. /// [`DEFAULT_CACHE_BUDGET_BYTES`] when unset.
pub fn resolved_cache_budget(&self) -> u64 { pub fn resolved_cache_budget(&self) -> u64 {
@@ -432,7 +454,7 @@ impl AppDirs {
/// typical umask makes them `0755`. Until this was fixed, the only thing /// typical umask makes them `0755`. Until this was fixed, the only thing
/// keeping another local user out was `~/.cache` and `~/.local/state` /// keeping another local user out was `~/.cache` and `~/.local/state`
/// happening to be `0700`, which is a convention of the user's system and /// 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 /// Applied on every start, not just at creation: a directory that already
/// exists with a permissive mode — restored from a backup that flattened /// exists with a permissive mode — restored from a backup that flattened
@@ -483,7 +505,7 @@ fn restrict_dir(dir: &Path) -> Result<()> {
/// session — enumerate, read, upload, trash, share — without presenting any /// session — enumerate, read, upload, trash, share — without presenting any
/// credential, since the daemon already holds one. It is an authority boundary, /// credential, since the daemon already holds one. It is an authority boundary,
/// not merely private data, and `UnixListener::bind` applies the umask like any /// 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 /// 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 /// carries the umask's mode. It is closed in practice by the containing
@@ -504,7 +526,7 @@ mod tests {
std::fs::metadata(p).unwrap().permissions().mode() & 0o777 std::fs::metadata(p).unwrap().permissions().mode() & 0o777
} }
/// bugs.md B6. A permissive mode here is not a cosmetic problem: these /// `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, /// directories hold decrypted content, a plaintext index of every node name,
/// and the control socket that commands the daemon's session. /// and the control socket that commands the daemon's session.
#[test] #[test]
@@ -571,8 +593,8 @@ mod tests {
#[test] #[test]
fn test_app_config_serialization() { fn test_app_config_serialization() {
let config = AppConfig { let config = AppConfig {
app_version: "external-drive-test-client@1.0.0".to_string(), app_version: Some("external-drive-test-client@1.0.0".to_string()),
user_agent: "test-agent/1.0".to_string(), user_agent: Some("test-agent/1.0".to_string()),
cache_budget: Some(1234), cache_budget: Some(1234),
upload_limit: Some(500_000), upload_limit: Some(500_000),
download_limit: None, download_limit: None,
@@ -596,8 +618,11 @@ mod tests {
}; };
let json = serde_json::to_string(&config).unwrap(); let json = serde_json::to_string(&config).unwrap();
let decoded: AppConfig = serde_json::from_str(&json).unwrap(); let decoded: AppConfig = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.app_version, "external-drive-test-client@1.0.0"); assert_eq!(
assert_eq!(decoded.user_agent, "test-agent/1.0"); 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_eq!(decoded.proton_theme, Some(true));
assert!(decoded.tray_hidden); assert!(decoded.tray_hidden);
assert!(decoded.online_map); assert!(decoded.online_map);
@@ -628,8 +653,55 @@ mod tests {
#[test] #[test]
fn test_default_app_config() { fn test_default_app_config() {
let default_config = AppConfig::default(); let default_config = AppConfig::default();
assert_eq!(default_config.app_version, APP_VERSION); assert_eq!(default_config.resolved_app_version(), APP_VERSION);
assert_eq!(default_config.user_agent, USER_AGENT); 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] #[test]
+88 -8
View File
@@ -443,11 +443,18 @@ pub enum Request {
DeleteDevice { uid: String }, DeleteDevice { uid: String },
/// Adopt an existing device as this machine's, pinning its uid in /// Adopt an existing device as this machine's, pinning its uid in
/// `config.json` so a hostname change or reinstall re-attaches to it instead /// `config.json` so a hostname change or reinstall re-attaches to it instead
/// of registering a duplicate (features.md 5.1). `uid: None` clears the pin. /// of registering a duplicate. `uid: None` clears the pin.
/// Replies with [`Response::Ok`]. /// Replies with [`Response::Ok`].
AdoptDevice { uid: Option<String> }, AdoptDevice { uid: Option<String> },
/// Move every folder another device backs up into this machine's own
/// device, as server-side moves on the main volume. The other device stays
/// registered, without those folders. Sync folders here that already track
/// one of them follow it to its new share. Nothing is downloaded: the moved
/// folders are then offered by [`Request::ListRestorableFolders`].
/// Replies with [`Response::Ok`].
MigrateDevice { uid: String },
// ---- device folder sync (devices.md) ---------------------------------- // ---- device folder sync ----------------------------------
/// Add a local folder to this machine's device, uploading its tree and /// Add a local folder to this machine's device, uploading its tree and
/// registering the device on first use. Replies with [`Response::Ok`]. /// registering the device on first use. Replies with [`Response::Ok`].
AddSyncFolder { local_path: String }, AddSyncFolder { local_path: String },
@@ -496,7 +503,7 @@ pub enum Request {
/// [`Response::Ok`]. /// [`Response::Ok`].
ResolveConflict { path: String, keep: ConflictKeep }, ResolveConflict { path: String, keep: ConflictKeep },
/// List the folders under this machine's device that can be synced here, /// List the folders under this machine's device that can be synced here,
/// each with a proposed local path (features.md 5.2). Replies with /// each with a proposed local path. Replies with
/// [`Response::RestorableFolders`]. /// [`Response::RestorableFolders`].
ListRestorableFolders, ListRestorableFolders,
/// Attach the given remote device folders to local paths and sync them down. /// Attach the given remote device folders to local paths and sync them down.
@@ -1218,6 +1225,12 @@ pub struct JobItem {
/// Steps known to need doing, or `0` when unknown (indeterminate progress). /// Steps known to need doing, or `0` when unknown (indeterminate progress).
/// May *grow* mid-job as more work is discovered. /// May *grow* mid-job as more work is discovered.
pub total: u64, pub total: u64,
/// Housekeeping that changes nothing in the user's files: a mirror folder
/// scan that finds nothing to do yet, an index rebuild, a cache check. Front
/// ends may show it, but should not report the drive as busy over it. Absent
/// from older daemons, which never set it.
#[serde(default)]
pub background: bool,
} }
/// One in-flight transfer in a [`Response::Transfers`] snapshot. /// One in-flight transfer in a [`Response::Transfers`] snapshot.
@@ -1247,8 +1260,15 @@ pub struct DirEntry {
pub size: u64, pub size: u64,
/// Modification time, epoch seconds. /// Modification time, epoch seconds.
pub modified: i64, pub modified: i64,
/// Whether the file is pinned to this device. /// Whether the node is pinned to this device, on its own or through a
/// pinned folder above it.
pub pinned: bool, pub pinned: bool,
/// Whether the node is kept on this device because a folder above it is
/// pinned. Such a node cannot be unpinned on its own. Set in
/// [`Request::ListDir`] listings only. Defaulted for wire-compat with
/// daemons predating the field.
#[serde(default)]
pub pinned_by_folder: bool,
/// Whether the file's full content is present in the local cache (a current, /// Whether the file's full content is present in the local cache (a current,
/// non-stale blob). Always false for folders. Defaulted for wire-compat with /// non-stale blob). Always false for folders. Defaulted for wire-compat with
/// clients/daemons predating the field. /// clients/daemons predating the field.
@@ -1837,6 +1857,27 @@ pub fn pending_summary(uploads: u64, changes: u64) -> Option<String> {
} }
} }
/// Whether the daemon that reported `version` in a [`Response::Status`] is an
/// older release than this build. Installing a package replaces the binaries
/// but leaves the running user service on the old one, which keeps its bugs
/// until it restarts, while the app and the CLI already describe the new
/// release (#33). Empty means a daemon from before the field, so older too.
///
/// Only older counts. A newer daemon means this front-end is the one still
/// running from before the upgrade, and restarting the service would not help.
pub fn daemon_outdated(version: &str) -> bool {
fn parse(v: &str) -> Option<(u64, u64, u64)> {
let mut parts = v.split('.').map(|p| p.parse().ok());
let parsed = (parts.next()??, parts.next()??, parts.next()??);
parts.next().is_none().then_some(parsed)
}
match (parse(version), parse(env!("CARGO_PKG_VERSION"))) {
(Some(running), Some(this)) => running < this,
(None, _) => version.is_empty(),
(Some(_), None) => false,
}
}
/// One worker thread in a [`Diagnostics`] report. /// One worker thread in a [`Diagnostics`] report.
#[derive(Serialize, Deserialize, Debug, Clone)] #[derive(Serialize, Deserialize, Debug, Clone)]
pub struct WorkerState { pub struct WorkerState {
@@ -1903,17 +1944,17 @@ pub enum Response {
/// The pin registry. /// The pin registry.
pins: Vec<Pin>, pins: Vec<Pin>,
/// False when the daemon is serving the cached tree because the API is /// False when the daemon is serving the cached tree because the API is
/// unreachable (offline.md Phase 1). Cached and pinned content still /// unreachable. Cached and pinned content still
/// reads; anything else fails until the network is back. /// reads; anything else fails until the network is back.
#[serde(default = "default_online")] #[serde(default = "default_online")]
online: bool, online: bool,
/// Writes accepted locally but not yet uploaded (offline.md Phase 3). /// Writes accepted locally but not yet uploaded.
/// Non-zero means the mount is ahead of the remote — either a copy is /// Non-zero means the mount is ahead of the remote — either a copy is
/// still draining, or it cannot drain because we are offline. /// still draining, or it cannot drain because we are offline.
#[serde(default)] #[serde(default)]
pending_uploads: u64, pending_uploads: u64,
/// Queued mutations that carry no bytes: `mkdir`, `rename`, `trash` /// Queued mutations that carry no bytes: `mkdir`, `rename`, `trash`.
/// (offline.md Phase 3b). Counted apart from `pending_uploads` because /// Counted apart from `pending_uploads` because
/// calling a queued `mkdir` an upload is a lie. /// calling a queued `mkdir` an upload is a lie.
#[serde(default)] #[serde(default)]
pending_changes: u64, pending_changes: u64,
@@ -1946,6 +1987,21 @@ pub enum Response {
/// resumed, or not paused. /// resumed, or not paused.
#[serde(default)] #[serde(default)]
paused_until: Option<i64>, paused_until: Option<i64>,
/// True when Proton refused the session itself (refresh token refused,
/// account deleted or disabled). `online` is false then too, but no
/// connection brings it back: only signing in again does.
#[serde(default)]
session_expired: bool,
/// True while the keyring refuses the session's refreshed tokens. The
/// daemon keeps retrying; until it succeeds, a reboot signs the user
/// out.
#[serde(default)]
tokens_unsaved: bool,
/// The release of the daemon that answered. A package upgrade does not
/// restart the user service, so it can be older than the front-end
/// asking; daemons before 3.3.0 omit it, which reads as empty.
#[serde(default)]
version: String,
}, },
/// A human-readable success message. /// A human-readable success message.
Ok { message: String }, Ok { message: String },
@@ -2589,6 +2645,27 @@ mod tests {
); );
} }
/// #33: a daemon left running over an upgrade is named as older, also when
/// it predates the version field, but a newer one is not, since restarting
/// the service cannot bring it closer to this build.
#[test]
fn only_an_older_daemon_is_outdated() {
assert!(!daemon_outdated(env!("CARGO_PKG_VERSION")));
assert!(daemon_outdated(""));
assert!(daemon_outdated("0.9.9"));
assert!(!daemon_outdated("999.0.0"));
assert!(!daemon_outdated("not-a-version"));
let old: Response = serde_json::from_str(
r#"{"Status":{"username":"u","mountpoint":"/m","pinned":0,
"used":0,"budget":0,"pins":[]}}"#,
)
.unwrap();
let Response::Status { version, .. } = old else {
panic!("not a status: {old:?}");
};
assert!(daemon_outdated(&version));
}
/// The mutation requests must survive a line-delimited JSON round-trip, since /// The mutation requests must survive a line-delimited JSON round-trip, since
/// that is exactly how they cross the control socket. /// that is exactly how they cross the control socket.
#[test] #[test]
@@ -3082,6 +3159,9 @@ mod tests {
uid: Some("dev-1".into()), uid: Some("dev-1".into()),
}, },
Request::AdoptDevice { uid: None }, Request::AdoptDevice { uid: None },
Request::MigrateDevice {
uid: "dev-2".into(),
},
Request::ListRestorableFolders, Request::ListRestorableFolders,
Request::RestoreSyncFolders { Request::RestoreSyncFolders {
items: vec![RestoreItem { items: vec![RestoreItem {
+1 -1
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: //! 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 //! a crash mid-write, a full disk, a cache that has grown out of proportion to
+7 -7
View File
@@ -577,7 +577,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 /// **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 /// 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, /// new one each run. `sync_folder` is one row per local folder the user added,
@@ -638,8 +638,8 @@ ALTER TABLE sync_folder ADD COLUMN pending_mode TEXT;
"; ";
/// Schema v11: writes no longer upload inside the FUSE handler. `release` stages /// 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 /// 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 row outlives the process, so a write survives both a
/// dead network and a restart. /// dead network and a restart.
/// ///
/// `blob_path` points into the content cache's `staging/` dir and `meta_json` is /// `blob_path` points into the content cache's `staging/` dir and `meta_json` is
@@ -666,7 +666,7 @@ CREATE INDEX pending_op_uid ON pending_op(uid);
/// Schema v12: `pending_op` also carries mutations that *create* a node, which a /// 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 /// 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 /// 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 /// locally under a `local~<uuid>` placeholder and the op records where it goes
@@ -1056,7 +1056,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 /// 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 /// 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 /// *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 /// Nullable rather than a converted `local_mtime * 1_000_000_000`: the
/// nanosecond part of an existing baseline is genuinely unknown, and inventing /// nanosecond part of an existing baseline is genuinely unknown, and inventing
@@ -1168,8 +1168,8 @@ const MIGRATION_V35: &str = "
ALTER TABLE trash ADD COLUMN trashed_at INTEGER; ALTER TABLE trash ADD COLUMN trashed_at INTEGER;
"; ";
/// Schema v36: every node row has a local id (`lid`), phase 2 of /// Schema v36: every node row has a local id (`lid`); see
/// docs/MILESTONE-3.0.0.md. /// docs/ARCHITECTURE.md §2.
/// ///
/// The `uid` was the row's key, so a node had no identity until Drive minted /// 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 /// one, and a create that landed moved its row to a new key. The `lid` is given
+1 -2
View File
@@ -568,8 +568,7 @@ impl Db {
} }
/// Load one persisted node back by uid. Used to recover the My Files root /// 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 /// when the API is unreachable, so the mount can still serve the cached tree.
/// (offline.md Phase 1).
pub fn node_by_uid(&self, uid: &str) -> Result<Option<Node>> { pub fn node_by_uid(&self, uid: &str) -> Result<Option<Node>> {
let conn = self.read(); let conn = self.read();
let json: Option<String> = conn let json: Option<String> = conn
+32 -22
View File
@@ -15,7 +15,7 @@ use crate::Result;
pub const OP_REVISION: &str = "revision"; pub const OP_REVISION: &str = "revision";
/// The `kind` of a [`PendingOp`] that creates a file that so far exists only /// The `kind` of a [`PendingOp`] that creates a file that so far exists only
/// locally, under a `local:` placeholder uid (offline.md Phase 3b). /// locally, under a `local:` placeholder uid.
/// ///
/// The written bytes ride along on the same row rather than as a follow-on /// The written bytes ride along on the same row rather than as a follow-on
/// [`OP_REVISION`]: draining the create mints the node's real uid, which would /// [`OP_REVISION`]: draining the create mints the node's real uid, which would
@@ -29,7 +29,7 @@ pub const OP_CREATE: &str = "create";
pub const OP_MKDIR: &str = "mkdir"; pub const OP_MKDIR: &str = "mkdir";
/// The `kind` of a [`PendingOp`] that gives a node a new name, a new parent, or /// The `kind` of a [`PendingOp`] that gives a node a new name, a new parent, or
/// both — the queued form of `mv` (offline.md Phase 3b). /// both — the queued form of `mv`.
/// ///
/// `parent_uid` and `name` hold the node's *desired end state*, not a delta, so /// `parent_uid` and `name` hold the node's *desired end state*, not a delta, so
/// a second rename simply replaces the row (see [`Db::enqueue_op`]) and the /// a second rename simply replaces the row (see [`Db::enqueue_op`]) and the
@@ -55,8 +55,7 @@ pub struct RenameMeta {
pub original_name: Option<String>, pub original_name: Option<String>,
} }
/// The `kind` of a [`PendingOp`] that trashes a node the server knows about /// The `kind` of a [`PendingOp`] that trashes a node the server knows about.
/// (offline.md Phase 3b).
/// ///
/// A node that only ever existed locally never gets one of these: there is /// A node that only ever existed locally never gets one of these: there is
/// nothing to trash remotely, so deleting it just drops its queued ops /// nothing to trash remotely, so deleting it just drops its queued ops
@@ -253,6 +252,25 @@ const UID_NOW: &str = "COALESCE((SELECT uid FROM nodes WHERE lid = pending_op.li
const PARENT_NOW: &str = const PARENT_NOW: &str =
"COALESCE((SELECT uid FROM nodes WHERE lid = pending_op.parent_lid), parent_uid)"; "COALESCE((SELECT uid FROM nodes WHERE lid = pending_op.parent_lid), parent_uid)";
/// The `WHERE` terms for a row a drain worker may claim once it falls due: not
/// claimed already, no other claimed op on the same node, and not under a
/// folder that has yet to land.
///
/// One fragment for [`Db::claim_next_due_op`] and [`Db::earliest_due_at`]. A
/// row the claim skips but the wait counts is due "now" for as long as it is
/// held, and every idle worker spun on it (`docs/BUGS.md` B200).
fn claimable() -> String {
format!(
"claimed_at = 0
AND (parent_uid IS NULL OR substr({PARENT_NOW}, 1, {n}) <> '{v}~')
AND uid NOT IN (SELECT uid FROM pending_op WHERE claimed_at <> 0)
AND (lid IS NULL OR lid NOT IN (
SELECT lid FROM pending_op WHERE claimed_at <> 0 AND lid IS NOT NULL))",
v = LOCAL_VOLUME,
n = LOCAL_VOLUME.len() + 1,
)
}
/// The volume id given to a node that exists only on this machine, so far. A /// The volume id given to a node that exists only on this machine, so far. A
/// real [`NodeUid`] is `{volume}~{link}`, so a placeholder is `local~<lid>` and /// real [`NodeUid`] is `{volume}~{link}`, so a placeholder is `local~<lid>` and
/// round-trips through the same `Display`/parse path as any other uid. /// round-trips through the same `Display`/parse path as any other uid.
@@ -311,7 +329,7 @@ pub const PARK_UNTIL: i64 = 8_000_000_000_000;
pub const PARK_EXPIRY_MS: i64 = 60 * 60 * 1000; pub const PARK_EXPIRY_MS: i64 = 60 * 60 * 1000;
/// A mutation that has been accepted locally but not yet performed against the /// A mutation that has been accepted locally but not yet performed against the
/// API — the durable half of the write-back queue (offline.md Phase 3). /// API — the durable half of the write-back queue.
/// ///
/// The daemon answers the FUSE call the moment this row and its staged blob are /// The daemon answers the FUSE call the moment this row and its staged blob are
/// on disk, so a `cp` into the mount runs at disk speed and the upload happens /// on disk, so a `cp` into the mount runs at disk speed and the upload happens
@@ -887,7 +905,7 @@ impl Db {
/// make — the node is still only a queued intent, and rewriting that intent /// make — the node is still only a queued intent, and rewriting that intent
/// *is* the rename. Returns false when the create has already drained, in /// *is* the rename. Returns false when the create has already drained, in
/// which case the node has a real uid and the caller must rename it there /// which case the node has a real uid and the caller must rename it there
/// instead (offline.md Phase 3b). /// instead.
/// ///
/// A create backing off is made due: what it failed on may have been the /// A create backing off is made due: what it failed on may have been the
/// name it no longer wants, such as one held by a queued change. A parked /// name it no longer wants, such as one held by a queued change. A parked
@@ -1128,15 +1146,9 @@ impl Db {
"SELECT id, kind, {UID_NOW}, {PARENT_NOW}, name, blob_path, meta_json, created_at, "SELECT id, kind, {UID_NOW}, {PARENT_NOW}, name, blob_path, meta_json, created_at,
attempts, last_error, next_attempt_at attempts, last_error, next_attempt_at
FROM pending_op FROM pending_op
WHERE next_attempt_at <= ?1 WHERE next_attempt_at <= ?1 AND {claimable}
AND claimed_at = 0
AND (parent_uid IS NULL OR substr({PARENT_NOW}, 1, {n}) <> '{v}~')
AND uid NOT IN (SELECT uid FROM pending_op WHERE claimed_at <> 0)
AND (lid IS NULL OR lid NOT IN (
SELECT lid FROM pending_op WHERE claimed_at <> 0 AND lid IS NOT NULL))
ORDER BY id LIMIT 1", ORDER BY id LIMIT 1",
v = LOCAL_VOLUME, claimable = claimable(),
n = LOCAL_VOLUME.len() + 1,
))?; ))?;
stmt.query_row(params![now], |r| { stmt.query_row(params![now], |r| {
Ok(PendingOp { Ok(PendingOp {
@@ -1209,19 +1221,17 @@ impl Db {
/// debounced or backed-off op becomes eligible rather than waiting the full /// debounced or backed-off op becomes eligible rather than waiting the full
/// idle-poll interval. /// idle-poll interval.
/// ///
/// Claimed ops are excluded: another worker is already on them, so they are /// Only rows [`claim_next_due_op`](Self::claim_next_due_op) could take
/// not work this one is waiting for, and counting them would have an idle /// count. A claimed op, or one held behind a claimed op on the same node,
/// worker spin on a row it cannot have. /// is not work this worker is waiting for, and counting it would have an
/// idle worker spin on a row it cannot have.
pub fn earliest_due_at(&self) -> Result<Option<i64>> { pub fn earliest_due_at(&self) -> Result<Option<i64>> {
let conn = self.read(); let conn = self.read();
let ts: Option<i64> = conn let ts: Option<i64> = conn
.query_row( .query_row(
&format!( &format!(
"SELECT MIN(next_attempt_at) FROM pending_op \ "SELECT MIN(next_attempt_at) FROM pending_op WHERE {claimable}",
WHERE claimed_at = 0 \ claimable = claimable(),
AND (parent_uid IS NULL OR substr({PARENT_NOW}, 1, {n}) <> '{v}~')",
v = LOCAL_VOLUME,
n = LOCAL_VOLUME.len() + 1,
), ),
[], [],
|r| r.get(0), |r| r.get(0),
+15 -4
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 /// 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 /// last successful sync, so the next reconcile can tell which side changed.
/// (devices.md Phase 2). `remote_rev`/`remote_hash` hold the remote signature — /// `remote_rev`/`remote_hash` hold the remote signature —
/// its modification time and size as strings — since no cheap content hash is /// its modification time and size as strings — since no cheap content hash is
/// exposed; change detection is `(mtime, size)` on each side. /// exposed; change detection is `(mtime, size)` on each side.
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
@@ -39,7 +39,7 @@ pub struct StoredSyncEntry {
/// ///
/// `local_mtime` alone cannot tell an edit that landed inside the same /// `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 /// 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 /// written before schema 28, which the comparison reads as "compare whole
/// seconds", so upgrading does not make an entire mirror look changed. /// seconds", so upgrading does not make an entire mirror look changed.
pub local_mtime_ns: Option<i64>, pub local_mtime_ns: Option<i64>,
@@ -139,6 +139,17 @@ impl Db {
Ok(()) 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 /// Queue (or, with `None`, withdraw) a mode the folder should move to once it
/// is safe to switch. /// is safe to switch.
pub fn sync_folder_set_pending_mode(&self, id: i64, mode: Option<&str>) -> Result<()> { pub fn sync_folder_set_pending_mode(&self, id: i64, mode: Option<&str>) -> Result<()> {
@@ -345,7 +356,7 @@ impl Db {
/// Drop the entire baseline for a folder. Used when flipping ondemand→mirror: /// 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 /// 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 /// 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<()> { pub fn sync_entries_clear(&self, folder_id: i64) -> Result<()> {
let conn = self.conn.lock(); let conn = self.conn.lock();
conn.execute( conn.execute(
+64 -5
View File
@@ -675,8 +675,8 @@ fn foreign_folder(volume: &str, link: &str, parent: NodeUid, name: &str) -> Node
node node
} }
/// Recovering the root by uid is what lets the daemon mount offline /// Recovering the root by uid is what lets the daemon mount offline:
/// (offline.md Phase 1): the uid is remembered in `sync_state`, the node /// the uid is remembered in `sync_state`, the node
/// itself comes back out of `nodes`. /// itself comes back out of `nodes`.
#[test] #[test]
fn node_by_uid_recovers_a_stored_node() { fn node_by_uid_recovers_a_stored_node() {
@@ -796,7 +796,7 @@ fn children_if_listed_gated_on_flag() {
assert_eq!(kids.len(), 2); assert_eq!(kids.len(), 2);
} }
/// The shape of the `mv`-loses-the-file bug (bugs.md B1): a rename deletes the /// The shape of the `mv`-loses-the-file bug (`docs/BUGS.md` B1): a rename deletes the
/// moved node's row, so a destination left marked `listed` is rebuilt from the DB /// moved node's row, so a destination left marked `listed` is rebuilt from the DB
/// without it — the file is gone from the source and absent from the destination, /// without it — the file is gone from the source and absent from the destination,
/// with `rename(2)` having reported success. Clearing the flag is what forces the /// with `rename(2)` having reported success. Clearing the flag is what forces the
@@ -1689,9 +1689,16 @@ fn writing_nodes_does_not_scale_with_the_size_of_the_index() {
.collect(); .collect();
db.upsert_nodes(&batch).unwrap(); db.upsert_nodes(&batch).unwrap();
// The fastest of a few runs: the rest of the suite runs alongside, and
// one run that lost the CPU to it read as the regression.
(0..5)
.map(|_| {
let t = Instant::now(); let t = Instant::now();
db.upsert_nodes(&batch).unwrap(); db.upsert_nodes(&batch).unwrap();
t.elapsed() t.elapsed()
})
.min()
.unwrap()
} }
let small = cost_against_index_of(200); let small = cost_against_index_of(200);
@@ -4940,6 +4947,28 @@ fn pending_mode_is_queued_until_the_mode_is_reached() {
assert_eq!(folder.pending_mode, None); assert_eq!(folder.pending_mode, None);
} }
#[test]
fn moved_sync_folder_points_at_its_new_share() {
let db = Db::open_in_memory().unwrap();
let id = db
.sync_folder_add("/tmp/pdfs-moved-share", "vol~folder", "windows-share")
.unwrap();
let other = db
.sync_folder_add("/tmp/pdfs-unmoved-share", "vol~other", "windows-share")
.unwrap();
db.sync_folder_set_share(id, "linux-share").unwrap();
let folder = db.sync_folder_get(id).unwrap().unwrap();
assert_eq!(folder.remote_share_id, "linux-share");
// A move keeps the uid, so nothing but the share changes.
assert_eq!(folder.remote_uid, "vol~folder");
assert_eq!(
db.sync_folder_get(other).unwrap().unwrap().remote_share_id,
"windows-share"
);
}
#[test] #[test]
fn mode_commit_clears_only_the_intent_it_satisfies() { fn mode_commit_clears_only_the_intent_it_satisfies() {
let db = Db::open_in_memory().unwrap(); let db = Db::open_in_memory().unwrap();
@@ -5735,7 +5764,7 @@ fn cache_eviction_candidates_are_lru_ordered_and_limited() {
); );
} }
/// **The B5 checkpoint.** `improvements.md` P2.3 proposes concurrent SQLite /// **The B5 checkpoint.** A performance plan proposed concurrent SQLite
/// reads, on the premise that the single `Mutex<Connection>` serializes the FUSE /// reads, on the premise that the single `Mutex<Connection>` serializes the FUSE
/// workers. This measures what the connection is actually asked to do now that /// workers. This measures what the connection is actually asked to do now that
/// B3 and B4 have landed, so the proposal is decided on evidence. /// B3 and B4 have landed, so the proposal is decided on evidence.
@@ -6291,7 +6320,7 @@ fn migration_v27_adds_the_access_deferral_column_to_a_v26_queue() {
/// back with no nanosecond time. That `None` is the whole point: it makes the /// back with no nanosecond time. That `None` is the whole point: it makes the
/// comparison fall back to whole seconds for rows written before the column /// comparison fall back to whole seconds for rows written before the column
/// existed, instead of asserting a sub-second time of zero and re-uploading /// existed, instead of asserting a sub-second time of zero and re-uploading
/// every already-synced file in the folder (bugs.md B25). /// every already-synced file in the folder (`docs/BUGS.md` B25).
#[test] #[test]
fn migration_v28_adds_sub_second_local_times_without_disturbing_a_v27_baseline() { fn migration_v28_adds_sub_second_local_times_without_disturbing_a_v27_baseline() {
let path = std::env::temp_dir().join(format!( let path = std::env::temp_dir().join(format!(
@@ -6525,6 +6554,36 @@ fn an_idle_worker_does_not_wait_on_an_op_another_worker_has() {
); );
} }
/// B200: a rename queued behind a long upload of the same node is due, but no
/// worker may claim it until the upload retires. Counted as due, it had every
/// idle worker wake at once, find nothing, and go round again.
#[test]
fn earliest_due_at_skips_an_op_held_behind_a_claimed_one() {
let db = Db::open_in_memory().unwrap();
let root = uid("root").to_string();
let mut revision = bulk_op(0, &root);
revision.parent_uid = None;
db.enqueue_op(&revision).unwrap();
db.enqueue_op(&PendingOp {
kind: OP_RENAME.to_string(),
parent_uid: Some(root.clone()),
blob_path: None,
meta_json: None,
..bulk_op(0, &root)
})
.unwrap();
db.claim_next_due_op(10)
.unwrap()
.expect("the revision is due");
assert!(db.claim_next_due_op(10).unwrap().is_none());
assert_eq!(
db.earliest_due_at().unwrap(),
None,
"a row no worker can claim is not work an idle worker is waiting for"
);
}
/// Trashing a photo from the gallery must not leave it behind on an album page: /// Trashing a photo from the gallery must not leave it behind on an album page:
/// the row goes, its album membership goes with it, and nothing else moves. /// the row goes, its album membership goes with it, and nothing else moves.
#[test] #[test]
+1 -1
View File
@@ -102,5 +102,5 @@ impl Db {
Ok(seen) Ok(seen)
} }
// ---- device sync (devices.md) ----------------------------------------- // ---- device sync -----------------------------------------
} }
+85 -15
View File
@@ -32,6 +32,16 @@ pub enum Error {
#[error("saved session can no longer unlock your keys — run `pdfs login` again")] #[error("saved session can no longer unlock your keys — run `pdfs login` again")]
ReloginRequired, 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 /// 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 /// 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)). /// with the resulting token (see [`auth::login_verified`](crate::auth::login_verified)).
@@ -144,17 +154,17 @@ impl CoreError {
/// through the boxes the drain deals in, where the concrete type survives /// through the boxes the drain deals in, where the concrete type survives
/// but the static one does not. /// but the static one does not.
/// ///
/// A failure that is not an API response at all — a transport error, a /// A transport error, a timeout or a TLS failure is what being offline
/// timeout, a TLS failure — is what being offline looks like from here, so /// looks like from here, so it classifies as [`ErrorKind::Offline`] rather
/// it classifies as [`ErrorKind::Offline`] rather than /// than [`ErrorKind::Remote`]: the request never reached Proton, and
/// [`ErrorKind::Remote`]: the request never reached Proton, and telling the /// telling the user their connection is down beats telling them the
/// user their connection is down beats telling them the server refused. /// server refused. The SDK's other failures say what went wrong instead
/// (see [`proton_kind`]).
pub fn from_api(e: &(dyn std::error::Error + 'static), what: &str) -> Self { pub fn from_api(e: &(dyn std::error::Error + 'static), what: &str) -> Self {
let kind = match e.downcast_ref::<proton_sdk::ProtonError>() { let kind = match e.downcast_ref::<proton_sdk::ProtonError>() {
Some(proton_sdk::ProtonError::Api(api)) => api_kind(api.code), Some(proton) => proton_kind(proton),
// A `ProtonError` that is not an `Api` variant never reached the // Not the SDK's at all: a timeout around the call, or the socket.
// API; so does anything that is not a `ProtonError` at all. None => ErrorKind::Offline,
_ => ErrorKind::Offline,
}; };
Self::new(kind, format!("{what}: {e}")) Self::new(kind, format!("{what}: {e}"))
} }
@@ -199,18 +209,38 @@ impl From<serde_json::Error> for CoreError {
impl From<Error> for CoreError { impl From<Error> for CoreError {
fn from(e: Error) -> Self { fn from(e: Error) -> Self {
let kind = match &e { let kind = match &e {
Error::NotLoggedIn | Error::ReloginRequired | Error::HumanVerificationRequired(_) => { Error::NotLoggedIn
ErrorKind::Denied | Error::ReloginRequired
} | Error::Locked
Error::Proton(proton_sdk::ProtonError::Api(api)) => api_kind(api.code), | Error::WrongMailboxPassword
// A `ProtonError` that never reached the API is a transport failure. | Error::HumanVerificationRequired(_) => ErrorKind::Denied,
Error::Proton(_) => ErrorKind::Offline, Error::Proton(proton) => proton_kind(proton),
_ => ErrorKind::Internal, _ => ErrorKind::Internal,
}; };
Self::new(kind, e.to_string()) Self::new(kind, e.to_string())
} }
} }
/// 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,
}
}
/// What an API response code means to a front-end. /// What an API response code means to a front-end.
/// ///
/// The grouping is by *what the user can do about it*, which is what /// The grouping is by *what the user can do about it*, which is what
@@ -266,6 +296,23 @@ 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,
}
}
/// The result every request-serving method in the daemon returns. /// The result every request-serving method in the daemon returns.
pub type CoreResult<T> = std::result::Result<T, CoreError>; pub type CoreResult<T> = std::result::Result<T, CoreError>;
@@ -297,6 +344,17 @@ mod tests {
assert_eq!(api_kind(C::ProtonDriveUnknown), ErrorKind::Remote); 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));
}
/// The regression this whole pass exists to prevent: before it, every SDK /// 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 /// failure was hardcoded to `Remote`, so a 403 asked the user to retry a
/// request that could never succeed. /// request that could never succeed.
@@ -327,6 +385,18 @@ mod tests {
assert_eq!(CoreError::from_api(&io, "list").kind, ErrorKind::Offline); 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);
}
/// `Error` is the plumbing type; converting it must classify as sharply as /// `Error` is the plumbing type; converting it must classify as sharply as
/// the direct path, or the two disagree about the same failure. /// the direct path, or the two disagree about the same failure.
#[test] #[test]
+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();
}
}
+1
View File
@@ -10,6 +10,7 @@ pub mod config;
pub mod control; pub mod control;
pub mod db; pub mod db;
pub mod error; pub mod error;
pub mod kernelkey;
pub mod localindex; pub mod localindex;
pub mod menu; pub mod menu;
pub mod mounts; pub mod mounts;
+1 -1
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 //! 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, //! `cache.db` and `config.json` — both on the local disk. When that disk dies,
+1 -1
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 //! A mirror folder otherwise uploads every path beneath its root, which for a
//! project directory means `node_modules/`, `.git/`, and `target/` — quota //! project directory means `node_modules/`, `.git/`, and `target/` — quota
+15 -4
View File
@@ -2,6 +2,7 @@
use super::*; use super::*;
use crate::shutdown::Shutdown; 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 /// 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 /// entry is gone. Shared by the trash and delete paths, which differ only in
@@ -143,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 // create, and re-fetching would replace the size and mtime of the
// write we just accepted with the stale revision's — making a file // write we just accepted with the stale revision's — making a file
// that was copied in seconds ago read as empty until its upload // 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: // 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 // the drain brought the tree level with the revision it sealed, so
@@ -375,7 +376,7 @@ pub(super) async fn run_event_sync(
// server head; persist it so the next restart resumes instead of // server head; persist it so the next restart resumes instead of
// reseeding (which would skip everything that changed offline). // reseeding (which would skip everything that changed offline).
// Seeding needs the network, and this task also runs on mounts that // 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. // which used to disable live sync for the life of the daemon.
Ok(None) => { Ok(None) => {
// Nothing says what changed before this cursor, so a persisted SDK // Nothing says what changed before this cursor, so a persisted SDK
@@ -402,6 +403,12 @@ pub(super) async fn run_event_sync(
} }
break Some(c.clone()); 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) => { Err(e) => {
warn!(error = %e, ?delay, "seed event cursor failed; retrying"); warn!(error = %e, ?delay, "seed event cursor failed; retrying");
tokio::time::sleep(delay).await; tokio::time::sleep(delay).await;
@@ -502,6 +509,10 @@ pub(super) async fn run_photos_event_sync(core: Core) {
debug!("no photos volume; photo event sync not started"); debug!("no photos volume; photo event sync not started");
return; return;
} }
Err(error) if session_revoked(&error) => {
core.mark_session_expired("read the photos root", &error);
return;
}
Err(error) => { Err(error) => {
warn!(%error, "reading the photos root failed; retrying"); warn!(%error, "reading the photos root failed; retrying");
tokio::time::sleep(ONLINE_PROBE_MAX).await; tokio::time::sleep(ONLINE_PROBE_MAX).await;
@@ -660,7 +671,7 @@ pub(super) fn run_local_index(
scan_local_once(&db, &indexing, &transfers, &mountpoint); scan_local_once(&db, &indexing, &transfers, &mountpoint);
} }
// Interruptible, so teardown joins this thread instead of leaving it to // 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) { if !shutdown.sleep(LOCAL_INDEX_CHECK) {
return; return;
} }
@@ -710,7 +721,7 @@ fn scan_local_once(
// The walk has no idea how many files it will find, so the job counts what it // The walk has no idea how many files it will find, so the job counts what it
// has seen and stays indeterminate. // 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"); job.detail("Scanning your files");
let walked = localindex::scan(&[home], &excludes, |batch| { let walked = localindex::scan(&[home], &excludes, |batch| {
if let Err(e) = db.local_upsert_batch(generation, &batch) { if let Err(e) = db.local_upsert_batch(generation, &batch) {
+12 -2
View File
@@ -334,6 +334,9 @@ pub(crate) fn status_response(core: &Core, username: &str, mountpoint: &Path) ->
budget: core.cache.budget(), budget: core.cache.budget(),
pins, pins,
online: core.online.load(Ordering::Relaxed), online: core.online.load(Ordering::Relaxed),
session_expired: core.session_expired(),
tokens_unsaved: pdfs_core::auth::tokens_unsaved(),
version: env!("CARGO_PKG_VERSION").to_string(),
pending_uploads: queued.uploads.max(0) as u64 + landing, pending_uploads: queued.uploads.max(0) as u64 + landing,
pending_changes: queued.changes.max(0) as u64, pending_changes: queued.changes.max(0) as u64,
} }
@@ -369,7 +372,7 @@ fn touched_topics(request: &CtlRequest) -> &'static [Topic] {
RefreshScope::Photos { .. } => &[], RefreshScope::Photos { .. } => &[],
}, },
R::RenameDevice { .. } | R::DeleteDevice { .. } => &[Topic::Devices], R::RenameDevice { .. } | R::DeleteDevice { .. } => &[Topic::Devices],
R::AdoptDevice { .. } => &[Topic::Devices, Topic::Locations], R::AdoptDevice { .. } | R::MigrateDevice { .. } => &[Topic::Devices, Topic::Locations],
R::AddSyncFolder { .. } R::AddSyncFolder { .. }
| R::RemoveSyncFolder { .. } | R::RemoveSyncFolder { .. }
| R::SetSyncFolderMode { .. } | R::SetSyncFolderMode { .. }
@@ -938,6 +941,9 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
} }
} }
Err(e) => { Err(e) => {
// The reply goes to the app's toast only; without
// this the journal shows a failure and no reason.
warn!(error = %e, "photo upload failed");
CtlResponse::error(CoreError::from_api(&e, "upload photo failed")) CtlResponse::error(CoreError::from_api(&e, "upload photo failed"))
} }
} }
@@ -1297,6 +1303,10 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
Ok(message) => CtlResponse::Ok { message }, Ok(message) => CtlResponse::Ok { message },
Err(e) => CtlResponse::error(e), Err(e) => CtlResponse::error(e),
}, },
Ok(CtlRequest::MigrateDevice { uid }) => match core.migrate_device(&uid) {
Ok(message) => CtlResponse::Ok { message },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::AddSyncFolder { local_path }) => { Ok(CtlRequest::AddSyncFolder { local_path }) => {
// Registering the device and uploading a folder tree far outlasts the // Registering the device and uploading a folder tree far outlasts the
// socket read timeout, so ack immediately and work on a background // socket read timeout, so ack immediately and work on a background
@@ -1816,7 +1826,7 @@ pub(crate) fn run_control_socket(
info!("control socket listening"); info!("control socket listening");
for conn in listener.incoming() { for conn in listener.incoming() {
// Checked on every accept, including the deliberate poke teardown sends // Checked on every accept, including the deliberate poke teardown sends
// to wake this thread out of a blocking accept (bugs.md B44). // to wake this thread out of a blocking accept (`docs/BUGS.md` B44).
if core.shutdown.is_stopping() { if core.shutdown.is_stopping() {
info!("control socket closing"); info!("control socket closing");
return; return;
+192 -23
View File
@@ -1,5 +1,5 @@
//! This machine as a Proton Drive Device, and the local folders it keeps in //! 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 //! 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 //! 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::db::{StoredDevice, StoredSyncFolder};
use pdfs_core::mounts::MountMode; use pdfs_core::mounts::MountMode;
use pdfs_core::profile::PROFILE_DIR_NAME;
use pdfs_core::{CoreError, CoreResult}; use pdfs_core::{CoreError, CoreResult};
use proton_drive_rs::proton_sdk::ids::{DeviceUid, NodeUid}; use proton_drive_rs::proton_sdk::ids::{DeviceUid, NodeUid};
use proton_drive_rs::{DeviceType, Node}; use proton_drive_rs::{DeviceType, Node};
@@ -114,6 +115,22 @@ pub(crate) fn set_adopted_device_uid(uid: Option<&str>) -> CoreResult<()> {
.map_err(|e| CoreError::internal(format!("write config: {e}"))) .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 { impl Core {
// ---- devices ---------------------------------------------------------- // ---- devices ----------------------------------------------------------
@@ -140,7 +157,7 @@ impl Core {
pub(crate) fn list_devices(&self) -> CoreResult<Vec<DeviceInfo>> { pub(crate) fn list_devices(&self) -> CoreResult<Vec<DeviceInfo>> {
let devices = self let devices = self
.rt .rt
.block_on(self.client.enumerate_devices()) .block_on(self.drive.enumerate_devices())
.map_err(|e| CoreError::from_api(&e, "list devices"))?; .map_err(|e| CoreError::from_api(&e, "list devices"))?;
// No cached device row yet means this machine syncs nothing, so none of // No cached device row yet means this machine syncs nothing, so none of
// the listed devices is ours. // the listed devices is ours.
@@ -184,8 +201,8 @@ impl Core {
} }
/// Adopt an existing device as *this* machine's, pinning it in `config.json` /// Adopt an existing device as *this* machine's, pinning it in `config.json`
/// so hostname changes and reinstalls stop registering duplicates /// so hostname changes and reinstalls stop registering duplicates.
/// (features.md 5.1). `None` clears the pin and returns to hostname matching. /// `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 /// 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 /// that it is authoritative, so accepting a bad one would defer the failure
@@ -197,7 +214,7 @@ impl Core {
}; };
let remote = self let remote = self
.rt .rt
.block_on(self.client.enumerate_devices()) .block_on(self.drive.enumerate_devices())
.map_err(|e| CoreError::from_api(&e, "enumerate devices"))?; .map_err(|e| CoreError::from_api(&e, "enumerate devices"))?;
let dev = remote let dev = remote
.into_iter() .into_iter()
@@ -240,7 +257,7 @@ impl Core {
}; };
let remote = self let remote = self
.rt .rt
.block_on(self.client.enumerate_devices()) .block_on(self.drive.enumerate_devices())
.map_err(|e| CoreError::from_api(&e, "enumerate devices"))?; .map_err(|e| CoreError::from_api(&e, "enumerate devices"))?;
let d = remote let d = remote
.into_iter() .into_iter()
@@ -260,7 +277,108 @@ impl Core {
}) })
} }
// ---- device folder sync (devices.md, Phase 1) ------------------------- /// 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 /// Auto-register (or recover) this machine as a Proton Drive Device, caching
/// it so restarts reuse the same device. Recovery matches an existing remote /// it so restarts reuse the same device. Recovery matches an existing remote
@@ -270,15 +388,14 @@ impl Core {
/// Resolution order: the *adopted* uid pinned in `config.json`, then the /// Resolution order: the *adopted* uid pinned in `config.json`, then the
/// cached DB row (validated remotely), then a hostname match, then create. /// cached DB row (validated remotely), then a hostname match, then create.
/// The pin exists because the hostname heuristic silently registers a second /// The pin exists because the hostname heuristic silently registers a second
/// device after a rename or reinstall, orphaning the first one's folders /// device after a rename or reinstall, orphaning the first one's folders.
/// (features.md 5.1).
pub(crate) fn ensure_device(&self) -> CoreResult<StoredDevice> { pub(crate) fn ensure_device(&self) -> CoreResult<StoredDevice> {
let name = this_hostname(); let name = this_hostname();
// Enumerate the remote devices once: used to validate the pin, validate // Enumerate the remote devices once: used to validate the pin, validate
// any cached record, and recover an existing device by name. // any cached record, and recover an existing device by name.
let remote = self let remote = self
.rt .rt
.block_on(self.client.enumerate_devices()) .block_on(self.drive.enumerate_devices())
.map_err(|e| CoreError::from_api(&e, "enumerate devices"))?; .map_err(|e| CoreError::from_api(&e, "enumerate devices"))?;
// An adopted uid is an explicit instruction, so it outranks everything // An adopted uid is an explicit instruction, so it outranks everything
@@ -337,7 +454,7 @@ impl Core {
None => { None => {
let d = self let d = self
.rt .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"))?; .map_err(|e| CoreError::from_api(&e, "create device"))?;
StoredDevice { StoredDevice {
uid: d.uid.to_string(), uid: d.uid.to_string(),
@@ -361,21 +478,25 @@ impl Core {
root_uid: &NodeUid, root_uid: &NodeUid,
name: &str, name: &str,
) -> CoreResult<Option<NodeUid>> { ) -> CoreResult<Option<NodeUid>> {
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 let uids = self
.rt .rt
.block_on(self.drive.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"))?; .map_err(|e| CoreError::from_api(&e, "list device root"))?;
if uids.is_empty() { if uids.is_empty() {
return Ok(None); return Ok(Vec::new());
} }
let nodes = self self.rt
.rt
.block_on(self.drive.enumerate_nodes(&uids)) .block_on(self.drive.enumerate_nodes(&uids))
.map_err(|e| CoreError::from_api(&e, "resolve device root children"))?; .map_err(|e| CoreError::from_api(&e, "resolve device root children"))
Ok(nodes
.into_iter()
.find(|n| n.is_folder() && !n.trashed && n.name == name)
.map(|n| n.uid))
} }
/// Add a local folder to this device's sync set: register the device if /// Add a local folder to this device's sync set: register the device if
@@ -536,12 +657,16 @@ impl Core {
} else { } else {
p.total.max(p.done) as u64 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 { SyncPhase::Applying => JobItem {
title: format!("Syncing {folder}"), title: format!("Syncing {folder}"),
detail: p.current.clone(), detail: p.current.clone(),
done: p.done as u64, done: p.done as u64,
total: p.total.max(p.done) as u64, total: p.total.max(p.done) as u64,
background: false,
}, },
}); });
} }
@@ -617,7 +742,7 @@ impl Core {
} }
/// A sibling Core for a secondary FUSE session rooted at an `ondemand` sync /// A sibling Core for a secondary FUSE session rooted at an `ondemand` sync
/// folder (devices.md Phase 3). It shares everything with this one, /// folder. It shares everything with this one,
/// the tree included, except what belongs to a session: its root and its /// the tree included, except what belongs to a session: its root and its
/// kernel channel, both filled in when the session is built. /// kernel channel, both filled in when the session is built.
pub(crate) fn fork_state(&self) -> Core { pub(crate) fn fork_state(&self) -> Core {
@@ -843,6 +968,23 @@ impl Core {
if folder.state != "idle" { if folder.state != "idle" {
return Err(SwitchBlocked::NotNow); 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:?}")))?;
if let Some(reason) =
sync::mirror_tree_unsynced(&local, &baseline, &self.ignore_rules(&local))
{
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(|| { let root_uid = parse_uid(&folder.remote_uid).ok_or_else(|| {
SwitchBlocked::Failed(format!("bad remote uid: {}", folder.remote_uid)) SwitchBlocked::Failed(format!("bad remote uid: {}", folder.remote_uid))
})?; })?;
@@ -986,8 +1128,7 @@ impl Core {
/// restart (their local dirs are empty on disk — the files live in the cloud). /// 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 /// 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 /// 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 /// own thread from `mount` so the network fetches never block startup.
/// (devices.md Phase 4).
pub(crate) fn restore_ondemand_mounts(&self) { pub(crate) fn restore_ondemand_mounts(&self) {
let folders = match self.db.sync_folder_list() { let folders = match self.db.sync_folder_list() {
Ok(f) => f, Ok(f) => f,
@@ -1109,10 +1250,38 @@ mod tests {
use pdfs_core::db::StoredSyncFolder; use pdfs_core::db::StoredSyncFolder;
use super::{ use super::{
ApplicableMountMode, commit_after_teardown, pending_intent_matches, ApplicableMountMode, commit_after_teardown, migrated_name, pending_intent_matches,
restore_snapshot_matches, select_mode_transition, 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] #[test]
fn unknown_mode_cannot_select_a_destructive_transition() { fn unknown_mode_cannot_select_a_destructive_transition() {
let mut unmounts = 0; let mut unmounts = 0;
+53 -26
View File
@@ -1,7 +1,7 @@
//! The write-back drain: turning queued mutations into remote calls. //! The write-back drain: turning queued mutations into remote calls.
//! //!
//! Every write the kernel hands us is answered the moment its bytes and its //! Every write the kernel hands us is answered the moment its bytes and its
//! `pending_op` row are on disk (offline.md Phase 3), which is what lets a `cp` //! `pending_op` row are on disk, which is what lets a `cp`
//! into the mount run at disk speed and lets an offline write succeed at all. //! into the mount run at disk speed and lets an offline write succeed at all.
//! This module is the other half: the worker that walks that queue and performs //! This module is the other half: the worker that walks that queue and performs
//! the uploads, creates, renames and trashes it recorded. //! the uploads, creates, renames and trashes it recorded.
@@ -276,8 +276,7 @@ fn run_authorized_drain(
} }
impl Core { impl Core {
/// Drain the pending-op queue: the background half of every write /// Drain the pending-op queue: the background half of every write.
/// (offline.md Phase 3).
/// ///
/// Runs for the life of the mount. Ops are replayed oldest-first, each /// Runs for the life of the mount. Ops are replayed oldest-first, each
/// retried with doubling backoff and *never* dropped on failure — the staged /// retried with doubling backoff and *never* dropped on failure — the staged
@@ -306,7 +305,7 @@ impl Core {
loop { loop {
// Between ops, never inside one: an op that has started is either // Between ops, never inside one: an op that has started is either
// retired or released, so stopping here cannot leave the queue with // retired or released, so stopping here cannot leave the queue with
// a row claimed by a worker that no longer exists (bugs.md B44). // a row claimed by a worker that no longer exists (`docs/BUGS.md` B44).
if self.shutdown.is_stopping() { if self.shutdown.is_stopping() {
debug!(primary, "drain worker stopping"); debug!(primary, "drain worker stopping");
return; return;
@@ -348,17 +347,7 @@ impl Core {
// its writer has to be noticed. // its writer has to be noticed.
self.sweep_parked_creates(); self.sweep_parked_creates();
} }
// Paused, nothing due changes that: sleep until a resume self.wait_for_drain_work_or_due(online && !paused);
// wakes the worker (or the idle poll notices a timed pause
// ran out), not until the next op falls due — that is
// "now" for everything already queued.
if paused {
self.wait_for_drain_work();
continue;
}
// A debounced or backed-off op may be waiting: sleep only
// until it becomes due rather than the full idle-poll.
self.wait_for_drain_work_or_due();
continue; continue;
} }
}; };
@@ -733,14 +722,16 @@ impl Core {
/// Like [`wait_for_drain_work`](Self::wait_for_drain_work), but first /// Like [`wait_for_drain_work`](Self::wait_for_drain_work), but first
/// checks whether a debounced or backed-off op is due before the idle-poll /// checks whether a debounced or backed-off op is due before the idle-poll
/// ceiling. A 2-second debounce must not sleep 30 seconds. /// ceiling. A 2-second debounce must not sleep 30 seconds.
fn wait_for_drain_work_or_due(&self) { ///
let timeout = match self.db.earliest_due_at() { /// `claiming` is false while the worker claims nothing (offline or
Ok(Some(ts)) => { /// paused); see [`idle_wait`].
let remaining = (ts - now_millis()).max(0) as u64; fn wait_for_drain_work_or_due(&self, claiming: bool) {
Duration::from_millis(remaining).min(DRAIN_IDLE_POLL) let earliest = if claiming {
} self.db.earliest_due_at().ok().flatten()
_ => DRAIN_IDLE_POLL, } else {
None
}; };
let timeout = idle_wait(claiming, earliest, now_millis());
sleep_for_drain_work(&self.drain_wake, || self.shutdown.is_stopping(), timeout); sleep_for_drain_work(&self.drain_wake, || self.shutdown.is_stopping(), timeout);
} }
@@ -1150,7 +1141,7 @@ impl Core {
} }
/// Make a node that so far exists only on this machine real, and adopt the /// Make a node that so far exists only on this machine real, and adopt the
/// uid the server gives it (offline.md Phase 3b). /// uid the server gives it.
pub(crate) fn drain_local_node( pub(crate) fn drain_local_node(
&self, &self,
op: &PendingOp, op: &PendingOp,
@@ -1850,7 +1841,7 @@ impl Core {
/// that window the node can be rewritten by another device, trashed, or /// that window the node can be rewritten by another device, trashed, or
/// deleted outright. Sending the blob anyway would silently drop whatever /// deleted outright. Sending the blob anyway would silently drop whatever
/// happened in between, which is exactly the thing the sync engine refuses /// happened in between, which is exactly the thing the sync engine refuses
/// to do (offline.md Phase 3b). /// to do.
/// ///
/// Only checkable against a recorded baseline: a write staged before /// Only checkable against a recorded baseline: a write staged before
/// [`StagedWrite::based_on`] existed, or one against a node that has never /// [`StagedWrite::based_on`] existed, or one against a node that has never
@@ -2009,7 +2000,7 @@ impl Core {
self.db.delete_op(op.id)?; self.db.delete_op(op.id)?;
// Dropping the pending entry hands the node back to the remote's truth: // Dropping the pending entry hands the node back to the remote's truth:
// reads stop coming from the staged blob, and the event sync stops // reads stop coming from the staged blob, and the event sync stops
// skipping it as "ahead of the server" (offline.md Phase 3a). Only if it // skipping it as "ahead of the server". Only if it
// is still *this* write's entry — see [`Core::release_pending`]. // is still *this* write's entry — see [`Core::release_pending`].
self.release_pending(uid, blob); self.release_pending(uid, blob);
self.cache.discard_staged(blob); self.cache.discard_staged(blob);
@@ -2679,7 +2670,9 @@ fn proton_error<'e>(e: &'e (dyn std::error::Error + 'static)) -> Option<&'e Prot
/// is, or `Some(None)` for a failure that is not a refusal. /// is, or `Some(None)` for a failure that is not a refusal.
fn refusal(e: &(dyn std::error::Error + 'static)) -> Option<Option<SyncIssue>> { fn refusal(e: &(dyn std::error::Error + 'static)) -> Option<Option<SyncIssue>> {
let proton = proton_error(e)?; let proton = proton_error(e)?;
if is_network_error(proton) { // A refused session refuses everything, so it says nothing about this
// op either; the mount is offline for it (`Core::lost_link`).
if is_network_error(proton) || pdfs_core::error::session_revoked(proton) {
return None; return None;
} }
let ProtonError::Api(api) = proton else { let ProtonError::Api(api) = proton else {
@@ -2834,6 +2827,23 @@ fn sleep_for_drain_work(
*woken = false; *woken = false;
} }
/// How long an idle drain worker sleeps: until the earliest queued op falls
/// due, at most the idle poll.
///
/// A worker that claims nothing — offline or paused — sleeps the full idle
/// poll whatever is due. Every op already queued is due "now" for it, so
/// sleeping until then was a zero wait, and all the workers spun on the queue
/// for as long as the outage lasted (`docs/BUGS.md` B200). A reconnect or a
/// resume wakes the drain, so nothing waits the full poll for it.
fn idle_wait(claiming: bool, earliest_due: Option<i64>, now: i64) -> Duration {
match earliest_due {
Some(ts) if claiming => {
Duration::from_millis((ts - now).max(0) as u64).min(DRAIN_IDLE_POLL)
}
_ => DRAIN_IDLE_POLL,
}
}
fn placed_in(st: &crate::state::State, uid: &NodeUid) -> Option<(String, Option<NodeUid>)> { fn placed_in(st: &crate::state::State, uid: &NodeUid) -> Option<(String, Option<NodeUid>)> {
let entry = st.by_uid.get(uid).and_then(|ino| st.entries.get(ino))?; let entry = st.by_uid.get(uid).and_then(|ino| st.entries.get(ino))?;
Some((entry.node.name.clone(), entry.node.parent_uid.clone())) Some((entry.node.name.clone(), entry.node.parent_uid.clone()))
@@ -2859,6 +2869,23 @@ mod tests {
assert!(started.elapsed() < Duration::from_secs(1)); assert!(started.elapsed() < Duration::from_secs(1));
} }
#[test]
fn an_offline_worker_waits_the_idle_poll_for_a_past_due_op() {
// The op fell due long ago, and offline nobody attempts it, so it
// stays due: waiting for it was a zero wait, forever (B200).
assert_eq!(idle_wait(false, Some(0), 10_000), DRAIN_IDLE_POLL);
}
#[test]
fn a_claiming_worker_waits_only_until_the_next_op_falls_due() {
assert_eq!(
idle_wait(true, Some(12_000), 10_000),
Duration::from_secs(2)
);
assert_eq!(idle_wait(true, Some(0), 10_000), Duration::ZERO);
assert_eq!(idle_wait(true, None, 10_000), DRAIN_IDLE_POLL);
}
fn pending(kind: &str) -> PendingOp { fn pending(kind: &str) -> PendingOp {
PendingOp { PendingOp {
id: 0, id: 0,
+21 -4
View File
@@ -3,13 +3,15 @@
//! `Core` used to hold a concrete [`ProtonDriveClient`], so no test could make //! `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 //! 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 //! behind most recent bugs could only be found on a real account over a slow
//! link (`docs/MILESTONE-3.0.0.md` §2, §8.1). [`DriveApi`] is the part of the //! link. [`DriveApi`] is the part of the
//! client the mount, the drain, the event feed and the mirror engine use; 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 //! real client implements it by forwarding, and the simulation tests implement
//! it with an in-memory Drive that injects Drive's known faults. //! it with an in-memory Drive that injects Drive's known faults.
//! //!
//! Sharing, Photos, devices, invitations and public links stay on the concrete //! Sharing, Photos, invitations, public links and the rest of the device calls
//! client (`Core::client`) until a test needs them faked. //! 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 //! 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 //! whichever it goes through. The client's generic methods (a reader or writer
@@ -26,7 +28,8 @@ use proton_drive_rs::proton_sdk::account::Quota;
use proton_drive_rs::proton_sdk::error::Result; use proton_drive_rs::proton_sdk::error::Result;
use proton_drive_rs::proton_sdk::ids::{DriveEventId, NodeUid}; use proton_drive_rs::proton_sdk::ids::{DriveEventId, NodeUid};
use proton_drive_rs::{ use proton_drive_rs::{
DriveEvent, DriveEventScopeId, Node, NodeMoveItem, ProtonDriveClient, RevisionReader, Thumbnail, Device, DeviceType, DriveEvent, DriveEventScopeId, Node, NodeMoveItem, ProtonDriveClient,
RevisionReader, Thumbnail,
}; };
/// One outcome per node of a batch request; see [`ProtonDriveClient::trash_nodes`]. /// One outcome per node of a batch request; see [`ProtonDriveClient::trash_nodes`].
@@ -154,6 +157,12 @@ pub(crate) trait DriveApi: Send + Sync {
/// The account's storage, across every Proton product. /// The account's storage, across every Proton product.
async fn quota(&self) -> Result<Quota>; 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 /// 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 /// `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 /// tree has the node, so a simulation can hold it there. Blocks the drain
@@ -470,4 +479,12 @@ impl DriveApi for ProtonDriveClient {
async fn quota(&self) -> Result<Quota> { async fn quota(&self) -> Result<Quota> {
ProtonDriveClient::quota(self).await 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
}
} }
+6 -1
View File
@@ -235,6 +235,8 @@ fn status_keys(status: &CtlResponse) -> (String, String) {
used, used,
budget, budget,
online, online,
session_expired,
tokens_unsaved,
pending_uploads, pending_uploads,
pending_changes, pending_changes,
.. ..
@@ -247,7 +249,7 @@ fn status_keys(status: &CtlResponse) -> (String, String) {
); );
let key = format!( let key = format!(
"{queue}/{staged_bytes}/{paused}/{paused_until:?}/{username}/{mountpoint}/{pinned}/\ "{queue}/{staged_bytes}/{paused}/{paused_until:?}/{username}/{mountpoint}/{pinned}/\
{used}/{budget}/{online}" {used}/{budget}/{online}/{session_expired}/{tokens_unsaved}"
); );
(key, queue) (key, queue)
} }
@@ -325,6 +327,9 @@ mod tests {
budget: 0, budget: 0,
pins: Vec::new(), pins: Vec::new(),
online: true, online: true,
session_expired: false,
tokens_unsaved: false,
version: "3.3.0".into(),
pending_uploads: 1, pending_uploads: 1,
pending_changes: 0, pending_changes: 0,
}; };
+42 -24
View File
@@ -132,7 +132,7 @@ impl Filesystem for ProtonFs {
// A provisional size is the *ciphertext* size, which is larger than the // A provisional size is the *ciphertext* size, which is larger than the
// file. Publishing it makes every reader that trusts `st_size` — rsync, // file. Publishing it makes every reader that trusts `st_size` — rsync,
// mmap, sendfile, a sized read loop — run off the end of the file and // mmap, sendfile, a sized read loop — run off the end of the file and
// fail. So resolve it before answering rather than after (bugs.md B14). // fail. So resolve it before answering rather than after (`docs/BUGS.md` B14).
// //
// The cost is one batched round trip for the whole folder, not one per // The cost is one batched round trip for the whole folder, not one per
// file: `ls -l` is one `getattr` per entry and they collapse onto a // file: `ls -l` is one `getattr` per entry and they collapse onto a
@@ -172,7 +172,7 @@ impl Filesystem for ProtonFs {
/// here — once per `opendir`, which the kernel pairs exactly with one /// here — once per `opendir`, which the kernel pairs exactly with one
/// `releasedir` — is what makes the offsets stable, so a create or a trash /// `releasedir` — is what makes the offsets stable, so a create or a trash
/// during an enumeration can no longer make the caller skip or repeat an /// during an enumeration can no longer make the caller skip or repeat an
/// entry (bugs.md B43). /// entry (`docs/BUGS.md` B43).
/// ///
/// Enumerating a cold folder is a remote call, so it goes to a worker for /// Enumerating a cold folder is a remote call, so it goes to a worker for
/// the same reason `lookup` and `readdir` do. /// the same reason `lookup` and `readdir` do.
@@ -346,7 +346,7 @@ impl Filesystem for ProtonFs {
self.core.workers.run(Lane::Transfer, "read", move || { self.core.workers.run(Lane::Transfer, "read", move || {
// The revision disagrees with a provisional size, and B84 fails // The revision disagrees with a provisional size, and B84 fails
// such a read outright, so settle the size first. On the network, // such a read outright, so settle the size first. On the network,
// hence here and not above (bugs.md B100). // hence here and not above (`docs/BUGS.md` B100).
let fsize = match provisional { let fsize = match provisional {
true => core.resolve_provisional_size(ino.0, &uid).unwrap_or(fsize), true => core.resolve_provisional_size(ino.0, &uid).unwrap_or(fsize),
false => fsize, false => fsize,
@@ -517,7 +517,7 @@ impl Filesystem for ProtonFs {
// `> file`. This is the second write path into the API, and // `> file`. This is the second write path into the API, and
// queueing it is what lets a redirect work offline at all: it // queueing it is what lets a redirect work offline at all: it
// never reaches `release`, so without this it failed before any // never reaches `release`, so without this it failed before any
// byte was written (offline.md Phase 2/3b). // byte was written.
// //
// The one branch of `setattr` that can reach the network: // The one branch of `setattr` that can reach the network:
// shrinking keeps the prefix, which `queue_truncate` gap-fills // shrinking keeps the prefix, which `queue_truncate` gap-fills
@@ -607,7 +607,7 @@ impl Filesystem for ProtonFs {
} }
/// `close(2)` calls flush before release. The upload is queued in `release` /// `close(2)` calls flush before release. The upload is queued in `release`
/// and performed in the background (offline.md Phase 3), so there is nothing /// and performed in the background, so there is nothing
/// to push here — the written bytes are already in the scratch file. /// to push here — the written bytes are already in the scratch file.
fn flush( fn flush(
&self, &self,
@@ -985,7 +985,7 @@ pub struct ProtonFs {
} }
/// How many times a write `open` will re-read its base after a `release` /// How many times a write `open` will re-read its base after a `release`
/// published a newer queued revision underneath it (bugs.md B32). /// published a newer queued revision underneath it (`docs/BUGS.md` B32).
/// ///
/// One retry covers the race, which needs a close and an open of the same file /// One retry covers the race, which needs a close and an open of the same file
/// to overlap on different threads. More than that is not contention, it is a /// to overlap on different threads. More than that is not contention, it is a
@@ -1021,7 +1021,7 @@ fn build_listing(st: &State, ino: u64) -> DirListing {
/// ///
/// The cookie handed back is the index of the *next* entry, so a resumed call /// The cookie handed back is the index of the *next* entry, so a resumed call
/// continues exactly where the last one stopped — which is only true because /// continues exactly where the last one stopped — which is only true because
/// the listing behind those indexes is a snapshot (bugs.md B43). /// the listing behind those indexes is a snapshot (`docs/BUGS.md` B43).
fn reply_listing(listing: &DirListing, offset: u64, root: u64, mut reply: ReplyDirectory) { fn reply_listing(listing: &DirListing, offset: u64, root: u64, mut reply: ReplyDirectory) {
for (i, (ino, is_dir, name)) in listing.iter().enumerate().skip(offset as usize) { for (i, (ino, is_dir, name)) in listing.iter().enumerate().skip(offset as usize) {
let ft = if *is_dir { let ft = if *is_dir {
@@ -1094,7 +1094,7 @@ impl ProtonFs {
// `getattr` would, so `ls -l` — which is one `lookup` // `getattr` would, so `ls -l` — which is one `lookup`
// per entry and no `getattr` at all — takes its sizes // per entry and no `getattr` at all — takes its sizes
// from here. Resolving only in `getattr` left the whole // from here. Resolving only in `getattr` left the whole
// listing provisional (bugs.md B14). // listing provisional (`docs/BUGS.md` B14).
let provisional = matches!( let provisional = matches!(
&e.node.kind, &e.node.kind,
NodeKind::File { NodeKind::File {
@@ -1267,8 +1267,12 @@ impl ProtonFs {
// //
// Complete pending blobs are the common case and can be copied without // Complete pending blobs are the common case and can be copied without
// any network access. An incomplete blob still has gaps referring to // any network access. An incomplete blob still has gaps referring to
// the remote base; stacking another write on it cannot be represented // the remote base, and stacking another write on it cannot be
// safely by WriteHandle, so fail the open rather than risk corruption. // represented by WriteHandle, so the copy's gaps are filled from that
// base first, as the drain fills them before an upload. Only when that
// fails (offline, or the base is gone) does the open fail: refusing
// every such open failed a create over a file whose partial edit had
// not drained yet (`docs/BUGS.md` B204).
// //
// Reading that blob and installing the handle cannot be done under one // Reading that blob and installing the handle cannot be done under one
// lock — the copy is the size of the file — so the base is re-checked at // lock — the copy is the size of the file — so the base is re-checked at
@@ -1276,7 +1280,7 @@ impl ProtonFs {
// revision in between sends this around again. Without the re-check the // revision in between sends this around again. Without the re-check the
// new handle carried the *older* base, and its own release published // new handle carried the *older* base, and its own release published
// content that silently discarded the revision closed a moment earlier // content that silently discarded the revision closed a moment earlier
// (bugs.md B32). A second attempt is already unlikely and a third would // (`docs/BUGS.md` B32). A second attempt is already unlikely and a third would
// mean something is republishing continuously, so give up rather than // mean something is republishing continuously, so give up rather than
// spin: the caller's `open` fails instead of its data being lost. // spin: the caller's `open` fails instead of its data being lost.
// //
@@ -1286,7 +1290,7 @@ impl ProtonFs {
for attempt in 0..OPEN_BASE_ATTEMPTS { for attempt in 0..OPEN_BASE_ATTEMPTS {
// The handle starts at the base's size. A provisional one is the // The handle starts at the base's size. A provisional one is the
// ciphertext size, so the file would grow by the encryption // ciphertext size, so the file would grow by the encryption
// overhead and reading the base would fail (bugs.md B100). A // overhead and reading the base would fail (`docs/BUGS.md` B100). A
// queued revision stamps a real size, so this only goes to the // queued revision stamps a real size, so this only goes to the
// network for a file the listing has not sized yet. // network for a file the listing has not sized yet.
if provisional && self.core.pending_blob(&uid).is_none() { if provisional && self.core.pending_blob(&uid).is_none() {
@@ -1302,10 +1306,6 @@ impl ProtonFs {
} }
} }
let pending_base = self.core.pending.lock().get(&uid).cloned(); let pending_base = self.core.pending.lock().get(&uid).cloned();
if pending_base.as_ref().is_some_and(|p| !p.meta.complete) {
error!(%uid, "refusing write over incomplete queued revision");
break 'install Err(Errno::EIO);
}
let (file, path) = match self.core.cache.create_scratch() { let (file, path) = match self.core.cache.create_scratch() {
Ok(x) => x, Ok(x) => x,
Err(e) => { Err(e) => {
@@ -1325,6 +1325,25 @@ impl ProtonFs {
"copy queued revision into write scratch failed"); "copy queued revision into write scratch failed");
break 'install Err(Errno::EIO); break 'install Err(Errno::EIO);
} }
if !pending.meta.complete {
let mut authored = Intervals::default();
for &(s, e) in &pending.meta.authored {
authored.add(s, e);
}
if let Err(error) = self.core.fill_gaps(
&uid,
&file,
pending.meta.len,
pending.meta.base_mtime,
pending.meta.base_size,
&authored,
) {
let _ = std::fs::remove_file(&path);
error!(%uid, ?error, "cannot complete the queued revision a write open is based on");
break 'install Err(error);
}
debug!(%uid, "write open completed an incomplete queued revision");
}
initial_written.add(0, pending.meta.len); initial_written.add(0, pending.meta.len);
} }
// Sampled *before* the state lock, never under it: no site in // Sampled *before* the state lock, never under it: no site in
@@ -1541,7 +1560,7 @@ impl ProtonFs {
let transient = is_transient_name(name); let transient = is_transient_name(name);
// Offline the server cannot mint a uid, so invent one and queue the // Offline the server cannot mint a uid, so invent one and queue the
// create. The file is real to the caller either way; only its identity is // create. The file is real to the caller either way; only its identity is
// provisional until the drain (offline.md Phase 3b). // provisional until the drain.
// //
// A parent that is itself still queued forces the same path even when we // A parent that is itself still queued forces the same path even when we
// are online: the API has no folder to put this in yet. // are online: the API has no folder to put this in yet.
@@ -1652,7 +1671,7 @@ impl ProtonFs {
.ok(); .ok();
// As in `create`: offline — or under a parent that is itself still // As in `create`: offline — or under a parent that is itself still
// queued — the folder becomes a placeholder that the drain turns into a // queued — the folder becomes a placeholder that the drain turns into a
// real one (offline.md Phase 3b). // real one.
// Losing the network on the way queues it the same way. // Losing the network on the way queues it the same way.
let minted = if self.core.sends_inline() && !is_local_uid(&parent_uid) { let minted = if self.core.sends_inline() && !is_local_uid(&parent_uid) {
if let Err(error) = self.core.require_uid_writable(&parent_uid) { if let Err(error) = self.core.require_uid_writable(&parent_uid) {
@@ -1874,7 +1893,7 @@ impl ProtonFs {
// rename, so the victim has to be removed first and the operation stops // rename, so the victim has to be removed first and the operation stops
// being atomic. Without this the 422 surfaced as a blanket EIO and every // being atomic. Without this the 422 surfaced as a blanket EIO and every
// write-to-temp-then-rename tool — rsync, atomic editor saves — failed // write-to-temp-then-rename tool — rsync, atomic editor saves — failed
// at the very end of its transfer (bugs.md B13). // at the very end of its transfer (`docs/BUGS.md` B13).
// //
// `RENAME_EXCHANGE` has no Proton primitive and cannot be emulated // `RENAME_EXCHANGE` has no Proton primitive and cannot be emulated
// without a window in which one of the two names does not exist. // without a window in which one of the two names does not exist.
@@ -1956,8 +1975,7 @@ impl ProtonFs {
}; };
// A node whose own creation is still queued has no server-side identity // A node whose own creation is still queued has no server-side identity
// to rename: the queued op *is* the node, so rewriting its target is the // to rename: the queued op *is* the node, so rewriting its target is the
// whole rename. Nothing reaches the API, which is why this works offline // whole rename. Nothing reaches the API, which is why this works offline.
// (offline.md Phase 3b).
// A create that lands meanwhile leaves no op to rewrite; the node is // A create that lands meanwhile leaves no op to rewrite; the node is
// then renamed by the uid Drive gave it (docs/BUGS.md B142). // then renamed by the uid Drive gave it (docs/BUGS.md B142).
if is_local_uid(&uid) { if is_local_uid(&uid) {
@@ -2156,7 +2174,7 @@ impl ProtonFs {
.is_some_and(|e| e.open_count > 0); .is_some_and(|e| e.open_count > 0);
// A node the server has never heard of cannot be trashed there; deleting // A node the server has never heard of cannot be trashed there; deleting
// it just means its queued creation is no longer wanted. This works // it just means its queued creation is no longer wanted. This works
// offline, which the remote path below cannot (offline.md Phase 3b). // offline, which the remote path below cannot.
if is_local_uid(&uid) { if is_local_uid(&uid) {
let dropped = if open_now { let dropped = if open_now {
self.core.withdraw_queued_ops(&uid) self.core.withdraw_queued_ops(&uid)
@@ -2185,7 +2203,7 @@ impl ProtonFs {
} }
// Offline: queue it. Trashing is the one mutation a user expects to work // Offline: queue it. Trashing is the one mutation a user expects to work
// regardless — the file is gone from their point of view the moment the // regardless — the file is gone from their point of view the moment the
// command returns (offline.md Phase 3b). The same when the network goes // command returns. The same when the network goes
// away under the remote call below. // away under the remote call below.
let queue_it = |reply: ReplyEmpty| match self.core.queue_trash(&uid, name) { let queue_it = |reply: ReplyEmpty| match self.core.queue_trash(&uid, name) {
Ok(()) => reply.ok(), Ok(()) => reply.ok(),
@@ -2234,7 +2252,7 @@ impl ProtonFs {
self.core.invalidate_trash(); self.core.invalidate_trash();
// Every other trash site records itself; this one did not, which made a // Every other trash site records itself; this one did not, which made a
// file found in the trash impossible to attribute after the fact — the // file found in the trash impossible to attribute after the fact — the
// activity log was the only record and it showed nothing (bugs.md B2). // activity log was the only record and it showed nothing (`docs/BUGS.md` B2).
self.core self.core
.log_activity(ActivityKind::Trash, name, "trashed from the mount", true); .log_activity(ActivityKind::Trash, name, "trashed from the mount", true);
reply.ok(); reply.ok();
+151 -70
View File
@@ -123,6 +123,7 @@ use mount::{
}; };
use reads::{BlockFlight, BlockRing, PREFETCH_BUDGET, Prefetch, ReaderSlot, STREAM_BYPASS_MIN}; use reads::{BlockFlight, BlockRing, PREFETCH_BUDGET, Prefetch, ReaderSlot, STREAM_BYPASS_MIN};
use state::{Entry, Intervals, PendingRevision, State, StateGuard, WriteHandle}; use state::{Entry, Intervals, PendingRevision, State, StateGuard, WriteHandle};
pub use systemd::idle_sleep;
use tracing::{debug, error, info, warn}; use tracing::{debug, error, info, warn};
use transfers::{CountingWriter, JobGuard, OwnedCountingReader, TransferRegistry}; use transfers::{CountingWriter, JobGuard, OwnedCountingReader, TransferRegistry};
use r#virtual::{ use r#virtual::{
@@ -226,7 +227,7 @@ const DRAIN_WORKERS: usize = 16;
const STAGING_ORPHAN_RETAIN: Duration = Duration::from_secs(30 * 24 * 60 * 60); const STAGING_ORPHAN_RETAIN: Duration = Duration::from_secs(30 * 24 * 60 * 60);
/// First and longest delay between probes for the network coming back after an /// First and longest delay between probes for the network coming back after an
/// offline mount (offline.md Phase 1). Doubles from min to max: a laptop shut in /// offline mount. Doubles from min to max: a laptop shut in
/// a bag is the common case, so the steady state must be cheap, while a brief /// a bag is the common case, so the steady state must be cheap, while a brief
/// blip should still recover in seconds. /// blip should still recover in seconds.
const ONLINE_PROBE_MIN: Duration = Duration::from_secs(5); const ONLINE_PROBE_MIN: Duration = Duration::from_secs(5);
@@ -334,7 +335,7 @@ const MAX_THUMBNAIL_MISSES: usize = 8192;
/// ///
/// Matches the SDK's own `MAX_BATCH_COUNT`, so a chunk is exactly one request: /// Matches the SDK's own `MAX_BATCH_COUNT`, so a chunk is exactly one request:
/// chunking smaller would add round trips, larger would be split anyway and /// chunking smaller would add round trips, larger would be split anyway and
/// delay the waiters this chunking exists to release (bugs.md B14). /// delay the waiters this chunking exists to release (`docs/BUGS.md` B14).
const SIZE_UPGRADE_CHUNK: usize = 150; const SIZE_UPGRADE_CHUNK: usize = 150;
/// How many folders may have a size upgrade in flight at once. /// How many folders may have a size upgrade in flight at once.
@@ -344,7 +345,7 @@ const SIZE_UPGRADE_CHUNK: usize = 150;
/// both with the number of folders it walked. Eight keeps an interactive `ls -l` /// both with the number of folders it walked. Eight keeps an interactive `ls -l`
/// (one folder, sometimes a couple) entirely unaffected while putting a ceiling /// (one folder, sometimes a couple) entirely unaffected while putting a ceiling
/// on the recursive case. A folder past the cap is queued, not dropped: one of /// on the recursive case. A folder past the cap is queued, not dropped: one of
/// the running threads takes it over when its own batch ends (bugs.md B100). /// the running threads takes it over when its own batch ends (`docs/BUGS.md` B100).
const MAX_SIZE_UPGRADES: usize = 8; const MAX_SIZE_UPGRADES: usize = 8;
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
@@ -535,7 +536,7 @@ struct Core {
workers: Arc<Workers>, workers: Arc<Workers>,
/// Unified SQLite metadata cache: the persistence layer behind the in-memory /// Unified SQLite metadata cache: the persistence layer behind the in-memory
/// `State` maps. Every mutation writes through here, and the maps rehydrate /// `State` maps. Every mutation writes through here, and the maps rehydrate
/// from it on mount (plan.md P1). /// from it on mount.
db: Arc<Db>, db: Arc<Db>,
/// Serializes only shared-list publication and access-loss invalidation. /// Serializes only shared-list publication and access-loss invalidation.
/// Network calls happen before taking it. /// Network calls happen before taking it.
@@ -545,8 +546,8 @@ struct Core {
/// Successful refreshes suppress retries even if persisting their timestamp /// Successful refreshes suppress retries even if persisting their timestamp
/// fails. Shared across primary/fork clones and cleared by access events. /// fails. Shared across primary/fork clones and cleared by access events.
shared_refresh_deadlines: Arc<Mutex<SharedRefreshDeadlines>>, shared_refresh_deadlines: Arc<Mutex<SharedRefreshDeadlines>>,
/// False while the API is unreachable and we are serving the cached tree /// False while the API is unreachable and we are serving the cached tree.
/// (offline.md Phase 1). Set by the probe thread; read by front-ends through /// Set by the probe thread; read by front-ends through
/// `Response::Status` so the UI can say so rather than leaving the user to /// `Response::Status` so the UI can say so rather than leaving the user to
/// infer it from a wall of EIO. /// infer it from a wall of EIO.
online: Arc<AtomicBool>, online: Arc<AtomicBool>,
@@ -557,8 +558,8 @@ struct Core {
/// [`pause::PAUSED_INDEFINITELY`] for "until resumed", `0` when not paused. /// [`pause::PAUSED_INDEFINITELY`] for "until resumed", `0` when not paused.
/// Persisted, so a pause survives a restart (see [`Core::sync_paused`]). /// Persisted, so a pause survives a restart (see [`Core::sync_paused`]).
sync_paused_until: Arc<AtomicI64>, sync_paused_until: Arc<AtomicI64>,
/// Writes accepted from the kernel but not yet uploaded, keyed by node /// Writes accepted from the kernel but not yet uploaded, keyed by node.
/// (offline.md Phase 3). The in-memory face of the `pending_op` table, from /// The in-memory face of the `pending_op` table, from
/// which it is rebuilt on mount. /// which it is rebuilt on mount.
/// ///
/// Two things read it: [`Core::read_range`], because until the op drains the /// Two things read it: [`Core::read_range`], because until the op drains the
@@ -586,7 +587,7 @@ struct Core {
/// the queue instead of waiting out their backoff. /// the queue instead of waiting out their backoff.
drain_wake: Arc<(Mutex<bool>, Condvar)>, drain_wake: Arc<(Mutex<bool>, Condvar)>,
/// The daemon's stop signal, shared with every background loop this `Core` /// The daemon's stop signal, shared with every background loop this `Core`
/// was cloned into so teardown can end and join them (bugs.md B44). /// was cloned into so teardown can end and join them (`docs/BUGS.md` B44).
shutdown: Arc<shutdown::Shutdown>, shutdown: Arc<shutdown::Shutdown>,
/// How long this node's last upload actually took, in milliseconds, with /// How long this node's last upload actually took, in milliseconds, with
/// the time it was measured for eviction. Feeds /// the time it was measured for eviction. Feeds
@@ -695,7 +696,7 @@ struct Core {
/// Size upgrades currently running, per folder inode, across every mount. A `getattr` that /// Size upgrades currently running, per folder inode, across every mount. A `getattr` that
/// needs a real size waits on the entry rather than issuing its own fetch — /// needs a real size waits on the entry rather than issuing its own fetch —
/// `ls -l` of a folder is one `getattr` per file, and they must collapse /// `ls -l` of a folder is one `getattr` per file, and they must collapse
/// onto a single batch (bugs.md B14). /// onto a single batch (`docs/BUGS.md` B14).
size_upgrades: Arc<Mutex<HashMap<u64, Arc<SizeUpgrade>>>>, size_upgrades: Arc<Mutex<HashMap<u64, Arc<SizeUpgrade>>>>,
/// Replies parked on those upgrades. Shared across every mount for the same /// Replies parked on those upgrades. Shared across every mount for the same
/// reason the pool is: the bound that matters is per daemon. /// reason the pool is: the bound that matters is per daemon.
@@ -726,11 +727,11 @@ struct Core {
/// can say what a pass is doing rather than just "syncing". An entry exists /// can say what a pass is doing rather than just "syncing". An entry exists
/// only while that folder's reconcile pass is running. /// only while that folder's reconcile pass is running.
sync_progress: Arc<Mutex<HashMap<i64, SyncProgress>>>, sync_progress: Arc<Mutex<HashMap<i64, SyncProgress>>>,
/// Channel to the folder-sync engine (devices.md Phase 2): nudges it to /// Channel to the folder-sync engine: nudges it to
/// reconcile a folder, reconcile everything, or re-scan its watch set. /// reconcile a folder, reconcile everything, or re-scan its watch set.
sync_tx: std::sync::mpsc::Sender<sync::SyncMsg>, sync_tx: std::sync::mpsc::Sender<sync::SyncMsg>,
/// Secondary FUSE sessions for `ondemand` sync folders, keyed by sync-folder /// Secondary FUSE sessions for `ondemand` sync folders, keyed by sync-folder
/// id (devices.md Phase 3). Each is a `ProtonFs` rooted at the folder's remote /// id. Each is a `ProtonFs` rooted at the folder's remote
/// node, mounted over its local path, sharing this Core's client/cache/db and /// node, mounted over its local path, sharing this Core's client/cache/db and
/// tree but with its own root (`fork_state`). Held so we can unmount on /// tree but with its own root (`fork_state`). Held so we can unmount on
/// toggle back to `mirror` and on daemon shutdown. Each entry retains the /// toggle back to `mirror` and on daemon shutdown. Each entry retains the
@@ -743,6 +744,9 @@ struct Core {
/// uploading that same tree — the engine would upload files as they vanish /// uploading that same tree — the engine would upload files as they vanish
/// and then walk the FUSE mount as if it were local. /// and then walk the FUSE mount as if it were local.
sync_locks: Arc<Mutex<HashMap<i64, Arc<Mutex<()>>>>>, sync_locks: Arc<Mutex<HashMap<i64, Arc<Mutex<()>>>>>,
/// Per-sync-folder paths the last pass could not read, so the activity feed
/// says so when the set changes rather than on every poll (B196).
sync_unscanned: Arc<Mutex<HashMap<i64, Vec<String>>>>,
/// Nodes this daemon changed on the remote itself, and how many echoes of /// Nodes this daemon changed on the remote itself, and how many echoes of
/// those changes the event feed still owes us. /// those changes the event feed still owes us.
/// ///
@@ -1030,7 +1034,9 @@ impl Core {
let core = self.clone(); let core = self.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
let job = core.transfers.begin_job("Checking the cache database"); let job = core
.transfers
.begin_background_job("Checking the cache database");
job.detail("Verifying every page"); job.detail("Verifying every page");
let problems = match core.db.integrity_check() { let problems = match core.db.integrity_check() {
Ok(problems) => problems, Ok(problems) => problems,
@@ -1833,8 +1839,7 @@ impl Core {
info!(nodes = st.entries.len(), "hydrated metadata cache from db"); info!(nodes = st.entries.len(), "hydrated metadata cache from db");
} }
/// Rebuild the in-memory pending map from the `pending_op` table on mount /// Rebuild the in-memory pending map from the `pending_op` table on mount.
/// (offline.md Phase 3).
/// ///
/// A queued write survives a restart — that is the point of persisting it — /// A queued write survives a restart — that is the point of persisting it —
/// so until the drain worker gets to it, reads of that file must still come /// so until the drain worker gets to it, reads of that file must still come
@@ -2774,7 +2779,7 @@ impl Core {
missing.push(uid.clone()); missing.push(uid.clone());
} }
// Returned, not awaited here: the caller is a `getattr` that must not // Returned, not awaited here: the caller is a `getattr` that must not
// answer with a provisional size (bugs.md B14), and it parks its reply // answer with a provisional size (`docs/BUGS.md` B14), and it parks its reply
// on this batch rather than holding a thread until it lands. // on this batch rather than holding a thread until it lands.
self.upgrade_sizes(key, missing) self.upgrade_sizes(key, missing)
} }
@@ -2791,7 +2796,7 @@ impl Core {
/// `total_size_on_storage`, the *ciphertext* size, which is slightly larger /// `total_size_on_storage`, the *ciphertext* size, which is slightly larger
/// than the real one. A read must not plan on it: since B84 a size the /// than the real one. A read must not plan on it: since B84 a size the
/// revision disagrees with fails the read, so `read` settles the size first /// revision disagrees with fails the read, so `read` settles the size first
/// ([`Core::resolve_provisional_size`], bugs.md B100). In `stat` it is a /// ([`Core::resolve_provisional_size`], `docs/BUGS.md` B100). In `stat` it is a
/// discrepancy that closes within a round trip, not a repeat of B11 (which /// discrepancy that closes within a round trip, not a repeat of B11 (which
/// reported **0** and so suppressed reads entirely). /// reported **0** and so suppressed reads entirely).
/// ///
@@ -2865,11 +2870,11 @@ impl Core {
/// Chunked so a waiter is released as soon as *its* file is resolved. A /// Chunked so a waiter is released as soon as *its* file is resolved. A
/// single 793-node batch took ~80 s, which outran [`SizeUpgrade::WAIT`] and /// single 793-node batch took ~80 s, which outran [`SizeUpgrade::WAIT`] and
/// put provisional sizes back in front of callers — the bug this was /// put provisional sizes back in front of callers — the bug this was
/// supposed to fix (bugs.md B14). /// supposed to fix (`docs/BUGS.md` B14).
/// ///
/// When the batch ends, the thread takes over the oldest batch the cap held /// When the batch ends, the thread takes over the oldest batch the cap held
/// back, so at most [`MAX_SIZE_UPGRADES`] threads run however many folders /// back, so at most [`MAX_SIZE_UPGRADES`] threads run however many folders
/// a recursive walk touches (bugs.md B100). /// a recursive walk touches (`docs/BUGS.md` B100).
fn run_size_upgrade(&self, key: u64, uids: Vec<NodeUid>, slot: Arc<SizeUpgrade>) { fn run_size_upgrade(&self, key: u64, uids: Vec<NodeUid>, slot: Arc<SizeUpgrade>) {
let (mut key, mut uids, mut slot) = (key, uids, slot); let (mut key, mut uids, mut slot) = (key, uids, slot);
loop { loop {
@@ -2897,7 +2902,7 @@ impl Core {
/// size it is given against the revision's own block table and fails the /// size it is given against the revision's own block table and fails the
/// read on a mismatch (B84), so reading on the provisional size is an `EIO` /// read on a mismatch (B84), so reading on the provisional size is an `EIO`
/// every time. `getattr` usually settles the size first, but not when its /// every time. `getattr` usually settles the size first, but not when its
/// wait times out, so the read cannot rely on it (bugs.md B100). /// wait times out, so the read cannot rely on it (`docs/BUGS.md` B100).
fn resolve_provisional_size(&self, ino: u64, uid: &NodeUid) -> Option<u64> { fn resolve_provisional_size(&self, ino: u64, uid: &NodeUid) -> Option<u64> {
let parent = self.state().entries.get(&ino)?.parent; let parent = self.state().entries.get(&ino)?.parent;
// Without a network the answer is `None` either way; the bound keeps a // Without a network the answer is `None` either way; the bound keeps a
@@ -3317,8 +3322,7 @@ impl Core {
} }
} }
/// Accept a released write handle's bytes and queue their upload /// Accept a released write handle's bytes and queue their upload.
/// (offline.md Phase 3).
/// ///
/// This is what makes a copy into the mount run at disk speed: the caller's /// This is what makes a copy into the mount run at disk speed: the caller's
/// `close` returns once the bytes are staged on local disk and the intent is /// `close` returns once the bytes are staged on local disk and the intent is
@@ -3785,8 +3789,8 @@ impl Core {
Ok(()) Ok(())
} }
/// Invent a node under `parent_uid` and queue the op that will make it real /// Invent a node under `parent_uid` and queue the op that will make it real.
/// (offline.md Phase 3b). Returns the node to intern, exactly as the online /// Returns the node to intern, exactly as the online
/// path returns the one the server minted. /// path returns the one the server minted.
/// ///
/// The parent may itself be a placeholder — `mkdir -p` offline, or `cp -r` of /// The parent may itself be a placeholder — `mkdir -p` offline, or `cp -r` of
@@ -3842,7 +3846,7 @@ impl Core {
} }
/// Queue giving a node a new parent and/or name, and apply it to the tree /// Queue giving a node a new parent and/or name, and apply it to the tree
/// now (offline.md Phase 3b). /// now.
/// ///
/// The op records the desired end state rather than the step, so it both /// The op records the desired end state rather than the step, so it both
/// supersedes any earlier queued rename and lets the drain skip whichever /// supersedes any earlier queued rename and lets the drain skip whichever
@@ -3950,7 +3954,7 @@ impl Core {
} }
/// Queue trashing a node the server knows about, and drop it from the tree /// Queue trashing a node the server knows about, and drop it from the tree
/// now (offline.md Phase 3b). /// now.
/// ///
/// Anything else queued for this node is discarded first: the user has said /// Anything else queued for this node is discarded first: the user has said
/// the file should not exist, so uploading bytes to it or renaming it are /// the file should not exist, so uploading bytes to it or renaming it are
@@ -4081,7 +4085,7 @@ impl Core {
/// ///
/// The path identifies the revision: publication stages a fresh file and /// The path identifies the revision: publication stages a fresh file and
/// swaps the map entry to point at it, so an unchanged path means the base a /// swaps the map entry to point at it, so an unchanged path means the base a
/// caller sampled earlier is still the base now (bugs.md B32). /// caller sampled earlier is still the base now (`docs/BUGS.md` B32).
fn pending_blob(&self, uid: &NodeUid) -> Option<PathBuf> { fn pending_blob(&self, uid: &NodeUid) -> Option<PathBuf> {
self.pending.lock().get(uid).map(|p| p.path.clone()) self.pending.lock().get(uid).map(|p| p.path.clone())
} }
@@ -4223,8 +4227,10 @@ impl Core {
} }
/// Pin the node at mountpoint-relative `rel`. A file downloads its full /// Pin the node at mountpoint-relative `rel`. A file downloads its full
/// plaintext into the content cache; a folder records a recursive pin and /// plaintext into the content cache before this returns. A folder records a
/// downloads every descendant file (selective sync). Returns a human message. /// recursive pin and downloads every descendant file on a background thread,
/// reported as a job: a big folder far outlasts the socket read timeout.
/// Returns a human message.
fn pin(&self, rel: &Path) -> CoreResult<String> { fn pin(&self, rel: &Path) -> CoreResult<String> {
let (ino, uid) = self.resolve(rel)?; let (ino, uid) = self.resolve(rel)?;
let (name, is_folder, mtime, size) = { let (name, is_folder, mtime, size) = {
@@ -4241,34 +4247,93 @@ impl Core {
) )
}; };
if is_folder { if is_folder {
// Offline, every download would fail and the folder would only look
// kept. Refuse instead, as a file pin does.
if !self.is_online() {
return Err(CoreError::offline());
}
// Record the recursive pin first so every descendant is eviction- // Record the recursive pin first so every descendant is eviction-
// exempt before we start filling the cache with the subtree. // exempt before we start filling the cache with the subtree.
self.cache self.cache
.add_pin(&uid, rel, true) .add_pin(&uid, rel, true)
.map_err(|e| CoreError::from_api(&e, "pin"))?; .map_err(|e| CoreError::from_api(&e, "pin"))?;
let n = self.pin_subtree(ino)?; let core = self.clone();
return Ok(format!("{name} ({n} files)")); let folder = name.clone();
std::thread::Builder::new()
.name("pin-folder".into())
.spawn(move || core.pin_subtree(ino, &uid, &folder))
.map_err(|e| CoreError::internal(format!("spawn pin: {e}")))?;
return Ok(format!("{name}: downloading its files in the background"));
} }
let bytes = self self.fetch_content(&uid, &name, mtime, size)?;
.download_file_tracked(&uid, &name, size)
.map_err(|e| CoreError::from_api(&e, "download"))?;
self.cache
.store(&uid, mtime, size, &bytes)
.map_err(|e| CoreError::internal(format!("cache store: {e}")))?;
self.cache self.cache
.add_pin(&uid, rel, false) .add_pin(&uid, rel, false)
.map_err(|e| CoreError::from_api(&e, "pin"))?; .map_err(|e| CoreError::from_api(&e, "pin"))?;
Ok(name) Ok(name)
} }
/// Download and cache every file in the subtree rooted at folder `ino`, /// Download and cache every file in the subtree rooted at the pinned folder
/// returning the count cached (already-fresh blobs counted, not re-fetched). /// `ino` (`folder`, named `name`), as a job the front ends show. Walks the
/// Walks the tree depth-first, enumerating each folder so a cold subtree is /// tree depth-first, enumerating each folder so a cold subtree is fully
/// fully discovered; the lock is dropped before each network download. /// discovered; the lock is dropped before each network download. Stops when
fn pin_subtree(&self, ino: u64) -> CoreResult<usize> { /// the folder is unpinned or the daemon stops.
fn pin_subtree(&self, ino: u64, folder: &NodeUid, name: &str) {
let job = self
.transfers
.begin_job(format!("Making {name} available offline"));
match self.pin_subtree_files(ino) {
Ok(files) => {
job.set_total(files.len() as u64);
let mut failed = 0usize;
for (uid, file, mtime, size) in files {
if self.shutdown.is_stopping() || !self.cache.is_pinned(folder) {
return;
}
job.detail(file.clone());
if let Err(e) = self.fetch_pinned(&uid, &file, mtime, size) {
warn!(%uid, error = %e, "pin folder: download failed");
failed += 1;
}
job.step();
}
info!(folder = %name, failed, "pin folder: done");
}
Err(e) => warn!(folder = %name, error = %e, "pin folder: enumerate failed"),
}
}
/// Cache the file `uid` for a folder pin. Its `mtime` and `size` were read
/// before the download, and a create landing or a remote edit can change
/// them while it runs. The blob is then stored under the old ones and reads
/// as stale, so the file is listed as kept but not available offline. Fetch
/// it again under the new ones, a few times at most.
fn fetch_pinned(&self, uid: &NodeUid, name: &str, mtime: i64, size: u64) -> CoreResult<()> {
let mut fetched = (mtime, size);
for _ in 0..3 {
self.fetch_content(uid, name, fetched.0, fetched.1)?;
let current = {
let st = self.state();
st.by_uid
.get(uid)
.and_then(|ino| st.entries.get(ino))
.map(|e| (e.node.modification_time, node_size(&e.node)))
};
match current {
Some(now) if now != fetched => fetched = now,
_ => break,
}
}
Ok(())
}
/// Every file in the subtree rooted at folder `ino`.
fn pin_subtree_files(&self, ino: u64) -> CoreResult<Vec<(NodeUid, String, i64, u64)>> {
let mut files: Vec<(NodeUid, String, i64, u64)> = Vec::new(); let mut files: Vec<(NodeUid, String, i64, u64)> = Vec::new();
let mut stack = vec![ino]; let mut stack = vec![ino];
while let Some(dir) = stack.pop() { while let Some(dir) = stack.pop() {
if self.shutdown.is_stopping() {
break;
}
self.ensure_children(dir) self.ensure_children(dir)
.map_err(|e| self.errno_error(e, "enumerate"))?; .map_err(|e| self.errno_error(e, "enumerate"))?;
let st = self.state(); let st = self.state();
@@ -4289,22 +4354,7 @@ impl Core {
} }
} }
} }
let mut count = 0; Ok(files)
for (uid, name, mtime, size) in files {
if self.cache.is_cached(&uid, mtime, size) {
count += 1;
continue;
}
match self.download_file_tracked(&uid, &name, size) {
Ok(bytes) => {
if self.cache.store(&uid, mtime, size, &bytes).is_ok() {
count += 1;
}
}
Err(e) => warn!(%uid, error = %e, "pin subtree: download failed"),
}
}
Ok(count)
} }
/// Fetch a thumbnail of `ttype` for the file at `ino`, served from the cache /// Fetch a thumbnail of `ttype` for the file at `ino`, served from the cache
@@ -4350,6 +4400,8 @@ impl Core {
/// Unpin the node at `rel`, evicting its cached content. For a folder, also /// Unpin the node at `rel`, evicting its cached content. For a folder, also
/// evicts every descendant's cached blob (the subtree is no longer kept). /// evicts every descendant's cached blob (the subtree is no longer kept).
/// A node kept only because a folder above it is pinned is refused: there
/// is no pin of its own to remove, and it would stay kept all the same.
fn unpin(&self, rel: &Path) -> CoreResult<String> { fn unpin(&self, rel: &Path) -> CoreResult<String> {
let (ino, uid) = self.resolve(rel)?; let (ino, uid) = self.resolve(rel)?;
let (name, is_folder) = { let (name, is_folder) = {
@@ -4359,14 +4411,24 @@ impl Core {
.map(|e| (e.node.name.clone(), e.node.is_folder())) .map(|e| (e.node.name.clone(), e.node.is_folder()))
.unwrap_or_default() .unwrap_or_default()
}; };
if self.pinned_by_folder(ino) {
return Err(CoreError::conflict(format!(
"{name} is kept offline because a folder above it is; unpin that folder instead"
)));
}
self.cache self.cache
.remove_pin(&uid) .remove_pin(&uid)
.map_err(|e| CoreError::from_api(&e, "unpin"))?; .map_err(|e| CoreError::from_api(&e, "unpin"))?;
// A recursively-pinned folder's descendants were eviction-exempt; now // A recursively-pinned folder's descendants were eviction-exempt; now
// that the pin is gone, reclaim their blobs eagerly instead of waiting // that the pin is gone, reclaim their blobs eagerly instead of waiting
// for budget pressure. Descendants come from the DB node tree. // for budget pressure. Descendants come from the DB node tree. One
// pinned on its own, or under a pinned subfolder, is still kept.
if is_folder && let Ok(uids) = self.db.descendants(&uid.to_string()) { if is_folder && let Ok(uids) = self.db.descendants(&uid.to_string()) {
let still_pinned = self.pinned_set();
for s in uids { for s in uids {
if still_pinned.contains(&s) {
continue;
}
if let Some(u) = parse_uid(&s) { if let Some(u) = parse_uid(&s) {
self.cache.evict(&u); self.cache.evict(&u);
self.evict_reader(&u); self.evict_reader(&u);
@@ -4376,6 +4438,20 @@ impl Core {
Ok(name) Ok(name)
} }
/// Whether the node at `ino` is kept offline because a folder above it is
/// pinned, whether or not it is also pinned on its own.
fn pinned_by_folder(&self, ino: u64) -> bool {
let parent = {
let st = self.state();
st.entries
.get(&ino)
.filter(|e| e.parent != ino)
.and_then(|e| st.entries.get(&e.parent))
.map(|p| p.uid.clone())
};
parent.is_some_and(|uid| self.cache.is_pinned(&uid))
}
/// A Photos API handle sharing this Core's Drive client and session, so it /// A Photos API handle sharing this Core's Drive client and session, so it
/// reuses the daemon's single authenticated session rather than logging in /// reuses the daemon's single authenticated session rather than logging in
/// again (Proton refresh tokens are single-use). Cheap — the Drive client /// again (Proton refresh tokens are single-use). Cheap — the Drive client
@@ -4388,10 +4464,13 @@ impl Core {
/// the same lazy remote enumeration `readdir` uses, projected into /// the same lazy remote enumeration `readdir` uses, projected into
/// serializable [`DirEntry`]s (with per-file pin state). /// serializable [`DirEntry`]s (with per-file pin state).
fn list_dir(&self, rel: &Path) -> CoreResult<Vec<DirEntry>> { fn list_dir(&self, rel: &Path) -> CoreResult<Vec<DirEntry>> {
let (ino, _uid) = self.resolve(rel)?; let (ino, uid) = self.resolve(rel)?;
self.ensure_children(ino) self.ensure_children(ino)
.map_err(|e| self.errno_error(e, "enumerate"))?; .map_err(|e| self.errno_error(e, "enumerate"))?;
self.settle_listing_sizes(ino); self.settle_listing_sizes(ino);
// Every entry shares this folder as its parent, so one lookup says
// whether they are all kept through it.
let pinned_by_folder = self.cache.is_pinned(&uid);
// Snapshot the listing, then drop the lock before touching the on-disk // Snapshot the listing, then drop the lock before touching the on-disk
// pin registry so a slow disk read doesn't block FUSE metadata ops. // pin registry so a slow disk read doesn't block FUSE metadata ops.
let rows: Vec<(String, bool, u64, i64, NodeUid, u64)> = { let rows: Vec<(String, bool, u64, i64, NodeUid, u64)> = {
@@ -4423,7 +4502,8 @@ impl Core {
is_dir, is_dir,
size, size,
modified, modified,
pinned: self.cache.is_pinned(&uid), pinned: pinned_by_folder || self.cache.is_pinned(&uid),
pinned_by_folder,
cached: !is_dir && self.cache.is_cached(&uid, modified, size), cached: !is_dir && self.cache.is_cached(&uid, modified, size),
uid: uid.to_string(), uid: uid.to_string(),
// Listing entries live in the requested dir; the caller derives // Listing entries live in the requested dir; the caller derives
@@ -5124,7 +5204,7 @@ impl Core {
/// `rename(2)` promises to replace an existing destination atomically. Proton /// `rename(2)` promises to replace an existing destination atomically. Proton
/// offers no such primitive — `rename_node` refuses a name that is already /// offers no such primitive — `rename_node` refuses a name that is already
/// taken — so this is the first half of an emulation that is *not* atomic: /// taken — so this is the first half of an emulation that is *not* atomic:
/// see [`Core::restore_replaced`] for the other half (bugs.md B13). /// see [`Core::restore_replaced`] for the other half (`docs/BUGS.md` B13).
/// ///
/// A node whose own creation is still queued has never reached the server, so /// A node whose own creation is still queued has never reached the server, so
/// dropping its queued ops is the whole removal; nothing goes to the wire and /// dropping its queued ops is the whole removal; nothing goes to the wire and
@@ -5315,6 +5395,7 @@ impl Core {
// A trashed node can't be pinned or served from the mount, so its // A trashed node can't be pinned or served from the mount, so its
// content is never current cache: report neither. // content is never current cache: report neither.
pinned: false, pinned: false,
pinned_by_folder: false,
cached: false, cached: false,
uid: item.uid, uid: item.uid,
path: String::new(), path: String::new(),
@@ -5950,8 +6031,8 @@ fn parse_node_uid(s: &str) -> Option<NodeUid> {
} }
/// The uid a node made on this machine goes by until it lands, so it can be /// The uid a node made on this machine goes by until it lands, so it can be
/// interned, listed and written to before the server has ever heard of it /// interned, listed and written to before the server has ever heard of it:
/// (offline.md Phase 3b): its row's local id, as [`pdfs_core::db::local_uid`] /// its row's local id, as [`pdfs_core::db::local_uid`]
/// has it. /// has it.
fn local_node_uid(lid: i64) -> NodeUid { fn local_node_uid(lid: i64) -> NodeUid {
NodeUid::new(VolumeId::from(LOCAL_VOLUME), LinkId::from(lid.to_string())) NodeUid::new(VolumeId::from(LOCAL_VOLUME), LinkId::from(lid.to_string()))
@@ -6114,7 +6195,7 @@ fn dir_is_empty(dir: &Path) -> std::io::Result<bool> {
/// Delete everything inside `dir` but keep `dir` itself (it stays as the FUSE /// Delete everything inside `dir` but keep `dir` itself (it stays as the FUSE
/// mountpoint). Used when a `mirror` folder flips to `ondemand`: the local files /// mountpoint). Used when a `mirror` folder flips to `ondemand`: the local files
/// are the disk we're reclaiming (devices.md Phase 3). /// are the disk we're reclaiming.
fn evict_dir_contents(dir: &Path) -> std::io::Result<()> { fn evict_dir_contents(dir: &Path) -> std::io::Result<()> {
for entry in std::fs::read_dir(dir)? { for entry in std::fs::read_dir(dir)? {
let path = entry?.path(); let path = entry?.path();
@@ -6196,7 +6277,7 @@ struct Progress {
/// ///
/// Waiters are released **per chunk**, not once at the end: a waiter only cares /// Waiters are released **per chunk**, not once at the end: a waiter only cares
/// about its own file, and waiting for the other 792 is what let a large folder /// about its own file, and waiting for the other 792 is what let a large folder
/// outrun the timeout (bugs.md B14). /// outrun the timeout (`docs/BUGS.md` B14).
#[derive(Default)] #[derive(Default)]
struct SizeUpgrade { struct SizeUpgrade {
inner: Mutex<Progress>, inner: Mutex<Progress>,
@@ -6222,7 +6303,7 @@ enum SizeUpgradeClaim {
/// Past the cap this used to return nothing, and the caller answered with the /// Past the cap this used to return nothing, and the caller answered with the
/// provisional size at once. Nothing retried the folder until the next `stat`, /// provisional size at once. Nothing retried the folder until the next `stat`,
/// so a recursive walk over more than [`MAX_SIZE_UPGRADES`] fresh folders showed /// so a recursive walk over more than [`MAX_SIZE_UPGRADES`] fresh folders showed
/// ciphertext sizes for most of them (bugs.md B100). Queued, the batch is still /// ciphertext sizes for most of them (`docs/BUGS.md` B100). Queued, the batch is still
/// fetched, and a waiter gets the real size unless [`SizeUpgrade::WAIT`] runs out. /// fetched, and a waiter gets the real size unless [`SizeUpgrade::WAIT`] runs out.
fn claim_size_upgrade( fn claim_size_upgrade(
in_flight: &mut HashMap<u64, Arc<SizeUpgrade>>, in_flight: &mut HashMap<u64, Arc<SizeUpgrade>>,
@@ -6425,7 +6506,7 @@ impl SizeWaitQueue {
/// Split out from the handler because it is the part that is pure and the part /// Split out from the handler because it is the part that is pure and the part
/// that is dangerous: every `Err` here is a refusal that happens *before* /// that is dangerous: every `Err` here is a refusal that happens *before*
/// anything is trashed, and getting one wrong turns a refusal into the /// anything is trashed, and getting one wrong turns a refusal into the
/// destruction of the destination (bugs.md B13). /// destruction of the destination (`docs/BUGS.md` B13).
/// ///
/// `dst_empty` is only meaningful when `dst_dir`; pass `true` otherwise. /// `dst_empty` is only meaningful when `dst_dir`; pass `true` otherwise.
fn check_replaceable(src_dir: bool, dst_dir: bool, dst_empty: bool) -> Result<(), Errno> { fn check_replaceable(src_dir: bool, dst_dir: bool, dst_empty: bool) -> Result<(), Errno> {
@@ -7155,7 +7236,7 @@ mod size_upgrade_tests {
} }
/// Past the cap a folder used to get no upgrade at all, so a recursive /// Past the cap a folder used to get no upgrade at all, so a recursive
/// walk reported ciphertext sizes for most of what it listed (bugs.md B100). /// walk reported ciphertext sizes for most of what it listed (`docs/BUGS.md` B100).
#[test] #[test]
fn a_folder_past_the_cap_is_queued_not_dropped() { fn a_folder_past_the_cap_is_queued_not_dropped() {
let mut in_flight = HashMap::new(); let mut in_flight = HashMap::new();
@@ -7208,7 +7289,7 @@ mod replace_tests {
/// The case that motivated all of this: rsync renaming its temp file over /// The case that motivated all of this: rsync renaming its temp file over
/// the real one. Two plain files, and it has to be allowed — refusing is /// the real one. Two plain files, and it has to be allowed — refusing is
/// what made every rsync transfer fail at the last step (bugs.md B13). /// what made every rsync transfer fail at the last step (`docs/BUGS.md` B13).
#[test] #[test]
fn a_file_may_replace_a_file() { fn a_file_may_replace_a_file() {
assert!(check_replaceable(false, false, true).is_ok()); assert!(check_replaceable(false, false, true).is_ok());
+66 -4
View File
@@ -9,10 +9,11 @@
//! queued path, and the probe thread here flips it back when Proton answers. //! queued path, and the probe thread here flips it back when Proton answers.
use std::future::Future; use std::future::Future;
use std::sync::atomic::Ordering; use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use parking_lot::{Condvar, Mutex}; 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::api::ResponseCode;
use proton_drive_rs::proton_sdk::error::{ProtonApiError, ProtonError}; use proton_drive_rs::proton_sdk::error::{ProtonApiError, ProtonError};
use tracing::{debug, info, warn}; use tracing::{debug, info, warn};
@@ -54,6 +55,26 @@ pub(crate) struct Link {
changed: Condvar, changed: Condvar,
/// Folders listed from the DB alone while offline, to relist once back. /// Folders listed from the DB alone while offline, to relist once back.
pub(crate) stale_listings: Mutex<std::collections::HashSet<u64>>, 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 /// Whether a failed call failed because Proton could not be reached, rather
@@ -161,10 +182,20 @@ impl Core {
self.rt.block_on(bounded_by(limit, call)) 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 /// Report a failed remote call. Returns whether it failed for want of a
/// network, in which case the mount is now offline and the caller should /// network, or of a session, in which case the mount is now offline and the
/// take its queued path instead of failing. /// caller should take its queued path instead of failing.
pub(crate) fn lost_link(&self, e: &ProtonError, what: &str) -> bool { 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) { if !is_network_error(e) {
return false; return false;
} }
@@ -172,6 +203,29 @@ impl Core {
true 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 /// Flip the mount offline and wake the probe. Only the first caller of an
/// outage logs it; the rest are the same outage. /// outage logs it; the rest are the same outage.
pub(crate) fn mark_offline(&self, what: &str, error: &dyn std::fmt::Display) { pub(crate) fn mark_offline(&self, what: &str, error: &dyn std::fmt::Display) {
@@ -248,7 +302,7 @@ impl Core {
} }
/// Watch the link for the life of the mount: sleep while it is up, and /// Watch the link for the life of the mount: sleep while it is up, and
/// probe it back while it is down (offline.md Phase 1). /// probe it back while it is down.
/// ///
/// A mount that started from the cache is down from the first moment; one /// 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. /// that started online goes down when [`Core::mark_offline`] says so.
@@ -276,6 +330,9 @@ impl Core {
/// Probe until Proton answers, then flip the mount online. Returns `false` /// Probe until Proton answers, then flip the mount online. Returns `false`
/// if the daemon is stopping. /// 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 { fn probe_until_online(&self) -> bool {
let started = self.link.lost_at.lock().unwrap_or_else(Instant::now); let started = self.link.lost_at.lock().unwrap_or_else(Instant::now);
let mut delay = if started.elapsed() < YOUNG_OUTAGE { let mut delay = if started.elapsed() < YOUNG_OUTAGE {
@@ -287,6 +344,10 @@ impl Core {
if !self.shutdown.sleep(delay) { if !self.shutdown.sleep(delay) {
return false; return false;
} }
if self.session_expired() {
delay = ONLINE_PROBE_MAX;
continue;
}
let probe = self.rt.block_on(async { let probe = self.rt.block_on(async {
match tokio::time::timeout(PROBE_TIMEOUT, self.drive.get_my_files_folder()).await { match tokio::time::timeout(PROBE_TIMEOUT, self.drive.get_my_files_folder()).await {
Ok(result) => result, Ok(result) => result,
@@ -308,6 +369,7 @@ impl Core {
self.mark_online(); self.mark_online();
return true; return true;
} }
Err(e) if session_revoked(&e) => self.mark_session_expired("online probe", &e),
Err(e) => { Err(e) => {
debug!(error = %e, ?delay, "online probe failed; still offline"); debug!(error = %e, ?delay, "online probe failed; still offline");
delay = next_probe_delay(delay, started.elapsed()); delay = next_probe_delay(delay, started.elapsed());
+58 -10
View File
@@ -12,6 +12,10 @@ pub enum MountOutcome {
/// The kernel mount ended on its own (e.g. an external `fusermount -u`). /// The kernel mount ended on its own (e.g. an external `fusermount -u`).
/// The caller may want to remount. /// The caller may want to remount.
Unmounted, 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 /// 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 /// `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"; const ROOT_UID_KEY: &str = "root_uid";
/// The My Files root, and whether we got it from the API (`true`) or from the /// The My Files root, and whether we got it from the API (`true`) or from the
@@ -210,30 +214,60 @@ fn fetch_or_recall_root(
drive: &dyn DriveApi, drive: &dyn DriveApi,
rt: &tokio::runtime::Handle, rt: &tokio::runtime::Handle,
db: &Db, db: &Db,
) -> std::io::Result<(Node, bool)> { ) -> std::io::Result<(Node, Start)> {
let err = match rt.block_on(drive.get_my_files_folder()) { let err = match rt.block_on(drive.get_my_files_folder()) {
Ok(root) => { Ok(root) => {
if let Err(e) = db.set_state_str(ROOT_UID_KEY, &root.uid.to_string()) { if let Err(e) = db.set_state_str(ROOT_UID_KEY, &root.uid.to_string()) {
warn!(error = %e, "persist root uid failed"); warn!(error = %e, "persist root uid failed");
} }
return Ok((root, true)); return Ok((root, Start::Online));
} }
Err(e) => e, 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 let cached = db
.state_str(ROOT_UID_KEY) .state_str(ROOT_UID_KEY)
.ok() .ok()
.flatten() .flatten()
.and_then(|uid| db.node_by_uid(&uid).ok().flatten()); .and_then(|uid| db.node_by_uid(&uid).ok().flatten());
match cached { 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) => { Some(root) => {
warn!(error = %err, "fetch My Files root failed; mounting from cache (offline)"); 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}"))), 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 /// Per-mount settings resolved from [`pdfs_core::config::AppConfig`] by the
/// caller, so `mount` itself never reads config or the environment. /// caller, so `mount` itself never reads config or the environment.
pub struct MountOptions { pub struct MountOptions {
@@ -393,7 +427,8 @@ pub(crate) fn mount_with(
// Start the uptime clock here rather than at the first `pdfs diagnostics`, // Start the uptime clock here rather than at the first `pdfs diagnostics`,
// so the age it reports is the daemon's own. // so the age it reports is the daemon's own.
super::diagnostics::start_clock(); super::diagnostics::start_clock();
let (root, online) = fetch_or_recall_root(drive.as_ref(), &rt, &db)?; let (root, start) = fetch_or_recall_root(drive.as_ref(), &rt, &db)?;
let online = start == Start::Online;
let scope = root.tree_event_scope_id(); let scope = root.tree_event_scope_id();
db.mount_upsert_my_files(&mountpoint.to_string_lossy(), &root.uid.to_string(), None) 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}")))?; .map_err(|error| std::io::Error::other(format!("project My Files mount: {error}")))?;
@@ -401,7 +436,7 @@ pub(crate) fn mount_with(
.all_share_access() .all_share_access()
.map_err(|error| std::io::Error::other(format!("load shared access: {error}")))?; .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 // nudged over this channel; the sender lives in Core so control-socket
// handlers can trigger reconciles. // handlers can trigger reconciles.
let (sync_tx, sync_rx) = std::sync::mpsc::channel::<sync::SyncMsg>(); let (sync_tx, sync_rx) = std::sync::mpsc::channel::<sync::SyncMsg>();
@@ -429,7 +464,7 @@ pub(crate) fn mount_with(
shared_generation: Arc::new(AtomicU64::new(0)), shared_generation: Arc::new(AtomicU64::new(0)),
shared_refresh_deadlines: Arc::new(Mutex::new(SharedRefreshDeadlines::default())), shared_refresh_deadlines: Arc::new(Mutex::new(SharedRefreshDeadlines::default())),
online: Arc::new(AtomicBool::new(online)), online: Arc::new(AtomicBool::new(online)),
link: Arc::new(Default::default()), link: Arc::new(link::Link::starting(start == Start::SessionExpired)),
sync_paused_until: Arc::new(AtomicI64::new(paused_until)), sync_paused_until: Arc::new(AtomicI64::new(paused_until)),
pending: Arc::new(Mutex::new(HashMap::new())), pending: Arc::new(Mutex::new(HashMap::new())),
hidden: Arc::new(Mutex::new(HashSet::new())), hidden: Arc::new(Mutex::new(HashSet::new())),
@@ -475,6 +510,7 @@ pub(crate) fn mount_with(
sync_tx, sync_tx,
mounts: Arc::new(Mutex::new(SecondaryMountRegistry::default())), mounts: Arc::new(Mutex::new(SecondaryMountRegistry::default())),
sync_locks: Arc::new(Mutex::new(HashMap::new())), sync_locks: Arc::new(Mutex::new(HashMap::new())),
sync_unscanned: Arc::new(Mutex::new(HashMap::new())),
states: Arc::new(StateRegistry::default()), states: Arc::new(StateRegistry::default()),
}; };
// Before anything can queue work against it: the drain thread below reaches // Before anything can queue work against it: the drain thread below reaches
@@ -501,7 +537,7 @@ pub(crate) fn mount_with(
// Every long-lived thread started from here on is kept in `workers` and // 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 // joined at the end of this function. Leaving them running turned an
// in-process remount into a second generation of drain workers, sync engines // 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(); let mut workers: Vec<std::thread::JoinHandle<()>> = Vec::new();
for worker in 0..DRAIN_WORKERS { for worker in 0..DRAIN_WORKERS {
let core = core.clone(); let core = core.clone();
@@ -718,6 +754,18 @@ pub(crate) fn mount_with(
} }
break MountOutcome::Shutdown; 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) => { Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
if bg.guard.is_finished() { if bg.guard.is_finished() {
info!("mount ended externally"); info!("mount ended externally");
@@ -738,7 +786,7 @@ pub(crate) fn mount_with(
}; };
// Unmount every on-demand sync folder too, or the kernel mounts linger as // 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). // stale and the next start fails with EBUSY.
if let Some(worker) = restore_worker if let Some(worker) = restore_worker
&& worker.join().is_err() && worker.join().is_err()
{ {
@@ -798,7 +846,7 @@ const WORKER_JOIN_DEADLINE: Duration = Duration::from_secs(10);
/// The mount used to return with its drain workers, sync engine, sweep, online /// 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 /// probe, indexer and control listener all still running on `Core` clones. The
/// process usually exited straight afterwards and hid it; an in-process remount /// 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 /// 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 /// block on their own condvar (`wake_drain`), and the control listener blocks
+2 -2
View File
@@ -1,5 +1,5 @@
//! Reading and writing this machine's `profile.json` in its device root, and //! 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 //! 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: //! that talks to Drive. Saving is best-effort and never blocks a user action:
@@ -246,7 +246,7 @@ impl Core {
Profile::parse(&buf).map(Some).map_err(CoreError::invalid) Profile::parse(&buf).map(Some).map_err(CoreError::invalid)
} }
// ---- restore (features.md 5.2) ---------------------------------------- // ---- restore ----------------------------------------
/// The folders under a device that could be synced here, each carrying a /// The folders under a device that could be synced here, each carrying a
/// proposed local path. `device` names another registered device by uid; /// proposed local path. `device` names another registered device by uid;
+9 -9
View File
@@ -464,7 +464,7 @@ impl Core {
/// it, and the [`BLOCK_SIZE`] assumption when it has not — which is every /// it, and the [`BLOCK_SIZE`] assumption when it has not — which is every
/// first read, because learning it means opening a `RevisionReader`, and /// first read, because learning it means opening a `RevisionReader`, and
/// opening one per read is precisely the per-file key derivation the block /// 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 /// 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 /// real sizes and clamps to what was asked for either way. It costs a
@@ -493,13 +493,13 @@ impl Core {
) -> Result<Vec<u8>, Errno> { ) -> Result<Vec<u8>, Errno> {
// A queued write has not reached the remote yet, so the remote's current // 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 // 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() { if let Some(pending) = self.pending.lock().get(uid).cloned() {
return self.read_pending(&pending, offset, len); return self.read_pending(&pending, offset, len);
} }
// A node created offline and never written has no blob and no remote: it // 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 // is an empty file, and asking the API about a `local~` uid would only
// earn a 404 (offline.md Phase 3b). Unless its create landed since the // earn a 404. Unless its create landed since the
// read looked: the landing moved the queued write to the uid Drive // read looked: the landing moved the queued write to the uid Drive
// gave it, and the row says which (`docs/BUGS.md` B179). // gave it, and the row says which (`docs/BUGS.md` B179).
if is_local_uid(uid) { if is_local_uid(uid) {
@@ -636,7 +636,7 @@ impl Core {
/// ///
/// A block that comes back shorter than the file's size says it must be is /// 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 /// 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( async fn read_block(
&self, &self,
reader: &Arc<dyn RevisionRead>, reader: &Arc<dyn RevisionRead>,
@@ -651,7 +651,7 @@ impl Core {
// file's boundaries are wrong, and the failure is not confined to short // 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 // 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 // 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 { let bytes = if reader.size() != fsize {
warn!( warn!(
%uid, bidx = span.idx, fsize, %uid, bidx = span.idx, fsize,
@@ -664,7 +664,7 @@ impl Core {
// Learned here rather than on a path of its own: this is where a // 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 // reader is open anyway, and recording the real geometry is what
// lets the *next* read plan its blocks on the revision's own // 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 { if cache_blocks {
self.cache self.cache
.store_block_geometry(uid, mtime, fsize, reader.block_sizes()); .store_block_geometry(uid, mtime, fsize, reader.block_sizes());
@@ -706,7 +706,7 @@ impl Core {
/// The range path derives every block boundary from the revision's recorded /// The range path derives every block boundary from the revision's recorded
/// block sizes. When those understate the file, reads come back short with /// block sizes. When those understate the file, reads come back short with
/// no error anywhere, and the caller hands userspace a truncated file that /// 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 /// arithmetic — it streams the block list — so it returns the whole file
/// where the range path cannot, which is why pinning was the workaround. /// 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 /// This does the same thing on the user's behalf, once, and the resulting
@@ -918,7 +918,7 @@ impl Core {
let end = offset.saturating_add(len).min(fsize); let end = offset.saturating_add(len).min(fsize);
let mut out = Vec::with_capacity((end - offset) as usize); let mut out = Vec::with_capacity((end - offset) as usize);
// On the revision's own boundaries where they are known, and on the // 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 wanted = self.block_geometry(uid, mtime, fsize).spans(offset, end);
let (Some(&head), Some(&tail)) = (wanted.first(), wanted.last()) else { let (Some(&head), Some(&tail)) = (wanted.first(), wanted.last()) else {
return Ok(out); return Ok(out);
@@ -1038,7 +1038,7 @@ impl Core {
// — the cache validates it on read, the network path in `read_block` // — the cache validates it on read, the network path in `read_block`
// — so this refuses rather than quietly contributing fewer bytes // — so this refuses rather than quietly contributing fewer bytes
// than the range covers, which is what turned a bad block into a // 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 { if block.len() as u64 != span.len {
error!( error!(
%uid, bidx = span.idx, len = block.len(), expected = span.len, fsize, %uid, bidx = span.idx, len = block.len(), expected = span.len, fsize,
+35 -9
View File
@@ -25,6 +25,7 @@ use proton_drive_rs::NodeKind;
use proton_drive_rs::proton_sdk::ids::NodeUid; use proton_drive_rs::proton_sdk::ids::NodeUid;
use tracing::{info, warn}; use tracing::{info, warn};
use super::state::State;
use super::sync::mirror_subtree_unsynced; use super::sync::mirror_subtree_unsynced;
use super::{Core, is_local_uid, parse_uid}; use super::{Core, is_local_uid, parse_uid};
@@ -130,6 +131,24 @@ fn rename_noreplace(from: &Path, to: &Path) -> std::io::Result<()> {
} }
} }
/// Whether inode `ino` is `root` or lies under it in the tree. Bounded, so a
/// parent cycle in corrupt data cannot hang the caller.
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. /// Whether `uid` is `root` or lies under it, as far as the nodes table knows.
fn under(core: &Core, root: &NodeUid, uid: &str) -> bool { fn under(core: &Core, root: &NodeUid, uid: &str) -> bool {
let root = root.to_string(); let root = root.to_string();
@@ -336,7 +355,7 @@ impl Core {
core.require_uid_writable(authority) core.require_uid_writable(authority)
.map_err(|e| core.errno_error(e, "move access"))?; .map_err(|e| core.errno_error(e, "move access"))?;
} }
if self.has_pending_work_under(&uid)? { if self.has_pending_work_under(ino, &uid)? {
return Err(CoreError::conflict( return Err(CoreError::conflict(
"it has changes that are not uploaded yet; wait for the upload to finish", "it has changes that are not uploaded yet; wait for the upload to finish",
)); ));
@@ -410,10 +429,11 @@ impl Core {
Ok(uid) Ok(uid)
} }
/// Whether the queue owes Drive anything for `uid` or a node under it, or a /// Whether the queue owes Drive anything for `uid` (inode `ino`) or a node
/// file under it is open for writing. Moving such a node away would strand /// under it, or a file under it is open for writing. Moving such a node away
/// the pending change against a path that no longer leads to it. /// would strand the pending change against a path that no longer leads to
fn has_pending_work_under(&self, uid: &NodeUid) -> CoreResult<bool> { /// it.
fn has_pending_work_under(&self, ino: u64, uid: &NodeUid) -> CoreResult<bool> {
let ops = self let ops = self
.db .db
.pending_ops() .pending_ops()
@@ -427,12 +447,18 @@ impl Core {
}) { }) {
return Ok(true); return Ok(true);
} }
// Collected first so no State lock is held across the nodes-table reads. // Open handles are placed in the tree by inode, not by uid in the nodes
let mut writing = Vec::new(); // 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| { self.for_each_state(|st| {
writing.extend(st.active_writes.values().map(|w| w.uid.to_string())); writing = st
.active_writes
.keys()
.any(|&open| ino_is_under(st, open, ino));
}); });
Ok(writing.iter().any(|w| under(self, uid, w))) Ok(writing)
} }
/// Bring the source location level with a move Drive has made. /// Bring the source location level with a move Drive has made.
+21 -12
View File
@@ -66,6 +66,7 @@ fn shared_entry_of(core: &Core, n: Node) -> DirEntry {
size, size,
modified: n.modification_time, modified: n.modification_time,
pinned: false, pinned: false,
pinned_by_folder: false,
cached: false, cached: false,
path: core.rel_path_for_uid(&n.uid).unwrap_or_default(), path: core.rel_path_for_uid(&n.uid).unwrap_or_default(),
uid: n.uid.to_string(), uid: n.uid.to_string(),
@@ -660,6 +661,12 @@ impl Core {
/// the shared uids; the per-node detail is then gathered best-effort — a single /// 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 /// node racing with an unshare drops from the list rather than failing the whole
/// request. /// 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>> { pub(crate) fn list_shared_by_me(&self) -> CoreResult<Vec<SharedItem>> {
let uids = self let uids = self
.rt .rt
@@ -678,24 +685,26 @@ impl Core {
let members = self let members = self
.rt .rt
.block_on(self.client.list_share_members(&uid)) .block_on(self.client.list_share_members(&uid))
.map(|m| m.len()) .map(|m| m.len());
.unwrap_or(0);
let proton_invites = self let proton_invites = self
.rt .rt
.block_on(self.client.list_share_invitations(&uid)) .block_on(self.client.list_share_invitations(&uid))
.map(|i| i.len()) .map(|i| i.len());
.unwrap_or(0);
let external_invites = self let external_invites = self
.rt .rt
.block_on(self.client.list_external_invitations(&uid)) .block_on(self.client.list_external_invitations(&uid))
.map(|i| i.len()) .map(|i| i.len());
.unwrap_or(0); let link = self.rt.block_on(self.client.get_public_link(&uid));
let link = self if let (Ok(0), Ok(0), Ok(0), Ok(None)) =
.rt (&members, &proton_invites, &external_invites, &link)
.block_on(self.client.get_public_link(&uid)) {
.ok() debug!(%uid, "shared by me: skipping an emptied share");
.flatten() continue;
.map(public_link_info); }
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 { items.push(SharedItem {
uid: uid.to_string(), uid: uid.to_string(),
is_dir: n.is_folder(), is_dir: n.is_folder(),
+1 -1
View File
@@ -6,7 +6,7 @@
//! invisible; in an in-process remount — which is how the daemon recovers from a //! 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 //! 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 //! 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 //! The primitive is deliberately small: a flag plus a condvar. Every loop that
//! would otherwise call `thread::sleep` calls [`Shutdown::sleep`] instead, which //! would otherwise call `thread::sleep` calls [`Shutdown::sleep`] instead, which
+356 -15
View File
@@ -46,6 +46,7 @@ impl Daemon {
/// cache and the database the last run left. /// cache and the database the last run left.
pub(crate) fn start(dir: &Path, client: FakeClient) -> io::Result<Self> { pub(crate) fn start(dir: &Path, client: FakeClient) -> io::Result<Self> {
log_to_test_output(); log_to_test_output();
isolate_from_the_developer();
let mountpoint = dir.join("mnt"); let mountpoint = dir.join("mnt");
let socket = dir.join("control.sock"); let socket = dir.join("control.sock");
std::fs::create_dir_all(&mountpoint)?; std::fs::create_dir_all(&mountpoint)?;
@@ -138,6 +139,40 @@ impl Daemon {
} }
} }
/// Write the new file `path` under the mount, and let the drain send its
/// create only once the create holds the bytes. Otherwise a drain worker
/// still busy with the op before can claim the create before the release
/// attaches them, which the kernel sends after `close` returns. The file
/// then goes out empty, and the bytes follow as a revision, past a hook on
/// the next create.
pub(crate) fn write_new(&self, path: &Path, bytes: &[u8]) -> io::Result<()> {
let rel = path
.strip_prefix(&self.mountpoint)
.map_err(io::Error::other)?
.to_string_lossy()
.into_owned();
let paused = |paused| Request::SetSyncPaused {
paused,
until: None,
};
self.request(&paused(true))?;
std::fs::write(path, bytes)?;
let attached = wait_until(Duration::from_secs(30), || {
self.pending().is_ok_and(|items| {
items
.iter()
.any(|item| item.kind == "create" && item.path == rel && item.exportable)
})
});
self.request(&paused(false))?;
match attached {
true => Ok(()),
false => Err(io::Error::other(format!(
"{rel}: create never held the bytes"
))),
}
}
/// Stop the daemon and start it again on the same state and link. /// Stop the daemon and start it again on the same state and link.
pub(crate) fn restart(mut self) -> io::Result<Self> { pub(crate) fn restart(mut self) -> io::Result<Self> {
self.shut_down()?; self.shut_down()?;
@@ -224,6 +259,33 @@ impl tracing::field::Visit for Message {
} }
} }
/// Point the XDG directories of the whole test process at a scratch
/// directory, once, before the first daemon starts.
///
/// The daemon reads `config.json` through `AppDirs` in a few places (the
/// adopted device, the ignore rules, the profile), and those would otherwise
/// be the developer's own: an adopted device of their real account made every
/// sync folder fail to register against the fake Drive.
fn isolate_from_the_developer() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
let home = scratch("xdg");
for (var, sub) in [
("XDG_CONFIG_HOME", "config"),
("XDG_STATE_HOME", "state"),
("XDG_CACHE_HOME", "cache"),
("XDG_DATA_HOME", "data"),
] {
let path = home.join(sub);
std::fs::create_dir_all(&path).expect("create a simulation XDG directory");
// SAFETY: the simulations run one at a time (`--test-threads=1`,
// docs/TESTING.md), and this runs before the first of them starts a
// daemon, so no other thread reads the environment meanwhile.
unsafe { std::env::set_var(var, &path) };
}
});
}
/// A client for what `DriveApi` does not cover (sharing, Photos, devices), /// A client for what `DriveApi` does not cover (sharing, Photos, devices),
/// pointed at a closed local port so none of it leaves the machine. /// pointed at a closed local port so none of it leaves the machine.
fn offline_client() -> ProtonDriveClient { fn offline_client() -> ProtonDriveClient {
@@ -451,6 +513,36 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
} }
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_partial_write_over_bytes_never_read_takes_another_write_at_once() {
// Nothing here ever read the file, so the release of a partial write
// had no cached base to fill its gaps from and queued an incomplete
// revision. The next write open on it failed with EIO (B204).
let drive = FakeDrive::new();
drive.device().write("f.txt", b"0123456789");
let dir = scratch("partial-write-unread-base");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let path = daemon.mountpoint.join("f.txt");
let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec())));
write_at(&path, 2, b"ab").unwrap();
write_at(&path, 6, b"cd").unwrap();
assert!(
wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
&& drive.tree().get("f.txt").cloned() == file(b"01ab45cd89")
}),
"{:?} {:?}",
daemon.pending(),
drive.tree()
);
assert_eq!(drive.tree().len(), 1, "{:?}", drive.tree());
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test] #[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_file_deleted_offline_while_open_still_reads_and_takes_writes() { fn a_file_deleted_offline_while_open_still_reads_and_takes_writes() {
@@ -729,7 +821,7 @@ mod tests {
let path = daemon.mountpoint.join("c.bin"); let path = daemon.mountpoint.join("c.bin");
let create = daemon.client.hold_reply_to_next_create(); let create = daemon.client.hold_reply_to_next_create();
std::fs::write(&path, b"abc").unwrap(); daemon.write_new(&path, b"abc").unwrap();
assert!(wait_until(Duration::from_secs(30), || create.reached())); assert!(wait_until(Duration::from_secs(30), || create.reached()));
let uid = drive.lookup("c.bin").unwrap(); let uid = drive.lookup("c.bin").unwrap();
let landing = daemon.client.hold_next_landing(&uid); let landing = daemon.client.hold_next_landing(&uid);
@@ -775,7 +867,7 @@ mod tests {
); );
let create = daemon.client.hold_reply_to_next_create(); let create = daemon.client.hold_reply_to_next_create();
std::fs::write(mnt.join("a.txt"), b"old").unwrap(); daemon.write_new(&mnt.join("a.txt"), b"old").unwrap();
assert!(wait_until(Duration::from_secs(30), || create.reached())); assert!(wait_until(Duration::from_secs(30), || create.reached()));
let uid = drive.lookup("a.txt").unwrap(); let uid = drive.lookup("a.txt").unwrap();
let landing = daemon.client.hold_next_landing(&uid); let landing = daemon.client.hold_next_landing(&uid);
@@ -813,7 +905,7 @@ mod tests {
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let mnt = daemon.mountpoint.clone(); let mnt = daemon.mountpoint.clone();
let create = daemon.client.hold_reply_to_next_create(); let create = daemon.client.hold_reply_to_next_create();
std::fs::write(mnt.join("a.txt"), b"old").unwrap(); daemon.write_new(&mnt.join("a.txt"), b"old").unwrap();
assert!(wait_until(Duration::from_secs(30), || create.reached())); assert!(wait_until(Duration::from_secs(30), || create.reached()));
std::fs::write(mnt.join("a.txt"), b"newer").unwrap(); std::fs::write(mnt.join("a.txt"), b"newer").unwrap();
let uid = drive.lookup("a.txt").unwrap(); let uid = drive.lookup("a.txt").unwrap();
@@ -996,7 +1088,7 @@ mod tests {
); );
let create = daemon.client.hold_reply_to_next_create(); let create = daemon.client.hold_reply_to_next_create();
std::fs::write(mnt.join("x/c.bin"), b"abc").unwrap(); daemon.write_new(&mnt.join("x/c.bin"), b"abc").unwrap();
assert!(wait_until(Duration::from_secs(30), || create.reached())); assert!(wait_until(Duration::from_secs(30), || create.reached()));
std::fs::rename(mnt.join("x/c.bin"), mnt.join("y/d")).unwrap(); std::fs::rename(mnt.join("x/c.bin"), mnt.join("y/d")).unwrap();
let uid = drive.lookup("x/c.bin").unwrap(); let uid = drive.lookup("x/c.bin").unwrap();
@@ -1074,7 +1166,7 @@ mod tests {
take_logged(); take_logged();
let create = daemon.client.hold_reply_to_next_create(); let create = daemon.client.hold_reply_to_next_create();
std::fs::write(mnt.join("x/c.bin"), b"abc").unwrap(); daemon.write_new(&mnt.join("x/c.bin"), b"abc").unwrap();
assert!(wait_until(Duration::from_secs(30), || create.reached())); assert!(wait_until(Duration::from_secs(30), || create.reached()));
let uid = drive.lookup("x/c.bin").unwrap(); let uid = drive.lookup("x/c.bin").unwrap();
// The read-back after the create, the one adopting it, and the size // The read-back after the create, the one adopting it, and the size
@@ -1122,7 +1214,7 @@ mod tests {
})); }));
let create = daemon.client.hold_reply_to_next_create(); let create = daemon.client.hold_reply_to_next_create();
std::fs::write(mnt.join("x/c.bin"), b"abc").unwrap(); daemon.write_new(&mnt.join("x/c.bin"), b"abc").unwrap();
assert!(wait_until(Duration::from_secs(30), || create.reached())); assert!(wait_until(Duration::from_secs(30), || create.reached()));
let uid = drive.lookup("x/c.bin").unwrap(); let uid = drive.lookup("x/c.bin").unwrap();
let read_back = daemon.client.hold_next_read(&uid); let read_back = daemon.client.hold_next_read(&uid);
@@ -1165,7 +1257,7 @@ mod tests {
})); }));
let create = daemon.client.hold_reply_to_next_create(); let create = daemon.client.hold_reply_to_next_create();
std::fs::write(mnt.join("x/c.bin"), b"abc").unwrap(); daemon.write_new(&mnt.join("x/c.bin"), b"abc").unwrap();
assert!(wait_until(Duration::from_secs(30), || create.reached())); assert!(wait_until(Duration::from_secs(30), || create.reached()));
// Long enough for the next event poll to bring the file's event, which // Long enough for the next event poll to bring the file's event, which
// drops the folder's listing. // drops the folder's listing.
@@ -1290,7 +1382,9 @@ mod tests {
let folder = drive.lookup("x").unwrap(); let folder = drive.lookup("x").unwrap();
let create = daemon.client.hold_next_create(); let create = daemon.client.hold_next_create();
std::fs::write(mnt.join("x/removed-while-uploading"), b"abc").unwrap(); daemon
.write_new(&mnt.join("x/removed-while-uploading"), b"abc")
.unwrap();
assert!(wait_until(Duration::from_secs(30), || create.reached())); assert!(wait_until(Duration::from_secs(30), || create.reached()));
std::fs::remove_file(mnt.join("x/removed-while-uploading")).unwrap(); std::fs::remove_file(mnt.join("x/removed-while-uploading")).unwrap();
std::fs::remove_dir(mnt.join("x")).unwrap(); std::fs::remove_dir(mnt.join("x")).unwrap();
@@ -1535,7 +1629,9 @@ mod tests {
let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec()))); let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec())));
daemon.client.lose_reply_to_next_create(); daemon.client.lose_reply_to_next_create();
std::fs::write(daemon.mountpoint.join("f.txt"), b"once").unwrap(); daemon
.write_new(&daemon.mountpoint.join("f.txt"), b"once")
.unwrap();
assert!( assert!(
wait_until(Duration::from_secs(30), || { wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty()) daemon.pending().is_ok_and(|items| items.is_empty())
@@ -1565,7 +1661,7 @@ mod tests {
let held = daemon.client.hold_reply_to_next_create(); let held = daemon.client.hold_reply_to_next_create();
daemon.client.lose_reply_to_next_create(); daemon.client.lose_reply_to_next_create();
std::fs::write(&path, b"first").unwrap(); daemon.write_new(&path, b"first").unwrap();
assert!(wait_until(Duration::from_secs(30), || held.reached())); assert!(wait_until(Duration::from_secs(30), || held.reached()));
std::fs::write(&path, b"second").unwrap(); std::fs::write(&path, b"second").unwrap();
drop(held); drop(held);
@@ -1597,7 +1693,7 @@ mod tests {
let path = daemon.mountpoint.join("f.txt"); let path = daemon.mountpoint.join("f.txt");
daemon.client.lose_reply_to_next_create(); daemon.client.lose_reply_to_next_create();
std::fs::write(&path, b"gone").unwrap(); daemon.write_new(&path, b"gone").unwrap();
assert!(wait_until(Duration::from_secs(30), || { assert!(wait_until(Duration::from_secs(30), || {
drive.tree().contains_key("f.txt") drive.tree().contains_key("f.txt")
})); }));
@@ -1630,7 +1726,7 @@ mod tests {
let held = daemon.client.hold_reply_to_next_create(); let held = daemon.client.hold_reply_to_next_create();
daemon.client.lose_reply_to_next_create(); daemon.client.lose_reply_to_next_create();
std::fs::write(&path, b"first").unwrap(); daemon.write_new(&path, b"first").unwrap();
assert!(wait_until(Duration::from_secs(30), || held.reached())); assert!(wait_until(Duration::from_secs(30), || held.reached()));
std::fs::write(&path, b"second").unwrap(); std::fs::write(&path, b"second").unwrap();
drop(held); drop(held);
@@ -1670,7 +1766,9 @@ mod tests {
let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec()))); let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec())));
daemon.client.lose_reply_to_next_create(); daemon.client.lose_reply_to_next_create();
std::fs::write(daemon.mountpoint.join("f.txt"), b"first").unwrap(); daemon
.write_new(&daemon.mountpoint.join("f.txt"), b"first")
.unwrap();
assert!(wait_until(Duration::from_secs(30), || { assert!(wait_until(Duration::from_secs(30), || {
daemon daemon
.pending() .pending()
@@ -1709,7 +1807,9 @@ mod tests {
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
daemon.client.lose_reply_to_next_create(); daemon.client.lose_reply_to_next_create();
std::fs::write(daemon.mountpoint.join("f.txt"), b"first").unwrap(); daemon
.write_new(&daemon.mountpoint.join("f.txt"), b"first")
.unwrap();
assert!(wait_until(Duration::from_secs(30), || { assert!(wait_until(Duration::from_secs(30), || {
daemon daemon
.pending() .pending()
@@ -1803,7 +1903,7 @@ mod tests {
let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec()))); let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec())));
let held = daemon.client.hold_reply_to_next_create(); let held = daemon.client.hold_reply_to_next_create();
std::fs::write(mount.join("c.bin"), b"first").unwrap(); daemon.write_new(&mount.join("c.bin"), b"first").unwrap();
assert!(wait_until(Duration::from_secs(30), || held.reached())); assert!(wait_until(Duration::from_secs(30), || held.reached()));
std::fs::rename(mount.join("c.bin"), mount.join("d")).unwrap(); std::fs::rename(mount.join("c.bin"), mount.join("d")).unwrap();
std::fs::write(mount.join("c.bin"), b"second").unwrap(); std::fs::write(mount.join("c.bin"), b"second").unwrap();
@@ -2069,4 +2169,245 @@ mod tests {
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
} }
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_pinned_folder_keeps_every_file_under_it_until_it_is_unpinned() {
let drive = FakeDrive::new();
let dir = scratch("pin-folder");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
std::fs::create_dir_all(daemon.mountpoint.join("Docs/sub")).unwrap();
std::fs::write(daemon.mountpoint.join("Docs/a.txt"), b"alpha").unwrap();
std::fs::write(daemon.mountpoint.join("Docs/sub/b.txt"), b"beta").unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
&& drive.lookup("Docs/sub/b.txt").is_some()
}));
daemon.request(&Request::PurgeCache).unwrap();
let entry = |folder: &str, name: &str| match daemon.request(&Request::ListDir {
path: folder.into(),
}) {
Ok(Response::Entries { entries }) => entries.into_iter().find(|e| e.name == name),
_ => None,
};
assert!(entry("Docs", "a.txt").is_some_and(|e| !e.cached && !e.pinned));
daemon
.request(&Request::Pin {
path: "Docs".into(),
})
.unwrap();
assert!(
wait_until(Duration::from_secs(30), || {
entry("Docs", "a.txt").is_some_and(|e| e.cached)
&& entry("Docs/sub", "b.txt").is_some_and(|e| e.cached)
}),
"the folder's files were downloaded in the background"
);
let a = entry("Docs", "a.txt").unwrap();
assert!(a.pinned && a.pinned_by_folder);
assert!(entry("", "Docs").is_some_and(|e| e.pinned && !e.pinned_by_folder));
assert!(
daemon
.request(&Request::Unpin {
path: "Docs/a.txt".into(),
})
.is_err(),
"a file kept through its folder cannot be unpinned on its own"
);
daemon
.request(&Request::Unpin {
path: "Docs".into(),
})
.unwrap();
let a = entry("Docs", "a.txt").unwrap();
assert!(!a.pinned && !a.pinned_by_folder && !a.cached);
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_folder_pinned_while_a_create_in_it_lands_keeps_that_file() {
// The pin's download of a file started before the file's landing set
// its modification time from Drive, and ended after it: the blob was
// stored under the old time, and the file never read as cached (B208).
let drive = FakeDrive::new();
let dir = scratch("pin-folder-landing");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
std::fs::create_dir(daemon.mountpoint.join("Docs")).unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
}));
let entry = || match daemon.request(&Request::ListDir {
path: "Docs".into(),
}) {
Ok(Response::Entries { entries }) => entries.into_iter().find(|e| e.name == "a.txt"),
_ => None,
};
// Drive stamps the file with the time it landed, a second after it
// was written, so the landing moves its modification time.
let paused = |paused| Request::SetSyncPaused {
paused,
until: None,
};
daemon.request(&paused(true)).unwrap();
std::fs::write(daemon.mountpoint.join("Docs/a.txt"), b"alpha").unwrap();
std::thread::sleep(Duration::from_millis(1100));
let landing = daemon.client.hold_next_landing_of_any();
daemon.request(&paused(false)).unwrap();
assert!(wait_until(Duration::from_secs(30), || landing.reached()));
let real = drive.lookup("Docs/a.txt").unwrap();
let read_back = daemon.client.hold_next_read(&real);
drop(landing);
assert!(wait_until(Duration::from_secs(30), || read_back.reached()));
let written = entry().unwrap().modified;
// Without the blob the landing kept, the pin downloads the file.
daemon.request(&Request::PurgeCache).unwrap();
let download = daemon.client.hold_next_download(&real);
take_logged();
daemon
.request(&Request::Pin {
path: "Docs".into(),
})
.unwrap();
assert!(wait_until(Duration::from_secs(30), || download.reached()));
drop(read_back);
assert!(wait_until(Duration::from_secs(30), || {
entry().is_some_and(|e| e.modified != written)
}));
drop(download);
let mut logged = Vec::new();
assert!(
wait_until(Duration::from_secs(30), || {
logged.extend(take_logged());
logged
.iter()
.any(|line| line.starts_with("pin folder: done"))
}),
"{logged:?}"
);
assert!(
entry().is_some_and(|e| e.cached),
"the file kept through its folder is available offline: {:?}",
entry()
);
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
/// Register `local` as a synced folder in `mode` and wait until the daemon
/// lists it that way, returning its id.
fn sync_folder(daemon: &Daemon, local: &Path, mode: &str) -> i64 {
std::fs::create_dir_all(local).unwrap();
let path = local.canonicalize().unwrap().to_string_lossy().into_owned();
daemon
.request(&Request::AddSyncFolder {
local_path: path.clone(),
})
.unwrap();
let listed = |mode: &str| match daemon.request(&Request::ListSyncFolders) {
Ok(Response::SyncFolders { items }) => items
.into_iter()
.find(|f| {
f.local_path == path
&& f.mode == mode
&& f.pending_mode.is_none()
&& f.last_sync > 0
})
.map(|f| f.id),
_ => None,
};
assert!(
wait_until(Duration::from_secs(30), || listed("mirror").is_some()),
"{} was registered: {:?}",
local.display(),
daemon.request(&Request::ListSyncFolders)
);
let id = listed("mirror").unwrap();
// Reads lag behind creates, so the remote folder the first pass made
// is not readable at once; switching modes reads it.
std::thread::sleep(Duration::from_millis(200));
if mode != "mirror" {
daemon
.request(&Request::SetSyncFolderMode {
id,
mode: mode.into(),
})
.unwrap();
assert!(
wait_until(Duration::from_secs(30), || listed(mode).is_some()
&& is_mounted(local)),
"{} switched to {mode}: {:?}",
local.display(),
daemon.request(&Request::ListSyncFolders)
);
}
id
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_move_out_of_an_on_demand_folder_refuses_a_file_still_open_for_writing() {
// The account run's "move refuses a file still being written" case let
// a move from an on-demand synced folder into a mirror folder go ahead
// while the file was open, so the write landed after the move.
let drive = FakeDrive::new();
let dir = scratch("move-open-out-of-ondemand");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let ondemand = dir.join("ondemand");
let mirror = dir.join("mirror");
sync_folder(&daemon, &ondemand, "ondemand");
sync_folder(&daemon, &mirror, "mirror");
let source = ondemand.join("open");
std::fs::create_dir(&source).unwrap();
daemon.request(&Request::SyncNow { id: None }).unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
}));
// The account run opened the file a moment after its create landed:
// the queue was already empty, and the tree still had the
// placeholder uid it gave the file.
let landing = daemon.client.hold_next_landing_of_any();
{
let mut file = std::fs::File::create(source.join("open.txt")).unwrap();
std::io::Write::write_all(&mut file, b"before").unwrap();
file.sync_all().unwrap();
}
assert!(wait_until(Duration::from_secs(30), || landing.reached()));
let mut file = std::fs::OpenOptions::new()
.append(true)
.open(source.join("open.txt"))
.unwrap();
std::io::Write::write_all(&mut file, b" and after").unwrap();
let moved = daemon.request(&Request::Move {
path: source.to_string_lossy().into_owned(),
new_parent: mirror
.canonicalize()
.unwrap()
.to_string_lossy()
.into_owned(),
});
drop(file);
drop(landing);
match moved {
Err(e) => assert!(
e.to_string().contains("not uploaded yet"),
"refused for another reason: {e}"
),
Ok(answer) => panic!("the move went ahead: {answer:?}"),
}
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
} }
+97 -4
View File
@@ -1,4 +1,4 @@
//! An in-memory Drive for the simulation tests (`docs/MILESTONE-3.0.0.md` §8.1). //! An in-memory Drive for the simulation tests (`docs/TESTING.md`).
//! //!
//! [`FakeDrive`] is the server: one tree of nodes with uids, revisions and //! [`FakeDrive`] is the server: one tree of nodes with uids, revisions and
//! names, and an event log. Each simulated daemon reaches it through its own //! names, and an event log. Each simulated daemon reaches it through its own
@@ -31,6 +31,9 @@
//! - **A full account** ([`FakeDrive::set_quota`]) refusing uploads with //! - **A full account** ([`FakeDrive::set_quota`]) refusing uploads with
//! `InsufficientQuota`. //! `InsufficientQuota`.
//! //!
//! It also holds the account's computers, each with a root folder outside My
//! files, so a simulated daemon can register its own and sync a local folder.
//!
//! A test can also hold the next read of one node on its way to Drive //! A test can also hold the next read of one node on its way to Drive
//! ([`FakeClient::hold_next_read`]), to make a change while the daemon waits //! ([`FakeClient::hold_next_read`]), to make a change while the daemon waits
//! for the answer, or on its way back ([`FakeClient::hold_answer_to_next_read`]), //! for the answer, or on its way back ([`FakeClient::hold_answer_to_next_read`]),
@@ -54,9 +57,12 @@ use parking_lot::Mutex;
use proton_drive_rs::proton_sdk::account::Quota; use proton_drive_rs::proton_sdk::account::Quota;
use proton_drive_rs::proton_sdk::api::ResponseCode; use proton_drive_rs::proton_sdk::api::ResponseCode;
use proton_drive_rs::proton_sdk::error::{ProtonApiError, ProtonError, Result}; use proton_drive_rs::proton_sdk::error::{ProtonApiError, ProtonError, Result};
use proton_drive_rs::proton_sdk::ids::{DriveEventId, LinkId, NodeUid, VolumeId}; use proton_drive_rs::proton_sdk::ids::{
DeviceUid, DriveEventId, LinkId, NodeUid, ShareId, VolumeId,
};
use proton_drive_rs::{ use proton_drive_rs::{
DriveEvent, DriveEventScopeId, Node, NodeKind, NodeMoveItem, RevisionState, Thumbnail, Device as Computer, DeviceType, DriveEvent, DriveEventScopeId, Node, NodeKind, NodeMoveItem,
RevisionState, Thumbnail,
}; };
use sha1::{Digest as _, Sha1}; use sha1::{Digest as _, Sha1};
@@ -111,6 +117,9 @@ struct Server {
block_pattern: Vec<u64>, block_pattern: Vec<u64>,
/// The account's storage limit in bytes. /// The account's storage limit in bytes.
max_space: i64, max_space: i64,
/// The account's computers, oldest first, as `(name, type, root folder)`.
/// [`Computer`] is not `Clone`, so each answer builds them afresh.
devices: Vec<(String, DeviceType, NodeUid)>,
} }
impl Server { impl Server {
@@ -162,6 +171,20 @@ impl Server {
false false
} }
/// The `n`th computer of the account, as Drive lists it.
fn computer(&self, n: usize) -> Computer {
let (name, device_type, root) = &self.devices[n];
Computer {
uid: DeviceUid::new(format!("device{}", n + 1)),
device_type: *device_type,
name: Ok(name.clone()),
root_folder_uid: root.clone(),
creation_time: self.current(root).map_or(0, |remote| remote.created),
last_sync_time: None,
share_id: ShareId::new(format!("device-share{}", n + 1)),
}
}
fn mint_uid(&mut self) -> NodeUid { fn mint_uid(&mut self) -> NodeUid {
self.next_link += 1; self.next_link += 1;
uid(&format!("L{}", self.next_link)) uid(&format!("L{}", self.next_link))
@@ -305,6 +328,7 @@ impl FakeDrive {
events: Vec::new(), events: Vec::new(),
block_pattern: vec![pdfs_core::cache::BLOCK_SIZE], block_pattern: vec![pdfs_core::cache::BLOCK_SIZE],
max_space: 1 << 40, max_space: 1 << 40,
devices: Vec::new(),
}; };
let now = now_secs(); let now = now_secs();
server.nodes.insert( server.nodes.insert(
@@ -338,6 +362,8 @@ impl FakeDrive {
hold: Mutex::new(None), hold: Mutex::new(None),
hold_answer: Mutex::new(None), hold_answer: Mutex::new(None),
hold_trash: Mutex::new(None), hold_trash: Mutex::new(None),
hold_download: Mutex::new(None),
hold_any_landing: AtomicBool::new(false),
hold_landing: Mutex::new(None), hold_landing: Mutex::new(None),
drop_after_create: AtomicBool::new(false), drop_after_create: AtomicBool::new(false),
lose_create_reply: AtomicBool::new(false), lose_create_reply: AtomicBool::new(false),
@@ -616,8 +642,13 @@ struct ClientInner {
hold_answer: Mutex<Option<Arc<HoldState>>>, hold_answer: Mutex<Option<Arc<HoldState>>>,
/// The trash [`FakeClient::hold_next_trash`] is waiting for. /// The trash [`FakeClient::hold_next_trash`] is waiting for.
hold_trash: Mutex<Option<Arc<HoldState>>>, hold_trash: Mutex<Option<Arc<HoldState>>>,
/// The download [`FakeClient::hold_next_download`] is waiting for.
hold_download: Mutex<Option<Arc<HoldState>>>,
/// The landing [`FakeClient::hold_next_landing`] is waiting for. /// The landing [`FakeClient::hold_next_landing`] is waiting for.
hold_landing: Mutex<Option<Arc<HoldState>>>, hold_landing: Mutex<Option<Arc<HoldState>>>,
/// Set by [`FakeClient::hold_next_landing_of_any`]: hold whichever node
/// lands next.
hold_any_landing: AtomicBool,
/// Set by [`FakeClient::drop_link_after_next_create`]. /// Set by [`FakeClient::drop_link_after_next_create`].
drop_after_create: AtomicBool, drop_after_create: AtomicBool,
/// Set by [`FakeClient::lose_reply_to_next_create`]. /// Set by [`FakeClient::lose_reply_to_next_create`].
@@ -755,6 +786,18 @@ impl FakeClient {
Held(state) Held(state)
} }
/// Hold the next download of `uid`'s content once Drive has answered it,
/// before the bytes reach the daemon, until the answer is dropped.
pub(crate) fn hold_next_download(&self, uid: &NodeUid) -> Held {
let state = Arc::new(HoldState {
uid: uid.clone(),
reached: AtomicBool::new(false),
released: AtomicBool::new(false),
});
*self.0.hold_download.lock() = Some(state.clone());
Held(state)
}
/// Hold the drain once the next upload to `uid` has landed and its blob /// Hold the drain once the next upload to `uid` has landed and its blob
/// is let go of, before the tree has the node (`DriveApi::landing`), until /// is let go of, before the tree has the node (`DriveApi::landing`), until
/// the answer is dropped. /// the answer is dropped.
@@ -768,6 +811,14 @@ impl FakeClient {
Held(state) Held(state)
} }
/// [`FakeClient::hold_next_landing`] for whichever node lands next, such
/// as a new file, whose uid Drive has not minted yet.
pub(crate) fn hold_next_landing_of_any(&self) -> Held {
let held = self.hold_next_landing(&uid("*"));
self.0.hold_any_landing.store(true, Ordering::SeqCst);
held
}
/// Make the next file create on Drive, then hold its answer until the /// Make the next file create on Drive, then hold its answer until the
/// answer is dropped, as a slow link does. /// answer is dropped, as a slow link does.
pub(crate) fn hold_reply_to_next_create(&self) -> Held { pub(crate) fn hold_reply_to_next_create(&self) -> Held {
@@ -1396,6 +1447,8 @@ impl DriveApi for FakeClient {
.ok_or_else(|| api(ResponseCode::DoesNotExist, "file not found")) .ok_or_else(|| api(ResponseCode::DoesNotExist, "file not found"))
}) })
.await?; .await?;
self.wait_if_held(&self.0.hold_download, std::slice::from_ref(uid))
.await;
output.write_all(&content).map_err(io_error) output.write_all(&content).map_err(io_error)
} }
@@ -1484,8 +1537,12 @@ impl DriveApi for FakeClient {
fn landing(&self, uid: &NodeUid) { fn landing(&self, uid: &NodeUid) {
let state = { let state = {
let mut hold = self.0.hold_landing.lock(); let mut hold = self.0.hold_landing.lock();
let any = self.0.hold_any_landing.load(Ordering::SeqCst);
match hold.as_ref() { match hold.as_ref() {
Some(state) if state.uid == *uid => hold.take(), Some(state) if any || state.uid == *uid => {
self.0.hold_any_landing.store(false, Ordering::SeqCst);
hold.take()
}
_ => None, _ => None,
} }
}; };
@@ -1506,6 +1563,42 @@ impl DriveApi for FakeClient {
.await .await
} }
async fn enumerate_devices(&self) -> Result<Vec<Computer>> {
self.request(false, |server| {
Ok((0..server.devices.len())
.map(|n| server.computer(n))
.collect())
})
.await
}
async fn create_device(&self, name: &str, device_type: DeviceType) -> Result<Computer> {
self.request(true, |server| {
// A device root has no parent: like on Drive, it is not in My files.
// It is listed at once, as the My files root is: the listing lag is
// about children appearing, and the daemon lists the new root next.
let root = server.mint_uid();
let now = now_secs();
server.nodes.insert(
root.clone(),
vec![(
Instant::now() - HISTORY,
Some(Remote {
parent: None,
name: name.to_owned(),
trashed: false,
created: now,
modified: now,
file: None,
}),
)],
);
server.devices.push((name.to_owned(), device_type, root));
Ok(server.computer(server.devices.len() - 1))
})
.await
}
async fn enumerate_trash_node_uids(&self) -> Result<Vec<NodeUid>> { async fn enumerate_trash_node_uids(&self) -> Result<Vec<NodeUid>> {
self.request(false, |server| { self.request(false, |server| {
Ok(server Ok(server
+1 -1
View File
@@ -1,4 +1,4 @@
//! The simulation tests of `docs/MILESTONE-3.0.0.md` §8: a fake Drive with //! 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. //! the faults the real one has, and the tools to drive daemons against it.
pub(crate) mod daemon; pub(crate) mod daemon;
+2 -2
View File
@@ -1,5 +1,5 @@
//! The reference file system of the simulation (`docs/MILESTONE-3.0.0.md` //! The reference file system of the simulation (`docs/TESTING.md`,
//! §8.2, property 4): what each syscall a simulated client makes should //! "Simulation runs", property 1): what each syscall a simulated client makes should
//! answer, and the tree it should leave, on a POSIX file system. //! 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 //! A [`Model`] covers one client's own folder; paths are relative to it. The
+1 -1
View File
@@ -1,4 +1,4 @@
//! The simulation runs of `docs/MILESTONE-3.0.0.md` §8.2. //! The simulation runs described in `docs/TESTING.md`.
//! //!
//! A run starts one or more daemons on a shared [`FakeDrive`], each owning a //! 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 //! folder `c<n>` that only it writes, so there is one writer per file. A seed
+4 -4
View File
@@ -183,7 +183,7 @@ impl WriteHandle {
} }
} }
/// A released write whose upload has not happened yet (offline.md Phase 3). /// 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 /// 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 /// `pending_op` table; this pairs them in memory so a read can be served without
@@ -253,7 +253,7 @@ pub(crate) struct State {
/// two pages shifted every later entry, and the caller silently skipped or /// two pages shifted every later entry, and the caller silently skipped or
/// repeated whatever crossed the page boundary. Each entry is /// repeated whatever crossed the page boundary. Each entry is
/// `(ino, is_dir, name)` — the kernel type is mapped at reply time so this /// `(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>>, pub(crate) dir_snapshots: HashMap<u64, Arc<DirListing>>,
/// Resident inode attributes whose effective access changed during an /// Resident inode attributes whose effective access changed during an
/// intern/root refresh. Core drains this set and notifies the matching /// intern/root refresh. Core drains this set and notifies the matching
@@ -1198,7 +1198,7 @@ impl State {
/// re-enumerate, which is the cheaper way to stay honest about what the /// 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 /// 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 /// 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( pub(crate) fn rename_in_place(
&mut self, &mut self,
ino: u64, ino: u64,
@@ -1317,7 +1317,7 @@ impl State {
/// created offline, nothing at all), so this row is the only record that the /// 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 /// 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 /// 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 /// 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: /// the one mutation here whose write-through the caller must do itself:
/// failure has to be reported, not logged. Dropping the row silently means /// failure has to be reported, not logged. Dropping the row silently means
+1 -1
View File
@@ -68,7 +68,7 @@ impl Core {
/// docs for the identical/divergent policy. /// docs for the identical/divergent policy.
pub(crate) fn run_conflict_sweep_loop(&self) { pub(crate) fn run_conflict_sweep_loop(&self) {
// Both waits are interruptible, so teardown does not have to sit out a // 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) { if !self.shutdown.sleep(CONFLICT_SWEEP_WARMUP) {
return; return;
} }
+566 -97
View File
@@ -1,4 +1,4 @@
//! Two-way folder sync engine (devices.md Phase 2). //! Two-way folder sync engine.
//! //!
//! Each `mirror`-mode synced folder is kept mirrored between a local directory //! Each `mirror`-mode synced folder is kept mirrored between a local directory
//! and a folder under this machine's Proton Drive device. A local filesystem //! and a folder under this machine's Proton Drive device. A local filesystem
@@ -50,7 +50,7 @@ mod planner;
use engine::SYNC_CONCURRENCY; use engine::SYNC_CONCURRENCY;
pub(crate) use engine::{SyncMsg, spawn}; pub(crate) use engine::{SyncMsg, spawn};
#[cfg(test)] #[cfg(test)]
use engine::{classify, settle_with}; use engine::{classify, event_target, settle_with};
#[cfg(test)] #[cfg(test)]
use local_scan::is_write_mode; use local_scan::is_write_mode;
use local_scan::open_for_write_set; use local_scan::open_for_write_set;
@@ -59,8 +59,9 @@ pub(crate) use planner::base_name;
#[cfg(test)] #[cfg(test)]
use planner::conflict_path; use planner::conflict_path;
use planner::{ use planner::{
FilePlan, classification_order, conflict_path_with_suffix, filter_baseline, guard_local_wipe, FilePlan, Unscanned, classification_order, conflict_path_with_suffix, filter_baseline,
join_rel, parent_rel, plan_file, rel_to_path, remote_sig, unchanged_remote_size, guard_local_wipe, join_rel, local_deletions, parent_rel, plan_file, rel_to_path, remote_sig,
tracked_baseline, unchanged_remote_size,
}; };
/// What a [`Pending`] op did, so the reconcile can update shared state on the /// What a [`Pending`] op did, so the reconcile can update shared state on the
/// engine thread (never inside a task). /// engine thread (never inside a task).
@@ -187,6 +188,7 @@ impl Core {
"idle" "idle"
}; };
let _ = self.db.sync_folder_set_state(folder.id, state, now_secs()); let _ = self.db.sync_folder_set_state(folder.id, state, now_secs());
self.report_unscanned(folder.id, name, &outcome.unscanned);
// Summarise a pass that actually moved something. A poll over an // Summarise a pass that actually moved something. A poll over an
// unchanged folder does nothing and says nothing — otherwise the // unchanged folder does nothing and says nothing — otherwise the
// feed would fill with "synced, 0 changes" every two minutes. // feed would fill with "synced, 0 changes" every two minutes.
@@ -243,6 +245,37 @@ impl Core {
} }
} }
/// Tell the activity feed what a pass over this folder had to leave out
/// because it cannot be read, once each time that changes. Every other path
/// in the folder still synced, so this does not make the folder's state an
/// error; but a folder the user has no permission for is never going to
/// reach Drive, and they should hear that once rather than not at all
/// (B196).
fn report_unscanned(&self, folder_id: i64, name: &str, unscanned: &[String]) {
let previous = {
let mut last = self.sync_unscanned.lock();
if unscanned.is_empty() {
last.remove(&folder_id)
} else {
last.insert(folder_id, unscanned.to_vec())
}
};
if unscanned.is_empty() || previous.as_deref() == Some(unscanned) {
return;
}
const SHOWN: usize = 3;
let mut detail = unscanned[..unscanned.len().min(SHOWN)].join(", ");
if unscanned.len() > SHOWN {
detail.push_str(&format!(" and {} more", unscanned.len() - SHOWN));
}
self.log_activity(
ActivityKind::Sync,
format!("{} item(s) in {name} cannot be synced", unscanned.len()),
detail,
false,
);
}
/// Compile the ignore rules for a synced folder: the global patterns from /// Compile the ignore rules for a synced folder: the global patterns from
/// the config, plus any `.pdfsignore` at `local_root`. /// the config, plus any `.pdfsignore` at `local_root`.
/// ///
@@ -250,7 +283,7 @@ impl Core {
/// than to "ignore nothing" — the defaults exclude build and VCS trees, and /// than to "ignore nothing" — the defaults exclude build and VCS trees, and
/// silently uploading those because a config read failed is the outcome this /// silently uploading those because a config read failed is the outcome this
/// feature exists to prevent. /// feature exists to prevent.
fn ignore_rules(&self, local_root: &Path) -> IgnoreRules { pub(crate) fn ignore_rules(&self, local_root: &Path) -> IgnoreRules {
let globals = match pdfs_core::config::AppDirs::new() { let globals = match pdfs_core::config::AppDirs::new() {
Ok(dirs) => dirs.load_config().resolved_ignore_patterns(), Ok(dirs) => dirs.load_config().resolved_ignore_patterns(),
Err(e) => { Err(e) => {
@@ -313,15 +346,23 @@ impl Core {
self.progress_scan_total(folder_id, baseline.len()); self.progress_scan_total(folder_id, baseline.len());
let mut local: HashMap<String, LocalItem> = HashMap::new(); let mut local: HashMap<String, LocalItem> = HashMap::new();
let mut unscanned = Unscanned::default();
let writing = open_for_write_set(local_root); let writing = open_for_write_set(local_root);
self.walk_local( self.walk_local(
folder_id, local_root, local_root, rules, &writing, &mut local, folder_id,
local_root,
rules,
&writing,
&mut local,
&mut unscanned,
)?; )?;
// The guard compares against the paths this pass could still see: an // The guard compares against the paths this pass could still see: an
// ignored path is absent from `local` by rule, not by loss, and counting // ignored or unreadable path is absent from `local` by rule or by
// those as missing would trip the guard on every pass — wedging the // failure, not by loss, and counting those as missing would trip the
// folder's sync permanently — the moment a rule covers the whole tree. // guard on every pass — wedging the folder's sync permanently — the
guard_local_wipe(&filter_baseline(&baseline, rules), &local)?; // moment a rule covers the whole tree.
let tracked = tracked_baseline(&baseline, rules, &unscanned);
guard_local_wipe(&tracked, &local)?;
// The remote folder uids come from the baseline instead of a walk: every // The remote folder uids come from the baseline instead of a walk: every
// directory this device has synced recorded its uid there when it was // directory this device has synced recorded its uid there when it was
@@ -391,6 +432,7 @@ impl Core {
let mut outcome = Outcome { let mut outcome = Outcome {
deferred: local.values().filter(|item| item.open_for_write).count(), deferred: local.values().filter(|item| item.open_for_write).count(),
unscanned: unscanned.report(),
..Default::default() ..Default::default()
}; };
let mut order: Vec<&String> = local.keys().collect(); let mut order: Vec<&String> = local.keys().collect();
@@ -515,13 +557,8 @@ impl Core {
// showing the folder's remote contents — otherwise everything they deleted // showing the folder's remote contents — otherwise everything they deleted
// comes back the moment the switch lands. Shallowest first, skipping anything // comes back the moment the switch lands. Shallowest first, skipping anything
// under a folder already trashed (its children went with it). // under a folder already trashed (its children went with it).
let mut missing: Vec<&String> = baseline
.keys()
.filter(|rel| !local.contains_key(*rel))
.collect();
missing.sort_by_key(|p| p.matches('/').count());
let mut trashed: Vec<String> = Vec::new(); let mut trashed: Vec<String> = Vec::new();
for rel in missing { for rel in &local_deletions(&tracked, &local) {
if trashed if trashed
.iter() .iter()
.any(|dir| rel.starts_with(&format!("{dir}/"))) .any(|dir| rel.starts_with(&format!("{dir}/")))
@@ -586,12 +623,18 @@ impl Core {
self.progress_scan_total(folder_id, baseline.len() * 2); self.progress_scan_total(folder_id, baseline.len() * 2);
let mut local: HashMap<String, LocalItem> = HashMap::new(); let mut local: HashMap<String, LocalItem> = HashMap::new();
let mut unscanned = Unscanned::default();
let writing = open_for_write_set(local_root); let writing = open_for_write_set(local_root);
self.walk_local( self.walk_local(
folder_id, local_root, local_root, rules, &writing, &mut local, folder_id,
local_root,
rules,
&writing,
&mut local,
&mut unscanned,
)?; )?;
// See `push_pass`: the guard must not count rule-excluded paths as lost. // See `push_pass`: the guard must not count excluded paths as lost.
guard_local_wipe(&filter_baseline(&baseline, rules), &local)?; guard_local_wipe(&tracked_baseline(&baseline, rules, &unscanned), &local)?;
let mut remote: HashMap<String, RemoteItem> = HashMap::new(); let mut remote: HashMap<String, RemoteItem> = HashMap::new();
let mut remote_dirs: HashMap<String, NodeUid> = HashMap::new(); let mut remote_dirs: HashMap<String, NodeUid> = HashMap::new();
@@ -605,9 +648,15 @@ impl Core {
&baseline, &baseline,
)?; )?;
let order = classification_order(&local, &remote, &baseline, rules); let mut order = classification_order(&local, &remote, &baseline, rules);
// A path the scan could not read is no more a local deletion than an
// ignored one, and a remote path under it has nowhere to be written.
order.retain(|rel| !unscanned.covers(rel));
let mut outcome = Outcome::default(); let mut outcome = Outcome {
unscanned: unscanned.report(),
..Default::default()
};
// Folders to delete, collected here and removed deepest-first at the end // Folders to delete, collected here and removed deepest-first at the end
// so a parent is never removed before its children. // so a parent is never removed before its children.
let mut delete_local_dirs: Vec<String> = Vec::new(); let mut delete_local_dirs: Vec<String> = Vec::new();
@@ -873,19 +922,19 @@ impl Core {
Ok(outcome) Ok(outcome)
} }
/// Recursively walk a local directory into `out`, keyed by `/`-joined relative /// Recursively walk a local tree into `out`, keyed by `/`-joined relative
/// path. Symlinks and other special files are skipped. Reports each entry to the /// path. Symlinks and other special files are skipped, and paths that cannot
/// pass's scan progress. /// be read go to `unscanned`. Reports each entry to the pass's scan progress.
fn walk_local( fn walk_local(
&self, &self,
folder_id: i64, folder_id: i64,
root: &Path, root: &Path,
dir: &Path,
rules: &IgnoreRules, rules: &IgnoreRules,
writing: &HashSet<PathBuf>, writing: &HashSet<PathBuf>,
out: &mut HashMap<String, LocalItem>, out: &mut HashMap<String, LocalItem>,
unscanned: &mut Unscanned,
) -> Result<(), String> { ) -> Result<(), String> {
walk_local_tree(root, dir, rules, writing, out, &mut |rel| { walk_local_tree(root, rules, writing, out, unscanned, &mut |rel| {
self.progress_scanned(folder_id, base_name(rel)) self.progress_scanned(folder_id, base_name(rel))
}) })
} }
@@ -1342,7 +1391,7 @@ impl Core {
/// rename below is the destructive step, and the download in between can run /// rename below is the destructive step, and the download in between can run
/// for minutes, so the destination is re-examined right before it: a local /// for minutes, so the destination is re-examined right before it: a local
/// file that no longer matches the plan is set aside as a conflict copy /// file that no longer matches the plan is set aside as a conflict copy
/// instead of being replaced (bugs.md B39). /// instead of being replaced (`docs/BUGS.md` B39).
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
async fn download_file( async fn download_file(
&self, &self,
@@ -1441,7 +1490,7 @@ impl Core {
/// the pre-upload identity is what makes the next pass see the path as /// the pre-upload identity is what makes the next pass see the path as
/// locally changed and upload a whole, clean revision over it. Recording the /// locally changed and upload a whole, clean revision over it. Recording the
/// post-upload stat instead declared the torn revision synchronized and left /// post-upload stat instead declared the torn revision synchronized and left
/// it as the file's content on Drive (bugs.md B40). /// it as the file's content on Drive (`docs/BUGS.md` B40).
async fn record_file_baseline( async fn record_file_baseline(
&self, &self,
folder_id: i64, folder_id: i64,
@@ -1505,7 +1554,7 @@ fn open_to_upload(path: &Path, rel: &str) -> Result<Option<std::fs::File>, Strin
/// records the identity the upload *read* rather than the one on disk now, so /// records the identity the upload *read* rather than the one on disk now, so
/// the next pass classifies the path as locally changed and replaces the torn /// the next pass classifies the path as locally changed and replaces the torn
/// revision. Without the log there is nothing in the journal to explain the /// revision. Without the log there is nothing in the journal to explain the
/// extra revision (bugs.md B40). /// extra revision (`docs/BUGS.md` B40).
fn warn_if_torn(rel: &str, path: &Path, streamed: LocalSig) { fn warn_if_torn(rel: &str, path: &Path, streamed: LocalSig) {
let now = local_sig(path); let now = local_sig(path);
if !now.is_some_and(|now| now.same_content(&streamed)) { if !now.is_some_and(|now| now.same_content(&streamed)) {
@@ -1537,7 +1586,7 @@ fn local_sig(path: &Path) -> Option<LocalSig> {
/// transfer started, and a download of a large file can run for minutes. Without /// transfer started, and a download of a large file can run for minutes. Without
/// this, a local edit completed inside that window was replaced by the remote /// this, a local edit completed inside that window was replaced by the remote
/// version with no copy of it kept anywhere — the one shape of loss a sync /// version with no copy of it kept anywhere — the one shape of loss a sync
/// engine must not have (bugs.md B39). Keeping the copy makes it re-upload as a /// engine must not have (`docs/BUGS.md` B39). Keeping the copy makes it re-upload as a
/// new file on the next pass, which is the same resolution a both-sides-changed /// new file on the next pass, which is the same resolution a both-sides-changed
/// conflict already gets. /// conflict already gets.
/// ///
@@ -1573,7 +1622,7 @@ fn keep_racing_local_edit(path: &Path, expected: Option<LocalSig>) -> std::io::R
/// exists, so the same removal was re-attempted and re-reported as an error on /// exists, so the same removal was re-attempted and re-reported as an error on
/// every pass, forever. Every other error is still an error, and keeping the /// every pass, forever. Every other error is still an error, and keeping the
/// baseline is exactly right there: the surviving file must not be mistaken for /// baseline is exactly right there: the surviving file must not be mistaken for
/// a new local creation on the next pass and uploaded back (bugs.md B42). /// a new local creation on the next pass and uploaded back (`docs/BUGS.md` B42).
fn removed_locally(result: std::io::Result<()>) -> std::io::Result<()> { fn removed_locally(result: std::io::Result<()>) -> std::io::Result<()> {
match result { match result {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
@@ -1582,51 +1631,120 @@ fn removed_locally(result: std::io::Result<()>) -> std::io::Result<()> {
} }
/// The body of [`Core::walk_local`], without a `Core`: recursively collect the /// The body of [`Core::walk_local`], without a `Core`: recursively collect the
/// local tree under `dir` into `out`, reporting each entry to `scanned`. /// local tree under `root` into `out`, reporting each entry to `scanned`.
/// ///
/// **Every failure here is fatal to the pass, deliberately.** A `read_dir` that /// **A path the scan cannot read is left out and recorded in `unscanned`, never
/// fails, an entry that cannot be stat-ed, a name that is not UTF-8 — each of /// silently dropped.** Every pass drops what `unscanned` covers from
/// them would, if skipped, leave `out` describing a *smaller* tree than the one /// classification, so a baseline row under it is neither a local deletion nor a
/// on disk. Reconciliation reads a path present in the baseline and absent from /// download target (`docs/BUGS.md` B55). That covers a subfolder the pass has no
/// the scan as "the user deleted this", and propagates that to Drive. So an /// permission for, an entry that vanished between the listing and its `stat`,
/// unreadable subdirectory would trash its remote counterpart, and the deeper /// and a name that is not UTF-8. Failing the whole pass on any of them, as this
/// the failure, the more it takes with it (`docs/BUGS.md` B55). Refusing the /// did before, left one root-owned folder stopping everything else in the tree
/// whole pass keeps the previous baseline and leaves the remote alone; the next /// from syncing, on every pass, for good (B196).
/// pass retries once the transient condition clears. ///
/// The root is the exception, and so is any other I/O error: an absent or
/// unreadable root fails the pass, since leaving the root out would leave
/// nothing in, and an error this cannot name a cause for is not one to guess
/// about.
/// ///
/// Split out from the method so exactly that can be tested against a real tree /// Split out from the method so exactly that can be tested against a real tree
/// with a real unreadable directory in it, which needs no `Core` at all. /// with a real unreadable directory in it, which needs no `Core` at all.
fn walk_local_tree( fn walk_local_tree(
root: &Path,
rules: &IgnoreRules,
writing: &HashSet<PathBuf>,
out: &mut HashMap<String, LocalItem>,
unscanned: &mut Unscanned,
scanned: &mut dyn FnMut(&str),
) -> Result<(), String> {
walk_local_dir(root, root, rules, writing, out, unscanned, scanned).map_err(|e| e.to_string())
}
/// Why [`walk_local_dir`] could not finish a folder.
enum ScanError {
/// An I/O error on `what`, which the caller may leave out of the pass.
Io { what: String, error: std::io::Error },
/// Anything else, which fails the pass.
Failed(String),
}
impl std::fmt::Display for ScanError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ScanError::Io { what, error } => write!(f, "{what}: {error}"),
ScanError::Failed(message) => f.write_str(message),
}
}
}
/// Leave `rel` out of the pass for `failure`, or hand it back when it is one
/// that fails the pass. Permission denied is reported to the user; an entry
/// that vanished mid-scan is not, since the next pass sees it settled.
fn leave_out(rel: &str, failure: ScanError, unscanned: &mut Unscanned) -> Result<(), ScanError> {
let ScanError::Io { what, error } = &failure else {
return Err(failure);
};
match error.kind() {
std::io::ErrorKind::PermissionDenied => {
warn!(%what, %error, "sync: cannot read; leaving it out of the pass");
unscanned.unreadable(rel.to_string(), &error.to_string());
Ok(())
}
std::io::ErrorKind::NotFound => {
debug!(%what, "sync: gone mid-scan; leaving it out of the pass");
unscanned.vanished(rel.to_string());
Ok(())
}
_ => Err(failure),
}
}
fn walk_local_dir(
root: &Path, root: &Path,
dir: &Path, dir: &Path,
rules: &IgnoreRules, rules: &IgnoreRules,
writing: &HashSet<PathBuf>, writing: &HashSet<PathBuf>,
out: &mut HashMap<String, LocalItem>, out: &mut HashMap<String, LocalItem>,
unscanned: &mut Unscanned,
scanned: &mut dyn FnMut(&str), scanned: &mut dyn FnMut(&str),
) -> Result<(), String> { ) -> Result<(), ScanError> {
let entries = std::fs::read_dir(dir).map_err(|e| format!("read {}: {e}", dir.display()))?; let io = |what: String| move |error| ScanError::Io { what, error };
let entries = std::fs::read_dir(dir).map_err(io(format!("read {}", dir.display())))?;
for entry in entries { for entry in entries {
let entry = entry.map_err(|e| format!("read entry in {}: {e}", dir.display()))?; let entry = entry.map_err(io(format!("read entry in {}", dir.display())))?;
let path = entry.path(); let path = entry.path();
let meta = std::fs::symlink_metadata(&path)
.map_err(|e| format!("stat {}: {e}", path.display()))?;
let stripped = path.strip_prefix(root).map_err(|e| { let stripped = path.strip_prefix(root).map_err(|e| {
format!( ScanError::Failed(format!(
"local path {} escaped sync root {}: {e}", "local path {} escaped sync root {}: {e}",
path.display(), path.display(),
root.display() root.display()
) ))
})?;
let rel = stripped.to_str().ok_or_else(|| {
format!(
"local path {} is not valid UTF-8; refusing a destructive sync pass",
path.display()
)
})?; })?;
let Some(rel) = stripped.to_str() else {
// Nothing can be keyed by this name: not the baseline, which only
// ever held paths this scan produced, and not Drive, whose names are
// UTF-8. Leaving it out cannot read as a deletion of anything.
warn!(path = %path.display(), "sync: name is not valid UTF-8; leaving it out of the pass");
unscanned.unreadable(
stripped.to_string_lossy().into_owned(),
"name is not valid UTF-8",
);
continue;
};
// Ignore our own in-flight download temp files. // Ignore our own in-flight download temp files.
if rel.contains(".pdfs-tmp-") { if rel.contains(".pdfs-tmp-") {
continue; continue;
} }
let meta = match std::fs::symlink_metadata(&path) {
Ok(meta) => meta,
// Gone since the listing, or in a folder that lists but does not
// allow `stat`. Either way this pass cannot say what it is.
Err(error) => {
let what = format!("stat {}", path.display());
leave_out(rel, ScanError::Io { what, error }, unscanned)?;
continue;
}
};
// Ignored paths are dropped here as well as from the classification // Ignored paths are dropped here as well as from the classification
// union — not for correctness (the union filter is what makes this // union — not for correctness (the union filter is what makes this
// safe) but so an ignored `node_modules/` is never descended into or // safe) but so an ignored `node_modules/` is never descended into or
@@ -1646,7 +1764,15 @@ fn walk_local_tree(
}, },
); );
scanned(rel); scanned(rel);
walk_local_tree(root, &path, rules, writing, out, scanned)?; if let Err(failure) =
walk_local_dir(root, &path, rules, writing, out, unscanned, scanned)
{
leave_out(rel, failure, unscanned)?;
// What the folder did list before failing goes with it: a
// half-read folder is as unknown as an unread one.
let prefix = format!("{rel}/");
out.retain(|k, _| k != rel && !k.starts_with(&prefix));
}
} else if meta.is_file() { } else if meta.is_file() {
// Check whether any process holds this file open for writing. // Check whether any process holds this file open for writing.
// The canonical path is used because /proc/*/fd links resolve // The canonical path is used because /proc/*/fd links resolve
@@ -1684,7 +1810,7 @@ pub(crate) fn mirror_subtree_unsynced(
baseline: &HashMap<String, StoredSyncEntry>, baseline: &HashMap<String, StoredSyncEntry>,
) -> Option<String> { ) -> Option<String> {
let mut seen = HashSet::new(); let mut seen = HashSet::new();
if let Some(reason) = unsynced_local(root, rel, baseline, &mut seen) { if let Some(reason) = unsynced_local(root, rel, baseline, None, &mut seen) {
return Some(reason); return Some(reason);
} }
let prefix = format!("{rel}/"); let prefix = format!("{rel}/");
@@ -1695,18 +1821,64 @@ pub(crate) fn mirror_subtree_unsynced(
.map(|r| format!("{r} was deleted here but not on Proton Drive yet")) .map(|r| format!("{r} was deleted here but not on Proton Drive yet"))
} }
/// [`mirror_subtree_unsynced`] for a whole mirror folder: why its local copy
/// holds something Proton Drive does not, or `None` when evicting it loses
/// nothing. The switch to on-demand asks this before it empties the folder.
///
/// A push pass that ends `idle` is not enough on its own. It never uploads 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 finishes; the eviction deleted
/// all of those for good (B198).
///
/// A tracked path deleted here but still in the baseline counts, since the
/// mount would show it again. An ignored one does not: ignoring never carries
/// a deletion to Drive, so its row stays in the baseline for good, and holding
/// the switch on it would hold it forever.
pub(crate) fn mirror_tree_unsynced(
root: &Path,
baseline: &HashMap<String, StoredSyncEntry>,
rules: &IgnoreRules,
) -> Option<String> {
let mut seen = HashSet::new();
if let Some(reason) = unsynced_local(root, "", baseline, Some(rules), &mut seen) {
return Some(reason);
}
let mut missing: Vec<String> = filter_baseline(baseline, rules)
.into_keys()
.filter(|r| !seen.contains(r.as_str()))
.collect();
missing.sort_unstable();
missing
.first()
.map(|r| format!("{r} was deleted here but not on Proton Drive yet"))
}
/// The walk behind both checks. `rules`, when given, only names an ignored
/// path as such in the reason; it is still something only this disk has.
fn unsynced_local( fn unsynced_local(
root: &Path, root: &Path,
rel: &str, rel: &str,
baseline: &HashMap<String, StoredSyncEntry>, baseline: &HashMap<String, StoredSyncEntry>,
rules: Option<&IgnoreRules>,
seen: &mut HashSet<String>, seen: &mut HashSet<String>,
) -> Option<String> { ) -> Option<String> {
let path = root.join(rel_to_path(rel)); let path = if rel.is_empty() {
root.to_path_buf()
} else {
root.join(rel_to_path(rel))
};
// The folder itself has no baseline row; only what is in it does.
if !rel.is_empty() {
let meta = match std::fs::symlink_metadata(&path) { let meta = match std::fs::symlink_metadata(&path) {
Ok(meta) => meta, Ok(meta) => meta,
Err(e) => return Some(format!("cannot read {rel}: {e}")), Err(e) => return Some(format!("cannot read {rel}: {e}")),
}; };
let Some(base) = baseline.get(rel) else { let Some(base) = baseline.get(rel) else {
if rules.is_some_and(|rules| rules.is_ignored(rel, meta.is_dir())) {
return Some(format!(
"{rel} is excluded from sync, so it is only on this computer"
));
}
return Some(format!("{rel} is not on Proton Drive yet")); return Some(format!("{rel} is not on Proton Drive yet"));
}; };
seen.insert(rel.to_string()); seen.insert(rel.to_string());
@@ -1717,23 +1889,25 @@ fn unsynced_local(
if !meta.is_dir() { if !meta.is_dir() {
return Some(format!("{rel} is not a regular file or folder")); return Some(format!("{rel} is not a regular file or folder"));
} }
}
let shown = if rel.is_empty() { "the folder" } else { rel };
let entries = match std::fs::read_dir(&path) { let entries = match std::fs::read_dir(&path) {
Ok(entries) => entries, Ok(entries) => entries,
Err(e) => return Some(format!("cannot read {rel}: {e}")), Err(e) => return Some(format!("cannot read {shown}: {e}")),
}; };
for entry in entries { for entry in entries {
let name = match entry { let name = match entry {
Ok(entry) => entry.file_name(), Ok(entry) => entry.file_name(),
Err(e) => return Some(format!("cannot read {rel}: {e}")), Err(e) => return Some(format!("cannot read {shown}: {e}")),
}; };
let Some(name) = name.to_str() else { let Some(name) = name.to_str() else {
return Some(format!("{rel} holds a name that is not valid UTF-8")); return Some(format!("{shown} holds a name that is not valid UTF-8"));
}; };
// A download the engine left half done; it is the engine's to clean up. // A download the engine left half done; it is the engine's to clean up.
if name.contains(".pdfs-tmp-") { if name.contains(".pdfs-tmp-") {
continue; continue;
} }
if let Some(reason) = unsynced_local(root, &format!("{rel}/{name}"), baseline, seen) { if let Some(reason) = unsynced_local(root, &join_rel(rel, name), baseline, rules, seen) {
return Some(reason); return Some(reason);
} }
} }
@@ -1830,7 +2004,7 @@ fn system_mtime(meta: &std::fs::Metadata) -> i64 {
/// ///
/// Whole seconds cannot distinguish an edit made in the same second as the last /// Whole seconds cannot distinguish an edit made in the same second as the last
/// sync from no edit at all, and if the size did not change either, that file /// sync from no edit at all, and if the size did not change either, that file
/// was never uploaded (bugs.md B25). `None` for a clock or filesystem that /// was never uploaded (`docs/BUGS.md` B25). `None` for a clock or filesystem that
/// cannot answer, which falls the comparison back to seconds rather than /// cannot answer, which falls the comparison back to seconds rather than
/// asserting an mtime of zero. /// asserting an mtime of zero.
fn system_mtime_ns(meta: &std::fs::Metadata) -> Option<i64> { fn system_mtime_ns(meta: &std::fs::Metadata) -> Option<i64> {
@@ -1845,7 +2019,7 @@ mod tests {
/// A removal of something already gone has produced the state the sync /// A removal of something already gone has produced the state the sync
/// engine wanted, so its baseline is dropped rather than retried forever. /// engine wanted, so its baseline is dropped rather than retried forever.
/// Anything else stays an error and keeps the baseline (bugs.md B42). /// Anything else stays an error and keeps the baseline (`docs/BUGS.md` B42).
#[test] #[test]
fn an_already_absent_path_counts_as_removed() { fn an_already_absent_path_counts_as_removed() {
let root = std::env::temp_dir().join(format!("pdfs-b42-{}", std::process::id())); let root = std::env::temp_dir().join(format!("pdfs-b42-{}", std::process::id()));
@@ -1862,7 +2036,7 @@ mod tests {
} }
/// The download guard: a destination that still matches the plan is /// The download guard: a destination that still matches the plan is
/// replaced silently, one that has moved on since is kept (bugs.md B39). /// replaced silently, one that has moved on since is kept (`docs/BUGS.md` B39).
#[test] #[test]
fn a_download_keeps_a_local_edit_it_did_not_plan_for() { fn a_download_keeps_a_local_edit_it_did_not_plan_for() {
let root = std::env::temp_dir().join(format!("pdfs-b39-{}", std::process::id())); let root = std::env::temp_dir().join(format!("pdfs-b39-{}", std::process::id()));
@@ -1923,6 +2097,32 @@ mod tests {
(ids, started.elapsed()) (ids, started.elapsed())
} }
#[test]
fn a_change_in_one_mirror_folder_reconciles_only_that_folder() {
let watched = vec![
(PathBuf::from("/home/me/Docs"), 1),
(PathBuf::from("/home/me/Photos"), 2),
(PathBuf::from("/home/me/Photos/Raw"), 3),
];
let target = |paths: &[&str]| {
let paths: Vec<PathBuf> = paths.iter().map(PathBuf::from).collect();
match event_target(&watched, &paths) {
SyncMsg::Reconcile(id) => Some(id),
SyncMsg::ReconcileAll => None,
_ => panic!("unexpected message"),
}
};
assert_eq!(target(&["/home/me/Docs/a.txt"]), Some(1));
// A rename within one folder.
assert_eq!(target(&["/home/me/Docs/a", "/home/me/Docs/b"]), Some(1));
// The deepest root wins.
assert_eq!(target(&["/home/me/Photos/Raw/x.cr2"]), Some(3));
// Across folders, outside them all, or no path at all: walk everything.
assert_eq!(target(&["/home/me/Docs/a", "/home/me/Photos/a"]), None);
assert_eq!(target(&["/home/me/Docsx/a"]), None);
assert_eq!(target(&[]), None);
}
/// The bug this replaced a fixed sleep for: a save that keeps writing past /// The bug this replaced a fixed sleep for: a save that keeps writing past
/// the debounce used to be walked mid-write, uploading a torn snapshot as a /// the debounce used to be walked mid-write, uploading a torn snapshot as a
/// real revision. The window has to restart on every event, so settling /// real revision. The window has to restart on every event, so settling
@@ -2154,7 +2354,15 @@ mod tests {
let writing = HashSet::new(); let writing = HashSet::new();
let mut local: HashMap<String, LocalItem> = HashMap::new(); let mut local: HashMap<String, LocalItem> = HashMap::new();
let mut progress = no_progress(); let mut progress = no_progress();
walk_local_tree(&root, &root, &rules, &writing, &mut local, &mut progress).unwrap(); walk_local_tree(
&root,
&rules,
&writing,
&mut local,
&mut Unscanned::default(),
&mut progress,
)
.unwrap();
assert!(local.is_empty(), "the replacement root is genuinely empty"); assert!(local.is_empty(), "the replacement root is genuinely empty");
for size in 1..=3 { for size in 1..=3 {
@@ -2170,14 +2378,22 @@ mod tests {
// remounted folder resumes rather than staying wedged. // remounted folder resumes rather than staying wedged.
std::fs::write(root.join("file0.txt"), b"back").unwrap(); std::fs::write(root.join("file0.txt"), b"back").unwrap();
let mut local: HashMap<String, LocalItem> = HashMap::new(); let mut local: HashMap<String, LocalItem> = HashMap::new();
walk_local_tree(&root, &root, &rules, &writing, &mut local, &mut progress).unwrap(); walk_local_tree(
&root,
&rules,
&writing,
&mut local,
&mut Unscanned::default(),
&mut progress,
)
.unwrap();
let baseline: HashMap<String, ()> = [("file0.txt".to_string(), ())].into_iter().collect(); let baseline: HashMap<String, ()> = [("file0.txt".to_string(), ())].into_iter().collect();
assert!(guard_local_wipe(&filter_baseline(&baseline, &rules), &local).is_ok()); assert!(guard_local_wipe(&filter_baseline(&baseline, &rules), &local).is_ok());
std::fs::remove_dir_all(&root).unwrap(); std::fs::remove_dir_all(&root).unwrap();
} }
/// A restored folder (features.md 5.2) starts with an empty baseline against /// A restored folder starts with an empty baseline against
/// an empty local directory and a full remote. That must reconcile as /// an empty local directory and a full remote. That must reconcile as
/// "download everything" rather than tripping the wipe guard — otherwise /// "download everything" rather than tripping the wipe guard — otherwise
/// every restore would wedge its folder on the first pass. /// every restore would wedge its folder on the first pass.
@@ -2475,33 +2691,176 @@ mod tests {
|_: &str| {} |_: &str| {}
} }
/// A directory the pass cannot read must fail the pass. Omitting it would /// Scan `root` with no rules, returning what it found and left out.
/// hand reconciliation a baseline-minus-subtree, which it reads as the user fn scan(root: &Path) -> Result<(HashMap<String, LocalItem>, Unscanned), String> {
/// having deleted that subtree and propagates to Drive (B55). let mut out = HashMap::new();
let mut unscanned = Unscanned::default();
let mut progress = no_progress();
walk_local_tree(
root,
&rules_for(&[]),
&HashSet::new(),
&mut out,
&mut unscanned,
&mut progress,
)?;
Ok((out, unscanned))
}
fn sorted_keys<V>(map: &HashMap<String, V>) -> Vec<&str> {
let mut keys: Vec<&str> = map.keys().map(String::as_str).collect();
keys.sort_unstable();
keys
}
/// B196, the report in #33: a folder the user may not read, such as a
/// container's root-owned cache, is left out of the pass and named, and
/// everything around it still scans. It used to fail the whole pass, so
/// nothing else in the tree ever synced again.
#[test] #[test]
fn an_unreadable_subdirectory_fails_the_scan_instead_of_shrinking_it() { fn an_unreadable_subdirectory_is_left_out_and_the_rest_scans() {
use std::os::unix::fs::PermissionsExt as _; use std::os::unix::fs::PermissionsExt as _;
let root = sync_test_dir("scan-eacces"); let root = sync_test_dir("scan-eacces");
std::fs::write(root.join("keep.txt"), b"a").unwrap(); std::fs::write(root.join("keep.txt"), b"a").unwrap();
let locked = root.join("locked"); std::fs::create_dir_all(root.join("docker/cache")).unwrap();
std::fs::write(root.join("docker/compose.yml"), b"c").unwrap();
let locked = root.join("docker/cache/locked");
std::fs::create_dir(&locked).unwrap(); std::fs::create_dir(&locked).unwrap();
std::fs::write(locked.join("inner.txt"), b"b").unwrap(); std::fs::write(locked.join("inner.txt"), b"b").unwrap();
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap(); std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
let rules = rules_for(&[]); let result = scan(&root);
let writing = HashSet::new();
let mut out = HashMap::new();
let mut progress = no_progress();
let result = walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut progress);
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o700)).unwrap(); std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o700)).unwrap();
std::fs::remove_dir_all(&root).unwrap(); std::fs::remove_dir_all(&root).unwrap();
let error = result.expect_err("an unreadable directory must abort the pass"); let (out, unscanned) = result.expect("one unreadable folder must not fail the pass");
assert!( assert_eq!(
error.contains("locked"), sorted_keys(&out),
"the error should name the subtree it could not read: {error}" vec!["docker", "docker/cache", "docker/compose.yml", "keep.txt"]
);
assert!(unscanned.covers("docker/cache/locked"));
assert!(unscanned.covers("docker/cache/locked/inner.txt"));
assert!(!unscanned.covers("docker/cache"));
let report = unscanned.report();
assert_eq!(report.len(), 1);
assert!(report[0].starts_with("docker/cache/locked ("), "{report:?}");
}
/// A folder with read but no search permission lists its names but cannot
/// `stat` them. Whatever it did list must go with it: a half-read folder
/// is as unknown as an unread one.
#[test]
fn a_listable_but_unsearchable_folder_is_left_out_whole() {
use std::os::unix::fs::PermissionsExt as _;
let root = sync_test_dir("scan-no-search");
std::fs::write(root.join("keep.txt"), b"a").unwrap();
let half = root.join("half");
std::fs::create_dir(&half).unwrap();
std::fs::write(half.join("one.txt"), b"1").unwrap();
std::fs::write(half.join("two.txt"), b"2").unwrap();
std::fs::set_permissions(&half, std::fs::Permissions::from_mode(0o400)).unwrap();
let result = scan(&root);
std::fs::set_permissions(&half, std::fs::Permissions::from_mode(0o700)).unwrap();
std::fs::remove_dir_all(&root).unwrap();
let (out, unscanned) = result.unwrap();
// Each entry fails its own `stat` and is left out by name.
assert_eq!(sorted_keys(&out), vec!["half", "keep.txt"]);
assert!(unscanned.covers("half/one.txt"));
assert!(unscanned.covers("half/two.txt"));
}
/// The other half of B196: what was left out is not a deletion, and not a
/// download target either. Its baseline rows leave the wipe guard's set
/// and a push pass's deletions, and its paths leave classification.
#[test]
fn a_path_the_scan_left_out_is_never_a_local_deletion() {
let mut baseline = baseline_with("keep.txt", 1, 1);
baseline.extend(baseline_with("locked", 0, 0));
baseline.extend(baseline_with("locked/inner.txt", 1, 1));
baseline.extend(baseline_with("gone.txt", 1, 1));
let local: HashMap<String, LocalItem> = [("keep.txt".to_string(), local_item(false))]
.into_iter()
.collect();
let mut unscanned = Unscanned::default();
unscanned.unreadable("locked".to_string(), "Permission denied");
let rules = rules_for(&[]);
let tracked = tracked_baseline(&baseline, &rules, &unscanned);
assert_eq!(sorted_keys(&tracked), vec!["gone.txt", "keep.txt"]);
assert_eq!(local_deletions(&tracked, &local), vec!["gone.txt"]);
// Every tracked path but one is gone, and the guard still lets the
// pass run: one survivor proves the tree is there.
assert!(guard_local_wipe(&tracked, &local).is_ok());
// With everything readable left out, nothing is tracked, so nothing
// can be lost and the guard has nothing to object to.
let mut all = Unscanned::default();
all.unreadable("keep.txt".to_string(), "Permission denied");
all.unreadable("locked".to_string(), "Permission denied");
all.vanished("gone.txt".to_string());
assert!(tracked_baseline(&baseline, &rules, &all).is_empty());
let remote: HashMap<String, RemoteItem> = HashMap::new();
let mut order = classification_order(&local, &remote, &baseline, &rules);
order.retain(|rel| !unscanned.covers(rel));
order.sort_unstable();
assert_eq!(order, vec!["gone.txt", "keep.txt"]);
}
#[test]
fn unscanned_covers_a_path_and_what_is_under_it_but_not_its_siblings() {
let mut unscanned = Unscanned::default();
unscanned.unreadable("a/locked".to_string(), "Permission denied");
unscanned.vanished("b.txt".to_string());
assert!(unscanned.covers("a/locked"));
assert!(unscanned.covers("a/locked/x/y.txt"));
assert!(unscanned.covers("b.txt"));
assert!(!unscanned.covers("a"));
assert!(!unscanned.covers("a/locked2"));
assert!(!unscanned.covers("b.txt.bak"));
// A file that only vanished mid-scan is not worth telling anyone about.
assert_eq!(unscanned.report(), vec!["a/locked (Permission denied)"]);
}
/// B197: a push pass read its deletions from the raw baseline, so a path
/// synced before it became ignored, which the walk skips, was trashed on
/// Drive, and the switch the push pass ran for then evicted the local copy.
#[test]
fn a_push_pass_never_reads_an_ignored_path_as_deleted() {
let mut baseline = baseline_with("notes.md", 1, 1);
baseline.extend(baseline_with("node_modules", 0, 0));
baseline.extend(baseline_with("node_modules/x/index.js", 1, 1));
baseline.extend(baseline_with("old.md", 1, 1));
let local: HashMap<String, LocalItem> = [("notes.md".to_string(), local_item(false))]
.into_iter()
.collect();
let rules = rules_for(&["node_modules/"]);
let tracked = tracked_baseline(&baseline, &rules, &Unscanned::default());
assert_eq!(local_deletions(&tracked, &local), vec!["old.md"]);
}
/// Deletions come out shallowest first, so a trashed folder can take its
/// children with it.
#[test]
fn local_deletions_come_out_shallowest_first() {
let mut baseline = baseline_with("d/e/f.txt", 1, 1);
baseline.extend(baseline_with("d", 0, 0));
baseline.extend(baseline_with("d/e", 0, 0));
let local: HashMap<String, LocalItem> = HashMap::new();
let tracked = tracked_baseline(&baseline, &rules_for(&[]), &Unscanned::default());
assert_eq!(
local_deletions(&tracked, &local),
vec!["d", "d/e", "d/e/f.txt"]
); );
} }
@@ -2593,6 +2952,94 @@ mod tests {
assert_eq!(reason, None); assert_eq!(reason, None);
} }
// ---- B198: going online-only never evicts what Drive does not have -----
#[test]
fn a_fully_synced_mirror_folder_may_go_online_only() {
let (root, baseline) = synced_subtree("evict-synced");
let reason = mirror_tree_unsynced(&root, &baseline, &rules_for(&[]));
std::fs::remove_dir_all(&root).unwrap();
assert_eq!(reason, None);
}
/// The case that lost data: `.git/` is ignored by default, so no pass ever
/// uploads it, and the eviction deleted it with the rest.
#[test]
fn an_ignored_folder_only_this_disk_has_holds_the_switch_back() {
let (root, baseline) = synced_subtree("evict-ignored");
std::fs::create_dir(root.join(".git")).unwrap();
std::fs::write(root.join(".git/HEAD"), b"ref").unwrap();
let reason = mirror_tree_unsynced(&root, &baseline, &rules_for(&[".git/"]));
std::fs::remove_dir_all(&root).unwrap();
let reason = reason.expect("an ignored folder is only on this disk");
assert!(
reason.contains(".git") && reason.contains("excluded"),
"{reason}"
);
}
/// An ignored row stays in the baseline for good once its file is gone, so
/// counting it as a deletion not carried up would hold the switch forever.
#[test]
fn a_deleted_ignored_path_does_not_hold_the_switch_back() {
let (root, mut baseline) = synced_subtree("evict-ignored-gone");
baseline.insert(
"build/out.o".to_string(),
StoredSyncEntry {
rel_path: "build/out.o".to_string(),
..baseline["dir/a.txt"].clone()
},
);
let rules = rules_for(&["build/"]);
let ignored = mirror_tree_unsynced(&root, &baseline, &rules);
let tracked = mirror_tree_unsynced(&root, &baseline, &rules_for(&[]));
std::fs::remove_dir_all(&root).unwrap();
assert_eq!(ignored, None);
assert!(tracked.unwrap().contains("build/out.o"));
}
#[test]
fn an_edit_after_the_last_pass_holds_the_switch_back() {
let (root, baseline) = synced_subtree("evict-edit");
std::fs::write(root.join("dir/sub/b.txt"), b"edited since").unwrap();
let reason = mirror_tree_unsynced(&root, &baseline, &rules_for(&[]));
std::fs::remove_dir_all(&root).unwrap();
assert!(reason.unwrap().contains("dir/sub/b.txt"));
}
#[test]
fn a_new_file_at_the_root_holds_the_switch_back() {
let (root, baseline) = synced_subtree("evict-new");
std::fs::write(root.join("new.txt"), b"n").unwrap();
let reason = mirror_tree_unsynced(&root, &baseline, &rules_for(&[]));
std::fs::remove_dir_all(&root).unwrap();
assert!(reason.unwrap().contains("new.txt"));
}
/// The folder B196 now leaves out of the pass instead of failing it is,
/// by the same token, something the eviction must not delete.
#[test]
fn an_unreadable_folder_holds_the_switch_back() {
use std::os::unix::fs::PermissionsExt as _;
let (root, baseline) = synced_subtree("evict-eacces");
let locked = root.join("dir/sub");
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
let reason = mirror_tree_unsynced(&root, &baseline, &rules_for(&[]));
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o700)).unwrap();
std::fs::remove_dir_all(&root).unwrap();
assert!(reason.unwrap().contains("dir/sub"));
}
#[test]
fn a_symlink_holds_the_switch_back() {
let (root, baseline) = synced_subtree("evict-symlink");
std::os::unix::fs::symlink("dir/a.txt", root.join("link")).unwrap();
let reason = mirror_tree_unsynced(&root, &baseline, &rules_for(&[]));
std::fs::remove_dir_all(&root).unwrap();
assert!(reason.unwrap().contains("link"));
}
/// The same rule one level up: if the sync root itself cannot be read, the /// The same rule one level up: if the sync root itself cannot be read, the
/// pass sees nothing, and "nothing" must never mean "delete everything". /// pass sees nothing, and "nothing" must never mean "delete everything".
#[test] #[test]
@@ -2607,7 +3054,14 @@ mod tests {
let writing = HashSet::new(); let writing = HashSet::new();
let mut out = HashMap::new(); let mut out = HashMap::new();
let mut progress = no_progress(); let mut progress = no_progress();
let result = walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut progress); let result = walk_local_tree(
&root,
&rules,
&writing,
&mut out,
&mut Unscanned::default(),
&mut progress,
);
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap(); std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap();
std::fs::remove_dir_all(&root).unwrap(); std::fs::remove_dir_all(&root).unwrap();
@@ -2628,30 +3082,40 @@ mod tests {
let writing = HashSet::new(); let writing = HashSet::new();
let mut out = HashMap::new(); let mut out = HashMap::new();
let mut progress = no_progress(); let mut progress = no_progress();
assert!(walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut progress).is_err()); assert!(
walk_local_tree(
&root,
&rules,
&writing,
&mut out,
&mut Unscanned::default(),
&mut progress
)
.is_err()
);
assert!(out.is_empty()); assert!(out.is_empty());
} }
/// A name that is not valid UTF-8 cannot be keyed into the scan, so it /// A name that is not valid UTF-8 cannot be keyed into the scan, but it
/// cannot be compared against the baseline either. Refusing the pass is the /// cannot be in the baseline or on Drive either, so leaving it out reads as
/// only safe reading: skipping it would classify the file as deleted. /// the deletion of nothing. It used to fail the pass (B196).
#[test] #[test]
fn a_non_utf8_name_fails_the_scan() { fn a_non_utf8_name_is_left_out_and_reported() {
use std::ffi::OsStr; use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt as _; use std::os::unix::ffi::OsStrExt as _;
let root = sync_test_dir("scan-non-utf8"); let root = sync_test_dir("scan-non-utf8");
std::fs::write(root.join(OsStr::from_bytes(b"bad\xffname")), b"x").unwrap(); std::fs::write(root.join(OsStr::from_bytes(b"bad\xffname")), b"x").unwrap();
std::fs::write(root.join("good.txt"), b"y").unwrap();
let rules = rules_for(&[]); let result = scan(&root);
let writing = HashSet::new();
let mut out = HashMap::new();
let mut progress = no_progress();
let result = walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut progress);
std::fs::remove_dir_all(&root).unwrap(); std::fs::remove_dir_all(&root).unwrap();
let error = result.expect_err("a name the scan cannot key must abort the pass"); let (out, unscanned) = result.unwrap();
assert!(error.contains("UTF-8"), "{error}"); assert_eq!(sorted_keys(&out), vec!["good.txt"]);
let report = unscanned.report();
assert_eq!(report.len(), 1);
assert!(report[0].contains("UTF-8"), "{report:?}");
} }
/// The complement: a scan that *can* read everything reports everything, so /// The complement: a scan that *can* read everything reports everything, so
@@ -2670,9 +3134,14 @@ mod tests {
let writing = HashSet::new(); let writing = HashSet::new();
let mut out = HashMap::new(); let mut out = HashMap::new();
let mut seen: Vec<String> = Vec::new(); let mut seen: Vec<String> = Vec::new();
let result = walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut |rel| { let result = walk_local_tree(
seen.push(rel.to_string()) &root,
}); &rules,
&writing,
&mut out,
&mut Unscanned::default(),
&mut |rel| seen.push(rel.to_string()),
);
std::fs::remove_dir_all(&root).unwrap(); std::fs::remove_dir_all(&root).unwrap();
result.unwrap(); result.unwrap();
+30 -4
View File
@@ -33,7 +33,7 @@ pub(crate) enum SyncMsg {
/// Re-read the folder set and adjust the filesystem watches (after add/remove). /// Re-read the folder set and adjust the filesystem watches (after add/remove).
Rewatch, Rewatch,
/// The daemon is tearing down: finish the current pass and end the engine /// The daemon is tearing down: finish the current pass and end the engine
/// thread, so the mount can join it (bugs.md B44). /// thread, so the mount can join it (`docs/BUGS.md` B44).
Stop, Stop,
} }
@@ -57,9 +57,10 @@ pub(crate) fn spawn(core: Core, rx: Receiver<SyncMsg>) -> Option<std::thread::Jo
fn engine_loop(core: Core, rx: Receiver<SyncMsg>) { fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
// Paths the watcher currently covers, mapped to their folder id, so an event // Paths the watcher currently covers, mapped to their folder id, so an event
// path can be resolved back to the folder it belongs to. // path can be resolved back to the folder it belongs to.
let watched: Mutex<Vec<(PathBuf, i64)>> = Mutex::new(Vec::new()); let watched: Arc<Mutex<Vec<(PathBuf, i64)>>> = Arc::new(Mutex::new(Vec::new()));
let tx_events = core.sync_tx.clone(); let tx_events = core.sync_tx.clone();
let watched_events = watched.clone();
let mut watcher = let mut watcher =
match notify::recommended_watcher(move |res: notify::Result<notify::Event>| { match notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
let Ok(event) = res else { return }; let Ok(event) = res else { return };
@@ -67,7 +68,8 @@ fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
if matches!(event.kind, notify::EventKind::Access(_)) { if matches!(event.kind, notify::EventKind::Access(_)) {
return; return;
} }
let _ = tx_events.send(SyncMsg::ReconcileAll); let msg = event_target(&watched_events.lock(), &event.paths);
let _ = tx_events.send(msg);
}) { }) {
Ok(w) => w, Ok(w) => w,
Err(e) => { Err(e) => {
@@ -256,8 +258,31 @@ fn reconcile_all(core: &Core) {
} }
} }
/// The reconcile a watcher event asks for: the one mirror folder all its paths
/// lie in, so an edit in one folder does not walk every other one too. An event
/// that spans folders, or that no watched folder claims (a watch list a moment
/// out of date), falls back to every folder.
pub(super) fn event_target(watched: &[(PathBuf, i64)], paths: &[PathBuf]) -> SyncMsg {
let mut ids = paths.iter().map(|path| {
watched
.iter()
.filter(|(root, _)| path.starts_with(root))
// The deepest root, should one mirror folder sit inside another.
.max_by_key(|(root, _)| root.components().count())
.map(|(_, id)| *id)
});
match ids.next() {
Some(Some(first)) if ids.all(|id| id == Some(first)) => SyncMsg::Reconcile(first),
_ => SyncMsg::ReconcileAll,
}
}
/// Bring the filesystem watches in line with the current mirror-folder set: /// Bring the filesystem watches in line with the current mirror-folder set:
/// watch newly-added folders, drop removed ones. /// watch newly-added folders, drop removed ones.
///
/// The watch calls run on a copy, outside the lock: the watcher's event
/// callback takes the same lock, and a backend that hands a watch request to
/// its event thread and waits for the answer would deadlock against it.
fn rewatch( fn rewatch(
core: &Core, core: &Core,
watcher: &mut notify::RecommendedWatcher, watcher: &mut notify::RecommendedWatcher,
@@ -274,7 +299,7 @@ fn rewatch(
return; return;
} }
}; };
let mut have = watched.lock(); let mut have = watched.lock().clone();
// Drop watches no longer wanted. // Drop watches no longer wanted.
have.retain(|(path, _)| { have.retain(|(path, _)| {
if want.iter().any(|(p, _)| p == path) { if want.iter().any(|(p, _)| p == path) {
@@ -294,6 +319,7 @@ fn rewatch(
Err(e) => warn!(path = %path.display(), error = %e, "sync: watch failed"), Err(e) => warn!(path = %path.display(), error = %e, "sync: watch failed"),
} }
} }
*watched.lock() = have;
} }
// ---- reconcile ------------------------------------------------------------ // ---- reconcile ------------------------------------------------------------
+7 -2
View File
@@ -20,7 +20,7 @@ impl LocalSig {
/// Nanoseconds decide it when both sides have them; otherwise the comparison /// Nanoseconds decide it when both sides have them; otherwise the comparison
/// falls back to whole seconds, which is what a baseline row written before /// falls back to whole seconds, which is what a baseline row written before
/// schema 28 — or a filesystem that reports no sub-second time — can support /// schema 28 — or a filesystem that reports no sub-second time — can support
/// (bugs.md B25). /// (`docs/BUGS.md` B25).
pub(super) fn same_content(&self, other: &Self) -> bool { pub(super) fn same_content(&self, other: &Self) -> bool {
if self.size != other.size { if self.size != other.size {
return false; return false;
@@ -98,6 +98,11 @@ pub(super) struct Outcome {
pub(super) errors: usize, pub(super) errors: usize,
/// Files skipped because another process held them open for writing. /// Files skipped because another process held them open for writing.
pub(super) deferred: usize, pub(super) deferred: usize,
/// Paths the local scan could not read, left out of the pass. Not part of
/// [`is_empty`](Self::is_empty) or the summary: an unreadable folder stays
/// unreadable from one poll to the next, so the pass reports it once, when
/// the set changes, rather than on every poll.
pub(super) unscanned: Vec<String>,
} }
impl Outcome { impl Outcome {
@@ -161,7 +166,7 @@ pub(super) enum Pending {
/// was there. A download can run for minutes, so the apply step revalidates /// was there. A download can run for minutes, so the apply step revalidates
/// against it immediately before it renames over the destination: a writer /// against it immediately before it renames over the destination: a writer
/// that finished an edit in the meantime must not be overwritten without a /// that finished an edit in the meantime must not be overwritten without a
/// conflict copy (bugs.md B39). /// conflict copy (`docs/BUGS.md` B39).
Download { Download {
rel: String, rel: String,
uid: NodeUid, uid: NodeUid,
+92 -2
View File
@@ -90,6 +90,96 @@ pub(super) fn filter_baseline<'a, B>(
.collect() .collect()
} }
/// The paths a local scan left out because it could not read them: a folder
/// it has no permission for, an entry that vanished between the listing and
/// its `stat`, a name that is not UTF-8.
///
/// A left-out path is not a deleted one. A pass drops everything an entry
/// covers from classification, exactly as it drops ignored paths, so a
/// baseline row under an unreadable folder is neither read as a local deletion
/// nor answered with a download into a folder the pass cannot write. Failing
/// the whole pass instead is what B55 did, and it left one root-owned folder
/// stopping every other file in the tree from syncing (B196).
#[derive(Default)]
pub(super) struct Unscanned {
covered: HashSet<String>,
/// The subset worth telling the user about, with why: an entry that only
/// vanished mid-scan is not, since the next pass sees it settled.
unreadable: Vec<String>,
}
impl Unscanned {
/// Leave out `rel`, which vanished while the scan looked at it.
pub(super) fn vanished(&mut self, rel: String) {
self.covered.insert(rel);
}
/// Leave out `rel`, which cannot be read for `reason`.
pub(super) fn unreadable(&mut self, rel: String, reason: &str) {
self.unreadable.push(format!("{rel} ({reason})"));
self.covered.insert(rel);
}
/// Whether `rel` is a left-out path or lies under one.
pub(super) fn covers(&self, rel: &str) -> bool {
if self.covered.is_empty() {
return false;
}
let mut at = rel;
loop {
if self.covered.contains(at) {
return true;
}
match at.rfind('/') {
Some(i) => at = &at[..i],
None => return false,
}
}
}
/// The unreadable paths with their reasons, sorted, for the activity feed.
pub(super) fn report(&self) -> Vec<String> {
let mut lines = self.unreadable.clone();
lines.sort_unstable();
lines
}
}
/// The baseline paths a pass can see: [`filter_baseline`], minus everything
/// under a path the scan could not read. This is the set [`guard_local_wipe`]
/// checks and the set a push pass reads deletions from, since a path outside
/// it is absent from the local walk by rule or by failure, never by loss.
pub(super) fn tracked_baseline<'a, B>(
baseline: &'a HashMap<String, B>,
rules: &IgnoreRules,
unscanned: &Unscanned,
) -> HashMap<String, &'a B> {
let mut tracked = filter_baseline(baseline, rules);
tracked.retain(|rel, _| !unscanned.covers(rel));
tracked
}
/// The baseline paths a push pass has to trash on Drive: tracked, and gone
/// from the local walk. Shallowest first, so a trashed folder takes its
/// children with it.
///
/// Built from [`tracked_baseline`] rather than from the raw baseline. Reading
/// it raw trashed every synced path that later became ignored, because the walk
/// skips those, and the switch to on-demand that the push pass runs for then
/// deleted the local copy too (B197).
pub(super) fn local_deletions<B, L>(
tracked: &HashMap<String, &B>,
local: &HashMap<String, L>,
) -> Vec<String> {
let mut missing: Vec<String> = tracked
.keys()
.filter(|rel| !local.contains_key(*rel))
.cloned()
.collect();
missing.sort_by_key(|p| p.matches('/').count());
missing
}
/// Refuse to run a pass whose local side has vanished in its entirety. /// Refuse to run a pass whose local side has vanished in its entirety.
/// ///
/// When every baseline path is absent locally, the likely cause is an unavailable /// When every baseline path is absent locally, the likely cause is an unavailable
@@ -218,7 +308,7 @@ pub(super) fn plan_file(
} }
let local_changed = local.is_some_and(|item| { let local_changed = local.is_some_and(|item| {
// Nanoseconds where both sides have them (bugs.md B25): whole seconds // Nanoseconds where both sides have them (`docs/BUGS.md` B25): whole seconds
// could not see an edit made in the same second as the last sync that // could not see an edit made in the same second as the last sync that
// left the file the same length, so it was never uploaded. // left the file the same length, so it was never uploaded.
baseline.is_none_or(|base| !LocalSig::from(item).same_content(&LocalSig::from(base))) baseline.is_none_or(|base| !LocalSig::from(item).same_content(&LocalSig::from(base)))
@@ -291,7 +381,7 @@ mod file_plan_tests {
/// Whole seconds cannot see an edit made inside the same second that leaves the /// Whole seconds cannot see an edit made inside the same second that leaves the
/// file the same length, so a mirror silently stopped uploading it. With a /// file the same length, so a mirror silently stopped uploading it. With a
/// nanosecond time on both sides it is an ordinary local change (bugs.md B25). /// nanosecond time on both sides it is an ordinary local change (`docs/BUGS.md` B25).
#[test] #[test]
fn a_same_second_same_size_edit_is_still_a_local_change() { fn a_same_second_same_size_edit_is_still_a_local_change() {
let mut base = baseline(10, 20, 30, 40); let mut base = baseline(10, 20, 30, 40);
+20
View File
@@ -74,6 +74,26 @@ pub(crate) fn watchdog_ping() {
let _ = notify("WATCHDOG=1\n"); let _ = notify("WATCHDOG=1\n");
} }
/// Sleep for `duration`, pinging the watchdog on the way.
///
/// For the stretches where the daemon is alive but has no mount, and so no
/// supervisor: waiting for a login, or backing off after a failed mount. The
/// manager arms the watchdog when the process starts, so without these pings a
/// daemon that is merely retrying is aborted after `WatchdogSec` and leaves a
/// coredump instead of the error it was logging. Never use this while mounted:
/// there the ping must prove the control socket answers.
pub fn idle_sleep(duration: Duration) {
const CHUNK: Duration = Duration::from_secs(10);
let mut left = duration;
watchdog_ping();
while !left.is_zero() {
let step = left.min(CHUNK);
std::thread::sleep(step);
left -= step;
watchdog_ping();
}
}
/// Tell the manager the daemon is stopping, so a slow teardown is not mistaken /// Tell the manager the daemon is stopping, so a slow teardown is not mistaken
/// for a hang by the watchdog it is racing. /// for a hang by the watchdog it is racing.
pub(crate) fn stopping() { pub(crate) fn stopping() {
+17 -1
View File
@@ -46,6 +46,8 @@ struct JobEntry {
detail: Mutex<String>, detail: Mutex<String>,
done: AtomicU64, done: AtomicU64,
total: AtomicU64, total: AtomicU64,
/// Housekeeping that moves nothing; see [`JobItem::background`].
background: bool,
} }
/// The set of transfers and jobs currently in flight. Cloned `Arc`-style across /// The set of transfers and jobs currently in flight. Cloned `Arc`-style across
@@ -160,12 +162,25 @@ impl TransferRegistry {
/// that carries its progress and deregisters the job when dropped. The job /// that carries its progress and deregisters the job when dropped. The job
/// starts indeterminate; give it a `total` once one is known. /// starts indeterminate; give it a `total` once one is known.
pub fn begin_job(self: &Arc<Self>, title: impl Into<String>) -> JobGuard { pub fn begin_job(self: &Arc<Self>, title: impl Into<String>) -> JobGuard {
self.register_job(title.into(), false)
}
/// [`TransferRegistry::begin_job`] for housekeeping the user did not ask
/// for and that changes nothing in their files, such as an index rebuild or
/// a cache check. It is reported, but front ends don't call the drive busy
/// over it.
pub fn begin_background_job(self: &Arc<Self>, title: impl Into<String>) -> JobGuard {
self.register_job(title.into(), true)
}
fn register_job(self: &Arc<Self>, title: String, background: bool) -> JobGuard {
let id = self.next.fetch_add(1, Ordering::Relaxed); let id = self.next.fetch_add(1, Ordering::Relaxed);
let entry = Arc::new(JobEntry { let entry = Arc::new(JobEntry {
title: title.into(), title,
detail: Mutex::new(String::new()), detail: Mutex::new(String::new()),
done: AtomicU64::new(0), done: AtomicU64::new(0),
total: AtomicU64::new(0), total: AtomicU64::new(0),
background,
}); });
self.jobs.lock().insert(id, entry.clone()); self.jobs.lock().insert(id, entry.clone());
JobGuard { JobGuard {
@@ -191,6 +206,7 @@ impl TransferRegistry {
detail: e.detail.lock().clone(), detail: e.detail.lock().clone(),
done: e.done.load(Ordering::Relaxed), done: e.done.load(Ordering::Relaxed),
total: e.total.load(Ordering::Relaxed), total: e.total.load(Ordering::Relaxed),
background: e.background,
} }
}) })
.collect() .collect()
@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<!-- Main menu: three bars. Bundled because themes such as Papirus draw
open-menu-symbolic as three dots, the same as every row's "more" menu. -->
<path fill="#222222" d="M 2,2.5 H 14 A 1,1 0 0 1 14,4.5 H 2 A 1,1 0 0 1 2,2.5 Z M 2,7 H 14 A 1,1 0 0 1 14,9 H 2 A 1,1 0 0 1 2,7 Z M 2,11.5 H 14 A 1,1 0 0 1 14,13.5 H 2 A 1,1 0 0 1 2,11.5 Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 441 B

@@ -14,7 +14,6 @@
<file preprocess="xml-stripblanks">scalable/actions/pdfs-details-pane-symbolic.svg</file> <file preprocess="xml-stripblanks">scalable/actions/pdfs-details-pane-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-link-symbolic.svg</file> <file preprocess="xml-stripblanks">scalable/actions/pdfs-link-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-map-symbolic.svg</file> <file preprocess="xml-stripblanks">scalable/actions/pdfs-map-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-menu-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-people-symbolic.svg</file> <file preprocess="xml-stripblanks">scalable/actions/pdfs-people-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-share-symbolic.svg</file> <file preprocess="xml-stripblanks">scalable/actions/pdfs-share-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-sync-symbolic.svg</file> <file preprocess="xml-stripblanks">scalable/actions/pdfs-sync-symbolic.svg</file>
+6 -1
View File
@@ -18,7 +18,9 @@
.sidebar-status image.warning { color: var(--warning-color); } .sidebar-status image.warning { color: var(--warning-color); }
.sidebar-status image.error { color: var(--error-color); } .sidebar-status image.error { color: var(--error-color); }
.sidebar-quota progressbar trough, .sidebar-quota progressbar progress { min-height: 4px; } .sidebar-quota progressbar trough, .sidebar-quota progressbar progress { min-height: 4px; }
.sidebar-account { padding: 4px 4px 4px 8px; } /* The account row is the main menu's button: a row, not a pushbutton, so
* the name keeps the text weight. */
button.sidebar-account { padding: 4px 8px; font-weight: normal; }
/* A count of what waits on a page, at the end of its sidebar row */ /* A count of what waits on a page, at the end of its sidebar row */
.sidebar-badge { .sidebar-badge {
min-width: 10px; min-width: 10px;
@@ -48,6 +50,9 @@
.badge-cloud { color: color-mix(in srgb, currentColor 55%, transparent); } .badge-cloud { color: color-mix(in srgb, currentColor 55%, transparent); }
.badge-issue { color: var(--error-color); } .badge-issue { color: var(--error-color); }
.browser-statusbar { background-color: alpha(currentColor, 0.025); } .browser-statusbar { background-color: alpha(currentColor, 0.025); }
/* The folder on screen, last in the breadcrumb trail: a flat button's side
* padding, so the "›" before it is spaced like the ones between buttons. */
.crumb-current { padding: 0 10px; }
scale.browser-status-meter trough { min-width: 104px; min-height: 6px; } scale.browser-status-meter trough { min-width: 104px; min-height: 6px; }
.bulk-bar { padding: 6px 8px; border-radius: 12px; background: color-mix(in srgb, var(--accent-bg-color) 12%, transparent); } .bulk-bar { padding: 6px 8px; border-radius: 12px; background: color-mix(in srgb, var(--accent-bg-color) 12%, transparent); }
.dropzone { border: 2px dashed alpha(currentColor, 0.25); border-radius: 16px; padding: 28px 18px; transition: background 160ms ease, border-color 160ms ease; } .dropzone { border: 2px dashed alpha(currentColor, 0.25); border-radius: 16px; padding: 28px 18px; transition: background 160ms ease, border-color 160ms ease; }
+35 -27
View File
@@ -34,6 +34,7 @@ use pages::verify::*;
use reload::{LoadTicket, Loader, replace_items}; use reload::{LoadTicket, Loader, replace_items};
use theme::{load_resources, proton_theme_active, set_proton_theme}; use theme::{load_resources, proton_theme_active, set_proton_theme};
use widgets::compat::*; use widgets::compat::*;
use widgets::crumb::*;
use widgets::details::*; use widgets::details::*;
use widgets::file_list::*; use widgets::file_list::*;
use widgets::menu::*; use widgets::menu::*;
@@ -119,6 +120,9 @@ struct Ui {
/// keyring (a DBus round-trip). Populated at startup and updated only on /// keyring (a DBus round-trip). Populated at startup and updated only on
/// login / logout. `None` = signed out. /// login / logout. `None` = signed out.
session: RefCell<Option<auth::StoredSession>>, session: RefCell<Option<auth::StoredSession>>,
/// Whether this run already asked for the mailbox password of a locked
/// account, so a refresh never re-opens a dialog the user cancelled.
unlock_offered: Cell<bool>,
/// Whether the last refresh saw a live mount daemon. Gates the unpin buttons /// Whether the last refresh saw a live mount daemon. Gates the unpin buttons
/// (which need the daemon to evict + re-hydrate) and every mutating action. /// (which need the daemon to evict + re-hydrate) and every mutating action.
mounted: RefCell<bool>, mounted: RefCell<bool>,
@@ -199,12 +203,20 @@ impl Ui {
} }
fn main() -> glib::ExitCode { fn main() -> glib::ExitCode {
tracing_subscriber::fmt() let verify = std::env::args().nth(1).as_deref() == Some(VERIFY_ARG);
.with_env_filter( let logs = tracing_subscriber::fmt().with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
) );
.init(); // The verification window's stdout carries its token back to the app.
if verify {
logs.with_writer(std::io::stderr).init();
} else {
logs.init();
}
i18n::init(); i18n::init();
if verify {
return run_verification_window();
}
// The tray launches the app with arguments ("--page locations", // The tray launches the app with arguments ("--page locations",
// "--confirm-stop"), which a running instance must receive too. // "--confirm-stop"), which a running instance must receive too.
@@ -300,6 +312,11 @@ fn build_window(app: &adw::Application) {
return; return;
} }
}; };
// On a fresh install the daemon has not run yet to make these, and the
// Takeout page keeps its staged archives in the state dir (B194).
if let Err(e) = dirs.ensure() {
tracing::warn!("cannot create the app's directories: {e}");
}
let stack = adw::ViewStack::new(); let stack = adw::ViewStack::new();
let (login_page, login_widgets) = build_login_page(); let (login_page, login_widgets) = build_login_page();
@@ -362,6 +379,7 @@ fn build_window(app: &adw::Application) {
busy: Cell::new(0), busy: Cell::new(0),
opening: RefCell::new(HashSet::new()), opening: RefCell::new(HashSet::new()),
session: RefCell::new(auth::load().ok()), session: RefCell::new(auth::load().ok()),
unlock_offered: Cell::new(false),
mounted: RefCell::new(false), mounted: RefCell::new(false),
sidebar: sidebar_list.clone(), sidebar: sidebar_list.clone(),
nav: split.clone(), nav: split.clone(),
@@ -520,6 +538,7 @@ fn build_window(app: &adw::Application) {
reload_pending: Cell::new(false), reload_pending: Cell::new(false),
loading: Cell::new(false), loading: Cell::new(false),
has_more: Cell::new(false), has_more: Cell::new(false),
library: Cell::new(true),
page_waiters: RefCell::new(Vec::new()), page_waiters: RefCell::new(Vec::new()),
width: Cell::new(0), width: Cell::new(0),
photo_tex: RefCell::new(HashMap::new()), photo_tex: RefCell::new(HashMap::new()),
@@ -561,6 +580,8 @@ fn build_window(app: &adw::Application) {
detail: locations_widgets.card_detail.clone(), detail: locations_widgets.card_detail.clone(),
pause: locations_widgets.pause.clone(), pause: locations_widgets.pause.clone(),
paused: Cell::new(false), paused: Cell::new(false),
sign_in: locations_widgets.card_sign_in.clone(),
restart: locations_widgets.card_restart.clone(),
}, },
queue: QueueState { queue: QueueState {
group: locations_widgets.queue_group.clone(), group: locations_widgets.queue_group.clone(),
@@ -813,7 +834,6 @@ fn build_sidebar(footer: &gtk4::Box) -> (adw::NavigationPage, gtk4::ListBox) {
let header = adw::HeaderBar::new(); let header = adw::HeaderBar::new();
header.set_title_widget(Some(&brand)); header.set_title_widget(Some(&brand));
header.pack_end(&build_primary_menu());
let scroll = gtk4::ScrolledWindow::builder() let scroll = gtk4::ScrolledWindow::builder()
.hscrollbar_policy(gtk4::PolicyType::Never) .hscrollbar_policy(gtk4::PolicyType::Never)
@@ -1136,25 +1156,7 @@ fn toast_failure(ui: &Rc<Ui>, what: &str, message: &str, kind: ErrorKind) {
} }
} }
/// The sidebar's primary (hamburger) menu: the app-level entries that don't /// Back the main menu's entries with window actions.
/// belong on any one page.
fn build_primary_menu() -> gtk4::MenuButton {
let menu = gio::Menu::new();
menu.append(Some(&gettext("Preferences")), Some("win.preferences"));
menu.append(Some(&gettext("Keyboard Shortcuts")), Some("win.shortcuts"));
menu.append(
Some(&gettext("About Proton Drive for Linux")),
Some("win.about"),
);
gtk4::MenuButton::builder()
.icon_name("pdfs-menu-symbolic")
.tooltip_text(gettext("Main menu"))
.primary(true)
.menu_model(&menu)
.build()
}
/// Back the primary and account menus' entries with window actions.
fn install_window_actions(ui: &Rc<Ui>, window: &adw::ApplicationWindow) { fn install_window_actions(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
let preferences = gio::SimpleAction::new("preferences", None); let preferences = gio::SimpleAction::new("preferences", None);
let ui_prefs = ui.clone(); let ui_prefs = ui.clone();
@@ -1206,7 +1208,7 @@ fn install_window_actions(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
let dialog = adw::AboutDialog::builder() let dialog = adw::AboutDialog::builder()
.application_name(gettext("Proton Drive for Linux")) .application_name(gettext("Proton Drive for Linux"))
.application_icon("io.narl.proton-drive-linux") .application_icon("io.narl.proton-drive-linux")
.version(pdfs_core::config::APP_VERSION) .version(env!("CARGO_PKG_VERSION"))
.developer_name("Nils Pukropp") .developer_name("Nils Pukropp")
.website("https://proton-drive.narl.io") .website("https://proton-drive.narl.io")
.issue_url("https://github.com/narrrl/proton-drive-linux/issues") .issue_url("https://github.com/narrrl/proton-drive-linux/issues")
@@ -1291,6 +1293,12 @@ fn install_launch_actions(ui: &Rc<Ui>, app: &adw::Application, window: &adw::App
if ui_page.session.borrow().is_none() { if ui_page.session.borrow().is_none() {
return; return;
} }
// Signed in, but Proton refused the session (the tray's "Sign In
// Again…"): the login page, with the rest of the app still reachable.
if page == "login" {
show_login(&ui_page);
return;
}
// Activity lives on as the Sync page's History view. // Activity lives on as the Sync page's History view.
if page == "activity" { if page == "activity" {
ui_page.locations.views.set_visible_child_name("history"); ui_page.locations.views.set_visible_child_name("history");
@@ -1346,8 +1354,8 @@ fn debug_info(ui: &Rc<Ui>) -> String {
format!( format!(
"App version: {}\nUser agent: {}\nlibadwaita: {}.{}.{}\nGTK: {}.{}.{}\n\ "App version: {}\nUser agent: {}\nlibadwaita: {}.{}.{}\nGTK: {}.{}.{}\n\
Mountpoint: {}\nControl socket: {}\nMount service running: {}\n", Mountpoint: {}\nControl socket: {}\nMount service running: {}\n",
pdfs_core::config::APP_VERSION, config.resolved_app_version(),
pdfs_core::config::USER_AGENT, config.resolved_user_agent(),
adw::major_version(), adw::major_version(),
adw::minor_version(), adw::minor_version(),
adw::micro_version(), adw::micro_version(),
+57 -38
View File
@@ -520,9 +520,9 @@ pub(crate) fn clear_selection(ui: &Rc<Ui>) {
} }
/// Reflect the current selection in the bulk bar: how many are selected, and /// Reflect the current selection in the bulk bar: how many are selected, and
/// which bulk actions apply to that mix. Offline state is a file-only notion, so /// which bulk actions apply to that mix. An entry kept offline through a pinned
/// a folder anywhere in the selection disables the two pin buttons rather than /// folder above it cannot be made online only on its own, so it does not count
/// half-failing once pressed. /// towards Make online only.
pub(crate) fn sync_bulk_bar(ui: &Rc<Ui>) { pub(crate) fn sync_bulk_bar(ui: &Rc<Ui>) {
let entries = selected_entries(ui); let entries = selected_entries(ui);
// One selected entry is the details pane's job; the bar is for a batch. // One selected entry is the details pane's job; the bar is for a batch.
@@ -530,8 +530,7 @@ pub(crate) fn sync_bulk_bar(ui: &Rc<Ui>) {
ui.browser.bulk.set_reveal_child(false); ui.browser.bulk.set_reveal_child(false);
return; return;
} }
let files_only = entries.iter().all(|e| !e.is_dir); let any_pinned = entries.iter().any(|e| e.pinned && !e.pinned_by_folder);
let any_pinned = entries.iter().any(|e| e.pinned);
let any_unpinned = entries.iter().any(|e| !e.pinned); let any_unpinned = entries.iter().any(|e| !e.pinned);
let mounted = *ui.mounted.borrow(); let mounted = *ui.mounted.borrow();
ui.browser.bulk_label.set_label(&ngettext_f( ui.browser.bulk_label.set_label(&ngettext_f(
@@ -541,12 +540,8 @@ pub(crate) fn sync_bulk_bar(ui: &Rc<Ui>) {
&[], &[],
)); ));
ui.browser.bulk_trash.set_sensitive(mounted); ui.browser.bulk_trash.set_sensitive(mounted);
ui.browser ui.browser.bulk_pin.set_sensitive(mounted && any_unpinned);
.bulk_pin ui.browser.bulk_unpin.set_sensitive(mounted && any_pinned);
.set_sensitive(mounted && files_only && any_unpinned);
ui.browser
.bulk_unpin
.set_sensitive(mounted && files_only && any_pinned);
ui.browser.bulk.set_reveal_child(true); ui.browser.bulk.set_reveal_child(true);
} }
@@ -652,16 +647,19 @@ pub(crate) fn restore_uids(ui: &Rc<Ui>, uids: Vec<String>, count: usize) {
); );
} }
/// Pin or unpin every selected file, one request at a time. /// Pin or unpin every selected entry, one request at a time. A folder's files
/// download in the background, so its request returns before they are there.
pub(crate) fn run_bulk_pin(ui: &Rc<Ui>, entries: Vec<DirEntry>, pin: bool) { pub(crate) fn run_bulk_pin(ui: &Rc<Ui>, entries: Vec<DirEntry>, pin: bool) {
let socket = ui.dirs.control_socket(); let socket = ui.dirs.control_socket();
// Only the entries that need changing: re-pinning an already-pinned file is // Only the entries that need changing: re-pinning an already-pinned file is
// a wasted round-trip, and it would inflate the count the toast reports. // a wasted round-trip, and it would inflate the count the toast reports. One
let paths: Vec<String> = entries // kept through a pinned folder above it has no pin of its own to remove.
let changing: Vec<&DirEntry> = entries
.iter() .iter()
.filter(|e| !e.is_dir && e.pinned != pin) .filter(|e| !e.pinned_by_folder && e.pinned != pin)
.map(|e| entry_rel(ui, e))
.collect(); .collect();
let any_folder = changing.iter().any(|e| e.is_dir);
let paths: Vec<String> = changing.iter().map(|e| entry_rel(ui, e)).collect();
if paths.is_empty() { if paths.is_empty() {
return; return;
} }
@@ -698,13 +696,27 @@ pub(crate) fn run_bulk_pin(ui: &Rc<Ui>, entries: Vec<DirEntry>, pin: bool) {
(0, None) => {} (0, None) => {}
(n, _) => toast( (n, _) => toast(
&ui, &ui,
&if pin { &if pin && any_folder {
ngettext_f(
"Making {n} item available offline…",
"Making {n} items available offline…",
n as u64,
&[],
)
} else if pin {
ngettext_f( ngettext_f(
"{n} file is now available offline", "{n} file is now available offline",
"{n} files are now available offline", "{n} files are now available offline",
n as u64, n as u64,
&[], &[],
) )
} else if any_folder {
ngettext_f(
"{n} item is now online only",
"{n} items are now online only",
n as u64,
&[],
)
} else { } else {
ngettext_f( ngettext_f(
"{n} file is now online only", "{n} file is now online only",
@@ -832,8 +844,8 @@ fn show_location_column(ui: &Rc<Ui>, visible: bool) {
} }
} }
/// The menu for one entry, in the order of `docs/UI_UX_PLAN.md` §6: open, /// The menu for one entry, in a fixed order: open, offline, sharing,
/// offline, sharing, organising, and the destructive item last on its own. /// organising, and the destructive item last on its own.
pub(crate) fn entry_context_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu { pub(crate) fn entry_context_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
let mut menu = ActionMenu::new(); let mut menu = ActionMenu::new();
let (ui_c, entry_c) = (ui.clone(), entry.clone()); let (ui_c, entry_c) = (ui.clone(), entry.clone());
@@ -860,13 +872,17 @@ pub(crate) fn entry_context_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
} }
menu.section(); menu.section();
if !entry.is_dir { // Kept through a pinned folder above it, the entry has no pin of its own
// to remove: the check shows that state without offering to change it.
if entry.pinned_by_folder {
menu.locked_toggle(&gettext("Available offline"), true);
} else {
let (ui_c, entry_c) = (ui.clone(), entry.clone()); let (ui_c, entry_c) = (ui.clone(), entry.clone());
menu.toggle(&gettext("Available offline"), entry.pinned, move |_| { menu.toggle(&gettext("Available offline"), entry.pinned, move |_| {
toggle_pin(&ui_c, &entry_c) toggle_pin(&ui_c, &entry_c)
}); });
menu.section();
} }
menu.section();
let (ui_c, entry_c) = (ui.clone(), entry.clone()); let (ui_c, entry_c) = (ui.clone(), entry.clone());
menu.item(&gettext("Share…"), move || { menu.item(&gettext("Share…"), move || {
@@ -908,9 +924,8 @@ pub(crate) fn entry_context_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
/// simply absent here rather than offered and then refused. /// simply absent here rather than offered and then refused.
pub(crate) fn bulk_context_menu(ui: &Rc<Ui>, entries: Vec<DirEntry>) -> ActionMenu { pub(crate) fn bulk_context_menu(ui: &Rc<Ui>, entries: Vec<DirEntry>) -> ActionMenu {
let mut menu = ActionMenu::new(); let mut menu = ActionMenu::new();
// Offline state is a file-only notion; the check shows whether the whole // The check shows whether the whole batch already is offline.
// batch already is. {
if entries.iter().all(|e| !e.is_dir) {
let (ui_c, batch) = (ui.clone(), entries.clone()); let (ui_c, batch) = (ui.clone(), entries.clone());
let all_pinned = entries.iter().all(|e| e.pinned); let all_pinned = entries.iter().all(|e| e.pinned);
menu.toggle(&gettext("Available offline"), all_pinned, move |pin| { menu.toggle(&gettext("Available offline"), all_pinned, move |pin| {
@@ -1147,6 +1162,7 @@ pub(crate) fn toggle_pin(ui: &Rc<Ui>, entry: &DirEntry) {
let rx = spawn_request(ui.dirs.control_socket(), req); let rx = spawn_request(ui.dirs.control_socket(), req);
let name = entry.name.clone(); let name = entry.name.clone();
let pinned = entry.pinned; let pinned = entry.pinned;
let is_dir = entry.is_dir;
let ui = ui.clone(); let ui = ui.clone();
glib::spawn_future_local(async move { glib::spawn_future_local(async move {
match rx.recv().await { match rx.recv().await {
@@ -1166,6 +1182,9 @@ pub(crate) fn toggle_pin(ui: &Rc<Ui>, entry: &DirEntry) {
&ui, &ui,
&if pinned { &if pinned {
gettext_f("“{name}” is now online only", &[("name", &name)]) gettext_f("“{name}” is now online only", &[("name", &name)])
} else if is_dir {
// The daemon downloads a folder's files in the background.
gettext_f("Making “{name}” available offline…", &[("name", &name)])
} else { } else {
gettext_f("“{name}” is now available offline", &[("name", &name)]) gettext_f("“{name}” is now available offline", &[("name", &name)])
}, },
@@ -1220,9 +1239,7 @@ pub(crate) fn repaint_crumb(ui: &Rc<Ui>, path: &str) {
)); ));
let mut acc = String::new(); let mut acc = String::new();
for (i, seg) in segments.iter().enumerate() { for (i, seg) in segments.iter().enumerate() {
let sep = gtk4::Label::new(Some("›")); ui.browser.crumb.append(&crumb_separator());
sep.add_css_class("dim-label");
ui.browser.crumb.append(&sep);
acc = if acc.is_empty() { acc = if acc.is_empty() {
seg.to_string() seg.to_string()
} else { } else {
@@ -1237,12 +1254,7 @@ pub(crate) fn repaint_crumb(ui: &Rc<Ui>, path: &str) {
/// flat button that navigates to `target` (clearing any active search first). /// flat button that navigates to `target` (clearing any active search first).
pub(crate) fn crumb_node(ui: &Rc<Ui>, label: &str, target: &str, current: bool) -> gtk4::Widget { pub(crate) fn crumb_node(ui: &Rc<Ui>, label: &str, target: &str, current: bool) -> gtk4::Widget {
if current { if current {
let l = gtk4::Label::builder() return crumb_current(label);
.label(label)
.ellipsize(gtk4::pango::EllipsizeMode::Start)
.build();
l.add_css_class("heading");
return l.upcast();
} }
let button = gtk4::Button::builder().label(label).build(); let button = gtk4::Button::builder().label(label).build();
button.add_css_class("flat"); button.add_css_class("flat");
@@ -2494,16 +2506,19 @@ pub(crate) fn start_upload(ui: &Rc<Ui>, sources: Vec<String>) {
}); });
} }
/// The sync-state badge for an entry: `(icon, css-class)`, or `None` for folders /// The sync-state badge for an entry: `(icon, css-class)`. A folder carries no
/// (which carry no per-file cache state). A sync issue ranks first, on folders /// cache state of its own, so it has one only when kept offline. A sync issue
/// too: the change waits for the user. Pinned (kept offline) ranks above merely /// ranks first, on folders too: the change waits for the user. Pinned (kept
/// cached (downloaded, evictable); everything else is online-only. /// offline) ranks above merely cached (downloaded, evictable); everything else
/// is online-only.
pub(crate) fn badge_for(entry: &DirEntry) -> Option<(&'static str, &'static str)> { pub(crate) fn badge_for(entry: &DirEntry) -> Option<(&'static str, &'static str)> {
if entry.issue.is_some() { if entry.issue.is_some() {
return Some(("dialog-warning-symbolic", "badge-issue")); return Some(("dialog-warning-symbolic", "badge-issue"));
} }
if entry.is_dir { if entry.is_dir {
return None; return entry
.pinned
.then_some(("pdfs-offline-symbolic", "badge-pinned"));
} }
Some(if entry.pinned { Some(if entry.pinned {
("pdfs-offline-symbolic", "badge-pinned") ("pdfs-offline-symbolic", "badge-pinned")
@@ -2912,6 +2927,7 @@ pub(crate) fn repaint_search(ui: &Rc<Ui>, hits: &[SearchHit]) {
size: h.size, size: h.size,
modified: h.modified, modified: h.modified,
pinned: h.pinned, pinned: h.pinned,
pinned_by_folder: false,
cached: h.cached, cached: h.cached,
uid: h.uid.clone(), uid: h.uid.clone(),
path: h.path.clone(), path: h.path.clone(),
@@ -2972,6 +2988,7 @@ mod tests {
size, size,
modified, modified,
pinned: false, pinned: false,
pinned_by_folder: false,
cached: false, cached: false,
uid: String::new(), uid: String::new(),
path: String::new(), path: String::new(),
@@ -2999,6 +3016,8 @@ mod tests {
let mut folder = entry("d", true, 0, 0); let mut folder = entry("d", true, 0, 0);
assert_eq!(badge_for(&folder), None); assert_eq!(badge_for(&folder), None);
folder.pinned = true;
assert_eq!(badge_for(&folder).unwrap().1, "badge-pinned");
folder.issue = Some(SyncIssue::Missing); folder.issue = Some(SyncIssue::Missing);
assert_eq!(badge_for(&folder).unwrap().1, "badge-issue"); assert_eq!(badge_for(&folder).unwrap().1, "badge-issue");
} }
+67 -9
View File
@@ -439,6 +439,10 @@ fn other_computer_row(ui: &Rc<Ui>, dev: &DeviceInfo) -> adw::ActionRow {
menu.item(&gettext("Restore to This Computer…"), move || { menu.item(&gettext("Restore to This Computer…"), move || {
prompt_restore_folders(&ui_c, Some((uid_c.clone(), name_c.clone()))) prompt_restore_folders(&ui_c, Some((uid_c.clone(), name_c.clone())))
}); });
let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone());
menu.item(&gettext("Move to This Computer…"), move || {
prompt_migrate_device(&ui_c, &uid_c, &name_c)
});
menu.section(); menu.section();
let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone()); let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone());
menu.item(&gettext("Rename…"), move || { menu.item(&gettext("Rename…"), move || {
@@ -483,9 +487,7 @@ fn repaint_device_crumb(ui: &Rc<Ui>, nav: &[(String, String)]) {
nav.is_empty(), nav.is_empty(),
)); ));
for (i, (_, name)) in nav.iter().enumerate() { for (i, (_, name)) in nav.iter().enumerate() {
let sep = gtk4::Label::new(Some("›")); crumb.append(&crumb_separator());
sep.add_css_class("dim-label");
crumb.append(&sep);
crumb.append(&device_crumb_node(ui, name, i + 1, i + 1 == nav.len())); crumb.append(&device_crumb_node(ui, name, i + 1, i + 1 == nav.len()));
} }
} }
@@ -494,12 +496,7 @@ fn repaint_device_crumb(ui: &Rc<Ui>, nav: &[(String, String)]) {
/// that goes back up to `depth` levels below the device list. /// that goes back up to `depth` levels below the device list.
fn device_crumb_node(ui: &Rc<Ui>, label: &str, depth: usize, current: bool) -> gtk4::Widget { fn device_crumb_node(ui: &Rc<Ui>, label: &str, depth: usize, current: bool) -> gtk4::Widget {
if current { if current {
let l = gtk4::Label::builder() return crumb_current(label);
.label(label)
.ellipsize(gtk4::pango::EllipsizeMode::Start)
.build();
l.add_css_class("heading");
return l.upcast();
} }
let button = gtk4::Button::builder().label(label).build(); let button = gtk4::Button::builder().label(label).build();
button.add_css_class("flat"); button.add_css_class("flat");
@@ -1096,6 +1093,67 @@ pub(crate) fn prompt_adopt_device(ui: &Rc<Ui>, uid: &str, name: &str) {
dialog.present(win.as_ref()); dialog.present(win.as_ref());
} }
/// Ask before moving another computer's folders into this computer's backup,
/// then offer them for syncing here.
///
/// Destructive-looking on purpose: the other computer keeps its device but
/// loses its folders, and a copy of Proton Drive still running there would
/// watch them vanish.
pub(crate) fn prompt_migrate_device(ui: &Rc<Ui>, uid: &str, name: &str) {
let win = ui_window(ui);
let dialog = adw::AlertDialog::builder()
.heading(gettext("Move Backup to This Computer?"))
// Translators: {name} is the name of the other computer whose folders move to this one.
.body(gettext_f(
"The folders backed up by “{name}” move into this computer's backup in Proton Drive, and you choose where they go on this computer. “{name}” stays in your account, without those folders.\n\nStop Proton Drive on “{name}” first. Otherwise it sees its folders disappear.",
&[("name", name)],
))
.build();
dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("move", &gettext("Move Here"));
dialog.set_response_appearance("move", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
let ui = ui.clone();
let uid = uid.to_string();
// Translators: {name} is the name of the computer whose folders moved to this one.
let done = gettext_f("Moved the backup of “{name}”", &[("name", name)]);
dialog.connect_response(None, move |_, resp| {
if resp != "move" {
return;
}
ui.busy_begin();
let rx = spawn_request(
ui.dirs.control_socket(),
Request::MigrateDevice { uid: uid.clone() },
);
let (ui, done) = (ui.clone(), done.clone());
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.busy_end();
let failed = gettext("Couldn't move that backup");
match result {
Ok(Ok(Response::Ok { .. })) => {
reload_sync_pages(&ui);
toast(&ui, &done);
// The folders are this computer's now, so its own restore
// picker is where the user chooses their local paths.
prompt_restore_folders(&ui, None);
}
Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, &failed, &message, kind)
}
_ => toast_error(
&ui,
&failed,
&gettext("The Proton Drive service didn't respond."),
),
}
});
});
dialog.present(win.as_ref());
}
/// Run a mutation raised from Computers or Locations and reload the page on success. /// Run a mutation raised from Computers or Locations and reload the page on success.
pub(crate) fn run_devices_mutation(ui: &Rc<Ui>, req: Request, done: &str, failed: &str) { pub(crate) fn run_devices_mutation(ui: &Rc<Ui>, req: Request, done: &str, failed: &str) {
ui.busy_begin(); ui.busy_begin();
+47 -6
View File
@@ -7,7 +7,7 @@
//! `~/ProtonDrive` mount, plus each folder this computer backs up, whether it is //! `~/ProtonDrive` mount, plus each folder this computer backs up, whether it is
//! a mirrored local directory or an on-demand FUSE session. The page is called //! a mirrored local directory or an on-demand FUSE session. The page is called
//! *Locations* rather than *Mounts* because a mirror folder is a plain directory //! *Locations* rather than *Mounts* because a mirror folder is a plain directory
//! with no FUSE session behind it (mount-architecture.md §4). //! with no FUSE session behind it.
//! //!
//! The device rows drive the same control requests the Computers page used to: //! The device rows drive the same control requests the Computers page used to:
//! mode switch, sync now, remove. What is new here is that the primary mount is //! mode switch, sync now, remove. What is new here is that the primary mount is
@@ -46,6 +46,10 @@ pub(crate) struct SyncCard {
pub(crate) pause: adw::SplitButton, pub(crate) pause: adw::SplitButton,
/// Whether the last status said paused, so the button knows which way to go. /// Whether the last status said paused, so the button knows which way to go.
pub(crate) paused: Cell<bool>, pub(crate) paused: Cell<bool>,
/// Shown only while Proton refuses the session; opens the sign-in page.
pub(crate) sign_in: gtk4::Button,
/// Shown only while the daemon is older than the app; restarts it.
pub(crate) restart: gtk4::Button,
} }
/// What a folder row's controls are built from. The subtitle and progress bar /// What a folder row's controls are built from. The subtitle and progress bar
@@ -108,6 +112,8 @@ pub(crate) struct LocationsWidgets {
pub(crate) card_icon: gtk4::Image, pub(crate) card_icon: gtk4::Image,
pub(crate) card_title: gtk4::Label, pub(crate) card_title: gtk4::Label,
pub(crate) card_detail: gtk4::Label, pub(crate) card_detail: gtk4::Label,
pub(crate) card_sign_in: gtk4::Button,
pub(crate) card_restart: gtk4::Button,
pub(crate) pause: adw::SplitButton, pub(crate) pause: adw::SplitButton,
pub(crate) queue_group: adw::PreferencesGroup, pub(crate) queue_group: adw::PreferencesGroup,
pub(crate) retry_all: gtk4::Button, pub(crate) retry_all: gtk4::Button,
@@ -134,7 +140,7 @@ pub(crate) fn build_locations_page(history: &gtk4::Widget) -> (gtk4::Widget, Loc
.build(); .build();
add_folder.add_css_class("flat"); add_folder.add_css_class("flat");
let transfers_group = build_transfers_group(); let transfers_group = build_transfers_group();
let (card, card_icon, card_title, card_detail) = build_sync_card(); let (card, card_icon, card_title, card_detail, card_sign_in, card_restart) = build_sync_card();
let pause = build_pause_button(); let pause = build_pause_button();
let (queue_group, retry_all) = build_queue_group(); let (queue_group, retry_all) = build_queue_group();
let conflicts_group = adw::PreferencesGroup::builder() let conflicts_group = adw::PreferencesGroup::builder()
@@ -253,6 +259,8 @@ pub(crate) fn build_locations_page(history: &gtk4::Widget) -> (gtk4::Widget, Loc
card_icon, card_icon,
card_title, card_title,
card_detail, card_detail,
card_sign_in,
card_restart,
pause, pause,
queue_group, queue_group,
retry_all, retry_all,
@@ -290,6 +298,16 @@ pub(crate) fn wire_locations(ui: &Rc<Ui>, retry: &gtk4::Button, add_folder: &gtk
let ui_add = ui.clone(); let ui_add = ui.clone();
add_folder.connect_clicked(move |_| prompt_add_sync_folder(&ui_add)); add_folder.connect_clicked(move |_| prompt_add_sync_folder(&ui_add));
wire_pause(ui); wire_pause(ui);
let ui_sign_in = ui.clone();
ui.locations
.card
.sign_in
.connect_clicked(move |_| show_login(&ui_sign_in));
let ui_restart = ui.clone();
ui.locations
.card
.restart
.connect_clicked(move |_| restart_service_then(&ui_restart, |_| {}));
let ui_retry = ui.clone(); let ui_retry = ui.clone();
ui.locations ui.locations
.queue .queue
@@ -379,9 +397,16 @@ pub(crate) fn paint_sync_banner(ui: &Rc<Ui>) {
state.banner.set_revealed(away && title.is_some()); state.banner.set_revealed(away && title.is_some());
} }
/// The status hero: a large state icon, the state in words, and what it /// The status hero: a large state icon, the state in words, what it means,
/// means. /// and the way out when there is one only the user can take.
fn build_sync_card() -> (gtk4::Box, gtk4::Image, gtk4::Label, gtk4::Label) { fn build_sync_card() -> (
gtk4::Box,
gtk4::Image,
gtk4::Label,
gtk4::Label,
gtk4::Button,
gtk4::Button,
) {
let icon = gtk4::Image::builder() let icon = gtk4::Image::builder()
.icon_name("pdfs-sync-symbolic") .icon_name("pdfs-sync-symbolic")
.pixel_size(48) .pixel_size(48)
@@ -408,9 +433,25 @@ fn build_sync_card() -> (gtk4::Box, gtk4::Image, gtk4::Label, gtk4::Label) {
let card = gtk4::Box::new(gtk4::Orientation::Horizontal, 18); let card = gtk4::Box::new(gtk4::Orientation::Horizontal, 18);
card.add_css_class("card"); card.add_css_class("card");
card.add_css_class("sync-card"); card.add_css_class("sync-card");
let sign_in = gtk4::Button::builder()
.label(gettext("Sign In Again"))
.valign(gtk4::Align::Center)
.visible(false)
.build();
sign_in.add_css_class("pill");
sign_in.add_css_class("suggested-action");
let restart = gtk4::Button::builder()
.label(gettext("Restart"))
.valign(gtk4::Align::Center)
.visible(false)
.build();
restart.add_css_class("pill");
restart.add_css_class("suggested-action");
card.append(&icon); card.append(&icon);
card.append(&text); card.append(&text);
(card, icon, title, detail) card.append(&sign_in);
card.append(&restart);
(card, icon, title, detail, sign_in, restart)
} }
/// Pause/Resume, with timed pauses in its menu. /// Pause/Resume, with timed pauses in its menu.
+283 -29
View File
@@ -1,5 +1,5 @@
use crate::*; use crate::*;
use pdfs_core::proton_sdk::api::HumanVerificationCredential; use pdfs_core::proton_sdk::api::{HumanVerification, HumanVerificationCredential};
pub(crate) struct LoginState { pub(crate) struct LoginState {
// Login page. // Login page.
@@ -128,7 +128,12 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
set_signing_in(&ui, true); set_signing_in(&ui, true);
ui.login.login_status.set_text(&gettext("Signing in…")); ui.login.login_status.set_text(&gettext("Signing in…"));
let (rx, totp_req_rx, hv_req_rx) = spawn_login(username, password); let LoginChannels {
result: rx,
totp: totp_req_rx,
hv: hv_req_rx,
mailbox: mailbox_req_rx,
} = spawn_login(username, password);
// Surface the 2FA dialog only if the SDK actually asks for a code (i.e. // Surface the 2FA dialog only if the SDK actually asks for a code (i.e.
// the account has 2FA enabled). The worker blocks until the dialog feeds // the account has 2FA enabled). The worker blocks until the dialog feeds
@@ -145,15 +150,40 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
}); });
// Same shape for human verification: only fires when the API gates the // Same shape for human verification: only fires when the API gates the
// sign-in, and the worker is blocked on the token until it does. // sign-in, and the worker is blocked on the credential until it does.
let ui_hv = ui.clone(); let ui_hv = ui.clone();
glib::spawn_future_local(async move { glib::spawn_future_local(async move {
if let Ok((url, token_tx)) = hv_req_rx.recv().await { if let Ok((hv, credential_tx)) = hv_req_rx.recv().await {
ui_hv ui_hv
.login .login
.login_status .login_status
.set_text(&gettext("Complete the verification to continue…")); .set_text(&gettext("Complete the verification to continue…"));
prompt_human_verification(&ui_hv, &url, token_tx); // Anything but a credential drops the sender, which the worker
// reads as a cancelled sign-in.
match verify_human(hv.verification_url()).await {
Verification::Solved(token) => {
let _ = credential_tx.send(HumanVerificationCredential::captcha(token));
}
Verification::Cancelled => {}
// The web view could not start, most likely its sandbox
// (B195): the user's browser can show the page instead.
Verification::Failed => verify_in_browser(&ui_hv, &hv, credential_tx),
}
}
});
// And for the mailbox password, which only a two-password account has.
// The worker asks again after a wrong answer, so this loops too.
let ui_mailbox = ui.clone();
glib::spawn_future_local(async move {
while let Ok((attempt, answer_tx)) = mailbox_req_rx.recv().await {
ask_mailbox(&ui_mailbox, attempt > 0, move |answer| {
// A cancelled dialog drops the sender, which the worker
// reads as a cancelled login.
if let Some(answer) = answer {
let _ = answer_tx.send(answer);
}
});
} }
}); });
@@ -209,6 +239,12 @@ pub(crate) fn login_error_message(error: &pdfs_core::Error) -> String {
pdfs_core::Error::Other(message) if message.contains("two-factor") => { pdfs_core::Error::Other(message) if message.contains("two-factor") => {
gettext("Sign-in cancelled: no two-factor code was entered.") gettext("Sign-in cancelled: no two-factor code was entered.")
} }
pdfs_core::Error::Other(message) if message.contains("mailbox") => {
gettext("Sign-in cancelled: no mailbox password was entered.")
}
pdfs_core::Error::WrongMailboxPassword => {
gettext("That mailbox password didn't unlock your account.")
}
pdfs_core::Error::Other(message) if message.contains("verification") => gettext( pdfs_core::Error::Other(message) if message.contains("verification") => gettext(
"Sign-in cancelled: the verification wasn't completed. Sign in again to get a new one.", "Sign-in cancelled: the verification wasn't completed. Sign in again to get a new one.",
), ),
@@ -220,26 +256,34 @@ pub(crate) fn login_error_message(error: &pdfs_core::Error) -> String {
} }
} }
/// What the login worker asks the UI for, and how it reports back.
pub(crate) struct LoginChannels {
/// The final outcome, once.
pub(crate) result: async_channel::Receiver<Result<(), String>>,
/// Fires only if the account needs a 2FA code; carries the sender for it.
pub(crate) totp: async_channel::Receiver<std::sync::mpsc::Sender<String>>,
/// Fires only if the sign-in is gated: the challenge, and the sender for the
/// credential the user earns by solving it.
pub(crate) hv: async_channel::Receiver<(
HumanVerification,
std::sync::mpsc::Sender<HumanVerificationCredential>,
)>,
/// Fires only for a two-password account: how many answers were already
/// wrong, and the sender for the password and the "remember" choice.
pub(crate) mailbox: async_channel::Receiver<(u32, std::sync::mpsc::Sender<(String, bool)>)>,
}
/// Run the async SRP + optional 2FA login on a dedicated current-thread Tokio /// Run the async SRP + optional 2FA login on a dedicated current-thread Tokio
/// runtime. Returns two channels: the first yields the final login result once; /// runtime. The returned [`LoginChannels`] carry the final result and the lazy
/// the second fires *only if* the account needs a 2FA code, carrying a /// prompts. Each login closure blocks the worker on a sender the UI answers, so
/// [`std::sync::mpsc::Sender`] the UI uses to feed the code back. The login /// a code is requested lazily and can't expire before the password proof.
/// closure blocks the worker on that sender until the dialog answers, so the pub(crate) fn spawn_login(username: String, password: String) -> LoginChannels {
/// code is requested lazily and can't expire before the password proof.
#[allow(clippy::type_complexity)]
pub(crate) fn spawn_login(
username: String,
password: String,
) -> (
async_channel::Receiver<Result<(), String>>,
async_channel::Receiver<std::sync::mpsc::Sender<String>>,
async_channel::Receiver<(String, std::sync::mpsc::Sender<String>)>,
) {
let (tx, rx) = async_channel::bounded(1); let (tx, rx) = async_channel::bounded(1);
// Two, not one: a CAPTCHA-gated login restarts, so a 2FA account is asked // Two, not one: a CAPTCHA-gated login restarts, so a 2FA account is asked
// for a code on each attempt and a single slot would deadlock the worker. // for a code on each attempt and a single slot would deadlock the worker.
let (totp_req_tx, totp_req_rx) = async_channel::bounded(2); let (totp_req_tx, totp_req_rx) = async_channel::bounded(2);
let (hv_req_tx, hv_req_rx) = async_channel::bounded(1); let (hv_req_tx, hv_req_rx) = async_channel::bounded(1);
let (mailbox_req_tx, mailbox_req_rx) = async_channel::bounded(1);
std::thread::spawn(move || { std::thread::spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread() let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all() .enable_all()
@@ -267,19 +311,30 @@ pub(crate) fn spawn_login(
.recv() .recv()
.map_err(|_| pdfs_core::Error::Other("two-factor entry cancelled".into())) .map_err(|_| pdfs_core::Error::Other("two-factor entry cancelled".into()))
}, },
|attempt| {
let (answer_tx, answer_rx) = std::sync::mpsc::channel::<(String, bool)>();
mailbox_req_tx
.send_blocking((attempt, answer_tx))
.map_err(|_| pdfs_core::Error::Other("mailbox prompt closed".into()))?;
let (password, remember) = answer_rx
.recv()
.map_err(|_| pdfs_core::Error::Other("mailbox entry cancelled".into()))?;
Ok(auth::Mailbox {
password: password.into(),
remember,
})
},
|hv| { |hv| {
// Gated: hand the UI the page to show, block on the token the // Gated: hand the UI the challenge, block on the credential
// user earns by solving it. // the user earns by solving it.
let (token_tx, token_rx) = std::sync::mpsc::channel::<String>(); let (credential_tx, credential_rx) =
hv_req_tx std::sync::mpsc::channel::<HumanVerificationCredential>();
.send_blocking((hv.verification_url(), token_tx)) hv_req_tx.send_blocking((hv, credential_tx)).map_err(|_| {
.map_err(|_| {
pdfs_core::Error::Other("verification prompt closed".into()) pdfs_core::Error::Other("verification prompt closed".into())
})?; })?;
let token = token_rx credential_rx
.recv() .recv()
.map_err(|_| pdfs_core::Error::Other("verification cancelled".into()))?; .map_err(|_| pdfs_core::Error::Other("verification cancelled".into()))
Ok(HumanVerificationCredential::captcha(token))
}, },
) )
.await .await
@@ -287,7 +342,143 @@ pub(crate) fn spawn_login(
}); });
let _ = tx.send_blocking(result); let _ = tx.send_blocking(result);
}); });
(rx, totp_req_rx, hv_req_rx) LoginChannels {
result: rx,
totp: totp_req_rx,
hv: hv_req_rx,
mailbox: mailbox_req_rx,
}
}
/// Ask for the mailbox password of a two-password account.
///
/// `done` gets the password and the "keep unlocked" choice, or `None` when the
/// dialog was cancelled. `retry` says the last answer was wrong. The choice
/// defaults to off: storing the mailbox unlock in the keyring trades away what
/// a separate mailbox password is for, so it is something to opt into.
pub(crate) fn ask_mailbox(
ui: &Rc<Ui>,
retry: bool,
done: impl FnOnce(Option<(String, bool)>) + 'static,
) {
let body = if retry {
gettext("That mailbox password didn't unlock your account. Try again.")
} else {
gettext("This account has a separate mailbox password. Enter it to unlock your files.")
};
let dialog = adw::AlertDialog::builder()
.heading(gettext("Mailbox Password"))
.body(body)
.build();
let group = adw::PreferencesGroup::new();
let entry = adw::PasswordEntryRow::builder()
.title(gettext("Mailbox password"))
.activates_default(true)
.build();
let remember = adw::SwitchRow::builder()
.title(gettext("Keep unlocked on this computer"))
.subtitle(gettext(
"Stores the unlock in the system keyring, so Proton Drive opens without asking after every login. Otherwise you enter the password once per session.",
))
.build();
group.add(&entry);
group.add(&remember);
dialog.set_extra_child(Some(&group));
dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("unlock", &gettext("Unlock"));
dialog.set_response_appearance("unlock", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("unlock"));
dialog.set_close_response("cancel");
let done = RefCell::new(Some(done));
dialog.connect_response(None, move |_, resp| {
let Some(done) = done.borrow_mut().take() else {
return;
};
let password = entry.text().to_string();
if resp == "unlock" && !password.is_empty() {
done(Some((password, remember.is_active())));
} else {
done(None);
}
});
let parent = ui_window(ui).map(|w| w.upcast::<gtk4::Window>());
dialog.present(parent.as_ref());
}
/// If the signed-in account is locked, ask for its mailbox password — once per
/// run; after a cancel, a toast keeps an Unlock button within reach.
///
/// The key state is a keyring and kernel-keyring read, so it runs on a worker.
pub(crate) fn offer_unlock(ui: &Rc<Ui>) {
if ui.unlock_offered.get() {
return;
}
let ui = ui.clone();
glib::spawn_future_local(async move {
let state = gio::spawn_blocking(|| auth::key_state().ok()).await;
if matches!(state, Ok(Some(auth::KeyState::Locked))) && !ui.unlock_offered.replace(true) {
prompt_unlock(&ui, false);
}
});
}
enum UnlockOutcome {
Unlocked,
Wrong,
Failed(String),
}
/// Ask for the mailbox password and unlock the account with it.
fn prompt_unlock(ui: &Rc<Ui>, retry: bool) {
let ui_done = ui.clone();
ask_mailbox(ui, retry, move |answer| {
let Some((password, remember)) = answer else {
toast_action(
&ui_done,
&gettext("Proton Drive is locked. Enter your mailbox password to unlock it."),
&gettext("Unlock"),
|ui| prompt_unlock(ui, false),
);
return;
};
glib::spawn_future_local(async move {
// Verifying talks to Proton and the restart talks to systemd, so
// both run on a worker, never on the main loop.
let outcome = gio::spawn_blocking(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => return UnlockOutcome::Failed(e.to_string()),
};
match rt.block_on(auth::unlock(&password, remember)) {
Ok(()) => {
service::restart();
UnlockOutcome::Unlocked
}
Err(pdfs_core::Error::WrongMailboxPassword) => UnlockOutcome::Wrong,
Err(e) => UnlockOutcome::Failed(login_error_message(&e)),
}
})
.await;
match outcome {
Ok(UnlockOutcome::Unlocked) => {
toast(&ui_done, &gettext("Unlocked."));
refresh(&ui_done);
}
Ok(UnlockOutcome::Wrong) => prompt_unlock(&ui_done, true),
Ok(UnlockOutcome::Failed(message)) => {
toast_error(&ui_done, &gettext("Couldn't unlock"), &message)
}
Err(_) => {}
}
});
});
} }
/// Show the lazy two-factor dialog and feed the entered code back to the waiting /// Show the lazy two-factor dialog and feed the entered code back to the waiting
@@ -330,6 +521,63 @@ pub(crate) fn prompt_2fa(ui: &Rc<Ui>, code_tx: std::sync::mpsc::Sender<String>)
dialog.present(parent.as_ref()); dialog.present(parent.as_ref());
} }
/// Let the user solve the challenge in their own browser, for when the
/// verification window could not come up. The page cannot hand its token back
/// to us, so the user says when they are done, and the retry carries the
/// challenge token, which the solved page made good (see
/// [`auth::browser_verified`]).
fn verify_in_browser(
ui: &Rc<Ui>,
hv: &HumanVerification,
credential_tx: std::sync::mpsc::Sender<HumanVerificationCredential>,
) {
let dialog = adw::AlertDialog::builder()
.heading(gettext("Verify in Your Browser"))
.body(gettext(
"The verification page can't be shown in the app on this system. Open it in your browser, complete it there, then come back and choose Continue.",
))
.build();
let url = auth::browser_verification_url(hv);
let open = gtk4::Button::builder()
.label(gettext("Open Verification Page"))
.halign(gtk4::Align::Center)
.build();
open.add_css_class("pill");
dialog.set_extra_child(Some(&open));
dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("continue", &gettext("Continue"));
dialog.set_response_appearance("continue", adw::ResponseAppearance::Suggested);
// Continuing before the page was even opened would only fail the retry.
dialog.set_response_enabled("continue", false);
dialog.set_default_response(Some("continue"));
dialog.set_close_response("cancel");
let dialog_weak = dialog.downgrade();
open.connect_clicked(move |_| {
open_uri(&url);
if let Some(dialog) = dialog_weak.upgrade() {
dialog.set_response_enabled("continue", true);
}
});
let credential = auth::browser_verified(hv);
let credential_tx = RefCell::new(Some(credential_tx));
dialog.connect_response(None, move |_, resp| {
// On cancel/close we take + drop `tx` without sending, so the worker's
// recv errors out and the login is reported as cancelled.
if let Some(tx) = credential_tx.borrow_mut().take()
&& resp == "continue"
{
let _ = tx.send(credential.clone());
}
});
let parent = ui.login.login_button.root().and_downcast::<gtk4::Window>();
dialog.present(parent.as_ref());
}
/// Sign out, after a confirmation: disable+stop the mount service (so the /// Sign out, after a confirmation: disable+stop the mount service (so the
/// daemon isn't left running without credentials), forget the stored session, /// daemon isn't left running without credentials), forget the stored session,
/// and drop back to the login page. /// and drop back to the login page.
@@ -374,6 +622,12 @@ mod tests {
assert!(login_error_message(&hv).contains("verification wasn't completed")); assert!(login_error_message(&hv).contains("verification wasn't completed"));
} }
#[test]
fn a_cancelled_mailbox_prompt_is_reported_as_such() {
let error = pdfs_core::Error::Other("mailbox entry cancelled".into());
assert!(login_error_message(&error).contains("no mailbox password"));
}
#[test] #[test]
fn an_unmapped_error_keeps_its_text() { fn an_unmapped_error_keeps_its_text() {
let error = pdfs_core::Error::Other("something odd".into()); let error = pdfs_core::Error::Other("something odd".into());
+9
View File
@@ -140,6 +140,9 @@ pub(crate) struct GalleryState {
pub(crate) loading: Cell<bool>, pub(crate) loading: Cell<bool>,
/// Whether the last page came back full, so there may be more to load. /// Whether the last page came back full, so there may be more to load.
pub(crate) has_more: Cell<bool>, pub(crate) has_more: Cell<bool>,
/// Whether the account has a photo library. Until the daemon says it has
/// none, assume it does, so Upload is not greyed out while the page loads.
pub(crate) library: Cell<bool>,
/// Run once the page in flight has landed in [`Self::model`] — how the /// Run once the page in flight has landed in [`Self::model`] — how the
/// lightbox steps past the last loaded photo. /// lightbox steps past the last loaded photo.
pub(crate) page_waiters: RefCell<Vec<Box<dyn FnOnce()>>>, pub(crate) page_waiters: RefCell<Vec<Box<dyn FnOnce()>>>,
@@ -3341,6 +3344,12 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
items, items,
counts, counts,
})) => { })) => {
// Nothing can go into a library the account does not have; the
// SDK refuses the upload before sending it (issue #38).
ui.gallery.library.set(available);
ui.gallery
.upload
.set_sensitive(available && *ui.mounted.borrow());
if !available { if !available {
gallery_status( gallery_status(
&ui, &ui,
+2 -9
View File
@@ -484,9 +484,7 @@ fn repaint_shared_crumb(ui: &Rc<Ui>, nav: &[(String, String)]) {
nav.is_empty(), nav.is_empty(),
)); ));
for (i, (_, name)) in nav.iter().enumerate() { for (i, (_, name)) in nav.iter().enumerate() {
let sep = gtk4::Label::new(Some("›")); crumb.append(&crumb_separator());
sep.add_css_class("dim-label");
crumb.append(&sep);
crumb.append(&shared_crumb_node(ui, name, i + 1, i + 1 == nav.len())); crumb.append(&shared_crumb_node(ui, name, i + 1, i + 1 == nav.len()));
} }
} }
@@ -495,12 +493,7 @@ fn repaint_shared_crumb(ui: &Rc<Ui>, nav: &[(String, String)]) {
/// that goes back up to `depth` folders below the top level. /// that goes back up to `depth` folders below the top level.
fn shared_crumb_node(ui: &Rc<Ui>, label: &str, depth: usize, current: bool) -> gtk4::Widget { fn shared_crumb_node(ui: &Rc<Ui>, label: &str, depth: usize, current: bool) -> gtk4::Widget {
if current { if current {
let l = gtk4::Label::builder() return crumb_current(label);
.label(label)
.ellipsize(gtk4::pango::EllipsizeMode::Start)
.build();
l.add_css_class("heading");
return l.upcast();
} }
let button = gtk4::Button::builder().label(label).build(); let button = gtk4::Button::builder().label(label).build();
button.add_css_class("flat"); button.add_css_class("flat");
@@ -565,6 +565,7 @@ pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entries: &[DirEntry]) {
let uids: Vec<String> = entries.iter().map(|e| e.uid.clone()).collect(); let uids: Vec<String> = entries.iter().map(|e| e.uid.clone()).collect();
dialog.connect_response(Some("stop"), move |_, _| { dialog.connect_response(Some("stop"), move |_, _| {
let done = done.clone(); let done = done.clone();
let stopped = uids.clone();
run_requests( run_requests(
&ui_c, &ui_c,
uids.iter() uids.iter()
@@ -572,6 +573,7 @@ pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entries: &[DirEntry]) {
.collect(), .collect(),
gettext_noop("Couldn't stop sharing"), gettext_noop("Couldn't stop sharing"),
move |ui| { move |ui| {
forget_shared_by_me(ui, &stopped);
reload_listing(ui); reload_listing(ui);
toast(ui, &done); toast(ui, &done);
}, },
@@ -580,6 +582,18 @@ pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entries: &[DirEntry]) {
dialog.present(ui_window(ui).as_ref()); dialog.present(ui_window(ui).as_ref());
} }
/// Take items whose sharing just stopped off the page now, rather than when
/// the reload that confirms it comes back: that reload asks after every share
/// in turn and takes a while with many of them.
fn forget_shared_by_me(ui: &Rc<Ui>, uids: &[String]) {
let items = {
let mut items = ui.shared_by_me.items.borrow_mut();
items.retain(|item| !uids.contains(&item.uid));
items.clone()
};
repaint_shared_by_me(ui, &items);
}
/// Open a shared item: a folder in My Files, a file the way My Files would. /// Open a shared item: a folder in My Files, a file the way My Files would.
fn open_shared_by_me(ui: &Rc<Ui>, entry: &DirEntry) { fn open_shared_by_me(ui: &Rc<Ui>, entry: &DirEntry) {
if entry.is_dir { if entry.is_dir {
@@ -608,6 +622,7 @@ pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry {
size: 0, size: 0,
modified: item.modified, modified: item.modified,
pinned: false, pinned: false,
pinned_by_folder: false,
cached: false, cached: false,
uid: item.uid.clone(), uid: item.uid.clone(),
path: item.path.clone(), path: item.path.clone(),
+99 -16
View File
@@ -381,23 +381,34 @@ pub(crate) fn build_main_page() -> MainWidgets {
.hexpand(true) .hexpand(true)
.ellipsize(gtk4::pango::EllipsizeMode::Middle) .ellipsize(gtk4::pango::EllipsizeMode::Middle)
.build(); .build();
// The app's one menu, on the account row the way Proton's own apps keep it:
// app-wide entries first, the account's last. The whole row opens it, and
// F10 too, as the primary menu.
let app_section = gio::Menu::new();
app_section.append(Some(&gettext("Preferences")), Some("win.preferences"));
app_section.append(Some(&gettext("Keyboard Shortcuts")), Some("win.shortcuts"));
app_section.append(
Some(&gettext("About Proton Drive for Linux")),
Some("win.about"),
);
let account_section = gio::Menu::new();
account_section.append(Some(&gettext("Sign Out…")), Some("win.sign-out"));
let account_menu = gio::Menu::new(); let account_menu = gio::Menu::new();
account_menu.append(Some(&gettext("Preferences")), Some("win.preferences")); account_menu.append_section(None, &app_section);
let sign_out = gio::Menu::new(); account_menu.append_section(None, &account_section);
sign_out.append(Some(&gettext("Sign Out…")), Some("win.sign-out")); let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 10);
account_menu.append_section(None, &sign_out); row.append(&avatar);
let account_button = gtk4::MenuButton::builder() row.append(&account_name);
.icon_name("view-more-symbolic") row.append(&gtk4::Image::from_icon_name("view-more-symbolic"));
.tooltip_text(gettext("Account")) let account = gtk4::MenuButton::builder()
.child(&row)
.tooltip_text(gettext("Main menu"))
.menu_model(&account_menu) .menu_model(&account_menu)
.valign(gtk4::Align::Center) .direction(gtk4::ArrowType::Up)
.primary(true)
.build(); .build();
account_button.add_css_class("flat"); account.add_css_class("flat");
let account = gtk4::Box::new(gtk4::Orientation::Horizontal, 10);
account.add_css_class("sidebar-account"); account.add_css_class("sidebar-account");
account.append(&avatar);
account.append(&account_name);
account.append(&account_button);
let footer = gtk4::Box::new(gtk4::Orientation::Vertical, 10); let footer = gtk4::Box::new(gtk4::Orientation::Vertical, 10);
footer.add_css_class("sidebar-footer"); footer.add_css_class("sidebar-footer");
@@ -825,6 +836,13 @@ pub(crate) fn wire_retry(ui: &Rc<Ui>) {
.connect_clicked(move |_| restart_service_then(&ui_trash, load_trash)); .connect_clicked(move |_| restart_service_then(&ui_trash, load_trash));
} }
/// Show the sign-in page while a session is still stored, because Proton has
/// refused it. The sidebar stays, so the user can go back to cached files; a
/// login moves on to the files as from any sign-in ([`refresh`]).
pub(crate) fn show_login(ui: &Rc<Ui>) {
ui.stack.set_visible_child_name("login");
}
/// Repaint the window from the cached login identity, then ask the daemon for /// Repaint the window from the cached login identity, then ask the daemon for
/// its status and transfers. Runs at startup, after a login and after a service /// its status and transfers. Runs at startup, after a login and after a service
/// restart; the daemon's event feed keeps both current in between. The identity /// restart; the daemon's event feed keeps both current in between. The identity
@@ -860,6 +878,7 @@ pub(crate) fn refresh(ui: &Rc<Ui>) {
} }
} }
offer_unlock(ui);
refresh_status(ui); refresh_status(ui);
refresh_transfers(ui); refresh_transfers(ui);
} }
@@ -939,7 +958,9 @@ fn quota_display(max_space: i64, used_space: i64) -> (f64, String) {
pub(crate) fn set_mounted(ui: &Rc<Ui>, mounted: bool) { pub(crate) fn set_mounted(ui: &Rc<Ui>, mounted: bool) {
*ui.mounted.borrow_mut() = mounted; *ui.mounted.borrow_mut() = mounted;
sync_mounted_actions(ui, mounted); sync_mounted_actions(ui, mounted);
ui.gallery.upload.set_sensitive(mounted); ui.gallery
.upload
.set_sensitive(mounted && ui.gallery.library.get());
ui.details.details.pin_row.set_sensitive(mounted); ui.details.details.pin_row.set_sensitive(mounted);
ui.details.details.rename_button.set_sensitive(mounted); ui.details.details.rename_button.set_sensitive(mounted);
ui.details.details.trash_button.set_sensitive(mounted); ui.details.details.trash_button.set_sensitive(mounted);
@@ -1209,6 +1230,9 @@ pub(crate) fn paint_status(ui: &Rc<Ui>, status: Response) {
failing_error, failing_error,
paused, paused,
paused_until, paused_until,
session_expired,
tokens_unsaved,
version,
.. ..
} = status } = status
else { else {
@@ -1219,7 +1243,13 @@ pub(crate) fn paint_status(ui: &Rc<Ui>, status: Response) {
// in it: it is why a file that looks saved is not on the remote // in it: it is why a file that looks saved is not on the remote
// yet, and offline is usually the reason it is still queued. // yet, and offline is usually the reason it is still queued.
let queued = i18n::pending_summary(pending_uploads, pending_changes); let queued = i18n::pending_summary(pending_uploads, pending_changes);
let state = if paused { let state = if session_expired {
SyncState::SessionExpired { queued }
} else if tokens_unsaved {
SyncState::TokensUnsaved
} else if pdfs_core::control::daemon_outdated(&version) {
SyncState::Outdated { running: version }
} else if paused {
SyncState::Paused { SyncState::Paused {
until: paused_until, until: paused_until,
queued, queued,
@@ -1282,6 +1312,20 @@ pub(crate) fn paint_disconnected(ui: &Rc<Ui>) {
/// What the sidebar's status strip reports, most urgent first. /// What the sidebar's status strip reports, most urgent first.
pub(crate) enum SyncState { pub(crate) enum SyncState {
/// Proton refused the session: nothing reaches it until the user signs in
/// again. Outranks a pause, which it makes moot.
SessionExpired {
queued: Option<String>,
},
/// The keyring refuses the session's refreshed tokens. Sync goes on, but
/// a reboot before the keyring takes them signs the user out.
TokensUnsaved,
/// The daemon is an older release than this window: an upgrade replaced
/// the files but left the service running, with the old release's bugs,
/// until it restarts (#33). `running` is empty when it is too old to say.
Outdated {
running: String,
},
/// The user paused syncing; nothing goes up until it resumes. Outranks /// The user paused syncing; nothing goes up until it resumes. Outranks
/// everything else, because it is the reason for everything else. /// everything else, because it is the reason for everything else.
Paused { Paused {
@@ -1316,7 +1360,44 @@ pub(crate) enum SyncState {
fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) { fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
let paused = matches!(state, SyncState::Paused { .. }); let paused = matches!(state, SyncState::Paused { .. });
let connected = !matches!(state, SyncState::Disconnected | SyncState::NotResponding); let connected = !matches!(state, SyncState::Disconnected | SyncState::NotResponding);
let expired = matches!(state, SyncState::SessionExpired { .. });
let outdated = matches!(state, SyncState::Outdated { .. });
let (icon, class, title, detail) = match state { let (icon, class, title, detail) = match state {
SyncState::SessionExpired { queued } => (
"dialog-password-symbolic",
Some("error"),
gettext("Session expired"),
Some(match queued {
// Translators: {queued} says what is waiting, such as "3 uploads
// queued".
Some(queued) => {
gettext_f("{queued} · sign in again to sync", &[("queued", &queued)])
}
None => gettext("Sign in again to sync"),
}),
),
SyncState::TokensUnsaved => (
"dialog-warning-symbolic",
Some("warning"),
gettext("Sign-in not saved"),
Some(gettext(
"Unlock your keyring, or the next reboot signs you out",
)),
),
SyncState::Outdated { running } => (
"dialog-warning-symbolic",
Some("warning"),
gettext("Restart to finish updating"),
Some(if running.is_empty() {
gettext("An older version of Proton Drive is still running")
} else {
// Translators: {version} is a release number such as "3.2.0".
gettext_f(
"Proton Drive {version} is still running",
&[("version", &running)],
)
}),
),
SyncState::Paused { until, queued } => ( SyncState::Paused { until, queued } => (
"media-playback-pause-symbolic", "media-playback-pause-symbolic",
Some("warning"), Some("warning"),
@@ -1387,6 +1468,8 @@ fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
if let Some(class) = class { if let Some(class) = class {
image.add_css_class(class); image.add_css_class(class);
} }
ui.locations.card.sign_in.set_visible(expired);
ui.locations.card.restart.set_visible(outdated);
ui.status.status_title.set_label(&title); ui.status.status_title.set_label(&title);
ui.status.status_detail.set_visible(detail.is_some()); ui.status.status_detail.set_visible(detail.is_some());
ui.status ui.status
@@ -1455,7 +1538,7 @@ pub(crate) fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted:
let row = adw::ActionRow::builder() let row = adw::ActionRow::builder()
.title(gettext("No files kept offline")) .title(gettext("No files kept offline"))
.subtitle(gettext( .subtitle(gettext(
"Right-click a file and choose “Make available offline”.", "Right-click a file or folder and choose “Available offline”.",
)) ))
.build(); .build();
ui.status.pins_group.add(&row); ui.status.pins_group.add(&row);
+1
View File
@@ -524,6 +524,7 @@ mod tests {
size: 0, size: 0,
modified, modified,
pinned: false, pinned: false,
pinned_by_folder: false,
cached: false, cached: false,
uid: name.into(), uid: name.into(),
path: String::new(), path: String::new(),
+168 -40
View File
@@ -1,4 +1,4 @@
//! The human-verification (CAPTCHA) dialog. //! The human-verification (CAPTCHA) window.
//! //!
//! Proton gates logins it does not recognise — a new IP, a VPN exit — behind a //! Proton gates logins it does not recognise — a new IP, a VPN exit — behind a
//! CAPTCHA. The API answers the login with a challenge instead of a session, and //! CAPTCHA. The API answers the login with a challenge instead of a session, and
@@ -8,10 +8,26 @@
//! The page is hosted in a `WebKitWebView` rather than handed to the system //! The page is hosted in a `WebKitWebView` rather than handed to the system
//! browser because it reports completion by posting a message to its host, not //! browser because it reports completion by posting a message to its host, not
//! by redirecting: a browser has nowhere to post it back to. //! by redirecting: a browser has nowhere to post it back to.
//!
//! The webview runs in a child `pdfs-app` started with [`VERIFY_ARG`], not in the
//! app itself. WebKit aborts its whole process when its sandbox cannot start, as
//! on hardened systems that keep bubblewrap from the journal socket
//! (`docs/BUGS.md` B195); in a child that costs the verification, not the app.
//! The URL goes to the child on stdin, so it never shows in a process list, and
//! the token comes back on stdout.
use crate::*; use crate::*;
use std::io::{BufRead, BufReader, Write as _};
use std::process::{ExitStatus, Stdio};
use webkit6::prelude::*; use webkit6::prelude::*;
/// The argument that makes `pdfs-app` the verification window instead of the app.
pub(crate) const VERIFY_ARG: &str = "--human-verification";
/// Marks the token among whatever else lands on the child's stdout: GLib and
/// WebKit write debug output there.
const TOKEN_PREFIX: &str = "hv-token ";
/// Bridge the verification page's `postMessage` to a handler this side can read. /// Bridge the verification page's `postMessage` to a handler this side can read.
/// ///
/// The page targets its embedding host (`window.parent`), which in a webview is /// The page targets its embedding host (`window.parent`), which in a webview is
@@ -26,15 +42,100 @@ window.addEventListener('message', function (event) {
}); });
"#; "#;
/// Present the challenge and hand the solved token back through `token_tx`. /// How the verification window ended.
/// #[derive(Debug, PartialEq, Eq)]
/// Closing the dialog without solving drops the sender, which the waiting login pub(crate) enum Verification {
/// worker reads as a cancelled sign-in — the same contract as the 2FA prompt. Solved(String),
pub(crate) fn prompt_human_verification( /// Closed without solving.
ui: &Rc<Ui>, Cancelled,
url: &str, /// Never came up, or died: most likely WebKit could not start its sandbox.
token_tx: std::sync::mpsc::Sender<String>, Failed,
) { }
/// Show the challenge in the verification window and wait until it closes.
pub(crate) async fn verify_human(url: String) -> Verification {
match gio::spawn_blocking(move || run_verification_child(&url)).await {
Ok(Ok(verification)) => verification,
Ok(Err(e)) => {
tracing::error!("cannot run the verification window: {e}");
Verification::Failed
}
Err(_) => Verification::Failed,
}
}
fn run_verification_child(url: &str) -> std::io::Result<Verification> {
let mut child = Command::new(own_exe())
.arg(VERIFY_ARG)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()?;
if let Some(mut stdin) = child.stdin.take() {
// A failed write means the child is already gone; its exit status says
// how. Dropping `stdin` closes it.
let _ = writeln!(stdin, "{url}");
}
// Stops reading at the token rather than at EOF: WebKit's helper processes
// inherit the pipe and may hold it open a moment after the child quits.
let token = child
.stdout
.take()
.and_then(|out| find_token(BufReader::new(out)));
let status = child.wait()?;
Ok(outcome(token, status))
}
/// This very binary, so a development build opens its own window. After an
/// upgrade replaced the file, `current_exe` names a deleted path, and the
/// installed `pdfs-app` is the next best.
fn own_exe() -> PathBuf {
std::env::current_exe()
.ok()
.filter(|path| path.is_file())
.unwrap_or_else(|| PathBuf::from("pdfs-app"))
}
fn find_token(out: impl BufRead) -> Option<String> {
out.lines()
.map_while(Result::ok)
.find_map(|line| line.strip_prefix(TOKEN_PREFIX).map(str::to_owned))
}
/// A token wins over the exit status: the user solved the page, even if
/// WebKit's teardown crashed after it.
fn outcome(token: Option<String>, status: ExitStatus) -> Verification {
match token {
Some(token) => Verification::Solved(token),
None if status.success() => Verification::Cancelled,
None => {
tracing::error!(%status, "the verification window failed");
Verification::Failed
}
}
}
/// Run as the verification window: read the page's URL from stdin, show it, and
/// print the token once the user has solved it. Closing the window exits
/// without a token, which the app reads as a cancelled sign-in.
pub(crate) fn run_verification_window() -> glib::ExitCode {
let mut url = String::new();
if std::io::stdin().lock().read_line(&mut url).is_err() || url.trim().is_empty() {
tracing::error!("{VERIFY_ARG} expects the verification URL on stdin");
return glib::ExitCode::FAILURE;
}
let url = url.trim().to_owned();
// Not unique: the app that started it already holds APP_ID.
let app = adw::Application::builder()
.flags(gio::ApplicationFlags::NON_UNIQUE)
.build();
app.connect_activate(move |app| build_verification_window(app, &url));
// GApplication would refuse VERIFY_ARG as an unknown option.
let argv0 = std::env::args().next().unwrap_or_default();
app.run_with_args(&[argv0])
}
fn build_verification_window(app: &adw::Application, url: &str) {
let content = webkit6::UserContentManager::new(); let content = webkit6::UserContentManager::new();
content.add_script(&webkit6::UserScript::new( content.add_script(&webkit6::UserScript::new(
BRIDGE, BRIDGE,
@@ -53,24 +154,19 @@ pub(crate) fn prompt_human_verification(
.hexpand(true) .hexpand(true)
.build(); .build();
let dialog = adw::Dialog::builder() let body = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
body.append(&adw::HeaderBar::new());
body.append(&webview);
let window = adw::ApplicationWindow::builder()
.application(app)
.title(gettext("Verification")) .title(gettext("Verification"))
.content_width(420) .icon_name(APP_ID)
.content_height(560) .default_width(420)
.default_height(560)
.content(&body)
.build(); .build();
let header = adw::HeaderBar::new(); let app = app.downgrade();
let body = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
body.append(&header);
body.append(&webview);
dialog.set_child(Some(&body));
// Taken on the first success *or* on close, so the token is sent at most
// once and a cancelled dialog always drops the sender.
let token_tx = Rc::new(RefCell::new(Some(token_tx)));
let tx = token_tx.clone();
let dlg_weak = dialog.downgrade();
content.connect_script_message_received(Some("hv"), move |_, value| { content.connect_script_message_received(Some("hv"), move |_, value| {
let Some(token) = extract_token(&value.to_str()) else { let Some(token) = extract_token(&value.to_str()) else {
// Every `postMessage` on the page reaches here, most of them the // Every `postMessage` on the page reaches here, most of them the
@@ -78,25 +174,17 @@ pub(crate) fn prompt_human_verification(
// is simply not ours. // is simply not ours.
return; return;
}; };
if let Some(tx) = tx.borrow_mut().take() { let mut stdout = std::io::stdout().lock();
let _ = tx.send(token); if let Err(e) = writeln!(stdout, "{TOKEN_PREFIX}{token}").and_then(|()| stdout.flush()) {
tracing::error!("cannot hand the verification token back: {e}");
} }
if let Some(dlg) = dlg_weak.upgrade() { if let Some(app) = app.upgrade() {
dlg.close(); app.quit();
} }
}); });
// Covers the close button, Escape, and the user giving up.
dialog.connect_closed(move |_| {
token_tx.borrow_mut().take();
});
webview.load_uri(url); webview.load_uri(url);
window.present();
let parent = ui.login.login_button.root().and_downcast::<gtk4::Window>();
// Returns immediately; the login worker stays blocked on the channel until
// the handler above sends, or the dialog closes and drops the sender.
dialog.present(parent.as_ref());
} }
/// Pull the verification token out of a message posted by the verification page. /// Pull the verification token out of a message posted by the verification page.
@@ -133,7 +221,12 @@ fn extract_token(raw: &str) -> Option<String> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::extract_token; use super::{Verification, extract_token, find_token, outcome};
use std::os::unix::process::ExitStatusExt as _;
use std::process::ExitStatus;
/// The wait status of a process killed by SIGABRT.
const ABORTED: i32 = 6;
#[test] #[test]
fn a_completion_message_yields_its_token() { fn a_completion_message_yields_its_token() {
@@ -163,4 +256,39 @@ mod tests {
assert!(extract_token(raw).is_none(), "accepted: {raw}"); assert!(extract_token(raw).is_none(), "accepted: {raw}");
} }
} }
#[test]
fn the_token_is_found_among_other_output() {
let out = "Gtk-Message: something\nhv-token tok-1\nmore\n";
assert_eq!(find_token(out.as_bytes()).as_deref(), Some("tok-1"));
assert_eq!(find_token("only chatter\n".as_bytes()), None);
}
#[test]
fn a_window_closed_without_a_token_cancels() {
assert_eq!(
outcome(None, ExitStatus::from_raw(0)),
Verification::Cancelled
);
}
/// WebKit aborts when its sandbox cannot start (B195). That must read as a
/// failure the app explains, not as the user giving up.
#[test]
fn a_window_that_died_fails() {
assert_eq!(
outcome(None, ExitStatus::from_raw(ABORTED)),
Verification::Failed
);
let exited_1 = ExitStatus::from_raw(1 << 8);
assert_eq!(outcome(None, exited_1), Verification::Failed);
}
#[test]
fn a_token_counts_even_if_the_window_died_after_it() {
assert_eq!(
outcome(Some("tok-1".into()), ExitStatus::from_raw(ABORTED)),
Verification::Solved("tok-1".into())
);
}
} }
+21
View File
@@ -0,0 +1,21 @@
use crate::*;
/// The "›" between two breadcrumb segments.
pub(crate) fn crumb_separator() -> gtk4::Label {
let sep = gtk4::Label::new(Some("›"));
sep.add_css_class("dim-label");
sep
}
/// The last breadcrumb segment, the folder on screen: a heading rather than a
/// button. It carries a flat button's side padding (`.crumb-current`), so the
/// "›" before it sits as far from it as from the button before.
pub(crate) fn crumb_current(label: &str) -> gtk4::Widget {
let l = gtk4::Label::builder()
.label(label)
.ellipsize(gtk4::pango::EllipsizeMode::Start)
.build();
l.add_css_class("heading");
l.add_css_class("crumb-current");
l.upcast()
}
+13 -4
View File
@@ -384,11 +384,20 @@ pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
} else { } else {
parent.to_string() parent.to_string()
}); });
// Pinning is per file: the context menu and the selection bar offer it for // Kept through a pinned folder above it, the entry has no pin of its own
// files only, so the pane must not offer it for folders either. // to remove, so the switch shows that state without offering to change it.
d.pin_row.set_active(entry.pinned); d.pin_row.set_active(entry.pinned);
d.pin_row.set_visible(!entry.is_dir); d.pin_row.set_subtitle(&if entry.pinned_by_folder {
d.pin_row.set_sensitive(*ui.mounted.borrow()); gettext("Kept on this computer because a folder it is in is available offline.")
} else if entry.is_dir {
gettext(
"Keep a copy of everything in this folder on this computer, even without a connection.",
)
} else {
gettext("Keep a copy on this computer, even without a connection.")
});
d.pin_row
.set_sensitive(*ui.mounted.borrow() && !entry.pinned_by_folder);
d.open_button.set_label(&if entry.is_dir { d.open_button.set_label(&if entry.is_dir {
gettext("Open folder") gettext("Open folder")
} else { } else {
+6 -1
View File
@@ -132,8 +132,13 @@ impl FileList {
// and the grid collapses to a single column. Allowing a mid-word // and the grid collapses to a single column. Allowing a mid-word
// break is what keeps the two-line-then-ellipsis budget below // break is what keeps the two-line-then-ellipsis budget below
// enforceable for *every* name rather than only the ones that // enforceable for *every* name rather than only the ones that
// happen to have spaces. // happen to have spaces. Pango marks such a break with a hyphen
// that is not in the name ("SteamSetup.e-xe"); a file name is
// not prose, so break it bare, as Nautilus does.
let no_hyphens = gtk4::pango::AttrList::new();
no_hyphens.insert(gtk4::pango::AttrInt::new_insert_hyphens(false));
let label = gtk4::Label::builder() let label = gtk4::Label::builder()
.attributes(&no_hyphens)
.ellipsize(gtk4::pango::EllipsizeMode::End) .ellipsize(gtk4::pango::EllipsizeMode::End)
.justify(gtk4::Justification::Center) .justify(gtk4::Justification::Center)
.max_width_chars(13) .max_width_chars(13)
+35 -9
View File
@@ -61,6 +61,18 @@ impl ActionMenu {
self self
} }
/// A greyed-out check item showing `active`, for a state the user cannot
/// change from here.
pub(crate) fn locked_toggle(&mut self, label: &str, active: bool) -> &mut Self {
let name = self.next_action();
let action = gio::SimpleAction::new_stateful(&name, None, &active.to_variant());
action.set_enabled(false);
self.group.add_action(&action);
self.section
.append(Some(label), Some(&format!("menu.{name}")));
self
}
/// Close the current section; the next item starts a new one below a /// Close the current section; the next item starts a new one below a
/// separator. An empty section is skipped, so callers can end a group of /// separator. An empty section is skipped, so callers can end a group of
/// conditional items unconditionally. /// conditional items unconditionally.
@@ -128,20 +140,34 @@ impl ActionMenu {
} }
} }
/// Where a menu opened over `anchor` should live in the widget tree: the /// Where a menu opened over `anchor` should live in the widget tree.
/// outermost grid or list view around it, or `anchor` itself. CSS descendant ///
/// selectors reach through a popover to its parent, and themes style the /// Not inside a list item: CSS descendant selectors reach through a popover to
/// insides of list items (`gridview > child box { margin: 12px }` in /// its parent, and themes style the insides of list items (`gridview > child
/// Catppuccin); a menu parented to a tile picks those rules up and puts a wide /// box { margin: 12px }` in Catppuccin); a menu parented to a tile picks those
/// margin around every section. Under the view itself it is no item's child. /// rules up and puts a wide margin around every section. So the search starts
/// at the outermost grid or list view around `anchor`.
///
/// Not on a widget without a layout manager either: a popover is sized and
/// placed again when its parent's layout manager allocates it. The list views
/// and `ScrolledWindow` have none, so a menu parented there kept the size it was
/// first given, often too small, and opened scrolling. The host is the first
/// widget from there up that has one.
fn popover_host(anchor: &gtk4::Widget) -> gtk4::Widget { fn popover_host(anchor: &gtk4::Widget) -> gtk4::Widget {
let mut host = anchor.clone(); let mut start = anchor.clone();
let mut widget = anchor.parent(); let mut widget = anchor.parent();
while let Some(w) = widget { while let Some(w) = widget {
widget = w.parent(); widget = w.parent();
if w.is::<gtk4::GridView>() || w.is::<gtk4::ListView>() || w.is::<gtk4::ColumnView>() { if w.is::<gtk4::GridView>() || w.is::<gtk4::ListView>() || w.is::<gtk4::ColumnView>() {
host = w; start = w;
} }
} }
host let mut host = Some(start.clone());
while let Some(w) = host {
if w.layout_manager().is_some() {
return w;
}
host = w.parent();
}
start
} }
+1
View File
@@ -1,4 +1,5 @@
pub(crate) mod compat; pub(crate) mod compat;
pub(crate) mod crumb;
pub(crate) mod details; pub(crate) mod details;
pub(crate) mod file_list; pub(crate) mod file_list;
pub(crate) mod menu; pub(crate) mod menu;
+69 -49
View File
@@ -127,13 +127,33 @@ impl Drop for Busy {
} }
} }
/// A section's action, such as Send Invitations. It goes under the section's
/// rows, at their right edge, rather than in a row of its own: a row in a boxed
/// list reads as a setting, and a button alone in one looks misplaced.
fn group_button(label: &str) -> gtk4::Button {
let button = gtk4::Button::builder()
.label(label)
.halign(gtk4::Align::End)
.margin_top(12)
.build();
button.add_css_class("suggested-action");
button
}
/// A section's only row while the share is being read.
fn loading_row() -> adw::ActionRow {
let row = adw::ActionRow::builder().title(gettext("Loading…")).build();
row.add_prefix(&spinner());
row
}
/// How an open Share dialog addresses its node. /// How an open Share dialog addresses its node.
/// ///
/// A node the browser is showing has a mountpoint-relative path. A node reached /// A node the browser is showing has a mountpoint-relative path. A node reached
/// from Shared or Shared-with-me may not: it can live under a device folder's /// from Shared or Shared-with-me may not: it can live under a device folder's
/// own inode space, or on someone else's volume, neither of which the primary /// own inode space, or on someone else's volume, neither of which the primary
/// mount's path space can name. Those carry a uid instead, and the daemon /// mount's path space can name. Those carry a uid instead, and the daemon
/// resolves it with `Core::resolve_anywhere` (mount-architecture.md P4). /// resolves it with `Core::resolve_anywhere`.
/// ///
/// The two are separate request variants rather than one request with an /// The two are separate request variants rather than one request with an
/// optional field on purpose: an older daemon that ignored `uid` would resolve /// optional field on purpose: an older daemon that ignored `uid` would resolve
@@ -287,26 +307,17 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
.selected(0) .selected(0)
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
let role_wrap = adw::ActionRow::builder().title(gettext("Role")).build(); // The role goes with the addresses it applies to, at the end of their row.
role_wrap.add_suffix(&role_drop); role_drop.set_tooltip_text(Some(&gettext("Role")));
email_row.add_suffix(&role_drop);
let message_row = adw::EntryRow::builder() let message_row = adw::EntryRow::builder()
.title(gettext("Message (optional)")) .title(gettext("Message (optional)"))
.build(); .build();
let invite_btn = gtk4::Button::builder() let invite_btn = group_button(&gettext("Send Invitations"));
.label(gettext("Send Invitations"))
.halign(gtk4::Align::End)
.margin_top(6)
.build();
invite_btn.add_css_class("suggested-action");
invite_btn.set_sensitive(false); invite_btn.set_sensitive(false);
let invite_wrap = adw::PreferencesRow::builder()
.activatable(false)
.child(&invite_btn)
.build();
invite_group.add(&email_row); invite_group.add(&email_row);
invite_group.add(&role_wrap);
invite_group.add(&message_row); invite_group.add(&message_row);
invite_group.add(&invite_wrap); invite_group.add(&invite_btn);
let people = adw::PreferencesGroup::builder() let people = adw::PreferencesGroup::builder()
.title(gettext("People with access")) .title(gettext("People with access"))
@@ -315,21 +326,18 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
.title(gettext("Public link")) .title(gettext("Public link"))
.build(); .build();
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 18); // Until the share is read, each section says so rather than standing
content.set_margin_top(12); // empty under its title.
content.set_margin_bottom(12); let people_loading = loading_row();
content.set_margin_start(12); people.add(&people_loading);
content.set_margin_end(12); let link_loading = loading_row();
content.append(&invite_group); link_group.add(&link_loading);
content.append(&people);
content.append(&link_group); let content = adw::PreferencesPage::new();
let clamp = adw::Clamp::builder().child(&content).build(); content.add(&invite_group);
let scroll = gtk4::ScrolledWindow::builder() content.add(&people);
.vexpand(true) content.add(&link_group);
.hscrollbar_policy(gtk4::PolicyType::Never) toolbar.set_content(Some(&content));
.child(&clamp)
.build();
toolbar.set_content(Some(&scroll));
let dialog = adw::Dialog::builder() let dialog = adw::Dialog::builder()
.title(pgettext("verb", "Share")) .title(pgettext("verb", "Share"))
@@ -343,8 +351,8 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
target, target,
people, people,
link_group, link_group,
people_rows: RefCell::new(Vec::new()), people_rows: RefCell::new(vec![people_loading.upcast()]),
link_rows: RefCell::new(Vec::new()), link_rows: RefCell::new(vec![link_loading.upcast()]),
}); });
// Check the addresses as they are typed: a malformed one marks the row as // Check the addresses as they are typed: a malformed one marks the row as
@@ -430,17 +438,35 @@ pub(crate) fn share_dialog_reload(state: &Rc<ShareDialog>) {
repaint_share_link(&state, link.as_ref()); repaint_share_link(&state, link.as_ref());
} }
Ok(Ok(Response::Error { message, .. })) => { Ok(Ok(Response::Error { message, .. })) => {
share_dialog_unread(&state);
toast_error(&state.ui, &gettext("Couldn't load sharing"), &message) toast_error(&state.ui, &gettext("Couldn't load sharing"), &message)
} }
_ => toast_error( _ => {
share_dialog_unread(&state);
toast_error(
&state.ui, &state.ui,
&gettext("Couldn't load sharing"), &gettext("Couldn't load sharing"),
&gettext("The Proton Drive service didn't respond."), &gettext("The Proton Drive service didn't respond."),
), )
}
} }
}); });
} }
/// The share couldn't be read: what the sections showed (rows still loading,
/// or ones that may now be stale) gives way to one line that says so.
fn share_dialog_unread(state: &Rc<ShareDialog>) {
for row in state.people_rows.borrow_mut().drain(..) {
state.people.remove(&row);
}
for row in state.link_rows.borrow_mut().drain(..) {
state.link_group.remove(&row);
}
let row = dim_row(&gettext("Couldn't load sharing"));
state.people.add(&row);
state.people_rows.borrow_mut().push(row.upcast());
}
/// Rebuild the "People with access" rows from a fresh share listing. /// Rebuild the "People with access" rows from a fresh share listing.
pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntry]) { pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntry]) {
for row in state.people_rows.borrow_mut().drain(..) { for row in state.people_rows.borrow_mut().drain(..) {
@@ -455,15 +481,18 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
return; return;
} }
for entry in entries { for entry in entries {
// A member is the plain case and needs no word; an invitation says
// it is still waiting on the other side.
let subtitle = match entry.kind { let subtitle = match entry.kind {
ShareEntryKind::Member => gettext("Member"), ShareEntryKind::Member => String::new(),
ShareEntryKind::ProtonInvite => gettext("Invited (pending)"), ShareEntryKind::ProtonInvite => gettext("Invitation pending"),
ShareEntryKind::ExternalInvite => gettext("Invited (external, pending)"), ShareEntryKind::ExternalInvite => gettext("Invitation pending, no Proton account yet"),
}; };
let row = adw::ActionRow::builder() let row = adw::ActionRow::builder()
.title(&entry.email) .title(&entry.email)
.subtitle(&subtitle) .subtitle(&subtitle)
.build(); .build();
row.add_prefix(&adw::Avatar::new(32, Some(&entry.email), true));
// External invites can't have their role changed; show it read-only. // External invites can't have their role changed; show it read-only.
// Members and Proton invites get a role dropdown. // Members and Proton invites get a role dropdown.
@@ -653,16 +682,7 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
.build(); .build();
let expiry_row = adw::ActionRow::builder().title(gettext("Expires")).build(); let expiry_row = adw::ActionRow::builder().title(gettext("Expires")).build();
expiry_row.add_suffix(&expiry_drop); expiry_row.add_suffix(&expiry_drop);
let create = gtk4::Button::builder() let create = group_button(&gettext("Create Public Link"));
.label(gettext("Create Public Link"))
.halign(gtk4::Align::End)
.margin_top(6)
.build();
create.add_css_class("suggested-action");
let create_wrap = adw::PreferencesRow::builder()
.activatable(false)
.child(&create)
.build();
// Enter in the password field creates the link. // Enter in the password field creates the link.
let btn = create.clone(); let btn = create.clone();
@@ -689,11 +709,11 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
state.link_group.add(&role_row); state.link_group.add(&role_row);
state.link_group.add(&pw_row); state.link_group.add(&pw_row);
state.link_group.add(&expiry_row); state.link_group.add(&expiry_row);
state.link_group.add(&create_wrap); state.link_group.add(&create);
rows.push(role_row.upcast()); rows.push(role_row.upcast());
rows.push(pw_row.upcast()); rows.push(pw_row.upcast());
rows.push(expiry_row.upcast()); rows.push(expiry_row.upcast());
rows.push(create_wrap.upcast()); rows.push(create.upcast());
} }
} }
*state.link_rows.borrow_mut() = rows; *state.link_rows.borrow_mut() = rows;
+5 -2
View File
@@ -77,13 +77,16 @@ pub(crate) fn icon_for(name: &str, is_dir: bool) -> gio::Icon {
} }
/// The sync badge of a Drive file, as in the app's browser: pinned, cached, /// The sync badge of a Drive file, as in the app's browser: pinned, cached,
/// or online only. Folders and local files have none. /// or online only. A folder has one only when kept offline; local files have
/// none.
pub(crate) fn badge_for(hit: &Hit) -> Option<(&'static str, &'static str)> { pub(crate) fn badge_for(hit: &Hit) -> Option<(&'static str, &'static str)> {
let Hit::Drive(drive) = hit else { let Hit::Drive(drive) = hit else {
return None; return None;
}; };
if drive.is_dir { if drive.is_dir {
return None; return drive
.pinned
.then_some(("pdfs-offline-symbolic", "badge-pinned"));
} }
Some(if drive.pinned { Some(if drive.pinned {
("pdfs-offline-symbolic", "badge-pinned") ("pdfs-offline-symbolic", "badge-pinned")
+172 -21
View File
@@ -22,7 +22,8 @@ use ksni::{MenuItem, Status, ToolTip, Tray};
use pdfs_core::auth; use pdfs_core::auth;
use pdfs_core::config::AppDirs; use pdfs_core::config::AppDirs;
use pdfs_core::control::{ use pdfs_core::control::{
Event, Feed, JobItem, Request, Response, TransferDirection, TransferItem, follow, send, Event, Feed, JobItem, Request, Response, TransferDirection, TransferItem, daemon_outdated,
follow, send,
}; };
use pdfs_core::service; use pdfs_core::service;
@@ -45,6 +46,8 @@ enum Phase {
Offline, Offline,
/// Changes keep failing; the user has to look. /// Changes keep failing; the user has to look.
Attention, Attention,
/// Mounted, but Proton refused the session: only signing in again helps.
SessionExpired,
/// Signed in, but no mount daemon is running. /// Signed in, but no mount daemon is running.
Disconnected, Disconnected,
SignedOut, SignedOut,
@@ -60,6 +63,7 @@ impl Phase {
Phase::Paused => "media-playback-pause-symbolic", Phase::Paused => "media-playback-pause-symbolic",
Phase::Offline => "network-offline-symbolic", Phase::Offline => "network-offline-symbolic",
Phase::Attention => "dialog-warning-symbolic", Phase::Attention => "dialog-warning-symbolic",
Phase::SessionExpired => "dialog-password-symbolic",
Phase::Disconnected | Phase::SignedOut => "network-offline-symbolic", Phase::Disconnected | Phase::SignedOut => "network-offline-symbolic",
} }
} }
@@ -83,13 +87,17 @@ struct DriveState {
paused: bool, paused: bool,
/// Queued changes that keep failing, for the "N issues" item. /// Queued changes that keep failing, for the "N issues" item.
failing: u64, failing: u64,
/// Whether the daemon is an older release than the tray, left running by
/// an upgrade; offers the restart that finishes it.
outdated: bool,
} }
/// Summarise a work snapshot into one menu line, or empty when idle. A single /// Summarise a work snapshot into one menu line, or empty when idle. A single
/// transfer names the file and its percentage; several collapse to counts so the /// transfer names the file and its percentage; several collapse to counts so the
/// menu stays one line regardless of queue depth. With nothing moving bytes, a /// menu stays one line regardless of queue depth. With nothing moving bytes, a
/// running job speaks for itself — a scan or an index rebuild is still "busy", /// running job speaks for itself — a scan or an index rebuild is still work
/// and the tray saying nothing there reads as "finished". /// going on, and the menu saying nothing there reads as "finished". The icon is
/// [`busy`]'s call, not this line's.
fn sync_line(items: &[TransferItem], jobs: &[JobItem]) -> String { fn sync_line(items: &[TransferItem], jobs: &[JobItem]) -> String {
match items { match items {
[] => match jobs.first() { [] => match jobs.first() {
@@ -143,12 +151,12 @@ struct DriveTray {
fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState { fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
match send(socket, &Request::Status) { match send(socket, &Request::Status) {
Ok(status @ Response::Status { .. }) => { Ok(status @ Response::Status { .. }) => {
// Same daemon is up, so a cheap follow-up request gives the sync line. // Same daemon is up, so a cheap follow-up request says what moves.
let sync = match send(socket, &Request::GetQueueStatus) { let (items, jobs) = match send(socket, &Request::GetQueueStatus) {
Ok(Response::Transfers { items, jobs }) => sync_line(&items, &jobs), Ok(Response::Transfers { items, jobs }) => (items, jobs),
_ => String::new(), _ => (Vec::new(), Vec::new()),
}; };
status_state(&status, sync, default_mountpoint) status_state(&status, &items, &jobs, default_mountpoint)
} }
// Socket answered but with something unexpected — treat as up but odd. // Socket answered but with something unexpected — treat as up but odd.
Ok(_) => unexpected_reply(default_mountpoint), Ok(_) => unexpected_reply(default_mountpoint),
@@ -162,10 +170,20 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
sync: String::new(), sync: String::new(),
paused: false, paused: false,
failing: 0, failing: 0,
outdated: false,
}, },
// No daemon: describe login state instead so the menu is still useful. // No daemon: describe login state instead so the menu is still useful.
Err(_) => { Err(_) => {
let (line, phase) = match auth::load() { let (line, phase) = match auth::load() {
Ok(s) if matches!(auth::key_state(), Ok(auth::KeyState::Locked)) => (
// Translators: {username} is the Proton account name. The
// account has a mailbox password that has to be entered.
gettext_f(
"Signed in as {username} — locked",
&[("username", &s.username)],
),
Phase::Attention,
),
Ok(s) => ( Ok(s) => (
// Translators: {username} is the Proton account name. // Translators: {username} is the Proton account name.
gettext_f( gettext_f(
@@ -189,6 +207,7 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
sync: String::new(), sync: String::new(),
paused: false, paused: false,
failing: 0, failing: 0,
outdated: false,
} }
} }
} }
@@ -205,12 +224,18 @@ fn unexpected_reply(default_mountpoint: &Path) -> DriveState {
sync: String::new(), sync: String::new(),
paused: false, paused: false,
failing: 0, failing: 0,
outdated: false,
} }
} }
/// The state a running daemon's [`Response::Status`] describes, with `sync` /// The state a running daemon's [`Response::Status`] describes, with `items`
/// saying what it is moving right now. /// and `jobs` saying what it is working on right now.
fn status_state(status: &Response, sync: String, default_mountpoint: &Path) -> DriveState { fn status_state(
status: &Response,
items: &[TransferItem],
jobs: &[JobItem],
default_mountpoint: &Path,
) -> DriveState {
let Response::Status { let Response::Status {
mountpoint, mountpoint,
pinned, pinned,
@@ -219,6 +244,9 @@ fn status_state(status: &Response, sync: String, default_mountpoint: &Path) -> D
pending_changes, pending_changes,
failing_ops, failing_ops,
paused, paused,
session_expired,
tokens_unsaved,
version,
.. ..
} = status } = status
else { else {
@@ -226,14 +254,29 @@ fn status_state(status: &Response, sync: String, default_mountpoint: &Path) -> D
}; };
let (pinned, online, failing_ops, paused) = (*pinned, *online, *failing_ops, *paused); let (pinned, online, failing_ops, paused) = (*pinned, *online, *failing_ops, *paused);
let queued = i18n::pending_summary(*pending_uploads, *pending_changes); let queued = i18n::pending_summary(*pending_uploads, *pending_changes);
let phase = phase_of( let outdated = daemon_outdated(version);
let phase = if *session_expired {
Phase::SessionExpired
} else if *tokens_unsaved || outdated {
Phase::Attention
} else {
phase_of(
paused, paused,
failing_ops, failing_ops,
online, online,
queued.is_some() || !sync.is_empty(), queued.is_some() || busy(items, jobs),
); )
};
DriveState { DriveState {
line: match (online, queued) { line: match (online, queued) {
// Outranks everything: it is why nothing else moves, and the one
// thing that fixes it is the user's to do.
_ if *session_expired => gettext("Session expired — sign in again"),
// Sync goes on meanwhile, but a reboot ends it until the next login.
_ if *tokens_unsaved => gettext("Sign-in not saved — unlock your keyring"),
// An upgrade replaced the files but not the running service, which
// keeps the old release's bugs until it restarts.
_ if outdated => gettext("Update not finished — restart Proton Drive"),
_ if paused => gettext("Sync paused"), _ if paused => gettext("Sync paused"),
_ if failing_ops > 0 => ngettext_f( _ if failing_ops > 0 => ngettext_f(
"{n} change needs attention", "{n} change needs attention",
@@ -269,11 +312,20 @@ fn status_state(status: &Response, sync: String, default_mountpoint: &Path) -> D
mounted: true, mounted: true,
paused, paused,
mountpoint: PathBuf::from(mountpoint), mountpoint: PathBuf::from(mountpoint),
sync, sync: sync_line(items, jobs),
failing: failing_ops, failing: failing_ops,
outdated,
} }
} }
/// Whether the daemon is changing something: bytes moving, or a job the user
/// is waiting on. A mirror folder's routine scan and the like are background
/// jobs; they still get the menu line, but the icon spinning every two minutes
/// for a scan that finds nothing reads as constant syncing.
fn busy(items: &[TransferItem], jobs: &[JobItem]) -> bool {
!items.is_empty() || jobs.iter().any(|j| !j.background)
}
/// The phase a running mount is in. A pause is the user's own doing and says /// The phase a running mount is in. A pause is the user's own doing and says
/// so first; failures outrank everything else the daemon reports. /// so first; failures outrank everything else the daemon reports.
fn phase_of(paused: bool, failing: u64, online: bool, busy: bool) -> Phase { fn phase_of(paused: bool, failing: u64, online: bool, busy: bool) -> Phase {
@@ -342,7 +394,7 @@ impl Tray for DriveTray {
fn status(&self) -> Status { fn status(&self) -> Status {
match self.state.phase { match self.state.phase {
Phase::Attention => Status::NeedsAttention, Phase::Attention | Phase::SessionExpired => Status::NeedsAttention,
_ => Status::Active, _ => Status::Active,
} }
} }
@@ -400,9 +452,19 @@ impl Tray for DriveTray {
// Nothing to mount without a session; the app's sign-in page is the // Nothing to mount without a session; the app's sign-in page is the
// way in. // way in.
Phase::SignedOut => items.push(item(&gettext("Sign In…"), |_| open_manager(&[]))), Phase::SignedOut => items.push(item(&gettext("Sign In…"), |_| open_manager(&[]))),
Phase::SessionExpired => items.push(item(&gettext("Sign In Again…"), |_| {
open_manager(&["--page", "login"])
})),
_ => items.push(item(&gettext("Open Proton Drive"), |_| open_manager(&[]))), _ => items.push(item(&gettext("Open Proton Drive"), |_| open_manager(&[]))),
} }
if self.state.outdated {
items.push(item(&gettext("Restart Proton Drive"), |this| {
service::restart();
this.state.line = gettext("Connecting…");
}));
}
if self.state.mounted { if self.state.mounted {
items.push( items.push(
StandardItem { StandardItem {
@@ -476,6 +538,10 @@ fn main() -> Result<()> {
return Ok(()); return Ok(());
} }
// The socket lives in the state dir, which nothing has made yet when the app
// starts the tray on a fresh install (B194).
dirs.ensure().context("create the app's directories")?;
// Ensure only one instance of the tray runs. // Ensure only one instance of the tray runs.
let tray_sock = dirs.tray_socket(); let tray_sock = dirs.tray_socket();
if std::os::unix::net::UnixStream::connect(&tray_sock).is_ok() { if std::os::unix::net::UnixStream::connect(&tray_sock).is_ok() {
@@ -534,11 +600,7 @@ fn main() -> Result<()> {
heard.take(feed); heard.take(feed);
} }
let st = match &heard.status { let st = match &heard.status {
Some(status) => status_state( Some(status) => status_state(status, &heard.items, &heard.jobs, &default_mountpoint),
status,
sync_line(&heard.items, &heard.jobs),
&default_mountpoint,
),
// Lost the daemon: ask once, which describes the sign-in instead. // Lost the daemon: ask once, which describes the sign-in instead.
None => poll_state(&socket, &default_mountpoint), None => poll_state(&socket, &default_mountpoint),
}; };
@@ -583,6 +645,95 @@ mod tests {
assert_eq!(phase_of(false, 0, true, false), Phase::Synced); assert_eq!(phase_of(false, 0, true, false), Phase::Synced);
} }
fn status(extra: &str) -> Response {
status_from(env!("CARGO_PKG_VERSION"), extra)
}
fn status_from(version: &str, extra: &str) -> Response {
serde_json::from_str(&format!(
r#"{{"Status":{{"username":"u","mountpoint":"/m","pinned":0,"used":0,"budget":0,
"pins":[],"online":false,"pending_uploads":3,"failing_ops":2,"paused":true,
"version":"{version}"{extra}}}}}"#
))
.unwrap()
}
/// Issue #35: a refused session read as "Offline — 3 uploads queued" for
/// good, and nothing said that signing in again was the way out.
#[test]
fn a_refused_session_outranks_everything_else() {
let state = status_state(
&status(r#","session_expired":true"#),
&[],
&[],
Path::new("/m"),
);
assert_eq!(state.phase, Phase::SessionExpired);
assert_eq!(state.line, "Session expired — sign in again");
}
/// B61: the keyring refusing the refreshed tokens was only in the log.
#[test]
fn a_sign_in_the_keyring_refused_needs_attention() {
let state = status_state(
&status(r#","tokens_unsaved":true"#),
&[],
&[],
Path::new("/m"),
);
assert_eq!(state.phase, Phase::Attention);
assert_eq!(state.line, "Sign-in not saved — unlock your keyring");
}
/// #33: a daemon an upgrade left running on the old release is named,
/// above a pause, and the menu offers the restart that finishes it.
#[test]
fn a_daemon_older_than_the_tray_needs_a_restart() {
let state = status_state(&status_from("0.1.0", ""), &[], &[], Path::new("/m"));
assert_eq!(state.phase, Phase::Attention);
assert_eq!(state.line, "Update not finished — restart Proton Drive");
assert!(state.outdated);
}
#[test]
fn a_daemon_that_does_not_say_has_a_live_session() {
let state = status_state(&status(""), &[], &[], Path::new("/m"));
assert_eq!(state.phase, Phase::Paused);
}
fn job(title: &str, background: bool) -> JobItem {
JobItem {
title: title.to_string(),
detail: String::new(),
done: 0,
total: 0,
background,
}
}
#[test]
fn a_routine_scan_is_not_busy_but_a_sync_is() {
assert!(!busy(&[], &[]));
assert!(!busy(&[], &[job("Checking Documents", true)]));
assert!(busy(
&[],
&[
job("Checking Documents", true),
job("Syncing Photos", false)
]
));
assert!(busy(&[], &[job("Uploading files", false)]));
}
#[test]
fn a_job_from_an_older_daemon_counts_as_busy() {
let job: JobItem = serde_json::from_str(
r#"{"title":"Checking Documents","detail":"","done":0,"total":0}"#,
)
.unwrap();
assert!(!job.background);
}
#[test] #[test]
fn the_issues_item_counts_in_the_singular_and_plural() { fn the_issues_item_counts_in_the_singular_and_plural() {
assert_eq!(issues_label(1), "1 issue — View"); assert_eq!(issues_label(1), "1 issue — View");
+1
View File
@@ -28,6 +28,7 @@ const STOCK: &[&str] = &[
"checkbox-symbolic", "checkbox-symbolic",
"computer-symbolic", "computer-symbolic",
"dialog-error-symbolic", "dialog-error-symbolic",
"dialog-password-symbolic",
"dialog-warning-symbolic", "dialog-warning-symbolic",
"document-edit-symbolic", "document-edit-symbolic",
"document-open-recent-symbolic", "document-open-recent-symbolic",
+536 -468
View File
File diff suppressed because it is too large. Load diff
+4105 -3184
View File
File diff suppressed because it is too large. Load diff
+199
View File
@@ -11,6 +11,193 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
## [Unreleased] ## [Unreleased]
## [3.4.0] — 2026-10-12
Schema: **42**, unchanged.
### Added
- **A binary cache for the Nix flake.** Every release and every build of `main` is uploaded to
`proton-drive-linux.cachix.org`, so NixOS installs download the client instead of compiling
it. See [Install → Binary cache](INSTALL.md#binary-cache).
([#37](https://github.com/narrrl/proton-drive-linux/issues/37))
- **Folders can be made available offline in the app.** The context menu, the details pane and
the selection bar now offer it for folders too. Every file in the folder and its subfolders
downloads in the background with its progress in the tray and the app, and pinned folders carry
the offline badge. A file kept through its folder shows as available offline but cannot be
made online only on its own. `pdfs pin` on a folder now returns at once instead of timing out
on a large folder, and pinned files download to disk instead of into memory.
### Fixed
- **Removing the package left the drive mounted, and deletes there still reached Proton Drive.**
The service kept running from the removed program, so deleting `~/ProtonDrive` afterwards moved
its files to the Proton Drive trash. The .deb, .rpm and Arch packages now stop the service of
every logged-in user before they are removed. Upgrades are not affected.
- **`pdfs move` between two locations could move a folder with a file open for writing in it**,
when the file had been opened just after it first reached Proton Drive. The write then landed
after the move. Such a move is refused now, as it always was meant to be (B207).
- **The Fedora package installed KDE packages on GNOME.** Its recommendation of KWallet is now
only for Plasma, the GNOME keyring only for desktops other than Plasma, and the AppIndicator
extension only for GNOME.
## [3.3.0] — 2026-10-11
Schema: **42**, unchanged.
### Added
- **A Nix flake for NixOS.** `nix build github:narrrl/proton-drive-linux` builds the client, and
the flake's NixOS module installs it and runs the mount in every graphical session. Pin the
input to a release tag such as `github:narrrl/proton-drive-linux/v3.3.0` to stay on releases.
See [Install → NixOS](INSTALL.md#nixos-flake).
([#37](https://github.com/narrrl/proton-drive-linux/issues/37))
### Fixed
- **A failed photo upload said "You're offline" while everything else worked.** Only a failed
connection now reads as offline. Other failures show what went wrong, and Upload is greyed out
on an account without a photo library.
([#38](https://github.com/narrrl/proton-drive-linux/issues/38))
- **After an upgrade, the old version kept running in the background, and nothing said so.**
Installing a package does not restart the service, so fixed bugs, such as an unreadable folder
stopping a synced folder, came back until the next login. The tray, the app, `pdfs status` and
`pdfs diagnose` now say when the service is older than the app, and the tray and the app offer
to restart it. ([#33](https://github.com/narrrl/proton-drive-linux/issues/33))
## [3.2.1] — 2026-10-11
Schema: **42**, unchanged.
### Fixed
- **Writing to a file again soon after a partial write could fail with an I/O error.** When the
first write changed only part of a file this client had not read, a second write, or saving
over the file, failed until the first one reached Drive. It now fetches the rest of the file
and goes ahead.
- **A CAPTCHA could not be solved on hardened systems.** When the verification page cannot open in
the app, as on secureblue, the app now offers it in your browser and continues the sign-in
once you are done. `pdfs login` now handles a CAPTCHA the same way instead of failing.
([#29](https://github.com/narrrl/proton-drive-linux/issues/29))
- **The daemon kept several CPU cores busy while offline with changes queued.** Its upload
workers checked the queue in a tight loop for as long as the outage lasted. They now sleep
until the connection comes back. ([#35](https://github.com/narrrl/proton-drive-linux/issues/35))
- **An expired sign-in was shown as "offline" for good.** When Proton refuses the session, the
tray now says "Session expired — sign in again" with a Sign In Again… item, the app shows the
same with a button, and `pdfs status` and `pdfs diagnose` tell you to run `pdfs login`. Cached
files stay readable and changes stay queued until you sign in.
([#35](https://github.com/narrrl/proton-drive-linux/issues/35))
- **The sign-in could be lost after `pdfs login`, `pdfs unlock` or a slow network.** The old
daemon wrote its spent tokens over a new sign-in when it stopped, a timeout could drop a token
refresh halfway, and a refresh was retried with a token Proton had already used. Each process
now writes only into its own session, a refresh always finishes, and it is never repeated.
([#35](https://github.com/narrrl/proton-drive-linux/issues/35))
- **A sign-in refreshed while the keyring was locked was lost on the next restart.** The client
now keeps retrying, holds the new tokens in the kernel keyring meanwhile, and the tray says
"Sign-in not saved — unlock your keyring" until it is written.
## [3.2.0] — 2026-10-08
Schema: **42**, unchanged.
### Fixed
- **One unreadable folder stopped a synced folder from syncing at all.** A folder you may not
read, such as a cache a container created, failed every pass, so nothing else in the synced
folder reached Drive. Such a folder is now left out and named once in the activity feed, and
everything around it syncs. A file deleted during a scan, and a name that is not UTF-8, no
longer fail the pass either.
([#33](https://github.com/narrrl/proton-drive-linux/issues/33))
- **Switching a folder to online-only could delete files that were never on Drive.** Ignored
folders such as `.git/`, symlinks, unreadable folders and files changed after the last sync were
deleted with the local copy. The switch now refuses and names what is only on this computer.
- **Switching to online-only trashed synced files that had become ignored.** Adding a synced path
to `.pdfsignore` and then switching moved it to the Drive trash. Ignoring never deletes now, as
the user guide says.
- **A stopped share stayed on the Shared by me page for minutes.** Stop Sharing now takes it off
the page at once, and an emptied share no longer shows with no people and no link.
- **The tray icon showed syncing every two minutes.** The routine check of a synced folder no
longer counts as syncing. Only work that changes files spins the icon. An edit in one synced
folder also no longer rescans all the others.
- **Right-click menus sometimes opened too short and had to be scrolled.**
- **The Share dialog looked unfinished.** Its buttons now sit under their sections instead of in a
row of their own. The role is chosen beside the addresses, people show with avatars, and both
sections show that they are loading.
- **Breadcrumbs spaced the "›" unevenly, and grid file names were broken with hyphens they do not
have.**
### Changed
- **One main menu.** The menu button beside "Proton Drive" is gone. Preferences, Keyboard
Shortcuts, About and Sign Out are all in the account row at the bottom of the sidebar. F10 opens
it.
### Added
- **Move another computer's backup to this one.** **Move to This Computer…** on the Computers page,
or `pdfs devices migrate UID`, moves every folder another computer backs up, for example an old
Windows PC, into this computer's backup on Proton Drive. Then it offers them for syncing here. The
folders move on Proton Drive itself, so nothing is uploaded again. The other computer stays in the
account, without those folders.
## [3.1.2] — 2026-10-08
Schema: **42**, unchanged.
### Fixed
- **The app crashed when Proton asked for a CAPTCHA on hardened systems.** On secureblue, WebKit
cannot start the sandbox for the verification page and ends its process, which took the app
down with it. The page now opens in a separate window process: if that process fails, the
sign-in stops with a message that says why, and TROUBLESHOOTING.md has a workaround.
([#29](https://github.com/narrrl/proton-drive-linux/issues/29))
- **On a fresh install the tray icon did not start.** The tray and the app now create their state
directory themselves instead of waiting for the service to do it. This also lets the Takeout
page remember staged archives before the first mount.
([#29](https://github.com/narrrl/proton-drive-linux/issues/29))
## [3.1.1] — 2026-10-07
Schema: **42**, unchanged.
### Changed
- **The client identifies itself with its release.** Sign-in and every API call used to send the
fixed `external-drive-linux@0.1.0-alpha` and `proton-drive-linux/0.1.0`. If Proton retired that
version, every release would fail to sign in at once and no update could fix it. The client now
sends `external-drive-linux@<version>-stable` and `proton-drive-linux/<version>`. `app_version`
and `user_agent` in `config.json` are optional overrides now. The fixed values that releases up
to 3.1.0 wrote there count as unset and are removed on the next save. A value you set by hand is
kept.
- The About dialog shows the release version, and its debug info shows the identity that is
actually sent.
## [3.1.0] — 2026-10-07
Schema: **42**, unchanged.
### Added
- **Two-password accounts.** An account with a separate mailbox password now signs in: the app
and `pdfs login` ask for it after the login password and check it against the account, so a
typo is a retry and not a daemon that never mounts. The mailbox password is never stored.
What it unlocks is kept in kernel memory for the login session, so it is asked for once per
session and is gone after a reboot or when you log out of the machine.
- **`pdfs unlock` and `pdfs lock`.** `pdfs unlock` enters the mailbox password of a locked
account and starts the daemon's mount; `pdfs lock` forgets it again. `--remember` on `login`
and `unlock` (and the "Keep unlocked on this computer" switch in the app) keeps the unlock in
the system keyring instead, so the daemon starts without asking; `pdfs lock --forget` removes
it. Off by default: it gives up the separation a second password is for.
- **Locked state.** `pdfs status`, `pdfs diagnose` and the tray say when the account is waiting
for its mailbox password, and the app asks for it when it opens.
### Changed
- **The keyring no longer holds a password.** The session blob keeps the keys' passphrases
instead of the mailbox password, and a blob written by 3.0.x is converted on the first start.
A stored password the account rejects (a two-password account that was signed in with only
its login password) leaves the account locked instead of failing every start.
- **The daemon is not dumpable.** It holds the unlock in memory, so core dumps and same-user
`ptrace` are off for it.
- **The offline cache is re-keyed** from the new secret, so the first start after the update
reads it as cold and fetches the tree again once.
### Fixed
- **systemd killed a daemon that was only retrying.** `WatchdogSec` was answered only once a
mount existed, so a daemon waiting for a login or retrying a failed mount was aborted after
two minutes and restarted in a loop, hiding the error it logged. It now answers the watchdog
while it waits. ([#26](https://github.com/narrrl/proton-drive-linux/issues/26))
- **A refreshed token could overwrite a newer unlock or revive a signed-out session.** Token
rotation now updates only the tokens in the stored blob.
## [3.0.0] — 2026-10-05 ## [3.0.0] — 2026-10-05
Schema: **42**. The queue table is rebuilt so an id is never given out twice; queued changes Schema: **42**. The queue table is rebuilt so an id is never given out twice; queued changes
@@ -1599,6 +1786,18 @@ First stable release: FUSE files-on-demand mount, sync daemon under `proton-driv
- The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated - The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated
by a new POSIX compliance suite for the filesystem. by a new POSIX compliance suite for the filesystem.
[3.4.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.4.0
[3.3.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.3.0
[3.2.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.2.1
[3.2.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.2.0
[3.1.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.1.2
[3.1.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.1.1
[3.1.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.1.0
[3.0.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.0.0 [3.0.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.0.0
[2.10.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.10.0 [2.10.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.10.0
+5 -4
View File
@@ -65,9 +65,9 @@ commands are for scripts and for cases where the mount is not available.
| Command | Description | | Command | Description |
|---|---| |---|---|
| `pdfs pin PATH` | Download a file completely and keep it on this computer. | | `pdfs pin PATH` | Download a file completely and keep it on this computer. For a folder, its files download in the background (`pdfs transfers` shows the progress). |
| `pdfs unpin PATH` | Release it back to online only. | | `pdfs unpin PATH` | Release it back to online only. A file inside a folder kept offline cannot be released on its own. |
| `pdfs pins` | List files kept available offline. | | `pdfs pins` | List files and folders kept available offline. |
## Synced folders ## Synced folders
@@ -83,7 +83,7 @@ commands are for scripts and for cases where the mount is not available.
| `pdfs sync limit [--up RATE] [--down RATE]` | Cap bandwidth, for example `500K` or `2M` per second. `0` removes a cap. Without options, prints the current caps. | | `pdfs sync limit [--up RATE] [--down RATE]` | Cap bandwidth, for example `500K` or `2M` per second. `0` removes a cap. Without options, prints the current caps. |
| `pdfs sync queue` | List changes that have not reached Proton Drive yet. | | `pdfs sync queue` | List changes that have not reached Proton Drive yet. |
| `pdfs sync issues` | List queued changes Proton Drive refused or that keep failing, and what to do. | | `pdfs sync issues` | List queued changes Proton Drive refused or that keep failing, and what to do. |
| `pdfs sync export ID DEST` | Save a copy of a queued upload's content. The upload stays queued. | | `pdfs sync export ID DEST` | Save a copy of a queued upload's content. `DEST` may be a folder; an existing file is never overwritten. The upload stays queued. |
| `pdfs sync discard ID [--yes]` | Drop a queued change and undo it here: a new file or folder goes, anything else goes back to what Proton Drive has. Content not exported first is lost. | | `pdfs sync discard ID [--yes]` | Drop a queued change and undo it here: a new file or folder goes, anything else goes back to what Proton Drive has. Content not exported first is lost. |
| `pdfs sync retry [ID]` | Retry one queued operation, or every failed one. | | `pdfs sync retry [ID]` | Retry one queued operation, or every failed one. |
| `pdfs sync restore [--device UID] [--yes]` | Re-attach this computer's backed-up folders to local folders and download them. `--device` restores another computer's folders. See [RECOVERY.md](RECOVERY.md). | | `pdfs sync restore [--device UID] [--yes]` | Re-attach this computer's backed-up folders to local folders and download them. `--device` restores another computer's folders. See [RECOVERY.md](RECOVERY.md). |
@@ -103,6 +103,7 @@ commands are for scripts and for cases where the mount is not available.
| `pdfs devices rename UID NAME` | Rename a computer. | | `pdfs devices rename UID NAME` | Rename a computer. |
| `pdfs devices rm UID` | Remove a computer and its backup from the account. | | `pdfs devices rm UID` | Remove a computer and its backup from the account. |
| `pdfs devices adopt UID` | Make this machine continue another computer's backup, for example after a reinstall. `--clear` returns to matching by hostname. | | `pdfs devices adopt UID` | Make this machine continue another computer's backup, for example after a reinstall. `--clear` returns to matching by hostname. |
| `pdfs devices migrate UID [--yes]` | Move another computer's folders into this computer's backup, then restore them here like `pdfs sync restore`. The other computer stays registered, without those folders. `--yes` skips the confirmation and accepts every proposed path. See [RECOVERY.md](RECOVERY.md). |
## Trash ## Trash
+13 -6
View File
@@ -38,7 +38,7 @@ Inside `content/`:
> not reached Proton Drive and exists nowhere else. > not reached Proton Drive and exists nowhere else.
Everything in these directories except the keyring is **decrypted**. See Everything in these directories except the keyring is **decrypted**. See
[ARCHITECTURE.md §8](ARCHITECTURE.md#8-threat-model-what-this-client-writes-to-disk-in-plaintext). [ARCHITECTURE.md §10](ARCHITECTURE.md#10-threat-model-what-this-client-writes-to-disk-in-plaintext).
## `config.json` ## `config.json`
@@ -89,7 +89,11 @@ fix it.
| `open_with` | object | `xdg-open` | — | See [`open_with`](#open_with) | | `open_with` | object | `xdg-open` | — | See [`open_with`](#open_with) |
| `prompt` | object | built-in window | — | See [`prompt`](#prompt) | | `prompt` | object | built-in window | — | See [`prompt`](#prompt) |
`app_version` and `user_agent` identify the client to Proton. Leave them alone. `app_version` and `user_agent` identify the client to Proton. When the keys are absent, the
client sends its own release (`external-drive-linux@<version>-stable`, `proton-drive-linux/<version>`).
Leave them out unless a support case asks for a different value. The fixed
`external-drive-linux@0.1.0-alpha` and `proton-drive-linux/0.1.0` that releases up to 3.1.0 wrote
here are treated as absent and removed on the next save.
### `ignore_patterns` ### `ignore_patterns`
@@ -190,13 +194,16 @@ Command-line flags win over the file: `--gtk`, `--dmenu`, `--fzf` and `--menu`.
| `PDFS_LOCALEDIR` | app, tray, prompt | Load translations from another directory (development) | | `PDFS_LOCALEDIR` | app, tray, prompt | Load translations from another directory (development) |
| `PDFS_STAND_IN_WIDGETS` | app | `1` uses the libadwaita 1.5 fallback widgets on newer systems (development) | | `PDFS_STAND_IN_WIDGETS` | app | `1` uses the libadwaita 1.5 fallback widgets on newer systems (development) |
The acceptance suite has its own `PDFS_ACCEPTANCE_*` variables, listed in [TESTING.md](TESTING.md). The tests have their own variables (`PDFS_ACCEPTANCE_*`, `PDFS_SIM_*`, `PDFS_MIGRATE_DB`),
listed in [TESTING.md](TESTING.md).
## The systemd service ## The systemd service
`proton-drive.service` is a user unit. It restarts the service whenever it exits, restarts it when `proton-drive.service` is a user unit started with your graphical session
it stops answering for two minutes (`WatchdogSec=120`), and caps its memory at 6 GiB. To change (`WantedBy=graphical-session.target`). It restarts the service whenever it exits, restarts it
the unit without editing the packaged file, use a drop-in: when it stops answering for two minutes (`WatchdogSec=120`), throttles it above 2 GiB of memory
(`MemoryHigh`) and stops it at 6 GiB (`MemoryMax`). To change the unit without editing the
packaged file, use a drop-in:
```bash ```bash
systemctl --user edit proton-drive.service systemctl --user edit proton-drive.service
+23 -9
View File
@@ -31,12 +31,12 @@ time when the installed library has them (`crates/pdfs-gui/src/compat.rs`).
| Path | Contents | | Path | Contents |
|---|---| |---|---|
| `crates/pdfs-core` | Shared library: authentication and keyring (`auth`), configuration (`config`), SQLite schema and queries (`db/`), content cache (`cache`), control protocol (`control`), search scoring, sync ignore rules, Google Takeout parsing, machine profile | | `crates/pdfs-core` | Shared library: authentication and keyring (`auth`), configuration (`config`), SQLite schema and queries (`db/`), content cache (`cache`), control protocol (`control`), search scoring, sync ignore rules, Google Takeout parsing, machine profile |
| `crates/pdfs-fuse` | The daemon: FUSE filesystem, read path (`reads`), write queue (`queue`, `drain`, `upload`), sync engine (`sync/`), remote events (`background`), push events for front ends (`events`), control handlers (`control`), photos, devices, supervisor | | `crates/pdfs-fuse` | The daemon: FUSE handlers (`filesystem`), the tree (`state`), reads (`reads`), the queue and the drain (`queue`, `drain`, `upload`), link state (`link`), the sync engine (`sync`, `sync/`), moves between locations (`relocate`), remote events (`background`), push events (`events`), control handlers (`control`), photos, devices, the supervisor, and the simulation tests (`sim/`) |
| `crates/pdfs-cli` | The `pdfs` binary | | `crates/pdfs-cli` | The `pdfs` binary |
| `crates/pdfs-gui` | `pdfs-app`, `pdfs-tray` and `pdfs-prompt`. Pages in `src/app/pages/`, shared widgets in `src/app/widgets/`, CSS and icons in `resources/` | | `crates/pdfs-gui` | `pdfs-app`, `pdfs-tray` and `pdfs-prompt`. Pages in `src/app/pages/`, shared widgets in `src/app/widgets/`, CSS and icons in `resources/` |
| `po/` | Translation template, catalogs and the scripts that maintain them | | `po/` | Translation template, catalogs and the scripts that maintain them |
| `packaging/` | systemd unit, desktop files, icon, PKGBUILD, RPM spec, AUR recipes | | `packaging/` | systemd unit, desktop files, icon, PKGBUILD, RPM spec, AUR recipes |
| `scripts/` | FUSE acceptance suite, map-data generators | | `scripts/` | FUSE acceptance suite, map-data generators, the wiki publisher |
| `docs/` | This documentation | | `docs/` | This documentation |
The GUI talks to the daemon only through the control socket. Anything a front end needs that the The GUI talks to the daemon only through the control socket. Anything a front end needs that the
@@ -87,8 +87,9 @@ cargo test --workspace --locked
scripts/fuse-acceptance.sh --offline-only scripts/fuse-acceptance.sh --offline-only
``` ```
For changes to the FUSE layer, the write queue or the sync engine, also run the live acceptance For changes to the FUSE layer, the queue or the sync engine, also run the
cases described in [TESTING.md](TESTING.md). [simulation runs](TESTING.md#simulation-runs), which CI runs only after a push to `main`, and the
[acceptance suite](TESTING.md#acceptance-suite) on a test account.
For changes to GUI strings, check that every catalog is complete: For changes to GUI strings, check that every catalog is complete:
@@ -136,6 +137,15 @@ file to `po/POTFILES.in`, run `po/update.sh`, and translate the new entries in e
`#[serde(default)]`, so older files still load, and document it in `#[serde(default)]`, so older files still load, and document it in
[CONFIGURATION.md](CONFIGURATION.md). [CONFIGURATION.md](CONFIGURATION.md).
**A bug fix.** Add or update the entry in [BUGS.md](BUGS.md#adding-an-entry), cover the fix with a
unit test, a simulation scenario or a `regression B<n>` acceptance case, and cite the entry in the
code as `docs/BUGS.md B<n>`. Do not cite planning notes that are not in the repository.
**A user-visible change.** Update the matching page (user guide, CLI reference, configuration)
and the `[Unreleased]` section of [CHANGELOG.md](CHANGELOG.md). `scripts/publish-wiki.sh` copies
the six user pages (`INSTALL`, `USER_GUIDE`, `CLI`, `CONFIGURATION`, `TROUBLESHOOTING`, `RECOVERY`)
to the GitHub wiki, so keep their file names.
## Releasing ## Releasing
Releases are cut from `main` by pushing a `v*` tag. `.github/workflows/release.yml` then: Releases are cut from `main` by pushing a `v*` tag. `.github/workflows/release.yml` then:
@@ -143,15 +153,19 @@ Releases are cut from `main` by pushing a `v*` tag. `.github/workflows/release.y
1. checks that the tag, the workspace version in `Cargo.toml` and `pkgver` in 1. checks that the tag, the workspace version in `Cargo.toml` and `pkgver` in
`packaging/PKGBUILD` agree; `packaging/PKGBUILD` agree;
2. runs the quality gates; 2. runs the quality gates;
3. builds the release binaries and packages a `.tar.gz`, a `.deb` and a GPG-signed `.rpm`; 3. builds the release binaries and packages a `.tar.gz`, a `.deb` and a GPG-signed `.rpm`, and
4. publishes them as a GitHub release; builds the Nix flake at the tag, which is what a flake input pinned to `vX.Y.Z` gets, and
pushes it to the `proton-drive-linux` Cachix cache (secret `CACHIX_AUTH_TOKEN`);
4. publishes them as a GitHub release, with the version's section of `docs/CHANGELOG.md` as the
notes;
5. calls `.github/workflows/aur.yml`, which updates and pushes the AUR recipes and commits them 5. calls `.github/workflows/aur.yml`, which updates and pushes the AUR recipes and commits them
back to `main`. back to `main`.
To prepare a release: To prepare a release:
```bash ```bash
# 1. Bump the version in Cargo.toml and packaging/PKGBUILD, then refresh the lock file. # 1. Bump the version in Cargo.toml, packaging/PKGBUILD, po/pdfs.pot and the pinned flake
# URL in docs/INSTALL.md, then refresh the lock file.
cargo update --workspace cargo update --workspace
# 2. Move the [Unreleased] notes in docs/CHANGELOG.md under the new version. # 2. Move the [Unreleased] notes in docs/CHANGELOG.md under the new version.
# 3. Commit, tag and push. # 3. Commit, tag and push.
@@ -160,5 +174,5 @@ git tag vX.Y.Z
git push origin main vX.Y.Z git push origin main vX.Y.Z
``` ```
Before a release that touches the filesystem or sync, run the managed live mode matrix from Before a release, work through [Before a release](TESTING.md#before-a-release). Open
[TESTING.md](TESTING.md). Open release-assurance items are listed in [ROADMAP.md](ROADMAP.md). release-assurance items are listed in [ROADMAP.md](ROADMAP.md#release-assurance).
+71 -2
View File
@@ -82,6 +82,68 @@ paru -S proton-drive-for-linux # or yay, or any other AUR helper
The recipes live in [`packaging/aur/`](../packaging/aur/README.md). The recipes live in [`packaging/aur/`](../packaging/aur/README.md).
### NixOS (flake)
The repository is a flake. Add it as an input and enable the module, which installs the package,
links the user service into every graphical session and turns on the `fusermount3` wrapper and
GNOME Keyring for you:
```nix
{
inputs.proton-drive-linux.url = "github:narrrl/proton-drive-linux";
outputs = { nixpkgs, proton-drive-linux, ... }: {
nixosConfigurations.<host> = nixpkgs.lib.nixosSystem {
modules = [
proton-drive-linux.nixosModules.default
{ services.proton-drive-linux.enable = true; }
];
};
};
}
```
That URL follows `main`. To stay on releases, pin the input to a release tag instead, and change
the tag to update:
```nix
inputs.proton-drive-linux.url = "github:narrrl/proton-drive-linux/v3.3.0";
```
Each release tag is built with Nix before the release is published.
#### Binary cache
CI uploads every build of `main` and of each release tag to a Cachix binary cache, so Nix can
download the client instead of compiling it. A system build does not read a flake input's cache
settings, so add the cache to your NixOS configuration once:
```nix
nix.settings = {
substituters = [ "https://proton-drive-linux.cachix.org" ];
trusted-public-keys = [
"proton-drive-linux.cachix.org-1:w1+3pXVXyTlqkFGn3dWpUhlaJtyGhSm3X0mFwfa++AA="
];
};
```
The cache holds the build against the `nixpkgs` this flake locks. Setting
`inputs.proton-drive-linux.inputs.nixpkgs.follows = "nixpkgs"` builds against yours instead, which
the cache does not have, so Nix compiles it. `nix run` and `nix shell` ask whether to use the
cache the first time; answer yes, or run `cachix use proton-drive-linux`.
To try it without changing your system, run the app straight from the flake:
```bash
nix run github:narrrl/proton-drive-linux#proton-drive-linux -- --help # the CLI
nix shell github:narrrl/proton-drive-linux -c pdfs-app # the desktop app
```
The mount itself needs the setuid `fusermount3` from NixOS (`programs.fuse.enable`, on by default
with the module) and a Secret Service such as GNOME Keyring or KWallet. To leave out ffmpeg and
with it video thumbnails, override the package with `withFfmpeg = false`. The recipe lives in
[`packaging/nix/`](../packaging/nix/).
### Other distributions ### Other distributions
Unpack the `.tar.gz` into a directory on your `PATH`, then install the service unit as shown in Unpack the `.tar.gz` into a directory on your `PATH`, then install the service unit as shown in
@@ -240,8 +302,15 @@ pdfs logout # removes the session from the
``` ```
Then remove the package (`sudo apt remove proton-drive-linux`, `sudo dnf remove Then remove the package (`sudo apt remove proton-drive-linux`, `sudo dnf remove
proton-drive-linux`, or `sudo pacman -R proton-drive-for-linux`), and, if you want no trace left, proton-drive-linux`, or `sudo pacman -R proton-drive-for-linux`). Removing the package also stops
the local data: the service of every logged-in user, in case a step above was skipped.
> [!CAUTION]
> Do not delete `~/ProtonDrive` while it is mounted. It is your Proton Drive, not a copy: every
> file deleted there is moved to the Proton Drive trash. Once the service is stopped the folder is
> an empty mount point, and removing it is safe.
If you want no trace left, remove the local data as well:
```bash ```bash
rm -rf ~/.config/proton-drive-linux ~/.local/state/proton-drive-linux ~/.cache/proton-drive-linux rm -rf ~/.config/proton-drive-linux ~/.local/state/proton-drive-linux ~/.cache/proton-drive-linux
-182
View File
@@ -1,182 +0,0 @@
# 3.0.0: what is done and what is left
Tracks `docs/MILESTONE-3.0.0.md`. Update it with every commit that moves a phase.
## Done
- **Phase 0**: fake Drive, simulation runs, CI job (2.9.0).
- **Phase 1**: queued-op preconditions and own sealed revisions in the database (2.9.0).
- **Phase 2**: local ids, inode = local id, one tree for every mount (2.10.0).
- **Phase 3, first part**: mkdir, create, rename, unlink and rmdir on the mount are recorded
locally and sent from the queue; `"local_first": false` brings back the old path (5e0f30c).
- Fixes from the simulation and account runs since then: B125, B127, B130 and B141 to B193.
- **Sync issues, first cut** (§6.2): refusals sorted into issues, shown at once in the app,
tray and CLI; Export button and `pdfs sync export`; `pdfs sync issues`; Discard button and
`pdfs sync discard`; a warning badge on the node in the file browser.
## Phase 3: left for 3.0.0
1. **Sync issues (§6.2)**: first cut done.
- [x] The drain sorts a refusal from Drive into an issue kind (quota, access, missing,
limit, rejected) and stores it on the queued op (schema 38).
- [x] An op with an issue counts as "needs attention" at once, not after six failures.
- [x] The queue listing carries the issue kind and whether its content can be exported.
- [x] Export a queued file's content (control request, `pdfs sync export`).
- [x] `pdfs sync issues`, and the issue shown in `pdfs sync queue`.
- [x] The app's Sync page shows each issue with what the user can do; Export and Retry
buttons; translated in every catalog.
- [x] A name collision lands under a conflict name (create, rename, move, re-homed create)
and goes in the activity log, as does a queued edit kept as a conflict copy. The app
words each one in the user's language (the last one since B181).
- [x] Discard an issue on purpose (after export, or for a change with no content), and
undo the local change it stood for: a node made here goes, a node Drive has goes back
to what Drive has. Control request, `pdfs sync discard`, Discard button with a
confirmation; translated in every catalog.
- [x] Show the issue on the node itself: a folder listing carries each node's issue, and
the file browser shows a warning badge with what it means. The listing is published
again when the set of issues changes.
2. **Planner and executor (§5.4, §5.5)**: ops keyed by local id; remove `mint_local_uid`,
`finish_create` and `adopt_real_uid`; a landing create sets `remote_uid` on its row.
- [x] Queued ops carry `lid` and `parent_lid` (schema 39). The claim reads the parent's uid
through `parent_lid`, and a landing folder rewrites the ops inside it by local id.
- [x] Node rows link to their parent by lid (`nodes.parent_lid`, schema 40), and a restart
places each node under its parent's row as it is now.
- [x] Queries over children, subtrees and ancestors by `parent_lid`, and no rewrite of the
children's `parent_uid` when a folder lands (R3), nor of the ops made inside it (R2).
- [x] No more placeholders: a node made here goes by its local id until it lands. In four
parts:
- [x] 6a: a node made here goes by `local~<lid>`, a function of its row, instead of a
minted `local~<ms>-<n>`; schema 41 rewrites the old placeholders (nodes, ops, pins).
- [x] 6b: an op is found by its node's local id under either uid, the claim keeps one op
per local id on the wire, and the drain sends the uid the row has now;
`remap_local_uid` is gone.
- [x] 6c: children, subtrees, ancestors and ops' parents by lid, so a landing create
rewrites no other row or op; what is read for a uid names the parent as its row has it
now. The row keeps its stand-in in `nodes.uid` rather than `NULL`: it is as stable,
and `NULL` would have changed some 40 node queries for no change in behaviour. Pins
stay keyed by uid and move with the row in the landing transaction (B148).
- [x] 6d: a node written under its stand-in after it landed updates its landed row
instead of bringing the placeholder row back, so a write that finds its create
landed takes the uid from the row and no longer polls the tree for up to 2 s
(`landed_uid` is gone). In memory, `pending`, the uploads and the tree stay keyed by
the uid Drive knows: ops are matched by lid in the database (6b), `hidden` and
`creating` only ever hold remote uids, and `landed_row_uid` is the one place at the
mount's edge where a stand-in a handle still holds becomes the real uid.
- [x] An executor that runs ops in dependency order, many at once: 16 drain workers instead
of 3, and a worker that lands an op wakes the idle ones, which slept through the ops it
made claimable. The claim already keeps the order: a create waits for its parent's
uid, a node's ops go one at a time. Ops stay blocking calls on worker threads: each
one spends its time waiting on Drive, so tasks would buy nothing threads do not.
3. **Speed (§7)**: run independent ops in parallel, batch trashes and moves; measure 1,000 files
and 100 folders on Wi-Fi against LAN (target: within 10 %).
- [x] The measurement: `a_thousand_files_drain_as_fast_on_wifi_as_on_lan` in `sim/daemon.rs`,
run with `PDFS_SIM_MEASURE=1`. It prints the times and asserts nothing yet.
- [x] Baseline on eb1c105, three drain threads: the syscalls take 3.7 s on LAN and 4.3 s on
Wi-Fi; the drain takes 138 s on LAN and 938 s on Wi-Fi, 6.8 times as long.
- [x] Sixteen workers: the syscalls take 5.2 s on LAN and 5.0 s on Wi-Fi, which meets the
target; the drain takes 47 s on LAN and 247 s on Wi-Fi. Most of a create on Wi-Fi is
its read-back waiting for Drive to list the new node (250 ms, then 1 s), so batching
(step 8) helps trashes and moves, not creates.
- [x] Batched trashes and moves: left for after 3.0.0 (see below). The target is met
without them, the drain waits on the creates' read-back, and splitting one answer into
many results is where the B15x to B17x races lived.
4. **Done-when checks**:
- [ ] Simulation passes on the Wi-Fi profile: `one_client_on_a_slow_link` failed in CI on
seeds 2 and 3. Two bugs (B127, B151) and a harness gap: a settle did not read the log, so
it could not explain a known bug's damage. All three fixed; not yet confirmed in CI.
Replays then found two races on the LAN profile too (B152, B153), both fixed. CI on
7269cb5 failed LAN seed 9, a regression from the B153 fix (B154), and Wi-Fi seed 2,
a write whose answer was lost (B155). Both fixed. One slow-link run of seed 2 failed
once with a rename answering `ENOENT` as a folder move landed. Not reproduced since;
it may be B152. Replays of slow-link seed 4 failed two of six times on a file written
again after its create lost its answer (B156, fixed). With sixteen drain workers
(16fca10), seed 4 failed every replay on a write after an upload that was not read
back (B157, fixed). Seed 3 then failed four of ten runs on two renames of one file
landing out of order (B158, fixed). Flaky-link seed 4 failed one of ten runs on a
lookup that answered `EAGAIN` while the root was listed again after a restart (B159,
fixed). Slow-link seed 4 also had folders reach Drive 30 s late, after the
drain's idle poll (B160, fixed). Slow-link seed 3 failed on a file moved while its
create was on the wire, which the mount showed under its old name (B161, fixed).
Seed 4 failed on a folder removed and made again, whose create adopted the removed
folder (B162, fixed). CI on 903452a passed every profile. CI on f9b1638 failed
slow-link seed 2: a step that unlinks an open file, appends and reads it back hung
for 150 s. The B130 matcher did not fire, and CI kept no stacks; one replay passed.
It may be a kernel notice sent from a runtime worker by another path than B130's,
which a68dae9 (not yet pushed then) also takes off the runtime. CI now keeps a hung
run's stacks.
- [x] Known-bug list in `sim/run.rs` is empty: the run on 812eb3d (`PDFS_SIM_KNOWN=fail`,
twelve seeds) passed every profile, so the matchers are gone. That confirms B125 to
B133; B134 stays open with `local_first` off only, which the runs do not mount. The confirmation run (`PDFS_SIM_KNOWN=fail`)
on da013c2 failed `one_client_on_a_flaky_link` seeds 6 and 11 on two new bugs, both
after restarts: a write over a create never read back (B182) and a trash that forgot
what its create sent (B183). Replays with those fixed found a third: a create renamed
after it lost its answer (B184). All three fixed; no known-bug matcher fired. It was
stopped before `three_clients`. The rerun on 3073052 found a create that waited out
a retry after a write replaced its blob (B185, fixed), and a file deleted offline
while open that lost the bytes its handle read (B186, fixed; older than 3.0.0).
The run on 2baad67 found that a restart before such a file's trash went out queued
its kept bytes again, for good (B188, fixed), and changes that sat out their retry
after an outage only the drain had met (B189, fixed; older than 3.0.0), and a file
saved just before a stop whose release the stop dropped (B190, fixed; older too). The run
on 1436c5f found a file deleted after its failed create was written again left on Drive
(B191, fixed). Its test for B193 found a new empty file waiting out the idle poll (B192,
fixed), and seed 6 a deleted file's trash taking a file of the user's renamed to the name
its create was sent under (B193, fixed).
- [ ] A clean account run. The one on a6f36d5 (2026-10-04, LAN: 427 passed, 12 failed)
failed B113 and B121 on every on-demand mount, two mirror-to-mirror move cases, and
the journal check. B121 was a bug (B149, fixed); B113's case still expected creates
to reach Drive at once and now accepts queued ones. The move cases were the harness:
it retried every source after one was busy, and expected a refusal where a mirror to
mirror move carries the local copy along. The journal showed a conflict copy retrying
into a deleted folder (B150, fixed). Not confirmed yet. The run on 2.10.0
(2026-10-04: 260 passed, 3 failed) failed a new file that read back empty (B163,
fixed), a 128-entry folder that listed 129 (B164, fixed), and its cleanup, which ran
while the daemon was restarting: the stop waited on a busy thread until systemd killed
it (B165, bounded). The next run on the working tree (2026-10-04: 259 passed, 4
failed) found a wide folder dropping files whose create landed while it was listed
(B166, fixed), or listing one empty: the new file that read back empty again
(B166 too). Its cleanup found no control socket because it ran 1.5 s after the
last restart, before the new daemon had bound it: `pdfs status` reports that as not
running, which the run's queue wait takes for an empty queue (the script's side).
Looking into it found the start spending 1.7 s rebuilding listings (B167, fixed).
Its journal check caught a file removed with its folder while uploading being
sent again, to the root (B168, fixed), and a stop that let a mirror pass go on
until the runtime ended under it (B169, fixed). CI on 9bbc994 failed
`one_client_on_a_good_link`: a write opened as an upload landed took the revision
it replaced as its base, and the next write failed with `EIO` (B170, fixed). The
suite's seed 9 found a deleted file's trash waiting out its create's backoff (B171,
fixed). Run three at a time on 2502af6, seed 8 failed two of nine runs: a file renamed
while its upload was read back showed its old name again (B172, fixed). The loop
on d850265 failed seed 12 once in nine runs: a file deleted just as its create
landed stayed on Drive (B173, fixed). The suite on 378667f failed seed 6: a rename
in the same gap was refused with `EACCES` (B174, fixed). The loop on 378667f failed
seed 8: a file replaced by a rename in that gap stayed on Drive (B175, fixed), and
seed 12: a write closed as its file's create landed was dropped (B176, fixed).
The account run on 378667f: a folder listed as its create landed asked Drive by
its stand-in and answered `EIO` (B177, fixed). A folder trashed while sync was
paused kept a create below it as a trash: a paused drain worker had claimed it
(B178, fixed). A file read as its create landed read empty (B179, fixed).
The suite on 0f3c06f: a stop gave up on a drain worker that slept through its
wake-up (B180, fixed). The account run on 903452a (2026-10-04, Wi-Fi: 262 passed,
1 failed) failed only its cleanup, which found no control socket: after the mirror
target's last restart the queue wait took the missing daemon for an empty queue
(the script's side, fixed in 6539c73).
5. **Release**: 2.10.0 is tagged; the 2.9.0 tag still waits for the user's go-ahead.
## After 3.0.0
- Remove the old online path in `filesystem.rs` and the `local_first` switch (the release after
3.0.0).
- **Phase 4**: one applier for remote changes.
- **Phase 5**: mirror folders on the same planner and executor.
- **Batched trashes and moves** (plan step 8). Not needed for the §7 target: the syscalls take
5.2 s on LAN and 5.0 s on Wi-Fi. The drain's time goes to the creates' read-back (250 ms,
then 1 s each), which batching does not shorten. A batch call answers for many ops at once,
and splitting that answer back per op is the step the B15x to B17x races came from. First
measure `rm -r` of 1,000 files on Wi-Fi. If it is slow, drop the queued trashes of a trashed
folder's children before batching anything: trashing the folder takes them with it.
- **Any time**: `Size` and block geometry as types; a deadline on the remote calls outside
the drain (the drain's have one: `link::upload_deadline`, `Core::block_on_within`).
- Open questions in §12 of the milestone.
- `PLAN.md`: the acceptance script notices network drops and reports `interrupted` instead of
TIMEOUT.
+5 -5
View File
@@ -5,24 +5,24 @@
| Page | What it covers | | Page | What it covers |
|---|---| |---|---|
| [Installation](INSTALL.md) | Packages, building from source, first sign-in, updating and uninstalling | | [Installation](INSTALL.md) | Packages, building from source, first sign-in, updating and uninstalling |
| [User guide](USER_GUIDE.md) | My files, available offline, synced folders, conflicts, Photos, sharing, Trash and versions, Computers, the search launcher, the tray | | [User guide](USER_GUIDE.md) | My files, available offline, synced folders, sync issues and conflicts, Photos, sharing, Trash and versions, Computers, the search launcher, the tray |
| [Command-line reference](CLI.md) | Every `pdfs` command, grouped by task, and scripting with `--json` | | [Command-line reference](CLI.md) | Every `pdfs` command, grouped by task, and scripting with `--json` |
| [Configuration](CONFIGURATION.md) | `config.json`, environment variables, file locations, the systemd service | | [Configuration](CONFIGURATION.md) | `config.json`, environment variables, file locations, the systemd service |
| [Troubleshooting](TROUBLESHOOTING.md) | Diagnosing problems, common symptoms, starting over, reporting a bug | | [Troubleshooting](TROUBLESHOOTING.md) | Diagnosing problems, common symptoms, starting over, reporting a bug |
| [Recovering on a new computer](RECOVERY.md) | What survives a lost machine and how to restore your synced folders | | [Recovering on a new computer](RECOVERY.md) | What survives a lost machine and how to restore your synced folders |
| [Security model](SECURITY_MODEL.md) | What protects your files, what is kept on this computer, what the client connects to |
| [Changelog](CHANGELOG.md) | What changed in each release | | [Changelog](CHANGELOG.md) | What changed in each release |
## For contributors ## For contributors
| Page | What it covers | | Page | What it covers |
|---|---| |---|---|
| [Development guide](DEVELOPMENT.md) | Building, running and debugging from source; releasing | | [Development guide](DEVELOPMENT.md) | Building, running and debugging from source; common changes; releasing |
| [Architecture](ARCHITECTURE.md) | Crates, read and write paths, sync engine, control protocol, threads, threat model | | [Architecture](ARCHITECTURE.md) | Crates, the local tree, read and write paths, the queue, the sync engine, the control protocol, threads, threat model |
| [Testing](TESTING.md) | Unit tests and the FUSE acceptance suite | | [Testing](TESTING.md) | Unit tests, simulation runs and the FUSE acceptance suite |
| [Translating](TRANSLATING.md) | Adding and updating translations | | [Translating](TRANSLATING.md) | Adding and updating translations |
| [Roadmap](ROADMAP.md) | Planned work and verification still owed | | [Roadmap](ROADMAP.md) | Planned work and verification still owed |
| [Bug ledger](BUGS.md) | Every tracked defect with its analysis and status | | [Bug ledger](BUGS.md) | Every tracked defect with its analysis and status |
| [UI/UX plan](UI_UX_PLAN.md) | Design audit and notes behind the 2.x desktop app |
Also see [CONTRIBUTING.md](../CONTRIBUTING.md) and [SECURITY.md](../SECURITY.md) in the repository Also see [CONTRIBUTING.md](../CONTRIBUTING.md) and [SECURITY.md](../SECURITY.md) in the repository
root. root.
+23
View File
@@ -133,6 +133,29 @@ pdfs sync restore --device UID
The folders stay part of the other computer's backup and sync in both directions. This machine The folders stay part of the other computer's backup and sync in both directions. This machine
does not take over that computer's backup. does not take over that computer's backup.
### Moving another computer's backup here
To switch computers for good, for example from a Windows PC to this Linux machine, move the old
computer's folders into this computer's backup instead. Use **Move to This Computer…** on that
computer's row, or:
```bash
pdfs devices list # find the old computer's UID
pdfs devices migrate UID
```
Each folder moves on Proton Drive itself, so nothing is uploaded again and no storage is used
twice. A folder whose name this computer already uses gets the old computer's name added, for
example `Documents (DESKTOP-1)`. The restore picker then opens, as in
[Step 3](#step-3-restore-the-folders), so you choose where each folder goes here.
The old computer stays in your account, without those folders. Unlike
[adoption](#step-2-continue-the-old-computers-backup), this machine keeps its own name and backup.
> [!WARNING]
> Stop Proton Drive on the old computer first. A client still running there sees its folders
> disappear, and may upload them again as new.
## 3. How the restore works ## 3. How the restore works
This section is for the curious and for anyone debugging a restore. This section is for the curious and for anyone debugging a restore.
+47 -39
View File
@@ -1,8 +1,7 @@
# Roadmap # Roadmap
Planned work and open verification, as of 3.0.0. Individual defects and their status live in Planned work and open verification after 3.0.0. Individual defects and their status are in
[BUGS.md](BUGS.md), which is the authoritative ledger; this page groups what is left by theme. [BUGS.md](BUGS.md); this page groups what is left by theme. Nothing here has a date.
Nothing here is a promise of a date.
- [Sync engine](#sync-engine) - [Sync engine](#sync-engine)
- [Data safety and recovery](#data-safety-and-recovery) - [Data safety and recovery](#data-safety-and-recovery)
@@ -14,38 +13,42 @@ Nothing here is a promise of a date.
## Sync engine ## Sync engine
3.0.0 records changes made through the mount locally and sends them from the queue 3.0.0 records changes made through the mount locally and sends them from the queue
([MILESTONE-3.0.0.md](MILESTONE-3.0.0.md)). What comes after it: ([ARCHITECTURE.md §4](ARCHITECTURE.md#4-write-path)). What comes next:
| Item | State | Notes | | Item | State | Notes |
|---|---|---| |---|---|---|
| Remove the online path | Not started | The old way of sending a change before the syscall returns, and the `local_first` switch that brings it back, go in the release after 3.0.0 | | Remove the online path | Not started | The old way of sending a change inside the syscall, and the `local_first` switch that brings it back, go in the next release |
| One applier for remote changes | Not started | Phase 4 of the milestone: a remote tree, and remote changes applied through the same planner as local ones | | One applier for remote changes | Not started | Everything that comes from Drive (events, listings) changes the tree in one place, which is also the only place that tells the kernel. Replaces today's invalidation helpers and in-memory overlays |
| Synced folders on the queue | Not started | Phase 5: synced folders sent by the same planner and executor as the mount | | Synced folders on the queue | Not started | Mirrored folders feed the local tree and use the same queue as the mount; `sync_entry` becomes the record of the last agreed state. Switching a folder's mode then moves no data except to evict or fetch content |
| Batch trashes and moves | Deferred | See below | | Batch trashes and moves | Deferred | See below |
| Provisional values as types | Not started | A size or block geometry that is not known yet as its own type, so it cannot reach the kernel as if it were known |
| Deadlines on every remote call | Partly done | The drain's calls and calls a syscall waits on have one; background calls outside the drain do not yet |
**Batching trashes and moves.** `trash_nodes` and `move_nodes_streaming` take up to 150 nodes per **Batching trashes and moves.** `trash_nodes` and `move_nodes_streaming` take many nodes per call,
call, but the queue still sends one node per call. It was left out of 3.0.0 on purpose: but the queue sends one node per call. It was left out of 3.0.0 on purpose:
- The speed target of the milestone (§7) is met without it. Making 1,000 files in 100 folders - The 3.0.0 speed target is met without it. Making 1,000 files in 100 folders through the mount
takes 5.2 s on a LAN and 5.0 s on Wi-Fi. takes 5.2 s on a LAN profile and 5.0 s on a Wi-Fi profile in the simulation
- What the queue spends its time on is the creates. Each one waits for Drive to list the new node (`PDFS_SIM_MEASURE`, see [TESTING.md](TESTING.md#simulation-runs)).
before it counts as landed (250 ms, then 1 s), and batching does not help with that. - The drain spends its time on creates. Each waits for Drive to list the new node before it
- Sending many ops in one call means splitting one answer back into many results. Races in counts as landed, and batching does not shorten that.
exactly that step were the largest class of bugs found for 3.0.0 (B15x to B17x). - One call answering for many ops has to be split back into results per op. Races in exactly that
step were the largest class of bugs found for 3.0.0 (B15x to B17x in [BUGS.md](BUGS.md)).
Before building it, measure `rm -r` of a folder with 1,000 files on Wi-Fi. If that is slow, the Before building it, measure `rm -r` of a folder with 1,000 files on Wi-Fi. If that is slow, the
cheaper fix is to drop the queued trashes of the files in a folder when the folder itself is cheaper fix is to drop the queued trashes of a trashed folder's contents: trashing the folder takes
trashed, since trashing the folder takes its contents with it. them along.
## Data safety and recovery ## Data safety and recovery
| Item | State | Notes | | Item | State | Notes |
|---|---|---| |---|---|---|
| Protect queued changes at shutdown | Not started | A systemd inhibitor lock while `staging/` holds unsent data, a warning when signing out, and a blocking `pdfs sync flush`. Today the queue is visible through `pdfs sync queue`, the tray and **Sync → Overview**, but nothing stops a shutdown (B66) | | Protect queued changes at shutdown | Not started | A systemd inhibitor lock while `staging/` holds unsent data, a warning when signing out, and a `pdfs sync flush` that waits for the queue. Today the queue is visible in `pdfs sync queue`, the tray and **Sync → Overview**, but nothing stops a shutdown (B66) |
| Restore pins from the machine profile | Not started | `profile.json` records pins; `sync restore` does not apply them yet | | Restore pins from the machine profile | Not started | `profile.json` records pins; `pdfs sync restore` does not apply them yet |
| Profile backup health | Not started | A failed profile upload is a `WARN` in the log and nothing in the UI (B68) | | Profile backup health | Not started | A failed profile upload is a `WARN` in the log and nothing in the app (B68) |
| Rotated credentials survive a keyring failure | Open | Surface and retry keyring write failures so a rotated single-use refresh token is not lost (B61) | | Rotated credentials survive a keyring failure | Open | Report and retry keyring write failures, so a rotated single-use refresh token is not lost (B61) |
| Encrypt local state at rest | Not started | SQLCipher for `cache.db` and an AEAD for the content cache, keyed from the keyring. `sdk_cache.db` is already encrypted. See [ARCHITECTURE.md §8](ARCHITECTURE.md#8-threat-model-what-this-client-writes-to-disk-in-plaintext) | | Restore never picks a new device silently | Open | Ask when the machine's device is ambiguous after a loss of local state (B67) |
| Encrypt local state at rest | Not started | SQLCipher for `cache.db` and an AEAD for the content cache, keyed from the keyring. `sdk_cache.db` is already encrypted. See [ARCHITECTURE.md §10](ARCHITECTURE.md#10-threat-model-what-this-client-writes-to-disk-in-plaintext) |
## Features ## Features
@@ -53,9 +56,10 @@ trashed, since trashing the folder takes its contents with it.
|---|---|---| |---|---|---|
| File-manager integration | Not started | Context-menu actions (available offline, share link, versions) and sync badges for Nautilus, Dolphin and Thunar | | File-manager integration | Not started | Context-menu actions (available offline, share link, versions) and sync badges for Nautilus, Dolphin and Thunar |
| Terminal UI | Not started | `pdfs tui` with transfers, sync state and logs | | Terminal UI | Not started | `pdfs tui` with transfers, sync state and logs |
| Multiple accounts | Not started | Separate config, state and mountpoint per profile, one daemon each | | Multiple accounts | Not started | Separate config, state and mountpoint per account, one daemon each |
| Mount options | Not started | Read-only mount, `allow_other` | | Mount options | Not started | Read-only mount, `allow_other` |
| Symbolic links | Not started | Proton Drive has no link type; links would need a placeholder format. Until then, creating one fails with `EOPNOTSUPP` | | Symbolic links | Not started | Proton Drive has no link type; links would need a placeholder format. Until then, `symlink` fails with `EPERM` |
| `fsync` that waits for Drive | Idea | `fsync` means "durable locally". A mount option or an xattr could offer "on Drive" without slowing the default |
| Add someone else's photo to your album | Blocked | Needs `copyPhoto` in the SDK | | Add someone else's photo to your album | Blocked | Needs `copyPhoto` in the SDK |
| Photo name search | Not started | Photos search by date and filters only | | Photo name search | Not started | Photos search by date and filters only |
| Automatic photo upload from a folder | Not started | | | Automatic photo upload from a folder | Not started | |
@@ -66,34 +70,38 @@ trashed, since trashing the folder takes its contents with it.
| Item | Notes | | Item | Notes |
|---|---| |---|---|
| Saturating stream-ring accounting | `self.bytes -= …` in `crates/pdfs-fuse/src/reads.rs` should saturate so an accounting drift cannot wrap in release builds | | Saturating stream-ring accounting | `self.bytes -= …` in `crates/pdfs-fuse/src/reads.rs` should saturate, so an accounting drift cannot wrap in release builds |
| Block geometry learned one read late | The first read of a file plans on 4 MiB blocks; accepted as a bounded cost (B87) | | Block geometry learned one read late | The first read of a file plans on 4 MiB blocks; accepted as a bounded cost (B87) |
| Path-based commands under secondary mounts | `pin`, `rename`, `move`, `rm` and sharing cannot name a path inside an on-demand synced folder yet (B88) | | Path-based commands under online-only folders | `pin`, `unpin`, opening a file, uploads, sharing and versions cannot name a path inside an online-only synced folder yet. `rm`, `rename`, `mkdir` and `move` can (B88) |
| Unicode normalization | NFC and NFD names are distinct, deliberately (B36) | | Unicode normalization | NFC and NFD names are distinct, on purpose (B36) |
| Pipelined node enumeration | Faster cold listings of large folders | | Pipelined node enumeration | Faster first listings of large folders |
## Verification still owed ## Verification still owed
Fixes marked *unverified* in [BUGS.md](BUGS.md) have unit or offline coverage but have not been Fixes marked *unverified* in [BUGS.md](BUGS.md) have unit or simulation coverage but have not
driven against a live account. The larger outstanding runs: been driven against a live account. The larger runs still owed:
- **A clean account run on 3.0.0.** The last account run before the release (2026-10-04, on Wi-Fi)
failed only its cleanup, on a script bug fixed since. No full run with `--journal-check` has
passed on the released code yet.
- **Disaster-recovery drill.** Register a device, sync a folder, delete all local state, restore - **Disaster-recovery drill.** Register a device, sync a folder, delete all local state, restore
with `pdfs sync restore` and compare the tree byte for byte. See [RECOVERY.md](RECOVERY.md). with `pdfs sync restore`, and compare the tree byte for byte. See [RECOVERY.md](RECOVERY.md).
- **Second-account sharing.** Shared trees on a foreign volume, accepting invitations, changing - **Second-account sharing.** Shared folders on a foreign volume, accepting invitations, changing
roles, the viewer cases of `regression B34`, and an editor writing into someone else's share. roles, the viewer cases of `regression B34`, and an editor writing into someone else's share.
- **Fault injection.** Network loss mid-upload, a full disk during staging, and crash points - **Fault injection on a real account.** Network loss mid-upload, a full disk during staging, and
across the write publication path (after each fsync, rename and queue commit). crashes at each step of publishing a write. The simulation covers link loss and restarts against
the fake Drive, not a kill at an arbitrary point.
- **Acceptance runs across a network drop.** A case that spans an outage times out while the queue
replays, instead of being reported as interrupted.
- **CAPTCHA sign-in** against a real verification request (B8). - **CAPTCHA sign-in** against a real verification request (B8).
- **Managed live mode matrix** before each release: - **The overnight hang** (B91) is not diagnosed. The supervisor and `pdfs diagnostics` exist to
`scripts/fuse-acceptance.sh --managed-live <empty-a> <empty-b>`. See [TESTING.md](TESTING.md). catch the next one.
- **The overnight hang** (B91) is not diagnosed yet. The supervisor and `pdfs diagnostics` exist to
catch the next occurrence.
## Release assurance ## Release assurance
| Item | State | | Item | State |
|---|---| |---|---|
| Tag, workspace and PKGBUILD version agreement | Enforced by the release workflow | | Tag, workspace and PKGBUILD version agree | Enforced by the release workflow |
| Clean-install test of the `.deb` and `.rpm` in a VM | Pending (B62) | | Clean-install test of the `.deb` and `.rpm` in a VM | Pending (B62) |
| Protocol version identity between front ends and daemon | Open (B63) | | Protocol version identity between front ends and daemon | Open (B63) |
| SBOM, provenance and checksums for release artifacts | Open (B65). The RPM is GPG-signed | | SBOM, provenance and checksums for release artifacts | Open (B65). The RPM is GPG-signed |
+83
View File
@@ -0,0 +1,83 @@
# Security model
This page explains what protects your files and account when you use this client, what it keeps
on your computer, and what it does not protect against. It is a summary for users. The details,
with references to the code, are in the
[threat model](ARCHITECTURE.md#10-threat-model-what-this-client-writes-to-disk-in-plaintext). To
report a vulnerability, see [SECURITY.md](../SECURITY.md).
This is an unofficial hobby project, not a Proton product. It has had no independent security
audit. The source is open under the MIT license, so you can read and build all of it yourself.
## Encryption
Proton Drive is end-to-end encrypted, and this client does not change that:
- **Files are encrypted on this computer before they are uploaded,** and decrypted on this
computer after they are downloaded. Proton never receives your keys or the content of your
files.
- **The cryptography comes from the SDK, not from this client.** The client uses
[`proton-sdk` and `proton-drive-rs`](https://github.com/narrrl/proton-sdk-rs), a Rust port of
Proton's official C# and TypeScript Drive SDKs. OpenPGP is done by the
[rPGP](https://github.com/rpgp/rpgp) library. The client itself does not implement any
encryption.
- **Your password is not sent to Proton.** Sign-in uses Proton's SRP protocol, which proves that
you know the password without sending it.
## What is kept on this computer
| What | Where | Protected by |
|---|---|---|
| Session tokens and the key passphrases | The system keyring (Secret Service: GNOME Keyring, KWallet, KeePassXC) | The keyring's own encryption and your login |
| Your password | Nowhere | — |
| Cached files, thumbnails and changes waiting to upload | `~/.cache/proton-drive-linux/content/` | File modes only: **not encrypted** |
| File and folder names, the folder tree, the activity log | `~/.local/state/proton-drive-linux/cache.db` | File modes only: **not encrypted** |
| The SDK's metadata cache | `~/.local/state/proton-drive-linux/sdk_cache.db` | Encrypted with a key derived from your mailbox password |
| Settings | `~/.config/proton-drive-linux/config.json` | No secrets are stored here |
The cache holds decrypted content so that files open fast and work offline. **The client assumes
that your disk is encrypted** (LUKS, or an encrypted home directory). On an unencrypted disk,
someone who has the powered-off computer can read the cached files and the names in your Drive.
Accounts with a separate mailbox password (two-password mode) are locked by default. Their key
passphrases are kept in the kernel keyring for your login session only, and you unlock again
after each login. Storing them in the system keyring is an opt-in.
## Who can use the client
- The state, cache and config directories must belong to you and have mode `0700`. The daemon
checks this at every start and refuses to run otherwise.
- The control socket that the app, tray and `pdfs` use has mode `0600`. Only your user account
can connect to it.
- Any program that runs as your user can read, change and delete files in `~/ProtonDrive`, the same
as in any other folder you own. It is your Proton Drive, not a copy: a deleted file is moved to
the Proton Drive trash, where you can restore it.
## Network connections
The client connects only to:
- **Proton**, for the Drive and account API, and for the CAPTCHA page when Proton asks for one.
- **OpenFreeMap**, for the street map of Places in Photos, only if you turn it on in Preferences. It is off by default, because the tile server learns which parts of the
world you look at. The default map is drawn from data shipped with the app.
There is no telemetry, no analytics and no update check. The SDK can report timings of its own
operations, and the client only writes them to its local log.
## What this client does not protect against
- **Malware running as your user.** It can read the cache, connect to the control socket, and use
your Drive through the mount, as it can with any data you have access to.
- **Swap.** Keys and decrypted data are in ordinary memory and can be written to swap. Use
encrypted swap.
- **The kernel page cache.** File content read through the mount is cached by the kernel like any
other file.
## Packages
- The `.rpm` is signed with GPG.
- The `.deb` and the `.tar.gz` are built by GitHub Actions from the tagged source, and are not
signed.
- The AUR packages build from the tagged source or from `main`, or install the release binaries
checked against their SHA-256 sums.
- The Nix flake builds from source. The binary cache on Cachix holds builds made by CI.
+207 -451
View File
@@ -1,509 +1,265 @@
# Testing # Testing
The normal Rust suite exercises the metadata database, sync planner, offline Three layers check the client, from fast and offline to slow and against a real account:
queue, write staging, cache, and FUSE handler state without requiring a Proton
account:
```console | Layer | Needs | Runs in CI |
|---|---|---|
| [Unit tests](#unit-tests) | Nothing | Every push and pull request |
| [Simulation runs](#simulation-runs) | `/dev/fuse` | Every push to `main` |
| [Acceptance suite](#acceptance-suite) | Nothing for the reference run; a signed-in account for the rest | The reference run only |
## Unit tests
```bash
cargo test --workspace cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings cargo clippy --workspace --all-targets -- -D warnings
``` ```
## Filesystem API and live FUSE acceptance suite Tests sit in `#[cfg(test)]` modules next to the code. Database tests are in
`crates/pdfs-core/src/db/tests.rs`; a schema change adds one that migrates from the previous
version.
The account-free suite exercises the filesystem syscall contract against a To check the migrations against a real database, point `PDFS_MIGRATE_DB` at it and run the
temporary local directory. This validates the runner and covers creation/open ignored test. It migrates a copy taken with `VACUUM INTO` and never writes the database named:
flags, positioned and vectored I/O, truncation and sparse files, `mmap`,
`sendfile`, `copy_file_range`, durability barriers, namespace/error semantics,
`renameat2` flags, open-file lifetime, names and enumeration, readdir stability
under concurrent mutation, metadata updates, extended attributes, allocation and
punched holes, refusal semantics for unimplemented operations, concurrent I/O,
and real application workloads. It never reads application state or contacts
Proton:
```console ```bash
scripts/fuse-acceptance.sh --offline-only PDFS_MIGRATE_DB=~/.local/state/proton-drive-linux/cache.db \
cargo test -p pdfs-core a_copy_of_a_real_database -- --ignored
``` ```
Kernel/FUSE behavior and remote convergence need a real mount. With no ## Simulation runs
arguments, the script tests the account the running service is logged in to:
```console The simulation runs (`crates/pdfs-fuse/src/sim/`) start real daemons with real FUSE mounts against
scripts/fuse-acceptance.sh # every case, every mount kind an in-memory Drive, `FakeDrive`. The daemon runs as in production (drain workers, sync engine,
scripts/fuse-acceptance.sh --quick # one on-demand/mirror pair instead of four event feed, online probe, control socket); only its Drive is fake. `FakeDrive` has the faults the
real one has: listings that lag a create, renames refused for a stale name hash, no atomic
replace, delayed and reordered events, requests that stall or lose their answer, per-request
latency, and blocks of uneven size.
A run draws a sequence of steps from a seed: syscalls through a client's mount, the link going
down and up, restarts, settles (every queue must drain within a budget), and files deleted while
the drain holds a write to them. Every syscall is checked against an in-memory model of the
filesystem as it happens. At the end 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; each file has one writer.
3. **Convergence.** Each mount shows Drive's tree.
4. **Liveness.** No syscall took longer than its budget, and every queue drained once the links
were up. A call that never returns is caught by a watchdog that writes every thread's stack to
`stacks.txt` in the run's state directory.
| Test | Seeds |
|---|---|
| `one_client_on_a_good_link` | 12 |
| `one_client_deleting_files_as_they_upload` | 3 |
| `one_client_with_stale_hashes_and_reordered_echoes` | 6 |
| `one_client_on_a_flaky_link` | 4 |
| `one_client_on_a_slow_link` | 4 |
| `three_clients_one_writer_each` | 3 |
`sim/daemon.rs` holds scenario tests for single bugs, run the same way.
The tests mount FUSE, so they are ignored by default:
```bash
cargo test -p pdfs-fuse --lib sim:: -- --ignored --test-threads=1 # what CI runs
PDFS_SIM_SEEDS=50 cargo test -p pdfs-fuse --lib sim::run -- --ignored --test-threads=1
PDFS_SIM_SEED=7 RUST_LOG=pdfs_fuse=debug cargo test -p pdfs-fuse --lib \
sim::run::tests::one_client_on_a_flaky_link -- --ignored --exact --nocapture
``` ```
An account run does this, in order: | Variable | Effect |
|---|---|
| `PDFS_SIM_SEEDS` | How many seeds each test runs, from 1 |
| `PDFS_SIM_SEED` | Replay one seed. A failing run prints the seed, the last steps, and where it kept its state |
| `PDFS_SIM_MEASURE` | Run `a_thousand_files_drain_as_fast_on_wifi_as_on_lan`, which times 1,000 files in 100 folders on a LAN and a Wi-Fi profile and prints the times |
1. Runs the account-free contract as the reference. A failure is a seed that can be replayed, but the daemon uses real threads, so a race may need
2. Runs the contract in a fresh `pdfs-acceptance-<id>` folder in My files and several replays. In CI, the `simulation` job uploads the stacks of a hung run as an artifact.
diffs it against the reference. After the last case it reads back a file
the run wrote and records its digest. A read that fails there, as one did
with the link down (B119), is a failed result named "a file of this run
reads back at the end", and the cleanup still runs.
3. Registers two new sync folders under `~/.cache/pdfs-acceptance/run-<id>/`
and runs the contract and the move cases in every on-demand/mirror pairing.
4. Removes everything it created, locally and remotely, and reports a
`cleanup` result. The run fails if anything is left behind.
Cleanup identifies remote folders by uid, never by name alone, so nothing of ## Acceptance suite
yours is touched. It removes each sync folder with `sync rm --delete-remote`,
trashes the My files folder, and then deletes both permanently from the trash.
It only counts a folder as gone once a fresh trash listing no longer shows it.
It then reads `pdfs sync queue` and counts any queued op that still names one
of its folders as left behind too, since such an op keeps its staged bytes and
retries forever (B102).
Ops that were queued before a target starts are not the run's. The run prints `scripts/fuse-acceptance.sh` checks the filesystem contract a program sees: creation and open
one `NOTE` line naming them, and the queue waits ignore them. They are told flags, positioned and vectored I/O, truncation and sparse files, `mmap`, `sendfile`,
apart by id and queue time together, since SQLite can give a new op the id of `copy_file_range`, durability barriers, renames and their `renameat2` flags, open-file lifetime,
one that has drained. names, `readdir` under concurrent change, metadata, extended attributes, holes, refusals of
unsupported operations, concurrent I/O, and real application workloads.
A write is queued when the kernel releases the file, and `close(2)` returns > [!CAUTION]
without waiting for that. A case that reads `pdfs sync queue` right after > Every mode except `--offline-only` creates and deletes real files in Proton Drive. Use a test
writing therefore waits for the ops it expects (`Daemon.queued`) instead of > account with nothing you cannot lose.
reading once. "regression B102" saw one revision of two before it did.
Cleanup also runs on a failure, Ctrl-C, `SIGTERM` or `SIGHUP`. While it runs, ### Modes
two more interrupts are ignored; a third abandons it.
Each run records what it is about to create in ```bash
`~/.cache/pdfs-acceptance/run-<id>/manifest.json` before it creates it, and scripts/fuse-acceptance.sh --offline-only # the reference run; no account
holds a lock on that directory. If a run dies without cleaning up (`kill -9`, a scripts/fuse-acceptance.sh # the signed-in account, every mode pairing
hard timeout, a power cut), the next run finds the unlocked manifest and scripts/fuse-acceptance.sh --quick # one on-demand/mirror pairing instead of four
finishes that cleanup first. It then removes stray `pdfs-acceptance-<32 hex>` scripts/fuse-acceptance.sh --live /mnt/testmount # mounts you set up yourself
folders that no live run claims: in My files (older than scripts/fuse-acceptance.sh --managed-live /mnt/a /mnt/b
`PDFS_ACCEPTANCE_REAP_AGE`, default one hour), under the acceptance home, and
in the trash. A run does not start while that reap leaves anything behind.
The run creates and destroys real remote folders, so a dedicated test account
without irreplaceable data is still the safest target.
`--live PATH...` tests mounts you set up yourself and leaves the sync folder
configuration alone. It runs destructive POSIX operations only below a fresh
`pdfs-acceptance-*` directory, refuses paths that are not FUSE mounts, and
removes its directory on success, failure, or interruption:
```console
scripts/fuse-acceptance.sh --live /mnt/on-demand-testmount
``` ```
**The reference run** always runs first, against a temporary local directory. It records the
facts each case establishes, and every mount is then **compared** against those facts. A case
fails when the mount differs from an ordinary filesystem, not only when it raises. A fact that may
legitimately differ (inode numbers, block counts, which optional syscalls exist) is **noted**
instead, and listed under the target's `accepted` line. The choice is made in the test body, so
the accepted differences are visible where they are accepted. Prefer `record`; use `note` only
for something a network filesystem cannot do.
**An account run** (no mode):
1. Runs the reference.
2. Runs the contract in a new `pdfs-acceptance-<id>` folder in My files, and at the end reads back
a file the run wrote.
3. Registers two sync folders under `~/.cache/pdfs-acceptance/run-<id>/` and runs the contract and
the move cases in every pairing of on-demand and mirror. Before a folder switches to on-demand,
a new file is written into it; once mounted, the file must be listed with its bytes. In every
pairing each folder must still hold the file written into it at setup.
4. Removes everything it made, locally and on Drive, and reports `cleanup`. A run that leaves
anything behind fails.
Cleanup finds remote folders by uid, never by name, so nothing of yours is touched. It removes
each sync folder with `sync rm --delete-remote`, trashes the My files folder, deletes both from
the trash for good, and checks that a fresh trash listing no longer shows them. Queued ops that
still name one of its folders count as left behind. Cleanup also runs on a failure, Ctrl-C,
`SIGTERM` or `SIGHUP`; while it runs, two more interrupts are ignored and a third abandons it.
Each run writes what it is about to create to `~/.cache/pdfs-acceptance/run-<id>/manifest.json`
first and holds a lock on that directory. A later run finishes the cleanup of a run that died, and
removes stray `pdfs-acceptance-<32 hex>` folders no live run claims: in My files (older than
`PDFS_ACCEPTANCE_REAP_AGE`, default one hour), under the acceptance home, and in the trash. It
does not start while anything is left.
Ops that were queued before the run started are not the run's: it prints one `NOTE` line naming
them, and its queue waits ignore them.
**`--live PATH...`** runs the contract on mounts you set up, and leaves the sync configuration
alone. It works only below a new `pdfs-acceptance-*` directory, refuses paths that are not FUSE
mounts, and removes its directory afterwards. The first path must be a FUSE mount; later paths may
be another mount or a mirrored folder. If all paths show the **same remote folder**, set
`PDFS_ACCEPTANCE_CONVERGENCE=1` to also check that bytes written through the first appear through
the others.
**`--managed-live A B`** takes two existing, empty, unmounted directories, registers them as sync
folders, and runs every pairing as an account run does, then deletes the remote folders. It
refuses non-empty paths, mounts, and paths already in `pdfs sync list`.
### Reading the output ### Reading the output
A run opens with what it is about to test: the mode, the `pdfs` binary and its A run opens with the mode, the `pdfs` binary and version, Python, the kernel, the per-case limit
version, Python and the kernel, the per-case limit, the options and the start and the options. Each target then names its root, the storage behind it, the daemon and the
time. Each target then names its root, the storage behind it, the daemon it number of cases, followed by one line per case:
talks to and how many cases it runs, followed by one line per case:
``` ```
==> live FUSE /mnt/testmount ==> live FUSE /mnt/testmount
-> root /mnt/testmount/pdfs-acceptance-… -> root /mnt/testmount/pdfs-acceptance-…
-> storage FUSE mount /mnt/testmount (fuse.protondrive) -> storage FUSE mount /mnt/testmount (fuse.protondrive)
-> daemon pdfs 2.8.3, user@proton.me, online, 0 queued ops -> daemon pdfs 3.0.0, user@proton.me, online, 0 queued ops
-> cases 45 of 45 -> cases 45 of 45
[ 1/45] creation and open flags .................................. ok 0.41s [ 1/45] creation and open flags .................................. ok 0.41s
… …
[21/45] throughput floors ........................................ ok 48.20s [21/45] throughput floors ........................................ ok 48.20s
write 31 MiB/s, read 54 MiB/s, 4 KiB writes 12.0 MiB/s, metadata 9 ops/s write 31 MiB/s, read 54 MiB/s, 4 KiB writes 12.0 MiB/s, metadata 9 ops/s
… …
[43/45] regression B115: pdfs ls reports a file's real size ...... ok 6.12s
-> reference matches the ordinary filesystem in 412 compared observations -> reference matches the ordinary filesystem in 412 compared observations
-> result 44 passed, 1 skipped in 11m02s -> result 44 passed, 1 skipped in 11m02s
``` ```
A case ends as `ok`, `FAIL`, `TIMEOUT`, `skip` when it does not apply to the A case ends as `ok`, `FAIL`, `TIMEOUT`, `skip` (it does not apply to the target), or `known` (an
target (no daemon, no share at that role), or `known` when an `open B<n>` case `open B<n>` case reproduced a bug that is still open). Dimmed lines under a case hold its details.
reproduced a bug that is still open (see below). The dimmed lines under a case A failed `regression B<n>` case points to its entry in [BUGS.md](BUGS.md).
are its details: the reason for a skip, measured rates, a hash. A failed
`regression B<n>` case also points to its entry in `docs/BUGS.md`. When a case
prints something itself, such as a service restart, the case line is printed
again once the case ends.
The run ends with a table of every target's counts and time, the report paths, The run ends with a table of every target, the report paths, the slowest cases, cases over
the slowest cases, the cases over `--budget`, the known issues that still `--budget`, known issues that still reproduce, errors the daemon logged, and each failure. The
reproduce, any errors the daemon logged, and each failure with its target. The last line is `PASS:` or `FAIL:` with the totals. Colour is used only on a terminal and never with
last line is `PASS:` or `FAIL:` and the totals. Colour is used only on a `NO_COLOR` set.
terminal and never with `NO_COLOR` set.
### Edge cases and performance ### What the cases cover
Besides the syscall contract, the suite covers: Besides the syscall contract:
- **Block boundaries.** Files of 0 bytes up to two 4 MiB blocks plus 3 bytes, - **Block boundaries.** Files from 0 bytes to two 4 MiB blocks plus 3 bytes, every size next to a
including every size next to a boundary, then overwrites that grow, shrink boundary, and overwrites that grow, shrink and reshape them.
and reshape them. - **Unusual names.** 255 bytes, NFC and NFD forms of the same text, newline, tab, emoji,
- **Unusual names.** A 255-byte name, NFC and NFD forms of the same text, characters Windows forbids, leading and trailing spaces, a case-only rename.
newline, tab, emoji, characters Windows forbids, leading and trailing - **A deep tree and a wide folder.** 24 levels renamed at the top; 128 entries created
spaces, and a case-only rename. concurrently, unlinked concurrently, then `rm -rf`.
- **A deep tree and a wide directory.** 24 levels of nesting renamed at the - **Rename patterns.** Swaps, chains, log rotation, replacing a file under an open reader, the
top; 128 entries created concurrently, concurrent unlinks, then `rm -rf`. `EISDIR`, `ENOTDIR` and `ENOENT` refusals.
Online, each create is a round trip to the server, and the kernel runs - **Handle coherency.** Unsaved writes seen by a second handle, two `O_APPEND` writers, 30 rewrite
creates in one directory one at a time. So the wide case gets twice the generations, a truncate under a reader.
per-case timeout and notes the time per create as `wide.create_ms`. - **Throughput.** 64 MiB written and read, 1 MiB in 4 KiB writes, metadata on 100 files. The case
- **Rename patterns.** Swaps, chains, log rotation, replacing a file under an fails below `PDFS_ACCEPTANCE_MIN_MIBPS` (default 10) or `PDFS_ACCEPTANCE_MIN_OPS` (default 20
open reader, the `EISDIR`/`ENOTDIR`/`ENOENT` refusals, and a folder moved out operations per second, 3 on a FUSE mount). `0` turns a floor off.
and back. - **Operations the filesystem does not implement.** `git`, `rsync`, `cp -a`, `tar` and installers
- **Handle coherency.** Unsynced writes seen by a second handle, two `O_APPEND` probe for symlinks, hard links, FIFOs, device nodes, locks, `O_DIRECT` and `SEEK_HOLE`. Each must
writers, 30 rewrite generations, and a truncate under a reader. work or be refused cleanly: a known errno, never `EIO`, never a hang.
- **Throughput.** 64 MiB written and read sequentially, 1 MiB in 4 KiB writes, - **Moves between locations** (`managed` in `--list`). Trees moved between two sync folders in
and metadata operations on 100 files. The rates are printed. The case fails both directions, several sources in one `pdfs move`, and a tree through My files and out again.
below `PDFS_ACCEPTANCE_MIN_MIBPS` (default 10) or `PDFS_ACCEPTANCE_MIN_OPS` The refusals must move nothing and lose nothing: a name already taken, a folder into itself, a
(default 20 operations per second, or 3 on a FUSE mount, where each create mirrored source holding something Drive lacks, a file still open for writing.
and unlink waits for Drive before it returns). - **The CLI.** `pdfs` into a closed pipe exits 0 with nothing on stderr; `pdfs mkdir`, `rename`
and `rm` by local path, and their refusals.
### The mount is diffed against an ordinary filesystem ### Regressions and open bugs
The local reference run is not only a self-test of the runner. Every case A case named `regression B<n>` reproduces an entry of [BUGS.md](BUGS.md) so it stays fixed. Most
records the facts it established, and a live run is compared against those need a live mount and a reachable daemon, and are skipped without one. `--list` prints every case
recordings, so a case fails when the mount *differs* from an ordinary and the targets it runs on.
filesystem — not only when it raises. The target's `reference` line names the
exact key:
``` A regression case checks the outcome **on Drive**, so it waits for the queue first. Reading back
-> reference 1 observation of 412 differs from the ordinary filesystem: through the mount proves nothing: the local caches return the bytes just written whether or not
! creation and open flags.truncated.size: live FUSE /mnt/testmount=6 reference=0 they reached Drive. A write is queued when the kernel releases the file, after `close(2)` has
``` returned, so a case that reads `pdfs sync queue` right after writing waits for the ops it expects.
Facts are recorded in one of two ways, chosen in the test body: A case named `open B<n>` checks a bug that is still open. While it reproduces, the case ends as
`known`: listed at the end, but not a failure. When it passes, the bug is fixed: rename the case to
`regression B<n>` and make it fail with `check`. No case is open at the moment.
- **compared** — a claim about filesystem semantics. The mount must agree. Some cases need what a test account cannot provide alone and skip without it: B34 needs a folder
- **noted** — context that legitimately differs: inode numbers, block counts, shared read-only by a **second account**, B34b one shared with editor rights, and B79 an on-demand
reported mode bits, and which optional syscalls exist at all. Listed under synced folder.
the target's `accepted` line, never a failure.
Because the choice is made at each call site, the set of accepted divergences ### Timeouts, reports and hung mounts
is visible in the test that accepts it. There is no separate allowlist to drift
out of date. When adding a case, prefer `record`; reach for `note` only when an
ordinary filesystem can do something a network filesystem genuinely cannot.
### Operations the filesystem does not implement A hung FUSE call cannot be interrupted from Python. Each case runs under a soft alarm (`--timeout`,
default 180 s, or `PDFS_ACCEPTANCE_TIMEOUT`) that reports the case and lets cleanup run, backed by
a hard stop that dumps every thread's stack and exits. After a timeout the run checks whether the
mount still answers a `stat` and a listing within 15 s. If it does, the target goes on; if not, its
remaining cases are skipped. Cases that make many round trips by design get twice the limit.
`git`, `rsync`, `cp -a`, `tar`, `df` and most installers probe for symlinks, | Option | Effect |
hard links, FIFOs, device nodes, `statfs`, file locks, `copy_file_range`, |---|---|
`O_DIRECT` and `SEEK_HOLE` whether or not a filesystem offers them. The suite | `--report-json PATH` | Every result and recorded observation. Attach it to a bug report |
asserts that each either works or is *refused cleanly* — a recognised errno, | `--report-junit PATH` | JUnit XML. `known` is reported as skipped |
never `EIO` and never a hang. A refusal that is merely unimplemented is fine; | `--budget SECONDS` | Flags cases that pass but take longer (`PDFS_ACCEPTANCE_BUDGET`) |
one that is dirty breaks software that never asked for the feature. | `--journal-check` | Fails the run if `proton-drive.service` logged an error during it, or the warnings "queued write conflicts; keeping a conflict copy" and "a fuse worker has held the same job for a long time" |
| `--durability` | Restarts the service mid-suite and checks the bytes written before it. Proves the queue survives a restart. `PDFS_ACCEPTANCE_UNIT` names another unit |
### Regressions from the bug ledger | `--fail-fast` | Stops at the first failure |
| `--list` | Prints every case and its targets |
Cases named `regression B<n>` reproduce a bug from `docs/BUGS.md` so it stays
fixed. B69 (identical rewrite must not fork a `(sync-conflict …)` copy), B70 (an
in-flight `.crdownload` must not be sealed as a revision) and B74 (a file
renamed after close must keep its content) need a real mount and a reachable
daemon; they are skipped, not failed, without one.
**B100** (a copied tree reads back at its exact sizes) copies 48 files of
assorted sizes into the mount at once, as `cp -r` does. On a live mount it
waits for the queue, checks every size and byte, runs `pdfs refresh` on each
folder to drop the cached listing, and checks again. The refresh is what
brought back the provisional sizes the bug reported.
**B101** (a write during its create's upload reaches Drive) holds new files open for
different times before writing, so their empty creates land before, during and after the
bytes arrive, then checks every file after the queue drains.
**B113** (moving a file before its upload makes no conflict) pauses sync with
`pdfs sync pause --for 10m` and writes three files. While their uploads are held,
it renames one, moves one into a folder, and renames and moves the third. It then
resumes sync, waits for the queue, and checks the bytes and names and that no
conflict copy appeared. It skips when sync was already paused. If the run dies
before the resume, the pause ends by itself after ten minutes.
**B88** (`pdfs mkdir`, `rename` and `rm` by local path) creates, renames and
removes a folder and a file by their paths in the mount. Then it checks the
refusals: `pdfs rename` of the mount's root to its own name, and `pdfs rm` of a
file outside every mount. `pdfs rm` of the root itself is tried only on a sync
folder the run created. In a mirrored sync folder all three commands must be
refused instead, and nothing may change.
**pdfs output into a closed pipe** runs `pdfs ls`, `pdfs --json ls` and
`pdfs status` with stdout on a pipe whose reader is already gone. Each must exit
0 with nothing on stderr, as `pdfs ls | head -1` needs.
**B102** (removing a folder drops the uploads queued inside it) uploads a file
into each of two nested folders, pauses sync and rewrites both. While the
revisions are held, it removes one folder with `pdfs rm` and the other through
the mount. Neither revision may stay queued, and neither folder may come back
after the resume. It skips when sync was already paused.
**B94** (a deleted `.part` file leaves nothing queued) writes a `.part` file,
checks that its create is parked, and unlinks it. The parked op must leave the
queue within 30 seconds. The sweep that lets an hour-old park through is not
driven.
**B95** (a `.tmp` file uploads at once) writes `index.tmp` and `preview.temp`
and waits for the queue, which fails at once on a parked op. `pdfs ls` must
list both, and their bytes must read back.
**B111** (renaming a file straight back works) renames a file there and back
eight times through the mount. On a mount it also does so three times with
`pdfs rename`, after the queue is empty, so the renames race nothing but Drive.
It runs against the reference too.
**B121** (a file renamed and moved at once lands) renames a file, moves it into
a subfolder and renames it back across folders through the mount, with no pause
between the steps. On a mount it then runs `pdfs rename` and `pdfs move` back to
back. The file must end up where it was sent, with its bytes, and the case
reports how long each half took. It runs against the reference too.
**B98** (an upload superseded by a write ends) writes 64 MiB and waits for its
upload to show in `pdfs transfers`, then appends to the file. The queue must
drain, the old upload must leave the transfer list, and the file must hold
every byte with no conflict copy. It skips when the upload is never seen on
the wire, and it gets twice the per-case timeout.
**B80** (`pdfs search` finds a new file after a rewrite) writes a file whose
name carries a unique token, waits for the queue, rewrites it and waits again.
`pdfs search` must find it within 30 seconds.
**B48** (an emptied folder can be removed at once) writes twelve files into a
folder, lists it, and moves one more in from elsewhere. It unlinks every
child, runs `pdfs refresh` on a mount, and checks that nothing is listed and
that `rmdir` works. It runs against the reference too.
**B114** (a name `pdfs rm` or `rename` took away stops resolving) looks up two
files, removes one with `pdfs rm` and renames the other with `pdfs rename`. At
once, neither old name may be listed or resolve.
**B115** (`pdfs ls` reports a file's real size) writes files of 6 bytes, 4097
bytes and one block plus 7, and compares the sizes `pdfs ls` lists with the
written ones, after the upload and again after `pdfs refresh`.
**B116** (trashing a folder drops the creates queued in its subfolders) makes
`b116/sub/deeper` and waits for Drive. With sync paused, it writes a `.part`
file there and renames it to its final name, so its create is due but held.
It then runs `pdfs rm b116`, and the create must be gone from the queue. It
restores the folder with `pdfs restore` before it resumes sync. If the file
ends up in the mount's root anyway, the case fails and moves the file to the
trash.
**B117** (a moved file's queued upload keeps its path) rewrites an uploaded
file with sync paused and moves it with `pdfs move`. `pdfs sync queue` must
list the upload under its new path, and the journal must not show the upload
put off with "pending operation deferred". The bytes must arrive at the new
path with no conflict copy. Without `journalctl`, only the queue is checked.
**B119** (an uploaded file stays cached) writes a small file and one of a block
plus 119 bytes, waits for the queue, and expects `pdfs ls` to list both as
cached, so reading them does not need the network.
B74 was found by exactly this mechanism: the B70 case failed, and narrowing it
produced a smaller reproduction that got its own case. The defect is fixed and
live-validated (2026-07-26); the case now guards against its return.
**B79** (an on-demand device folder accepts writes) discovers its target from
`pdfs locations --json` and skips when this machine has no mounted on-demand
folder. It is the cheap standing guard against a *cross-mount* permission
regression: the primary mount holds device-folder nodes too, and the queue guard
intersects what every live inode space says about a uid, so a fail-closed
classification in one mount denies writes in another.
**B34** (a viewer-role share is read-only) and **B34b** (an editor-role share
still writes) locate their subject through `pdfs shared-with-me --json`, which
now carries `role` and a mount-relative `path`. Both skip when the account has no
accepted share at that role — B34 needs a **second account** to have shared a
folder read-only, which is the one part of the sharing suite that cannot be
self-served. B34b degrades gracefully in the other direction: a shared *file*
gets its mode bits and `W_OK` checked but is never written to, because it is
someone else's document.
B34's fourth assertion is the load-bearing one: `pending_uploads` and
`pending_changes` must be **unchanged** across the refused writes. Mode bits
alone would pass a fix that still admitted the write into `pending_op`, which is
the actual harm — a background drain failing 403 forever.
**Synthetic `Shared with me/` directory contract** asserts the virtual directory
enumerates, reports `0555`, and refuses `mkdir`/`rmdir`/`rename`. It skips when
the directory is absent (an account with no accepted shares).
A regression case asserts against the *remote* outcome, so it must wait for the
queue to drain before reading. Reading straight back through the mount proves
nothing: the local attribute and content caches will happily return the bytes
that were just written even when nothing reached Drive, which is precisely how
B74 stayed invisible.
### Bugs that are still open
Cases named `open B<n>` check a bug that `docs/BUGS.md` still lists as open.
While the bug reproduces, the case ends as `known`: it is listed at the end of
the run, but does not fail it. So a known bug does not hide new failures, and
the run notices when a fix lands, because the case then passes. Rename it to
`regression B<n>` then, and fail it with `check` instead of `KnownIssue`. No
bug is open at the moment. B114 to B117 started as such cases.
### What a run verifies
A full account run with `--journal-check` checks these `docs/BUGS.md` entries,
many of them fixed but not yet verified:
- B48, B80, B94, B95, B98, B100, B101, B102, B111, B114, B115, B116, B117, B119
and B121, each with its own `regression B<n>` case.
- B118, with "application workloads" and "block boundaries", which failed on
it. It needs Drive to be slow to list a new node, so a clean run is weak
evidence.
- B120, with `--journal-check`, but only in a run that goes offline while it
writes. A mkdir made online goes to Drive at once, so only then does a
folder land from the queue.
- B86, with the mode-switch check in the full matrix.
- B104, B106, B108 and B109, with the cases that found them: "rename
patterns", "application workloads" and the account run's own setup.
B105, B107 and B110 are races. The suite hit each of them once, so a clean run
is weak evidence for them. `--journal-check` reports B110's stall warning, and
every case that writes checks for B105's conflict copies.
The suite does not drive:
- the GUI and the photo gallery (B81, B92, B93, B97);
- states it cannot set up: a `parent_uid` cycle (B82), staged bytes that name
no node (B96), a stuck trash refresh (B103), conflict copies left from an
earlier start (B99), a foreign FUSE mount in the local-file index (B90),
revisions without 4 MiB blocks (B85), and several shares from one owner
(B112);
- B94's hour-long expiry of a park;
- load and failure injection (B22, B27, B30, B37, B42).
Check those by hand, or with the unit tests their entries name.
### Timeouts, reports, and hung mounts
A wedged FUSE operation cannot be interrupted from Python, so each case runs
under a soft alarm (`--timeout`, default 180s) that reports the case and lets
cleanup run, backed by a hard stop that dumps every thread's stack and exits.
That backstop is the only way to learn *where* a mount hung. After a timeout the
run checks whether the mount still answers a `stat` and a listing within 15
seconds. If it does, the target goes on with its next case, so one slow stretch
of the link does not cost every later case. If it does not, the target's later
cases are not run: against a wedged mount they produce noise, not information.
Cases that make many round trips by design ("application workloads", "unusual
but legal names", "a wide directory" and "regression B98") get twice the limit.
```console
scripts/fuse-acceptance.sh --live /mnt/testmount \
--timeout 300 --report-junit results.xml --budget 30
```
`--report-json` also writes every recorded observation, which is what to attach
to a bug report. A known issue has the status `known` there; JUnit has no such
status, so `--report-junit` reports it as skipped. `--budget SECONDS` flags cases that pass but got slower —
B5 was a 186 ms-per-call regression that a pass/fail suite could not see.
`--list` prints every case and which targets it runs against, and `--fail-fast`
stops at the first failure.
`--journal-check` fails the run if `proton-drive.service` logged any error while
it ran. A suite can pass every assertion while the daemon logs failures behind
it; that has happened, and it was caught only by reading the journal by hand.
Two warnings count as errors too: "queued write conflicts; keeping a conflict
copy" (how B113 showed) and "a fuse worker has held the same job for a long
time" (how B110 showed).
With `--live`, abandoned `pdfs-acceptance-*` roots from a crashed run are
removed at startup, but only if they match the exact generated name and are
over an hour old (`PDFS_ACCEPTANCE_REAP_AGE`), so a concurrent run is never
disturbed. An account run instead uses the manifest and reaper described
above.
### Automated mode matrix
An account run drives this matrix itself, in folders it creates. To use
directories of your choice instead, the managed live runner accepts two
existing, empty, unmounted directories. It
registers both as new sync folders, waits for initial synchronization, then
tests all four pairs in order: on-demand/on-demand, on-demand/mirror,
mirror/on-demand, and mirror/mirror. Every transition must become idle, and an
on-demand folder must actually appear as a mount before its contract runs.
```console
mkdir -p /mnt/pdfs-test-a /mnt/pdfs-test-b
scripts/fuse-acceptance.sh --managed-live /mnt/pdfs-test-a /mnt/pdfs-test-b
```
This creates remote folders and permanently deletes those test remotes during
cleanup. It refuses non-empty paths, existing mounts, and paths already present
in `pdfs sync list`. Use `PDFS_ACCEPTANCE_SYNC_TIMEOUT` to extend transition
timeouts and `PDFS_ACCEPTANCE_PDFS` to select a non-installed CLI binary, for
example `target/debug/pdfs`.
After the contract, each mode pair also runs the move cases (`--list` shows
them as `managed`). They move trees between the two folders in both
directions, move several sources in one `pdfs move`, and pass a tree through
My files and out again. Every case waits for Drive before and after the move.
It then checks that the tree is only at the destination, that every byte
arrived, and that neither side made conflict copies. The refusal cases prove
that nothing moves and nothing is lost when:
- the destination already has the name;
- a folder would move into itself;
- a mirror source holds something Drive lacks (a symlink);
- a file in an on-demand source is still open for writing.
The My files case writes a test folder into your real My files and removes it
afterwards. It skips when the daemon reports no My files mount, or when the
locations are on different volumes.
Before a mirror folder switches to on-demand, the runner writes a new file into
it and syncs. Once the folder is mounted, the file must be listed with its
bytes (B86). Each switch reports a `regression B86` result under a separate
`mode switches` target. The bug only shows in a folder that was mounted in an
earlier pairing, because that mount's listing is stale. That happens in the
third pairing, so `--quick` runs the check but does not replay the bug.
In every pairing, each folder must still hold the file the runner wrote into it
at setup, byte for byte. These checks report under `mode switches` as well. A
switch to on-demand frees the local copy, so these reads go to Drive. If the
link drops, the daemon fails such a read with EIO once its two-minute read
budget runs out. That is correct, so the runner waits until the daemon reports
online again, tries once more and notes the retry under the result. Only the
second try decides the result.
### Durability drill
`--durability` restarts `proton-drive.service` in the middle of the suite and
re-verifies the bytes written before it. `staging/` and `recovery/` can hold the
only copy of a file whose upload has not finished, so this is the case that
proves the queue is persistent rather than in-memory. It is opt-in because it
interrupts a running daemon; `PDFS_ACCEPTANCE_UNIT` selects a different unit.
### Selecting cases ### Selecting cases
During development, `PDFS_ACCEPTANCE_ONLY` runs tests whose descriptive name `PDFS_ACCEPTANCE_ONLY` runs the cases whose name contains the text, ignoring case. Separate several
contains the supplied text, ignoring case (for example with commas (`PDFS_ACCEPTANCE_ONLY=B116,B117`). A filter that matches no case at all fails, as the
`PDFS_ACCEPTANCE_ONLY=namespace`). Separate several texts with commas to run typo it is. A release run leaves it unset.
every case that matches any of them (`PDFS_ACCEPTANCE_ONLY=B116,B117`). A
release acceptance run must leave it unset so the complete contract executes.
A filter naming a live-only case (every `regression B<n>` is one) selects nothing | Variable | Effect |
in the account-free reference run, which is not an error — that target simply has |---|---|
nothing to do. Only a filter matching *no case at all* fails, as the typo it is. | `PDFS_ACCEPTANCE_PDFS` | The `pdfs` binary to use, for example `target/debug/pdfs` |
| `PDFS_ACCEPTANCE_SYNC_TIMEOUT` | Seconds a sync folder may take to settle after a change of mode |
| `PDFS_ACCEPTANCE_REAP_AGE` | Age in seconds after which a stray `pdfs-acceptance-*` folder is removed (default 3600) |
| `PDFS_ACCEPTANCE_CONVERGENCE` | `1` checks that several `--live` paths show the same bytes |
| `PDFS_ACCEPTANCE_MIN_MIBPS`, `PDFS_ACCEPTANCE_MIN_OPS` | Throughput floors |
| `PDFS_ACCEPTANCE_UNIT` | The systemd unit `--durability` restarts |
Passing more paths runs the same filesystem suite independently against each. ## Before a release
The first path must be the FUSE mount under test; later paths may be another
FUSE mount or a normal local mirror filesystem:
```console 1. The quality gates and the simulation runs pass in CI.
PDFS_ACCEPTANCE_SYNC_TIMEOUT=180 \ 2. A full account run with `--journal-check` passes on a test account:
scripts/fuse-acceptance.sh --live /mnt/on-demand-testmount /mnt/testmount `scripts/fuse-acceptance.sh --journal-check`.
``` 3. Fixes marked *unverified* in [BUGS.md](BUGS.md) that a run now covers are marked verified.
If two paths really are views of the **same remote folder**, enable an What no automated run covers yet is listed under
additional byte-for-byte convergence check explicitly: [Verification still owed](ROADMAP.md#verification-still-owed).
```console
PDFS_ACCEPTANCE_CONVERGENCE=1 \
scripts/fuse-acceptance.sh --live /mnt/first-view /mnt/second-view
```
Do not enable that flag merely because two sync folders belong to the same
account. Their configured remote UIDs must be identical.
Normally the live suite applies the same API contract independently to each
supplied mount. In convergence mode it exercises the primary once and verifies
that the resulting bytes appear through every secondary view. A pass is
strong evidence for those paths, not a blanket data-loss guarantee. Before a
release, also run the recovery drills in `docs/RECOVERY.md` and inspect
`docs/BUGS.md` for live-verification items.
The runner accepts already-mounted paths instead of starting a daemon. This
keeps authentication and keyring setup explicit and prevents it from silently
reusing or replacing the normal application state directory.
+37 -9
View File
@@ -69,8 +69,24 @@ pdfs status
## Signing in ## Signing in
**"Proton is asking for a CAPTCHA"** — Proton wants human verification, usually on a VPN or an **"Proton is asking for a CAPTCHA"** — Proton wants human verification, usually on a VPN or an
unfamiliar network. The CLI cannot show it. Sign in with `pdfs-app`, which displays the challenge; unfamiliar network. `pdfs login` prints the verification page's address: open it in your browser,
the stored session then works for the CLI too. complete it, then press Enter. The app shows the page in its own window.
**"Verify in Your Browser"** — WebKit, which shows the CAPTCHA page in the app, could not start
its sandbox. Hardened systems such as secureblue keep its bubblewrap from the journal socket, and
the log then shows `bwrap: Can't get type of source /run/systemd/journal/socket: Permission
denied` and `Failed to fully launch dbus-proxy`. Choose **Open Verification Page**, complete the
page in your browser, then choose **Continue**. If that sign-in is refused too, check that `bwrap`
and `xdg-dbus-proxy` are installed. As a last resort, sign in once with the sandbox switched off
for this one run:
```bash
WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS=1 pdfs-app
```
Quit the app first, so this starts a new instance. The page then runs without WebKit's isolation,
so use this only for the sign-in. The stored session keeps working without it. Signing in from
your usual network, without a VPN, often avoids the CAPTCHA in the first place.
**Sign-in works but is forgotten after a restart** — no Secret Service provider is running, or its **Sign-in works but is forgotten after a restart** — no Secret Service provider is running, or its
keyring is locked. Install and unlock GNOME Keyring, KWallet or KeePassXC (with Secret Service keyring is locked. Install and unlock GNOME Keyring, KWallet or KeePassXC (with Secret Service
@@ -113,29 +129,41 @@ pdfs refresh Documents
**"Permission denied" when writing** — the item is in a share where you are a viewer, or under **"Permission denied" when writing** — the item is in a share where you are a viewer, or under
`Shared with me/`, which is read-only as a folder. Check your role with `pdfs shared-with-me`. `Shared with me/`, which is read-only as a folder. Check your role with `pdfs shared-with-me`.
**A program says "Operation not supported"** — Proton Drive cannot store symbolic links, hard **`ln` or another program fails with "Operation not permitted"** — Proton Drive cannot store
links, device files or FIFOs. The error is intentional. Use a synced folder for symbolic links, hard links, device files or FIFOs, so creating one fails at once. The error is
projects that need them, or exclude those paths. intentional. Use a synced folder for projects that need them, or exclude those paths.
## Uploads and sync ## Uploads and sync
**Something is stuck uploading** — list the queue and retry: **A change shows here but not on other devices** — changes are recorded on this computer first
and sent from a queue. List what is still waiting, and what needs attention:
```bash ```bash
pdfs sync queue pdfs sync queue
pdfs sync issues
```
The app shows the same on **Sync → Overview**. A change waits while the service is offline or
sync is paused (`pdfs status`), and backs off after a failure. To retry at once:
```bash
pdfs sync retry # every failed operation pdfs sync retry # every failed operation
pdfs sync retry 42 # one operation pdfs sync retry 42 # one operation
``` ```
The app shows the same list on **Sync → Overview**. An operation that keeps failing shows its A change Proton Drive refuses (storage full, access taken away, folder gone) is listed by
error there. A change Proton Drive will not take can be dropped instead, which undoes it here; `pdfs sync issues` with what to do. It stays queued until you fix the cause or drop it. Dropping
save a file's content first if you want to keep it: it undoes it here; save a file's content first if you want to keep it:
```bash ```bash
pdfs sync export 42 ~/Desktop/ pdfs sync export 42 ~/Desktop/
pdfs sync discard 42 pdfs sync discard 42
``` ```
If you suspect the local-first behaviour of 3.0.0 itself, `"local_first": false` in `config.json`
makes the Proton Drive folder wait for Proton Drive again while online (see
[CONFIGURATION.md](CONFIGURATION.md#local_first)), and please report the problem.
**A file in a synced folder does not upload** — check that: **A file in a synced folder does not upload** — check that:
- it is not matched by `.pdfsignore` or `ignore_patterns`; - it is not matched by `.pdfsignore` or `ignore_patterns`;
-354
View File
@@ -1,354 +0,0 @@
# UI/UX audit and improvement plan
Audit date: 2026-09-23. Scope: `pdfs-gui` (all pages, dialogs, photo viewer), `pdfs-tray`, and the
backend capabilities the UI would need. File references are to `crates/pdfs-gui/src/app/` unless
stated otherwise.
## 1. Summary
The client is functionally rich but the UI is built page by page, and it shows:
- **No sync control surface.** There is no way to pause or resume sync, no list of what is stuck or
failing, and no conflict overview. The backend has no pause at all, no per-op listing, and no
per-file conflict record (conflicts exist only as `(sync-conflict <ts>)` files plus activity rows).
The GUI reads `online`, `pending_uploads` and `pending_changes` from `Status` and ignores
`failing_ops`, `failing_error`, `parked_uploads` and `staged_*`.
- **Information architecture is scattered.** Live transfers are a group on *Settings* while the
*Activity* page is history only. *Locations* and *Computers* point at each other. Import lives under
Settings but is launched from Gallery and returns to Settings. Sign-out, quota, mount status,
pinned files, import and developer info all share the Settings page.
- **Chrome is non-standard.** Every page draws its own title and toolbar row under an almost empty
`AdwHeaderBar` whose title is always "Proton Drive". Two stacked bars, wasted height, no current
folder in the title.
- **Icons and wording drift.** Upload uses `document-send-symbolic` (reads as "send"), offline uses
stars (reads as "favorite"), trash icons mean "stop syncing", "remove device" and "remove
bookmark". "Keep offline" / "Unpin" / "Remove offline copy" / "Available offline" are one concept.
"Computers" vs "device", "Gallery" vs "Photos", "mount service" vs "daemon" vs "Connect".
- **Theming is hardcoded.** `PROTON_PURPLE` overrides the system accent; badge, viewer and tile
colours are hex/rgba literals and ignore light, dark and high-contrast modes.
- **Several visible bugs** (section 9).
## 2. Design principles
1. **Follow GNOME HIG and current libadwaita.** The system has libadwaita 1.9; the crate is pinned to
feature `v1_5`. Raise it to at least `v1_7` to get `adw::Spinner`, `ToggleGroup`,
`InlineViewSwitcher`, `ButtonRow`, `WrapBox`, `BottomSheet`, and (1.8) `ShortcutsDialog`.
2. **Sync state is first class.** The user must always see: up to date / syncing / paused / offline /
needs attention, and be one click from pausing or from the list of problems.
3. **One term, one icon, one place** per concept (glossary in section 8).
4. **Secondary and destructive actions go into menus.** Rows get at most one visible suffix action
plus a `view-more-symbolic` menu. Destructive actions are red inside the menu and in the confirm
dialog, not as filled buttons in headers.
5. **Never block on the network visibly.** Keep old content on screen while reloading, show a header
spinner, never flash the whole page to a "Loading…" status page.
6. **Respect the system.** Use the system accent by default (brand purple stays on login, app icon
and an opt-in toggle). Use named colours (`@success_color`, `@warning_color`, `@error_color`).
## 3. New information architecture
### Sidebar
```
Proton Drive (header: app name)
My files
Photos
Shared with me (badge: pending invitations)
Shared by me
Computers
Trash
─────────────
Sync (badge: issue count, icon reflects state)
───────────── footer
[●] Up to date · Pause ← sync status strip, click opens Sync
▓▓▓░░░ 269 GiB of 1.6 TiB ← quota
(avatar) user@proton.me ⋮ ← account menu: Preferences, Sign out…
```
- **Remove** sidebar rows: *Locations* (merged into Sync), *Activity* (becomes the Sync "History"
view), *Settings* (becomes an `AdwPreferencesDialog`, `Ctrl+,`).
- **Rename** *Gallery* to *Photos* and *Shared* to *Shared by me*.
- Primary menu: Preferences, Keyboard Shortcuts, About. The About dialog's `debug_info` gets app
version, user agent, daemon status and config path (replacing the "Developer" group).
### Sync page (new, replaces Locations + Activity + Settings→Activity)
Top: an `AdwBanner`-style status card with state icon, one-line summary ("Syncing 12 files · 3.4
MB/s", "Paused until you resume", "2 files need attention") and one primary button: **Pause** /
**Resume** (split button with "Pause for 1 hour / until tomorrow / until resumed").
Below, an `InlineViewSwitcher` with four views:
1. **Issues** (default when non-empty): conflicts, failing uploads (`last_error`, attempts, next
retry), folders in `error`/`conflict` state, parked uploads older than N minutes. Each row has a
primary fix action (Resolve…, Retry now) and a menu (Show in folder, Discard change…).
2. **Transfers**: in-flight uploads and downloads with progress, speed, cancel; background jobs
(thumbnail build, Takeout import, re-date) with their own cancel.
3. **Folders**: the old Locations list. Row: path, mode as a dropdown suffix (Synced / On-demand, with
confirm when going on-demand deletes local copies), state subtitle, `Sync now` and a menu (Open,
Change location…, Pause this folder, Stop syncing…). "Add folder…" as a `ButtonRow` at the end.
The primary mount appears first and is not removable.
4. **History**: the old Activity feed, with a kind filter, failures in `@error_color`, rows
activatable (open or reveal the target), and "Clear history".
### Preferences dialog (slimmed Settings)
| Page | Contents |
|---|---|
| General | Start on login; Mount location (path + Change…); Notifications (on sync issues, on import done) |
| Storage | Cache usage bar; Cache budget (spin row, in GB, "Unlimited" switch instead of magic 0); Clear cache…; Offline files (count + "Manage" → Sync › Folders filter or a sub-page) |
| Sync | Ignore patterns (editable list); Conflict clean-up: Off / Report / Remove identical copies (`conflict_sweep`); later: bandwidth limits |
| Advanced | Open-with rules, search prompt config (only if kept in GUI) |
Moved out: account row and Sign out (sidebar account menu, with confirm), quota (sidebar footer),
mount status (sidebar status strip), live transfers (Sync), Google Photos import (Photos page menu),
version/user agent (About → Debug info).
## 4. Backend work required
Everything below is new; nothing like it exists today.
### B-1 Pause and resume (global, then per folder)
- `Request::SetSyncPaused { paused: bool, until: Option<i64> }`, persisted (config key
`sync_paused_until` or a DB setting) so a pause survives restarts; `Response::Status` gains
`paused` and `paused_until`.
- Drain: `run_pending_drain` (`pdfs-fuse/src/drain.rs:265`) already releases claimed ops while
offline; gate on `paused` at the same point and reuse the wake path.
- Sync engine: add `SyncMsg::Pause/Resume` or gate `reconcile_all` / `reconcile_folder`; the 120 s
poll and watcher keep recording, they just do not apply.
- FUSE reads must keep working while paused (on-demand hydration is a read, not a sync); document that
pause means "no uploads, no mirror reconcile".
- Per folder later: `sync_folder.paused` column, skipped in `reconcile_all`.
- Bandwidth limits later: token bucket in `CountingReader` / `CountingWriter`
(`pdfs-fuse/src/transfers.rs`).
### B-2 Pending ops and transfers
- `Request::ListPendingOps` → id, kind, path/name, attempts, `last_error` (classified as
`ErrorKind`), `next_attempt_at`, parked flag, staged bytes.
- `Request::RetryOp { id }` (set `next_attempt_at = now`, wake drain) and `RetryAllFailing`.
- `Request::DiscardOp { id }` for a create/revision whose bytes should be dropped. Must save the
staged blob to a local recovery folder first; never silent data loss (see `docs/RECOVERY.md`).
- `TransferItem` gains a stable id and `Request::CancelTransfer { id }`.
- Per-folder last error message on `SyncFolderInfo` (today the text only exists in the activity feed).
### B-3 Conflicts
- `Request::ListConflicts`: walk nodes whose name parses with `sweep.rs` `conflict_base_name`, for
the mount and every mirror folder. Return copy path, original path (if it exists), both sizes and
mtimes, whether content is identical (size + `content_sha1`, as the sweep already proves), and
which side is local vs remote when known.
- Optionally a `conflict` table written by `keep_as_conflict_copy` / `preserve_conflict_copy` so the
origin (drain vs mirror, device) is recorded instead of inferred from names.
- `Request::ResolveConflict { copy, keep: Original | Copy | Both }`:
- Original: trash the copy (re-verify like the enforcing sweep does).
- Copy: upload the copy's content as a new revision of the original, then trash the copy (the old
content stays in version history, so this is reversible).
- Both: rename the copy to a user-chosen name, drop the `(sync-conflict …)` stamp.
- Activity rows for each resolution.
## 5. Shell and visual polish (all pages)
- **Per-page header bars.** Give each stack page its own `adw::ToolbarView` + `adw::HeaderBar` (or
inject page widgets into the shared bar on page switch). Title = page name, or the current folder
in My files. Page actions move into the header; the extra toolbar row disappears.
- **One page skeleton.** Same margins everywhere (today 12 vs 18), same `Clamp` rule (lists that are
settings-like clamp at 720, file/photo grids are full width), same status pages (`compact`,
`vexpand`, crossfade), same Retry semantics (Retry reloads; "Start Proton Drive" is a separate,
explicitly labelled button when the service is down).
- **Loading.** `adw::Spinner` in the header; keep the old model until the new one lands; status page
only on first load.
- **Theme.** Drop the global `accent_*` override; offer "Use Proton purple accent" in Preferences.
Replace hex colours with named colours; test light, dark and high contrast.
- **Move the 70-line CSS string** in `main.rs:232` to `resources/style.css` in the gresource
(`style-dark.css` if needed), so it can be edited and linted.
- **Shortcuts.** Replace the hand-rolled cheatsheet with `adw::ShortcutsDialog` (or
`GtkShortcutsWindow` before 1.8) listing all bindings, including F5/Ctrl+R and the viewer keys.
- **Toasts vs dialogs.** Dialogs report their own errors inline (versions, share, add bookmark); a
toast behind a modal is invisible. Pluralise properly (`ngettext`-ready helpers, no "item(s)").
- **i18n groundwork.** Wrap user strings in a `gettext!`-style macro now, even without translations,
so later localisation is mechanical.
## 6. Page-by-page plan
### My files
Header: `[‹ ›] Path bar (breadcrumbs, scroll to end, Ctrl+L to edit) … [search] [view ▾] [+ New ▾]`
- **New ▾** (`list-add-symbolic`, suggested): New folder (Ctrl+Shift+N), Upload files (Ctrl+U),
Upload folder. Replaces three separate icons, fixes the "send" icon.
- **View ▾** split button: Grid/List (Ctrl+1/2), Sort by Name/Size/Modified, ascending/descending,
Folders first, zoom slider. Persist choice (config or GSettings).
- **Folder ⋮ menu** (in header or background context menu): Refresh, Build thumbnails, Make folder
available offline, Open in file manager, Share folder…. Build thumbnails leaves the main toolbar.
- **Navigation.** Back/forward history (Alt+←/→), Up (Alt+↑); searching then pressing Back clears
the search consistently.
- **Context menus** as `gtk::PopoverMenu` from `gio::Menu` + actions: keyboard navigable, Menu /
Shift+F10, same action set as details pane. Add a background menu. Order: Open, Open with…, Play
(media) · Available offline (check item) · Share…, Copy link · Rename…, Move to…, Versions… ·
Move to Trash (destructive).
- **Offline state.** One concept "Available offline", toggle semantics. Badges: cloud-only = no
badge or subtle `cloud-outline`; downloaded = check; available offline = pin/`folder-download`
style filled icon. Use named colours. Folders can be made available offline too, or the toggle is
hidden for them (fix `details.rs:259`).
- **List view.** Sortable, resizable columns: Name, Status, Size, Modified (date + time), and
Location for search results. Rubberband selection. One selection model shared between views.
- **Details pane.** On wide windows a real side pane (breakpoint, not overlay); header bar with
close; real thumbnail; sections: Info, Offline, Sharing (people count, link), Versions (latest 3 +
"All versions…"). Same actions as the context menu.
- **Selection bar** (≥1 selected, not ≥2): count + size, Available offline, Move to…, Share (single),
Move to Trash; Select all (Ctrl+A). Drop the confirm dialog for trash since Undo exists (trash is
reversible), or keep confirm and drop Undo — not both.
- **Drag and drop.** Drop external files/folders to upload (reuse Takeout's `FileList` target), drag
the whole selection, highlight drop targets, breadcrumbs accept drops, Undo toast on move.
- **Move to…** a folder-picker dialog (tree/list of Drive folders) instead of a free-text path.
- **Rename** pre-selects the stem, not the extension; drop the redundant "Rename “x”." body.
- **Search.** Scope toggle (This folder / Everywhere), "Showing first 200 results" note, Location
column, correct cached badges (search hits hardcode `cached: false`).
- **Upload feedback.** Toast "Uploading 3 items" with "View" → Sync › Transfers.
### Photos (was Gallery)
- Header: title "Photos", `InlineViewSwitcher` Timeline | Albums in the header, Select toggle,
`+ Upload` (fix: use `adw::ButtonContent`, today `.icon_name()` overwrites `.label()` at
`photos.rs:426-432`), ⋮ menu: Import from Google Photos…, Refresh, Rebuild library.
- Filters: a single filter dropdown/`ToggleGroup` (All, Photos, Videos, RAW, Favorites) instead of
two segmented rows where "Photos" appears twice. Counts follow the active filter.
- **Month scrubber** on the right edge that *jumps* (not filters); the month dropdown becomes a real
"Filter by date" if kept.
- Infinite scroll with prefetch (no "Load more" pill; page size ≥ 200; also trigger when the first
page does not fill the viewport).
- Tiles: `ContentFit::Cover` (crop) in justified rows, known aspect ratio from metadata to prevent
reflow, placeholder as a tinted card of the final size, subtle hover (no 1.06 scale with heavy
shadow on a 2 px gap grid), last row justified up to a max stretch.
- Context menu on tiles: Open, Favorite, Add to album, Download/Save copy, Show in My files, Move to
Trash. Selection: Shift range, Ctrl+A, bulk Favorite, Add to album, Download.
- Empty states per filter ("No favorites yet", "No videos in June 2024") instead of the global
"No photos yet" with upload buttons.
- Import: runs show a banner on Photos ("Importing from Google Photos — 1,204 of 5,000 · View") and
Back returns to where the user came from.
- **Albums:** Create, rename, delete, remove from album (not trash). Fix state bugs: filter row
hidden on the album grid, subtitle count, scroll position kept when switching back.
- **Viewer:** `adw::Window` (or full-window overlay), controls auto-hide after 2 s idle, zoom/pan
(pinch, Ctrl+scroll, double-click), info panel beside the image instead of over the top bar,
pages beyond the loaded 60, counter against the real total, videos play inline with
`gtk::Video`, trash with the same confirm/undo rule as the grid, real filename in "Save a copy",
success toast, "Open with…" app chooser, favorite tooltip reflects state, readable dates.
### Shared with me / Shared by me
- Shared with me: header title shows the drill-down path, with back; invitations as a banner at
the top ("2 invitations · Review") opening a list, Reject confirmed; bookmarks become a separate
view or section via a switcher. Rows: Open, Save to My files, Available offline where supported.
- Shared by me: rows activatable (open), suffix "Manage" becomes a menu with Copy link, Manage
access…, Stop sharing….
- Share dialog: inline validation, loading spinner, confirm "Remove access" and "Delete link",
human role names, link expiry.
### Computers
- Show the real device name for "This computer" with rename inline.
- Other computers: one suffix menu (Rename, Use this identity…, Remove…). Remove uses a destructive
confirm that requires typing the device name since it deletes backups.
- "Restore folders" becomes a per-device action ("Restore to this computer…") with an inline-empty
state when nothing is restorable, and moves the "Add folder" responsibility fully to Sync.
- Terminology: "computer" everywhere in UI copy.
### Trash
- Same skeleton as other list pages (clamped `boxed-list`), sort by deleted date, multi-select with
Restore / Delete permanently, "Empty Trash…" moved into a header ⋮ menu or kept as a flat button
with a destructive confirm, proper plurals, Retry on error.
### Login
- Human error messages mapped from error kinds, spinner in the button, "Create account" and "Forgot
password" links, recovery-code hint in 2FA, cancelling CAPTCHA returns to the form with a message.
## 7. Tray
- Symbolic icon variants: `pdfs-synced`, `pdfs-syncing`, `pdfs-paused`, `pdfs-offline`,
`pdfs-attention`; tooltip with the one-line state; `NeedsAttention` status for issues.
- Menu: status line · "N issues — View" (opens Sync › Issues) · Pause sync ▸ (1 h, until tomorrow,
until resumed) / Resume · Open Proton Drive · Open folder · "Sign in…" when logged out (no
"Connect") · Stop Proton Drive (confirm) · "Hide tray icon" instead of "Quit" (daemon keeps
running and the label must not suggest otherwise).
## 8. Glossary (one term, one icon)
| Concept | UI term | Icon |
|---|---|---|
| Root of Drive | My files | `folder-symbolic` |
| Background service | Proton Drive (running / stopped) — never "daemon" or "mount service" in UI | — |
| Cloud-only file | Online only | none / `cloud-outline` |
| Pinned file | Available offline | pin/download-filled custom icon, not stars |
| Upload | Upload files / Upload folder | `document-upload`-style arrow up, custom pair |
| Folder sync mode | Synced / Online only | — |
| Where My files appears on disk | Proton Drive folder — never "mountpoint" in UI | — |
| Stop syncing a folder | Stop syncing… | `media-playback-stop-symbolic` in a menu, not trash |
| Remove a computer | Remove computer… | destructive, in menu |
| Conflict copy | Conflict | `dialog-warning-symbolic` + warning colour |
| Failure | Failed / Needs attention | `dialog-error-symbolic` + error colour |
| Pause | Pause sync / Resume sync | `media-playback-pause-symbolic` / `-start-` |
| Photos section | Photos | `image-x-generic-symbolic` |
| Sharing | Shared by me / Shared with me | distinct icons (`emblem-shared` / `folder-publicshare`) |
Spelling: US English throughout ("Favorite", "color"), matching the `pdfs favorite` command and
Proton's own apps. Title Case for
dialog headings and buttons in dialogs, Sentence case for menu items and rows (HIG).
## 9. Bugs found during the audit (quick wins)
1. Photos Upload pill loses its label (`photos.rs:426-432`); same for viewer "Show on map"
(`photo_viewer.rs:594-598`).
2. Takeout Back always goes to Settings, sidebar highlights Settings when opened from Photos
(`main.rs:779`, `takeout.rs:131-136`).
3. Thumbnail-build completion message computed then hidden (`browser.rs:1632-1667`).
4. Search results show cached files as cloud-only (`browser.rs:2344`).
5. Details "Available offline" switch shown for folders (`details.rs:259`).
6. Grid and list keep separate selections; details pane can describe a hidden-view item.
7. F2 silently does nothing with multiple items selected.
8. Filter changes on the Albums grid swap to the timeline while the Albums toggle stays active
(`photos.rs:958-997`).
9. Subtitle "60 photos" next to "All 1,416" (loaded count vs total, `photos.rs:2007-2015`); viewer
counter "12 of 60".
10. Viewer info panel covers Close/Trash/Save (`photo_viewer.rs:588` vs `648`).
11. Locations mode switch and removal reload Computers, not Locations; a rejected mode switch stays
flipped until the next tick (`devices.rs:612`, `devices.rs:809`, `locations.rs:374`).
12. Going on-demand deletes local copies without confirmation.
13. Retry on Not-connected silently restarts the systemd unit; Locations' Retry does not.
14. Toasts from Versions/Share dialogs land behind the modal.
15. "Emptied trash —" dangling dash, "item(s)", "archive(s)", raw revision id as version title,
raw lowercase link role.
16. Bulk context menu shows a separator directly under the header when a folder is selected.
17. Sign out, Reject invitation, Remove access, Delete link, tray Disconnect: no confirmation.
18. Stale doc comments (`devices.rs:46-48`, `locations.rs:329-331`, `albums.rs:4`,
`browser.rs:1155`).
## 10. Phased roadmap
Each phase is shippable on its own. Estimates are rough, for one developer.
| Phase | Content | Crates | Size |
|---|---|---|---|
| 0 Quick wins | Section 9 bugs, glossary wording, icon swaps (upload, offline, stop syncing), confirmations, plurals | gui, tray | S (2–3 days) |
| 1 Shell | libadwaita feature bump, per-page header bars, sidebar restructure + footer (status, quota, account), Preferences dialog, About debug info, theme/colour cleanup, CSS to gresource, shortcuts dialog | gui | M (1 week) |
| 2 Sync hub + pause | B-1 global pause, B-2 list/retry pending ops, Sync page (status card, Issues, Transfers, Folders, History), tray states and pause menu, GUI uses `failing_*` / `parked_*` | core, fuse, gui, tray, cli | L (1.5–2 weeks) |
| 3 Conflicts | B-3 list + resolve, Issues rows with a compare/resolve dialog (both versions side by side: size, date, device, preview for images/text), CLI `pdfs conflicts` | core, fuse, gui, cli | M–L |
| 4 File browser | Header layout, New menu, sort/persisted view, PopoverMenu context menus, details side pane, DnD upload, folder picker for Move, search scope | gui | L |
| 5 Photos | Rename, header switcher, filters, infinite scroll + scrubber, tile layout, context menu, album management, viewer rewrite (auto-hide, zoom, inline video) | gui (+ core for album mutations) | L |
| 6 Rest | Shared pages, Computers, Trash, Login, per-folder pause, bandwidth limits, i18n wrapping | all | M |
Phase 2 is where the user-reported gaps (pause, resume, stuck items) get closed; phase 3 closes the
conflict overview. Phases 0–1 fix most of the "unpolished" impression cheaply and should come first
because later phases build on the new header/page skeleton.
### Verification per phase
- `cargo fmt --all -- --check`, `cargo clippy --workspace --all-targets -- -D warnings`,
`cargo test --workspace`.
- Backend phases: DB migration tests in `crates/pdfs-core/src/db/tests.rs` (paused state, conflict
table if added), drain tests proving no op is claimed while paused, resolve-conflict tests proving
the losing side stays recoverable (trash or version history).
- GUI phases: before/after screenshots in `images/` (light and dark), keyboard-only walkthrough of
each page, narrow-window check (sidebar collapse breakpoint).
+54 -15
View File
@@ -7,6 +7,7 @@ How Proton Drive for Linux works and how to use it day to day. For installation
- [My files](#my-files) - [My files](#my-files)
- [Keeping files on this computer](#keeping-files-on-this-computer) - [Keeping files on this computer](#keeping-files-on-this-computer)
- [Syncing local folders](#syncing-local-folders) - [Syncing local folders](#syncing-local-folders)
- [Changes waiting for Proton Drive](#changes-waiting-for-proton-drive)
- [Conflicts](#conflicts) - [Conflicts](#conflicts)
- [Pausing and limiting sync](#pausing-and-limiting-sync) - [Pausing and limiting sync](#pausing-and-limiting-sync)
- [Photos](#photos) - [Photos](#photos)
@@ -33,7 +34,7 @@ the service to do things over a local socket:
Your Drive, your file names and your photos are end-to-end encrypted by Proton. On this computer, Your Drive, your file names and your photos are end-to-end encrypted by Proton. On this computer,
the service must decrypt what it shows you, and it keeps a decrypted cache and index on disk. Use the service must decrypt what it shows you, and it keeps a decrypted cache and index on disk. Use
full-disk or home-directory encryption. [ARCHITECTURE.md §8](ARCHITECTURE.md#8-threat-model-what-this-client-writes-to-disk-in-plaintext) full-disk or home-directory encryption. [ARCHITECTURE.md §10](ARCHITECTURE.md#10-threat-model-what-this-client-writes-to-disk-in-plaintext)
lists exactly what is stored. lists exactly what is stored.
The app uses these words consistently, and so does this guide: The app uses these words consistently, and so does this guide:
@@ -43,7 +44,7 @@ The app uses these words consistently, and so does this guide:
| **My files** | The root of your Drive | | **My files** | The root of your Drive |
| **Proton Drive folder** | Where My files appears on disk, `~/ProtonDrive` by default | | **Proton Drive folder** | Where My files appears on disk, `~/ProtonDrive` by default |
| **Online only** | A file or folder whose content downloads when opened | | **Online only** | A file or folder whose content downloads when opened |
| **Available offline** | A single file kept on this computer | | **Available offline** | A file, or a folder with everything in it, kept on this computer |
| **Synced folder** | A local folder with a full copy on this computer, backed up to Proton Drive | | **Synced folder** | A local folder with a full copy on this computer, backed up to Proton Drive |
| **Proton Drive service** | The background service (`proton-drive.service`) | | **Proton Drive service** | The background service (`proton-drive.service`) |
@@ -54,9 +55,11 @@ downloads only when a program reads it:
- **Reads fetch only what is needed.** A video player that seeks, or a tool that reads a file - **Reads fetch only what is needed.** A video player that seeks, or a tool that reads a file
header, downloads only those parts. Media streams while it plays. header, downloads only those parts. Media streams while it plays.
- **Writes are safe offline.** When a program closes a file it wrote, the service stores the new - **Changes work at local speed.** Creating, renaming, moving and deleting files and folders
content on disk and queues the upload. The queue survives network loss, restarts and crashes. returns at once, online or offline. When a program closes a file it wrote, the new content is
`pdfs sync queue` lists what has not reached Proton Drive yet. stored on disk. Every change is queued and sent to Proton Drive in the background; the changes
to one file are sent in the order you made them. The queue survives network loss, restarts and
crashes. See [Changes waiting for Proton Drive](#changes-waiting-for-proton-drive).
- **Recently read content is cached** on disk up to the cache size limit (5 GiB by default) and - **Recently read content is cached** on disk up to the cache size limit (5 GiB by default) and
evicted oldest first. evicted oldest first.
- **Items shared with you** appear under `Shared with me/` inside the Proton Drive folder. Shares - **Items shared with you** appear under `Shared with me/` inside the Proton Drive folder. Shares
@@ -72,11 +75,14 @@ try to create them get a clean error, not a hang or `EIO`.
## Keeping files on this computer ## Keeping files on this computer
Turn on **Make available offline** for a file in My files, or run `pdfs pin <path>`. The file is Turn on **Available offline** for a file or folder in My files, or run `pdfs pin <path>`. A file
downloaded completely and never evicted from the cache. `pdfs pins` lists these files, and is downloaded completely and never evicted from the cache. For a folder, every file in it and in
**Preferences → Storage → Available offline** shows them in the app. its subfolders downloads in the background, and the tray and the app show the progress.
`pdfs pins` lists what is kept, and **Preferences → Storage → Available offline** shows it in the
app.
Turning it off (`pdfs unpin <path>`) frees the space. The file stays on Proton Drive. Turning it off (`pdfs unpin <path>`) frees the space. The files stay on Proton Drive. A file inside
a folder kept offline stays with its folder: turn the folder off instead.
## Syncing local folders ## Syncing local folders
@@ -93,7 +99,9 @@ app and in the web app.
Each synced folder can also be switched to **Online only**. The local copy is replaced by a mount Each synced folder can also be switched to **Online only**. The local copy is replaced by a mount
of the folder, so the files stay visible but take no disk space until opened. Switch with the of the folder, so the files stay visible but take no disk space until opened. Switch with the
**Synced / Online only** switch on the folder's card, or `pdfs sync mode <id> ondemand` and back **Synced / Online only** switch on the folder's card, or `pdfs sync mode <id> ondemand` and back
with `pdfs sync mode <id> mirror`. `pdfs locations` lists every local place the service occupies. with `pdfs sync mode <id> mirror`. The switch waits until everything in the folder is on Proton
Drive, and refuses while the folder holds something that never goes there: an ignored path such
as `.git/`, a symlink, or a folder you may not read. The message names it. `pdfs locations` lists every local place the service occupies.
### Moving between synced folders and My files ### Moving between synced folders and My files
@@ -143,6 +151,39 @@ places, and both apply:
Rules are re-read on every pass. Ignoring never deletes anything: a file that becomes ignored is Rules are re-read on every pass. Ignoring never deletes anything: a file that becomes ignored is
simply no longer tracked, and its copy on Proton Drive stays. simply no longer tracked, and its copy on Proton Drive stays.
A folder you may not read, such as a cache a container created as root, is skipped the same way,
and the activity feed names it once. Add it to `.pdfsignore` to stop that message.
## Changes waiting for Proton Drive
A change you made in the Proton Drive folder exists only on this computer until it reaches
Proton Drive. `pdfs status`, the tray and **Sync → Overview** count what is still waiting, and
`pdfs sync queue` lists it.
Sometimes Proton Drive refuses a change: the storage is full, a share was taken away, the folder
is gone, the folder holds too many items, or Drive does not accept a name. The change then needs
your attention. It shows at once in **Sync → Overview**, in the tray, and in `pdfs sync issues`,
with what it means and what you can do. A file or folder with such a change carries a warning
badge in **Files**; hover it to see why.
Nothing is deleted. The change stays queued and is retried. You can:
- **Retry** it now, from the same list or with `pdfs sync retry`, for example after freeing space.
- **Export** a file's content to keep your version (`pdfs sync export <id> <file>`).
- **Discard** the change (`pdfs sync discard <id>`). It is undone here and never sent: a new file
or folder goes away, anything else goes back to what Proton Drive has. Export first if you want
to keep the content.
Two cases are not refused, and the activity log records both:
- A new file or folder whose name is already taken on Proton Drive by something this computer had
not seen yet lands under a `(sync-conflict …)` name, as described below.
- A new file whose folder was deleted on another device meanwhile lands at the top of My files,
so its content is not lost.
Before you shut down a computer for good, check that nothing is waiting: see
[RECOVERY.md](RECOVERY.md#lost-with-the-machine).
## Conflicts ## Conflicts
When the same file changed on this computer and somewhere else since the last sync, neither side When the same file changed on this computer and somewhere else since the last sync, neither side
@@ -166,11 +207,6 @@ The version you do not keep goes to the Trash, so a wrong choice can be undone.
reading files keeps working. `pdfs sync resume` continues. reading files keeps working. `pdfs sync resume` continues.
- **Limit bandwidth** under **Preferences → General → Network**, or with - **Limit bandwidth** under **Preferences → General → Network**, or with
`pdfs sync limit --up 2M --down 10M`. `0` removes a limit. `pdfs sync limit --up 2M --down 10M`. `0` removes a limit.
- **Retry** a failed upload from **Sync → Overview**, or with `pdfs sync retry`.
- **Discard** a change Proton Drive refused from the same list, or with `pdfs sync discard`: it is
undone here and never sent. Export a file first to keep your version.
- A file or folder with a change Proton Drive refused carries a warning badge in **Files**; hover
it to see why.
## Photos ## Photos
@@ -250,6 +286,9 @@ Proton Drive keeps earlier versions of every file. **Details → Versions…** i
**Restore to This Computer…** downloads its folders here. **Restore to This Computer…** downloads its folders here.
- **Continue This Backup Here…** makes this computer take over another computer's backup, for - **Continue This Backup Here…** makes this computer take over another computer's backup, for
example after reinstalling or replacing a machine. See [RECOVERY.md](RECOVERY.md). example after reinstalling or replacing a machine. See [RECOVERY.md](RECOVERY.md).
- **Move to This Computer…** moves another computer's folders into this computer's backup, for
example when you switch from Windows to Linux, and then offers them for syncing here. The other
computer stays in the list, without those folders.
## Search launcher ## Search launcher
Generated
+27
View File
@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1791456040,
"narHash": "sha256-H83yz/do+hjOO0uujHyH8RgtIQP2v7Mw+cLwcZK/xKc=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e7439b6b14ad3cc35d05608ebca9bce01a25f5f8",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+51
View File
@@ -0,0 +1,51 @@
{
description = "Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app and tray";
# CI pushes every build of `main` and of each release tag here, so installing
# downloads the client instead of compiling it. Nix asks before it uses a
# flake's caches unless the user trusts them (docs/INSTALL.md).
nixConfig = {
extra-substituters = [ "https://proton-drive-linux.cachix.org" ];
extra-trusted-public-keys = [
"proton-drive-linux.cachix.org-1:w1+3pXVXyTlqkFGn3dWpUhlaJtyGhSm3X0mFwfa++AA="
];
};
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs =
{ self, nixpkgs }:
let
systems = [
"x86_64-linux"
"aarch64-linux"
];
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
in
{
packages = forAllSystems (pkgs: rec {
proton-drive-linux = pkgs.callPackage ./packaging/nix/package.nix { };
default = proton-drive-linux;
});
overlays.default = final: _: {
proton-drive-linux = final.callPackage ./packaging/nix/package.nix { };
};
nixosModules.default = import ./packaging/nix/module.nix self;
devShells = forAllSystems (pkgs: {
default = pkgs.mkShell {
inputsFrom = [ self.packages.${pkgs.stdenv.hostPlatform.system}.proton-drive-linux ];
packages = with pkgs; [
cargo
clippy
rustfmt
rust-analyzer
];
};
});
formatter = forAllSystems (pkgs: pkgs.nixfmt);
};
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 363 KiB

+156
View File
@@ -0,0 +1,156 @@
<svg width="1920" height="1080" viewBox="0 0 1920 1080" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1920" y2="1080" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#1A1240"/>
<stop offset="1" stop-color="#0A0618"/>
</linearGradient>
<radialGradient id="glow1" cx="1390" cy="520" r="640" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#6D4AFF" stop-opacity="0.55"/>
<stop offset="0.5" stop-color="#6D4AFF" stop-opacity="0.15"/>
<stop offset="1" stop-color="#6D4AFF" stop-opacity="0"/>
</radialGradient>
<radialGradient id="glow2" cx="160" cy="1080" r="700" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#A855F7" stop-opacity="0.25"/>
<stop offset="1" stop-color="#A855F7" stop-opacity="0"/>
</radialGradient>
<pattern id="dots" width="32" height="32" patternUnits="userSpaceOnUse">
<circle cx="2" cy="2" r="1.6" fill="#FFFFFF"/>
</pattern>
<radialGradient id="dotfade" cx="1390" cy="520" r="900" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#FFFFFF" stop-opacity="1"/>
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0"/>
</radialGradient>
<mask id="dotmask" maskUnits="userSpaceOnUse" x="0" y="0" width="1920" height="1080">
<rect width="1920" height="1080" fill="url(#dotfade)"/>
</mask>
<linearGradient id="title2" x1="140" y1="0" x2="760" y2="0" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#C9BBFF"/>
<stop offset="1" stop-color="#8A6CFF"/>
</linearGradient>
<!-- app logo -->
<linearGradient id="pdl-face" x1="12" y1="12" x2="116" y2="112" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#A48BFF"/>
<stop offset="0.55" stop-color="#7552FF"/>
<stop offset="1" stop-color="#5B36F2"/>
</linearGradient>
<linearGradient id="pdl-cloud" x1="0" y1="34" x2="0" y2="88" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#FFFFFF"/>
<stop offset="1" stop-color="#ECE6FF"/>
</linearGradient>
<mask id="pdl-cut" maskUnits="userSpaceOnUse" x="0" y="0" width="128" height="128">
<g fill="#FFFFFF">
<circle cx="66" cy="57" r="23"/>
<circle cx="42" cy="68" r="16"/>
<circle cx="88" cy="69" r="15"/>
<rect x="26" y="68" width="77" height="20" rx="10"/>
</g>
<g stroke="#000000" stroke-width="7.5" stroke-linecap="round" stroke-linejoin="round" fill="none">
<path d="M64 49v26"/>
<path d="M54.5 65.5l9.5 9.5 9.5-9.5"/>
</g>
</mask>
<filter id="logoshadow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="6"/>
</filter>
<filter id="chipshadow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="14"/>
</filter>
</defs>
<!-- background -->
<rect width="1920" height="1080" fill="url(#bg)"/>
<rect width="1920" height="1080" fill="url(#glow2)"/>
<rect width="1920" height="1080" fill="url(#glow1)"/>
<rect width="1920" height="1080" fill="url(#dots)" opacity="0.07" mask="url(#dotmask)"/>
<!-- orbit rings -->
<g fill="none" stroke="#FFFFFF">
<circle cx="1390" cy="520" r="300" stroke-opacity="0.10" stroke-width="2"/>
<circle cx="1390" cy="520" r="420" stroke-opacity="0.06" stroke-width="2" stroke-dasharray="4 14" stroke-linecap="round"/>
</g>
<!-- logo, 128 -> 384 -->
<g transform="translate(1198 328) scale(3)">
<rect x="16" y="24" width="96" height="92" rx="24" fill="#2A1080" opacity="0.8" filter="url(#logoshadow)"/>
<rect x="12" y="16" width="104" height="100" rx="26" fill="#4424C0"/>
<rect x="12" y="12" width="104" height="100" rx="26" fill="url(#pdl-face)"/>
<rect x="12.5" y="12.5" width="103" height="99" rx="25.5" fill="none" stroke="#FFFFFF" stroke-opacity="0.2"/>
<rect y="3" width="128" height="128" fill="#2E1596" fill-opacity="0.3" mask="url(#pdl-cut)"/>
<rect width="128" height="128" fill="url(#pdl-cloud)" mask="url(#pdl-cut)"/>
</g>
<!-- feature chips around the logo -->
<g font-family="Adwaita Sans" font-size="26" font-weight="600" fill="#FFFFFF">
<!-- folder, top left -->
<g transform="translate(1000 200)">
<rect x="0" y="10" width="236" height="76" rx="22" fill="#000000" opacity="0.35" filter="url(#chipshadow)"/>
<rect x="0" y="0" width="236" height="76" rx="22" fill="#2A2058" fill-opacity="0.85" stroke="#FFFFFF" stroke-opacity="0.14" stroke-width="1.5"/>
<g transform="translate(22 20)">
<rect width="36" height="36" rx="10" fill="#6D4AFF"/>
<path d="M8 13a2 2 0 0 1 2-2h5l2.5 2.5H26a2 2 0 0 1 2 2V25a2 2 0 0 1-2 2H10a2 2 0 0 1-2-2z" fill="#FFFFFF"/>
</g>
<text x="74" y="47">On demand</text>
</g>
<!-- sync, top right -->
<g transform="translate(1590 290)">
<rect x="0" y="10" width="164" height="76" rx="22" fill="#000000" opacity="0.35" filter="url(#chipshadow)"/>
<rect x="0" y="0" width="164" height="76" rx="22" fill="#2A2058" fill-opacity="0.85" stroke="#FFFFFF" stroke-opacity="0.14" stroke-width="1.5"/>
<g transform="translate(22 20)">
<rect width="36" height="36" rx="10" fill="#1EA885"/>
<g fill="none" stroke="#FFFFFF" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M26 15a8.5 8.5 0 0 0-15-2"/>
<path d="M10 9v4.5h4.5"/>
<path d="M10 21a8.5 8.5 0 0 0 15 2"/>
<path d="M26 27v-4.5h-4.5"/>
</g>
</g>
<text x="74" y="47">Sync</text>
</g>
<!-- photos, bottom right -->
<g transform="translate(1560 690)">
<rect x="0" y="10" width="206" height="76" rx="22" fill="#000000" opacity="0.35" filter="url(#chipshadow)"/>
<rect x="0" y="0" width="206" height="76" rx="22" fill="#2A2058" fill-opacity="0.85" stroke="#FFFFFF" stroke-opacity="0.14" stroke-width="1.5"/>
<g transform="translate(22 20)">
<rect width="36" height="36" rx="10" fill="#E8618C"/>
<rect x="8" y="9" width="20" height="18" rx="3" fill="none" stroke="#FFFFFF" stroke-width="2.4"/>
<circle cx="14" cy="15" r="2" fill="#FFFFFF"/>
<path d="M9.5 25l6-6 4 4 3-3 4 4" fill="none" stroke="#FFFFFF" stroke-width="2.4" stroke-linejoin="round"/>
</g>
<text x="74" y="47">Photos</text>
</g>
<!-- terminal, bottom left -->
<g transform="translate(980 790)">
<rect x="0" y="10" width="206" height="76" rx="22" fill="#000000" opacity="0.35" filter="url(#chipshadow)"/>
<rect x="0" y="0" width="206" height="76" rx="22" fill="#2A2058" fill-opacity="0.85" stroke="#FFFFFF" stroke-opacity="0.14" stroke-width="1.5"/>
<g transform="translate(22 20)">
<rect width="36" height="36" rx="10" fill="#3B3F5C"/>
<path d="M9 12l6 6-6 6" fill="none" stroke="#FFFFFF" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M18 25h9" stroke="#FFFFFF" stroke-width="2.6" stroke-linecap="round"/>
</g>
<text x="74" y="48" font-family="JetBrainsMono NF" font-size="25" font-weight="700">pdfs</text>
<text x="150" y="48" font-weight="500" fill="#B9B0D9">CLI</text>
</g>
</g>
<!-- text block -->
<g font-family="Adwaita Sans">
<g transform="translate(140 292)">
<rect width="410" height="48" rx="24" fill="#6D4AFF" fill-opacity="0.18" stroke="#8A6CFF" stroke-opacity="0.6" stroke-width="1.5"/>
<circle cx="28" cy="24" r="6" fill="#A48BFF"/>
<text x="46" y="32" font-size="21" font-weight="700" letter-spacing="2.4" fill="#D6CCFF">UNOFFICIAL · OPEN SOURCE</text>
</g>
<text x="134" y="478" font-size="124" font-weight="800" letter-spacing="-3" fill="#FFFFFF">Proton Drive</text>
<text x="134" y="610" font-size="124" font-weight="800" letter-spacing="-3" fill="url(#title2)">for Linux</text>
<text x="140" y="702" font-size="38" font-weight="400" fill="#B9B0D9">Your Drive as a folder, and a native GTK4 app.</text>
<text x="140" y="752" font-size="38" font-weight="400" fill="#B9B0D9">No Electron.</text>
</g>
<!-- footer -->
<g font-family="JetBrainsMono NF" font-size="24" fill="#8C83B0">
<text x="140" y="968">github.com/narrrl/proton-drive-linux</text>
</g>
<g font-family="Adwaita Sans" font-size="22" fill="#6F6894">
<text x="1780" y="968" text-anchor="end">Not affiliated with Proton AG</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.3 KiB

Loaded 100 of 130 files, more files were not shown because too many files have changed in this diff. Show more