mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
170c3f01ff | ||
|
|
976ad2aa04 | ||
|
|
b73cd087aa | ||
|
|
3e0db8cf46 | ||
|
|
2a86830e72 | ||
|
|
43031ecf99 | ||
|
|
2b6c37f543 | ||
|
|
3458e2e72d | ||
|
|
0ac5928138 | ||
|
|
beb3b2e4e4 | ||
|
|
bf43476e88 | ||
|
|
6363125fec | ||
|
|
51d114a8da | ||
|
|
31be27f30e | ||
|
|
4c498d559c | ||
|
|
7fd3439b60 | ||
|
|
c4847c4d52 | ||
|
|
ad49e32de9 | ||
|
|
19854b2d76 | ||
|
|
085335ca14 | ||
|
|
9fb7e84d41 | ||
|
|
7ebe8e5210 | ||
|
|
922399b9a2 | ||
|
|
3b80e9bc8f | ||
|
|
dc8eb27c99 | ||
|
|
07d4ee42d9 | ||
|
|
0ac5e2e4fe | ||
|
|
7eb6aa0640 | ||
|
|
d13e74fc12 | ||
|
|
c0d211238b | ||
|
|
07c9b675d3 | ||
|
|
f82343b844 | ||
|
|
3e3dd7db41 | ||
|
|
d09e10dc7d | ||
|
|
8d0351124c | ||
|
|
a888e0bb28 | ||
|
|
c7fc85e943 | ||
|
|
5bfae0fee1 | ||
|
|
edd62c5f7a | ||
|
|
5c1530b4b9 | ||
|
|
ce324510de | ||
|
|
274bfd0ca2 | ||
|
|
b8cfd30ff3 | ||
|
|
9fa88ff397 | ||
|
|
f105159186 | ||
|
|
a14a63c94c | ||
|
|
ed3477d1b3 | ||
|
|
aa384ae2eb | ||
|
|
10c69b0d64 | ||
|
|
39be354bd2 | ||
|
|
2cca8afba9 | ||
|
|
6e9beb4b16 | ||
|
|
63484bf4b6 | ||
|
|
37c66b7220 | ||
|
|
41b6edbecb | ||
|
|
217738318e | ||
|
|
9291580fd6 | ||
|
|
812eb3d0bc | ||
|
|
31863a1da1 | ||
|
|
57a825887c | ||
|
|
1436c5f37e | ||
|
|
f0a43fb923 | ||
|
|
b47620651f | ||
|
|
2baad6720d | ||
|
|
c271a1897c | ||
|
|
d26dc8ada4 | ||
|
|
93f02672e6 | ||
|
|
307305282e | ||
|
|
a8ed6060af | ||
|
|
9dfe4c5450 | ||
|
|
da013c29d6 | ||
|
|
457a9d26f4 | ||
|
|
a68dae9422 | ||
|
|
e743cd60cf | ||
|
|
f9b1638603 | ||
|
|
6539c7314b | ||
|
|
903452a15b | ||
|
|
4e0fd3c0b1 | ||
|
|
a6f019ebe6 | ||
|
|
0f3c06f982 | ||
|
|
4f141c78ca | ||
|
|
ce60e006ac | ||
|
|
1612a9d07a | ||
|
|
378667fb37 | ||
|
|
3b81cc5240 | ||
|
|
d850265225 | ||
|
|
e32f72bfca | ||
|
|
2502af6e3c | ||
|
|
4757b957d2 | ||
|
|
8e54ed0517 | ||
|
|
9bbc994a4f | ||
|
|
ffa0fe698d | ||
|
|
25c46cfe0a | ||
|
|
573a29e596 | ||
|
|
ce2bc0ac84 | ||
|
|
b37d5e7928 | ||
|
|
18587124ac | ||
|
|
cd18029944 | ||
|
|
d6dd41407f | ||
|
|
60445d3b26 | ||
|
|
c2f93449ad | ||
|
|
4676a4e32b | ||
|
|
16fca10e6c | ||
|
|
dbc4d9a8c5 | ||
|
|
9c93860cd8 | ||
|
|
85ec91d6b3 | ||
|
|
d5d3b2da3f | ||
|
|
e5f3f612f8 | ||
|
|
3f74b95b34 | ||
|
|
7269cb5118 | ||
|
|
6d75a56b00 | ||
|
|
eb1c105bbf | ||
|
|
85e80d8aac | ||
|
|
23dc5c3723 | ||
|
|
50ae25bb88 | ||
|
|
9b2b9f24c5 | ||
|
|
ae6650f7e0 | ||
|
|
f029e652a1 | ||
|
|
9b3651e4e8 | ||
|
|
0dc476dfb7 | ||
|
|
de258cbd60 | ||
|
|
4ea18e9187 | ||
|
|
a6f36d56f3 | ||
|
|
77d0f254fc | ||
|
|
4eccdeeda6 | ||
|
|
473842eefc | ||
|
|
5e0f30cc2c | ||
|
|
b3e33a7002 | ||
|
|
f6456829fb | ||
|
|
327f538fed | ||
|
|
ad8b225cfd | ||
|
|
ea27f2bcd0 | ||
|
|
e4062cc97b | ||
|
|
c34cc93c45 | ||
|
|
7e3df7602d | ||
|
|
61a0d31408 | ||
|
|
644a47eb36 | ||
|
|
cd90c566cd | ||
|
|
1c262eb601 | ||
|
|
bf73a395e5 | ||
|
|
a5138639a8 | ||
|
|
988b78e810 | ||
|
|
2c46d62429 | ||
|
|
97743f17e3 | ||
|
|
3d10338807 | ||
|
|
d53ba1be21 | ||
|
|
c73d5c9436 | ||
|
|
8d92c69279 | ||
|
|
e32959e40a | ||
|
|
0b27c884a7 | ||
|
|
37c04f1e69 | ||
|
|
625776081b | ||
|
|
8cde79b8e3 | ||
|
|
eb53d122cc | ||
|
|
c64252193f | ||
|
|
cc70a21c05 | ||
|
|
644995cc56 | ||
|
|
50483bf3e1 | ||
|
|
4caba0a34c | ||
|
|
e8554d6434 | ||
|
|
ba1fdd65be | ||
|
|
0d4163b9bf | ||
|
|
f3c7a809f4 | ||
|
|
c1b5378045 | ||
|
|
3d6f53c16d | ||
|
|
8ceee5285b | ||
|
|
a181f1a5f0 | ||
|
|
7251f00e91 | ||
|
|
cb38f79aa3 | ||
|
|
54e9921efb | ||
|
|
f252b09757 | ||
|
|
a2992b3b9d | ||
|
|
7b67db5ced | ||
|
|
84c9ade8e3 | ||
|
|
9e26cd0285 | ||
|
|
73e60f1151 | ||
|
|
088e8f3b0d | ||
|
|
261d5dbf84 | ||
|
|
5f9d07aff1 | ||
|
|
3fb7b9be6b | ||
|
|
6c3a50ce7f | ||
|
|
f8304a0e86 | ||
|
|
e63f39f8c2 | ||
|
|
6f100dbe5e | ||
|
|
7c5ef269cd | ||
|
|
f13b6fc0ef | ||
|
|
3d61fc637b | ||
|
|
889b2aa4a9 | ||
|
|
8bd3867cb3 | ||
|
|
9577387c2f | ||
|
|
7a087b8827 | ||
|
|
7099106802 | ||
|
|
43385d36ee | ||
|
|
4b97f401cb | ||
|
|
af869944cf | ||
|
|
4b90abf3b5 | ||
|
|
61266fc9b1 | ||
|
|
d18655538e | ||
|
|
da7ea0ec56 | ||
|
|
127b71efd0 | ||
|
|
2339b17d97 | ||
|
|
bd4f797d1f | ||
|
|
e872cad058 | ||
|
|
081c8fb889 | ||
|
|
da35a05b84 | ||
|
|
a90eeecda9 | ||
|
|
c98ed0d83b | ||
|
|
c46b2c1082 | ||
|
|
fde9c2d271 | ||
|
|
4fd14eb745 | ||
|
|
b3c03a7162 | ||
|
|
8749d6c2c6 | ||
|
|
4ee2aed29b | ||
|
|
753056418c | ||
|
|
2757977fd6 | ||
|
|
2f14c1688f | ||
|
|
7dbf818d83 | ||
|
|
c3ec82392a | ||
|
|
ce4656e46a | ||
|
|
557eeb5f1d | ||
|
|
b47e6c91a0 | ||
|
|
5c1da9d7a5 | ||
|
|
0135c29a4f | ||
|
|
0b81b76cb7 | ||
|
|
7470bd89f3 | ||
|
|
e6cb52339d | ||
|
|
912acc760a | ||
|
|
5109427c32 | ||
|
|
e2c4dac8df | ||
|
|
ebeb9cdbef | ||
|
|
82e96543ac | ||
|
|
96c77e96cb | ||
|
|
90e110e893 | ||
|
|
0b7236bd4a | ||
|
|
2fa5eaa75b | ||
|
|
dc9ea9f541 | ||
|
|
b13d9d9f38 | ||
|
|
b696adefc2 | ||
|
|
519ec1af07 | ||
|
|
4820ea1236 | ||
|
|
a9d6e0280d | ||
|
|
3e4c756361 | ||
|
|
38126ff2b3 | ||
|
|
9529f2124b | ||
|
|
77b8fd65be | ||
|
|
d9064ef880 | ||
|
|
66ab1f5b16 | ||
|
|
56161f649e | ||
|
|
69b5ccb28b | ||
|
|
af84ce2168 | ||
|
|
52651caf8e | ||
|
|
0b63a65661 | ||
|
|
7f84ff7dea | ||
|
|
15985f8684 | ||
|
|
dfc48375e2 | ||
|
|
2dde69525e | ||
|
|
f6136ce344 | ||
|
|
4fa61a14c3 | ||
|
|
fe8fb8cdcf | ||
|
|
f457fa68fc | ||
|
|
be8464f4a0 | ||
|
|
a5d29969dc | ||
|
|
43d1afe705 | ||
|
|
29e5769dec | ||
|
|
0fbc6421ba | ||
|
|
abf76cd5e8 | ||
|
|
8d0a575a7c | ||
|
|
91a5f21340 | ||
|
|
8351382ef2 | ||
|
|
b58b60010e | ||
|
|
a86c3e586d | ||
|
|
fdda35918f | ||
|
|
8bc52edeb4 | ||
|
|
792eee75a4 | ||
|
|
067561564e | ||
|
|
cf9e1665ca | ||
|
|
cc308ac25a | ||
|
|
690b91deab | ||
|
|
9cd8a196aa | ||
|
|
860d29cfe8 | ||
|
|
6cc8f72bab | ||
|
|
376f3edb28 | ||
|
|
09c0684a5e | ||
|
|
c1df3b6cc2 | ||
|
|
afd71713d5 | ||
|
|
c6bdba024f | ||
|
|
5a8e73657c | ||
|
|
cf0d495f8c | ||
|
|
bee15ee7cf | ||
|
|
f24ac8a429 | ||
|
|
f78f663740 | ||
|
|
470948c191 | ||
|
|
b095b9f6b0 | ||
|
|
f4d6580cd3 | ||
|
|
b2f62f70e1 | ||
|
|
dcb2ab7fac | ||
|
|
eb56f4a1e0 | ||
|
|
c28803b037 | ||
|
|
cd8474954b | ||
|
|
d7e07d042d | ||
|
|
45852082c0 | ||
|
|
99ad3838e1 | ||
|
|
637b512f6a | ||
|
|
16ad6548c8 | ||
|
|
847ece17d7 | ||
|
|
50d2acdaba | ||
|
|
53caf63ad4 | ||
|
|
5176dd91e1 | ||
|
|
e88903d5ff | ||
|
|
b3e6af1ae5 | ||
|
|
b0f9513915 | ||
|
|
91b0864fce | ||
|
|
8bd9481902 | ||
|
|
b2d5870834 | ||
|
|
078774f6a1 | ||
|
|
326a9b4e92 | ||
|
|
78dc72a34d | ||
|
|
438b2b463e | ||
|
|
4cd49168c4 | ||
|
|
481c3fe0c7 | ||
|
|
098da2e97a | ||
|
|
d03d3eac3f | ||
|
|
433e206203 | ||
|
|
35418eea3f | ||
|
|
e48c4bb4b2 | ||
|
|
7ad9231858 | ||
|
|
e396c6b4d6 | ||
|
|
b37ab78451 | ||
|
|
6e3d2c335e | ||
|
|
14b6df7bce | ||
|
|
4d76462e62 | ||
|
|
a5e03ad8da | ||
|
|
2336ad9e87 | ||
|
|
d988236bfa | ||
|
|
d78ddeab8e | ||
|
|
b04cce3a23 | ||
|
|
7e8403936b | ||
|
|
866a174c7a | ||
|
|
352117e806 | ||
|
|
4b5666eacb | ||
|
|
9bbd8eb982 | ||
|
|
77198ed261 | ||
|
|
0d79c46729 | ||
|
|
a8210ce8fd | ||
|
|
551e14b644 | ||
|
|
7de667b386 | ||
|
|
295bbeadac |
No files matched your search
@@ -0,0 +1,76 @@
|
||||
name: Bug report
|
||||
description: Something in the client misbehaves
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Please check [`docs/BUGS.md`](../blob/main/docs/BUGS.md) first — the issue may already be
|
||||
tracked there with context.
|
||||
|
||||
**Do not report a security vulnerability here.** See [SECURITY.md](../blob/main/SECURITY.md).
|
||||
- type: textarea
|
||||
id: what
|
||||
attributes:
|
||||
label: What happened
|
||||
description: What you did, what you expected, and what happened instead.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: repro
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
placeholder: |
|
||||
1. …
|
||||
2. …
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
description: Output of `pdfs --version`, and how you installed it (deb / rpm / AUR / source).
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: environment
|
||||
attributes:
|
||||
label: Distribution and desktop
|
||||
placeholder: "Fedora 44, GNOME 48 (Wayland)"
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: surface
|
||||
attributes:
|
||||
label: Where does it happen
|
||||
multiple: true
|
||||
options:
|
||||
- FUSE mount / file operations
|
||||
- Sync or backed-up folders (Computers)
|
||||
- CLI (pdfs)
|
||||
- Desktop app (pdfs-app)
|
||||
- Tray (pdfs-tray)
|
||||
- Search launcher (pdfs-prompt)
|
||||
- Login / keyring
|
||||
- Photos
|
||||
- Packaging / installation
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: diagnose
|
||||
attributes:
|
||||
label: "`pdfs diagnose` output"
|
||||
description: Redact paths or the account address if you prefer.
|
||||
render: console
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Daemon logs
|
||||
description: "`journalctl --user -u proton-drive.service -n 200 --no-pager`"
|
||||
render: console
|
||||
- type: checkboxes
|
||||
id: dataloss
|
||||
attributes:
|
||||
label: Data
|
||||
options:
|
||||
- label: This bug involved missing, truncated, or overwritten file content.
|
||||
@@ -0,0 +1,11 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Security vulnerability
|
||||
url: https://github.com/narrrl/proton-drive-linux/security/advisories/new
|
||||
about: Report privately — never in a public issue.
|
||||
- name: Known issues
|
||||
url: https://github.com/narrrl/proton-drive-linux/blob/main/docs/BUGS.md
|
||||
about: The authoritative ledger of tracked bugs and their status.
|
||||
- name: Proton Drive service problems
|
||||
url: https://proton.me/support
|
||||
about: This is an unofficial client. Account or service issues go to Proton AG.
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Feature request
|
||||
description: Suggest a capability or an improvement
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: The problem
|
||||
description: What are you trying to do that the client makes hard or impossible today?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: proposal
|
||||
attributes:
|
||||
label: What you'd like to see
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: surface
|
||||
attributes:
|
||||
label: Which part of the client
|
||||
multiple: true
|
||||
options:
|
||||
- FUSE mount
|
||||
- Sync / Computers
|
||||
- CLI (pdfs)
|
||||
- Desktop app (pdfs-app)
|
||||
- Tray (pdfs-tray)
|
||||
- Search launcher (pdfs-prompt)
|
||||
- Photos
|
||||
- Packaging
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives you considered
|
||||
@@ -0,0 +1,26 @@
|
||||
## What this changes
|
||||
|
||||
<!-- The user-visible effect, in a sentence or two. -->
|
||||
|
||||
**Crates touched:**
|
||||
**Related `docs/BUGS.md` entries:**
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo test --workspace --locked
|
||||
```
|
||||
|
||||
<!-- Plus whatever else you ran: acceptance suite, a live mount, manual steps. -->
|
||||
|
||||
## Implications
|
||||
|
||||
- [ ] Adds a SQLite migration (new `MIGRATION_V*`, `SCHEMA_VERSION` bumped — no shipped migration edited)
|
||||
- [ ] Touches recovery, staging, or anything that can be the only copy of user data
|
||||
- [ ] Changes packaging, the systemd unit, or desktop entries
|
||||
- [ ] Changes the control protocol (daemon and front ends updated together)
|
||||
- [ ] None of the above
|
||||
|
||||
<!-- Screenshots for GTK changes. -->
|
||||
@@ -0,0 +1,107 @@
|
||||
name: AUR
|
||||
|
||||
on:
|
||||
# The -git package computes its pkgver from git at build time, so a new commit
|
||||
# upstream needs no AUR push. Only a changed -git recipe is published.
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'packaging/aur/proton-drive-for-linux-git/**'
|
||||
# Called by release.yml once the GitHub release and its assets exist.
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
AUR_SSH_PRIVATE_KEY:
|
||||
required: true
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Release to publish, such as 2.1.0. Leave empty to publish only the -git package.'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: aur
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Publish AUR packages
|
||||
runs-on: ubuntu-24.04
|
||||
container: archlinux:base-devel
|
||||
env:
|
||||
# makepkg refuses to run as root, so the packaging steps run as `builder`.
|
||||
HOME: /home/builder
|
||||
VERSION: ${{ inputs.version }}
|
||||
|
||||
steps:
|
||||
- name: Install packaging tools
|
||||
run: pacman -Syu --noconfirm --needed git openssh pacman-contrib
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
# The updated recipes are committed back to main, not to the tag.
|
||||
ref: main
|
||||
|
||||
- name: Create build user
|
||||
run: |
|
||||
useradd --create-home builder
|
||||
chown -R builder: "$GITHUB_WORKSPACE"
|
||||
|
||||
- name: Configure AUR access
|
||||
env:
|
||||
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||
run: |
|
||||
install -d -m700 -o builder -g builder "$HOME/.ssh"
|
||||
printf '%s\n' "$AUR_SSH_PRIVATE_KEY" > "$HOME/.ssh/aur"
|
||||
cat > "$HOME/.ssh/config" <<'EOF'
|
||||
Host aur.archlinux.org
|
||||
IdentityFile ~/.ssh/aur
|
||||
User aur
|
||||
EOF
|
||||
# Pin the host key to the fingerprint published on aur.archlinux.org.
|
||||
# The scan sometimes returns nothing from the runner, so retry it.
|
||||
for attempt in 1 2 3 4 5; do
|
||||
ssh-keyscan -T 15 -t ed25519 aur.archlinux.org > "$HOME/.ssh/known_hosts" &&
|
||||
[ -s "$HOME/.ssh/known_hosts" ] && break
|
||||
echo "ssh-keyscan attempt $attempt returned no key; retrying"
|
||||
sleep 10
|
||||
done
|
||||
ssh-keygen -lf "$HOME/.ssh/known_hosts" | grep -q 'SHA256:RFzBCUItH9LZS0cKB5UE6ceAYhBD5C8GeOBip8Z11+4'
|
||||
chown -R builder: "$HOME/.ssh"
|
||||
chmod 600 "$HOME/.ssh/aur"
|
||||
runuser -u builder -- git config --global user.name 'Nils Pukropp'
|
||||
runuser -u builder -- git config --global user.email 'contact@narl.io'
|
||||
|
||||
- name: Update release packages
|
||||
if: env.VERSION != ''
|
||||
run: runuser -u builder -- packaging/aur/update.sh "${VERSION#v}"
|
||||
|
||||
- name: Refresh the -git metadata
|
||||
if: env.VERSION == ''
|
||||
working-directory: packaging/aur/proton-drive-for-linux-git
|
||||
run: runuser -u builder -- sh -c 'makepkg --printsrcinfo > .SRCINFO'
|
||||
|
||||
- name: Publish to the AUR
|
||||
run: |
|
||||
if [ -n "$VERSION" ]; then
|
||||
runuser -u builder -- packaging/aur/push.sh
|
||||
else
|
||||
runuser -u builder -- packaging/aur/push.sh proton-drive-for-linux-git
|
||||
fi
|
||||
|
||||
- name: Commit the updated recipes to main
|
||||
if: env.VERSION != ''
|
||||
run: |
|
||||
runuser -u builder -- git add packaging/aur
|
||||
if ! runuser -u builder -- git diff --cached --quiet; then
|
||||
runuser -u builder -- git commit -m "chore(aur): update packages to ${VERSION#v}"
|
||||
runuser -u builder -- git push origin HEAD:main
|
||||
fi
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
rust:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install native dependencies
|
||||
run: |
|
||||
@@ -32,8 +32,14 @@ jobs:
|
||||
with:
|
||||
toolchain: '1.96'
|
||||
components: rustfmt, clippy
|
||||
cache: false
|
||||
|
||||
# Tags can only restore caches saved on main, so only main saves one. The
|
||||
# release workflow restores this key for its quality gates.
|
||||
- uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: debug
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Formatting
|
||||
run: cargo fmt --all -- --check
|
||||
@@ -57,3 +63,84 @@ jobs:
|
||||
|
||||
# Real Proton credentials and /dev/fuse deliberately do not run on pull
|
||||
# requests. Follow docs/TESTING.md on a dedicated test-account runner.
|
||||
|
||||
# The seeded simulation runs (docs/TESTING.md): real FUSE
|
||||
# daemons against an in-memory Drive, so they need /dev/fuse but no account.
|
||||
simulation:
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install native dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
pkg-config \
|
||||
fuse3 \
|
||||
libfuse3-dev \
|
||||
libgtk-4-dev \
|
||||
libadwaita-1-dev \
|
||||
libwebkitgtk-6.0-dev \
|
||||
libsecret-1-dev \
|
||||
libdbus-1-dev
|
||||
|
||||
- name: Install minimum supported Rust
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: '1.96'
|
||||
cache: false
|
||||
|
||||
- uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: debug
|
||||
save-if: false
|
||||
|
||||
- name: Simulation runs
|
||||
run: 'cargo test -p pdfs-fuse --lib --locked sim:: -- --ignored --test-threads=1'
|
||||
|
||||
# A hung step writes every daemon thread's stack next to the run's
|
||||
# state; without them a hang that does not replay cannot be told apart.
|
||||
- name: Keep the stacks of a hung run
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: simulation-stacks
|
||||
path: /tmp/pdfs-sim-*/stacks.txt
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Keeps the release-profile dependencies cached on main, where the release
|
||||
# workflow can restore them; a tag cannot reuse the caches of earlier tags.
|
||||
release-cache:
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install native dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
pkg-config \
|
||||
libfuse3-dev \
|
||||
libgtk-4-dev \
|
||||
libadwaita-1-dev \
|
||||
libwebkitgtk-6.0-dev \
|
||||
libsecret-1-dev \
|
||||
libdbus-1-dev
|
||||
|
||||
- name: Install minimum supported Rust
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: '1.96'
|
||||
cache: false
|
||||
|
||||
- uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: release
|
||||
|
||||
- name: Release build
|
||||
run: cargo build --release --locked
|
||||
+101
-15
@@ -10,13 +10,13 @@ permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build-release:
|
||||
name: Build and Release
|
||||
checks:
|
||||
name: Release quality gates
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
@@ -28,18 +28,22 @@ jobs:
|
||||
libadwaita-1-dev \
|
||||
libwebkitgtk-6.0-dev \
|
||||
libsecret-1-dev \
|
||||
libdbus-1-dev \
|
||||
desktop-file-utils \
|
||||
rpm
|
||||
libdbus-1-dev
|
||||
|
||||
- name: Set up Rust
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: '1.96'
|
||||
components: rustfmt, clippy
|
||||
cache: false
|
||||
|
||||
# A tag can read the caches of main but its own caches are useless to the next
|
||||
# tag, so this only restores what CI saved on main.
|
||||
- name: Cache Cargo target
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: debug
|
||||
save-if: false
|
||||
|
||||
- name: Verify tag and manifest versions
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
@@ -58,6 +62,43 @@ jobs:
|
||||
cargo test --workspace --locked
|
||||
scripts/fuse-acceptance.sh --offline-only
|
||||
|
||||
build-release:
|
||||
name: Build packages
|
||||
runs-on: ubuntu-24.04
|
||||
environment: 'rpm signing'
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
pkg-config \
|
||||
libfuse3-dev \
|
||||
libgtk-4-dev \
|
||||
libadwaita-1-dev \
|
||||
libwebkitgtk-6.0-dev \
|
||||
libsecret-1-dev \
|
||||
libdbus-1-dev \
|
||||
desktop-file-utils \
|
||||
gettext \
|
||||
rpm
|
||||
|
||||
- name: Set up Rust
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: '1.96'
|
||||
cache: false
|
||||
|
||||
# Restores the release-profile dependencies that CI builds on main.
|
||||
- name: Cache Cargo target
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: release
|
||||
save-if: false
|
||||
|
||||
- name: Build release binaries
|
||||
run: |
|
||||
cargo build --release --locked
|
||||
@@ -83,6 +124,7 @@ jobs:
|
||||
cp target/release/pdfs-tray dist/binaries/
|
||||
cp target/release/pdfs-app dist/binaries/
|
||||
cp target/release/pdfs-prompt dist/binaries/
|
||||
po/build.sh dist/binaries/locale
|
||||
|
||||
# Create tarball
|
||||
tar -czf proton-drive-linux-${{ env.VERSION }}-x86_64.tar.gz -C dist/binaries .
|
||||
@@ -109,6 +151,7 @@ jobs:
|
||||
cp packaging/io.narl.proton-drive-linux.svg deb/usr/share/icons/hicolor/scalable/apps/
|
||||
cp packaging/proton-drive.service deb/usr/lib/systemd/user/
|
||||
cp LICENSE deb/usr/share/licenses/proton-drive-linux/
|
||||
po/build.sh deb/usr/share/locale
|
||||
|
||||
# Calculate Installed-Size in KB for the control file
|
||||
INSTALLED_SIZE=$(du -sk deb | cut -f1)
|
||||
@@ -121,13 +164,14 @@ jobs:
|
||||
Priority: optional
|
||||
Architecture: amd64
|
||||
Maintainer: Nils Pukropp <contact@narl.io>
|
||||
Depends: fuse3, libgtk-4-1 (>= 4.12.0), libadwaita-1-0 (>= 1.5.0), libsecret-1-0, libwebkitgtk-6.0-4
|
||||
Depends: fuse3, libgtk-4-1 (>= 4.12.0), libadwaita-1-0 (>= 1.5.0), libsecret-1-0, libwebkitgtk-6.0-4, libimage-exiftool-perl
|
||||
Installed-Size: ${INSTALLED_SIZE}
|
||||
Description: Proton Drive client for Linux
|
||||
Proton Drive client for Linux featuring:
|
||||
- FUSE files-on-demand mount
|
||||
- Command-line interface (CLI)
|
||||
- GTK4 system tray, desktop application, and search launcher
|
||||
Homepage: https://proton-drive.narl.io
|
||||
Description: Unofficial Proton Drive client for Linux
|
||||
Files-on-demand Proton Drive for the Linux desktop, unaffiliated with Proton AG:
|
||||
- FUSE virtual mount with block-level caching
|
||||
- Scriptable command-line interface (pdfs)
|
||||
- GTK4 desktop application, system tray, and search launcher
|
||||
EOF
|
||||
|
||||
# Build Debian package
|
||||
@@ -147,6 +191,7 @@ jobs:
|
||||
--define "_topdir $PWD/rpmbuild" \
|
||||
--define "_sourcedir $PWD" \
|
||||
--define "git_dir $PWD" \
|
||||
--define "prebuilt 1" \
|
||||
--define "version ${RPM_VERSION}" \
|
||||
--define "_dbpath $PWD/rpmbuild/db" \
|
||||
packaging/proton-drive-linux.spec
|
||||
@@ -154,9 +199,29 @@ jobs:
|
||||
# Copy built RPM to root directory
|
||||
cp rpmbuild/RPMS/x86_64/*.rpm .
|
||||
|
||||
- name: Sign Fedora package (.rpm)
|
||||
env:
|
||||
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||
GPG_KEY_NAME: ${{ secrets.GPG_KEY_NAME }}
|
||||
GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
|
||||
run: |
|
||||
if [ -n "$GPG_PRIVATE_KEY" ] && [ -n "$GPG_KEY_NAME" ]; then
|
||||
echo "$GPG_PRIVATE_KEY" | gpg --batch --pinentry-mode loopback --passphrase "$GPG_PASSPHRASE" --import
|
||||
|
||||
cat <<EOF > ~/.rpmmacros
|
||||
%_signature gpg
|
||||
%_gpg_name ${GPG_KEY_NAME}
|
||||
%__gpg_sign_cmd %{__gpg} gpg --batch --verbose --no-armor --pinentry-mode loopback --passphrase %{_gpg_passphrase} --no-secmem-warning -u "%{_gpg_name}" -sbo %{__signature_filename} --digest-algo sha256 %{__plaintext_filename}
|
||||
EOF
|
||||
|
||||
rpm --addsign --define "_gpg_passphrase $GPG_PASSPHRASE" *.rpm
|
||||
else
|
||||
echo "Skipping RPM signing: GPG_PRIVATE_KEY or GPG_KEY_NAME secret is not set."
|
||||
fi
|
||||
|
||||
|
||||
- name: Upload Workflow Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: proton-drive-linux-builds
|
||||
path: |
|
||||
@@ -165,9 +230,20 @@ jobs:
|
||||
*.rpm
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
name: Create GitHub Release
|
||||
needs: [checks, build-release]
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- name: Download packages
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: proton-drive-linux-builds
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
files: |
|
||||
*.deb
|
||||
@@ -177,3 +253,13 @@ jobs:
|
||||
prerelease: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
aur:
|
||||
name: Publish AUR packages
|
||||
needs: publish
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: ./.github/workflows/aur.yml
|
||||
with:
|
||||
version: ${{ github.ref_name }}
|
||||
secrets:
|
||||
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||
@@ -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."
|
||||
@@ -0,0 +1,32 @@
|
||||
# Code of Conduct
|
||||
|
||||
## Our pledge
|
||||
|
||||
We want participation in this project to be a harassment-free experience for everyone, regardless
|
||||
of age, body size, disability, ethnicity, sex characteristics, gender identity and expression,
|
||||
level of experience, education, socioeconomic status, nationality, personal appearance, race,
|
||||
religion, or sexual identity and orientation.
|
||||
|
||||
## Standards
|
||||
|
||||
Examples of behavior that helps:
|
||||
|
||||
- being respectful of differing viewpoints and experience levels,
|
||||
- giving and accepting constructive feedback gracefully,
|
||||
- focusing on what is best for the project and its users.
|
||||
|
||||
Examples of unacceptable behavior:
|
||||
|
||||
- sexualized language or imagery, and unwelcome sexual attention,
|
||||
- trolling, insulting or derogatory comments, and personal or political attacks,
|
||||
- public or private harassment,
|
||||
- publishing others' private information without explicit permission.
|
||||
|
||||
## Scope and enforcement
|
||||
|
||||
This applies in all project spaces — issues, pull requests, discussions — and when representing the
|
||||
project in public. Report unacceptable behavior to <contact@narl.io>. Reports are handled
|
||||
confidentially. Maintainers may edit, remove, or reject contributions that violate this document,
|
||||
and may temporarily or permanently ban any contributor for behavior they deem inappropriate.
|
||||
|
||||
Adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.1.
|
||||
@@ -0,0 +1,79 @@
|
||||
# Contributing
|
||||
|
||||
Thanks for taking the time. This is an unofficial Proton Drive client that people trust with real
|
||||
files, so the bar for changes that touch the filesystem, the sync engine, or the database is
|
||||
deliberately high — everything else is ordinary Rust review.
|
||||
|
||||
## Before you start
|
||||
|
||||
- Read [`AGENTS.md`](AGENTS.md) for repository conventions,
|
||||
[`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md) for building, running and debugging from source,
|
||||
and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for how the daemon, front ends, and SDK fit
|
||||
together. [`docs/README.md`](docs/README.md) indexes the rest.
|
||||
- Check [`docs/BUGS.md`](docs/BUGS.md) — it is the authoritative issue ledger, and your problem may
|
||||
already be tracked there with context.
|
||||
- For anything larger than a fix, open an issue first so the design can be discussed before you
|
||||
spend the time.
|
||||
|
||||
## Development setup
|
||||
|
||||
```bash
|
||||
git clone https://github.com/narrrl/proton-drive-linux.git
|
||||
cd proton-drive-linux
|
||||
cargo build --workspace
|
||||
```
|
||||
|
||||
GUI and FUSE targets need the GTK4, libadwaita, WebKitGTK 6, libsecret, D-Bus, and FUSE3
|
||||
development packages listed in [`docs/INSTALL.md`](docs/INSTALL.md#build-dependencies). Minimum
|
||||
supported Rust version is 1.96. [`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md#running-from-source)
|
||||
explains how to run your build next to, or instead of, the installed service.
|
||||
|
||||
## Quality gates
|
||||
|
||||
CI runs exactly these, and they must pass with zero warnings:
|
||||
|
||||
```bash
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo test --workspace --locked
|
||||
scripts/fuse-acceptance.sh --offline-only
|
||||
```
|
||||
|
||||
Changes to the FUSE layer, sync, or the queue should also pass the simulation runs and be
|
||||
exercised against a real mount before review — see [`docs/TESTING.md`](docs/TESTING.md). Live
|
||||
runs are destructive: use a dedicated test account, never one holding data you cannot lose.
|
||||
|
||||
## Making changes
|
||||
|
||||
- **Keep shared logic in `pdfs-core`.** The CLI and GUI never touch the database or the network;
|
||||
they speak the control protocol to the daemon.
|
||||
- **The SQLite schema is forward-only.** Never edit a shipped migration — add a new one and bump
|
||||
`SCHEMA_VERSION` in `crates/pdfs-core/src/db/migrations.rs`.
|
||||
- **Typed errors and `?` over panics** on runtime paths. Blocking filesystem, keyring, or
|
||||
control-socket work stays off GTK's main thread.
|
||||
- **`staging/` and `recovery/` may hold the only copy of user data.** Nothing may clear or purge
|
||||
them opportunistically.
|
||||
- **The GUI is fully translated.** Wrap every user-visible string in the helpers from
|
||||
`crates/pdfs-gui/src/i18n.rs`, run `po/update.sh`, and translate the new entries in every
|
||||
catalog in `po/LINGUAS` in the same change. See [`docs/TRANSLATING.md`](docs/TRANSLATING.md).
|
||||
The CLI and daemon messages stay in English.
|
||||
- **Document what users see.** A new command, setting or behaviour belongs in the matching page
|
||||
under `docs/` (user guide, CLI reference or configuration) and in the `[Unreleased]` section of
|
||||
[`docs/CHANGELOG.md`](docs/CHANGELOG.md).
|
||||
|
||||
## Commits and pull requests
|
||||
|
||||
Commits follow scoped [Conventional Commits](https://www.conventionalcommits.org), e.g.
|
||||
`fix(fuse,core): keep truncate composable with queued revisions`.
|
||||
|
||||
A pull request should state:
|
||||
|
||||
- the user-visible effect and which crates it touches,
|
||||
- the `docs/BUGS.md` entries it fixes or affects,
|
||||
- the commands you ran to verify it,
|
||||
- screenshots for GTK changes,
|
||||
- and, explicitly, any migration, recovery, data-loss, or packaging implications.
|
||||
|
||||
## Security
|
||||
|
||||
Do not open a public issue for a vulnerability — see [SECURITY.md](SECURITY.md).
|
||||
Generated
+701
-791
File diff suppressed because it is too large.
Load diff
+27
-8
@@ -3,15 +3,20 @@ resolver = "2"
|
||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.1.1"
|
||||
version = "3.2.1"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||
rust-version = "1.96"
|
||||
repository = "https://github.com/narrrl/proton-drive-linux"
|
||||
homepage = "https://proton-drive.narl.io"
|
||||
readme = "README.md"
|
||||
keywords = ["proton", "drive", "fuse", "sync", "linux"]
|
||||
categories = ["filesystem", "command-line-utilities"]
|
||||
|
||||
[workspace.dependencies]
|
||||
proton-sdk = { version = "0.2.2" }
|
||||
proton-drive-rs = { version = "0.2.2" }
|
||||
proton-sdk = { version = "0.8.2" }
|
||||
proton-drive-rs = { version = "0.8.2" }
|
||||
|
||||
# Internal crates.
|
||||
pdfs-core = { path = "crates/pdfs-core" }
|
||||
@@ -20,6 +25,8 @@ pdfs-fuse = { path = "crates/pdfs-fuse" }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "io-util", "time", "signal"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
base64 = "0.23"
|
||||
zeroize = "1"
|
||||
thiserror = "2"
|
||||
anyhow = "1"
|
||||
# Non-poisoning mutexes. A handler that panics while holding a lock must not
|
||||
@@ -29,16 +36,28 @@ anyhow = "1"
|
||||
parking_lot = "0.12"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
directories = "5"
|
||||
directories = "6"
|
||||
ignore = "0.4"
|
||||
sha2 = "0.10"
|
||||
sha2 = "0.11"
|
||||
# Google Takeout exports are zip sets; the importer reads their central
|
||||
# directories and streams entries out without extracting to disk first.
|
||||
zip = { version = "8", default-features = false, features = ["deflate"] }
|
||||
# Proton's photo duplicate detection is keyed on the plaintext SHA-1, so an
|
||||
# import has to speak SHA-1 whatever its age.
|
||||
sha1 = "0.11"
|
||||
# Thumbnail generation for photos the server has none for. Decoders only for the
|
||||
# formats a camera or phone actually produces — the rest of the crate's codecs
|
||||
# would be dead weight in the daemon.
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "tiff", "bmp", "rayon"] }
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
keyring = { version = "3", features = ["sync-secret-service", "crypto-rust"] }
|
||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||
# keyring 4 split into `keyring-core` plus per-platform stores. The libdbus
|
||||
# Secret Service store is the one keyring 3's `sync-secret-service` wrapped, and
|
||||
# with no `target` it matches items v3 wrote, so existing logins survive.
|
||||
keyring-core = "1"
|
||||
dbus-secret-service-keyring-store = { version = "1", features = ["crypto-rust"] }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
rpassword = "7"
|
||||
fuser = "0.17"
|
||||
async-trait = "0.1"
|
||||
futures = "0.3"
|
||||
fuser = "0.18"
|
||||
libc = "0.2"
|
||||
@@ -1,299 +1,177 @@
|
||||
# Proton Drive client for Linux (unofficial)
|
||||
<div align="center">
|
||||
|
||||
A fast, unofficial Proton Drive client for Linux. This client features an advanced files-on-demand FUSE virtual mount with block-level caching, a command-line interface (CLI), and a fully non-blocking GTK4 desktop application with system tray integration.
|
||||
<img src="images/logo.svg" alt="" width="112" height="112">
|
||||
|
||||
# Proton Drive for Linux
|
||||
|
||||
**Proton Drive on the Linux desktop.** Your files as a folder that downloads on demand, synced
|
||||
local folders, Photos, sharing, and a native GTK4 app with tray and search launcher, plus a
|
||||
scriptable CLI.
|
||||
|
||||
**[proton-drive.narl.io](https://proton-drive.narl.io)**
|
||||
|
||||
[](https://github.com/narrrl/proton-drive-linux/actions/workflows/ci.yml)
|
||||
[](https://github.com/narrrl/proton-drive-linux/releases/latest)
|
||||
[](https://aur.archlinux.org/packages/proton-drive-for-linux)
|
||||
[](LICENSE)
|
||||
[](https://www.rust-lang.org)
|
||||
[](https://buymeacoffee.com/narl)
|
||||
|
||||
<img src="images/files.png" alt="My files in the Proton Drive app" width="820">
|
||||
|
||||
</div>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> This is an **unofficial**, community-built client. It is not affiliated with, endorsed by, or
|
||||
> supported by Proton AG. "Proton" and "Proton Drive" are trademarks of Proton AG, used here only
|
||||
> to describe what the software talks to. Try it with data you have another copy of first, and
|
||||
> read [what survives a lost machine](docs/RECOVERY.md) before you rely on it.
|
||||
|
||||
## Features
|
||||
|
||||
- **Files-on-Demand (FUSE)**: Mount your Proton Drive as a virtual filesystem where files are downloaded only when opened, utilizing smart block-level caching and disk-backed writes.
|
||||
- **Command-Line Interface (CLI)**: Manage your drive, authenticate, and monitor sync status directly from the terminal.
|
||||
- **Non-Blocking GTK4 Desktop App**: Browse files, manage pins, configure options, and monitor active transfers through a modern, native GUI with a fully non-blocking asynchronous main loop.
|
||||
- **Shared Links & Invites**: Browse files shared with you by other users, and view/manage your own public shared links directly in the GUI.
|
||||
- **Local Backup (Computers)**: Sync and back up local directories (like Downloads, Documents, Pictures, etc.) directly to your Proton Drive account.
|
||||
- **System Tray Integration**: Background indicator for status monitoring, quick actions, and fast sync controls.
|
||||
- **Unified Search Launcher (HUD)**: A resident Google Drive-style launcher (`pdfs-prompt`) that searches Proton Drive and local files together, ranks the best matches, tolerates abbreviations and typos, and is ideal for a system-wide hotkey.
|
||||
- **Secure Credential Storage**: Integrates with the system Secret Service (GNOME Keyring, KWallet, etc.) with smart in-memory credential caching to avoid UI thread blockages.
|
||||
- **Proton Photos Support**: Access your Proton Photos timeline, view thumbnails, and download backed-up media natively (available in the GUI as a navigation tab and via the CLI).
|
||||
- **Human Verification (CAPTCHA) Recovery**: Detects sign-in gates (VPN/new IP challenges) and launches an embedded `WebKitWebView` dialog to safely complete the challenge, transparently retrying authentication with the earned token.
|
||||
- **Selective Sync (`.pdfsignore`)**: Keep build trees, dependency directories, and editor leftovers out of synced folders using gitignore-style rules.
|
||||
- **Data-Safe Offline Writes**: Durable scratch/staging files and a transactional pending queue preserve acknowledged writes across network failures and restarts.
|
||||
**Files**
|
||||
|
||||
## Quick Search and Media Opening
|
||||
- **Your Drive as a folder.** `~/ProtonDrive` works with every program. Files download when they
|
||||
are opened, a block at a time, so a video starts playing and a large archive can be read
|
||||
without fetching all of it.
|
||||
- **Available offline.** Keep chosen files and folders fully downloaded; everything else is
|
||||
cached within a size limit you set.
|
||||
- **Synced folders.** Back up folders such as `~/Documents` under your computer in Proton Drive,
|
||||
as a full two-way copy or online only. Gitignore-style `.pdfsignore` rules, conflict copies
|
||||
instead of overwrites, pause and bandwidth limits.
|
||||
- **Works at local speed.** Making, renaming and deleting files and folders returns at once, online
|
||||
or offline. Changes are staged on disk and queued before they are acknowledged, so they survive
|
||||
a lost connection, a crash or a reboot and reach Drive when they can.
|
||||
|
||||
`pdfs-prompt` searches Drive metadata and the local home-directory index in one request. Results are ranked together rather than split by source. Matching is case-insensitive and supports exact names, prefixes, parent paths, multiple terms, punctuation-separated words, ordered abbreviations, common typos, and adjacent transpositions.
|
||||
**Desktop app**
|
||||
|
||||
The prompt stays resident after its first launch, so invoking the shortcut again reuses and resets the existing window. Drive folders open from the mount. Audio and video results also open through FUSE, allowing media players to seek and request ranges without downloading the entire file first; ordinary Drive files are materialized into the local cache before opening.
|
||||
- **Browse** My files, Shared with me, Shared by me, Computers and Trash, with thumbnails
|
||||
(including camera RAW), drag and drop, details and **version history**.
|
||||
- **Photos.** Timeline with favorites, albums, and Places on a map; RAW+JPEG pairs shown as one
|
||||
photo; duplicate finder; slideshow; **import from Google Photos** (Takeout).
|
||||
- **Sharing.** Invite people as viewer, editor or admin, create public links with password and
|
||||
expiry, accept invitations.
|
||||
- **Sync overview.** What is uploading, what is waiting, what failed and why, and a history of
|
||||
what happened. A change Drive refuses (storage full, access taken away) shows at once, on the
|
||||
file too, and can be exported or discarded.
|
||||
- **Tray icon** with status, pause and quick actions, and a **search launcher** (`pdfs-prompt`)
|
||||
that searches Proton Drive and your home folder together, as its own window, through fuzzel,
|
||||
rofi or wofi, or live in `fzf`.
|
||||
- **Restore a computer.** Continue a backup on a new machine and bring its folders back in one
|
||||
step.
|
||||
- **Sign-in** with two-factor authentication and CAPTCHA; the session is kept in the system
|
||||
keyring.
|
||||
- Available in English, German, Spanish, French, Italian, Dutch, Polish and Brazilian
|
||||
Portuguese.
|
||||
|
||||
## Selective Sync (`.pdfsignore`)
|
||||
**Command line**
|
||||
|
||||
Two-way synced folders skip paths matched by ignore rules, so syncing a project
|
||||
directory does not upload `node_modules/`, `target/`, or `.git/`.
|
||||
- About 55 `pdfs` commands cover files, sync, sharing, Photos, Trash, versions and diagnostics.
|
||||
Query commands print JSON with `--json`.
|
||||
|
||||
Rules come from two places, and both apply:
|
||||
## Install
|
||||
|
||||
1. **Per folder** — a `.pdfsignore` file at the root of the synced folder
|
||||
(`.protonignore` also works). Gitignore syntax, including negation:
|
||||
|
||||
```gitignore
|
||||
# everything build-related
|
||||
build/
|
||||
*.log
|
||||
|
||||
# ...except this one
|
||||
!important.log
|
||||
```
|
||||
|
||||
2. **Globally** — an `ignore_patterns` list in `config.json`, applied to every
|
||||
synced folder. When unset, sensible defaults apply: `.git/`, `.hg/`, `.svn/`,
|
||||
`node_modules/`, `target/`, `.venv/`, `__pycache__/`, `*~`, `*.swp`, `*.tmp`,
|
||||
`.DS_Store`, and `Thumbs.db`.
|
||||
|
||||
```json
|
||||
{
|
||||
"ignore_patterns": ["node_modules/", "target/", "*.iso"]
|
||||
}
|
||||
```
|
||||
|
||||
Set it to `[]` to opt out of the defaults entirely.
|
||||
|
||||
Rules are re-read at the start of every sync pass, so edits take effect on the
|
||||
next pass without restarting the daemon.
|
||||
|
||||
**Ignoring is never destructive.** If a rule starts matching a file that was
|
||||
already synced, its copy on Drive is left untouched — the file simply stops
|
||||
being tracked. Removing the rule later picks the existing remote file back up
|
||||
rather than re-uploading it.
|
||||
|
||||
## Diagnostics & Maintenance
|
||||
|
||||
When something looks wrong, `pdfs diagnose` checks the installation and prints a
|
||||
report. It runs without a daemon on purpose — the state worth diagnosing is
|
||||
usually the state where the daemon will not start:
|
||||
|
||||
```console
|
||||
$ pdfs diagnose
|
||||
Paths
|
||||
[ok ] state dir: /home/you/.local/state/proton-drive-linux
|
||||
[ok ] database: /home/you/.local/state/proton-drive-linux/cache.db (170.3 MiB)
|
||||
|
||||
Account
|
||||
[ok ] keyring session: you@proton.me
|
||||
|
||||
Daemon
|
||||
[ok ] daemon responding
|
||||
[ok ] mounted at: /home/you/ProtonDrive
|
||||
[ok ] queued writes: none
|
||||
|
||||
No problems found.
|
||||
```
|
||||
|
||||
It exits non-zero if any check fails, so it works in a health-check script.
|
||||
|
||||
For the local metadata database and content cache:
|
||||
|
||||
| Command | What it does |
|
||||
| Distribution | Package |
|
||||
|---|---|
|
||||
| `pdfs cache inspect` | Database size, reclaimable space, per-table row counts, cache usage against budget |
|
||||
| `pdfs cache inspect --deep` | Also runs SQLite's integrity check — reads every page, so it is slow on a large database |
|
||||
| `pdfs cache vacuum` | Checkpoints the write-ahead log and compacts the database |
|
||||
| `pdfs cache clear` | Deletes cached file content, keeping pinned files |
|
||||
| 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) |
|
||||
| Arch Linux | [`proton-drive-for-linux`](https://aur.archlinux.org/packages/proton-drive-for-linux) (also `-bin` and `-git`) in the AUR |
|
||||
| Anything else | [Build from source](docs/INSTALL.md#build-from-source) |
|
||||
|
||||
`vacuum` takes a write lock for its duration and needs room for a second copy of
|
||||
the database while it runs, so it is a deliberate operation rather than
|
||||
something the daemon does on a timer.
|
||||
|
||||
Before a release, run the real-kernel acceptance suite against a dedicated test
|
||||
account and mount. See [`docs/TESTING.md`](docs/TESTING.md); it includes optional
|
||||
two-mount convergence checks and confines destructive operations to a fresh
|
||||
test directory.
|
||||
|
||||
## Scripting (`--json`)
|
||||
|
||||
Query commands accept a global `--json` flag and emit machine-readable output:
|
||||
|
||||
```console
|
||||
$ pdfs --json sync list | jq '.items[] | select(.state != "idle") | .local_path'
|
||||
$ pdfs --json cache inspect | jq '.db_reclaimable_bytes'
|
||||
$ pdfs --json status | jq -r '.mount.mountpoint'
|
||||
```bash
|
||||
sudo apt install ./proton-drive-linux_*.deb # Debian, Ubuntu
|
||||
sudo dnf install ./proton-drive-linux-*.rpm # Fedora
|
||||
paru -S proton-drive-for-linux # Arch
|
||||
```
|
||||
|
||||
Supported on `status`, `ls`, `pins`, `sync list`, `devices list`, `transfers`,
|
||||
`activity`, and `cache inspect`. Commands that perform an action keep their
|
||||
human output — a script that needs to know whether one worked has the exit code.
|
||||
Then open **Proton Drive** from your applications menu and sign in. The app starts the background
|
||||
service, and your files appear in `~/ProtonDrive`.
|
||||
|
||||
Two things worth relying on:
|
||||
Signing in from a terminal instead? `pdfs login` stores the session but does not start the
|
||||
service. Enable it once:
|
||||
|
||||
- **The payload is unwrapped.** Output is `{"items": […]}`, not the daemon's
|
||||
internal `{"SyncFolders": {"items": […]}}`, so scripts never name an internal
|
||||
variant.
|
||||
- **Errors still fail.** A daemon-side error prints its JSON body (with a
|
||||
machine-readable `kind`) on stdout *and* exits non-zero, so `set -e` and
|
||||
`if pdfs …` behave as expected rather than treating a failed lookup as
|
||||
success.
|
||||
```bash
|
||||
pdfs login
|
||||
systemctl --user enable --now proton-drive.service
|
||||
pdfs status
|
||||
```
|
||||
|
||||
## Performance & Caching
|
||||
[docs/INSTALL.md](docs/INSTALL.md) has the requirements, optional extras (tray on GNOME, RAW and
|
||||
video thumbnails), headless use and uninstalling.
|
||||
|
||||
The client includes several optimizations designed for high efficiency, a low memory footprint, and a responsive user experience:
|
||||
## Documentation
|
||||
|
||||
- **On-Demand Block Cache**: Files are read in fixed 4 MiB blocks. For unpinned files, the client fetches and caches only the blocks containing the requested byte ranges. This enables fast sequential and sparse reads (e.g. media streaming or metadata scanning) without downloading entire files.
|
||||
- **Disk-Backed Writes**: Large file writes are staged on disk in temporary scratch files (rather than fully buffered in RAM) and track modified byte intervals. Only the unedited remote segments are fetched at commit time, keeping memory usage minimal.
|
||||
- **Non-Blocking GTK4 Loop**: To prevent UI freezes, all synchronous D-Bus credential checks, control socket requests, and cache usage calculations are offloaded to background worker threads or fetched asynchronously.
|
||||
- **Flicker-Free UI Rendering**: The GTK4 application performs differential rendering of the pins list, only modifying list rows when the list content changes, preserving the user's scroll position and widget focus.
|
||||
- **Durable Staging**: Scratch metadata and staged writes are synced and atomically published before an upload is acknowledged locally. Rapid revisions supersede pending work transactionally.
|
||||
|
||||
## Filesystem Safety
|
||||
|
||||
Version 1.0 strengthens the boundaries around local-only data and destructive reconciliation:
|
||||
|
||||
- Truncate composes with queued revisions, preserving the authoritative prefix and correct zero-filled growth.
|
||||
- Combined move-and-rename operations are queued durably and tolerate partially completed remote state.
|
||||
- Session tombstones prevent successfully unlinked entries from reappearing through eventually consistent listings.
|
||||
- Names are valid UTF-8 and no longer than Linux's 255-byte component limit.
|
||||
- Incomplete mirror scans are non-destructive, and total-wipe protection covers every non-empty baseline.
|
||||
- Failed conflict preservation, local deletion, or staging publication does not settle a new baseline or discard the only local copy.
|
||||
- State, cache, and config directories must be real, current-user-owned `0700` directories; control sockets are `0600`.
|
||||
| | |
|
||||
|---|---|
|
||||
| [User guide](docs/USER_GUIDE.md) | Everything the app, tray and launcher do |
|
||||
| [Command-line reference](docs/CLI.md) | Every `pdfs` command and scripting with `--json` |
|
||||
| [Configuration](docs/CONFIGURATION.md) | `config.json`, file locations, environment variables, the service |
|
||||
| [Troubleshooting](docs/TROUBLESHOOTING.md) | Diagnosing problems and fixing common ones |
|
||||
| [Recovering on a new computer](docs/RECOVERY.md) | Getting your synced folders back after a reinstall or a lost machine |
|
||||
| [Changelog](docs/CHANGELOG.md) | What changed in each release |
|
||||
|
||||
For contributors: [development guide](docs/DEVELOPMENT.md), [architecture](docs/ARCHITECTURE.md),
|
||||
[testing](docs/TESTING.md), [translating](docs/TRANSLATING.md), [roadmap](docs/ROADMAP.md) and the
|
||||
[bug ledger](docs/BUGS.md). The [documentation index](docs/README.md) lists everything.
|
||||
|
||||
## Screenshots
|
||||
|
||||
### GUI Application & Launcher
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="50%"><img src="images/login.png" alt="Login Screen" width="100%"><br><sub><b>Login Screen</b></sub></td>
|
||||
<td align="center" width="50%"><img src="images/files.png" alt="Files Browser" width="100%"><br><sub><b>Files Browser</b></sub></td>
|
||||
<td align="center" width="50%"><img src="images/files.png" alt="My files" width="100%"><br><sub><b>My files</b></sub></td>
|
||||
<td align="center" width="50%"><img src="images/photos.png" alt="Photos timeline" width="100%"><br><sub><b>Photos</b></sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="50%"><img src="images/shared.png" alt="Shared Links" width="100%"><br><sub><b>Shared Links</b></sub></td>
|
||||
<td align="center" width="50%"><img src="images/shared_with_me.png" alt="Shared with me" width="100%"><br><sub><b>Shared with me</b></sub></td>
|
||||
<td align="center" width="50%"><img src="images/photos_map.png" alt="Photos places map" width="100%"><br><sub><b>Places</b></sub></td>
|
||||
<td align="center" width="50%"><img src="images/computers.png" alt="Computers" width="100%"><br><sub><b>Computers</b></sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="50%"><img src="images/computers.png" alt="Local Backups (Computers)" width="100%"><br><sub><b>Local Backups (Computers)</b></sub></td>
|
||||
<td align="center" width="50%"><img src="images/photos.png" alt="Photos Timeline" width="100%"><br><sub><b>Photos Timeline</b></sub></td>
|
||||
<td align="center" width="50%"><img src="images/sync.png" alt="Sync folders" width="100%"><br><sub><b>Sync</b></sub></td>
|
||||
<td align="center" width="50%"><img src="images/conflicts.png" alt="Sync history showing resolved conflicts" width="100%"><br><sub><b>Sync history</b></sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="50%"><img src="images/prompt.png" alt="Search Launcher Prompt" width="100%"><br><sub><b>Search Launcher Prompt</b></sub></td>
|
||||
<td align="center" width="50%"><img src="images/settings.png" alt="Settings" width="100%"><br><sub><b>Settings</b></sub></td>
|
||||
<td align="center" width="50%"><img src="images/prompt_gtk.png" alt="Search launcher" width="100%"><br><sub><b>Search launcher</b></sub></td>
|
||||
<td align="center" width="50%"><img src="images/prompt_fzf.png" alt="Search launcher in fzf" width="100%"><br><sub><b>Search launcher in fzf</b></sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="50%"><img src="images/login.png" alt="Sign in" width="100%"><br><sub><b>Sign in</b></sub></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
## How it works
|
||||
|
||||
## Prerequisites
|
||||
A background service (`proton-drive.service`, the `pdfs daemon` command) holds your session,
|
||||
mounts `~/ProtonDrive` through FUSE, runs the upload queue and the sync engine, and follows
|
||||
Proton's event stream for changes made elsewhere. The app, the tray, the launcher and the CLI are
|
||||
clients of that service over a local socket. Everything is end-to-end encrypted with your Proton
|
||||
keys before it leaves the machine, through a Rust implementation of Proton's Drive SDK.
|
||||
|
||||
To compile the application from source or run the built binaries, ensure you have the following system libraries installed on your distribution:
|
||||
Files you open are decrypted into a local cache so programs can read them. What that means for
|
||||
the security of the machine is spelled out in the
|
||||
[threat model](docs/ARCHITECTURE.md#10-threat-model-what-this-client-writes-to-disk-in-plaintext).
|
||||
|
||||
### Ubuntu / Debian (24.04+)
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
pkg-config \
|
||||
libfuse3-dev \
|
||||
libgtk-4-dev \
|
||||
libadwaita-1-dev \
|
||||
libwebkitgtk-6.0-dev \
|
||||
libsecret-1-dev \
|
||||
libdbus-1-dev
|
||||
```
|
||||
## Contributing
|
||||
|
||||
### Arch Linux
|
||||
```bash
|
||||
sudo pacman -S --needed pkgconf fuse3 gtk4 libadwaita libsecret dbus webkitgtk-6.0
|
||||
```
|
||||
Bug reports, translations and patches are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for the
|
||||
workflow and the checks CI runs, and [SECURITY.md](SECURITY.md) to report a vulnerability
|
||||
privately.
|
||||
|
||||
### Fedora (44+)
|
||||
```bash
|
||||
sudo dnf install -y \
|
||||
pkgconf-pkg-config fuse3-devel gtk4-devel libadwaita-devel \
|
||||
webkitgtk6.0-devel libsecret-devel dbus-devel glib2-devel cargo rust
|
||||
```
|
||||
## Support
|
||||
|
||||
Runtime extras (pick your desktop):
|
||||
```bash
|
||||
# GNOME — keyring + tray (AppIndicator)
|
||||
sudo dnf install -y gnome-keyring gnome-shell-extension-appindicator xdg-utils
|
||||
Proton Drive Linux is free and built in spare time. If it saves you a few headaches, you can
|
||||
[buy me a coffee](https://buymeacoffee.com/narl).
|
||||
|
||||
# KDE Plasma — KWallet (Secret Service); tray works via built-in SNI
|
||||
sudo dnf install -y kwallet xdg-utils
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Building from Source
|
||||
|
||||
Ensure you have Rust and Cargo installed (minimum supported Rust version is 1.96).
|
||||
|
||||
1. Clone the repository and navigate into the project directory:
|
||||
```bash
|
||||
git clone https://github.com/narl/proton-drive-linux.git
|
||||
cd proton-drive-linux
|
||||
```
|
||||
2. Build the workspace in release mode:
|
||||
```bash
|
||||
cargo build --release --locked
|
||||
```
|
||||
|
||||
The compiled binaries will be available under `target/release/`:
|
||||
- `pdfs`: The CLI utility.
|
||||
- `pdfs-app`: The GTK4 application.
|
||||
- `pdfs-tray`: The tray status notifier.
|
||||
- `pdfs-prompt`: The launcher prompt for quick HUD search.
|
||||
|
||||
---
|
||||
|
||||
## Installation & Packages
|
||||
|
||||
### 1. Debian / Ubuntu (.deb)
|
||||
Install the debian package via `dpkg` or `apt`:
|
||||
```bash
|
||||
sudo apt install ./proton-drive-linux_*.deb
|
||||
```
|
||||
|
||||
### 2. Arch Linux
|
||||
A local `PKGBUILD` is available under the `packaging/` directory. You can build and install it using:
|
||||
```bash
|
||||
cd packaging && makepkg -fi
|
||||
```
|
||||
|
||||
### 3. Fedora (local RPM)
|
||||
A local `.spec` is available under `packaging/`. From the repository root:
|
||||
```bash
|
||||
sudo dnf install -y rpm-build
|
||||
rpmbuild -bb packaging/proton-drive-linux.spec \
|
||||
--define "git_dir $PWD" \
|
||||
--define "_rpmdir $PWD/packaging/out" \
|
||||
--define "_builddir $PWD/packaging/build" \
|
||||
--define "_sourcedir $PWD" \
|
||||
--define "_specdir $PWD/packaging" \
|
||||
--define "_srcrpmdir $PWD/packaging/out"
|
||||
sudo dnf install packaging/out/x86_64/proton-drive-linux-*.rpm
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Automated Releases (CI/CD)
|
||||
|
||||
This project has a GitHub Actions CI workflow configured under `.github/workflows/release.yml`.
|
||||
|
||||
### How it works:
|
||||
1. **Triggers**:
|
||||
- Pushing a git tag matching `v*` (e.g. `git tag v0.1.0 && git push origin v0.1.0`).
|
||||
- Manual runs via the **Actions** tab in GitHub (**workflow_dispatch**).
|
||||
2. **Quality Gates and Build Process**:
|
||||
- For tagged builds, verifies that the tag, workspace version, and `packaging/PKGBUILD` version agree.
|
||||
- Runs `cargo fmt`, Clippy with warnings denied, the locked workspace test suite, and the account-free FUSE acceptance contract.
|
||||
- Spawns an Ubuntu runner and installs GTK4, Libadwaita, FUSE3, and Secret Service packages.
|
||||
- Sets up the Rust compiler and caches build targets to speed up runs.
|
||||
- Compiles the workspace members in release mode.
|
||||
3. **Artifact Packaging**:
|
||||
- Generates a `.tar.gz` containing the raw binaries (`pdfs`, `pdfs-app`, `pdfs-tray`, `pdfs-prompt`).
|
||||
- Packs them into Debian (`.deb`) and Fedora (`.rpm`) packages.
|
||||
- Includes the systemd user service and tray autostart entry in the Debian package.
|
||||
4. **Publishing**:
|
||||
- Creates a GitHub Release matching the pushed tag and uploads the `.deb`, `.rpm`, and `.tar.gz` packages as release assets.
|
||||
- For manual runs, compiles and exposes the packages as workflow run artifacts for testing.
|
||||
|
||||
---
|
||||
<a href="https://buymeacoffee.com/narl"><img src="https://img.shields.io/badge/Buy%20me%20a%20coffee-FFDD00?logo=buymeacoffee&logoColor=black" alt="Buy me a coffee"></a>
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the [MIT License](LICENSE).
|
||||
Released under the [MIT License](LICENSE).
|
||||
|
||||
The Places map uses [GeoNames](https://www.geonames.org/) data (CC BY 4.0) and
|
||||
[Natural Earth](https://www.naturalearthdata.com/) outlines (public domain). The optional street
|
||||
map comes from [OpenFreeMap](https://openfreemap.org/), © [OpenStreetMap](https://www.openstreetmap.org/copyright)
|
||||
contributors.
|
||||
|
||||
Proton Drive is a service of Proton AG. This project is an independent client and carries no
|
||||
affiliation with or endorsement from Proton AG.
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported versions
|
||||
|
||||
Fixes land on the latest release. Please reproduce on the most recent version before reporting.
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
**Do not open a public issue.** Report privately through GitHub's
|
||||
[private vulnerability reporting](https://github.com/narrrl/proton-drive-linux/security/advisories/new),
|
||||
or by email to <contact@narl.io>.
|
||||
|
||||
Please include the version (`pdfs --version`), your distribution and desktop environment, what an
|
||||
attacker gains, and a reproduction if you have one. You can expect an acknowledgement within a few
|
||||
days; a fix ships in the next release, and you are credited in the advisory unless you ask
|
||||
otherwise.
|
||||
|
||||
## Scope
|
||||
|
||||
This client holds Proton account credentials and decrypted file content on the local machine, so
|
||||
the interesting boundaries are:
|
||||
|
||||
- **Credentials.** Sessions and the mailbox password live in the system Secret Service. Anything
|
||||
that writes them elsewhere — logs, temp files, crash dumps — is a vulnerability.
|
||||
- **The control socket.** Anything that can connect to `control.sock` has the daemon's
|
||||
authenticated session. It is `0600`, and the state, cache, and config directories must be real,
|
||||
current-user-owned `0700` directories; the daemon fails closed otherwise. Reports that defeat
|
||||
those checks are in scope.
|
||||
- **Local cache and staging.** Decrypted block content and staged writes on disk.
|
||||
- **Cryptography.** Key derivation, signature handling, and anything that could publish plaintext
|
||||
or the wrong key material to Proton.
|
||||
|
||||
What the client deliberately keeps in plaintext on disk, and why, is documented in the
|
||||
[threat model](docs/ARCHITECTURE.md#10-threat-model-what-this-client-writes-to-disk-in-plaintext).
|
||||
Behaviour described there is known and not a vulnerability by itself; a way to make it worse
|
||||
than described is.
|
||||
|
||||
Out of scope: vulnerabilities in Proton's own service (report those to Proton AG), and issues that
|
||||
require an attacker who already has your user account on the machine.
|
||||
@@ -5,6 +5,8 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
description = "CLI for the Proton Drive Linux client"
|
||||
|
||||
[[bin]]
|
||||
@@ -21,3 +23,4 @@ tracing-subscriber.workspace = true
|
||||
clap.workspace = true
|
||||
rpassword.workspace = true
|
||||
serde_json.workspace = true
|
||||
zeroize.workspace = true
|
||||
+1833
-107
File diff suppressed because it is too large.
Load diff
@@ -5,6 +5,8 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
description = "Core auth/session/config for the Proton Drive Linux client"
|
||||
|
||||
[dependencies]
|
||||
@@ -18,7 +20,13 @@ tracing.workspace = true
|
||||
directories.workspace = true
|
||||
ignore.workspace = true
|
||||
sha2.workspace = true
|
||||
sha1.workspace = true
|
||||
zip.workspace = true
|
||||
rusqlite.workspace = true
|
||||
keyring.workspace = true
|
||||
keyring-core.workspace = true
|
||||
dbus-secret-service-keyring-store.workspace = true
|
||||
parking_lot.workspace = true
|
||||
async-trait.workspace = true
|
||||
libc.workspace = true
|
||||
zeroize.workspace = true
|
||||
base64.workspace = true
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,250 @@
|
||||
AD Andorra
|
||||
AE United Arab Emirates
|
||||
AF Afghanistan
|
||||
AG Antigua and Barbuda
|
||||
AI Anguilla
|
||||
AL Albania
|
||||
AM Armenia
|
||||
AO Angola
|
||||
AQ Antarctica
|
||||
AR Argentina
|
||||
AS American Samoa
|
||||
AT Austria
|
||||
AU Australia
|
||||
AW Aruba
|
||||
AX Åland Islands
|
||||
AZ Azerbaijan
|
||||
BA Bosnia and Herzegovina
|
||||
BB Barbados
|
||||
BD Bangladesh
|
||||
BE Belgium
|
||||
BF Burkina Faso
|
||||
BG Bulgaria
|
||||
BH Bahrain
|
||||
BI Burundi
|
||||
BJ Benin
|
||||
BL Saint Barthélemy
|
||||
BM Bermuda
|
||||
BN Brunei Darussalam
|
||||
BO Bolivia, Plurinational State of
|
||||
BQ Bonaire, Sint Eustatius and Saba
|
||||
BR Brazil
|
||||
BS Bahamas
|
||||
BT Bhutan
|
||||
BV Bouvet Island
|
||||
BW Botswana
|
||||
BY Belarus
|
||||
BZ Belize
|
||||
CA Canada
|
||||
CC Cocos (Keeling) Islands
|
||||
CD Congo, The Democratic Republic of the
|
||||
CF Central African Republic
|
||||
CG Congo
|
||||
CH Switzerland
|
||||
CI Côte d'Ivoire
|
||||
CK Cook Islands
|
||||
CL Chile
|
||||
CM Cameroon
|
||||
CN China
|
||||
CO Colombia
|
||||
CR Costa Rica
|
||||
CU Cuba
|
||||
CV Cabo Verde
|
||||
CW Curaçao
|
||||
CX Christmas Island
|
||||
CY Cyprus
|
||||
CZ Czechia
|
||||
DE Germany
|
||||
DJ Djibouti
|
||||
DK Denmark
|
||||
DM Dominica
|
||||
DO Dominican Republic
|
||||
DZ Algeria
|
||||
EC Ecuador
|
||||
EE Estonia
|
||||
EG Egypt
|
||||
EH Western Sahara
|
||||
ER Eritrea
|
||||
ES Spain
|
||||
ET Ethiopia
|
||||
FI Finland
|
||||
FJ Fiji
|
||||
FK Falkland Islands (Malvinas)
|
||||
FM Micronesia, Federated States of
|
||||
FO Faroe Islands
|
||||
FR France
|
||||
GA Gabon
|
||||
GB United Kingdom
|
||||
GD Grenada
|
||||
GE Georgia
|
||||
GF French Guiana
|
||||
GG Guernsey
|
||||
GH Ghana
|
||||
GI Gibraltar
|
||||
GL Greenland
|
||||
GM Gambia
|
||||
GN Guinea
|
||||
GP Guadeloupe
|
||||
GQ Equatorial Guinea
|
||||
GR Greece
|
||||
GS South Georgia and the South Sandwich Islands
|
||||
GT Guatemala
|
||||
GU Guam
|
||||
GW Guinea-Bissau
|
||||
GY Guyana
|
||||
HK Hong Kong
|
||||
HM Heard Island and McDonald Islands
|
||||
HN Honduras
|
||||
HR Croatia
|
||||
HT Haiti
|
||||
HU Hungary
|
||||
ID Indonesia
|
||||
IE Ireland
|
||||
IL Israel
|
||||
IM Isle of Man
|
||||
IN India
|
||||
IO British Indian Ocean Territory
|
||||
IQ Iraq
|
||||
IR Iran, Islamic Republic of
|
||||
IS Iceland
|
||||
IT Italy
|
||||
JE Jersey
|
||||
JM Jamaica
|
||||
JO Jordan
|
||||
JP Japan
|
||||
KE Kenya
|
||||
KG Kyrgyzstan
|
||||
KH Cambodia
|
||||
KI Kiribati
|
||||
KM Comoros
|
||||
KN Saint Kitts and Nevis
|
||||
KP Korea, Democratic People's Republic of
|
||||
KR Korea, Republic of
|
||||
KW Kuwait
|
||||
KY Cayman Islands
|
||||
KZ Kazakhstan
|
||||
LA Lao People's Democratic Republic
|
||||
LB Lebanon
|
||||
LC Saint Lucia
|
||||
LI Liechtenstein
|
||||
LK Sri Lanka
|
||||
LR Liberia
|
||||
LS Lesotho
|
||||
LT Lithuania
|
||||
LU Luxembourg
|
||||
LV Latvia
|
||||
LY Libya
|
||||
MA Morocco
|
||||
MC Monaco
|
||||
MD Moldova, Republic of
|
||||
ME Montenegro
|
||||
MF Saint Martin (French part)
|
||||
MG Madagascar
|
||||
MH Marshall Islands
|
||||
MK North Macedonia
|
||||
ML Mali
|
||||
MM Myanmar
|
||||
MN Mongolia
|
||||
MO Macao
|
||||
MP Northern Mariana Islands
|
||||
MQ Martinique
|
||||
MR Mauritania
|
||||
MS Montserrat
|
||||
MT Malta
|
||||
MU Mauritius
|
||||
MV Maldives
|
||||
MW Malawi
|
||||
MX Mexico
|
||||
MY Malaysia
|
||||
MZ Mozambique
|
||||
NA Namibia
|
||||
NC New Caledonia
|
||||
NE Niger
|
||||
NF Norfolk Island
|
||||
NG Nigeria
|
||||
NI Nicaragua
|
||||
NL Netherlands
|
||||
NO Norway
|
||||
NP Nepal
|
||||
NR Nauru
|
||||
NU Niue
|
||||
NZ New Zealand
|
||||
OM Oman
|
||||
PA Panama
|
||||
PE Peru
|
||||
PF French Polynesia
|
||||
PG Papua New Guinea
|
||||
PH Philippines
|
||||
PK Pakistan
|
||||
PL Poland
|
||||
PM Saint Pierre and Miquelon
|
||||
PN Pitcairn
|
||||
PR Puerto Rico
|
||||
PS Palestine, State of
|
||||
PT Portugal
|
||||
PW Palau
|
||||
PY Paraguay
|
||||
QA Qatar
|
||||
RE Réunion
|
||||
RO Romania
|
||||
RS Serbia
|
||||
RU Russian Federation
|
||||
RW Rwanda
|
||||
SA Saudi Arabia
|
||||
SB Solomon Islands
|
||||
SC Seychelles
|
||||
SD Sudan
|
||||
SE Sweden
|
||||
SG Singapore
|
||||
SH Saint Helena, Ascension and Tristan da Cunha
|
||||
SI Slovenia
|
||||
SJ Svalbard and Jan Mayen
|
||||
SK Slovakia
|
||||
SL Sierra Leone
|
||||
SM San Marino
|
||||
SN Senegal
|
||||
SO Somalia
|
||||
SR Suriname
|
||||
SS South Sudan
|
||||
ST Sao Tome and Principe
|
||||
SV El Salvador
|
||||
SX Sint Maarten (Dutch part)
|
||||
SY Syrian Arab Republic
|
||||
SZ Eswatini
|
||||
TC Turks and Caicos Islands
|
||||
TD Chad
|
||||
TF French Southern Territories
|
||||
TG Togo
|
||||
TH Thailand
|
||||
TJ Tajikistan
|
||||
TK Tokelau
|
||||
TL Timor-Leste
|
||||
TM Turkmenistan
|
||||
TN Tunisia
|
||||
TO Tonga
|
||||
TR Türkiye
|
||||
TT Trinidad and Tobago
|
||||
TV Tuvalu
|
||||
TW Taiwan, Province of China
|
||||
TZ Tanzania, United Republic of
|
||||
UA Ukraine
|
||||
UG Uganda
|
||||
UM United States Minor Outlying Islands
|
||||
US United States
|
||||
UY Uruguay
|
||||
UZ Uzbekistan
|
||||
VA Holy See (Vatican City State)
|
||||
VC Saint Vincent and the Grenadines
|
||||
VE Venezuela, Bolivarian Republic of
|
||||
VG Virgin Islands, British
|
||||
VI Virgin Islands, U.S.
|
||||
VN Viet Nam
|
||||
VU Vanuatu
|
||||
WF Wallis and Futuna
|
||||
WS Samoa
|
||||
XK Kosovo
|
||||
YE Yemen
|
||||
YT Mayotte
|
||||
ZA South Africa
|
||||
ZM Zambia
|
||||
ZW Zimbabwe
|
||||
|
@@ -0,0 +1,108 @@
|
||||
//! Effective access carried by mounted Drive nodes.
|
||||
|
||||
use proton_drive_rs::MemberRole;
|
||||
|
||||
/// Effective write access for a node in a mounted tree.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Access {
|
||||
Owner,
|
||||
Editor,
|
||||
Viewer,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl Access {
|
||||
pub fn writable(self) -> bool {
|
||||
matches!(self, Self::Owner | Self::Editor)
|
||||
}
|
||||
|
||||
pub(crate) fn as_db_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Owner => "owner",
|
||||
Self::Editor => "editor",
|
||||
Self::Viewer => "viewer",
|
||||
Self::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn from_db_str(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
"owner" => Some(Self::Owner),
|
||||
"editor" => Some(Self::Editor),
|
||||
"viewer" => Some(Self::Viewer),
|
||||
"unknown" => Some(Self::Unknown),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a share root's effective access from its exact membership role.
|
||||
///
|
||||
/// Owned trees fail open. A share whose role is absent or unrecognised fails
|
||||
/// closed. Descendants normally inherit directly at intern time; `Inherited`
|
||||
/// exists for the explicit wire role and therefore returns `parent`.
|
||||
pub fn access_for(role: Option<MemberRole>, parent: Access, under_share: bool) -> Access {
|
||||
match role {
|
||||
Some(MemberRole::Viewer) => Access::Viewer,
|
||||
Some(MemberRole::Editor | MemberRole::Admin) => Access::Editor,
|
||||
Some(MemberRole::Inherited) => parent,
|
||||
None if under_share => Access::Viewer,
|
||||
None => Access::Owner,
|
||||
}
|
||||
}
|
||||
|
||||
/// POSIX permission bits advertised for a mounted node.
|
||||
pub fn perm_bits(is_dir: bool, access: Access) -> u16 {
|
||||
match (is_dir, access.writable()) {
|
||||
(true, true) => 0o755,
|
||||
(false, true) => 0o644,
|
||||
(true, false) => 0o555,
|
||||
(false, false) => 0o444,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn access_for_covers_the_fail_open_and_fail_closed_table() {
|
||||
for parent in [
|
||||
Access::Owner,
|
||||
Access::Editor,
|
||||
Access::Viewer,
|
||||
Access::Unknown,
|
||||
] {
|
||||
assert_eq!(access_for(None, parent, false), Access::Owner);
|
||||
assert_eq!(access_for(None, parent, true), Access::Viewer);
|
||||
assert_eq!(
|
||||
access_for(Some(MemberRole::Viewer), parent, true),
|
||||
Access::Viewer
|
||||
);
|
||||
assert_eq!(
|
||||
access_for(Some(MemberRole::Editor), parent, true),
|
||||
Access::Editor
|
||||
);
|
||||
assert_eq!(
|
||||
access_for(Some(MemberRole::Admin), parent, true),
|
||||
Access::Editor
|
||||
);
|
||||
assert_eq!(
|
||||
access_for(Some(MemberRole::Inherited), parent, true),
|
||||
parent
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_bits_follow_effective_access() {
|
||||
for writable in [Access::Owner, Access::Editor] {
|
||||
assert_eq!(perm_bits(true, writable), 0o755);
|
||||
assert_eq!(perm_bits(false, writable), 0o644);
|
||||
}
|
||||
for read_only in [Access::Viewer, Access::Unknown] {
|
||||
assert_eq!(perm_bits(true, read_only), 0o555);
|
||||
assert_eq!(perm_bits(false, read_only), 0o444);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1070
-110
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,84 @@
|
||||
//! Per-node outcomes of the SDK's batch node operations.
|
||||
//!
|
||||
//! `trash_nodes`, `restore_nodes`, `delete_nodes` and `move_nodes` report one
|
||||
//! outcome per node instead of failing the whole call: a node the server
|
||||
//! rejects leaves the rest of the batch applied. The daemon's callers fall into
|
||||
//! two shapes, and both are here so neither has to re-derive the fold:
|
||||
//!
|
||||
//! - one node in, one answer out — [`into_unit`], which collapses the single
|
||||
//! outcome back to a `Result`;
|
||||
//! - a user-supplied list — [`split`], which keeps the successes and hands back
|
||||
//! the failures to report.
|
||||
|
||||
use proton_sdk::error::ProtonError;
|
||||
use proton_sdk::ids::NodeUid;
|
||||
|
||||
/// One node's outcome, as the SDK batch operations report it.
|
||||
pub type Outcome = (NodeUid, Result<(), ProtonError>);
|
||||
|
||||
/// Collapse a batch result to the first per-node failure, for the callers that
|
||||
/// pass exactly one node and want the old all-or-nothing answer.
|
||||
///
|
||||
/// An empty outcome list is `Ok`: the server accepted the request and reported
|
||||
/// on nothing, which is not an error the caller can act on.
|
||||
pub fn into_unit(outcomes: Vec<Outcome>) -> Result<(), ProtonError> {
|
||||
for (_, outcome) in outcomes {
|
||||
outcome?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Split a batch result into the nodes that succeeded and the ones that failed.
|
||||
pub fn split(outcomes: Vec<Outcome>) -> (Vec<NodeUid>, Vec<(NodeUid, ProtonError)>) {
|
||||
let mut done = Vec::with_capacity(outcomes.len());
|
||||
let mut failed = Vec::new();
|
||||
for (uid, outcome) in outcomes {
|
||||
match outcome {
|
||||
Ok(()) => done.push(uid),
|
||||
Err(e) => failed.push((uid, e)),
|
||||
}
|
||||
}
|
||||
(done, failed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use proton_sdk::ids::{LinkId, VolumeId};
|
||||
|
||||
fn uid(link: &str) -> NodeUid {
|
||||
NodeUid::new(VolumeId::new("vol"), LinkId::new(link))
|
||||
}
|
||||
|
||||
fn failure() -> ProtonError {
|
||||
ProtonError::invalid_operation("nope")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn into_unit_is_ok_when_every_node_succeeded() {
|
||||
assert!(into_unit(vec![(uid("a"), Ok(())), (uid("b"), Ok(()))]).is_ok());
|
||||
assert!(
|
||||
into_unit(Vec::new()).is_ok(),
|
||||
"nothing reported is not a failure"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn into_unit_surfaces_a_failed_node() {
|
||||
let outcomes = vec![(uid("a"), Ok(())), (uid("b"), Err(failure()))];
|
||||
assert!(into_unit(outcomes).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_keeps_successes_and_failures_apart() {
|
||||
let outcomes = vec![
|
||||
(uid("a"), Ok(())),
|
||||
(uid("b"), Err(failure())),
|
||||
(uid("c"), Ok(())),
|
||||
];
|
||||
let (done, failed) = split(outcomes);
|
||||
assert_eq!(done, vec![uid("a"), uid("c")]);
|
||||
assert_eq!(failed.len(), 1);
|
||||
assert_eq!(failed[0].0, uid("b"));
|
||||
}
|
||||
}
|
||||
+1683
-142
File diff suppressed because it is too large.
Load diff
+270
-23
@@ -3,15 +3,28 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use directories::ProjectDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::menu::PromptConfig;
|
||||
use crate::opener::OpenWith;
|
||||
use crate::syncignore::DEFAULT_IGNORE_PATTERNS;
|
||||
|
||||
/// Proton requires `external-drive-{name}@{semver}-{channel}` (channel ∈
|
||||
/// stable/beta/alpha); a malformed value trips the 422 anti-abuse path.
|
||||
pub const APP_VERSION: &str = "external-drive-linux@0.1.0-alpha";
|
||||
pub const USER_AGENT: &str = "proton-drive-linux/0.1.0";
|
||||
/// Follows the release so Proton can tell releases apart and retire old ones
|
||||
/// without retiring every one.
|
||||
pub const APP_VERSION: &str = concat!(
|
||||
"external-drive-linux@",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
"-stable"
|
||||
);
|
||||
pub const USER_AGENT: &str = concat!("proton-drive-linux/", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
/// The fixed identity every release up to 3.1.0 wrote into `config.json`. Read
|
||||
/// back as "unset" so those configs follow the release instead of pinning it.
|
||||
const LEGACY_APP_VERSION: &str = "external-drive-linux@0.1.0-alpha";
|
||||
const LEGACY_USER_AGENT: &str = "proton-drive-linux/0.1.0";
|
||||
|
||||
/// Keyring service name; one entry per credential kind keyed by username.
|
||||
pub const KEYRING_SERVICE: &str = "proton-drive-linux";
|
||||
@@ -74,15 +87,38 @@ fn resolve_sweep_mode(env: Option<&str>, stored: Option<SweepMode>) -> SweepMode
|
||||
}
|
||||
|
||||
/// Configuration structure allowing the user to customize client identification headers.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
|
||||
pub struct AppConfig {
|
||||
pub app_version: String,
|
||||
pub user_agent: String,
|
||||
/// Override for [`APP_VERSION`]. `None` means "use the release's own";
|
||||
/// the old fixed default reads as `None`, so it leaves the file on the
|
||||
/// next save.
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "identity_override",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub app_version: Option<String>,
|
||||
/// Override for [`USER_AGENT`], handled like `app_version`.
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "identity_override",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub user_agent: Option<String>,
|
||||
/// Soft cap on the on-disk content cache, in bytes (`0` = unlimited).
|
||||
/// `None` means "use [`DEFAULT_CACHE_BUDGET_BYTES`]"; the Settings page
|
||||
/// writes an explicit value here. Defaulted for configs predating the field.
|
||||
#[serde(default)]
|
||||
pub cache_budget: Option<u64>,
|
||||
/// Upload cap in bytes per second shared by every upload. `None` or `0`
|
||||
/// means no cap. Defaulted for configs predating the field.
|
||||
#[serde(default)]
|
||||
pub upload_limit: Option<u64>,
|
||||
/// Download cap in bytes per second shared by every download, FUSE reads
|
||||
/// included. `None` or `0` means no cap. Defaulted for configs predating
|
||||
/// the field.
|
||||
#[serde(default)]
|
||||
pub download_limit: Option<u64>,
|
||||
/// Mountpoint the daemon mounts at. `None` means
|
||||
/// [`AppDirs::default_mountpoint`]; the Settings page writes an explicit
|
||||
/// path here. Defaulted for configs predating the field.
|
||||
@@ -106,29 +142,145 @@ pub struct AppConfig {
|
||||
/// the field, and overridable at runtime via [`CONFLICT_SWEEP_ENV`].
|
||||
#[serde(default)]
|
||||
pub conflict_sweep: Option<SweepMode>,
|
||||
/// How files opened from the prompt/browser are launched. `None` means
|
||||
/// "hand everything to `xdg-open`", which is what the client did before
|
||||
/// this existed. See [`crate::opener`]. Defaulted for configs predating the
|
||||
/// field.
|
||||
#[serde(default)]
|
||||
pub open_with: Option<OpenWith>,
|
||||
/// `pdfs-prompt` behaviour, including the external launcher used by
|
||||
/// `--dmenu`. See [`crate::menu`]. Defaulted for configs predating the
|
||||
/// field.
|
||||
#[serde(default)]
|
||||
pub prompt: Option<PromptConfig>,
|
||||
/// Paint the desktop app in Proton's colours instead of the system theme
|
||||
/// and accent colour. `None` means "follow the system". Defaulted for
|
||||
/// configs predating the field; also read from `proton_accent`, its name
|
||||
/// while it only changed the accent.
|
||||
#[serde(default, alias = "proton_accent")]
|
||||
pub proton_theme: Option<bool>,
|
||||
/// How the desktop app's My Files page lays out and orders a folder.
|
||||
/// Defaulted for configs predating the field.
|
||||
#[serde(default)]
|
||||
pub files_view: FilesView,
|
||||
/// The user hid the tray icon from its own menu. The tray exits at start
|
||||
/// while this is set, and the app does not spawn it; Preferences turns it
|
||||
/// back on. Defaulted for configs predating the field.
|
||||
#[serde(default)]
|
||||
pub tray_hidden: bool,
|
||||
/// Language for the desktop app, tray and search prompt, as a gettext code
|
||||
/// such as `de` or `pt_BR`. `None` means "follow the system locale".
|
||||
/// Defaulted for configs predating the field.
|
||||
#[serde(default)]
|
||||
pub language: Option<String>,
|
||||
/// Draw the Places map from OpenFreeMap's street map instead of the
|
||||
/// built-in outline. Off by default: the tiles come from a third party,
|
||||
/// which then learns which parts of the world are looked at. Defaulted for
|
||||
/// configs predating the field.
|
||||
#[serde(default)]
|
||||
pub online_map: bool,
|
||||
/// Record a new folder, a new file, a rename or a delete through the mount
|
||||
/// locally and send it from the queue, instead of waiting for Drive in the
|
||||
/// syscall. `None` means on. `false` brings back the old path for one
|
||||
/// release (docs/ROADMAP.md). Defaulted for configs predating
|
||||
/// the field.
|
||||
#[serde(default)]
|
||||
pub local_first: Option<bool>,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
/// The My Files page's view choices, remembered between runs.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[serde(default)]
|
||||
pub struct FilesView {
|
||||
/// Columns instead of the icon grid.
|
||||
pub list: bool,
|
||||
pub sort: FileSort,
|
||||
pub descending: bool,
|
||||
/// Folders ahead of files, whatever the sort.
|
||||
pub folders_first: bool,
|
||||
}
|
||||
|
||||
impl Default for FilesView {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
app_version: APP_VERSION.to_string(),
|
||||
user_agent: USER_AGENT.to_string(),
|
||||
cache_budget: None,
|
||||
mountpoint: None,
|
||||
ignore_patterns: None,
|
||||
device_uid: None,
|
||||
conflict_sweep: None,
|
||||
list: false,
|
||||
sort: FileSort::Name,
|
||||
descending: false,
|
||||
folders_first: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The key a My Files listing is ordered by.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum FileSort {
|
||||
#[default]
|
||||
Name,
|
||||
Size,
|
||||
Modified,
|
||||
}
|
||||
|
||||
impl FileSort {
|
||||
/// The name used in the desktop app's actions and the config file.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
FileSort::Name => "name",
|
||||
FileSort::Size => "size",
|
||||
FileSort::Modified => "modified",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
"name" => Some(FileSort::Name),
|
||||
"size" => Some(FileSort::Size),
|
||||
"modified" => Some(FileSort::Modified),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads an `app_version` or `user_agent` override, dropping the fixed
|
||||
/// identity older releases stored as the default.
|
||||
fn identity_override<'de, D: Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
) -> std::result::Result<Option<String>, D::Error> {
|
||||
let value = Option::<String>::deserialize(deserializer)?;
|
||||
Ok(value.filter(|v| v != LEGACY_APP_VERSION && v != LEGACY_USER_AGENT))
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
/// The `x-pm-appversion` sent to Proton: the user's override, or
|
||||
/// [`APP_VERSION`].
|
||||
pub fn resolved_app_version(&self) -> &str {
|
||||
self.app_version.as_deref().unwrap_or(APP_VERSION)
|
||||
}
|
||||
|
||||
/// The `User-Agent` sent to Proton: the user's override, or
|
||||
/// [`USER_AGENT`].
|
||||
pub fn resolved_user_agent(&self) -> &str {
|
||||
self.user_agent.as_deref().unwrap_or(USER_AGENT)
|
||||
}
|
||||
|
||||
/// The effective cache budget in bytes: the user's explicit choice, or
|
||||
/// [`DEFAULT_CACHE_BUDGET_BYTES`] when unset.
|
||||
pub fn resolved_cache_budget(&self) -> u64 {
|
||||
self.cache_budget.unwrap_or(DEFAULT_CACHE_BUDGET_BYTES)
|
||||
}
|
||||
|
||||
/// The effective opener policy: the user's rules, or the all-`xdg-open`
|
||||
/// default. Cloned rather than borrowed because callers hold it for the
|
||||
/// life of a window, long after the config value is gone.
|
||||
pub fn resolved_open_with(&self) -> OpenWith {
|
||||
self.open_with.clone().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The effective prompt settings, defaulted when the section is absent.
|
||||
pub fn resolved_prompt(&self) -> PromptConfig {
|
||||
self.prompt.clone().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The effective conflict-sweep mode: the [`CONFLICT_SWEEP_ENV`] override if
|
||||
/// it parses, then the user's stored choice, then [`SweepMode::default`].
|
||||
///
|
||||
@@ -302,7 +454,7 @@ impl AppDirs {
|
||||
/// typical umask makes them `0755`. Until this was fixed, the only thing
|
||||
/// keeping another local user out was `~/.cache` and `~/.local/state`
|
||||
/// happening to be `0700`, which is a convention of the user's system and
|
||||
/// not something this client established (bugs.md B6).
|
||||
/// not something this client established (`docs/BUGS.md` B6).
|
||||
///
|
||||
/// Applied on every start, not just at creation: a directory that already
|
||||
/// exists with a permissive mode — restored from a backup that flattened
|
||||
@@ -353,7 +505,7 @@ fn restrict_dir(dir: &Path) -> Result<()> {
|
||||
/// session — enumerate, read, upload, trash, share — without presenting any
|
||||
/// credential, since the daemon already holds one. It is an authority boundary,
|
||||
/// not merely private data, and `UnixListener::bind` applies the umask like any
|
||||
/// other file (bugs.md B6).
|
||||
/// other file (`docs/BUGS.md` B6).
|
||||
///
|
||||
/// There is a window between `bind` and this call during which the socket
|
||||
/// carries the umask's mode. It is closed in practice by the containing
|
||||
@@ -374,7 +526,7 @@ mod tests {
|
||||
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,
|
||||
/// and the control socket that commands the daemon's session.
|
||||
#[test]
|
||||
@@ -441,25 +593,115 @@ mod tests {
|
||||
#[test]
|
||||
fn test_app_config_serialization() {
|
||||
let config = AppConfig {
|
||||
app_version: "external-drive-test-client@1.0.0".to_string(),
|
||||
user_agent: "test-agent/1.0".to_string(),
|
||||
app_version: Some("external-drive-test-client@1.0.0".to_string()),
|
||||
user_agent: Some("test-agent/1.0".to_string()),
|
||||
cache_budget: Some(1234),
|
||||
upload_limit: Some(500_000),
|
||||
download_limit: None,
|
||||
mountpoint: Some("/tmp/x".to_string()),
|
||||
ignore_patterns: Some(vec!["build/".to_string()]),
|
||||
device_uid: Some("dev-uid".to_string()),
|
||||
conflict_sweep: Some(SweepMode::Enforce),
|
||||
open_with: None,
|
||||
prompt: None,
|
||||
proton_theme: Some(true),
|
||||
files_view: FilesView {
|
||||
list: true,
|
||||
sort: FileSort::Modified,
|
||||
descending: true,
|
||||
folders_first: false,
|
||||
},
|
||||
tray_hidden: true,
|
||||
language: Some("de".to_string()),
|
||||
online_map: true,
|
||||
local_first: Some(false),
|
||||
};
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(decoded.app_version, "external-drive-test-client@1.0.0");
|
||||
assert_eq!(decoded.user_agent, "test-agent/1.0");
|
||||
assert_eq!(
|
||||
decoded.resolved_app_version(),
|
||||
"external-drive-test-client@1.0.0"
|
||||
);
|
||||
assert_eq!(decoded.resolved_user_agent(), "test-agent/1.0");
|
||||
assert_eq!(decoded.proton_theme, Some(true));
|
||||
assert!(decoded.tray_hidden);
|
||||
assert!(decoded.online_map);
|
||||
assert_eq!(decoded.language.as_deref(), Some("de"));
|
||||
assert_eq!(decoded.upload_limit, Some(500_000));
|
||||
assert_eq!(decoded.download_limit, None);
|
||||
assert_eq!(decoded.files_view, config.files_view);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_config_without_a_files_view_gets_the_default() {
|
||||
let decoded: AppConfig =
|
||||
serde_json::from_str(r#"{"app_version":"a","user_agent":"b"}"#).unwrap();
|
||||
assert_eq!(decoded.files_view, FilesView::default());
|
||||
let partial: FilesView = serde_json::from_str(r#"{"sort":"size"}"#).unwrap();
|
||||
assert_eq!(partial.sort, FileSort::Size);
|
||||
assert!(partial.folders_first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_old_proton_accent_setting_turns_on_the_proton_theme() {
|
||||
let decoded: AppConfig =
|
||||
serde_json::from_str(r#"{"app_version":"a","user_agent":"b","proton_accent":true}"#)
|
||||
.unwrap();
|
||||
assert_eq!(decoded.proton_theme, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_app_config() {
|
||||
let default_config = AppConfig::default();
|
||||
assert_eq!(default_config.app_version, APP_VERSION);
|
||||
assert_eq!(default_config.user_agent, USER_AGENT);
|
||||
assert_eq!(default_config.resolved_app_version(), APP_VERSION);
|
||||
assert_eq!(default_config.resolved_user_agent(), USER_AGENT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_client_identity_follows_the_release() {
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
assert_eq!(
|
||||
APP_VERSION,
|
||||
format!("external-drive-linux@{version}-stable")
|
||||
);
|
||||
assert_eq!(USER_AGENT, format!("proton-drive-linux/{version}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_old_config_with_the_fixed_identity_follows_the_release() {
|
||||
// Every config written up to 3.1.0 holds these two values, so they must
|
||||
// not pin the identity once Proton retires 0.1.0.
|
||||
let json = format!(
|
||||
r#"{{"app_version":"{LEGACY_APP_VERSION}","user_agent":"{LEGACY_USER_AGENT}","tray_hidden":true}}"#
|
||||
);
|
||||
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(decoded.resolved_app_version(), APP_VERSION);
|
||||
assert_eq!(decoded.resolved_user_agent(), USER_AGENT);
|
||||
assert!(decoded.tray_hidden, "the rest of the config survives");
|
||||
|
||||
let saved = serde_json::to_string(&decoded).unwrap();
|
||||
assert!(!saved.contains("app_version"), "{saved}");
|
||||
assert!(!saved.contains("user_agent"), "{saved}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hand_set_client_identity_is_kept() {
|
||||
let json = r#"{"app_version":"external-drive-linux@9.9.9-beta","user_agent":"custom/1"}"#;
|
||||
let decoded: AppConfig = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(
|
||||
decoded.resolved_app_version(),
|
||||
"external-drive-linux@9.9.9-beta"
|
||||
);
|
||||
assert_eq!(decoded.resolved_user_agent(), "custom/1");
|
||||
let saved = serde_json::to_string(&decoded).unwrap();
|
||||
assert!(saved.contains("external-drive-linux@9.9.9-beta"), "{saved}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_config_without_a_client_identity_loads() {
|
||||
let decoded: AppConfig = serde_json::from_str("{}").unwrap();
|
||||
assert_eq!(decoded.resolved_app_version(), APP_VERSION);
|
||||
assert_eq!(decoded.resolved_user_agent(), USER_AGENT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -525,6 +767,11 @@ mod tests {
|
||||
let json = r#"{"app_version":"v","user_agent":"u"}"#;
|
||||
let decoded: AppConfig = serde_json::from_str(json).unwrap();
|
||||
assert!(decoded.ignore_patterns.is_none());
|
||||
assert!(!decoded.tray_hidden, "an old config keeps the tray icon");
|
||||
assert!(
|
||||
decoded.language.is_none(),
|
||||
"an old config follows the system"
|
||||
);
|
||||
assert_eq!(
|
||||
decoded.resolved_ignore_patterns().len(),
|
||||
DEFAULT_IGNORE_PATTERNS.len(),
|
||||
|
||||
+2075
-21
File diff suppressed because it is too large.
Load diff
@@ -29,11 +29,22 @@ impl Db {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether the kept log already holds an entry of `kind` for `target`. Lets
|
||||
/// the daemon report something once across restarts rather than once per
|
||||
/// run, without keeping a second table in step with the log.
|
||||
pub fn activity_has(&self, kind: ActivityKind, target: &str) -> Result<bool> {
|
||||
let kind = serde_json::to_string(&kind)?;
|
||||
let conn = self.read();
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT 1 FROM activity WHERE kind = ?1 AND target = ?2 LIMIT 1")?;
|
||||
Ok(stmt.exists(params![kind, target])?)
|
||||
}
|
||||
|
||||
/// The most recent activity, newest first, capped at `limit` entries. Rows
|
||||
/// whose stored `kind` no longer parses (written by an older build) are
|
||||
/// skipped rather than failing the whole read.
|
||||
pub fn activity_list(&self, limit: usize) -> Result<Vec<ActivityEntry>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT time, kind, target, detail, ok FROM activity
|
||||
ORDER BY id DESC LIMIT ?1",
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
//! Photo albums: the album listing and each album's contents, persisted like
|
||||
//! the timeline so the Albums view opens from disk instead of re-enumerating the
|
||||
//! photos volume on every launch.
|
||||
//!
|
||||
//! An album's photos are stored per album rather than as a join against
|
||||
//! `photos`: an album shared with us lives on the sharer's volume, so its photos
|
||||
//! never appear in our own timeline and have nowhere else to record what a
|
||||
//! thumbnail attempt learned about them.
|
||||
|
||||
use rusqlite::params;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::{Db, StoredPhoto, THUMB_UNKNOWN};
|
||||
use crate::Result;
|
||||
|
||||
/// One album of the persisted listing, ordered newest-activity-first in `seq`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct StoredAlbum {
|
||||
pub uid: String,
|
||||
pub name: String,
|
||||
pub photo_count: usize,
|
||||
/// The photo shown as the album's cover, when the server named one.
|
||||
pub cover_uid: Option<String>,
|
||||
/// Epoch seconds of the last change to the album's contents.
|
||||
pub last_activity: Option<i64>,
|
||||
/// True when the album lives on someone else's photos volume.
|
||||
pub shared: bool,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
/// Replace the album listing wholesale, in the given order.
|
||||
pub fn albums_replace(&self, albums: &[StoredAlbum]) -> Result<()> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
// Contents of an album that no longer exists (unshared, deleted) go with
|
||||
// it — otherwise they would linger forever, keyed by an album uid no
|
||||
// listing mentions again.
|
||||
{
|
||||
let kept: std::collections::HashSet<&str> =
|
||||
albums.iter().map(|a| a.uid.as_str()).collect();
|
||||
let stale: Vec<String> = {
|
||||
let mut stmt = tx.prepare("SELECT DISTINCT album_uid FROM album_photos")?;
|
||||
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
|
||||
rows.filter_map(|r| r.ok())
|
||||
.filter(|uid| !kept.contains(uid.as_str()))
|
||||
.collect()
|
||||
};
|
||||
let mut stmt = tx.prepare("DELETE FROM album_photos WHERE album_uid = ?1")?;
|
||||
for uid in stale {
|
||||
stmt.execute([uid])?;
|
||||
}
|
||||
}
|
||||
tx.execute("DELETE FROM albums", [])?;
|
||||
{
|
||||
let mut stmt = tx.prepare(
|
||||
"INSERT INTO albums
|
||||
(uid, name, photo_count, cover_uid, last_activity, shared, seq)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
)?;
|
||||
for (seq, album) in albums.iter().enumerate() {
|
||||
stmt.execute(params![
|
||||
album.uid,
|
||||
album.name,
|
||||
album.photo_count as i64,
|
||||
album.cover_uid,
|
||||
album.last_activity,
|
||||
album.shared as i64,
|
||||
seq as i64,
|
||||
])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The persisted album listing, in stored order.
|
||||
pub fn albums_list(&self) -> Result<Vec<StoredAlbum>> {
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT uid, name, photo_count, cover_uid, last_activity, shared
|
||||
FROM albums ORDER BY seq",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map([], |r| {
|
||||
Ok(StoredAlbum {
|
||||
uid: r.get(0)?,
|
||||
name: r.get(1)?,
|
||||
photo_count: r.get::<_, i64>(2)?.max(0) as usize,
|
||||
cover_uid: r.get(3)?,
|
||||
last_activity: r.get(4)?,
|
||||
shared: r.get::<_, i64>(5)? != 0,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Number of albums in the persisted listing.
|
||||
pub fn albums_count(&self) -> Result<usize> {
|
||||
let conn = self.read();
|
||||
let n: i64 = conn.query_row("SELECT COUNT(*) FROM albums", [], |r| r.get(0))?;
|
||||
Ok(n.max(0) as usize)
|
||||
}
|
||||
|
||||
/// Replace one album's contents, in the given (newest-capture-first) order.
|
||||
/// Learned aspect ratios and thumbnail verdicts survive the replacement, the
|
||||
/// same way [`Db::photos_replace`] keeps them for the timeline.
|
||||
pub fn album_photos_replace(
|
||||
&self,
|
||||
album_uid: &str,
|
||||
items: &[(String, i64, Option<String>, Option<String>)],
|
||||
) -> Result<()> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
let learned: HashMap<String, (Option<f64>, i64, Option<String>)> = {
|
||||
let mut stmt = tx.prepare(
|
||||
"SELECT uid, ratio, thumb_state, media_type FROM album_photos
|
||||
WHERE album_uid = ?1",
|
||||
)?;
|
||||
let rows = stmt.query_map([album_uid], |r| {
|
||||
Ok((r.get::<_, String>(0)?, (r.get(1)?, r.get(2)?, r.get(3)?)))
|
||||
})?;
|
||||
rows.collect::<rusqlite::Result<_>>()?
|
||||
};
|
||||
|
||||
tx.execute("DELETE FROM album_photos WHERE album_uid = ?1", [album_uid])?;
|
||||
{
|
||||
let mut stmt = tx.prepare(
|
||||
"INSERT INTO album_photos
|
||||
(album_uid, uid, capture_time, name, media_type, kind, ratio, thumb_state, seq)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
)?;
|
||||
for (seq, (uid, capture_time, name, media_type)) in items.iter().enumerate() {
|
||||
let (ratio, thumb_state, learned_media) =
|
||||
learned
|
||||
.get(uid)
|
||||
.cloned()
|
||||
.unwrap_or((None, THUMB_UNKNOWN, None));
|
||||
let media_type = media_type.clone().or(learned_media);
|
||||
let kind =
|
||||
crate::control::PhotoKind::classify(name.as_deref(), media_type.as_deref());
|
||||
stmt.execute(params![
|
||||
album_uid,
|
||||
uid,
|
||||
capture_time,
|
||||
name,
|
||||
media_type,
|
||||
kind.as_i64(),
|
||||
ratio,
|
||||
thumb_state,
|
||||
seq as i64,
|
||||
])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One page of an album's photos, in stored order.
|
||||
pub fn album_photos_page(
|
||||
&self,
|
||||
album_uid: &str,
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
) -> Result<Vec<StoredPhoto>> {
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT uid, capture_time, name, ratio, thumb_state, kind FROM album_photos
|
||||
WHERE album_uid = ?1 ORDER BY seq LIMIT ?2 OFFSET ?3",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map(params![album_uid, limit as i64, offset as i64], |r| {
|
||||
Ok(StoredPhoto {
|
||||
uid: r.get(0)?,
|
||||
capture_time: r.get(1)?,
|
||||
name: r.get(2)?,
|
||||
ratio: r.get(3)?,
|
||||
thumb_state: r.get(4)?,
|
||||
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
||||
// Album rows have no favorite state of their own: an album
|
||||
// photo on someone else's volume is not in our timeline, and
|
||||
// favouriting one is not supported yet anyway.
|
||||
favorite: false,
|
||||
// Nor are album rows grouped: an album is a list the user
|
||||
// made, and it shows exactly what they put in it.
|
||||
group_size: 1,
|
||||
has_raw: false,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Which albums hold `uid`, by album node uid. Read by the photo re-date,
|
||||
/// which has to put the re-uploaded copy back into the albums the original
|
||||
/// was in.
|
||||
pub fn albums_of_photo(&self, uid: &str) -> Result<Vec<String>> {
|
||||
let conn = self.read();
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT DISTINCT album_uid FROM album_photos WHERE uid = ?1")?;
|
||||
let rows = stmt
|
||||
.query_map([uid], |r| r.get(0))?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// How many photos of `album_uid` are persisted.
|
||||
pub fn album_photos_count(&self, album_uid: &str) -> Result<usize> {
|
||||
let conn = self.read();
|
||||
let n: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM album_photos WHERE album_uid = ?1",
|
||||
[album_uid],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
Ok(n.max(0) as usize)
|
||||
}
|
||||
|
||||
/// The album-held photos among `uids`, one row per uid (a photo in two albums
|
||||
/// is returned once). Backs the thumbnail path for photos that are in an
|
||||
/// album but not in our own timeline — everything in a shared album, and an
|
||||
/// album cover whose photo the current timeline page hasn't reached.
|
||||
pub fn album_photos_by_uid(&self, uids: &[String]) -> Result<Vec<StoredPhoto>> {
|
||||
if uids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let conn = self.read();
|
||||
let placeholders = vec!["?"; uids.len()].join(",");
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT uid, capture_time, name, ratio, thumb_state, kind FROM album_photos
|
||||
WHERE uid IN ({placeholders}) GROUP BY uid"
|
||||
))?;
|
||||
let rows = stmt
|
||||
.query_map(rusqlite::params_from_iter(uids), |r| {
|
||||
Ok(StoredPhoto {
|
||||
uid: r.get(0)?,
|
||||
capture_time: r.get(1)?,
|
||||
name: r.get(2)?,
|
||||
ratio: r.get(3)?,
|
||||
thumb_state: r.get(4)?,
|
||||
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
||||
// Album rows have no favorite state of their own: an album
|
||||
// photo on someone else's volume is not in our timeline, and
|
||||
// favouriting one is not supported yet anyway.
|
||||
favorite: false,
|
||||
// Nor are album rows grouped: an album is a list the user
|
||||
// made, and it shows exactly what they put in it.
|
||||
group_size: 1,
|
||||
has_raw: false,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Record a thumbnail verdict against every album row for `uid`, so what one
|
||||
/// attempt learned is not re-learned per album the photo sits in. The
|
||||
/// timeline's own copy is updated separately by [`Db::photo_set_thumb`].
|
||||
pub fn album_photo_set_thumb(&self, uid: &str, state: i64, ratio: Option<f64>) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"UPDATE album_photos SET thumb_state = ?2, ratio = COALESCE(?3, ratio) WHERE uid = ?1",
|
||||
params![uid, state, ratio],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,30 @@ impl Db {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bump `last_accessed` for many keys in one transaction.
|
||||
///
|
||||
/// The read path touches a key per 128 KiB the kernel asks for — 32 times
|
||||
/// per 4 MiB block — and each of those was its own write transaction under
|
||||
/// the single connection mutex, contending with every `lookup` and
|
||||
/// `readdir`. Callers accumulate touches in memory and flush them here.
|
||||
pub fn cache_accessed_batch(&self, touches: &[(String, i64)]) -> Result<()> {
|
||||
if touches.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
{
|
||||
let mut stmt = tx.prepare_cached(
|
||||
"UPDATE cache_entries SET last_accessed = ?2 WHERE cache_key = ?1",
|
||||
)?;
|
||||
for (key, at) in touches {
|
||||
stmt.execute(params![key, at])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop a single cache-entry row (one evicted blob or block).
|
||||
pub fn cache_remove(&self, key: &str) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
@@ -75,7 +99,7 @@ impl Db {
|
||||
/// because `enforce_block_budget` runs on every cached block — once per
|
||||
/// 4 MiB of every cold read — while holding the shared connection.
|
||||
pub fn cache_total_bytes(&self, kind: &str) -> Result<u64> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let total: i64 = conn.query_row(
|
||||
"SELECT COALESCE(SUM(size_bytes), 0) FROM cache_entries WHERE kind = ?1",
|
||||
params![kind],
|
||||
@@ -95,7 +119,7 @@ impl Db {
|
||||
kind: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<(String, u64)>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"SELECT cache_key, size_bytes FROM cache_entries
|
||||
WHERE kind = ?1 ORDER BY last_accessed ASC LIMIT ?2",
|
||||
@@ -115,7 +139,7 @@ impl Db {
|
||||
/// (`clear_unpinned`); incremental eviction wants
|
||||
/// [`cache_eviction_candidates`](Self::cache_eviction_candidates).
|
||||
pub fn cache_entries_by_kind(&self, kind: &str) -> Result<Vec<(String, u64)>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT cache_key, size_bytes FROM cache_entries
|
||||
WHERE kind = ?1 ORDER BY last_accessed ASC",
|
||||
|
||||
@@ -15,7 +15,7 @@ pub struct StoredDevice {
|
||||
|
||||
impl Db {
|
||||
pub fn device_get(&self) -> Result<Option<StoredDevice>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
conn.query_row(
|
||||
"SELECT uid, share_id, root_uid, name, created FROM device LIMIT 1",
|
||||
[],
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
/* WARNING: Script requires that SQLITE_DBCONFIG_DEFENSIVE be disabled */
|
||||
PRAGMA foreign_keys=OFF;
|
||||
BEGIN TRANSACTION;
|
||||
CREATE TABLE sync_state (key TEXT PRIMARY KEY, value TEXT);
|
||||
INSERT INTO sync_state VALUES('schema_version','35');
|
||||
CREATE TABLE nodes (
|
||||
uid TEXT PRIMARY KEY,
|
||||
parent_uid TEXT,
|
||||
name TEXT NOT NULL,
|
||||
is_dir INTEGER NOT NULL,
|
||||
size INTEGER,
|
||||
mtime INTEGER NOT NULL,
|
||||
revision_hash TEXT,
|
||||
trashed INTEGER NOT NULL DEFAULT 0
|
||||
, node_json TEXT, listed INTEGER NOT NULL DEFAULT 0, path TEXT);
|
||||
INSERT INTO nodes VALUES('vol~root',NULL,'My Files',1,0,200,NULL,0,'{"uid":{"volume_id":"vol","link_id":"root"},"parent_uid":null,"kind":"Folder","name":"My Files","creation_time":100,"modification_time":200,"trashed":false,"is_shared":false,"is_shared_publicly":false,"signature_email":null,"membership":null,"direct_role":null,"share_id":null,"photo":null,"album":null,"verification":{"name":"NotSigned","passphrase":"NotSigned","content_key":null,"extended_attributes":null}}',0,'');
|
||||
INSERT INTO nodes VALUES('vol~docs','vol~root','Docs',1,0,200,NULL,0,'{"uid":{"volume_id":"vol","link_id":"docs"},"parent_uid":{"volume_id":"vol","link_id":"root"},"kind":"Folder","name":"Docs","creation_time":100,"modification_time":200,"trashed":false,"is_shared":false,"is_shared_publicly":false,"signature_email":null,"membership":null,"direct_role":null,"share_id":null,"photo":null,"album":null,"verification":{"name":"NotSigned","passphrase":"NotSigned","content_key":null,"extended_attributes":null}}',0,'Docs');
|
||||
INSERT INTO nodes VALUES('vol~report','vol~docs','report.txt',0,10,200,NULL,0,'{"uid":{"volume_id":"vol","link_id":"report"},"parent_uid":{"volume_id":"vol","link_id":"docs"},"kind":{"File":{"media_type":"text/plain","total_size_on_storage":20,"active_revision_state":null,"active_revision_id":null,"claimed_size":10,"claimed_modification_time":null,"content_sha1":null}},"name":"report.txt","creation_time":100,"modification_time":200,"trashed":false,"is_shared":false,"is_shared_publicly":false,"signature_email":null,"membership":null,"direct_role":null,"share_id":null,"photo":null,"album":null,"verification":{"name":"NotSigned","passphrase":"NotSigned","content_key":null,"extended_attributes":null}}',0,'Docs/report.txt');
|
||||
INSERT INTO nodes VALUES('vol~old','vol~docs','old.txt',0,3,200,NULL,0,'{"uid":{"volume_id":"vol","link_id":"old"},"parent_uid":{"volume_id":"vol","link_id":"docs"},"kind":{"File":{"media_type":"text/plain","total_size_on_storage":13,"active_revision_state":null,"active_revision_id":null,"claimed_size":3,"claimed_modification_time":null,"content_sha1":null}},"name":"old.txt","creation_time":100,"modification_time":200,"trashed":false,"is_shared":false,"is_shared_publicly":false,"signature_email":null,"membership":null,"direct_role":null,"share_id":null,"photo":null,"album":null,"verification":{"name":"NotSigned","passphrase":"NotSigned","content_key":null,"extended_attributes":null}}',0,'Docs/old.txt');
|
||||
INSERT INTO nodes VALUES('vol~shared','vol~docs','shared.txt',0,4,200,NULL,0,'{"uid":{"volume_id":"vol","link_id":"shared"},"parent_uid":{"volume_id":"vol","link_id":"docs"},"kind":{"File":{"media_type":"text/plain","total_size_on_storage":14,"active_revision_state":null,"active_revision_id":null,"claimed_size":4,"claimed_modification_time":null,"content_sha1":null}},"name":"shared.txt","creation_time":100,"modification_time":200,"trashed":false,"is_shared":false,"is_shared_publicly":false,"signature_email":null,"membership":null,"direct_role":null,"share_id":null,"photo":null,"album":null,"verification":{"name":"NotSigned","passphrase":"NotSigned","content_key":null,"extended_attributes":null}}',0,'Docs/shared.txt');
|
||||
INSERT INTO nodes VALUES('local~dir','vol~root','Offline folder',1,0,200,NULL,0,'{"uid":{"volume_id":"local","link_id":"dir"},"parent_uid":{"volume_id":"vol","link_id":"root"},"kind":"Folder","name":"Offline folder","creation_time":100,"modification_time":200,"trashed":false,"is_shared":false,"is_shared_publicly":false,"signature_email":null,"membership":null,"direct_role":null,"share_id":null,"photo":null,"album":null,"verification":{"name":"NotSigned","passphrase":"NotSigned","content_key":null,"extended_attributes":null}}',1,'Offline folder');
|
||||
INSERT INTO nodes VALUES('local~inner','local~dir','inner.txt',0,5,200,NULL,0,'{"uid":{"volume_id":"local","link_id":"inner"},"parent_uid":{"volume_id":"local","link_id":"dir"},"kind":{"File":{"media_type":"text/plain","total_size_on_storage":15,"active_revision_state":null,"active_revision_id":null,"claimed_size":5,"claimed_modification_time":null,"content_sha1":null}},"name":"inner.txt","creation_time":100,"modification_time":200,"trashed":false,"is_shared":false,"is_shared_publicly":false,"signature_email":null,"membership":null,"direct_role":null,"share_id":null,"photo":null,"album":null,"verification":{"name":"NotSigned","passphrase":"NotSigned","content_key":null,"extended_attributes":null}}',0,'Offline folder/inner.txt');
|
||||
INSERT INTO nodes VALUES('local~part','vol~root','movie.part',0,7,200,NULL,0,'{"uid":{"volume_id":"local","link_id":"part"},"parent_uid":{"volume_id":"vol","link_id":"root"},"kind":{"File":{"media_type":"text/plain","total_size_on_storage":17,"active_revision_state":null,"active_revision_id":null,"claimed_size":7,"claimed_modification_time":null,"content_sha1":null}},"name":"movie.part","creation_time":100,"modification_time":200,"trashed":false,"is_shared":false,"is_shared_publicly":false,"signature_email":null,"membership":null,"direct_role":null,"share_id":null,"photo":null,"album":null,"verification":{"name":"NotSigned","passphrase":"NotSigned","content_key":null,"extended_attributes":null}}',0,'movie.part');
|
||||
CREATE TABLE cache_entries (
|
||||
cache_key TEXT PRIMARY KEY,
|
||||
size_bytes INTEGER,
|
||||
last_accessed INTEGER,
|
||||
is_pinned INTEGER NOT NULL DEFAULT 0
|
||||
, kind TEXT NOT NULL DEFAULT 'blob');
|
||||
CREATE TABLE pins (
|
||||
uid TEXT PRIMARY KEY,
|
||||
path TEXT NOT NULL,
|
||||
recursive INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE local_files (
|
||||
id INTEGER PRIMARY KEY,
|
||||
path TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
is_dir INTEGER NOT NULL,
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
mtime INTEGER NOT NULL DEFAULT 0,
|
||||
scan_gen INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE photos (
|
||||
uid TEXT PRIMARY KEY,
|
||||
capture_time INTEGER NOT NULL,
|
||||
name TEXT,
|
||||
ratio REAL,
|
||||
thumb_state INTEGER NOT NULL DEFAULT 0,
|
||||
seq INTEGER NOT NULL
|
||||
, media_type TEXT, kind INTEGER NOT NULL DEFAULT 0, favorite INTEGER NOT NULL DEFAULT 0, content_hash TEXT, main_uid TEXT, group_key TEXT, resolved_at INTEGER, latitude REAL, longitude REAL, place_id INTEGER, similar_hash INTEGER);
|
||||
CREATE TABLE trash (
|
||||
uid TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
is_dir INTEGER NOT NULL,
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
mtime INTEGER NOT NULL DEFAULT 0
|
||||
, parent_uid TEXT, trashed_at INTEGER);
|
||||
CREATE TABLE device (
|
||||
uid TEXT PRIMARY KEY,
|
||||
share_id TEXT NOT NULL,
|
||||
root_uid TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
created INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE sync_folder (
|
||||
id INTEGER PRIMARY KEY,
|
||||
local_path TEXT NOT NULL UNIQUE,
|
||||
remote_uid TEXT NOT NULL,
|
||||
remote_share_id TEXT NOT NULL,
|
||||
mode TEXT NOT NULL DEFAULT 'mirror',
|
||||
state TEXT NOT NULL DEFAULT 'idle',
|
||||
last_sync INTEGER NOT NULL DEFAULT 0
|
||||
, pending_mode TEXT);
|
||||
CREATE TABLE sync_entry (
|
||||
folder_id INTEGER NOT NULL,
|
||||
rel_path TEXT NOT NULL,
|
||||
remote_uid TEXT,
|
||||
local_mtime INTEGER NOT NULL DEFAULT 0,
|
||||
local_size INTEGER NOT NULL DEFAULT 0,
|
||||
remote_hash TEXT,
|
||||
remote_rev TEXT, local_mtime_ns INTEGER,
|
||||
PRIMARY KEY (folder_id, rel_path)
|
||||
);
|
||||
CREATE TABLE activity (
|
||||
id INTEGER PRIMARY KEY,
|
||||
time INTEGER NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
detail TEXT NOT NULL DEFAULT '',
|
||||
ok INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
CREATE TABLE pending_op (
|
||||
id INTEGER PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
uid TEXT NOT NULL,
|
||||
blob_path TEXT,
|
||||
meta_json TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
next_attempt_at INTEGER NOT NULL DEFAULT 0
|
||||
, parent_uid TEXT, name TEXT, claimed_at INTEGER NOT NULL DEFAULT 0, access_deferred_since INTEGER NOT NULL DEFAULT 0);
|
||||
INSERT INTO pending_op VALUES(1,'mkdir','local~dir',NULL,NULL,1700000000000,0,NULL,0,'vol~root','Offline folder',4611686018427387903,0);
|
||||
INSERT INTO pending_op VALUES(2,'create','local~inner','/blobs/inner','{"len":5,"mtime":1700000000,"based_on":null}',1700000000000,0,NULL,0,'local~dir','inner.txt',0,0);
|
||||
INSERT INTO pending_op VALUES(3,'create','local~part','/blobs/part','{"len":5,"mtime":1700000000,"based_on":null}',1700000000000,0,NULL,8000000000000,'vol~root','movie.part',0,0);
|
||||
INSERT INTO pending_op VALUES(4,'revision','vol~report','/blobs/report','{"len":5,"mtime":1700000000,"based_on":null}',1700000000000,2,'network unreachable',1700000900000,NULL,NULL,0,0);
|
||||
INSERT INTO pending_op VALUES(5,'rename','vol~shared',NULL,'{"original_parent_uid":"vol~docs","original_name":"shared.txt"}',1700000000000,0,NULL,1700000700000,'vol~root','shared-moved.txt',0,1700000100000);
|
||||
INSERT INTO pending_op VALUES(6,'trash','vol~old',NULL,NULL,1700000000000,0,NULL,0,NULL,NULL,0,0);
|
||||
CREATE TABLE IF NOT EXISTS 'local_fts_data'(id INTEGER PRIMARY KEY, block BLOB);
|
||||
INSERT INTO local_fts_data VALUES(1,x'000000');
|
||||
INSERT INTO local_fts_data VALUES(10,x'00000000000000');
|
||||
CREATE TABLE IF NOT EXISTS 'local_fts_idx'(segid, term, pgno, PRIMARY KEY(segid, term)) WITHOUT ROWID;
|
||||
CREATE TABLE IF NOT EXISTS 'local_fts_docsize'(id INTEGER PRIMARY KEY, sz BLOB);
|
||||
CREATE TABLE IF NOT EXISTS 'local_fts_config'(k PRIMARY KEY, v) WITHOUT ROWID;
|
||||
INSERT INTO local_fts_config VALUES('version',4);
|
||||
CREATE TABLE share_access (
|
||||
root_uid TEXT PRIMARY KEY,
|
||||
access TEXT NOT NULL
|
||||
CHECK (access IN ('owner', 'editor', 'viewer', 'unknown'))
|
||||
);
|
||||
CREATE TABLE mount (
|
||||
id INTEGER PRIMARY KEY,
|
||||
kind TEXT NOT NULL
|
||||
CHECK (kind IN ('myfiles', 'device', 'shared')),
|
||||
sync_folder_id INTEGER REFERENCES sync_folder(id) ON DELETE CASCADE,
|
||||
share_root_uid TEXT,
|
||||
local_path TEXT NOT NULL DEFAULT '',
|
||||
root_uid TEXT NOT NULL DEFAULT '',
|
||||
root_share_id TEXT NOT NULL DEFAULT '',
|
||||
mode TEXT NOT NULL DEFAULT 'unknown',
|
||||
access TEXT NOT NULL DEFAULT 'rw'
|
||||
CHECK (access IN ('rw', 'ro')),
|
||||
CHECK (
|
||||
(kind = 'myfiles' AND sync_folder_id IS NULL AND share_root_uid IS NULL)
|
||||
OR
|
||||
(kind = 'device' AND sync_folder_id IS NOT NULL AND share_root_uid IS NULL)
|
||||
OR
|
||||
(kind = 'shared' AND sync_folder_id IS NULL AND share_root_uid IS NOT NULL)
|
||||
)
|
||||
);
|
||||
CREATE TABLE albums (
|
||||
uid TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
photo_count INTEGER NOT NULL DEFAULT 0,
|
||||
cover_uid TEXT,
|
||||
last_activity INTEGER,
|
||||
shared INTEGER NOT NULL DEFAULT 0,
|
||||
seq INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE album_photos (
|
||||
album_uid TEXT NOT NULL,
|
||||
uid TEXT NOT NULL,
|
||||
capture_time INTEGER NOT NULL,
|
||||
name TEXT,
|
||||
media_type TEXT,
|
||||
kind INTEGER NOT NULL DEFAULT 0,
|
||||
ratio REAL,
|
||||
thumb_state INTEGER NOT NULL DEFAULT 0,
|
||||
seq INTEGER NOT NULL,
|
||||
PRIMARY KEY (album_uid, uid)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS 'nodes_fts_data'(id INTEGER PRIMARY KEY, block BLOB);
|
||||
INSERT INTO nodes_fts_data VALUES(1,x'083850');
|
||||
INSERT INTO nodes_fts_data VALUES(10,x'000000000108080008010101020101030101040101050101060101070101080101');
|
||||
INSERT INTO nodes_fts_data VALUES(137438953473,x'000000340430206669010204010366696c0102050103696c6501020601036c657301020701036d79200102020103792066010203040808080808');
|
||||
INSERT INTO nodes_fts_data VALUES(274877906945,x'0000001a0430646f6302080201010201036f6373020803010103040b');
|
||||
INSERT INTO nodes_fts_data VALUES(412316860417,x'0000008b04302e747803080801010d01032f72650306010106010363732f03060101040103646f630306010102010365706f03080301010801036f637303060101030202727403080501010a0103706f7203080401010901037265700308020101070202742e03080601010b0103732f7203060101050103742e7403080701010c0202787403080901010e040b0a0a0a0b0a0a0b0b0a0a0b');
|
||||
INSERT INTO nodes_fts_data VALUES(549755813889,x'0000006b04302e747804080501010a01032f6f6c0406010106010363732f04060101040103642e7404080401010902026f63040601010201036c642e04080301010801036f6373040601010302026c640408020101070103732f6f0406010105010374787404080601010b040b0a0a0b090b0a0a0a');
|
||||
INSERT INTO nodes_fts_data VALUES(687194767361,x'0000008c04302e747805080801010d01032f736805060101060103617265050804010109010363732f05060101040103642e7405080701010c02026f630506010102010365642e05080601010b010368617205080301010801036f63730506010103010372656405080501010a0103732f73050601010502026861050802010107010374787405080901010e040b0a0b0a0b090b0b0a0b0a0a');
|
||||
INSERT INTO nodes_fts_data VALUES(824633720833,x'00000084043020666f060809010109010364657206080d01010d0103652066060808010108010366666c06080301010302026c6906080401010402026f6c06080a01010a0103696e6506080601010601036c646506080c01010c0202696e06080501010501036e652006080701010701036f666606080201010202026c6406080b01010b040b0b0b0b0a0a0b0b0a0b0b');
|
||||
INSERT INTO nodes_fts_data VALUES(962072674305,x'000000da043020666f070601010901032e747807080701011601032f696e07060101100103646572070601010d010365206607060101080202722e07080501011403012f070601010e010366666c070601010302026c69070601010402026f6c070601010a0103696e65070601010603016e07080201011101036c6465070601010c0202696e070601010501036e6520070601010703017207080401011302026e6507080301011201036f6666070601010202026c64070601010b0103722e7407080601011502022f69070601010f0103747874070808010117040a0b0a0a0a0a080a09090a090a090a090a0a090b09');
|
||||
INSERT INTO nodes_fts_data VALUES(1099511627777,x'0000005c04302e706108080701010701036172740808090101090103652e70080806010106010369652e08080501010501036d6f7608080201010201036f766908080301010301037061720808080101080103766965080804010104040b0b0b0b0b0b0b');
|
||||
CREATE TABLE IF NOT EXISTS 'nodes_fts_idx'(segid, term, pgno, PRIMARY KEY(segid, term)) WITHOUT ROWID;
|
||||
INSERT INTO nodes_fts_idx VALUES(1,x'',2);
|
||||
INSERT INTO nodes_fts_idx VALUES(2,x'',2);
|
||||
INSERT INTO nodes_fts_idx VALUES(3,x'',2);
|
||||
INSERT INTO nodes_fts_idx VALUES(4,x'',2);
|
||||
INSERT INTO nodes_fts_idx VALUES(5,x'',2);
|
||||
INSERT INTO nodes_fts_idx VALUES(6,x'',2);
|
||||
INSERT INTO nodes_fts_idx VALUES(7,x'',2);
|
||||
INSERT INTO nodes_fts_idx VALUES(8,x'',2);
|
||||
CREATE TABLE IF NOT EXISTS 'nodes_fts_content'(id INTEGER PRIMARY KEY, c0, c1);
|
||||
INSERT INTO nodes_fts_content VALUES(1,'My Files','');
|
||||
INSERT INTO nodes_fts_content VALUES(2,'Docs','Docs');
|
||||
INSERT INTO nodes_fts_content VALUES(3,'report.txt','Docs/report.txt');
|
||||
INSERT INTO nodes_fts_content VALUES(4,'old.txt','Docs/old.txt');
|
||||
INSERT INTO nodes_fts_content VALUES(5,'shared.txt','Docs/shared.txt');
|
||||
INSERT INTO nodes_fts_content VALUES(6,'Offline folder','Offline folder');
|
||||
INSERT INTO nodes_fts_content VALUES(7,'inner.txt','Offline folder/inner.txt');
|
||||
INSERT INTO nodes_fts_content VALUES(8,'movie.part','movie.part');
|
||||
CREATE TABLE IF NOT EXISTS 'nodes_fts_docsize'(id INTEGER PRIMARY KEY, sz BLOB);
|
||||
INSERT INTO nodes_fts_docsize VALUES(1,x'0600');
|
||||
INSERT INTO nodes_fts_docsize VALUES(2,x'0202');
|
||||
INSERT INTO nodes_fts_docsize VALUES(3,x'080d');
|
||||
INSERT INTO nodes_fts_docsize VALUES(4,x'050a');
|
||||
INSERT INTO nodes_fts_docsize VALUES(5,x'080d');
|
||||
INSERT INTO nodes_fts_docsize VALUES(6,x'0c0c');
|
||||
INSERT INTO nodes_fts_docsize VALUES(7,x'0716');
|
||||
INSERT INTO nodes_fts_docsize VALUES(8,x'0808');
|
||||
CREATE TABLE IF NOT EXISTS 'nodes_fts_config'(k PRIMARY KEY, v) WITHOUT ROWID;
|
||||
INSERT INTO nodes_fts_config VALUES('version',4);
|
||||
CREATE TABLE own_sealed_rev (
|
||||
uid TEXT PRIMARY KEY,
|
||||
revision_id TEXT NOT NULL,
|
||||
sealed_at INTEGER NOT NULL
|
||||
);
|
||||
INSERT INTO own_sealed_rev VALUES('vol~report','rev-1',1700000000000);
|
||||
PRAGMA writable_schema=ON;
|
||||
INSERT INTO sqlite_schema(type,name,tbl_name,rootpage,sql)VALUES('table','local_fts','local_fts',0,'CREATE VIRTUAL TABLE local_fts USING fts5(
|
||||
name, path, content=''local_files'', content_rowid=''id'', tokenize=''trigram''
|
||||
)');
|
||||
INSERT INTO sqlite_schema(type,name,tbl_name,rootpage,sql)VALUES('table','nodes_fts','nodes_fts',0,'CREATE VIRTUAL TABLE nodes_fts USING fts5(
|
||||
name, path, tokenize=''trigram''
|
||||
)');
|
||||
CREATE TRIGGER mount_sync_folder_insert
|
||||
AFTER INSERT ON sync_folder
|
||||
BEGIN
|
||||
INSERT OR IGNORE INTO mount (kind, sync_folder_id)
|
||||
VALUES ('device', NEW.id);
|
||||
END;
|
||||
CREATE INDEX idx_nodes_parent ON nodes(parent_uid);
|
||||
CREATE INDEX idx_photos_seq ON photos(seq);
|
||||
CREATE INDEX pending_op_uid ON pending_op(uid);
|
||||
CREATE INDEX pending_op_parent ON pending_op(parent_uid);
|
||||
CREATE INDEX cache_entries_lru ON cache_entries(kind, last_accessed);
|
||||
CREATE INDEX idx_nodes_name_nocase ON nodes(name COLLATE NOCASE);
|
||||
CREATE INDEX idx_local_files_name_nocase
|
||||
ON local_files(name COLLATE NOCASE);
|
||||
CREATE UNIQUE INDEX mount_myfiles
|
||||
ON mount(kind) WHERE kind = 'myfiles';
|
||||
CREATE UNIQUE INDEX mount_device
|
||||
ON mount(sync_folder_id) WHERE kind = 'device';
|
||||
CREATE UNIQUE INDEX mount_shared
|
||||
ON mount(share_root_uid) WHERE kind = 'shared';
|
||||
CREATE INDEX idx_album_photos_seq ON album_photos(album_uid, seq);
|
||||
CREATE INDEX idx_album_photos_uid ON album_photos(uid);
|
||||
CREATE INDEX idx_photos_favorite ON photos(favorite, seq);
|
||||
CREATE INDEX idx_sync_entry_remote_uid ON sync_entry(remote_uid);
|
||||
CREATE INDEX idx_local_files_scan_gen ON local_files(scan_gen);
|
||||
CREATE INDEX idx_nodes_parent_live ON nodes(parent_uid) WHERE trashed = 0;
|
||||
CREATE INDEX idx_pending_op_claimed
|
||||
ON pending_op(uid) WHERE claimed_at <> 0;
|
||||
CREATE INDEX idx_trash_parent ON trash(parent_uid);
|
||||
CREATE INDEX idx_photos_group ON photos(group_key);
|
||||
CREATE INDEX idx_photos_unresolved ON photos(resolved_at);
|
||||
CREATE INDEX idx_photos_place ON photos(place_id);
|
||||
PRAGMA writable_schema=OFF;
|
||||
COMMIT;
|
||||
@@ -41,32 +41,43 @@ impl Db {
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
/// Write one batch of walked entries under scan generation `generation`. The
|
||||
/// FTS index is *not* touched here — it is rebuilt once in
|
||||
/// [`local_finish_scan`](Self::local_finish_scan).
|
||||
/// Write one batch of walked entries under scan generation `generation`,
|
||||
/// keeping the FTS index in step with them.
|
||||
///
|
||||
/// The index is maintained incrementally rather than rebuilt at the end of
|
||||
/// the scan, because a rebuild reads and re-tokenises every row in the table
|
||||
/// — trigrams over a few hundred thousand paths — inside one transaction on
|
||||
/// the single shared connection, which stalls the whole mount for as long as
|
||||
/// it takes. Incremental maintenance is possible here for a reason specific
|
||||
/// to this table: `local_fts` covers `name` and `path`, `path` is the
|
||||
/// table's key, and `name` is derived from it, so an existing row's indexed
|
||||
/// content can never change. Only insertions and deletions touch the index;
|
||||
/// a re-scan that finds the same files does no index work at all.
|
||||
///
|
||||
/// Hence the update-then-insert shape below rather than an upsert: the
|
||||
/// distinction between "already indexed" and "new row" is exactly what
|
||||
/// decides whether the index needs a write.
|
||||
pub fn local_upsert_batch(&self, generation: i64, entries: &[LocalEntry]) -> Result<()> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
{
|
||||
let mut stmt = tx.prepare_cached(
|
||||
"INSERT INTO local_files (path, name, is_dir, size, mtime, scan_gen)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||
ON CONFLICT(path) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
is_dir = excluded.is_dir,
|
||||
size = excluded.size,
|
||||
mtime = excluded.mtime,
|
||||
scan_gen = excluded.scan_gen",
|
||||
let mut update = tx.prepare_cached(
|
||||
"UPDATE local_files
|
||||
SET name = ?2, is_dir = ?3, size = ?4, mtime = ?5, scan_gen = ?6
|
||||
WHERE path = ?1",
|
||||
)?;
|
||||
let mut insert = tx.prepare_cached(
|
||||
"INSERT INTO local_files (path, name, is_dir, size, mtime, scan_gen)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
)?;
|
||||
let mut index =
|
||||
tx.prepare_cached("INSERT INTO local_fts (rowid, name, path) VALUES (?1, ?2, ?3)")?;
|
||||
for e in entries {
|
||||
stmt.execute(params![
|
||||
e.path,
|
||||
e.name,
|
||||
e.is_dir as i64,
|
||||
e.size,
|
||||
e.mtime,
|
||||
generation
|
||||
])?;
|
||||
let row = params![e.path, e.name, e.is_dir as i64, e.size, e.mtime, generation];
|
||||
if update.execute(row)? == 0 {
|
||||
insert.execute(row)?;
|
||||
index.execute(params![tx.last_insert_rowid(), e.name, e.path])?;
|
||||
}
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
@@ -74,16 +85,25 @@ impl Db {
|
||||
}
|
||||
|
||||
/// Close scan generation `generation`: drop every row an older scan wrote
|
||||
/// (those paths are gone from disk), rebuild the FTS index over what remains,
|
||||
/// and stamp the completion time. Returns the number of indexed entries.
|
||||
/// (those paths are gone from disk), drop their FTS entries, and stamp the
|
||||
/// completion time. Returns the number of indexed entries.
|
||||
///
|
||||
/// The FTS deletes name the old values explicitly, which is how an
|
||||
/// external-content FTS5 table is told to retract a row — see
|
||||
/// [`local_upsert_batch`](Self::local_upsert_batch) for why this is done
|
||||
/// per-row instead of by rebuilding the index.
|
||||
pub fn local_finish_scan(&self, generation: i64, finished_at: i64) -> Result<i64> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(
|
||||
"INSERT INTO local_fts (local_fts, rowid, name, path)
|
||||
SELECT 'delete', id, name, path FROM local_files WHERE scan_gen != ?1",
|
||||
params![generation],
|
||||
)?;
|
||||
tx.execute(
|
||||
"DELETE FROM local_files WHERE scan_gen != ?1",
|
||||
params![generation],
|
||||
)?;
|
||||
tx.execute_batch("INSERT INTO local_fts(local_fts) VALUES('rebuild');")?;
|
||||
tx.execute(
|
||||
"INSERT INTO sync_state (key, value) VALUES ('local_indexed_at', ?1)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
@@ -98,7 +118,7 @@ impl Db {
|
||||
/// has never been built. The daemon uses this to decide whether a fresh mount
|
||||
/// needs an immediate rescan or can serve the existing index.
|
||||
pub fn local_indexed_at(&self) -> Result<Option<i64>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
Ok(conn
|
||||
.query_row(
|
||||
"SELECT value FROM sync_state WHERE key = 'local_indexed_at'",
|
||||
@@ -118,7 +138,7 @@ impl Db {
|
||||
if query.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let mut stmt;
|
||||
let rows = if query.chars().count() < TRIGRAM_MIN {
|
||||
let pat = format!("%{}%", like_escape(query));
|
||||
@@ -157,7 +177,7 @@ impl Db {
|
||||
if query.is_empty() || limit == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let terms = candidate_trigrams(query);
|
||||
let lane_limit = limit.div_ceil(2);
|
||||
let mut rows: Vec<LocalFileHit> = if terms.is_empty() {
|
||||
@@ -182,16 +202,36 @@ impl Db {
|
||||
if !terms.is_empty()
|
||||
&& let Some(first) = query.chars().next()
|
||||
{
|
||||
let prefix = format!("{}%", like_escape(&first.to_string()));
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT path, name, is_dir, size, mtime FROM local_files
|
||||
WHERE name COLLATE NOCASE LIKE ?1 ESCAPE '\\'
|
||||
ORDER BY name COLLATE NOCASE LIMIT ?2",
|
||||
)?;
|
||||
let fallback = stmt
|
||||
.query_map(params![prefix, lane_limit as i64], local_hit)?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
for hit in fallback {
|
||||
// Trigram rank says nothing about how well a name *starts* with the
|
||||
// query, so on a home directory full of dependency trees the pool
|
||||
// fills with vendored files before the obvious answer is reached
|
||||
// (measured: 73% of the 500 best-ranked candidates for "test" came
|
||||
// from one Go module cache). Give whole-query prefix matches their
|
||||
// own lane, newest first, and keep the single-character lane —
|
||||
// which recovers a typo that destroyed every trigram — behind it.
|
||||
let mut lanes = vec![(
|
||||
format!("{}%", like_escape(query)),
|
||||
"ORDER BY mtime DESC LIMIT ?2",
|
||||
)];
|
||||
lanes.push((
|
||||
format!("{}%", like_escape(&first.to_string())),
|
||||
"ORDER BY name COLLATE NOCASE LIMIT ?2",
|
||||
));
|
||||
let mut extra = Vec::new();
|
||||
for (pattern, order) in lanes {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT path, name, is_dir, size, mtime FROM local_files
|
||||
WHERE name COLLATE NOCASE LIKE ?1 ESCAPE '\\' {order}"
|
||||
))?;
|
||||
extra.extend(
|
||||
stmt.query_map(params![pattern, lane_limit as i64], local_hit)?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?,
|
||||
);
|
||||
}
|
||||
// Prefix hits go in front: `rows` is truncated to `limit` below, and
|
||||
// the lane exists precisely because trigram rank was burying them.
|
||||
let trigram = std::mem::take(&mut rows);
|
||||
for hit in extra.into_iter().chain(trigram) {
|
||||
if !rows.iter().any(|existing| existing.path == hit.path) {
|
||||
rows.push(hit);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Database health reporting and repair (features.md 4.3).
|
||||
//! Database health reporting and repair.
|
||||
//!
|
||||
//! Everything here exists for the case where something has already gone wrong:
|
||||
//! a crash mid-write, a full disk, a cache that has grown out of proportion to
|
||||
@@ -77,10 +77,12 @@ const REPORTED_TABLES: &[&str] = &[
|
||||
"trash",
|
||||
"device",
|
||||
"sync_folder",
|
||||
"mount",
|
||||
"sync_entry",
|
||||
"pending_op",
|
||||
"activity",
|
||||
"local_files",
|
||||
"share_access",
|
||||
];
|
||||
|
||||
impl Db {
|
||||
|
||||
@@ -5,11 +5,13 @@
|
||||
//! is already on disk somewhere, so a change here would silently diverge from
|
||||
//! what an existing install actually has. Add a new one instead.
|
||||
|
||||
use rusqlite::OptionalExtension;
|
||||
|
||||
use super::Db;
|
||||
use crate::Result;
|
||||
|
||||
/// Current schema version. Bump on every forward migration added below.
|
||||
pub(super) const SCHEMA_VERSION: i64 = 16;
|
||||
pub(super) const SCHEMA_VERSION: i64 = 42;
|
||||
|
||||
impl Db {
|
||||
pub(super) fn migrate(&self) -> Result<()> {
|
||||
@@ -89,6 +91,355 @@ impl Db {
|
||||
if current < 16 {
|
||||
tx.execute_batch(MIGRATION_V16)?;
|
||||
}
|
||||
if current < 17 {
|
||||
tx.execute_batch(MIGRATION_V17)?;
|
||||
}
|
||||
if current < 18 {
|
||||
tx.execute_batch(MIGRATION_V18)?;
|
||||
}
|
||||
if current < 19 {
|
||||
tx.execute_batch(MIGRATION_V19)?;
|
||||
}
|
||||
if current < 20 {
|
||||
tx.execute_batch(MIGRATION_V20)?;
|
||||
}
|
||||
if current < 21 {
|
||||
// A V17-and-older fixture may carry only the tables its own test
|
||||
// cares about; there is nothing to reindex without `nodes`.
|
||||
let has_nodes: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'nodes'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
if has_nodes {
|
||||
tx.execute_batch(MIGRATION_V21)?;
|
||||
}
|
||||
}
|
||||
if current < 22 {
|
||||
// Older fixtures (and a database that never grew a table because
|
||||
// its feature was never used) may be missing any of these, and an
|
||||
// index on an absent table is a hard error rather than a no-op.
|
||||
for (table, sql) in MIGRATION_V22 {
|
||||
let present: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1",
|
||||
[table],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
if present {
|
||||
tx.execute_batch(sql)?;
|
||||
}
|
||||
}
|
||||
// `activity_time` indexes `time DESC` while both activity queries
|
||||
// order by `id`: it has only ever cost writes.
|
||||
tx.execute_batch("DROP INDEX IF EXISTS activity_time;")?;
|
||||
}
|
||||
if current < 23 {
|
||||
// One last full rebuild of the local index, because from here on the
|
||||
// scan maintains it incrementally (`local_upsert_batch`) and so can
|
||||
// no longer repair a `local_fts` that drifted from `local_files`.
|
||||
// Whatever it costs, it costs once, at startup, before the mount
|
||||
// serves anything.
|
||||
let present: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'local_fts'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
if present {
|
||||
tx.execute_batch("INSERT INTO local_fts(local_fts) VALUES('rebuild');")?;
|
||||
}
|
||||
}
|
||||
if current < 24 {
|
||||
tx.execute_batch(MIGRATION_V24)?;
|
||||
}
|
||||
if current < 25 {
|
||||
// Same guard as V21: an old fixture may not have `nodes` at all,
|
||||
// and there is no path to materialise without it.
|
||||
let has_nodes: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'nodes'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
if has_nodes {
|
||||
tx.execute_batch(MIGRATION_V25)?;
|
||||
}
|
||||
}
|
||||
if current < 26 {
|
||||
// Same guard as V22: a fixture that never exercised the queue may
|
||||
// not have `pending_op`, and altering an absent table is an error
|
||||
// rather than a no-op.
|
||||
// A fixture built from the current schema and then rewound to an
|
||||
// older `schema_version` already has the column, and `ADD COLUMN`
|
||||
// has no `IF NOT EXISTS`.
|
||||
let has_column: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('pending_op') WHERE name = 'claimed_at'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
let has_ops: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'pending_op'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
if has_ops && !has_column {
|
||||
tx.execute_batch(MIGRATION_V26)?;
|
||||
}
|
||||
}
|
||||
if current < 27 {
|
||||
// Same guards as V26, for the same two reasons: the table may not
|
||||
// exist in an old fixture, and a fixture rewound from the current
|
||||
// schema already has the column.
|
||||
let has_column: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('pending_op') \
|
||||
WHERE name = 'access_deferred_since'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
let has_ops: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'pending_op'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
if has_ops && !has_column {
|
||||
tx.execute_batch(MIGRATION_V27)?;
|
||||
}
|
||||
}
|
||||
if current < 28 {
|
||||
// Same guards as V26/V27, for the same two reasons.
|
||||
let has_column: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('sync_entry') \
|
||||
WHERE name = 'local_mtime_ns'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
let has_entries: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'sync_entry'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
if has_entries && !has_column {
|
||||
tx.execute_batch(MIGRATION_V28)?;
|
||||
}
|
||||
}
|
||||
if current < 29 {
|
||||
// Same guards as V26/V27/V28: an old fixture may not have `trash`,
|
||||
// and a fixture rewound from the current schema already has the
|
||||
// column.
|
||||
let has_column: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('trash') WHERE name = 'parent_uid'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
let has_trash: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'trash'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
if has_trash && !has_column {
|
||||
tx.execute_batch(MIGRATION_V29)?;
|
||||
}
|
||||
}
|
||||
if current < 30 {
|
||||
// Same guards as V26-V29: the column may already be there on a
|
||||
// fixture rewound from the current schema, and a very old fixture
|
||||
// may not have `photos` at all.
|
||||
let has_column: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('photos') WHERE name = 'group_key'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
let has_photos: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'photos'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
if has_photos && !has_column {
|
||||
tx.execute_batch(MIGRATION_V30)?;
|
||||
}
|
||||
}
|
||||
if current < 31 {
|
||||
// Same guards as V26-V30.
|
||||
let has_column: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('photos') WHERE name = 'resolved_at'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
let has_photos: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'photos'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
if has_photos && !has_column {
|
||||
tx.execute_batch(MIGRATION_V31)?;
|
||||
}
|
||||
}
|
||||
if current < 32 {
|
||||
// Data only, so safe to repeat; each table is guarded for a very
|
||||
// old fixture that never had it.
|
||||
for table in ["photos", "album_photos"] {
|
||||
let has_table: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1",
|
||||
[table],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
if has_table {
|
||||
tx.execute(&MIGRATION_V32.replace("{table}", table), [])?;
|
||||
}
|
||||
}
|
||||
}
|
||||
if current < 33 {
|
||||
// Same guards as V26-V31.
|
||||
let has_column: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('photos') WHERE name = 'place_id'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
let has_photos: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'photos'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
if has_photos && !has_column {
|
||||
tx.execute_batch(MIGRATION_V33)?;
|
||||
}
|
||||
}
|
||||
if current < 34 {
|
||||
// Same guards as V26-V33.
|
||||
let has_column: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('photos') WHERE name = 'similar_hash'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
let has_photos: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'photos'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
if has_photos && !has_column {
|
||||
tx.execute_batch(MIGRATION_V34)?;
|
||||
}
|
||||
}
|
||||
if current < 35 {
|
||||
// Same guards as V26-V34.
|
||||
let has_column: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('trash') WHERE name = 'trashed_at'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
let has_trash: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'trash'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
if has_trash && !has_column {
|
||||
tx.execute_batch(MIGRATION_V35)?;
|
||||
}
|
||||
}
|
||||
if current < 36 {
|
||||
// Same guards as V26-V35.
|
||||
let has_column: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('nodes') WHERE name = 'lid'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
let has_nodes: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'nodes'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
if has_nodes && !has_column {
|
||||
tx.execute_batch(MIGRATION_V36)?;
|
||||
}
|
||||
}
|
||||
if current < 37 {
|
||||
// Same guards as V26-V36.
|
||||
let sql: Option<String> = tx
|
||||
.query_row(
|
||||
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'pending_op'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
if sql.is_some_and(|sql| !sql.contains("AUTOINCREMENT")) {
|
||||
tx.execute_batch(MIGRATION_V37)?;
|
||||
}
|
||||
}
|
||||
if current < 38 {
|
||||
// Same guards as V26-V37.
|
||||
let has_column: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('pending_op') WHERE name = 'issue'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
let has_ops: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'pending_op'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
if has_ops && !has_column {
|
||||
tx.execute_batch(MIGRATION_V38)?;
|
||||
}
|
||||
}
|
||||
if current < 39 {
|
||||
// Same guards as V26-V38.
|
||||
let has_column: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('pending_op') WHERE name = 'lid'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
let has_ops: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'pending_op'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
if has_ops && !has_column {
|
||||
tx.execute_batch(MIGRATION_V39)?;
|
||||
}
|
||||
}
|
||||
if current < 40 {
|
||||
// Same guards as V26-V39.
|
||||
let has_column: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('nodes') WHERE name = 'parent_lid'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
let has_nodes: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'nodes'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
if has_nodes && !has_column {
|
||||
tx.execute_batch(MIGRATION_V40)?;
|
||||
}
|
||||
}
|
||||
if current < 41 {
|
||||
// Needs both tables; on a database that never had them there is
|
||||
// nothing to rewrite.
|
||||
let tables: i64 = tx.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master
|
||||
WHERE type = 'table' AND name IN ('nodes', 'pending_op', 'pins')",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if tables == 3 {
|
||||
tx.execute_batch(MIGRATION_V41)?;
|
||||
}
|
||||
}
|
||||
if current < 42 {
|
||||
// Same guards as V26-V40.
|
||||
let has_column: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('pending_op') WHERE name = 'sent_sha1'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
let has_ops: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'pending_op'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
if has_ops && !has_column {
|
||||
tx.execute_batch(MIGRATION_V42)?;
|
||||
}
|
||||
}
|
||||
tx.execute(
|
||||
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
@@ -226,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
|
||||
/// 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,
|
||||
@@ -287,8 +638,8 @@ ALTER TABLE sync_folder ADD COLUMN pending_mode TEXT;
|
||||
";
|
||||
|
||||
/// Schema v11: writes no longer upload inside the FUSE handler. `release` stages
|
||||
/// the bytes and records the intended upload here, and a drain worker performs it
|
||||
/// (offline.md Phase 3). The row outlives the process, so a write survives both a
|
||||
/// the bytes and records the intended upload here, and a drain worker performs it.
|
||||
/// The row outlives the process, so a write survives both a
|
||||
/// dead network and a restart.
|
||||
///
|
||||
/// `blob_path` points into the content cache's `staging/` dir and `meta_json` is
|
||||
@@ -315,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
|
||||
/// revision op never had to describe — it always addressed a node the server had
|
||||
/// already minted a uid for (offline.md Phase 3b).
|
||||
/// already minted a uid for.
|
||||
///
|
||||
/// An offline `create`/`mkdir` cannot get a real uid, so the node is invented
|
||||
/// locally under a `local~<uuid>` placeholder and the op records where it goes
|
||||
@@ -411,3 +762,571 @@ CREATE INDEX IF NOT EXISTS idx_local_files_name_nocase
|
||||
ON local_files(name COLLATE NOCASE);
|
||||
INSERT INTO local_fts(local_fts) VALUES('rebuild');
|
||||
";
|
||||
|
||||
/// Schema v17: effective access for roots shared with this account. The role is
|
||||
/// persisted separately from `nodes.node_json` so a cold/offline mount can
|
||||
/// enforce the last known permission even when the SDK cannot refresh metadata.
|
||||
const MIGRATION_V17: &str = "
|
||||
CREATE TABLE share_access (
|
||||
root_uid TEXT PRIMARY KEY,
|
||||
access TEXT NOT NULL
|
||||
CHECK (access IN ('owner', 'editor', 'viewer', 'unknown'))
|
||||
);
|
||||
";
|
||||
|
||||
/// Schema v18: unified location presentation. Device rows carry only their
|
||||
/// `sync_folder` identity; reads join all device state from that authoritative
|
||||
/// table. The trigger projects future folders, while the foreign key removes a
|
||||
/// presentation row with its sync folder.
|
||||
const MIGRATION_V18: &str = "
|
||||
CREATE TABLE mount (
|
||||
id INTEGER PRIMARY KEY,
|
||||
kind TEXT NOT NULL
|
||||
CHECK (kind IN ('myfiles', 'device', 'shared')),
|
||||
sync_folder_id INTEGER REFERENCES sync_folder(id) ON DELETE CASCADE,
|
||||
share_root_uid TEXT,
|
||||
local_path TEXT NOT NULL DEFAULT '',
|
||||
root_uid TEXT NOT NULL DEFAULT '',
|
||||
root_share_id TEXT NOT NULL DEFAULT '',
|
||||
mode TEXT NOT NULL DEFAULT 'unknown',
|
||||
access TEXT NOT NULL DEFAULT 'rw'
|
||||
CHECK (access IN ('rw', 'ro')),
|
||||
CHECK (
|
||||
(kind = 'myfiles' AND sync_folder_id IS NULL AND share_root_uid IS NULL)
|
||||
OR
|
||||
(kind = 'device' AND sync_folder_id IS NOT NULL AND share_root_uid IS NULL)
|
||||
OR
|
||||
(kind = 'shared' AND sync_folder_id IS NULL AND share_root_uid IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX mount_myfiles
|
||||
ON mount(kind) WHERE kind = 'myfiles';
|
||||
CREATE UNIQUE INDEX mount_device
|
||||
ON mount(sync_folder_id) WHERE kind = 'device';
|
||||
CREATE UNIQUE INDEX mount_shared
|
||||
ON mount(share_root_uid) WHERE kind = 'shared';
|
||||
|
||||
INSERT INTO mount (kind, sync_folder_id)
|
||||
SELECT 'device', id FROM sync_folder;
|
||||
|
||||
CREATE TRIGGER mount_sync_folder_insert
|
||||
AFTER INSERT ON sync_folder
|
||||
BEGIN
|
||||
INSERT OR IGNORE INTO mount (kind, sync_folder_id)
|
||||
VALUES ('device', NEW.id);
|
||||
END;
|
||||
";
|
||||
|
||||
/// Schema v19: photo albums and their contents. Both are projections of the
|
||||
/// server's listings — like `photos`, they are replaced wholesale on refresh —
|
||||
/// so the Albums view opens from disk instead of re-enumerating every launch.
|
||||
///
|
||||
/// `album_photos` carries its own `ratio` / `thumb_state` rather than joining
|
||||
/// `photos`: an album shared with us lives on the sharer's volume, so its
|
||||
/// photos are not in our timeline at all and would otherwise have nowhere to
|
||||
/// remember what a thumbnail attempt learned.
|
||||
const MIGRATION_V19: &str = "
|
||||
CREATE TABLE albums (
|
||||
uid TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
photo_count INTEGER NOT NULL DEFAULT 0,
|
||||
cover_uid TEXT,
|
||||
last_activity INTEGER,
|
||||
shared INTEGER NOT NULL DEFAULT 0,
|
||||
seq INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE album_photos (
|
||||
album_uid TEXT NOT NULL,
|
||||
uid TEXT NOT NULL,
|
||||
capture_time INTEGER NOT NULL,
|
||||
name TEXT,
|
||||
media_type TEXT,
|
||||
kind INTEGER NOT NULL DEFAULT 0,
|
||||
ratio REAL,
|
||||
thumb_state INTEGER NOT NULL DEFAULT 0,
|
||||
seq INTEGER NOT NULL,
|
||||
PRIMARY KEY (album_uid, uid)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_album_photos_seq ON album_photos(album_uid, seq);
|
||||
CREATE INDEX idx_album_photos_uid ON album_photos(uid);
|
||||
";
|
||||
|
||||
/// V20: whether a photo is a favorite.
|
||||
///
|
||||
/// A column rather than a tag table: `Favorite` is the only Proton photo tag a
|
||||
/// user sets by hand (the rest — Video, Screenshot, Selfie, Raw… — are the
|
||||
/// server's own classification, and the Photos page already derives its tabs
|
||||
/// from the media type). Defaulting to 0 means an existing timeline stays
|
||||
/// intact and each row learns its real value on the next refresh, the same way
|
||||
/// `media_type` did.
|
||||
const MIGRATION_V20: &str = "
|
||||
ALTER TABLE photos ADD COLUMN favorite INTEGER NOT NULL DEFAULT 0;
|
||||
CREATE INDEX IF NOT EXISTS idx_photos_favorite ON photos(favorite, seq);
|
||||
";
|
||||
|
||||
/// V21: rebuild the node search index after fixing what counts as indexable.
|
||||
///
|
||||
/// The v16 backfill rooted its path walk at any node whose parent is null *or*
|
||||
/// not cached, but the write path's `node_is_indexable_tx` demanded a
|
||||
/// null-parent ancestor. Only the My Files root has one — a device folder's
|
||||
/// root is stored in `device`/`sync_folder`, never in `nodes` — so every node
|
||||
/// under a device folder was dropped from `nodes_fts` the first time it was
|
||||
/// upserted after the backfill, and was unfindable from the prompt (2,705 of
|
||||
/// 9,252 nodes on the account this was diagnosed against).
|
||||
///
|
||||
/// The rule now matches the backfill, so this replays it: same recursive walk
|
||||
/// as v16, over the current `nodes`.
|
||||
/// Two details the v16 backfill did not carry, both cheap here: a node under a
|
||||
/// trashed folder stays out (the write path has always excluded it, and
|
||||
/// `tombstone_subtree_tx` only marks a subtree it was told about), and the walk
|
||||
/// is depth-capped so a parent cycle in a corrupted database cannot spin the
|
||||
/// migration forever.
|
||||
const MIGRATION_V21: &str = "
|
||||
DROP TABLE IF EXISTS nodes_fts;
|
||||
CREATE VIRTUAL TABLE nodes_fts USING fts5(
|
||||
name, path, tokenize='trigram'
|
||||
);
|
||||
INSERT INTO nodes_fts (rowid, name, path)
|
||||
WITH RECURSIVE paths(rowid, uid, path, trashed, depth) AS (
|
||||
-- Only a true root (no parent at all) contributes nothing to the path: its
|
||||
-- name is the mount. A row whose parent is merely uncached is an ordinary
|
||||
-- folder and keeps its name, which is what `path_of` does at runtime.
|
||||
SELECT n.rowid, n.uid,
|
||||
CASE WHEN n.parent_uid IS NULL THEN '' ELSE n.name END, n.trashed, 0
|
||||
FROM nodes n
|
||||
WHERE n.parent_uid IS NULL
|
||||
OR NOT EXISTS (SELECT 1 FROM nodes p WHERE p.uid = n.parent_uid)
|
||||
UNION ALL
|
||||
SELECT n.rowid, n.uid,
|
||||
CASE WHEN paths.path = '' THEN n.name ELSE paths.path || '/' || n.name END,
|
||||
MAX(paths.trashed, n.trashed), paths.depth + 1
|
||||
FROM nodes n JOIN paths ON n.parent_uid = paths.uid
|
||||
WHERE paths.depth < 256
|
||||
)
|
||||
SELECT n.rowid, n.name, paths.path
|
||||
FROM nodes n JOIN paths ON paths.rowid = n.rowid
|
||||
WHERE paths.trashed = 0;
|
||||
";
|
||||
|
||||
/// V22: indexes for the lookups that were scanning, each paired with the table
|
||||
/// it needs to exist for.
|
||||
///
|
||||
/// Each backs a query on a hot path that had no index to use:
|
||||
///
|
||||
/// - `sync_entry(remote_uid)` — the reconcile resolves a remote uid to its
|
||||
/// local entry once per node, per pass.
|
||||
/// - `local_files(scan_gen)` — the sweep that retires the previous generation
|
||||
/// after a local index scan.
|
||||
/// - `nodes(parent_uid) WHERE trashed = 0` — every listing, with the partial
|
||||
/// index keeping it to the live rows a listing actually wants.
|
||||
///
|
||||
/// Deliberately *not* here: an index on `pending_op(next_attempt_at, id)`.
|
||||
/// `next_due_op` orders by `id`, which is the rowid, so it already stops at the
|
||||
/// first due row; offering the planner a `next_attempt_at` index makes it scan
|
||||
/// that index and then sort the matches by `id` instead —
|
||||
/// `next_due_op_does_not_scale_with_queue_length` measures the regression.
|
||||
const MIGRATION_V22: [(&str, &str); 3] = [
|
||||
(
|
||||
"sync_entry",
|
||||
"CREATE INDEX IF NOT EXISTS idx_sync_entry_remote_uid ON sync_entry(remote_uid);",
|
||||
),
|
||||
(
|
||||
"local_files",
|
||||
"CREATE INDEX IF NOT EXISTS idx_local_files_scan_gen ON local_files(scan_gen);",
|
||||
),
|
||||
(
|
||||
"nodes",
|
||||
"CREATE INDEX IF NOT EXISTS idx_nodes_parent_live ON nodes(parent_uid) WHERE trashed = 0;",
|
||||
),
|
||||
];
|
||||
|
||||
/// V24: the revisions this daemon sealed itself, so restarting does not reopen
|
||||
/// the conflict hole B70 layer B closed.
|
||||
///
|
||||
/// `Core::own_sealed_revs` held these in memory: a queued write whose baseline
|
||||
/// names an earlier revision is a conflict *unless* the remote sits at a
|
||||
/// revision we sealed, in which case it is one writer stalling and resuming and
|
||||
/// must supersede rather than fork a `(sync-conflict)` copy. Losing the map at
|
||||
/// shutdown meant the first drain after a restart could not tell the two apart.
|
||||
///
|
||||
/// `sealed_at` is what bounds the table: entries older than the window in which
|
||||
/// a stall→resume is plausible are pruned on write.
|
||||
const MIGRATION_V24: &str = "
|
||||
CREATE TABLE IF NOT EXISTS own_sealed_rev (
|
||||
uid TEXT PRIMARY KEY,
|
||||
revision_id TEXT NOT NULL,
|
||||
sealed_at INTEGER NOT NULL
|
||||
);
|
||||
";
|
||||
|
||||
/// V25: `nodes.path` — a node's mountpoint-relative path, stored rather than
|
||||
/// walked.
|
||||
///
|
||||
/// `path_of` was a recursive CTE per node, and search ran it once per candidate
|
||||
/// (up to a thousand per keystroke) on top of the one the write path already ran
|
||||
/// per descendant of every folder upsert. The value only changes when a node's
|
||||
/// name or parent changes, which is exactly when the write path is already
|
||||
/// walking the subtree — so it belongs in a column.
|
||||
///
|
||||
/// The backfill is the same walk V21 used for `nodes_fts`, minus the trashed
|
||||
/// filter: a trashed node stays out of the index but still has a path. Roots get
|
||||
/// `''` (their name is the mount), and a node whose parent is merely uncached
|
||||
/// keeps its own name — both matching what `path_of` returned at runtime.
|
||||
///
|
||||
/// Materialised into a temp table rather than written by a correlated subquery,
|
||||
/// so the recursive walk runs once instead of once per row.
|
||||
const MIGRATION_V25: &str = "
|
||||
ALTER TABLE nodes ADD COLUMN path TEXT;
|
||||
|
||||
CREATE TEMP TABLE node_paths (uid TEXT PRIMARY KEY, path TEXT NOT NULL);
|
||||
|
||||
INSERT INTO node_paths (uid, path)
|
||||
WITH RECURSIVE paths(uid, path, depth) AS (
|
||||
SELECT n.uid, CASE WHEN n.parent_uid IS NULL THEN '' ELSE n.name END, 0
|
||||
FROM nodes n
|
||||
WHERE n.parent_uid IS NULL
|
||||
OR NOT EXISTS (SELECT 1 FROM nodes p WHERE p.uid = n.parent_uid)
|
||||
UNION ALL
|
||||
SELECT n.uid,
|
||||
CASE WHEN paths.path = '' THEN n.name ELSE paths.path || '/' || n.name END,
|
||||
paths.depth + 1
|
||||
FROM nodes n JOIN paths ON n.parent_uid = paths.uid
|
||||
WHERE paths.depth < 256
|
||||
)
|
||||
SELECT uid, path FROM paths;
|
||||
|
||||
UPDATE nodes
|
||||
SET path = (SELECT p.path FROM node_paths p WHERE p.uid = nodes.uid)
|
||||
WHERE EXISTS (SELECT 1 FROM node_paths p WHERE p.uid = nodes.uid);
|
||||
|
||||
DROP TABLE node_paths;
|
||||
";
|
||||
|
||||
/// V26: `pending_op.claimed_at` — the drain's claim column.
|
||||
///
|
||||
/// The drain was one thread taking one op at a time, so a 10 GiB upload held
|
||||
/// every queued rename and small write behind it for as long as it ran. Several
|
||||
/// workers can share the queue only if a claim is visible to the others: this
|
||||
/// column is what makes "already being drained by someone" a fact about the row
|
||||
/// rather than about one thread's local variable.
|
||||
///
|
||||
/// `0` is unclaimed; anything else is the ms at which a worker took it. A
|
||||
/// claim is process-local state that outlives nothing — the single-writer lock
|
||||
/// means a non-zero value found at open belongs to a crashed run, and
|
||||
/// [`Db::clear_op_claims`](super::Db::clear_op_claims) drops the lot.
|
||||
///
|
||||
/// The index is partial so it costs nothing on the ordinary row: only the
|
||||
/// handful of claimed rows are in it, which is exactly the set the claim query
|
||||
/// has to subtract to keep two workers off one node.
|
||||
const MIGRATION_V26: &str = "
|
||||
ALTER TABLE pending_op ADD COLUMN claimed_at INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_pending_op_claimed
|
||||
ON pending_op(uid) WHERE claimed_at <> 0;
|
||||
";
|
||||
|
||||
/// Schema v27: when an op was first deferred by the local access check.
|
||||
///
|
||||
/// An access deferral consumes no attempt and records no error — the op has not
|
||||
/// failed remotely, it has not been sent at all. That is right for a permission
|
||||
/// change the user is about to undo, and wrong for a condition that never
|
||||
/// clears: the row is re-deferred every
|
||||
/// [`DRAIN_ACCESS_RECHECK`](../../../pdfs_fuse/drain/constant.DRAIN_ACCESS_RECHECK.html)
|
||||
/// forever while `attempts`, `last_error`, `failing` and the logs all stay
|
||||
/// empty, so a queue that can never drain is indistinguishable from a busy one.
|
||||
/// Found in the field as 28 revision ops holding 18 GiB of accepted writes for
|
||||
/// 30 days, none of them visible anywhere.
|
||||
///
|
||||
/// `0` means "not currently access-deferred"; anything else is the ms at which
|
||||
/// the first consecutive deferral happened, which is what
|
||||
/// [`Db::defer_op_for_access`](super::Db::defer_op_for_access) measures the
|
||||
/// deferral against.
|
||||
const MIGRATION_V27: &str = "
|
||||
ALTER TABLE pending_op ADD COLUMN access_deferred_since INTEGER NOT NULL DEFAULT 0;
|
||||
";
|
||||
|
||||
/// Schema v28: sub-second precision for a mirror folder's local baseline.
|
||||
///
|
||||
/// A reconcile pass decides whether the local side of a path changed by
|
||||
/// comparing `(local_mtime, local_size)` against the baseline. Both sides of
|
||||
/// that comparison were whole seconds, so an edit that landed in the same second
|
||||
/// as the last sync and left the file the same length — a fixed-size record
|
||||
/// rewritten, an image re-exported, a database page updated in place — read as
|
||||
/// *unchanged* and was never uploaded. The remote copy silently stayed at the
|
||||
/// older content until something else about the file moved (`docs/BUGS.md` B25).
|
||||
///
|
||||
/// Nullable rather than a converted `local_mtime * 1_000_000_000`: the
|
||||
/// nanosecond part of an existing baseline is genuinely unknown, and inventing
|
||||
/// zero for it would make every already-synced file compare as changed and
|
||||
/// re-upload an entire mirror on first start. `NULL` means "this row predates
|
||||
/// the column", which the comparison reads as "fall back to whole seconds" —
|
||||
/// exactly the behaviour that row was written under. Each row gains a real value
|
||||
/// the next time its path is synced.
|
||||
const MIGRATION_V28: &str = "
|
||||
ALTER TABLE sync_entry ADD COLUMN local_mtime_ns INTEGER;
|
||||
";
|
||||
|
||||
/// Schema v29: the trash listing remembers each trashed node's parent, so a
|
||||
/// restore can reason about the shape of what it is putting back.
|
||||
///
|
||||
/// Trash is a flat list of uids on the wire, but the user deletes a *tree*: a
|
||||
/// folder and, often, items inside it that were trashed separately. Restoring
|
||||
/// one of those in isolation is what the parent link fixes — a child restored
|
||||
/// under a still-trashed parent lands somewhere invisible, and a folder restored
|
||||
/// without its separately-trashed contents comes back empty.
|
||||
///
|
||||
/// `NULL` means "this row predates the column" (or the node is a volume root);
|
||||
/// the next trash refresh fills it in, and until then the restore behaves as it
|
||||
/// did before.
|
||||
const MIGRATION_V29: &str = "
|
||||
ALTER TABLE trash ADD COLUMN parent_uid TEXT;
|
||||
CREATE INDEX IF NOT EXISTS idx_trash_parent ON trash(parent_uid);
|
||||
";
|
||||
|
||||
/// Schema v30: a RAW and the JPEG of the same shot are one photo to the person
|
||||
/// who took it, so the timeline records what ties them together.
|
||||
///
|
||||
/// `content_hash` and `main_uid` come from the server's own photo properties —
|
||||
/// the duplicate-detection hash, and the main photo a related one belongs to.
|
||||
/// `group_key` is what the gallery pages by: every member of one shot carries
|
||||
/// the same key, and the member the grid shows is the one whose `main_uid` is
|
||||
/// its own uid.
|
||||
///
|
||||
/// All three are `NULL` until the next timeline refresh resolves the photo's
|
||||
/// node, and a row without a `group_key` is its own group — which is exactly how
|
||||
/// the gallery behaved before this column existed.
|
||||
const MIGRATION_V30: &str = "
|
||||
ALTER TABLE photos ADD COLUMN content_hash TEXT;
|
||||
ALTER TABLE photos ADD COLUMN main_uid TEXT;
|
||||
ALTER TABLE photos ADD COLUMN group_key TEXT;
|
||||
CREATE INDEX IF NOT EXISTS idx_photos_group ON photos(group_key);
|
||||
";
|
||||
|
||||
/// Schema v31: which photos a refresh still has to read from the server.
|
||||
///
|
||||
/// A refresh used to resolve every photo's node to learn its name, media type,
|
||||
/// favorite tag, content hash and photo relation — minutes of round-trips for a
|
||||
/// library of any size, repeated every time the timeline went stale. `resolved_at`
|
||||
/// records that the work was already done, so a refresh only reads the photos it
|
||||
/// has never read and the ones a remote event marked stale (`resolved_at = NULL`).
|
||||
///
|
||||
/// The backfill treats an existing timeline as resolved, so an upgrade does not
|
||||
/// re-read the whole library once. `WHERE name IS NOT NULL` keeps the photos
|
||||
/// whose earlier resolve had failed eligible: a nameless row is one nothing was
|
||||
/// ever learned about.
|
||||
const MIGRATION_V31: &str = "
|
||||
ALTER TABLE photos ADD COLUMN resolved_at INTEGER;
|
||||
CREATE INDEX IF NOT EXISTS idx_photos_unresolved ON photos(resolved_at);
|
||||
UPDATE photos SET resolved_at = strftime('%s','now') WHERE name IS NOT NULL;
|
||||
";
|
||||
|
||||
/// V32: give videos written off as un-thumbnailable another chance.
|
||||
///
|
||||
/// Before local video thumbnails, the fallback fed a video's bytes to the image
|
||||
/// decoder, which could only fail, and that failure was recorded as the
|
||||
/// permanent "no thumbnail" verdict. Run once per table in `{table}`.
|
||||
const MIGRATION_V32: &str = "UPDATE {table} SET thumb_state = 0 WHERE kind = 1 AND thumb_state = 2";
|
||||
|
||||
/// Schema v33: where each photo was taken.
|
||||
///
|
||||
/// `latitude` and `longitude` come from the location other clients write into
|
||||
/// a photo's extended attributes; `place_id` is the GeoNames id of the nearest
|
||||
/// town (see `crate::places`), so the Places view is a plain `GROUP BY`.
|
||||
///
|
||||
/// Every photo is marked unresolved, so the next refresh reads the location of
|
||||
/// the whole library once. That is the backfill: the metadata is on the node
|
||||
/// the refresh reads anyway, and nothing has to be downloaded.
|
||||
const MIGRATION_V33: &str = "
|
||||
ALTER TABLE photos ADD COLUMN latitude REAL;
|
||||
ALTER TABLE photos ADD COLUMN longitude REAL;
|
||||
ALTER TABLE photos ADD COLUMN place_id INTEGER;
|
||||
CREATE INDEX IF NOT EXISTS idx_photos_place ON photos(place_id);
|
||||
UPDATE photos SET resolved_at = NULL;
|
||||
";
|
||||
|
||||
/// Schema v34: what each photo looks like, for finding similar ones.
|
||||
///
|
||||
/// `similar_hash` is a 64-bit difference hash of the photo's thumbnail (see
|
||||
/// `crate::similar`): resized and re-encoded copies of one picture land a few
|
||||
/// bits apart, where the server's `content_hash` only matches byte-identical
|
||||
/// files. `NULL` until the daemon has hashed the thumbnail, which it does as
|
||||
/// thumbnails arrive and when the similar-photo finder asks for the rest.
|
||||
const MIGRATION_V34: &str = "
|
||||
ALTER TABLE photos ADD COLUMN similar_hash INTEGER;
|
||||
";
|
||||
|
||||
/// Schema v35: when each trashed node went to the trash.
|
||||
///
|
||||
/// The API does not tell this client, so `trashed_at` is when the daemon first
|
||||
/// saw the node in the trash, in epoch seconds. `NULL` when it cannot say: rows
|
||||
/// from before this version, and nodes found by a refresh that did not follow a
|
||||
/// complete one, which may have been in the trash for a long time already.
|
||||
const MIGRATION_V35: &str = "
|
||||
ALTER TABLE trash ADD COLUMN trashed_at INTEGER;
|
||||
";
|
||||
|
||||
/// Schema v36: every node row has a local id (`lid`); see
|
||||
/// docs/ARCHITECTURE.md §2.
|
||||
///
|
||||
/// The `uid` was the row's key, so a node had no identity until Drive minted
|
||||
/// one, and a create that landed moved its row to a new key. The `lid` is given
|
||||
/// when the row is first written and never changes or comes back, which is what
|
||||
/// `AUTOINCREMENT` adds over a plain rowid. It takes the old rowid, so the
|
||||
/// search index, keyed by rowid since v15, stays valid. The `uid` becomes
|
||||
/// nullable for a node that is not on Drive yet.
|
||||
const MIGRATION_V36: &str = "
|
||||
CREATE TABLE nodes_v36 (
|
||||
lid INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uid TEXT UNIQUE,
|
||||
parent_uid TEXT,
|
||||
name TEXT NOT NULL,
|
||||
is_dir INTEGER NOT NULL,
|
||||
size INTEGER,
|
||||
mtime INTEGER NOT NULL,
|
||||
revision_hash TEXT,
|
||||
trashed INTEGER NOT NULL DEFAULT 0,
|
||||
node_json TEXT,
|
||||
listed INTEGER NOT NULL DEFAULT 0,
|
||||
path TEXT
|
||||
);
|
||||
INSERT INTO nodes_v36
|
||||
(lid, uid, parent_uid, name, is_dir, size, mtime, revision_hash, trashed, node_json, listed,
|
||||
path)
|
||||
SELECT rowid, uid, parent_uid, name, is_dir, size, mtime, revision_hash, trashed, node_json,
|
||||
listed, path
|
||||
FROM nodes;
|
||||
DROP TABLE nodes;
|
||||
ALTER TABLE nodes_v36 RENAME TO nodes;
|
||||
CREATE INDEX idx_nodes_parent ON nodes(parent_uid);
|
||||
CREATE INDEX idx_nodes_name_nocase ON nodes(name COLLATE NOCASE);
|
||||
CREATE INDEX idx_nodes_parent_live ON nodes(parent_uid) WHERE trashed = 0;
|
||||
";
|
||||
|
||||
/// Schema v37: a `pending_op` id is never given out twice.
|
||||
///
|
||||
/// A plain rowid is the largest one plus one, so an op queued right after the
|
||||
/// newest one was dropped took its id. The drain retires the op it is
|
||||
/// working on by id, and so deleted the new op: a trash queued over a rename
|
||||
/// on the wire was lost when the rename landed. `AUTOINCREMENT` never reuses
|
||||
/// an id. Ids are kept, as is every index.
|
||||
const MIGRATION_V37: &str = "
|
||||
CREATE TABLE pending_op_v37 (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kind TEXT NOT NULL,
|
||||
uid TEXT NOT NULL,
|
||||
blob_path TEXT,
|
||||
meta_json TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
next_attempt_at INTEGER NOT NULL DEFAULT 0,
|
||||
parent_uid TEXT,
|
||||
name TEXT,
|
||||
claimed_at INTEGER NOT NULL DEFAULT 0,
|
||||
access_deferred_since INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
INSERT INTO pending_op_v37
|
||||
(id, kind, uid, blob_path, meta_json, created_at, attempts, last_error, next_attempt_at,
|
||||
parent_uid, name, claimed_at, access_deferred_since)
|
||||
SELECT id, kind, uid, blob_path, meta_json, created_at, attempts, last_error,
|
||||
next_attempt_at, parent_uid, name, claimed_at, access_deferred_since
|
||||
FROM pending_op;
|
||||
DROP TABLE pending_op;
|
||||
ALTER TABLE pending_op_v37 RENAME TO pending_op;
|
||||
CREATE INDEX pending_op_uid ON pending_op(uid);
|
||||
CREATE INDEX pending_op_parent ON pending_op(parent_uid);
|
||||
CREATE INDEX idx_pending_op_claimed
|
||||
ON pending_op(uid) WHERE claimed_at <> 0;
|
||||
";
|
||||
|
||||
/// Schema v38: why Drive refused a queued op, when it did (a sync issue).
|
||||
/// `NULL` for an op that has not been refused, which is every op migrated.
|
||||
const MIGRATION_V38: &str = "
|
||||
ALTER TABLE pending_op ADD COLUMN issue TEXT;
|
||||
";
|
||||
|
||||
/// Schema v39: a queued op names its node and its parent by local id too.
|
||||
///
|
||||
/// A create made inside a folder that had not landed named the folder by its
|
||||
/// placeholder uid, and every landing had to find and rewrite those strings.
|
||||
/// The local id stays the same when the folder lands. An op whose node has no
|
||||
/// row keeps `NULL` and is matched by uid, as before.
|
||||
const MIGRATION_V39: &str = "
|
||||
ALTER TABLE pending_op ADD COLUMN lid INTEGER;
|
||||
ALTER TABLE pending_op ADD COLUMN parent_lid INTEGER;
|
||||
UPDATE pending_op
|
||||
SET lid = (SELECT lid FROM nodes WHERE nodes.uid = pending_op.uid),
|
||||
parent_lid = (SELECT lid FROM nodes WHERE nodes.uid = pending_op.parent_uid);
|
||||
CREATE INDEX pending_op_lid ON pending_op(lid);
|
||||
CREATE INDEX pending_op_parent_lid ON pending_op(parent_lid);
|
||||
";
|
||||
|
||||
/// Schema v40: a node row names its parent by local id too.
|
||||
///
|
||||
/// The local id stays when a folder made here lands and its row takes the uid
|
||||
/// Drive gave it, so a child linked by it needs no rewrite. `NULL` while the
|
||||
/// parent has no row, as `parent_uid` then names nothing either.
|
||||
const MIGRATION_V40: &str = "
|
||||
ALTER TABLE nodes ADD COLUMN parent_lid INTEGER;
|
||||
UPDATE nodes SET parent_lid = (SELECT p.lid FROM nodes p WHERE p.uid = nodes.parent_uid);
|
||||
CREATE INDEX idx_nodes_parent_lid ON nodes(parent_lid);
|
||||
";
|
||||
|
||||
/// Schema v41: a node made on this machine goes by `local~<lid>` until it
|
||||
/// lands, a function of its row, where it used to get a `local~<ms>-<n>`
|
||||
/// placeholder. Every placeholder still stored is rewritten to its row's
|
||||
/// stand-in: the row itself and its `node_json`, its children, the ops on it
|
||||
/// or into it, and its pins. A placeholder op with no row keeps its uid and
|
||||
/// drains as before.
|
||||
const MIGRATION_V41: &str = "
|
||||
CREATE TEMP TABLE local_v41 AS
|
||||
SELECT uid AS old, 'local~' || lid AS new, lid FROM nodes
|
||||
WHERE uid LIKE 'local~%' AND uid <> 'local~' || lid;
|
||||
UPDATE pending_op SET uid = (SELECT new FROM local_v41 WHERE old = pending_op.uid)
|
||||
WHERE uid IN (SELECT old FROM local_v41);
|
||||
UPDATE pending_op SET parent_uid = (SELECT new FROM local_v41 WHERE old = pending_op.parent_uid)
|
||||
WHERE parent_uid IN (SELECT old FROM local_v41);
|
||||
UPDATE pending_op
|
||||
SET meta_json = json_set(meta_json, '$.uid',
|
||||
(SELECT new FROM local_v41 WHERE old = json_extract(pending_op.meta_json, '$.uid')))
|
||||
WHERE json_valid(meta_json)
|
||||
AND json_extract(meta_json, '$.uid') IN (SELECT old FROM local_v41);
|
||||
UPDATE pending_op
|
||||
SET meta_json = json_set(meta_json, '$.original_parent_uid',
|
||||
(SELECT new FROM local_v41
|
||||
WHERE old = json_extract(pending_op.meta_json, '$.original_parent_uid')))
|
||||
WHERE json_valid(meta_json)
|
||||
AND json_extract(meta_json, '$.original_parent_uid') IN (SELECT old FROM local_v41);
|
||||
UPDATE pins SET uid = (SELECT new FROM local_v41 WHERE old = pins.uid)
|
||||
WHERE uid IN (SELECT old FROM local_v41);
|
||||
UPDATE nodes
|
||||
SET node_json = json_set(node_json, '$.parent_uid.link_id',
|
||||
(SELECT CAST(lid AS TEXT) FROM local_v41 WHERE old = nodes.parent_uid))
|
||||
WHERE parent_uid IN (SELECT old FROM local_v41)
|
||||
AND json_valid(node_json)
|
||||
AND json_extract(node_json, '$.parent_uid.volume_id') = 'local';
|
||||
UPDATE nodes SET parent_uid = (SELECT new FROM local_v41 WHERE old = nodes.parent_uid)
|
||||
WHERE parent_uid IN (SELECT old FROM local_v41);
|
||||
UPDATE nodes
|
||||
SET node_json = json_set(node_json, '$.uid.link_id', CAST(lid AS TEXT))
|
||||
WHERE lid IN (SELECT lid FROM local_v41)
|
||||
AND json_valid(node_json)
|
||||
AND json_extract(node_json, '$.uid.volume_id') = 'local';
|
||||
UPDATE nodes SET uid = 'local~' || lid WHERE lid IN (SELECT lid FROM local_v41);
|
||||
DROP TABLE local_v41;
|
||||
";
|
||||
|
||||
/// Schema v42: the SHA-1 of each blob a queued create sent, space-separated.
|
||||
/// A create whose answer was lost may have made its file with any of them,
|
||||
/// and the op that finds that file again may run after a restart. `NULL` for
|
||||
/// an op that has sent nothing, which is every op migrated: what the daemon
|
||||
/// that wrote it sent is not known. Likewise the parent and name each attempt
|
||||
/// was sent under, as a JSON list of pairs: a rename since then moves the op
|
||||
/// but not the file Drive may have made.
|
||||
const MIGRATION_V42: &str = "
|
||||
ALTER TABLE pending_op ADD COLUMN sent_sha1 TEXT;
|
||||
ALTER TABLE pending_op ADD COLUMN sent_to TEXT;
|
||||
";
|
||||
+346
-10
@@ -2,9 +2,17 @@
|
||||
//! inode bookkeeping, full-text search, content-cache LRU tracking, and pins.
|
||||
//!
|
||||
//! Only the daemon (`pdfs-fuse`) opens this for writes; the GUI and CLI reach
|
||||
//! the same data through the control socket. The connection is wrapped in a
|
||||
//! `Mutex` because the FUSE callbacks are synchronous and already serialize
|
||||
//! behind the `State` lock, so a connection pool would be overkill.
|
||||
//! the same data through the control socket. There is one write connection
|
||||
//! behind a `Mutex` — SQLite allows exactly one writer, and the single-instance
|
||||
//! `flock` makes sure it is ours — and a small pool of read-only connections
|
||||
//! behind [`Db::read`], which every `SELECT`-only method uses.
|
||||
//!
|
||||
//! The pool is what makes WAL worth having. The original justification for a
|
||||
//! single connection was that "FUSE callbacks already serialize behind the
|
||||
//! `State` lock"; that stopped being true when reads moved onto an 11-thread
|
||||
//! worker pool, and a lookup then queued behind whatever listing happened to be
|
||||
//! committing. Under WAL a reader never blocks the writer and vice versa, so the
|
||||
//! only thing left serialising them was this process's own mutex.
|
||||
//!
|
||||
//! This module is the P0 foundation: it opens the database, enables WAL, and
|
||||
//! applies the forward-only schema migrations. Write-through of nodes, the
|
||||
@@ -18,34 +26,42 @@
|
||||
use parking_lot::Mutex;
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::Result;
|
||||
use std::path::Path;
|
||||
use crate::{Error, Result};
|
||||
use std::fs::File;
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
mod activity;
|
||||
mod albums;
|
||||
mod cache;
|
||||
mod devices;
|
||||
mod local;
|
||||
mod maintenance;
|
||||
mod migrations;
|
||||
mod mounts;
|
||||
mod nodes;
|
||||
mod ops;
|
||||
mod photos;
|
||||
mod pins;
|
||||
mod share_access;
|
||||
mod state;
|
||||
mod sync;
|
||||
mod trash;
|
||||
mod utils;
|
||||
|
||||
pub use albums::StoredAlbum;
|
||||
pub use cache::CacheEntryInput;
|
||||
pub use devices::StoredDevice;
|
||||
pub use local::LocalFileHit;
|
||||
pub use maintenance::{DbStats, VacuumOutcome};
|
||||
pub use nodes::{SearchHit, StoredNode};
|
||||
pub use nodes::{PublishedSharedRoot, SearchHit, StoredNode};
|
||||
pub use ops::{
|
||||
AttachedBlob, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_UNTIL,
|
||||
PendingCounts, PendingOp, op_supersedes,
|
||||
AttachedBlob, CreateLanding, CreateRetired, FAILING_ATTEMPTS, LOCAL_VOLUME, OP_CREATE,
|
||||
OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_EXPIRY_MS, PARK_UNTIL, PendingCounts,
|
||||
PendingOp, RenameMeta, local_lid, local_uid, op_supersedes,
|
||||
};
|
||||
pub use photos::{StoredPhoto, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN};
|
||||
pub use photos::{StoredPhoto, StoredPlace, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN, TimelineRow};
|
||||
pub use pins::PinRow;
|
||||
pub use sync::{StoredSyncEntry, StoredSyncFolder};
|
||||
pub use trash::StoredTrash;
|
||||
|
||||
@@ -61,18 +77,208 @@ const WAL_SIZE_LIMIT: i64 = 64 * 1024 * 1024;
|
||||
/// genuinely stuck holder surfaces as an error rather than an apparent hang.
|
||||
const BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
/// Page cache, in KiB (applied negated, which is how SQLite reads a size in KiB
|
||||
/// rather than in pages). 32 MiB against a database that is typically tens of
|
||||
/// MiB: large enough to hold the working set of a listing-heavy session, small
|
||||
/// enough to be unremarkable in a desktop daemon's RSS.
|
||||
const CACHE_SIZE_KIB: i64 = 32 * 1024;
|
||||
|
||||
/// Ceiling on the memory-mapped window over the database file. 256 MiB covers
|
||||
/// any realistic index while keeping the mapping bounded.
|
||||
const MMAP_SIZE: i64 = 256 * 1024 * 1024;
|
||||
|
||||
/// WAL pages between automatic checkpoints (default 1000, i.e. ~4 MiB). Halved
|
||||
/// so the synchronous checkpoint a committing thread inherits stays short.
|
||||
const WAL_AUTOCHECKPOINT_PAGES: i64 = 500;
|
||||
|
||||
/// Handle to the unified metadata database.
|
||||
///
|
||||
/// Cheap to wrap in an `Arc`; clone the `Arc`, not this. All access goes through
|
||||
/// the inner `Mutex<Connection>`.
|
||||
pub struct Db {
|
||||
conn: Mutex<Connection>,
|
||||
/// Read-only connections for `SELECT`-only methods. `None` for an in-memory
|
||||
/// database, where a second connection would open a different, empty one —
|
||||
/// those fall back to the write connection.
|
||||
readers: Option<ReadPool>,
|
||||
/// The single-writer lock, held open for as long as this handle lives.
|
||||
///
|
||||
/// Never read; dropping the `File` is what releases the `flock`, so it has
|
||||
/// to be owned here rather than by `open`'s stack frame. `None` for
|
||||
/// in-memory databases, which nothing else can reach.
|
||||
_single_writer: Option<File>,
|
||||
}
|
||||
|
||||
/// Idle read-only connections, opened on demand and kept for reuse.
|
||||
///
|
||||
/// Not a bounded pool: the number in flight is bounded by the number of threads
|
||||
/// that can be inside a read at once (the FUSE worker lanes plus the control
|
||||
/// handlers), and blocking one of those on a permit would reintroduce exactly
|
||||
/// the queueing this exists to remove. Only the *idle* set is capped, so a burst
|
||||
/// does not leave a connection per thread parked forever.
|
||||
struct ReadPool {
|
||||
path: PathBuf,
|
||||
idle: Mutex<Vec<Connection>>,
|
||||
}
|
||||
|
||||
/// How many idle read connections to keep. Enough that the steady state never
|
||||
/// reopens, small enough to be unremarkable.
|
||||
const MAX_IDLE_READERS: usize = 4;
|
||||
|
||||
impl ReadPool {
|
||||
fn take(&self) -> Option<Connection> {
|
||||
if let Some(conn) = self.idle.lock().pop() {
|
||||
return Some(conn);
|
||||
}
|
||||
match open_read_only(&self.path) {
|
||||
Ok(conn) => Some(conn),
|
||||
Err(error) => {
|
||||
// Falling back to the write connection is correct, just slower,
|
||||
// so this is a warning and not a failed read.
|
||||
tracing::warn!(%error, "could not open a read-only connection");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn put(&self, conn: Connection) {
|
||||
let mut idle = self.idle.lock();
|
||||
if idle.len() < MAX_IDLE_READERS {
|
||||
idle.push(conn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A connection to read through: one borrowed from [`ReadPool`], or the write
|
||||
/// connection when there is no pool (in-memory) or opening one failed.
|
||||
///
|
||||
/// Derefs to `Connection`, so a method converts by swapping `self.conn.lock()`
|
||||
/// for `self.read()` and nothing else.
|
||||
pub(super) struct Reader<'a> {
|
||||
pooled: Option<Connection>,
|
||||
writer: Option<parking_lot::MutexGuard<'a, Connection>>,
|
||||
pool: Option<&'a ReadPool>,
|
||||
}
|
||||
|
||||
impl std::ops::Deref for Reader<'_> {
|
||||
type Target = Connection;
|
||||
|
||||
fn deref(&self) -> &Connection {
|
||||
self.pooled
|
||||
.as_ref()
|
||||
.or(self.writer.as_deref())
|
||||
.expect("a Reader always holds one of the two")
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Reader<'_> {
|
||||
fn drop(&mut self) {
|
||||
if let (Some(conn), Some(pool)) = (self.pooled.take(), self.pool) {
|
||||
pool.put(conn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a second connection to the same file for reads only.
|
||||
///
|
||||
/// `SQLITE_OPEN_READ_ONLY` plus `query_only` is belt and braces: the flag stops
|
||||
/// a routing mistake at the file, the pragma stops it at the statement. The rest
|
||||
/// mirror [`Db::open_configured`] — a read connection has its own page cache,
|
||||
/// its own temp store and its own busy timeout, and gets nothing from the
|
||||
/// writer's.
|
||||
///
|
||||
/// `journal_mode` is deliberately absent: it is a property of the database file,
|
||||
/// which the writer already set to WAL, and a read-only connection cannot change
|
||||
/// it anyway.
|
||||
fn open_read_only(path: &Path) -> Result<Connection> {
|
||||
use rusqlite::OpenFlags;
|
||||
let conn = Connection::open_with_flags(
|
||||
path,
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
)?;
|
||||
conn.busy_timeout(BUSY_TIMEOUT)?;
|
||||
conn.pragma_update(None, "cache_size", -CACHE_SIZE_KIB)?;
|
||||
conn.pragma_update(None, "temp_store", "MEMORY")?;
|
||||
conn.pragma_update(None, "mmap_size", MMAP_SIZE)?;
|
||||
conn.pragma_update(None, "query_only", true)?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
impl Db {
|
||||
/// Open (creating if absent) the database at `path`, enable WAL, and bring
|
||||
/// the schema up to [`SCHEMA_VERSION`].
|
||||
///
|
||||
/// Takes the single-instance lock first (see
|
||||
/// [`acquire_single_writer_lock`]), and rebuilds from empty if what is on
|
||||
/// disk turns out not to be a readable database (see [`is_corrupt`]).
|
||||
pub fn open(path: &Path) -> Result<Self> {
|
||||
let single_writer = acquire_single_writer_lock(path)?;
|
||||
let conn = match Self::open_configured(path) {
|
||||
Ok(conn) => conn,
|
||||
Err(e) if is_corrupt(&e) => {
|
||||
// Every byte in here is derived from the account or from the
|
||||
// content cache directory, so the recovery is to start over —
|
||||
// loudly, and keeping the damaged file for a post-mortem rather
|
||||
// than deleting evidence.
|
||||
tracing::error!(
|
||||
db = %path.display(),
|
||||
error = %e,
|
||||
"cache database is corrupt — moving it aside and rebuilding \
|
||||
(metadata will be re-fetched; no user data is stored here)"
|
||||
);
|
||||
quarantine_corrupt_db(path)?;
|
||||
Self::open_configured(path)?
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
let db = Self {
|
||||
conn: Mutex::new(conn),
|
||||
// No pool for `:memory:`. A second connection to that name is a
|
||||
// second, empty database rather than another view of this one, so
|
||||
// every routed read would find a schema that does not exist.
|
||||
readers: (!is_in_memory(path)).then(|| ReadPool {
|
||||
path: path.to_path_buf(),
|
||||
idle: Mutex::new(Vec::new()),
|
||||
}),
|
||||
_single_writer: single_writer,
|
||||
};
|
||||
// A drain claim names a worker thread in the process that took it, and
|
||||
// the single-writer lock above says that process is not running. Any
|
||||
// claim still on disk is therefore a crashed run's, and leaving it would
|
||||
// hide those ops from every worker for good.
|
||||
match db.clear_op_claims() {
|
||||
Ok(0) => {}
|
||||
Ok(n) => tracing::info!(ops = n, "released drain claims left by a previous run"),
|
||||
Err(e) => tracing::warn!(error = %e, "clearing stale drain claims failed"),
|
||||
}
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
/// A connection for a `SELECT`-only method.
|
||||
///
|
||||
/// Reads see the last committed state, which is the same thing they saw
|
||||
/// through the write connection: every method here commits before it
|
||||
/// returns, and nothing holds a transaction open across calls.
|
||||
pub(super) fn read(&self) -> Reader<'_> {
|
||||
if let Some(pool) = &self.readers
|
||||
&& let Some(conn) = pool.take()
|
||||
{
|
||||
return Reader {
|
||||
pooled: Some(conn),
|
||||
writer: None,
|
||||
pool: Some(pool),
|
||||
};
|
||||
}
|
||||
Reader {
|
||||
pooled: None,
|
||||
writer: Some(self.conn.lock()),
|
||||
pool: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the file, apply every PRAGMA, and migrate. Split out of
|
||||
/// [`open`](Self::open) so the corruption path can retry it verbatim.
|
||||
fn open_configured(path: &Path) -> Result<Connection> {
|
||||
let conn = Connection::open(path)?;
|
||||
// WAL: readers never block the single writer. NORMAL sync is the
|
||||
// standard durability/throughput tradeoff for WAL.
|
||||
@@ -97,20 +303,53 @@ impl Db {
|
||||
// (Bare SQLite does default to 0, which is where the belief that this
|
||||
// was unset came from. That default is not what we get.)
|
||||
conn.busy_timeout(BUSY_TIMEOUT)?;
|
||||
// Page cache. The default is 2 MiB, against a schema whose hot table
|
||||
// stores a JSON blob per node — a listing of a few thousand files
|
||||
// evicts itself while it is being read.
|
||||
conn.pragma_update(None, "cache_size", -CACHE_SIZE_KIB)?;
|
||||
// Sorts and recursive CTEs materialise their intermediate results.
|
||||
// Every `ORDER BY … COLLATE NOCASE` listing and every ancestor walk
|
||||
// spills those to a file in `/tmp` by default; they are small and
|
||||
// short-lived, so memory is both faster and less surprising.
|
||||
conn.pragma_update(None, "temp_store", "MEMORY")?;
|
||||
// Read the database through the page cache of the OS rather than
|
||||
// `pread` per page. Bounded rather than unlimited so the daemon's RSS
|
||||
// does not track the database size.
|
||||
conn.pragma_update(None, "mmap_size", MMAP_SIZE)?;
|
||||
// Checkpointing is synchronous in whichever thread happens to commit
|
||||
// the transaction that crosses the threshold — often a FUSE callback.
|
||||
// A smaller autocheckpoint makes that debt smaller and more frequent
|
||||
// rather than rare and multi-second.
|
||||
conn.pragma_update(None, "wal_autocheckpoint", WAL_AUTOCHECKPOINT_PAGES)?;
|
||||
|
||||
let db = Self {
|
||||
conn: Mutex::new(conn),
|
||||
readers: None,
|
||||
_single_writer: None,
|
||||
};
|
||||
db.migrate()?;
|
||||
Ok(db)
|
||||
Ok(db.into_conn())
|
||||
}
|
||||
|
||||
/// Unwrap the connection back out of a temporary handle. Only
|
||||
/// [`open_configured`](Self::open_configured) uses it, to run `migrate`
|
||||
/// (which is written against `&self`) before the real handle exists.
|
||||
fn into_conn(self) -> Connection {
|
||||
self.conn.into_inner()
|
||||
}
|
||||
|
||||
/// Open an in-memory database. For tests.
|
||||
#[cfg(test)]
|
||||
pub fn open_in_memory() -> Result<Self> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
// Keep relational behavior identical to `open`: V18 relies on the
|
||||
// `mount.sync_folder_id` cascade, and SQLite disables foreign keys per
|
||||
// connection unless explicitly enabled.
|
||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||
let db = Self {
|
||||
conn: Mutex::new(conn),
|
||||
readers: None,
|
||||
_single_writer: None,
|
||||
};
|
||||
db.migrate()?;
|
||||
Ok(db)
|
||||
@@ -124,5 +363,102 @@ impl Db {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `path` names the in-memory database rather than a file. Both
|
||||
/// spellings SQLite accepts are checked, because neither has a lock file or a
|
||||
/// corruption story.
|
||||
fn is_in_memory(path: &Path) -> bool {
|
||||
let p = path.as_os_str();
|
||||
p == ":memory:" || p.is_empty()
|
||||
}
|
||||
|
||||
/// Take the single-instance write lock for the database at `path`.
|
||||
///
|
||||
/// The daemon is the only writer by design — the front-ends go through the
|
||||
/// control socket — but nothing stopped a hand-run `pdfs mount` from starting
|
||||
/// alongside the systemd unit, at which point two processes own the same inode
|
||||
/// space, the same content cache, and the same drain queue. SQLite's own
|
||||
/// locking makes each *statement* safe and does nothing about that.
|
||||
///
|
||||
/// `flock` on a sibling `.lock` file rather than on the database itself:
|
||||
/// SQLite uses POSIX record locks, so the two never interact, and a lock file
|
||||
/// left behind carries no state — the lock lives in the kernel and dies with
|
||||
/// the process, however it exits.
|
||||
fn acquire_single_writer_lock(path: &Path) -> Result<Option<File>> {
|
||||
if is_in_memory(path) {
|
||||
return Ok(None);
|
||||
}
|
||||
let lock_path = lock_path_for(path);
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.read(true)
|
||||
.truncate(false)
|
||||
.write(true)
|
||||
.open(&lock_path)?;
|
||||
// SAFETY: `file` owns the descriptor and outlives the call.
|
||||
let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
|
||||
if rc != 0 {
|
||||
let e = std::io::Error::last_os_error();
|
||||
if e.raw_os_error() == Some(libc::EWOULDBLOCK) {
|
||||
return Err(Error::Other(format!(
|
||||
"another Proton Drive daemon is already using {} — \
|
||||
stop it first (`systemctl --user stop proton-drive.service`)",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
return Err(Error::Io(e));
|
||||
}
|
||||
Ok(Some(file))
|
||||
}
|
||||
|
||||
fn lock_path_for(path: &Path) -> PathBuf {
|
||||
let mut name = path.as_os_str().to_owned();
|
||||
name.push(".lock");
|
||||
PathBuf::from(name)
|
||||
}
|
||||
|
||||
/// Whether `e` says the file is not a usable database, as opposed to any other
|
||||
/// failure. These are the only two conditions rebuilding fixes; a disk error, a
|
||||
/// permission problem or a newer-than-supported schema must all still fail.
|
||||
fn is_corrupt(e: &Error) -> bool {
|
||||
matches!(
|
||||
e,
|
||||
Error::Db(rusqlite::Error::SqliteFailure(inner, _))
|
||||
if inner.code == rusqlite::ErrorCode::DatabaseCorrupt
|
||||
|| inner.code == rusqlite::ErrorCode::NotADatabase
|
||||
)
|
||||
}
|
||||
|
||||
/// Move a damaged database (and its WAL sidecars) out of the way so the next
|
||||
/// open starts from empty.
|
||||
///
|
||||
/// Renamed rather than deleted: this is a cache, but it is also the only
|
||||
/// evidence of what went wrong, and it costs one file the user can remove.
|
||||
fn quarantine_corrupt_db(path: &Path) -> Result<()> {
|
||||
let stamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
for suffix in ["", "-wal", "-shm"] {
|
||||
let mut from = path.as_os_str().to_owned();
|
||||
from.push(suffix);
|
||||
let from = PathBuf::from(from);
|
||||
if !from.exists() {
|
||||
continue;
|
||||
}
|
||||
let mut to = path.as_os_str().to_owned();
|
||||
to.push(format!(".corrupt-{stamp}{suffix}"));
|
||||
let to = PathBuf::from(to);
|
||||
// A failure to rename the sidecars is survivable — SQLite discards a
|
||||
// WAL whose database header no longer matches — but failing to move the
|
||||
// database itself is not, because the retry would hit the same file.
|
||||
match std::fs::rename(&from, &to) {
|
||||
Ok(()) => tracing::warn!(from = %from.display(), to = %to.display(), "quarantined"),
|
||||
Err(e) if suffix.is_empty() => return Err(Error::Io(e)),
|
||||
Err(e) => tracing::warn!(file = %from.display(), error = %e, "could not quarantine"),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,179 @@
|
||||
//! Unified mount/location presentation rows.
|
||||
//!
|
||||
//! A device row stores only its `sync_folder` identity. Its path, remote root,
|
||||
//! mode, state, pending mode, and last-sync timestamp are selected from
|
||||
//! `sync_folder`, which remains the authoritative sync-engine table.
|
||||
|
||||
use rusqlite::params;
|
||||
|
||||
use super::Db;
|
||||
use crate::mounts::{MountAccess, MountKind, MountMode, MountSpec};
|
||||
use crate::{Error, Result};
|
||||
|
||||
impl Db {
|
||||
/// Project the configured primary mount into the presentation table.
|
||||
///
|
||||
/// `root_share_id` is optional because an offline startup may only have the
|
||||
/// cached root node. A previously learned share id is retained only when it
|
||||
/// belongs to the same root uid.
|
||||
pub fn mount_upsert_my_files(
|
||||
&self,
|
||||
local_path: &str,
|
||||
root_uid: &str,
|
||||
root_share_id: Option<&str>,
|
||||
) -> Result<i64> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
let updated = tx.execute(
|
||||
"UPDATE mount
|
||||
SET local_path = ?1,
|
||||
root_share_id = CASE
|
||||
WHEN root_uid = ?2
|
||||
THEN COALESCE(NULLIF(?3, ''), root_share_id)
|
||||
ELSE COALESCE(?3, '')
|
||||
END,
|
||||
root_uid = ?2,
|
||||
mode = 'ondemand',
|
||||
access = 'rw'
|
||||
WHERE kind = 'myfiles'",
|
||||
params![local_path, root_uid, root_share_id],
|
||||
)?;
|
||||
let id = if updated == 0 {
|
||||
tx.execute(
|
||||
"INSERT INTO mount
|
||||
(kind, local_path, root_uid, root_share_id, mode, access)
|
||||
VALUES ('myfiles', ?1, ?2, COALESCE(?3, ''), 'ondemand', 'rw')",
|
||||
params![local_path, root_uid, root_share_id],
|
||||
)?;
|
||||
tx.last_insert_rowid()
|
||||
} else {
|
||||
tx.query_row("SELECT id FROM mount WHERE kind = 'myfiles'", [], |row| {
|
||||
row.get(0)
|
||||
})?
|
||||
};
|
||||
tx.commit()?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Store a share id learned asynchronously, but only if the projection still
|
||||
/// names the root it was resolved for.
|
||||
pub fn mount_repair_my_files_share_id(
|
||||
&self,
|
||||
root_uid: &str,
|
||||
root_share_id: &str,
|
||||
) -> Result<bool> {
|
||||
let conn = self.conn.lock();
|
||||
Ok(conn.execute(
|
||||
"UPDATE mount
|
||||
SET root_share_id = ?2
|
||||
WHERE kind = 'myfiles'
|
||||
AND root_uid = ?1",
|
||||
params![root_uid, root_share_id],
|
||||
)? > 0)
|
||||
}
|
||||
|
||||
/// Every local location, with device fields joined from `sync_folder`.
|
||||
pub fn mount_list(&self) -> Result<Vec<MountSpec>> {
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT
|
||||
m.id,
|
||||
m.kind,
|
||||
m.sync_folder_id,
|
||||
m.share_root_uid,
|
||||
CASE WHEN m.kind = 'device' THEN sf.local_path ELSE m.local_path END,
|
||||
CASE WHEN m.kind = 'device' THEN sf.remote_uid ELSE m.root_uid END,
|
||||
CASE WHEN m.kind = 'device' THEN sf.remote_share_id ELSE m.root_share_id END,
|
||||
CASE WHEN m.kind = 'device' THEN sf.mode ELSE m.mode END,
|
||||
m.access,
|
||||
CASE WHEN m.kind = 'device' THEN sf.state ELSE 'idle' END,
|
||||
CASE WHEN m.kind = 'device' THEN sf.last_sync ELSE 0 END,
|
||||
CASE WHEN m.kind = 'device' THEN sf.pending_mode ELSE NULL END
|
||||
FROM mount AS m
|
||||
LEFT JOIN sync_folder AS sf ON sf.id = m.sync_folder_id
|
||||
ORDER BY CASE m.kind
|
||||
WHEN 'myfiles' THEN 0
|
||||
WHEN 'device' THEN 1
|
||||
ELSE 2
|
||||
END,
|
||||
m.id",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, Option<i64>>(2)?,
|
||||
row.get::<_, Option<String>>(3)?,
|
||||
row.get::<_, String>(4)?,
|
||||
row.get::<_, String>(5)?,
|
||||
row.get::<_, String>(6)?,
|
||||
row.get::<_, String>(7)?,
|
||||
row.get::<_, String>(8)?,
|
||||
row.get::<_, String>(9)?,
|
||||
row.get::<_, i64>(10)?,
|
||||
row.get::<_, Option<String>>(11)?,
|
||||
))
|
||||
})?;
|
||||
|
||||
let mut mounts = Vec::new();
|
||||
for row in rows {
|
||||
let (
|
||||
id,
|
||||
kind,
|
||||
sync_folder_id,
|
||||
share_root_uid,
|
||||
local_path,
|
||||
root_uid,
|
||||
root_share_id,
|
||||
mode,
|
||||
access,
|
||||
state,
|
||||
last_sync,
|
||||
pending_mode,
|
||||
) = row?;
|
||||
let kind = match kind.as_str() {
|
||||
"myfiles" => MountKind::MyFiles,
|
||||
"device" => MountKind::Device {
|
||||
sync_folder_id: sync_folder_id.ok_or_else(|| {
|
||||
Error::Other(format!("mount row {id} has no sync_folder_id"))
|
||||
})?,
|
||||
},
|
||||
"shared" => MountKind::Shared {
|
||||
share_root_uid: share_root_uid.ok_or_else(|| {
|
||||
Error::Other(format!("mount row {id} has no share_root_uid"))
|
||||
})?,
|
||||
},
|
||||
other => {
|
||||
return Err(Error::Other(format!(
|
||||
"mount row {id} has unknown kind {other:?}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
let access = match access.as_str() {
|
||||
"rw" => MountAccess::Rw,
|
||||
"ro" => MountAccess::Ro,
|
||||
other => {
|
||||
return Err(Error::Other(format!(
|
||||
"mount row {id} has unknown access {other:?}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
mounts.push(MountSpec {
|
||||
id,
|
||||
kind,
|
||||
local_path,
|
||||
root_uid,
|
||||
root_share_id,
|
||||
mode: MountMode::from(mode.as_str()),
|
||||
access,
|
||||
state,
|
||||
last_sync,
|
||||
pending_mode: pending_mode.as_deref().map(MountMode::from),
|
||||
mounted: false,
|
||||
progress: None,
|
||||
paused: false,
|
||||
});
|
||||
}
|
||||
Ok(mounts)
|
||||
}
|
||||
}
|
||||
+982
-154
File diff suppressed because it is too large.
Load diff
+1234
-79
File diff suppressed because it is too large.
Load diff
@@ -3,7 +3,7 @@
|
||||
|
||||
use rusqlite::params;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use super::Db;
|
||||
use crate::Result;
|
||||
@@ -32,56 +32,382 @@ pub struct StoredPhoto {
|
||||
/// Which Photos-page tab this entry belongs to, derived from its name and
|
||||
/// media type when the timeline was last replaced.
|
||||
pub kind: crate::control::PhotoKind,
|
||||
/// Whether the photo carries Proton's `Favorite` tag.
|
||||
pub favorite: bool,
|
||||
/// How many photos this photo's group holds — one shot taken as RAW+JPEG is
|
||||
/// two files and one group. `1` for a photo that stands on its own.
|
||||
pub group_size: usize,
|
||||
/// Whether any member of the group is a camera raw file. The grid badges
|
||||
/// that, because the JPEG it shows is not the whole of what is stored.
|
||||
pub has_raw: bool,
|
||||
}
|
||||
|
||||
/// A town photos were taken in: its GeoNames id (see [`crate::places`]), how
|
||||
/// many photos it holds, and the newest of them.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct StoredPlace {
|
||||
pub id: u32,
|
||||
pub count: usize,
|
||||
pub cover: StoredPhoto,
|
||||
}
|
||||
|
||||
/// One row of a timeline replacement. Everything but the uid and the capture
|
||||
/// time is "what this refresh learned": a `None` keeps whatever is already
|
||||
/// stored rather than clearing it, because the timeline DTO carries only the uid
|
||||
/// and the capture time and resolving the node can fail.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct TimelineRow {
|
||||
pub uid: String,
|
||||
pub capture_time: i64,
|
||||
pub name: Option<String>,
|
||||
pub media_type: Option<String>,
|
||||
pub favorite: Option<bool>,
|
||||
/// The server's duplicate-detection hash for the plaintext.
|
||||
pub content_hash: Option<String>,
|
||||
/// The main photo this one is *related* to (a live-photo video, a burst
|
||||
/// sibling), when the server says it belongs to one.
|
||||
pub main_uid: Option<String>,
|
||||
/// Where the photo was taken, as `(latitude, longitude)` in decimal degrees.
|
||||
pub location: Option<(f64, f64)>,
|
||||
/// When this refresh resolved the photo's node, if it did at all. `Some`
|
||||
/// makes every other field authoritative — including a `None` that means
|
||||
/// "the server no longer reports one" — while `None` means the refresh
|
||||
/// skipped this photo and whatever is stored must be kept.
|
||||
pub resolved_at: Option<i64>,
|
||||
}
|
||||
|
||||
impl TimelineRow {
|
||||
/// A row carrying only what the timeline listing itself gives.
|
||||
pub fn new(uid: impl Into<String>, capture_time: i64) -> Self {
|
||||
Self {
|
||||
uid: uid.into(),
|
||||
capture_time,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a previous refresh learned about a photo and a new one must not lose.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct Learned {
|
||||
name: Option<String>,
|
||||
ratio: Option<f64>,
|
||||
thumb_state: i64,
|
||||
media_type: Option<String>,
|
||||
favorite: bool,
|
||||
content_hash: Option<String>,
|
||||
main_uid: Option<String>,
|
||||
location: Option<(f64, f64)>,
|
||||
/// What its thumbnail looks like (see [`crate::similar`]).
|
||||
similar_hash: Option<i64>,
|
||||
/// When this photo's node was last resolved, kept so a refresh that skips it
|
||||
/// does not make it look unresolved again.
|
||||
resolved_at: Option<i64>,
|
||||
}
|
||||
|
||||
/// The local calendar day a capture time falls on, as a `YYYY-MM-DD` string.
|
||||
/// Grouping by day rather than by exact second is deliberate: a camera writes
|
||||
/// the RAW and the JPEG of one shot a moment apart, and two shots of the same
|
||||
/// subject on different days are not one photo.
|
||||
fn capture_day(conn: &rusqlite::Connection, capture_time: i64) -> Option<String> {
|
||||
conn.query_row(
|
||||
"SELECT strftime('%Y-%m-%d', ?1, 'unixepoch', 'localtime')",
|
||||
params![capture_time],
|
||||
|r| r.get::<_, Option<String>>(0),
|
||||
)
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// The file name without its extension, lowercased — `IMG_1234.CR2` and
|
||||
/// `IMG_1234.JPG` share `img_1234` — with the camera's own part-of-a-shot
|
||||
/// marker removed, so the two files a raw capture writes share a stem too.
|
||||
fn name_stem(name: Option<&str>) -> Option<String> {
|
||||
let name = name?;
|
||||
let stem = match name.rsplit_once('.') {
|
||||
Some((stem, _)) if !stem.is_empty() => stem,
|
||||
_ => name,
|
||||
};
|
||||
let stem = stem.to_ascii_lowercase();
|
||||
Some(strip_shot_marker(&stem).to_string())
|
||||
}
|
||||
|
||||
/// Drop a `.RAW-NN.ROLE` marker from a stem.
|
||||
///
|
||||
/// Pixel stores one raw capture as `PXL_20260919_000625670.RAW-01.COVER.jpg`
|
||||
/// and `PXL_20260919_000625670.RAW-02.ORIGINAL.dng`: one shot, two files, two
|
||||
/// stems that differ in exactly the part naming which file of the shot this is.
|
||||
/// Cutting the marker off is what lets rule 2 see the pair. The check is narrow
|
||||
/// on purpose — digits, then a single role word — so an ordinary name that
|
||||
/// happens to contain `.raw-` keeps its stem.
|
||||
fn strip_shot_marker(stem: &str) -> &str {
|
||||
let Some(at) = stem.rfind(".raw-") else {
|
||||
return stem;
|
||||
};
|
||||
let rest = &stem[at + ".raw-".len()..];
|
||||
let Some((index, role)) = rest.split_once('.') else {
|
||||
return stem;
|
||||
};
|
||||
let marked = !index.is_empty()
|
||||
&& index.bytes().all(|b| b.is_ascii_digit())
|
||||
&& !role.is_empty()
|
||||
&& role.bytes().all(|b| b.is_ascii_alphabetic());
|
||||
if marked { &stem[..at] } else { stem }
|
||||
}
|
||||
|
||||
/// One entry of the grouping pass: what rule 2 needs, plus what choosing a
|
||||
/// representative needs.
|
||||
struct Grouped {
|
||||
uid: String,
|
||||
kind: crate::control::PhotoKind,
|
||||
main_uid: Option<String>,
|
||||
day: Option<String>,
|
||||
stem: Option<String>,
|
||||
}
|
||||
|
||||
/// Which group each photo belongs to, and which member of it the gallery shows.
|
||||
///
|
||||
/// Precedence, as agreed:
|
||||
///
|
||||
/// 1. The server's own relation — a photo with a `main_photo_uid` joins that
|
||||
/// photo's group. The server knows about live photos and bursts; we do not
|
||||
/// have to guess at them.
|
||||
/// 2. Otherwise, same capture day **and** same file-name stem **and** one of the
|
||||
/// two is a camera raw while the other is not. That is the RAW+JPEG pair a
|
||||
/// camera writes, and the differing-kind clause keeps two JPEGs that happen
|
||||
/// to share a name apart.
|
||||
/// 3. Otherwise the photo is its own group.
|
||||
///
|
||||
/// The representative is the non-raw member when the group has one — a JPEG
|
||||
/// decodes in milliseconds and is what the person expects to see — and the
|
||||
/// earliest member in server order otherwise.
|
||||
fn group_photos(entries: &[Grouped]) -> Vec<String> {
|
||||
let mut parent: Vec<usize> = (0..entries.len()).collect();
|
||||
fn find(parent: &mut [usize], mut i: usize) -> usize {
|
||||
while parent[i] != i {
|
||||
parent[i] = parent[parent[i]];
|
||||
i = parent[i];
|
||||
}
|
||||
i
|
||||
}
|
||||
let union = |parent: &mut [usize], a: usize, b: usize| {
|
||||
let (a, b) = (find(parent, a), find(parent, b));
|
||||
if a != b {
|
||||
parent[b] = a;
|
||||
}
|
||||
};
|
||||
|
||||
let index: HashMap<&str, usize> = entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, e)| (e.uid.as_str(), i))
|
||||
.collect();
|
||||
for (i, entry) in entries.iter().enumerate() {
|
||||
if let Some(main) = entry.main_uid.as_deref()
|
||||
&& let Some(&j) = index.get(main)
|
||||
{
|
||||
union(&mut parent, j, i);
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 2 needs only one pass: everything sharing a day and a stem lands in
|
||||
// the same bucket, and the raw / non-raw split is checked pairwise there.
|
||||
let mut by_name: HashMap<(&str, &str), Vec<usize>> = HashMap::new();
|
||||
for (i, entry) in entries.iter().enumerate() {
|
||||
if let (Some(day), Some(stem)) = (entry.day.as_deref(), entry.stem.as_deref()) {
|
||||
by_name.entry((day, stem)).or_default().push(i);
|
||||
}
|
||||
}
|
||||
let is_raw = |i: usize| entries[i].kind == crate::control::PhotoKind::Raw;
|
||||
for bucket in by_name.values() {
|
||||
for (n, &i) in bucket.iter().enumerate() {
|
||||
for &j in &bucket[n + 1..] {
|
||||
if is_raw(i) != is_raw(j) {
|
||||
union(&mut parent, i, j);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Representative per root, then the key every member carries.
|
||||
let mut representative: HashMap<usize, usize> = HashMap::new();
|
||||
for i in 0..entries.len() {
|
||||
let root = find(&mut parent, i);
|
||||
match representative.get(&root).copied() {
|
||||
Some(current) if !is_raw(current) || is_raw(i) => {}
|
||||
_ => {
|
||||
representative.insert(root, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
(0..entries.len())
|
||||
.map(|i| {
|
||||
let root = find(&mut parent, i);
|
||||
entries[representative[&root]].uid.clone()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The columns every photo read selects, with the group aggregates joined in.
|
||||
/// `g.n` is how many photos the group holds and `g.raw` whether any of them is a
|
||||
/// camera raw file — one query rather than a second lookup per tile.
|
||||
///
|
||||
/// `COALESCE(group_key, uid)` is what makes a row written before schema v30 its
|
||||
/// own group: the column is `NULL` until the next refresh computes it, and a
|
||||
/// `NULL` would otherwise match nothing and drop the photo off the page.
|
||||
const PHOTO_SELECT: &str = "SELECT p.uid, p.capture_time, p.name, p.ratio, p.thumb_state, \
|
||||
p.kind, p.favorite, g.n, g.raw \
|
||||
FROM photos p JOIN (SELECT COALESCE(group_key, uid) AS k, COUNT(*) AS n, \
|
||||
MAX(kind = 2) AS raw FROM photos GROUP BY k) g ON g.k = COALESCE(p.group_key, p.uid)";
|
||||
|
||||
/// Read one row of [`PHOTO_SELECT`].
|
||||
fn stored_photo(r: &rusqlite::Row<'_>) -> rusqlite::Result<StoredPhoto> {
|
||||
Ok(StoredPhoto {
|
||||
uid: r.get(0)?,
|
||||
capture_time: r.get(1)?,
|
||||
name: r.get(2)?,
|
||||
ratio: r.get(3)?,
|
||||
thumb_state: r.get(4)?,
|
||||
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
||||
favorite: r.get::<_, i64>(6)? != 0,
|
||||
group_size: r.get::<_, i64>(7)?.max(1) as usize,
|
||||
has_raw: r.get::<_, i64>(8)? != 0,
|
||||
})
|
||||
}
|
||||
|
||||
impl Db {
|
||||
pub fn photos_replace(
|
||||
&self,
|
||||
items: &[(String, i64, Option<String>, Option<String>)],
|
||||
) -> Result<()> {
|
||||
/// Replace the timeline wholesale. A row whose `resolved_at` is `Some` was
|
||||
/// read from the server in this pass, so its fields are taken as they are —
|
||||
/// a `None` there means the server no longer reports that value. A row whose
|
||||
/// `resolved_at` is `None` was skipped or failed to resolve, and keeps
|
||||
/// whatever is already stored rather than having it silently cleared.
|
||||
pub fn photos_replace(&self, items: &[TimelineRow]) -> Result<()> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
// `media_type` is learned-and-kept like the ratio and thumb verdict: the
|
||||
// timeline DTO carries only the uid and capture time, so the daemon may
|
||||
// not know a photo's media type yet when it replaces the timeline. Keep
|
||||
// any previously learned value so the Photos/Videos/Raw split survives a
|
||||
// refresh instead of collapsing back to name-extension guesses.
|
||||
let learned: HashMap<String, (Option<f64>, i64, Option<String>)> = {
|
||||
let mut stmt = tx.prepare("SELECT uid, ratio, thumb_state, media_type FROM photos")?;
|
||||
// `name` and `media_type` are learned-and-kept like the ratio and thumb
|
||||
// verdict: the timeline DTO carries only the uid and capture time, so the
|
||||
// daemon may not know them when it replaces the timeline — because the
|
||||
// refresh skipped an already-resolved photo, or because resolving it
|
||||
// failed. Keep any previously learned value so the tab split and the
|
||||
// RAW+JPEG grouping survive a refresh instead of collapsing to nothing:
|
||||
// both are derived from the name. The photo relation is kept for the same
|
||||
// reason — losing it would break a group up until the next resolve.
|
||||
let learned: HashMap<String, Learned> = {
|
||||
let mut stmt = tx.prepare(
|
||||
"SELECT uid, name, ratio, thumb_state, media_type, favorite, content_hash, \
|
||||
main_uid, resolved_at, latitude, longitude, similar_hash FROM photos",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok((r.get::<_, String>(0)?, (r.get(1)?, r.get(2)?, r.get(3)?)))
|
||||
Ok((
|
||||
r.get::<_, String>(0)?,
|
||||
Learned {
|
||||
name: r.get(1)?,
|
||||
ratio: r.get(2)?,
|
||||
thumb_state: r.get(3)?,
|
||||
media_type: r.get(4)?,
|
||||
favorite: r.get::<_, i64>(5)? != 0,
|
||||
content_hash: r.get(6)?,
|
||||
main_uid: r.get(7)?,
|
||||
resolved_at: r.get(8)?,
|
||||
location: r
|
||||
.get::<_, Option<f64>>(9)?
|
||||
.zip(r.get::<_, Option<f64>>(10)?),
|
||||
similar_hash: r.get(11)?,
|
||||
},
|
||||
))
|
||||
})?;
|
||||
rows.collect::<rusqlite::Result<_>>()?
|
||||
};
|
||||
|
||||
// Everything the insert and the grouping need, resolved once per photo.
|
||||
let resolved: Vec<(&TimelineRow, Learned, crate::control::PhotoKind)> = items
|
||||
.iter()
|
||||
.map(|row| {
|
||||
let mut carried = learned.get(&row.uid).cloned().unwrap_or(Learned {
|
||||
thumb_state: THUMB_UNKNOWN,
|
||||
..Learned::default()
|
||||
});
|
||||
if row.resolved_at.is_some() {
|
||||
// This pass read the node itself, so what it says is the
|
||||
// truth — including what it no longer says. A photo unlinked
|
||||
// from its main on another device loses its `main_uid` here,
|
||||
// which a carry could never express.
|
||||
carried.name = row.name.clone();
|
||||
carried.media_type = row.media_type.clone();
|
||||
carried.favorite = row.favorite.unwrap_or(false);
|
||||
// New bytes may show another picture: hash it again.
|
||||
if row.content_hash != carried.content_hash {
|
||||
carried.similar_hash = None;
|
||||
}
|
||||
carried.content_hash = row.content_hash.clone();
|
||||
carried.main_uid = row.main_uid.clone();
|
||||
carried.location = row.location;
|
||||
} else {
|
||||
carried.name = row.name.clone().or(carried.name);
|
||||
carried.media_type = row.media_type.clone().or(carried.media_type);
|
||||
carried.favorite = row.favorite.unwrap_or(carried.favorite);
|
||||
carried.content_hash = row.content_hash.clone().or(carried.content_hash);
|
||||
carried.main_uid = row.main_uid.clone().or(carried.main_uid);
|
||||
carried.location = row.location.or(carried.location);
|
||||
}
|
||||
carried.resolved_at = row.resolved_at.or(carried.resolved_at);
|
||||
// The tab this photo lands in is derived here, once, so a page or
|
||||
// count query is a plain indexed `WHERE kind = ?` rather than a
|
||||
// reclassification of every row.
|
||||
let kind = crate::control::PhotoKind::classify(
|
||||
carried.name.as_deref(),
|
||||
carried.media_type.as_deref(),
|
||||
);
|
||||
(row, carried, kind)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let entries: Vec<Grouped> = resolved
|
||||
.iter()
|
||||
.map(|(row, carried, kind)| Grouped {
|
||||
uid: row.uid.clone(),
|
||||
kind: *kind,
|
||||
main_uid: carried.main_uid.clone(),
|
||||
day: capture_day(&tx, row.capture_time),
|
||||
stem: name_stem(carried.name.as_deref()),
|
||||
})
|
||||
.collect();
|
||||
let group_keys = group_photos(&entries);
|
||||
|
||||
tx.execute("DELETE FROM photos", [])?;
|
||||
{
|
||||
let mut stmt = tx.prepare(
|
||||
"INSERT INTO photos
|
||||
(uid, capture_time, name, ratio, thumb_state, seq, media_type, kind)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
(uid, capture_time, name, ratio, thumb_state, seq, media_type, kind, favorite,
|
||||
content_hash, main_uid, group_key, resolved_at, latitude, longitude,
|
||||
place_id, similar_hash)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15,
|
||||
?16, ?17)",
|
||||
)?;
|
||||
for (seq, (uid, capture_time, name, media_type)) in items.iter().enumerate() {
|
||||
let (ratio, thumb_state, learned_media) =
|
||||
learned
|
||||
.get(uid)
|
||||
.cloned()
|
||||
.unwrap_or((None, THUMB_UNKNOWN, None));
|
||||
let media_type = media_type.clone().or(learned_media);
|
||||
// The tab this photo lands in is derived here, once, so a page or
|
||||
// count query is a plain indexed `WHERE kind = ?` rather than a
|
||||
// reclassification of every row.
|
||||
let kind =
|
||||
crate::control::PhotoKind::classify(name.as_deref(), media_type.as_deref());
|
||||
for (seq, ((row, carried, kind), group_key)) in
|
||||
resolved.iter().zip(group_keys.iter()).enumerate()
|
||||
{
|
||||
stmt.execute(params![
|
||||
uid,
|
||||
capture_time,
|
||||
name,
|
||||
ratio,
|
||||
thumb_state,
|
||||
row.uid,
|
||||
row.capture_time,
|
||||
carried.name,
|
||||
carried.ratio,
|
||||
carried.thumb_state,
|
||||
seq as i64,
|
||||
media_type,
|
||||
carried.media_type,
|
||||
kind.as_i64(),
|
||||
carried.favorite as i64,
|
||||
carried.content_hash,
|
||||
carried.main_uid,
|
||||
group_key,
|
||||
carried.resolved_at,
|
||||
carried.location.map(|(lat, _)| lat),
|
||||
carried.location.map(|(_, lon)| lon),
|
||||
carried
|
||||
.location
|
||||
.and_then(|(lat, lon)| crate::places::nearest(lat, lon))
|
||||
.map(|city| city.id),
|
||||
carried.similar_hash,
|
||||
])?;
|
||||
}
|
||||
}
|
||||
@@ -89,33 +415,132 @@ impl Db {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The uids whose node metadata is already resolved, so a refresh can skip
|
||||
/// reading their nodes again. Everything else — a photo never seen before, or
|
||||
/// one a remote event marked stale — is resolved. That is what keeps a
|
||||
/// 12k-photo library off 63 round-trips of `enumerate_nodes` every refresh.
|
||||
pub fn photos_resolved(&self) -> Result<Vec<String>> {
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare("SELECT uid FROM photos WHERE resolved_at IS NOT NULL")?;
|
||||
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
|
||||
let mut uids = Vec::new();
|
||||
for row in rows {
|
||||
uids.push(row?);
|
||||
}
|
||||
Ok(uids)
|
||||
}
|
||||
|
||||
/// Mark photos as needing a fresh metadata resolve, and report how many rows
|
||||
/// that matched. This is how a remote change is recorded: the event feed says
|
||||
/// *which* node changed but never *what* changed about it, so the uid is
|
||||
/// written down and the next refresh reads the node.
|
||||
pub fn photos_mark_unresolved(&self, uids: &[String]) -> Result<usize> {
|
||||
if uids.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
let mut marked = 0;
|
||||
{
|
||||
let mut stmt = tx.prepare("UPDATE photos SET resolved_at = NULL WHERE uid = ?1")?;
|
||||
for uid in uids {
|
||||
marked += stmt.execute(params![uid])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(marked)
|
||||
}
|
||||
|
||||
/// The same for the whole timeline: every photo is resolved again on the next
|
||||
/// refresh. For a continuity loss, where the daemon cannot know what it
|
||||
/// missed, and for an explicit `refresh photos --full`.
|
||||
pub fn photos_mark_all_unresolved(&self) -> Result<usize> {
|
||||
let conn = self.conn.lock();
|
||||
Ok(conn.execute("UPDATE photos SET resolved_at = NULL", [])?)
|
||||
}
|
||||
|
||||
/// Forget photos that have been trashed, so the gallery does not show them
|
||||
/// until the next timeline refresh would have caught up.
|
||||
///
|
||||
/// Album membership goes with them: a trashed photo is gone from every album
|
||||
/// that held it, and leaving the rows behind would keep it on the album
|
||||
/// pages and in the album covers.
|
||||
pub fn photos_delete(&self, uids: &[String]) -> Result<usize> {
|
||||
if uids.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
let mut removed = 0;
|
||||
{
|
||||
let mut photos = tx.prepare("DELETE FROM photos WHERE uid = ?1")?;
|
||||
let mut albums = tx.prepare("DELETE FROM album_photos WHERE uid = ?1")?;
|
||||
for uid in uids {
|
||||
removed += photos.execute([uid])?;
|
||||
albums.execute([uid])?;
|
||||
}
|
||||
}
|
||||
// A group whose representative went is left pointing at nothing, which
|
||||
// drops its other files off every page. Make them stand alone until the
|
||||
// next refresh groups them again.
|
||||
tx.execute(
|
||||
"UPDATE photos SET group_key = NULL WHERE group_key IS NOT NULL \
|
||||
AND group_key NOT IN (SELECT uid FROM photos)",
|
||||
[],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
/// One page of the persisted timeline, newest first. `kind`, when set,
|
||||
/// restricts the page to one tab (Photos / Videos / Raw); `range`, when set,
|
||||
/// restricts it to a `[from, to)` capture-time window (epoch seconds) — the
|
||||
/// date scrubber's jump. `offset` is relative to whatever the filters leave.
|
||||
/// `favorites` restricts the page to favorited photos, and `not_in_album`
|
||||
/// to the shots no album holds any file of.
|
||||
///
|
||||
/// The page holds one row per *group*: the RAW and the JPEG of one shot are
|
||||
/// one tile. The Raw tab is the exception and lists every raw file, because
|
||||
/// that tab exists to answer "which raws do I have".
|
||||
pub fn photos_page(
|
||||
&self,
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
kind: Option<crate::control::PhotoKind>,
|
||||
range: Option<(i64, i64)>,
|
||||
favorites: bool,
|
||||
not_in_album: bool,
|
||||
) -> Result<Vec<StoredPhoto>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
// Built up so any combination of the optional filters is one indexed
|
||||
// query rather than a statement per case.
|
||||
let mut sql =
|
||||
String::from("SELECT uid, capture_time, name, ratio, thumb_state, kind FROM photos");
|
||||
let mut sql = String::from(PHOTO_SELECT);
|
||||
let mut binds: Vec<i64> = Vec::new();
|
||||
let mut conds: Vec<String> = Vec::new();
|
||||
if kind != Some(crate::control::PhotoKind::Raw) {
|
||||
conds.push("p.uid = COALESCE(p.group_key, p.uid)".to_string());
|
||||
}
|
||||
if let Some(k) = kind {
|
||||
binds.push(k.as_i64());
|
||||
conds.push(format!("kind = ?{}", binds.len()));
|
||||
conds.push(format!("p.kind = ?{}", binds.len()));
|
||||
}
|
||||
if let Some((from, to)) = range {
|
||||
binds.push(from);
|
||||
conds.push(format!("capture_time >= ?{}", binds.len()));
|
||||
conds.push(format!("p.capture_time >= ?{}", binds.len()));
|
||||
binds.push(to);
|
||||
conds.push(format!("capture_time < ?{}", binds.len()));
|
||||
conds.push(format!("p.capture_time < ?{}", binds.len()));
|
||||
}
|
||||
if favorites {
|
||||
conds.push("p.favorite = 1".to_string());
|
||||
}
|
||||
// By group, not by file: a shot filed as its JPEG is filed, even though
|
||||
// its RAW was never added anywhere.
|
||||
if not_in_album {
|
||||
conds.push(
|
||||
"NOT EXISTS (SELECT 1 FROM album_photos a JOIN photos m ON m.uid = a.uid \
|
||||
WHERE COALESCE(m.group_key, m.uid) = COALESCE(p.group_key, p.uid))"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if !conds.is_empty() {
|
||||
sql.push_str(" WHERE ");
|
||||
@@ -126,21 +551,41 @@ impl Db {
|
||||
binds.push(offset as i64);
|
||||
let offset_pos = binds.len();
|
||||
sql.push_str(&format!(
|
||||
" ORDER BY seq LIMIT ?{limit_pos} OFFSET ?{offset_pos}"
|
||||
" ORDER BY p.seq LIMIT ?{limit_pos} OFFSET ?{offset_pos}"
|
||||
));
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt
|
||||
.query_map(rusqlite::params_from_iter(binds), |r| {
|
||||
Ok(StoredPhoto {
|
||||
uid: r.get(0)?,
|
||||
capture_time: r.get(1)?,
|
||||
name: r.get(2)?,
|
||||
ratio: r.get(3)?,
|
||||
thumb_state: r.get(4)?,
|
||||
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
||||
})
|
||||
})?
|
||||
.query_map(rusqlite::params_from_iter(binds), stored_photo)?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Every photo of one photo's group, in server order — the RAW and the JPEG
|
||||
/// of a shot, or just the photo itself when it stands alone. The lightbox
|
||||
/// switches between these, and deleting a tile trashes all of them.
|
||||
pub fn photos_group(&self, uid: &str) -> Result<Vec<StoredPhoto>> {
|
||||
let conn = self.read();
|
||||
let sql = format!(
|
||||
"{PHOTO_SELECT} WHERE COALESCE(p.group_key, p.uid) = \
|
||||
(SELECT COALESCE(group_key, uid) FROM photos WHERE uid = ?1) ORDER BY p.seq"
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt
|
||||
.query_map(params![uid], stored_photo)?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// The whole persisted timeline, in stored order — every file, not one row
|
||||
/// per group. Only for whole-library passes (the photo re-date); the gallery
|
||||
/// pages with [`Db::photos_page`].
|
||||
pub fn photos_all(&self) -> Result<Vec<StoredPhoto>> {
|
||||
let conn = self.read();
|
||||
let sql = format!("{PHOTO_SELECT} ORDER BY p.seq");
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt
|
||||
.query_map([], stored_photo)?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
@@ -154,10 +599,16 @@ impl Db {
|
||||
&self,
|
||||
kind: Option<crate::control::PhotoKind>,
|
||||
) -> Result<Vec<(i32, i32, usize)>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
// The scrubber has to agree with the page it scrubs, so it counts what
|
||||
// the page shows: groups everywhere except on the Raw tab.
|
||||
let (filter, binds): (&str, Vec<i64>) = match kind {
|
||||
Some(k) => (" WHERE kind = ?1", vec![k.as_i64()]),
|
||||
None => ("", Vec::new()),
|
||||
Some(crate::control::PhotoKind::Raw) => (" WHERE kind = 2", Vec::new()),
|
||||
Some(k) => (
|
||||
" WHERE uid = COALESCE(group_key, uid) AND kind = ?1",
|
||||
vec![k.as_i64()],
|
||||
),
|
||||
None => (" WHERE uid = COALESCE(group_key, uid)", Vec::new()),
|
||||
};
|
||||
let sql = format!(
|
||||
"SELECT CAST(strftime('%Y', capture_time, 'unixepoch', 'localtime') AS INTEGER) AS y, \
|
||||
@@ -178,11 +629,267 @@ impl Db {
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Photos taken on `today`'s calendar day in earlier years, newest first —
|
||||
/// the "On this day" strip. `today` is epoch seconds: its local month and day
|
||||
/// pick the date, and its own year is left out, because today's photos are
|
||||
/// what the timeline already opens on. One row per group, like the timeline.
|
||||
pub fn photos_on_this_day(&self, today: i64, limit: usize) -> Result<Vec<StoredPhoto>> {
|
||||
let conn = self.read();
|
||||
let sql = format!(
|
||||
"{PHOTO_SELECT} WHERE p.uid = COALESCE(p.group_key, p.uid) \
|
||||
AND strftime('%m-%d', p.capture_time, 'unixepoch', 'localtime') \
|
||||
= strftime('%m-%d', ?1, 'unixepoch', 'localtime') \
|
||||
AND strftime('%Y', p.capture_time, 'unixepoch', 'localtime') \
|
||||
< strftime('%Y', ?1, 'unixepoch', 'localtime') \
|
||||
ORDER BY p.seq LIMIT ?2"
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt
|
||||
.query_map(params![today, limit as i64], stored_photo)?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Every town photos were taken in, with how many and the newest one as its
|
||||
/// cover, most photographed first. Counted per group, like the timeline;
|
||||
/// photos without a location, or taken far from any town, are in none.
|
||||
pub fn photo_places(&self) -> Result<Vec<StoredPlace>> {
|
||||
let rows: Vec<(u32, usize, String)> = {
|
||||
let conn = self.read();
|
||||
// SQLite fills the bare `uid` from the row `MIN(seq)` picked, which
|
||||
// is the newest photo of the place.
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT place_id, COUNT(*), uid, MIN(seq) FROM photos \
|
||||
WHERE uid = COALESCE(group_key, uid) AND place_id IS NOT NULL \
|
||||
GROUP BY place_id ORDER BY COUNT(*) DESC, MIN(seq)",
|
||||
)?;
|
||||
stmt.query_map([], |r| {
|
||||
Ok((r.get(0)?, r.get::<_, i64>(1)? as usize, r.get(2)?))
|
||||
})?
|
||||
.collect::<rusqlite::Result<_>>()?
|
||||
};
|
||||
let uids: Vec<String> = rows.iter().map(|(_, _, uid)| uid.clone()).collect();
|
||||
let mut covers: HashMap<String, StoredPhoto> = self
|
||||
.photos_by_uid(&uids)?
|
||||
.into_iter()
|
||||
.map(|photo| (photo.uid.clone(), photo))
|
||||
.collect();
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.filter_map(|(id, count, uid)| {
|
||||
Some(StoredPlace {
|
||||
id,
|
||||
count,
|
||||
cover: covers.remove(&uid)?,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// One page of the photos taken in a town, newest first, one per group.
|
||||
pub fn photos_at_place(
|
||||
&self,
|
||||
id: u32,
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
) -> Result<Vec<StoredPhoto>> {
|
||||
let conn = self.read();
|
||||
let sql = format!(
|
||||
"{PHOTO_SELECT} WHERE p.uid = COALESCE(p.group_key, p.uid) AND p.place_id = ?1 \
|
||||
ORDER BY p.seq LIMIT ?2 OFFSET ?3"
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt
|
||||
.query_map(params![id, limit as i64, offset as i64], stored_photo)?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Every photo taken in a town, one per group as in [`Self::photos_at_place`],
|
||||
/// with where exactly it was taken. A group's own location is that of the
|
||||
/// photo standing for it.
|
||||
pub fn photo_locations_at_place(&self, id: u32) -> Result<Vec<(StoredPhoto, (f64, f64))>> {
|
||||
let photos = self.photos_at_place(id, 0, usize::MAX >> 1)?;
|
||||
let located: HashMap<String, (f64, f64)> = {
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT uid, latitude, longitude FROM photos \
|
||||
WHERE place_id = ?1 AND latitude IS NOT NULL AND longitude IS NOT NULL",
|
||||
)?;
|
||||
stmt.query_map(params![id], |r| Ok((r.get(0)?, (r.get(1)?, r.get(2)?))))?
|
||||
.collect::<rusqlite::Result<_>>()?
|
||||
};
|
||||
Ok(photos
|
||||
.into_iter()
|
||||
.filter_map(|photo| {
|
||||
let at = *located.get(&photo.uid)?;
|
||||
Some((photo, at))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Sets of photos with the same content hash, each set ordered with the
|
||||
/// copy worth keeping first, and the sets newest first.
|
||||
///
|
||||
/// The copy to keep is the one in the most albums, then the one that is part
|
||||
/// of a larger group (the JPEG's RAW is filed with it, a stray re-upload is
|
||||
/// not), then the earliest in server order. At most one file per group is
|
||||
/// listed: the files of one RAW+JPEG shot are not copies of each other, and a
|
||||
/// set left with one member is no set.
|
||||
pub fn photo_duplicates(&self) -> Result<Vec<Vec<StoredPhoto>>> {
|
||||
let rows: Vec<(String, String, String)> = {
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT p.uid, p.content_hash, COALESCE(p.group_key, p.uid) AS k FROM photos p \
|
||||
WHERE p.content_hash IN (SELECT content_hash FROM photos \
|
||||
WHERE content_hash IS NOT NULL GROUP BY content_hash HAVING COUNT(*) > 1) \
|
||||
ORDER BY p.content_hash, \
|
||||
(SELECT COUNT(*) FROM album_photos a WHERE a.uid = p.uid) DESC, \
|
||||
(SELECT COUNT(*) FROM photos m WHERE COALESCE(m.group_key, m.uid) = COALESCE(p.group_key, p.uid)) DESC, \
|
||||
p.seq",
|
||||
)?;
|
||||
stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?
|
||||
.collect::<rusqlite::Result<_>>()?
|
||||
};
|
||||
let uids: Vec<String> = rows.iter().map(|(uid, _, _)| uid.clone()).collect();
|
||||
let mut stored: HashMap<String, StoredPhoto> = self
|
||||
.photos_by_uid(&uids)?
|
||||
.into_iter()
|
||||
.map(|photo| (photo.uid.clone(), photo))
|
||||
.collect();
|
||||
|
||||
let mut sets: Vec<Vec<StoredPhoto>> = Vec::new();
|
||||
let mut hash_of_set: Option<&str> = None;
|
||||
let mut groups_in_set: HashSet<&str> = HashSet::new();
|
||||
for (uid, hash, group) in &rows {
|
||||
if hash_of_set != Some(hash.as_str()) {
|
||||
hash_of_set = Some(hash);
|
||||
groups_in_set.clear();
|
||||
sets.push(Vec::new());
|
||||
}
|
||||
if !groups_in_set.insert(group) {
|
||||
continue;
|
||||
}
|
||||
if let (Some(set), Some(photo)) = (sets.last_mut(), stored.remove(uid)) {
|
||||
set.push(photo);
|
||||
}
|
||||
}
|
||||
sets.retain(|set| set.len() > 1);
|
||||
sets.sort_by_key(|set| std::cmp::Reverse(set.iter().map(|p| p.capture_time).max()));
|
||||
Ok(sets)
|
||||
}
|
||||
|
||||
/// Shots whose look hasn't been hashed yet, by the uid of the photo the
|
||||
/// grid shows for each, newest first, at most `limit`. Videos, and photos
|
||||
/// with no thumbnail to hash, are left out. A shot counts as hashed once
|
||||
/// any of its files is, since they all show one picture.
|
||||
pub fn photos_unhashed(&self, limit: usize) -> Result<Vec<StoredPhoto>> {
|
||||
let uids: Vec<String> = {
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT COALESCE(group_key, uid) AS k FROM photos \
|
||||
GROUP BY k \
|
||||
HAVING MAX(similar_hash) IS NULL \
|
||||
AND MAX(kind != ?1 AND thumb_state != ?2) \
|
||||
ORDER BY MAX(capture_time) DESC LIMIT ?3",
|
||||
)?;
|
||||
stmt.query_map(
|
||||
params![
|
||||
crate::control::PhotoKind::Video.as_i64(),
|
||||
THUMB_NONE,
|
||||
limit as i64
|
||||
],
|
||||
|r| r.get(0),
|
||||
)?
|
||||
.collect::<rusqlite::Result<_>>()?
|
||||
};
|
||||
self.photos_by_uid(&uids)
|
||||
}
|
||||
|
||||
/// Record the look of photo `uid`: its difference hash, or
|
||||
/// [`crate::similar::NO_HASH`] when its thumbnail gave none.
|
||||
pub fn photo_set_similar_hash(&self, uid: &str, hash: u64) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"UPDATE photos SET similar_hash = ?2 WHERE uid = ?1",
|
||||
params![uid, hash as i64],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// How many shots have been hashed, out of how many could be.
|
||||
pub fn similar_progress(&self) -> Result<(usize, usize)> {
|
||||
let conn = self.read();
|
||||
let (hashed, total): (i64, i64) = conn.query_row(
|
||||
"SELECT COUNT(*) FILTER (WHERE hashed), COUNT(*) FROM \
|
||||
(SELECT MAX(similar_hash) IS NOT NULL AS hashed FROM photos \
|
||||
GROUP BY COALESCE(group_key, uid) \
|
||||
HAVING MAX(similar_hash) IS NOT NULL \
|
||||
OR MAX(kind != ?1 AND thumb_state != ?2))",
|
||||
params![crate::control::PhotoKind::Video.as_i64(), THUMB_NONE],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)?;
|
||||
Ok((hashed.max(0) as usize, total.max(0) as usize))
|
||||
}
|
||||
|
||||
/// Sets of shots that look alike (see [`crate::similar`]), one photo per
|
||||
/// shot in capture order, and the sets newest first. A set whose photos
|
||||
/// are all byte-identical copies is left to [`Self::photo_duplicates`].
|
||||
pub fn photo_similar(&self) -> Result<Vec<Vec<StoredPhoto>>> {
|
||||
let rows: Vec<(String, u64, Option<String>)> = {
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT COALESCE(group_key, uid) AS k, MAX(similar_hash), \
|
||||
MAX(content_hash) FILTER (WHERE uid = COALESCE(group_key, uid)) \
|
||||
FROM photos GROUP BY k \
|
||||
HAVING MAX(similar_hash) IS NOT NULL AND MAX(similar_hash) != ?1",
|
||||
)?;
|
||||
stmt.query_map(params![crate::similar::NO_HASH as i64], |r| {
|
||||
Ok((r.get(0)?, r.get::<_, i64>(1)? as u64, r.get(2)?))
|
||||
})?
|
||||
.collect::<rusqlite::Result<_>>()?
|
||||
};
|
||||
let hashes: Vec<u64> = rows.iter().map(|(_, hash, _)| *hash).collect();
|
||||
let found: Vec<Vec<usize>> = crate::similar::sets(&hashes)
|
||||
.into_iter()
|
||||
.filter(|set| {
|
||||
let first = &rows[set[0]].2;
|
||||
first.is_none() || set.iter().any(|&i| rows[i].2 != *first)
|
||||
})
|
||||
.collect();
|
||||
let uids: Vec<String> = found.iter().flatten().map(|&i| rows[i].0.clone()).collect();
|
||||
let mut stored: HashMap<String, StoredPhoto> = self
|
||||
.photos_by_uid(&uids)?
|
||||
.into_iter()
|
||||
.map(|photo| (photo.uid.clone(), photo))
|
||||
.collect();
|
||||
let mut sets: Vec<Vec<StoredPhoto>> = found
|
||||
.iter()
|
||||
.map(|set| {
|
||||
let mut photos: Vec<StoredPhoto> = set
|
||||
.iter()
|
||||
.filter_map(|&i| stored.remove(&rows[i].0))
|
||||
.collect();
|
||||
photos.sort_by_key(|p| p.capture_time);
|
||||
photos
|
||||
})
|
||||
.filter(|set| set.len() > 1)
|
||||
.collect();
|
||||
sets.sort_by_key(|set| std::cmp::Reverse(set.iter().map(|p| p.capture_time).max()));
|
||||
Ok(sets)
|
||||
}
|
||||
|
||||
/// Per-tab counts for the Photos page subtitle: `(photos, videos, raw)`.
|
||||
pub fn photos_counts(&self) -> Result<(usize, usize, usize)> {
|
||||
use crate::control::PhotoKind;
|
||||
let conn = self.conn.lock();
|
||||
let mut stmt = conn.prepare("SELECT kind, COUNT(*) FROM photos GROUP BY kind")?;
|
||||
let conn = self.read();
|
||||
// Photos and Videos count groups — one shot is one entry, however many
|
||||
// files it was stored as. Raw counts files, because the Raw tab lists
|
||||
// them individually.
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT kind, COUNT(*) FROM photos \
|
||||
WHERE uid = COALESCE(group_key, uid) OR kind = 2 GROUP BY kind",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?;
|
||||
let (mut photos, mut videos, mut raw) = (0usize, 0usize, 0usize);
|
||||
for row in rows {
|
||||
@@ -203,22 +910,11 @@ impl Db {
|
||||
if uids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let placeholders = vec!["?"; uids.len()].join(",");
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT uid, capture_time, name, ratio, thumb_state, kind FROM photos
|
||||
WHERE uid IN ({placeholders})"
|
||||
))?;
|
||||
let rows = stmt.query_map(rusqlite::params_from_iter(uids), |r| {
|
||||
Ok(StoredPhoto {
|
||||
uid: r.get(0)?,
|
||||
capture_time: r.get(1)?,
|
||||
name: r.get(2)?,
|
||||
ratio: r.get(3)?,
|
||||
thumb_state: r.get(4)?,
|
||||
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
||||
})
|
||||
})?;
|
||||
let sql = format!("{PHOTO_SELECT} WHERE p.uid IN ({placeholders})");
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(rusqlite::params_from_iter(uids), stored_photo)?;
|
||||
let mut photos = Vec::new();
|
||||
for row in rows {
|
||||
photos.push(row?);
|
||||
@@ -226,6 +922,18 @@ impl Db {
|
||||
Ok(photos)
|
||||
}
|
||||
|
||||
/// Record a photo's favorite flag locally, after the server accepted the
|
||||
/// change. A uid the timeline does not hold is a no-op — an album photo on
|
||||
/// someone else's volume is never in our own `photos` table.
|
||||
pub fn photos_set_favorite(&self, uid: &str, favorite: bool) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"UPDATE photos SET favorite = ?2 WHERE uid = ?1",
|
||||
params![uid, favorite as i64],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record what a thumbnail attempt learned: whether the photo now has one
|
||||
/// ([`THUMB_HAVE`] / [`THUMB_NONE`]), and its aspect ratio if the pixels were
|
||||
/// seen. A `None` ratio leaves any previously learned one alone.
|
||||
@@ -240,7 +948,7 @@ impl Db {
|
||||
|
||||
/// Number of photos in the persisted timeline.
|
||||
pub fn photos_count(&self) -> Result<usize> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let n: i64 = conn.query_row("SELECT COUNT(*) FROM photos", [], |r| r.get(0))?;
|
||||
Ok(n.max(0) as usize)
|
||||
}
|
||||
|
||||
@@ -4,8 +4,21 @@
|
||||
use rusqlite::{OptionalExtension, params};
|
||||
|
||||
use super::Db;
|
||||
use super::utils::MAX_PATH_DEPTH;
|
||||
use crate::Result;
|
||||
|
||||
/// One row of [`Db::pin_list`]. `recursive` is pin *policy* — whether the whole
|
||||
/// subtree is kept — while `is_dir` is the node's kind as the node cache knows
|
||||
/// it, or `None` for a pin whose node is not cached. Reading the former as the
|
||||
/// latter is what made a non-recursively pinned folder render as a file.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PinRow {
|
||||
pub uid: String,
|
||||
pub path: String,
|
||||
pub recursive: bool,
|
||||
pub is_dir: Option<bool>,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
pub fn pin_add(&self, uid: &str, path: &str, recursive: bool) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
@@ -25,16 +38,23 @@ impl Db {
|
||||
Ok(n > 0)
|
||||
}
|
||||
|
||||
/// Every directly-pinned entry `(uid, path, recursive)`, ordered by uid.
|
||||
pub fn pin_list(&self) -> Result<Vec<(String, String, bool)>> {
|
||||
let conn = self.conn.lock();
|
||||
let mut stmt = conn.prepare("SELECT uid, path, recursive FROM pins ORDER BY uid")?;
|
||||
/// Every directly-pinned entry, ordered by uid.
|
||||
pub fn pin_list(&self) -> Result<Vec<PinRow>> {
|
||||
let conn = self.read();
|
||||
// `recursive` is pin policy; the node's kind comes from `nodes`, and is
|
||||
// NULL for a pin whose node was never cached.
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT p.uid, p.path, p.recursive, n.is_dir
|
||||
FROM pins p LEFT JOIN nodes n ON n.uid = p.uid
|
||||
ORDER BY p.uid",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, String>(0)?,
|
||||
r.get::<_, String>(1)?,
|
||||
r.get::<_, i64>(2)? != 0,
|
||||
))
|
||||
Ok(PinRow {
|
||||
uid: r.get(0)?,
|
||||
path: r.get(1)?,
|
||||
recursive: r.get::<_, i64>(2)? != 0,
|
||||
is_dir: r.get::<_, Option<i64>>(3)?.map(|is_dir| is_dir != 0),
|
||||
})
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
@@ -44,11 +64,16 @@ impl Db {
|
||||
}
|
||||
|
||||
/// Whether `uid` is pinned — directly, or because a strict ancestor folder
|
||||
/// is pinned recursively. The ancestor check walks `parent_uid` to the root
|
||||
/// is pinned recursively. The ancestor check walks `parent_lid` to the root
|
||||
/// via a CTE; a direct pin is honoured even when the node has no `nodes` row
|
||||
/// yet (e.g. a CLI that never hydrates the node cache).
|
||||
///
|
||||
/// The walk is depth-capped like [`super::utils::path_of`]: a parent
|
||||
/// cycle is corrupt data the API can hand us, and `UNION ALL` over one never
|
||||
/// terminates — while holding the daemon's only SQLite connection, on a path
|
||||
/// that runs per cached read.
|
||||
pub fn is_pinned(&self, uid: &str) -> Result<bool> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let direct: Option<i64> = conn
|
||||
.query_row("SELECT 1 FROM pins WHERE uid = ?1", params![uid], |r| {
|
||||
r.get(0)
|
||||
@@ -59,14 +84,18 @@ impl Db {
|
||||
}
|
||||
let anc: Option<i64> = conn
|
||||
.query_row(
|
||||
"WITH RECURSIVE anc(uid, parent_uid) AS (
|
||||
SELECT uid, parent_uid FROM nodes WHERE uid = ?1
|
||||
UNION ALL
|
||||
SELECT n.uid, n.parent_uid FROM nodes n JOIN anc ON n.uid = anc.parent_uid
|
||||
)
|
||||
SELECT 1 FROM anc a JOIN pins p ON p.uid = a.uid
|
||||
WHERE a.uid != ?1 AND p.recursive = 1
|
||||
LIMIT 1",
|
||||
&format!(
|
||||
"WITH RECURSIVE anc(uid, parent_lid, depth) AS (
|
||||
SELECT uid, parent_lid, 0 FROM nodes WHERE uid = ?1
|
||||
UNION ALL
|
||||
SELECT n.uid, n.parent_lid, anc.depth + 1
|
||||
FROM nodes n JOIN anc ON n.lid = anc.parent_lid
|
||||
WHERE anc.depth < {MAX_PATH_DEPTH}
|
||||
)
|
||||
SELECT 1 FROM anc a JOIN pins p ON p.uid = a.uid
|
||||
WHERE a.uid != ?1 AND p.recursive = 1
|
||||
LIMIT 1"
|
||||
),
|
||||
params![uid],
|
||||
|r| r.get(0),
|
||||
)
|
||||
@@ -78,12 +107,13 @@ impl Db {
|
||||
/// every descendant of a recursively-pinned folder. Used to build the
|
||||
/// eviction-exempt set (the budget enforcer hashes these into cache keys).
|
||||
pub fn pinned_uids(&self) -> Result<Vec<String>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"WITH RECURSIVE sub(uid) AS (
|
||||
SELECT uid FROM pins WHERE recursive = 1
|
||||
"WITH RECURSIVE sub(uid, lid) AS (
|
||||
SELECT p.uid, n.lid FROM pins p LEFT JOIN nodes n ON n.uid = p.uid
|
||||
WHERE p.recursive = 1
|
||||
UNION
|
||||
SELECT n.uid FROM nodes n JOIN sub ON n.parent_uid = sub.uid
|
||||
SELECT n.uid, n.lid FROM nodes n JOIN sub ON n.parent_lid = sub.lid
|
||||
)
|
||||
SELECT uid FROM sub
|
||||
UNION
|
||||
@@ -97,15 +127,16 @@ impl Db {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Every descendant uid of `folder` (all depths), via a `parent_uid` CTE.
|
||||
/// Every descendant uid of `folder` (all depths), via a `parent_lid` CTE.
|
||||
/// Used to evict a recursively-pinned subtree's cached blobs on unpin.
|
||||
pub fn descendants(&self, folder: &str) -> Result<Vec<String>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"WITH RECURSIVE sub(uid) AS (
|
||||
SELECT uid FROM nodes WHERE parent_uid = ?1
|
||||
"WITH RECURSIVE sub(uid, lid) AS (
|
||||
SELECT c.uid, c.lid FROM nodes c JOIN nodes f ON c.parent_lid = f.lid
|
||||
WHERE f.uid = ?1
|
||||
UNION
|
||||
SELECT n.uid FROM nodes n JOIN sub ON n.parent_uid = sub.uid
|
||||
SELECT n.uid, n.lid FROM nodes n JOIN sub ON n.parent_lid = sub.lid
|
||||
)
|
||||
SELECT uid FROM sub",
|
||||
)?;
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
//! Persisted effective access for roots shared with this account.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use proton_drive_rs::proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
||||
use rusqlite::{OptionalExtension, params};
|
||||
|
||||
use super::Db;
|
||||
use crate::{Access, Error, Result};
|
||||
|
||||
impl Db {
|
||||
/// Load the complete, deliberately small shared-root authority map.
|
||||
///
|
||||
/// Mount state keeps this resident so ordinary node interning and subtree
|
||||
/// propagation never turn into one SQLite query per inode. The database
|
||||
/// remains the restart/offline authority and every explicit role change is
|
||||
/// written through before this cache is updated.
|
||||
pub fn all_share_access(&self) -> Result<HashMap<NodeUid, Access>> {
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare("SELECT root_uid, access FROM share_access")?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||
})?;
|
||||
let mut access = HashMap::new();
|
||||
for row in rows {
|
||||
let (uid, value) = row?;
|
||||
let (volume, link) = uid.split_once('~').ok_or_else(|| {
|
||||
Error::Other(format!(
|
||||
"invalid shared-root uid {uid:?} stored in database"
|
||||
))
|
||||
})?;
|
||||
let access_value = Access::from_db_str(&value).ok_or_else(|| {
|
||||
Error::Other(format!("invalid share access {value:?} stored for {uid}"))
|
||||
})?;
|
||||
access.insert(
|
||||
NodeUid::new(VolumeId::from(volume), LinkId::from(link)),
|
||||
access_value,
|
||||
);
|
||||
}
|
||||
Ok(access)
|
||||
}
|
||||
|
||||
/// Read the effective access recorded for a shared-tree root.
|
||||
pub fn share_access(&self, uid: &NodeUid) -> Result<Option<Access>> {
|
||||
let conn = self.read();
|
||||
let value = conn
|
||||
.query_row(
|
||||
"SELECT access FROM share_access WHERE root_uid = ?1",
|
||||
[uid.to_string()],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.optional()?;
|
||||
value
|
||||
.map(|value| {
|
||||
Access::from_db_str(&value).ok_or_else(|| {
|
||||
Error::Other(format!("invalid share access {value:?} stored for {uid}"))
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
/// Insert or replace the effective access for a shared-tree root.
|
||||
pub fn set_share_access(&self, uid: &NodeUid, access: Access) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"INSERT INTO share_access (root_uid, access) VALUES (?1, ?2)
|
||||
ON CONFLICT(root_uid) DO UPDATE SET access = excluded.access",
|
||||
params![uid.to_string(), access.as_db_str()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Forget a shared-tree root's effective access.
|
||||
pub fn delete_share_access(&self, uid: &NodeUid) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"DELETE FROM share_access WHERE root_uid = ?1",
|
||||
[uid.to_string()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fail closed every persisted shared-tree authority.
|
||||
///
|
||||
/// Owned and device trees have no row in `share_access`, so this only
|
||||
/// affects roots already identified as shared with this account.
|
||||
pub fn downgrade_all_share_access(&self) -> Result<usize> {
|
||||
let conn = self.conn.lock();
|
||||
Ok(conn.execute(
|
||||
"UPDATE share_access SET access = 'viewer' WHERE access != 'viewer'",
|
||||
[],
|
||||
)?)
|
||||
}
|
||||
|
||||
/// Resolve a persisted node's effective access through its nearest recorded
|
||||
/// share root. `None` means the node itself is not persisted, which callers
|
||||
/// must not treat as owned: stale handles need to fail closed.
|
||||
///
|
||||
/// A stand-in ([`local_uid`](super::local_uid)) finds its row by local id,
|
||||
/// so it still does once its create has landed and the row has the uid
|
||||
/// Drive gave it. The tree learns that uid a moment later, and a rename in
|
||||
/// between was refused (`docs/BUGS.md` B174).
|
||||
pub fn effective_node_access(&self, uid: &NodeUid) -> Result<Option<Access>> {
|
||||
let conn = self.read();
|
||||
let key = uid.to_string();
|
||||
let key = match super::local_lid(&key) {
|
||||
Some(lid) => conn
|
||||
.query_row("SELECT uid FROM nodes WHERE lid = ?1", [lid], |row| {
|
||||
row.get::<_, Option<String>>(0)
|
||||
})
|
||||
.optional()?
|
||||
.flatten()
|
||||
.unwrap_or(key),
|
||||
None => key,
|
||||
};
|
||||
let exists: bool = conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM nodes WHERE uid = ?1 AND node_json IS NOT NULL)",
|
||||
[&key],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if !exists {
|
||||
return Ok(None);
|
||||
}
|
||||
let value = conn
|
||||
.query_row(
|
||||
"WITH RECURSIVE ancestors(uid, parent_uid, parent_lid, depth, path) AS (
|
||||
SELECT uid, parent_uid, parent_lid, 0, char(31) || uid || char(31)
|
||||
FROM nodes WHERE uid = ?1
|
||||
UNION ALL
|
||||
SELECT COALESCE(n.uid, a.parent_uid), n.parent_uid, n.parent_lid,
|
||||
a.depth + 1,
|
||||
a.path || COALESCE(n.uid, a.parent_uid) || char(31)
|
||||
FROM ancestors a
|
||||
LEFT JOIN nodes n ON n.lid = a.parent_lid
|
||||
WHERE a.parent_uid IS NOT NULL
|
||||
AND instr(
|
||||
a.path,
|
||||
char(31) || COALESCE(n.uid, a.parent_uid) || char(31)
|
||||
) = 0
|
||||
)
|
||||
SELECT sa.access
|
||||
FROM ancestors a JOIN share_access sa ON sa.root_uid = a.uid
|
||||
ORDER BY a.depth ASC
|
||||
LIMIT 1",
|
||||
[&key],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.optional()?;
|
||||
value
|
||||
.map(|value| {
|
||||
Access::from_db_str(&value).ok_or_else(|| {
|
||||
Error::Other(format!(
|
||||
"invalid effective share access {value:?} for {uid}"
|
||||
))
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
.map(|access| Some(access.unwrap_or(Access::Owner)))
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ use crate::Result;
|
||||
|
||||
impl Db {
|
||||
pub fn get_event_cursor(&self) -> Result<Option<String>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let v = conn
|
||||
.query_row(
|
||||
"SELECT value FROM sync_state WHERE key = 'event_cursor'",
|
||||
@@ -32,7 +32,7 @@ impl Db {
|
||||
|
||||
/// Read a `sync_state` value as a string.
|
||||
pub fn state_str(&self, key: &str) -> Result<Option<String>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let v = conn
|
||||
.query_row(
|
||||
"SELECT value FROM sync_state WHERE key = ?1",
|
||||
@@ -66,7 +66,7 @@ impl Db {
|
||||
/// dropping it would leave a file that exists nowhere but this machine with
|
||||
/// nothing left to create it. (Writes to a node that is itself still queued
|
||||
pub fn state_i64(&self, key: &str) -> Result<Option<i64>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let v: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT value FROM sync_state WHERE key = ?1",
|
||||
@@ -94,4 +94,26 @@ impl Db {
|
||||
conn.execute("DELETE FROM sync_state WHERE key = ?1", params![key])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop every `sync_state` key starting with `prefix` — the per-entity
|
||||
/// stamps (one album, one shared folder) that are keyed by uid and so have
|
||||
/// no fixed name to clear individually.
|
||||
pub fn clear_state_prefix(&self, prefix: &str) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
// `\` escapes the LIKE wildcards, so a prefix containing `%` or `_`
|
||||
// (a uid never does today, but this is not the place to rely on that)
|
||||
// matches literally instead of sweeping unrelated keys.
|
||||
let pattern = format!(
|
||||
"{}%",
|
||||
prefix
|
||||
.replace('\\', "\\\\")
|
||||
.replace('%', "\\%")
|
||||
.replace('_', "\\_")
|
||||
);
|
||||
conn.execute(
|
||||
"DELETE FROM sync_state WHERE key LIKE ?1 ESCAPE '\\'",
|
||||
params![pattern],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+162
-19
@@ -25,8 +25,8 @@ pub struct StoredSyncFolder {
|
||||
}
|
||||
|
||||
/// One per-file sync baseline row: what a path looked like on both sides at the
|
||||
/// last successful sync, so the next reconcile can tell which side changed
|
||||
/// (devices.md Phase 2). `remote_rev`/`remote_hash` hold the remote signature —
|
||||
/// last successful sync, so the next reconcile can tell which side changed.
|
||||
/// `remote_rev`/`remote_hash` hold the remote signature —
|
||||
/// its modification time and size as strings — since no cheap content hash is
|
||||
/// exposed; change detection is `(mtime, size)` on each side.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -35,6 +35,14 @@ pub struct StoredSyncEntry {
|
||||
pub remote_uid: Option<String>,
|
||||
pub local_mtime: i64,
|
||||
pub local_size: i64,
|
||||
/// The same modification time to nanosecond precision, when it is known.
|
||||
///
|
||||
/// `local_mtime` alone cannot tell an edit that landed inside the same
|
||||
/// second as the last sync — and left the file the same length — from no
|
||||
/// edit at all, so those never uploaded (`docs/BUGS.md` B25). `None` on a row
|
||||
/// written before schema 28, which the comparison reads as "compare whole
|
||||
/// seconds", so upgrading does not make an entire mirror look changed.
|
||||
pub local_mtime_ns: Option<i64>,
|
||||
pub remote_rev: Option<String>,
|
||||
pub remote_hash: Option<String>,
|
||||
}
|
||||
@@ -57,7 +65,7 @@ impl Db {
|
||||
|
||||
/// Every synced folder, oldest first.
|
||||
pub fn sync_folder_list(&self) -> Result<Vec<StoredSyncFolder>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync
|
||||
FROM sync_folder ORDER BY id",
|
||||
@@ -83,7 +91,7 @@ impl Db {
|
||||
|
||||
/// Look up one synced folder by id.
|
||||
pub fn sync_folder_get(&self, id: i64) -> Result<Option<StoredSyncFolder>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
conn.query_row(
|
||||
"SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync
|
||||
FROM sync_folder WHERE id = ?1",
|
||||
@@ -115,19 +123,33 @@ impl Db {
|
||||
Ok(n > 0)
|
||||
}
|
||||
|
||||
/// Update a synced folder's mode (`mirror`/`ondemand`). The folder has
|
||||
/// reached the mode it was asked for, so any queued request is satisfied and
|
||||
/// cleared in the same write — a `pending_mode` outliving the switch it asked
|
||||
/// for would have the engine try to apply it again on the next pass.
|
||||
/// Update a synced folder's mode (`mirror`/`ondemand`). A queued request for
|
||||
/// that mode is satisfied and cleared in the same write. An opposite pending
|
||||
/// mode arrived after this transition started, so preserve it as the newer
|
||||
/// last-request intent.
|
||||
pub fn sync_folder_set_mode(&self, id: i64, mode: &str) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"UPDATE sync_folder SET mode = ?2, pending_mode = NULL WHERE id = ?1",
|
||||
"UPDATE sync_folder
|
||||
SET mode = ?2,
|
||||
pending_mode = CASE WHEN pending_mode = ?2 THEN NULL ELSE pending_mode END
|
||||
WHERE id = ?1",
|
||||
params![id, mode],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Re-point a synced folder at the share that now holds its remote folder,
|
||||
/// after the folder moved to another device without changing its uid.
|
||||
pub fn sync_folder_set_share(&self, id: i64, share_id: &str) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"UPDATE sync_folder SET remote_share_id = ?2 WHERE id = ?1",
|
||||
params![id, share_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Queue (or, with `None`, withdraw) a mode the folder should move to once it
|
||||
/// is safe to switch.
|
||||
pub fn sync_folder_set_pending_mode(&self, id: i64, mode: Option<&str>) -> Result<()> {
|
||||
@@ -139,6 +161,18 @@ impl Db {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Withdraw a queued mode only if it is still the intent the caller
|
||||
/// inspected. A newer request must survive an older settlement attempt.
|
||||
pub fn sync_folder_clear_pending_mode_if(&self, id: i64, expected: &str) -> Result<bool> {
|
||||
let conn = self.conn.lock();
|
||||
let changed = conn.execute(
|
||||
"UPDATE sync_folder SET pending_mode = NULL
|
||||
WHERE id = ?1 AND pending_mode = ?2",
|
||||
params![id, expected],
|
||||
)?;
|
||||
Ok(changed > 0)
|
||||
}
|
||||
|
||||
/// Update a synced folder's state and stamp `last_sync` to now.
|
||||
pub fn sync_folder_set_state(&self, id: i64, state: &str, last_sync: i64) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
@@ -151,9 +185,10 @@ impl Db {
|
||||
|
||||
/// The whole per-file sync baseline for a folder, keyed by relative path.
|
||||
pub fn sync_entries(&self, folder_id: i64) -> Result<HashMap<String, StoredSyncEntry>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash
|
||||
"SELECT rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash,
|
||||
local_mtime_ns
|
||||
FROM sync_entry WHERE folder_id = ?1",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![folder_id], |r| {
|
||||
@@ -164,6 +199,7 @@ impl Db {
|
||||
local_size: r.get(3)?,
|
||||
remote_rev: r.get(4)?,
|
||||
remote_hash: r.get(5)?,
|
||||
local_mtime_ns: r.get(6)?,
|
||||
})
|
||||
})?;
|
||||
let mut map = HashMap::new();
|
||||
@@ -174,19 +210,43 @@ impl Db {
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
/// Whether `uid` is represented by a mirror location.
|
||||
///
|
||||
/// An on-demand location proves residency through its live inode state.
|
||||
/// Mirror locations have no FUSE state, so their folder root comes from
|
||||
/// `sync_folder` and descendants come from the last successful
|
||||
/// `sync_entry` baseline.
|
||||
pub fn mirror_contains_uid(&self, uid: &str) -> Result<bool> {
|
||||
let conn = self.read();
|
||||
conn.query_row(
|
||||
"SELECT EXISTS(
|
||||
SELECT 1
|
||||
FROM sync_folder AS folder
|
||||
LEFT JOIN sync_entry AS entry ON entry.folder_id = folder.id
|
||||
WHERE folder.mode = 'mirror'
|
||||
AND (folder.remote_uid = ?1 OR entry.remote_uid = ?1)
|
||||
)",
|
||||
params![uid],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Insert or replace one baseline row.
|
||||
pub fn sync_entry_upsert(&self, folder_id: i64, e: &StoredSyncEntry) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"INSERT INTO sync_entry
|
||||
(folder_id, rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||
(folder_id, rel_path, remote_uid, local_mtime, local_size, remote_rev,
|
||||
remote_hash, local_mtime_ns)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
||||
ON CONFLICT(folder_id, rel_path) DO UPDATE SET
|
||||
remote_uid = excluded.remote_uid,
|
||||
local_mtime = excluded.local_mtime,
|
||||
local_size = excluded.local_size,
|
||||
remote_rev = excluded.remote_rev,
|
||||
remote_hash = excluded.remote_hash",
|
||||
remote_uid = excluded.remote_uid,
|
||||
local_mtime = excluded.local_mtime,
|
||||
local_size = excluded.local_size,
|
||||
remote_rev = excluded.remote_rev,
|
||||
remote_hash = excluded.remote_hash,
|
||||
local_mtime_ns = excluded.local_mtime_ns",
|
||||
params![
|
||||
folder_id,
|
||||
e.rel_path,
|
||||
@@ -195,6 +255,7 @@ impl Db {
|
||||
e.local_size,
|
||||
e.remote_rev,
|
||||
e.remote_hash,
|
||||
e.local_mtime_ns,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
@@ -210,10 +271,92 @@ impl Db {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop the baseline rows of `rel_path` and everything under it.
|
||||
///
|
||||
/// Used when a subtree leaves a mirror folder through a move on Drive: its
|
||||
/// local copy is about to be removed, and rows left behind would read as
|
||||
/// "deleted locally" and trash the moved nodes at their new place.
|
||||
pub fn sync_entries_remove_subtree(&self, folder_id: i64, rel_path: &str) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"DELETE FROM sync_entry
|
||||
WHERE folder_id = ?1
|
||||
AND (rel_path = ?2 OR substr(rel_path, 1, length(?2) + 1) = ?2 || '/')",
|
||||
params![folder_id, rel_path],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Move the baseline rows of `from_rel` and everything under it to `to_rel`
|
||||
/// in folder `to_folder`, in one transaction.
|
||||
///
|
||||
/// Used when a move on Drive took a mirror subtree along with its local
|
||||
/// copy: the rows still describe both sides correctly, only under a new
|
||||
/// path, so the next pass on either folder has nothing to do. The two
|
||||
/// folders may be the same one.
|
||||
pub fn sync_entries_move(
|
||||
&self,
|
||||
from_folder: i64,
|
||||
from_rel: &str,
|
||||
to_folder: i64,
|
||||
to_rel: &str,
|
||||
) -> Result<()> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
{
|
||||
let mut select = tx.prepare(
|
||||
"SELECT rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash,
|
||||
local_mtime_ns
|
||||
FROM sync_entry
|
||||
WHERE folder_id = ?1
|
||||
AND (rel_path = ?2 OR substr(rel_path, 1, length(?2) + 1) = ?2 || '/')",
|
||||
)?;
|
||||
let rows = select
|
||||
.query_map(params![from_folder, from_rel], |r| {
|
||||
Ok(StoredSyncEntry {
|
||||
rel_path: r.get(0)?,
|
||||
remote_uid: r.get(1)?,
|
||||
local_mtime: r.get(2)?,
|
||||
local_size: r.get(3)?,
|
||||
remote_rev: r.get(4)?,
|
||||
remote_hash: r.get(5)?,
|
||||
local_mtime_ns: r.get(6)?,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
let mut delete =
|
||||
tx.prepare("DELETE FROM sync_entry WHERE folder_id = ?1 AND rel_path = ?2")?;
|
||||
for row in &rows {
|
||||
delete.execute(params![from_folder, row.rel_path])?;
|
||||
}
|
||||
let mut insert = tx.prepare(
|
||||
"INSERT OR REPLACE INTO sync_entry
|
||||
(folder_id, rel_path, remote_uid, local_mtime, local_size, remote_rev,
|
||||
remote_hash, local_mtime_ns)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
)?;
|
||||
for row in rows {
|
||||
let rel = format!("{to_rel}{}", &row.rel_path[from_rel.len()..]);
|
||||
insert.execute(params![
|
||||
to_folder,
|
||||
rel,
|
||||
row.remote_uid,
|
||||
row.local_mtime,
|
||||
row.local_size,
|
||||
row.remote_rev,
|
||||
row.remote_hash,
|
||||
row.local_mtime_ns,
|
||||
])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop the entire baseline for a folder. Used when flipping ondemand→mirror:
|
||||
/// the local tree was evicted, so the old baseline is stale and would make the
|
||||
/// next reconcile mistake "locally deleted" for "must re-download". Clearing it
|
||||
/// leaves an empty baseline + full remote = pure download (devices.md P3).
|
||||
/// leaves an empty baseline + full remote = pure download.
|
||||
pub fn sync_entries_clear(&self, folder_id: i64) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
|
||||
+5367
-23
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,7 @@
|
||||
//! Trashed nodes, persisted so the trash view opens without a round-trip.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use rusqlite::params;
|
||||
|
||||
use super::Db;
|
||||
@@ -11,6 +13,13 @@ pub struct StoredTrash {
|
||||
pub is_dir: bool,
|
||||
pub size: i64,
|
||||
pub mtime: i64,
|
||||
/// The folder this node was trashed from, when it is known. `None` for a
|
||||
/// row written before schema v29, and for a node whose parent the server
|
||||
/// did not report.
|
||||
pub parent_uid: Option<String>,
|
||||
/// When the daemon first saw the node in the trash, epoch seconds. `None`
|
||||
/// when it cannot say (see schema v35).
|
||||
pub trashed_at: Option<i64>,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
@@ -20,7 +29,8 @@ impl Db {
|
||||
tx.execute("DELETE FROM trash", [])?;
|
||||
{
|
||||
let mut stmt = tx.prepare(
|
||||
"INSERT INTO trash (uid, name, is_dir, size, mtime) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
"INSERT INTO trash (uid, name, is_dir, size, mtime, parent_uid, trashed_at) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
)?;
|
||||
for item in items {
|
||||
stmt.execute(params![
|
||||
@@ -28,7 +38,9 @@ impl Db {
|
||||
item.name,
|
||||
item.is_dir as i64,
|
||||
item.size,
|
||||
item.mtime
|
||||
item.mtime,
|
||||
item.parent_uid,
|
||||
item.trashed_at
|
||||
])?;
|
||||
}
|
||||
}
|
||||
@@ -39,9 +51,9 @@ impl Db {
|
||||
/// The persisted trash listing, folders first then by name — the order the
|
||||
/// Trash page shows it in.
|
||||
pub fn trash_list(&self) -> Result<Vec<StoredTrash>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT uid, name, is_dir, size, mtime FROM trash
|
||||
"SELECT uid, name, is_dir, size, mtime, parent_uid, trashed_at FROM trash
|
||||
ORDER BY is_dir DESC, name COLLATE NOCASE",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
@@ -51,6 +63,8 @@ impl Db {
|
||||
is_dir: r.get::<_, i64>(2)? != 0,
|
||||
size: r.get(3)?,
|
||||
mtime: r.get(4)?,
|
||||
parent_uid: r.get(5)?,
|
||||
trashed_at: r.get(6)?,
|
||||
})
|
||||
})?;
|
||||
let mut items = Vec::new();
|
||||
@@ -60,5 +74,33 @@ impl Db {
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
// ---- device sync (devices.md) -----------------------------------------
|
||||
/// Every trashed node paired with the folder it was trashed from, for the
|
||||
/// restore's tree walk. Kept separate from [`Db::trash_list`] because the
|
||||
/// restore wants only the links, not the names, sizes or ordering.
|
||||
pub fn trash_parents(&self) -> Result<Vec<(String, Option<String>)>> {
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare("SELECT uid, parent_uid FROM trash")?;
|
||||
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?;
|
||||
let mut pairs = Vec::new();
|
||||
for row in rows {
|
||||
pairs.push(row?);
|
||||
}
|
||||
Ok(pairs)
|
||||
}
|
||||
|
||||
/// When each node now in the trash was first seen there, keyed by uid, so
|
||||
/// a refresh can carry the times over into the listing it writes.
|
||||
pub fn trash_seen(&self) -> Result<HashMap<String, Option<i64>>> {
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare("SELECT uid, trashed_at FROM trash")?;
|
||||
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?;
|
||||
let mut seen = HashMap::new();
|
||||
for row in rows {
|
||||
let (uid, at) = row?;
|
||||
seen.insert(uid, at);
|
||||
}
|
||||
Ok(seen)
|
||||
}
|
||||
|
||||
// ---- device sync -----------------------------------------
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Small query helpers shared across the table modules.
|
||||
|
||||
use rusqlite::{Connection, params};
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
|
||||
use crate::Result;
|
||||
|
||||
@@ -8,14 +8,19 @@ use crate::Result;
|
||||
/// grams), so short queries fall back to a `LIKE` scan over `nodes.name`.
|
||||
pub(super) const TRIGRAM_MIN: usize = 3;
|
||||
|
||||
pub(super) fn pair(row: &rusqlite::Row) -> rusqlite::Result<(String, String)> {
|
||||
Ok((row.get(0)?, row.get(1)?))
|
||||
/// One raw search row: the node's JSON, its uid, and its stored path — `None`
|
||||
/// only for a row written before the `path` column existed, which
|
||||
/// [`path_of`] repairs by walking.
|
||||
pub(super) type HitRow = (String, String, Option<String>);
|
||||
|
||||
pub(super) fn hit_row(row: &rusqlite::Row) -> rusqlite::Result<HitRow> {
|
||||
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
|
||||
}
|
||||
|
||||
/// Drain a `query_map` of [`pair`] rows into a `Vec`, propagating row errors.
|
||||
pub(super) fn collect_pairs(
|
||||
rows: impl Iterator<Item = rusqlite::Result<(String, String)>>,
|
||||
) -> Result<Vec<(String, String)>> {
|
||||
/// Drain a `query_map` of [`hit_row`] rows into a `Vec`, propagating row errors.
|
||||
pub(super) fn collect_hits(
|
||||
rows: impl Iterator<Item = rusqlite::Result<HitRow>>,
|
||||
) -> Result<Vec<HitRow>> {
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
out.push(r?);
|
||||
@@ -31,19 +36,58 @@ pub(super) fn like_escape(s: &str) -> String {
|
||||
.replace('_', "\\_")
|
||||
}
|
||||
|
||||
/// Resolve a node's mountpoint-relative path by walking `parent_uid` to the
|
||||
/// root via a recursive CTE. The root (the node with no parent) is excluded, so
|
||||
/// a top-level file `report.pdf` yields `"report.pdf"`, not `"My Files/report.pdf"`.
|
||||
/// How deep the parent walk may go before it gives up. Real Drive trees are
|
||||
/// nowhere near this; a parent *cycle* is unbounded, and this query is on the
|
||||
/// path of every search result, including the `LIKE` lane that reads `nodes`
|
||||
/// directly and so never passed through the index's cycle check.
|
||||
pub(super) const MAX_PATH_DEPTH: usize = 256;
|
||||
|
||||
/// Append `name` to a parent's stored path. An empty parent path is the root,
|
||||
/// whose name is the mount and so contributes nothing.
|
||||
pub(super) fn join_path(parent_path: &str, name: &str) -> String {
|
||||
if parent_path.is_empty() {
|
||||
name.to_string()
|
||||
} else {
|
||||
format!("{parent_path}/{name}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a node's mountpoint-relative path. The root (the node with no
|
||||
/// parent) is excluded, so a top-level file `report.pdf` yields
|
||||
/// `"report.pdf"`, not `"My Files/report.pdf"`.
|
||||
///
|
||||
/// Reads the stored `nodes.path`, which the write path maintains (see
|
||||
/// `upsert_node_tx`). Falls back to [`walk_path_of`] for a row that predates the
|
||||
/// column or was never written through — a search serving a keystroke used to
|
||||
/// run that walk once per candidate, which is the reason the column exists.
|
||||
pub(super) fn path_of(conn: &Connection, uid: &str) -> Result<String> {
|
||||
let mut stmt = conn.prepare(
|
||||
"WITH RECURSIVE anc(uid, parent_uid, name, depth) AS (
|
||||
SELECT uid, parent_uid, name, 0 FROM nodes WHERE uid = ?1
|
||||
let stored: Option<Option<String>> = conn
|
||||
.query_row("SELECT path FROM nodes WHERE uid = ?1", params![uid], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.optional()?;
|
||||
match stored.flatten() {
|
||||
Some(path) => Ok(path),
|
||||
None => walk_path_of(conn, uid),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a path by walking `parent_lid` to the root via a recursive CTE.
|
||||
///
|
||||
/// A cycle in the parents is corrupt data the API can still hand us, and
|
||||
/// `UNION ALL` over one never terminates — the walk is therefore depth-capped
|
||||
/// and returns the truncated path rather than hanging the caller.
|
||||
pub(super) fn walk_path_of(conn: &Connection, uid: &str) -> Result<String> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"WITH RECURSIVE anc(parent_lid, parent_uid, name, depth) AS (
|
||||
SELECT parent_lid, parent_uid, name, 0 FROM nodes WHERE uid = ?1
|
||||
UNION ALL
|
||||
SELECT n.uid, n.parent_uid, n.name, anc.depth + 1
|
||||
FROM nodes n JOIN anc ON n.uid = anc.parent_uid
|
||||
SELECT n.parent_lid, n.parent_uid, n.name, anc.depth + 1
|
||||
FROM nodes n JOIN anc ON n.lid = anc.parent_lid
|
||||
WHERE anc.depth < {MAX_PATH_DEPTH}
|
||||
)
|
||||
SELECT name, parent_uid FROM anc ORDER BY depth DESC",
|
||||
)?;
|
||||
SELECT name, parent_uid FROM anc ORDER BY depth DESC"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![uid], |r| {
|
||||
Ok((r.get::<_, String>(0)?, r.get::<_, Option<String>>(1)?))
|
||||
})?;
|
||||
|
||||
@@ -11,7 +11,7 @@ pub enum Error {
|
||||
Proton(#[from] proton_sdk::ProtonError),
|
||||
|
||||
#[error("keyring: {0}")]
|
||||
Keyring(#[from] keyring::Error),
|
||||
Keyring(#[from] keyring_core::Error),
|
||||
|
||||
#[error("serialization: {0}")]
|
||||
Serde(#[from] serde_json::Error),
|
||||
@@ -32,6 +32,16 @@ pub enum Error {
|
||||
#[error("saved session can no longer unlock your keys — run `pdfs login` again")]
|
||||
ReloginRequired,
|
||||
|
||||
/// The account has a separate mailbox password that has not been entered
|
||||
/// since login, so its keys cannot be unlocked. The fix is
|
||||
/// [`auth::unlock`](crate::auth::unlock), not a new login.
|
||||
#[error("account is locked — enter the mailbox password with `pdfs unlock`")]
|
||||
Locked,
|
||||
|
||||
/// The mailbox password was checked against the account and is wrong.
|
||||
#[error("that mailbox password does not unlock your account")]
|
||||
WrongMailboxPassword,
|
||||
|
||||
/// The login was gated behind human verification. Not a failure the user can
|
||||
/// fix by retrying: the challenge has to be solved and the login restarted
|
||||
/// with the resulting token (see [`auth::login_verified`](crate::auth::login_verified)).
|
||||
@@ -45,6 +55,41 @@ pub enum Error {
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl Error {
|
||||
/// Whether this is the local disk being full (`ENOSPC`), as opposed to any
|
||||
/// other I/O failure.
|
||||
///
|
||||
/// Worth distinguishing because it is the one storage failure the user can
|
||||
/// act on, and because the caller's response differs: a full disk is met by
|
||||
/// evicting cached blobs and answering `ENOSPC`, where `EIO` would read as a
|
||||
/// broken file. `std::io::ErrorKind::StorageFull` is still unstable, so this
|
||||
/// compares the raw code.
|
||||
pub fn is_disk_full(&self) -> bool {
|
||||
match self {
|
||||
Error::Io(e) => e.raw_os_error() == Some(libc::ENOSPC),
|
||||
// SQLite reports it as its own code rather than surfacing the errno.
|
||||
Error::Db(rusqlite::Error::SqliteFailure(e, _)) => {
|
||||
e.code == rusqlite::ErrorCode::DiskFull
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error {
|
||||
/// Whether a control-socket round-trip gave up waiting for the reply: the
|
||||
/// daemon is running but stuck, as opposed to not running at all.
|
||||
pub fn is_timeout(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Error::Io(e) if matches!(
|
||||
e.kind(),
|
||||
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// A failure on its way to a front-end: prose for the user, plus the
|
||||
@@ -164,9 +209,11 @@ impl From<serde_json::Error> for CoreError {
|
||||
impl From<Error> for CoreError {
|
||||
fn from(e: Error) -> Self {
|
||||
let kind = match &e {
|
||||
Error::NotLoggedIn | Error::ReloginRequired | Error::HumanVerificationRequired(_) => {
|
||||
ErrorKind::Denied
|
||||
}
|
||||
Error::NotLoggedIn
|
||||
| Error::ReloginRequired
|
||||
| Error::Locked
|
||||
| Error::WrongMailboxPassword
|
||||
| Error::HumanVerificationRequired(_) => ErrorKind::Denied,
|
||||
Error::Proton(proton_sdk::ProtonError::Api(api)) => api_kind(api.code),
|
||||
// A `ProtonError` that never reached the API is a transport failure.
|
||||
Error::Proton(_) => ErrorKind::Offline,
|
||||
@@ -231,6 +278,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.
|
||||
pub type CoreResult<T> = std::result::Result<T, CoreError>;
|
||||
|
||||
@@ -262,6 +326,17 @@ mod tests {
|
||||
assert_eq!(api_kind(C::ProtonDriveUnknown), ErrorKind::Remote);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_refused_refresh_or_a_closed_account_revokes_the_session() {
|
||||
assert!(session_revoked(&api(C::InvalidRefreshToken)));
|
||||
assert!(session_revoked(&api(C::AccountDeleted)));
|
||||
assert!(session_revoked(&api(C::AccountDisabled)));
|
||||
// One file the account may not touch says nothing about the session.
|
||||
assert!(!session_revoked(&api(C::Forbidden)));
|
||||
assert!(!session_revoked(&api(C::Timeout)));
|
||||
assert!(!session_revoked(&ProtonError::KeysLocked));
|
||||
}
|
||||
|
||||
/// The regression this whole pass exists to prevent: before it, every SDK
|
||||
/// failure was hardcoded to `Remote`, so a 403 asked the user to retry a
|
||||
/// request that could never succeed.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -2,19 +2,31 @@
|
||||
//! identity, XDG paths, and the login/session/keyring layer that every
|
||||
//! front-end (CLI today, daemon + GUI later) shares.
|
||||
|
||||
pub mod access;
|
||||
pub mod auth;
|
||||
pub mod batch;
|
||||
pub mod cache;
|
||||
pub mod config;
|
||||
pub mod control;
|
||||
pub mod db;
|
||||
pub mod error;
|
||||
pub mod kernelkey;
|
||||
pub mod localindex;
|
||||
pub mod menu;
|
||||
pub mod mounts;
|
||||
pub mod opener;
|
||||
pub mod places;
|
||||
pub mod profile;
|
||||
pub mod sdkcache;
|
||||
pub mod search;
|
||||
pub mod service;
|
||||
pub mod shell;
|
||||
pub mod similar;
|
||||
pub mod syncignore;
|
||||
pub mod takeout;
|
||||
pub mod tray;
|
||||
|
||||
pub use access::{Access, access_for, perm_bits};
|
||||
pub use error::{CoreError, CoreResult, Error, Result};
|
||||
|
||||
// Re-export the SDK surface downstream crates need, so they depend on
|
||||
|
||||
@@ -26,10 +26,20 @@ const SKIP_DIRS: &[&str] = &[
|
||||
"venv",
|
||||
".venv",
|
||||
"vendor",
|
||||
"dist",
|
||||
"dist-newstyle",
|
||||
"build",
|
||||
"Trash",
|
||||
];
|
||||
|
||||
/// Directory *paths* skipped when they end with one of these, for caches whose
|
||||
/// own name is too generic to blacklist (`mod`). The Go module cache was 63,399
|
||||
/// of the 110,187 entries indexed on the machine this was measured on — 58% of
|
||||
/// the index, crowding real files out of the bounded candidate pool the fuzzy
|
||||
/// scorer draws from. The other big dependency caches (`.cargo`, `.rustup`,
|
||||
/// `.gradle`, `.m2`) are already skipped by the hidden-file filter.
|
||||
const SKIP_PATH_SUFFIXES: &[&str] = &["go/pkg/mod"];
|
||||
|
||||
/// Hard cap on indexed entries, so a pathological home directory cannot grow the
|
||||
/// database without bound. Reaching it stops the walk early.
|
||||
const MAX_ENTRIES: usize = 400_000;
|
||||
@@ -90,7 +100,10 @@ pub fn scan(
|
||||
if excludes.iter().any(|e| path.starts_with(e)) {
|
||||
return false;
|
||||
}
|
||||
!matches!(entry.file_name().to_str(), Some(name) if SKIP_DIRS.contains(&name))
|
||||
if matches!(entry.file_name().to_str(), Some(name) if SKIP_DIRS.contains(&name)) {
|
||||
return false;
|
||||
}
|
||||
!is_skipped_path(path)
|
||||
});
|
||||
|
||||
let state = parking_lot::Mutex::new(SinkState {
|
||||
@@ -150,6 +163,17 @@ impl<F: FnMut(Vec<LocalEntry>)> SinkState<F> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `path` is (or is inside) one of the [`SKIP_PATH_SUFFIXES`] caches.
|
||||
/// Matched on the path's own components so a directory merely *named* `mod`
|
||||
/// keeps being indexed.
|
||||
fn is_skipped_path(path: &Path) -> bool {
|
||||
let path = path.to_string_lossy();
|
||||
SKIP_PATH_SUFFIXES.iter().any(|suffix| {
|
||||
path.strip_suffix(suffix)
|
||||
.is_some_and(|head| head.is_empty() || head.ends_with('/'))
|
||||
})
|
||||
}
|
||||
|
||||
/// Convert a walker entry into a [`LocalEntry`], reusing the metadata the walker
|
||||
/// already fetched. Entries with non-UTF-8 paths are dropped: the index (and the
|
||||
/// JSON wire format) is UTF-8 only.
|
||||
@@ -175,6 +199,9 @@ fn to_entry(entry: &ignore::DirEntry) -> Option<LocalEntry> {
|
||||
/// The paths a scan should never descend into: the Drive mountpoint (walking it
|
||||
/// would fault every remote node in through FUSE) plus our own state and cache
|
||||
/// dirs, which hold blobs no user searches for by name.
|
||||
///
|
||||
/// Callers should extend this with [`nested_mount_points`] for each scan root —
|
||||
/// see that function for why `same_file_system(true)` is not enough on its own.
|
||||
pub fn default_excludes(mountpoint: &Path, state_dir: &Path, cache_dir: &Path) -> Vec<PathBuf> {
|
||||
vec![
|
||||
mountpoint.to_path_buf(),
|
||||
@@ -183,6 +210,66 @@ pub fn default_excludes(mountpoint: &Path, state_dir: &Path, cache_dir: &Path) -
|
||||
]
|
||||
}
|
||||
|
||||
/// Every mountpoint strictly below `root`, read from `/proc/self/mounts`.
|
||||
///
|
||||
/// [`scan`] already asks the walker to stay on one filesystem, but that check is
|
||||
/// a *stat of the mountpoint* (`ignore`'s `is_same_file_system`), performed after
|
||||
/// the directory is queued — so the walker has to touch a foreign mount to learn
|
||||
/// it should skip it. On a dead network FUSE mount (an sshfs whose server is
|
||||
/// gone, an unresponsive rclone) that stat blocks in `request_wait_answer` with
|
||||
/// nobody left to answer it, and blocks *uninterruptibly*: the walker thread then
|
||||
/// ignores SIGKILL, so the daemon can never exit, systemd's stop times out, and
|
||||
/// the half-dead process keeps the cache.db lock that the next start needs
|
||||
/// (docs/BUGS.md B90).
|
||||
///
|
||||
/// Excluding the mountpoints by path avoids that stat entirely. It does not
|
||||
/// change what ends up indexed: a nested mount is a different device, so
|
||||
/// `same_file_system(true)` was already going to skip it.
|
||||
pub fn nested_mount_points(root: &Path) -> Vec<PathBuf> {
|
||||
let Ok(mounts) = std::fs::read_to_string("/proc/self/mounts") else {
|
||||
return Vec::new();
|
||||
};
|
||||
parse_nested_mount_points(&mounts, root)
|
||||
}
|
||||
|
||||
/// The mountpoint-field parse behind [`nested_mount_points`], split out so it can
|
||||
/// be tested without a real `/proc`.
|
||||
fn parse_nested_mount_points(mounts: &str, root: &Path) -> Vec<PathBuf> {
|
||||
mounts
|
||||
.lines()
|
||||
.filter_map(|line| line.split_whitespace().nth(1))
|
||||
.map(|field| PathBuf::from(unescape_mount_field(field)))
|
||||
// Strictly below: `root` itself being a mountpoint must not exclude the
|
||||
// whole scan.
|
||||
.filter(|path| path.starts_with(root) && path != root)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Undo the octal escaping `/proc/self/mounts` applies to space, tab, newline and
|
||||
/// backslash in path fields. An un-unescaped `\040` would simply fail to match a
|
||||
/// real path, silently putting a mount with a space in its name back in the walk.
|
||||
fn unescape_mount_field(field: &str) -> String {
|
||||
let mut out = String::with_capacity(field.len());
|
||||
let mut rest = field;
|
||||
while let Some(at) = rest.find('\\') {
|
||||
out.push_str(&rest[..at]);
|
||||
let escape = rest[at + 1..].get(..3);
|
||||
match escape.and_then(|digits| u8::from_str_radix(digits, 8).ok()) {
|
||||
Some(byte) => {
|
||||
out.push(byte as char);
|
||||
rest = &rest[at + 4..];
|
||||
}
|
||||
// Not an octal escape after all — keep the backslash verbatim.
|
||||
None => {
|
||||
out.push('\\');
|
||||
rest = &rest[at + 1..];
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -221,4 +308,65 @@ mod tests {
|
||||
|
||||
std::fs::remove_dir_all(&tmp).unwrap();
|
||||
}
|
||||
|
||||
/// The Go module cache is the single largest source of index noise on a
|
||||
/// developer's machine (58% of the entries on the one this was measured
|
||||
/// on), and it cannot be pruned by directory name: `mod` is far too
|
||||
/// common a name to blacklist outright.
|
||||
#[test]
|
||||
fn the_go_module_cache_is_pruned_but_a_plain_mod_dir_is_not() {
|
||||
let tmp = std::env::temp_dir().join(format!("pdfs-scan-gomod-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
std::fs::create_dir_all(tmp.join("go/pkg/mod/example.com/lib")).unwrap();
|
||||
std::fs::create_dir_all(tmp.join("kernel/mod")).unwrap();
|
||||
std::fs::write(tmp.join("go/pkg/mod/example.com/lib/vendored.go"), b"x").unwrap();
|
||||
std::fs::write(tmp.join("kernel/mod/mine.conf"), b"x").unwrap();
|
||||
|
||||
let mut got = Vec::new();
|
||||
scan(std::slice::from_ref(&tmp), &[], |batch| got.extend(batch));
|
||||
|
||||
let names: Vec<&str> = got.iter().map(|e| e.name.as_str()).collect();
|
||||
assert!(!names.contains(&"vendored.go"), "{names:?}");
|
||||
assert!(names.contains(&"mine.conf"), "{names:?}");
|
||||
|
||||
std::fs::remove_dir_all(&tmp).unwrap();
|
||||
}
|
||||
|
||||
/// A dead network FUSE mount under the scan root must be skipped by *path*,
|
||||
/// before anything stats it — see `nested_mount_points`. The scan root itself
|
||||
/// being a mountpoint must not exclude the entire walk.
|
||||
#[test]
|
||||
fn nested_mount_points_are_excluded_but_the_root_mount_is_not() {
|
||||
let mounts = "\
|
||||
/dev/sda2 /home ext4 rw 0 0
|
||||
narl@narl.io:/opt /home/narl/remote/narl.io fuse.sshfs rw 0 0
|
||||
google_drive: /home/narl/remote/gdrive fuse.rclone rw 0 0
|
||||
tmpfs /run/user/1000 tmpfs rw 0 0
|
||||
";
|
||||
let nested = parse_nested_mount_points(mounts, Path::new("/home/narl"));
|
||||
assert_eq!(
|
||||
nested,
|
||||
vec![
|
||||
PathBuf::from("/home/narl/remote/narl.io"),
|
||||
PathBuf::from("/home/narl/remote/gdrive"),
|
||||
]
|
||||
);
|
||||
|
||||
// `/home` is an ancestor, not a descendant: excluding it would empty the
|
||||
// scan. `/run/user/1000` is unrelated.
|
||||
assert!(parse_nested_mount_points(mounts, Path::new("/home")).len() == 2);
|
||||
}
|
||||
|
||||
/// `/proc/self/mounts` octal-escapes a space; leaving it escaped would put a
|
||||
/// mount whose path contains one back into the walk.
|
||||
#[test]
|
||||
fn mount_paths_are_unescaped() {
|
||||
let mounts = "srv:/x /home/narl/My\\040Cloud fuse.sshfs rw 0 0\n";
|
||||
assert_eq!(
|
||||
parse_nested_mount_points(mounts, Path::new("/home/narl")),
|
||||
vec![PathBuf::from("/home/narl/My Cloud")]
|
||||
);
|
||||
assert_eq!(unescape_mount_field("/a\\134b"), "/a\\b");
|
||||
assert_eq!(unescape_mount_field("/plain/path"), "/plain/path");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
//! Driving an external dmenu-style launcher (fuzzel, rofi, wofi, …).
|
||||
//!
|
||||
//! `pdfs-prompt` ships its own GTK HUD, but on a tiling-WM desktop the user
|
||||
//! already has a launcher bound to a key, themed to match everything else. This
|
||||
//! module lets the prompt borrow it: entries go out on the child's stdin, the
|
||||
//! chosen line comes back on stdout.
|
||||
//!
|
||||
//! Two things vary between launchers and are the only reasons this is not four
|
||||
//! lines of `Command`:
|
||||
//!
|
||||
//! * the flag that turns on filter mode and the one that sets the prompt text;
|
||||
//! * icon support, which fuzzel and rofi both spell `text\0icon\x1fNAME` and
|
||||
//! everything else would render as literal garbage.
|
||||
//!
|
||||
//! Selection is matched back by label rather than by index, because the one
|
||||
//! feature no launcher agrees on is index output. A line that matches nothing is
|
||||
//! the user's own typing — [`MenuChoice::Custom`] — which is what makes
|
||||
//! type-then-search work at all.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// Separates the label from its icon name in the fuzzel/rofi protocol.
|
||||
const ICON_SEPARATOR: &str = "\0icon\x1f";
|
||||
|
||||
/// Launchers we know how to invoke, most-preferred first:
|
||||
/// `(program, filter-mode args, prompt flag, icon protocol)`.
|
||||
///
|
||||
/// The prompt flag is written as it is passed: fuzzel wants `--prompt=TEXT` as
|
||||
/// one argument, the rest take the text as a separate one.
|
||||
const KNOWN_MENUS: [MenuFlavor; 6] = [
|
||||
MenuFlavor {
|
||||
program: "fuzzel",
|
||||
mode: &["--dmenu"],
|
||||
prompt: PromptFlag::Joined("--prompt="),
|
||||
icons: true,
|
||||
},
|
||||
MenuFlavor {
|
||||
program: "rofi",
|
||||
mode: &["-dmenu"],
|
||||
prompt: PromptFlag::Separate("-p"),
|
||||
icons: true,
|
||||
},
|
||||
MenuFlavor {
|
||||
program: "wofi",
|
||||
mode: &["--dmenu"],
|
||||
prompt: PromptFlag::Separate("--prompt"),
|
||||
icons: false,
|
||||
},
|
||||
MenuFlavor {
|
||||
program: "tofi",
|
||||
mode: &[],
|
||||
prompt: PromptFlag::Joined("--prompt-text="),
|
||||
icons: false,
|
||||
},
|
||||
MenuFlavor {
|
||||
program: "bemenu",
|
||||
mode: &[],
|
||||
prompt: PromptFlag::Separate("-p"),
|
||||
icons: false,
|
||||
},
|
||||
MenuFlavor {
|
||||
program: "dmenu",
|
||||
mode: &[],
|
||||
prompt: PromptFlag::Separate("-p"),
|
||||
icons: false,
|
||||
},
|
||||
];
|
||||
|
||||
/// Terminals that can host `pdfs-prompt --fzf`, most-preferred first.
|
||||
///
|
||||
/// The fzf front end is a terminal program, so a keybinding that has no tty
|
||||
/// needs one spawned for it. Each entry sets a stable app-id/class
|
||||
/// ([`TERMINAL_APP_ID`]) so a tiling WM can be told to float exactly this
|
||||
/// window, and ends with the flag after which the command to run follows.
|
||||
const KNOWN_TERMINALS: [&[&str]; 6] = [
|
||||
&["foot", "--app-id=pdfs-prompt", "--"],
|
||||
&["ghostty", "--class=pdfs-prompt", "-e"],
|
||||
&["kitty", "--class", "pdfs-prompt"],
|
||||
&["alacritty", "--class", "pdfs-prompt", "-e"],
|
||||
&["wezterm", "start", "--class", "pdfs-prompt", "--"],
|
||||
&["xterm", "-class", "pdfs-prompt", "-e"],
|
||||
];
|
||||
|
||||
/// The app-id/class every [`KNOWN_TERMINALS`] entry sets, so the window rule a
|
||||
/// user writes for one terminal keeps working if they switch.
|
||||
pub const TERMINAL_APP_ID: &str = "pdfs-prompt";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum PromptFlag {
|
||||
/// `--prompt=TEXT`
|
||||
Joined(&'static str),
|
||||
/// `-p TEXT`
|
||||
Separate(&'static str),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct MenuFlavor {
|
||||
program: &'static str,
|
||||
mode: &'static [&'static str],
|
||||
prompt: PromptFlag,
|
||||
icons: bool,
|
||||
}
|
||||
|
||||
/// Prompt behaviour from `config.json`, under `prompt`.
|
||||
///
|
||||
/// ```json
|
||||
/// "prompt": { "menu": ["fuzzel", "--dmenu", "--width=60"], "menu_limit": 40 }
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PromptConfig {
|
||||
/// Which front end `pdfs-prompt` uses when invoked with no mode flag, so a
|
||||
/// desktop shortcut does not have to be re-bound to switch. `None` is
|
||||
/// [`PromptMode::Gtk`], the built-in HUD.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub mode: Option<PromptMode>,
|
||||
/// Launcher argv for `pdfs-prompt --dmenu`. Unset means "find one" — see
|
||||
/// [`resolve_menu`]. A `{prompt}` token anywhere in the argv is replaced by
|
||||
/// the prompt text; without one the launcher's own prompt flag is appended.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub menu: Option<Vec<String>>,
|
||||
/// Terminal argv for `pdfs-prompt --fzf` when it is started without a tty.
|
||||
/// Unset means "find one" — see [`resolve_terminal`]. A `{cmd}` token
|
||||
/// anywhere in the argv is replaced by the command to run; without one the
|
||||
/// command is appended, which is what every [`KNOWN_TERMINALS`] entry
|
||||
/// expects.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub terminal: Option<Vec<String>>,
|
||||
/// How many results to feed the launcher. Unset means
|
||||
/// [`DEFAULT_MENU_LIMIT`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub menu_limit: Option<usize>,
|
||||
/// Show icons when the launcher understands them. Defaults to true; set
|
||||
/// false for a launcher that advertises support but is themed without them.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub menu_icons: Option<bool>,
|
||||
}
|
||||
|
||||
/// Which front end the prompt presents.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PromptMode {
|
||||
/// The built-in GTK HUD.
|
||||
#[default]
|
||||
Gtk,
|
||||
/// The user's own launcher, driven over stdin/stdout.
|
||||
Dmenu,
|
||||
/// `fzf` in a terminal, re-querying the daemon on every keystroke.
|
||||
Fzf,
|
||||
}
|
||||
|
||||
/// Rows an external launcher can show without becoming a scroll exercise. Larger
|
||||
/// than the GTK prompt's cap because filtering happens launcher-side.
|
||||
pub const DEFAULT_MENU_LIMIT: usize = 50;
|
||||
|
||||
impl PromptConfig {
|
||||
pub fn resolved_mode(&self) -> PromptMode {
|
||||
self.mode.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn resolved_menu_limit(&self) -> usize {
|
||||
self.menu_limit.unwrap_or(DEFAULT_MENU_LIMIT)
|
||||
}
|
||||
|
||||
pub fn resolved_menu_icons(&self) -> bool {
|
||||
self.menu_icons.unwrap_or(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// One line offered to the launcher.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MenuItem {
|
||||
/// What the user sees, and what identifies the choice on the way back.
|
||||
pub label: String,
|
||||
/// Icon theme name, used only by launchers that support the protocol.
|
||||
pub icon: Option<String>,
|
||||
}
|
||||
|
||||
impl MenuItem {
|
||||
pub fn new(label: impl Into<String>, icon: Option<String>) -> Self {
|
||||
Self {
|
||||
label: label.into(),
|
||||
icon,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What came back from the launcher.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum MenuChoice {
|
||||
/// Index into the items that were passed in.
|
||||
Item(usize),
|
||||
/// Text the user typed that matched no item — treat it as a query.
|
||||
Custom(String),
|
||||
/// Escape, or an empty selection.
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// The launcher to run: the configured one, or the first [`KNOWN_MENUS`] entry
|
||||
/// on `PATH`. Returns the argv as it should be spawned, filter-mode flags
|
||||
/// included.
|
||||
pub fn resolve_menu(configured: Option<&Vec<String>>) -> Option<Vec<String>> {
|
||||
if let Some(argv) = configured.filter(|argv| !argv.is_empty()) {
|
||||
return Some(argv.clone());
|
||||
}
|
||||
let flavor = KNOWN_MENUS.iter().find(|f| on_path(f.program))?;
|
||||
let mut argv = vec![flavor.program.to_string()];
|
||||
argv.extend(flavor.mode.iter().map(|f| (*f).to_string()));
|
||||
Some(argv)
|
||||
}
|
||||
|
||||
/// The terminal to host `pdfs-prompt --fzf` in: the configured one, or the
|
||||
/// first [`KNOWN_TERMINALS`] entry on `PATH`. Returns the argv up to and
|
||||
/// including the "run this command" flag — the caller appends the command, or
|
||||
/// substitutes it for a `{cmd}` token, via [`with_command`].
|
||||
pub fn resolve_terminal(configured: Option<&Vec<String>>) -> Option<Vec<String>> {
|
||||
if let Some(argv) = configured.filter(|argv| !argv.is_empty()) {
|
||||
return Some(argv.clone());
|
||||
}
|
||||
let flavor = KNOWN_TERMINALS
|
||||
.iter()
|
||||
.find(|argv| argv.first().is_some_and(|program| on_path(program)))?;
|
||||
Some(flavor.iter().map(|arg| (*arg).to_string()).collect())
|
||||
}
|
||||
|
||||
/// Place `command` into a terminal argv: substituted for a `{cmd}` token if the
|
||||
/// user wrote one, otherwise appended.
|
||||
///
|
||||
/// The token exists for terminals that want the command in the middle of their
|
||||
/// own arguments; appending is right for every [`KNOWN_TERMINALS`] entry, whose
|
||||
/// last element is the flag the command follows.
|
||||
pub fn with_command(argv: &[String], command: &[String]) -> Vec<String> {
|
||||
if !argv.iter().any(|arg| arg == "{cmd}") {
|
||||
let mut argv = argv.to_vec();
|
||||
argv.extend_from_slice(command);
|
||||
return argv;
|
||||
}
|
||||
let mut out = Vec::with_capacity(argv.len() + command.len());
|
||||
for arg in argv {
|
||||
if arg == "{cmd}" {
|
||||
out.extend_from_slice(command);
|
||||
} else {
|
||||
out.push(arg.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Show `items` in the launcher named by `argv` and wait for a choice.
|
||||
///
|
||||
/// Blocking by design: the caller is a one-shot CLI invocation whose entire job
|
||||
/// is this round-trip.
|
||||
pub fn run(argv: &[String], prompt: &str, items: &[MenuItem]) -> Result<MenuChoice> {
|
||||
let argv = with_prompt(argv, prompt);
|
||||
let Some((program, args)) = argv.split_first() else {
|
||||
return Err(Error::Other("no launcher command configured".into()));
|
||||
};
|
||||
let icons = supports_icons(program);
|
||||
|
||||
let mut child = Command::new(program)
|
||||
.args(args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| Error::Other(format!("cannot run launcher {program}: {e}")))?;
|
||||
|
||||
// Write on a worker: a launcher that starts reading only after it maps its
|
||||
// window would otherwise deadlock against a full pipe buffer.
|
||||
let payload = render(items, icons);
|
||||
let mut stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| Error::Other("launcher stdin unavailable".into()))?;
|
||||
let writer = std::thread::spawn(move || {
|
||||
let _ = stdin.write_all(payload.as_bytes());
|
||||
let _ = stdin.flush();
|
||||
});
|
||||
|
||||
let mut selected = String::new();
|
||||
if let Some(stdout) = child.stdout.take() {
|
||||
// Only the first line matters; multi-select is not a thing here, and a
|
||||
// launcher that emits more must not leave us blocked on a full pipe.
|
||||
let mut reader = BufReader::new(stdout);
|
||||
let mut line = String::new();
|
||||
while reader.read_line(&mut line).unwrap_or(0) > 0 {
|
||||
if selected.is_empty() {
|
||||
selected = line.trim_end_matches('\n').to_string();
|
||||
}
|
||||
line.clear();
|
||||
}
|
||||
}
|
||||
let _ = child.wait();
|
||||
let _ = writer.join();
|
||||
|
||||
Ok(interpret(&selected, items))
|
||||
}
|
||||
|
||||
/// Map the launcher's output back to a choice. Exposed for tests; the ambiguity
|
||||
/// it resolves (label vs. free text) is the whole contract with the launcher.
|
||||
fn interpret(selected: &str, items: &[MenuItem]) -> MenuChoice {
|
||||
// Some launchers echo the icon protocol back verbatim.
|
||||
let selected = selected
|
||||
.split(ICON_SEPARATOR)
|
||||
.next()
|
||||
.unwrap_or(selected)
|
||||
.trim_end();
|
||||
if selected.is_empty() {
|
||||
return MenuChoice::Cancelled;
|
||||
}
|
||||
match items.iter().position(|item| item.label == selected) {
|
||||
Some(index) => MenuChoice::Item(index),
|
||||
None => MenuChoice::Custom(selected.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The stdin payload. Labels are stripped of newlines rather than escaped —
|
||||
/// there is no escape in this protocol, and a file name with a newline in it
|
||||
/// would otherwise become two entries.
|
||||
fn render(items: &[MenuItem], icons: bool) -> String {
|
||||
let mut out = String::new();
|
||||
for item in items {
|
||||
let label = item.label.replace(['\n', '\r'], " ");
|
||||
out.push_str(&label);
|
||||
if icons && let Some(icon) = &item.icon {
|
||||
out.push_str(ICON_SEPARATOR);
|
||||
out.push_str(icon);
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Add the prompt text: substituted for `{prompt}` if the user placed one,
|
||||
/// otherwise appended using the launcher's own flag. An unrecognised launcher
|
||||
/// gets no prompt rather than a guessed flag that might not parse.
|
||||
fn with_prompt(argv: &[String], prompt: &str) -> Vec<String> {
|
||||
if argv.iter().any(|arg| arg.contains("{prompt}")) {
|
||||
return argv
|
||||
.iter()
|
||||
.map(|arg| arg.replace("{prompt}", prompt))
|
||||
.collect();
|
||||
}
|
||||
let mut argv = argv.to_vec();
|
||||
if prompt.is_empty() {
|
||||
return argv;
|
||||
}
|
||||
if let Some(flavor) = argv.first().and_then(|program| flavor_of(program)) {
|
||||
match flavor.prompt {
|
||||
PromptFlag::Joined(flag) => argv.push(format!("{flag}{prompt}")),
|
||||
PromptFlag::Separate(flag) => {
|
||||
argv.push(flag.to_string());
|
||||
argv.push(prompt.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
argv
|
||||
}
|
||||
|
||||
fn supports_icons(program: &str) -> bool {
|
||||
flavor_of(program).is_some_and(|flavor| flavor.icons)
|
||||
}
|
||||
|
||||
fn flavor_of(program: &str) -> Option<&'static MenuFlavor> {
|
||||
let base = Path::new(program).file_name()?.to_str()?;
|
||||
KNOWN_MENUS.iter().find(|flavor| flavor.program == base)
|
||||
}
|
||||
|
||||
/// Whether `program` is an executable file on `PATH`.
|
||||
pub fn on_path(program: &str) -> bool {
|
||||
let Some(path) = std::env::var_os("PATH") else {
|
||||
return false;
|
||||
};
|
||||
std::env::split_paths(&path).any(|dir| dir.join(program).is_file())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn items() -> Vec<MenuItem> {
|
||||
vec![
|
||||
MenuItem::new(
|
||||
"notes.md · My files / Docs",
|
||||
Some("text-x-generic".into()),
|
||||
),
|
||||
MenuItem::new("photo.png · Home / Pictures", None),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_matching_line_is_an_item_and_anything_else_is_a_query() {
|
||||
let items = items();
|
||||
assert_eq!(
|
||||
interpret("notes.md · My files / Docs", &items),
|
||||
MenuChoice::Item(0)
|
||||
);
|
||||
assert_eq!(
|
||||
interpret("invoice", &items),
|
||||
MenuChoice::Custom("invoice".into())
|
||||
);
|
||||
assert_eq!(interpret("", &items), MenuChoice::Cancelled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_echoed_icon_field_does_not_break_the_match() {
|
||||
let items = items();
|
||||
assert_eq!(
|
||||
interpret(
|
||||
"notes.md · My files / Docs\0icon\x1ftext-x-generic",
|
||||
&items
|
||||
),
|
||||
MenuChoice::Item(0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icons_are_only_rendered_for_launchers_that_understand_them() {
|
||||
let items = items();
|
||||
assert_eq!(
|
||||
render(&items, true),
|
||||
"notes.md · My files / Docs\0icon\x1ftext-x-generic\nphoto.png · Home / Pictures\n"
|
||||
);
|
||||
assert_eq!(
|
||||
render(&items, false),
|
||||
"notes.md · My files / Docs\nphoto.png · Home / Pictures\n"
|
||||
);
|
||||
assert!(supports_icons("fuzzel"));
|
||||
assert!(supports_icons("/usr/bin/rofi"));
|
||||
assert!(!supports_icons("wofi"));
|
||||
assert!(!supports_icons("mystery-menu"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_label_with_a_newline_stays_one_entry() {
|
||||
let sneaky = vec![MenuItem::new("two\nlines.txt", None)];
|
||||
assert_eq!(render(&sneaky, false), "two lines.txt\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_prompt_uses_each_launchers_own_flag() {
|
||||
assert_eq!(
|
||||
with_prompt(&["fuzzel".into(), "--dmenu".into()], "Drive"),
|
||||
vec!["fuzzel", "--dmenu", "--prompt=Drive"]
|
||||
);
|
||||
assert_eq!(
|
||||
with_prompt(&["rofi".into(), "-dmenu".into()], "Drive"),
|
||||
vec!["rofi", "-dmenu", "-p", "Drive"]
|
||||
);
|
||||
// Unknown launcher: no guessed flag.
|
||||
assert_eq!(
|
||||
with_prompt(&["mystery-menu".into()], "Drive"),
|
||||
vec!["mystery-menu"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_placeholder_wins_over_the_flag() {
|
||||
assert_eq!(
|
||||
with_prompt(
|
||||
&[
|
||||
"rofi".into(),
|
||||
"-dmenu".into(),
|
||||
"-p".into(),
|
||||
"{prompt} >".into()
|
||||
],
|
||||
"Drive"
|
||||
),
|
||||
vec!["rofi", "-dmenu", "-p", "Drive >"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_configured_terminal_is_used_verbatim() {
|
||||
let configured = vec!["my-term".to_string(), "-e".to_string()];
|
||||
assert_eq!(
|
||||
resolve_terminal(Some(&configured)),
|
||||
Some(configured.clone())
|
||||
);
|
||||
// An empty list is "not configured", not "run nothing".
|
||||
assert_eq!(
|
||||
resolve_terminal(Some(&Vec::new())),
|
||||
resolve_terminal(None),
|
||||
"an empty terminal list must fall back to detection"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_command_is_appended_unless_the_user_placed_it() {
|
||||
let command = vec!["pdfs-prompt".to_string(), "--fzf".to_string()];
|
||||
assert_eq!(
|
||||
with_command(&["foot".into(), "--".into()], &command),
|
||||
vec!["foot", "--", "pdfs-prompt", "--fzf"]
|
||||
);
|
||||
assert_eq!(
|
||||
with_command(
|
||||
&["term".into(), "-e".into(), "{cmd}".into(), "--hold".into()],
|
||||
&command
|
||||
),
|
||||
vec!["term", "-e", "pdfs-prompt", "--fzf", "--hold"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_known_terminal_sets_the_same_app_id() {
|
||||
for argv in KNOWN_TERMINALS {
|
||||
assert!(
|
||||
argv.iter().any(|arg| arg.contains(TERMINAL_APP_ID)),
|
||||
"{argv:?} must be floatable by one window rule"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_configured_launcher_is_used_verbatim() {
|
||||
let configured = vec!["my-menu".to_string(), "--pick".to_string()];
|
||||
assert_eq!(resolve_menu(Some(&configured)), Some(configured.clone()));
|
||||
// An empty list is "not configured", not "run nothing".
|
||||
assert_eq!(
|
||||
resolve_menu(Some(&Vec::new())),
|
||||
resolve_menu(None),
|
||||
"an empty menu list must fall back to detection"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
//! Unified presentation model for every local Proton Drive location.
|
||||
//!
|
||||
//! Device sync state remains owned by `sync_folder`; these types are the stable
|
||||
//! shape exposed to front-ends after that state is joined with the `mount`
|
||||
//! presentation table.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::control::SyncProgress;
|
||||
|
||||
/// How a location makes remote content available locally.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MountMode {
|
||||
/// A full local copy reconciled in both directions.
|
||||
Mirror,
|
||||
/// A FUSE session that fetches content as it is accessed.
|
||||
OnDemand,
|
||||
/// A mode written by a newer client.
|
||||
#[default]
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl MountMode {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Mirror => "mirror",
|
||||
Self::OnDemand => "ondemand",
|
||||
Self::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for MountMode {
|
||||
fn from(value: &str) -> Self {
|
||||
match value {
|
||||
"mirror" => Self::Mirror,
|
||||
"ondemand" => Self::OnDemand,
|
||||
_ => Self::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for MountMode {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the location itself was declared writable.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MountAccess {
|
||||
Rw,
|
||||
Ro,
|
||||
}
|
||||
|
||||
impl MountAccess {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Rw => "rw",
|
||||
Self::Ro => "ro",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for MountAccess {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// What remote root a local location presents.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
#[serde(tag = "kind", rename_all = "lowercase")]
|
||||
pub enum MountKind {
|
||||
/// The account's primary My Files mount.
|
||||
MyFiles,
|
||||
/// A folder owned by this machine's Proton Drive device.
|
||||
Device { sync_folder_id: i64 },
|
||||
/// A future standalone mount rooted at a node shared with this account.
|
||||
Shared { share_root_uid: String },
|
||||
}
|
||||
|
||||
/// One local Proton Drive location as exposed over the control protocol.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct MountSpec {
|
||||
/// Presentation-row id. Device actions use the `sync_folder_id` carried by
|
||||
/// [`MountKind::Device`], not this id.
|
||||
pub id: i64,
|
||||
pub kind: MountKind,
|
||||
pub local_path: String,
|
||||
pub root_uid: String,
|
||||
pub root_share_id: String,
|
||||
pub mode: MountMode,
|
||||
pub access: MountAccess,
|
||||
/// `idle` | `syncing` | `error` | `conflict` for device locations.
|
||||
pub state: String,
|
||||
/// Last successful device sync, epoch seconds; `0` when not applicable.
|
||||
pub last_sync: i64,
|
||||
/// A device mode switch accepted but not yet applied.
|
||||
#[serde(default)]
|
||||
pub pending_mode: Option<MountMode>,
|
||||
/// Whether a FUSE session currently owns `local_path`.
|
||||
#[serde(default)]
|
||||
pub mounted: bool,
|
||||
/// Live device sync progress, absent when no pass is running.
|
||||
#[serde(default)]
|
||||
pub progress: Option<SyncProgress>,
|
||||
/// A synced folder paused on its own. Live daemon state, not a stored column.
|
||||
#[serde(default)]
|
||||
pub paused: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mount_mode_keeps_legacy_wire_strings() {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&MountMode::Mirror).unwrap(),
|
||||
r#""mirror""#
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&MountMode::OnDemand).unwrap(),
|
||||
r#""ondemand""#
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<MountMode>(r#""mirror""#).unwrap(),
|
||||
MountMode::Mirror
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<MountMode>(r#""ondemand""#).unwrap(),
|
||||
MountMode::OnDemand
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mount_mode_tolerates_unknown_values() {
|
||||
assert_eq!(
|
||||
serde_json::from_str::<MountMode>(r#""from-the-future""#).unwrap(),
|
||||
MountMode::Unknown
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,618 @@
|
||||
//! How a file chosen in the prompt (or the browser) is actually opened.
|
||||
//!
|
||||
//! The default is `xdg-open`, which routes through the desktop's MIME
|
||||
//! associations. That is the right answer for a GNOME session and the wrong one
|
||||
//! for a tiling-WM setup where "open a text file" means "run `nvim` inside my
|
||||
//! terminal" — there is no desktop file for that, and pointing `xdg-open` at a
|
||||
//! terminal editor globally breaks every graphical caller.
|
||||
//!
|
||||
//! So the rules live in `config.json` under `open_with`: an ordered list of
|
||||
//! name/class patterns mapped to argv, each optionally wrapped in a terminal.
|
||||
//! First match wins; anything unmatched falls through to `default` (itself
|
||||
//! `xdg-open` unless overridden). Nothing here is required — an absent
|
||||
//! `open_with` reproduces the previous behaviour exactly.
|
||||
//!
|
||||
//! ```json
|
||||
//! "open_with": {
|
||||
//! "terminal": ["alacritty", "-e"],
|
||||
//! "rules": [
|
||||
//! { "match": ["@text", "*.rs", "*.toml"], "command": ["nvim"], "terminal": true },
|
||||
//! { "match": ["*.png"], "command": ["imv"] }
|
||||
//! ]
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use std::ffi::OsStr;
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Environment override for the terminal used by `"terminal": true` rules.
|
||||
/// Checked before `$TERMINAL` so a support case can redirect it without editing
|
||||
/// config.
|
||||
pub const TERMINAL_ENV: &str = "PDFS_TERMINAL";
|
||||
|
||||
/// Terminals we know how to hand a command line to, most-preferred first, with
|
||||
/// the argument that means "run this, don't parse it as options". `kitty` and
|
||||
/// `wezterm start --` take the command positionally.
|
||||
const KNOWN_TERMINALS: [(&str, &[&str]); 11] = [
|
||||
("alacritty", &["-e"]),
|
||||
("foot", &["-e"]),
|
||||
("kitty", &[]),
|
||||
("ghostty", &["-e"]),
|
||||
("wezterm", &["start", "--"]),
|
||||
("gnome-terminal", &["--"]),
|
||||
("konsole", &["-e"]),
|
||||
("xfce4-terminal", &["-x"]),
|
||||
("urxvt", &["-e"]),
|
||||
("st", &["-e"]),
|
||||
("xterm", &["-e"]),
|
||||
];
|
||||
|
||||
/// The opener policy as it appears in `config.json`.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct OpenWith {
|
||||
/// Terminal argv used by rules with `"terminal": true`, e.g.
|
||||
/// `["alacritty", "-e"]`. Unset means "resolve one" — see
|
||||
/// [`resolve_terminal`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub terminal: Option<Vec<String>>,
|
||||
/// What to run when no rule matches. Unset means `xdg-open`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub default: Option<Vec<String>>,
|
||||
/// Ordered; first match wins.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub rules: Vec<OpenRule>,
|
||||
}
|
||||
|
||||
/// One `pattern set -> command` mapping.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct OpenRule {
|
||||
/// Patterns this rule answers to. Either a shell-style glob against the file
|
||||
/// name (`*.md`, `notes-*.txt`) or one of the classes `@dir`, `@text`,
|
||||
/// `@document`, `@image`, `@media`, `@any`. Matching is case-insensitive.
|
||||
#[serde(rename = "match", default)]
|
||||
pub patterns: Vec<String>,
|
||||
/// argv to run. A `{}` token is replaced by the path; without one the path
|
||||
/// is appended. `$VAR`/`${VAR}` tokens expand from the environment, so
|
||||
/// `["$EDITOR"]` follows the user's editor.
|
||||
#[serde(default)]
|
||||
pub command: Vec<String>,
|
||||
/// Wrap [`command`](Self::command) in the terminal emulator.
|
||||
#[serde(default)]
|
||||
pub terminal: bool,
|
||||
}
|
||||
|
||||
impl OpenWith {
|
||||
/// The argv that opens `path`, terminal wrapping included. Never fails: an
|
||||
/// unmatched path — or a rule whose terminal cannot be resolved — degrades
|
||||
/// to the default opener rather than doing nothing.
|
||||
pub fn command_for(&self, path: &Path, is_dir: bool) -> Vec<String> {
|
||||
let name = path
|
||||
.file_name()
|
||||
.and_then(OsStr::to_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
self.command_for_named(path, &name, is_dir)
|
||||
}
|
||||
|
||||
/// [`command_for`](Self::command_for) with the rules matched against `name`
|
||||
/// instead of the last component of `path`.
|
||||
///
|
||||
/// A materialised Drive file lives in the content cache under its content
|
||||
/// hash — no extension, no original name — so matching the on-disk path
|
||||
/// classifies every download as "unknown" and hands it to `xdg-open`, which
|
||||
/// sniffs the bytes and picks whatever claims `text/plain` (LibreOffice, for
|
||||
/// a Markdown file). The rules are about *what the user opened*, so the
|
||||
/// caller passes that name and the argv still gets the real path.
|
||||
pub fn command_for_named(&self, path: &Path, name: &str, is_dir: bool) -> Vec<String> {
|
||||
for rule in &self.rules {
|
||||
if rule.command.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !rule.patterns.iter().any(|p| matches(p, name, is_dir)) {
|
||||
continue;
|
||||
}
|
||||
let argv = expand(&rule.command, path);
|
||||
if !rule.terminal {
|
||||
return argv;
|
||||
}
|
||||
match self.terminal_argv() {
|
||||
Some(mut terminal) => {
|
||||
terminal.extend(argv);
|
||||
return terminal;
|
||||
}
|
||||
// A terminal rule with no terminal to run it in would silently
|
||||
// launch a TUI with no tty. Fall through to the desktop instead.
|
||||
None => {
|
||||
tracing::warn!(
|
||||
"no terminal found for a terminal open rule; using the default opener"
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let fallback = self
|
||||
.default
|
||||
.clone()
|
||||
.unwrap_or_else(|| vec!["xdg-open".to_string()]);
|
||||
expand(&fallback, path)
|
||||
}
|
||||
|
||||
/// The configured terminal, or a resolved one.
|
||||
fn terminal_argv(&self) -> Option<Vec<String>> {
|
||||
match &self.terminal {
|
||||
Some(argv) if !argv.is_empty() => Some(argv.clone()),
|
||||
_ => resolve_terminal(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Launch `path` under `policy`. Fire-and-forget: the child outlives us, and a
|
||||
/// spawn failure is logged rather than propagated — every caller is a UI action
|
||||
/// with nowhere useful to report to.
|
||||
pub fn open(policy: &OpenWith, path: &Path, is_dir: bool) {
|
||||
let argv = policy.command_for(path, is_dir);
|
||||
spawn(&argv, path);
|
||||
}
|
||||
|
||||
/// [`open`] for a path whose own file name says nothing about its type — see
|
||||
/// [`OpenWith::command_for_named`].
|
||||
pub fn open_named(policy: &OpenWith, path: &Path, name: &str, is_dir: bool) {
|
||||
let argv = policy.command_for_named(path, name, is_dir);
|
||||
spawn(&argv, path);
|
||||
}
|
||||
|
||||
/// Launch `argv`, detached from whatever is opening it.
|
||||
///
|
||||
/// The child gets its own process group and no inherited stdio, because the
|
||||
/// caller may be about to exit and take its terminal with it: `pdfs-prompt
|
||||
/// --fzf` runs inside a terminal it spawned for itself, and that terminal
|
||||
/// closes the moment the selection is made. A child left in the terminal's
|
||||
/// foreground process group is sent `SIGHUP` when the pty hangs up, so
|
||||
/// "open this folder in a terminal" would open a window that dies immediately
|
||||
/// — which is exactly what it did. Nothing here needs the caller's stdio in the
|
||||
/// first place: every `argv` is another application, and a terminal one is
|
||||
/// wrapped in its own emulator by the `terminal` rule flag.
|
||||
fn spawn(argv: &[String], path: &Path) {
|
||||
let Some((program, args)) = argv.split_first() else {
|
||||
return;
|
||||
};
|
||||
tracing::info!(path = %path.display(), command = ?argv, "opening");
|
||||
let spawned = Command::new(program)
|
||||
.args(args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.process_group(0)
|
||||
.spawn();
|
||||
if let Err(e) = spawned {
|
||||
tracing::error!(command = ?argv, "open failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// [`open`] with the policy from the user's `config.json`, read once per
|
||||
/// process. For callers that open the occasional path and have no reason to
|
||||
/// carry a policy around; long-lived windows should hold their own copy.
|
||||
pub fn open_default(path: &Path, is_dir: bool) {
|
||||
open(default_policy(), path, is_dir);
|
||||
}
|
||||
|
||||
/// [`open_default`] matching the rules against `name` — see
|
||||
/// [`OpenWith::command_for_named`].
|
||||
pub fn open_default_named(path: &Path, name: &str, is_dir: bool) {
|
||||
open_named(default_policy(), path, name, is_dir);
|
||||
}
|
||||
|
||||
fn default_policy() -> &'static OpenWith {
|
||||
static POLICY: std::sync::OnceLock<OpenWith> = std::sync::OnceLock::new();
|
||||
POLICY.get_or_init(|| {
|
||||
crate::config::AppDirs::new()
|
||||
.map(|dirs| dirs.load_config().resolved_open_with())
|
||||
.unwrap_or_default()
|
||||
})
|
||||
}
|
||||
|
||||
/// `$PDFS_TERMINAL`, then `$TERMINAL`, then the first [`KNOWN_TERMINALS`] entry
|
||||
/// on `PATH`. An env value may be a full command line (`"foot -e"`), in which
|
||||
/// case it is used verbatim; a bare program name gains that terminal's "run
|
||||
/// this" argument so `TERMINAL=alacritty` does the obvious thing.
|
||||
pub fn resolve_terminal() -> Option<Vec<String>> {
|
||||
for key in [TERMINAL_ENV, "TERMINAL"] {
|
||||
let Ok(value) = std::env::var(key) else {
|
||||
continue;
|
||||
};
|
||||
let argv: Vec<String> = value.split_whitespace().map(str::to_string).collect();
|
||||
match argv.len() {
|
||||
0 => continue,
|
||||
1 => return Some(with_exec_flag(&argv[0])),
|
||||
_ => return Some(argv),
|
||||
}
|
||||
}
|
||||
|
||||
KNOWN_TERMINALS
|
||||
.iter()
|
||||
.find(|(program, _)| on_path(program))
|
||||
.map(|(program, _)| with_exec_flag(program))
|
||||
}
|
||||
|
||||
/// A bare terminal name plus whatever it needs before a command.
|
||||
fn with_exec_flag(program: &str) -> Vec<String> {
|
||||
let base = Path::new(program)
|
||||
.file_name()
|
||||
.and_then(OsStr::to_str)
|
||||
.unwrap_or(program);
|
||||
let mut argv = vec![program.to_string()];
|
||||
let flags = KNOWN_TERMINALS
|
||||
.iter()
|
||||
.find(|(known, _)| *known == base)
|
||||
.map_or(&["-e"][..], |(_, flags)| *flags);
|
||||
argv.extend(flags.iter().map(|f| (*f).to_string()));
|
||||
argv
|
||||
}
|
||||
|
||||
fn on_path(program: &str) -> bool {
|
||||
let Some(path) = std::env::var_os("PATH") else {
|
||||
return false;
|
||||
};
|
||||
std::env::split_paths(&path).any(|dir| dir.join(program).is_file())
|
||||
}
|
||||
|
||||
/// Substitute `{}` (or append) the path, and expand `$VAR` tokens. Tokens that
|
||||
/// expand to nothing are dropped, so `["$EDITOR"]` with no `EDITOR` set leaves
|
||||
/// an empty argv and falls back at the call site.
|
||||
fn expand(argv: &[String], path: &Path) -> Vec<String> {
|
||||
let target = path.display().to_string();
|
||||
let mut out = Vec::with_capacity(argv.len() + 1);
|
||||
let mut substituted = false;
|
||||
for token in argv {
|
||||
if token.contains("{}") {
|
||||
out.push(token.replace("{}", &target));
|
||||
substituted = true;
|
||||
continue;
|
||||
}
|
||||
match expand_env(token) {
|
||||
Some(value) => out.push(value),
|
||||
None => continue,
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
return vec!["xdg-open".to_string(), target];
|
||||
}
|
||||
if !substituted {
|
||||
out.push(target);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `$VAR` / `${VAR}` for a whole token, `None` when the variable is unset or
|
||||
/// empty so the token drops out. A token that is not a variable reference is
|
||||
/// returned unchanged; this is deliberately not a shell — no word splitting, no
|
||||
/// quoting rules.
|
||||
fn expand_env(token: &str) -> Option<String> {
|
||||
let Some(name) = token
|
||||
.strip_prefix("${")
|
||||
.and_then(|rest| rest.strip_suffix('}'))
|
||||
.or_else(|| token.strip_prefix('$'))
|
||||
else {
|
||||
return Some(token.to_string());
|
||||
};
|
||||
if name.is_empty() {
|
||||
return Some(token.to_string());
|
||||
}
|
||||
// Follow `$EDITOR`'s convention of preferring the graphical-capable one.
|
||||
let value = if name == "EDITOR" {
|
||||
std::env::var("VISUAL")
|
||||
.ok()
|
||||
.filter(|v| !v.is_empty())
|
||||
.or_else(|| std::env::var("EDITOR").ok())
|
||||
} else {
|
||||
std::env::var(name).ok()
|
||||
};
|
||||
value.filter(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
/// Whether `pattern` — a glob or an `@class` — matches this name.
|
||||
fn matches(pattern: &str, name: &str, is_dir: bool) -> bool {
|
||||
let name_lower = name.to_lowercase();
|
||||
match pattern.to_lowercase().as_str() {
|
||||
"@any" => true,
|
||||
"@dir" => is_dir,
|
||||
"@text" => !is_dir && class_text(&name_lower),
|
||||
"@document" => !is_dir && class_document(&name_lower),
|
||||
"@image" => !is_dir && class_image(&name_lower),
|
||||
"@media" => !is_dir && class_media(&name_lower),
|
||||
other if other.starts_with('@') => false,
|
||||
other => glob_match(other, &name_lower),
|
||||
}
|
||||
}
|
||||
|
||||
fn extension(name: &str) -> &str {
|
||||
Path::new(name)
|
||||
.extension()
|
||||
.and_then(OsStr::to_str)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Plain-text-ish files a terminal editor is a sensible default for. Extensions
|
||||
/// only: the daemon's hits may name files that are not on this machine yet, so
|
||||
/// there is nothing to sniff.
|
||||
fn class_text(name: &str) -> bool {
|
||||
matches!(
|
||||
extension(name),
|
||||
"txt"
|
||||
| "md"
|
||||
| "markdown"
|
||||
| "rst"
|
||||
| "org"
|
||||
| "log"
|
||||
| "conf"
|
||||
| "cfg"
|
||||
| "ini"
|
||||
| "toml"
|
||||
| "yaml"
|
||||
| "yml"
|
||||
| "json"
|
||||
| "xml"
|
||||
| "csv"
|
||||
| "tsv"
|
||||
| "env"
|
||||
| "sh"
|
||||
| "bash"
|
||||
| "zsh"
|
||||
| "fish"
|
||||
| "rs"
|
||||
| "py"
|
||||
| "go"
|
||||
| "c"
|
||||
| "h"
|
||||
| "cpp"
|
||||
| "hpp"
|
||||
| "js"
|
||||
| "ts"
|
||||
| "tsx"
|
||||
| "jsx"
|
||||
| "css"
|
||||
| "html"
|
||||
| "sql"
|
||||
| "lua"
|
||||
| "vim"
|
||||
| "nix"
|
||||
| "patch"
|
||||
| "diff"
|
||||
) || name == "makefile"
|
||||
|| name == "dockerfile"
|
||||
}
|
||||
|
||||
fn class_document(name: &str) -> bool {
|
||||
matches!(
|
||||
extension(name),
|
||||
"pdf"
|
||||
| "doc"
|
||||
| "docx"
|
||||
| "odt"
|
||||
| "rtf"
|
||||
| "xls"
|
||||
| "xlsx"
|
||||
| "ods"
|
||||
| "ppt"
|
||||
| "pptx"
|
||||
| "odp"
|
||||
| "epub"
|
||||
) || class_text(name)
|
||||
}
|
||||
|
||||
fn class_image(name: &str) -> bool {
|
||||
matches!(
|
||||
extension(name),
|
||||
"png" | "jpg" | "jpeg" | "webp" | "gif" | "bmp" | "svg" | "avif" | "heic" | "tiff"
|
||||
)
|
||||
}
|
||||
|
||||
fn class_media(name: &str) -> bool {
|
||||
matches!(
|
||||
extension(name),
|
||||
"mp4"
|
||||
| "mkv"
|
||||
| "webm"
|
||||
| "mov"
|
||||
| "avi"
|
||||
| "mp3"
|
||||
| "flac"
|
||||
| "wav"
|
||||
| "ogg"
|
||||
| "opus"
|
||||
| "m4a"
|
||||
| "aac"
|
||||
)
|
||||
}
|
||||
|
||||
/// `*` and `?` globbing, no character classes and no path semantics — patterns
|
||||
/// are matched against a bare file name. Iterative with backtracking so a
|
||||
/// pathological pattern cannot blow the stack.
|
||||
fn glob_match(pattern: &str, name: &str) -> bool {
|
||||
let pattern: Vec<char> = pattern.chars().collect();
|
||||
let name: Vec<char> = name.chars().collect();
|
||||
let (mut p, mut n) = (0usize, 0usize);
|
||||
// Where to resume if the current `*` turns out to have matched too little.
|
||||
let (mut star, mut resume) = (None, 0usize);
|
||||
|
||||
while n < name.len() {
|
||||
match pattern.get(p) {
|
||||
Some('*') => {
|
||||
star = Some(p);
|
||||
resume = n;
|
||||
p += 1;
|
||||
}
|
||||
Some('?') => {
|
||||
p += 1;
|
||||
n += 1;
|
||||
}
|
||||
Some(c) if *c == name[n] => {
|
||||
p += 1;
|
||||
n += 1;
|
||||
}
|
||||
_ => match star {
|
||||
Some(s) => {
|
||||
p = s + 1;
|
||||
resume += 1;
|
||||
n = resume;
|
||||
}
|
||||
None => return false,
|
||||
},
|
||||
}
|
||||
}
|
||||
pattern[p..].iter().all(|c| *c == '*')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn rule(patterns: &[&str], command: &[&str], terminal: bool) -> OpenRule {
|
||||
OpenRule {
|
||||
patterns: patterns.iter().map(|p| (*p).to_string()).collect(),
|
||||
command: command.iter().map(|c| (*c).to_string()).collect(),
|
||||
terminal,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cache_blob_is_classified_by_the_name_it_was_opened_as() {
|
||||
let policy = OpenWith {
|
||||
terminal: Some(vec!["alacritty".into(), "-e".into()]),
|
||||
default: None,
|
||||
rules: vec![rule(&["@text"], &["nvim"], true)],
|
||||
};
|
||||
// What the daemon hands back: content-hash name, no extension. Matched
|
||||
// on its own it is "unknown" and would land in xdg-open's lap.
|
||||
let blob = Path::new("/home/me/.cache/proton-drive-linux/content/364d1d8cc8e6");
|
||||
assert_eq!(
|
||||
policy.command_for(blob, false),
|
||||
vec![
|
||||
"xdg-open",
|
||||
"/home/me/.cache/proton-drive-linux/content/364d1d8cc8e6"
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
policy.command_for_named(blob, "ONBOARDING.md", false),
|
||||
vec![
|
||||
"alacritty",
|
||||
"-e",
|
||||
"nvim",
|
||||
"/home/me/.cache/proton-drive-linux/content/364d1d8cc8e6"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_policy_is_xdg_open() {
|
||||
let policy = OpenWith::default();
|
||||
assert_eq!(
|
||||
policy.command_for(Path::new("/tmp/a.md"), false),
|
||||
vec!["xdg-open".to_string(), "/tmp/a.md".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_terminal_rule_wraps_the_command() {
|
||||
let policy = OpenWith {
|
||||
terminal: Some(vec!["alacritty".into(), "-e".into()]),
|
||||
default: None,
|
||||
rules: vec![rule(&["@text"], &["nvim"], true)],
|
||||
};
|
||||
assert_eq!(
|
||||
policy.command_for(Path::new("/tmp/notes.md"), false),
|
||||
vec!["alacritty", "-e", "nvim", "/tmp/notes.md"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_matching_rule_wins_and_globs_are_case_insensitive() {
|
||||
let policy = OpenWith {
|
||||
terminal: None,
|
||||
default: None,
|
||||
rules: vec![
|
||||
rule(&["*.PNG"], &["imv"], false),
|
||||
rule(&["@image"], &["eog"], false),
|
||||
],
|
||||
};
|
||||
assert_eq!(
|
||||
policy.command_for(Path::new("/tmp/shot.png"), false),
|
||||
vec!["imv", "/tmp/shot.png"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_placeholder_positions_the_path_instead_of_appending_it() {
|
||||
let policy = OpenWith {
|
||||
terminal: None,
|
||||
default: None,
|
||||
rules: vec![rule(&["@any"], &["sh", "-c", "cat {} | less"], false)],
|
||||
};
|
||||
assert_eq!(
|
||||
policy.command_for(Path::new("/tmp/a.log"), false),
|
||||
vec!["sh", "-c", "cat /tmp/a.log | less"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directories_only_match_the_dir_class() {
|
||||
let policy = OpenWith {
|
||||
terminal: None,
|
||||
default: None,
|
||||
rules: vec![
|
||||
rule(&["@text"], &["nvim"], false),
|
||||
rule(&["@dir"], &["nautilus"], false),
|
||||
],
|
||||
};
|
||||
assert_eq!(
|
||||
policy.command_for(Path::new("/tmp/notes.md"), true),
|
||||
vec!["nautilus", "/tmp/notes.md"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rule_that_cannot_find_a_terminal_falls_back_to_the_default() {
|
||||
// A terminal that is spelled out in config is always honoured; this is
|
||||
// the other path — resolution must not invent one from the test host.
|
||||
let policy = OpenWith {
|
||||
terminal: Some(Vec::new()),
|
||||
default: Some(vec!["gio".into(), "open".into()]),
|
||||
rules: vec![rule(&["@text"], &["nvim"], true)],
|
||||
};
|
||||
let argv = policy.command_for(Path::new("/tmp/a.md"), false);
|
||||
// Either a terminal exists on this machine (wrapped nvim) or it does
|
||||
// not (the default). Both are correct; what must not happen is a bare
|
||||
// `nvim` with no tty.
|
||||
assert!(
|
||||
argv == vec!["gio", "open", "/tmp/a.md"]
|
||||
|| argv.ends_with(&["nvim".to_string(), "/tmp/a.md".to_string()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn globs_backtrack() {
|
||||
assert!(glob_match("*.md", "notes.md"));
|
||||
assert!(glob_match("*notes*.md", "my-notes-2024.md"));
|
||||
assert!(glob_match("a?c.txt", "abc.txt"));
|
||||
assert!(!glob_match("*.md", "notes.mdx"));
|
||||
assert!(!glob_match("a?c.txt", "ac.txt"));
|
||||
assert!(glob_match("*", "anything"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_terminal_name_gains_its_exec_flag() {
|
||||
assert_eq!(with_exec_flag("alacritty"), vec!["alacritty", "-e"]);
|
||||
assert_eq!(with_exec_flag("kitty"), vec!["kitty"]);
|
||||
assert_eq!(
|
||||
with_exec_flag("/usr/bin/wezterm"),
|
||||
vec!["/usr/bin/wezterm", "start", "--"]
|
||||
);
|
||||
// Unknown terminals get the near-universal `-e`.
|
||||
assert_eq!(with_exec_flag("mystery-term"), vec!["mystery-term", "-e"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
//! Offline reverse geocoding: the town a photo was taken in, from its GPS
|
||||
//! position, without asking any service.
|
||||
//!
|
||||
//! The towns are GeoNames' `cities15000` dump (every place with more than
|
||||
//! 15,000 people), trimmed to id, name, position and country in
|
||||
//! `data/cities15000.tsv` by `scripts/geonames-cities.py`. The script drops the
|
||||
//! parts of a city, such as Kreuzberg or "Paris 15 Vaugirard", so that a photo
|
||||
//! lands in the town people would name. GeoNames publishes the dump under
|
||||
//! CC BY 4.0, which is why the About dialog credits it.
|
||||
//!
|
||||
//! `data/countries.tsv` maps each country code to its ISO 3166-1 English name,
|
||||
//! as the `iso-codes` project spells it, so a front end can translate it
|
||||
//! through that project's `iso_3166-1` gettext domain.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// How far from the nearest town a photo may be and still count as taken
|
||||
/// there, in km. Farther out (at sea, in the wilderness) it has no place.
|
||||
pub const MAX_DISTANCE_KM: f64 = 50.0;
|
||||
|
||||
/// Mean Earth radius in km, for the haversine distance.
|
||||
const EARTH_RADIUS_KM: f64 = 6371.0;
|
||||
|
||||
const CITIES: &str = include_str!("../data/cities15000.tsv");
|
||||
const COUNTRIES: &str = include_str!("../data/countries.tsv");
|
||||
|
||||
/// One town of the table.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct City {
|
||||
/// The GeoNames id, stable across dumps.
|
||||
pub id: u32,
|
||||
pub name: &'static str,
|
||||
pub latitude: f64,
|
||||
pub longitude: f64,
|
||||
/// ISO 3166-1 alpha-2 country code.
|
||||
pub country: &'static str,
|
||||
}
|
||||
|
||||
/// The towns, and an index of them by whole-degree cell.
|
||||
struct Table {
|
||||
cities: Vec<City>,
|
||||
cells: HashMap<(i32, i32), Vec<usize>>,
|
||||
}
|
||||
|
||||
fn table() -> &'static Table {
|
||||
static TABLE: OnceLock<Table> = OnceLock::new();
|
||||
TABLE.get_or_init(|| {
|
||||
let cities: Vec<City> = CITIES.lines().filter_map(parse_city).collect();
|
||||
let mut cells: HashMap<(i32, i32), Vec<usize>> = HashMap::new();
|
||||
for (index, city) in cities.iter().enumerate() {
|
||||
cells
|
||||
.entry(cell(city.latitude, city.longitude))
|
||||
.or_default()
|
||||
.push(index);
|
||||
}
|
||||
Table { cities, cells }
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_city(line: &'static str) -> Option<City> {
|
||||
let mut fields = line.split('\t');
|
||||
Some(City {
|
||||
id: fields.next()?.parse().ok()?,
|
||||
name: fields.next()?,
|
||||
latitude: fields.next()?.parse().ok()?,
|
||||
longitude: fields.next()?.parse().ok()?,
|
||||
country: fields.next()?,
|
||||
})
|
||||
}
|
||||
|
||||
fn cell(latitude: f64, longitude: f64) -> (i32, i32) {
|
||||
(latitude.floor() as i32, longitude.floor() as i32)
|
||||
}
|
||||
|
||||
/// Great-circle distance between two positions, in km.
|
||||
pub fn distance_km(a: (f64, f64), b: (f64, f64)) -> f64 {
|
||||
let (lat1, lat2) = (a.0.to_radians(), b.0.to_radians());
|
||||
let dlat = lat2 - lat1;
|
||||
let dlon = (b.1 - a.1).to_radians();
|
||||
let h = (dlat / 2.0).sin().powi(2) + lat1.cos() * lat2.cos() * (dlon / 2.0).sin().powi(2);
|
||||
2.0 * EARTH_RADIUS_KM * h.sqrt().min(1.0).asin()
|
||||
}
|
||||
|
||||
/// The town nearest to a position, if one lies within [`MAX_DISTANCE_KM`].
|
||||
pub fn nearest(latitude: f64, longitude: f64) -> Option<&'static City> {
|
||||
if !latitude.is_finite() || !longitude.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let table = table();
|
||||
// A degree of latitude is about 111 km everywhere; a degree of longitude
|
||||
// shrinks towards the poles, so more cells are needed to cover the radius.
|
||||
let lat_span = (MAX_DISTANCE_KM / 111.0).ceil() as i32;
|
||||
let lon_span = {
|
||||
let scale = latitude.to_radians().cos().abs().max(0.01);
|
||||
((MAX_DISTANCE_KM / (111.0 * scale)).ceil() as i32).min(180)
|
||||
};
|
||||
let (row, column) = cell(latitude, longitude);
|
||||
let mut best: Option<(f64, &City)> = None;
|
||||
for dlat in -lat_span..=lat_span {
|
||||
for dlon in -lon_span..=lon_span {
|
||||
// Longitude wraps at the antimeridian: -181 is 179.
|
||||
let lon_cell = (column + dlon + 180).rem_euclid(360) - 180;
|
||||
let Some(indices) = table.cells.get(&(row + dlat, lon_cell)) else {
|
||||
continue;
|
||||
};
|
||||
for &index in indices {
|
||||
let city = &table.cities[index];
|
||||
let d = distance_km((latitude, longitude), (city.latitude, city.longitude));
|
||||
if d <= MAX_DISTANCE_KM && best.is_none_or(|(bd, _)| d < bd) {
|
||||
best = Some((d, city));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, city)| city)
|
||||
}
|
||||
|
||||
/// The town with this GeoNames id.
|
||||
pub fn city(id: u32) -> Option<&'static City> {
|
||||
let cities = &table().cities;
|
||||
// The table is sorted by id.
|
||||
cities
|
||||
.binary_search_by_key(&id, |city| city.id)
|
||||
.ok()
|
||||
.map(|index| &cities[index])
|
||||
}
|
||||
|
||||
/// The English ISO 3166-1 name of a country code, such as "Germany" for "DE".
|
||||
pub fn country_name(code: &str) -> Option<&'static str> {
|
||||
COUNTRIES.lines().find_map(|line| {
|
||||
let (c, name) = line.split_once('\t')?;
|
||||
(c == code).then_some(name)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_position_in_a_town_resolves_to_that_town() {
|
||||
let berlin = nearest(52.52, 13.405).expect("Berlin is a town");
|
||||
assert_eq!(berlin.name, "Berlin");
|
||||
assert_eq!(berlin.country, "DE");
|
||||
assert_eq!(city(berlin.id), Some(berlin));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_open_sea_has_no_town() {
|
||||
assert_eq!(nearest(0.0, -30.0), None);
|
||||
assert_eq!(nearest(f64::NAN, 0.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_town_across_the_antimeridian_is_still_found() {
|
||||
// Just east of 180°, the nearest towns in Fiji are west of it.
|
||||
let town = nearest(-16.8, 179.99);
|
||||
assert!(town.is_none_or(|t| t.country == "FJ"));
|
||||
let suva = nearest(-18.14, 178.44).expect("Suva is a town");
|
||||
assert_eq!(suva.country, "FJ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_town_has_a_named_country() {
|
||||
for city in &table().cities {
|
||||
assert!(
|
||||
country_name(city.country).is_some(),
|
||||
"{} has no country name for {}",
|
||||
city.name,
|
||||
city.country
|
||||
);
|
||||
}
|
||||
assert_eq!(country_name("DE"), Some("Germany"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distances_are_great_circle_km() {
|
||||
let d = distance_km((52.52, 13.405), (48.137, 11.575));
|
||||
assert!(
|
||||
(d - 504.0).abs() < 5.0,
|
||||
"Berlin to Munich is about 504 km, got {d}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! The machine profile that travels with a device (features.md 5.3).
|
||||
//! The machine profile that travels with a device.
|
||||
//!
|
||||
//! Sync folder mappings, pins, and the settings that shape them live in
|
||||
//! `cache.db` and `config.json` — both on the local disk. When that disk dies,
|
||||
@@ -17,9 +17,20 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// File name of the profile inside the device root folder.
|
||||
use crate::mounts::MountMode;
|
||||
|
||||
/// File name of the profile.
|
||||
pub const PROFILE_FILE_NAME: &str = "profile.json";
|
||||
|
||||
/// Folder, directly under the device root, that holds the profile.
|
||||
///
|
||||
/// The API refuses `Cannot create file at the root of a device` (HTTP 422), so
|
||||
/// the profile cannot live at the device root itself even though that is where
|
||||
/// it logically belongs (docs/BUGS.md B68/B78). A dotted name keeps it out of
|
||||
/// the way in the web UI, and the restore ignores it when listing the device's
|
||||
/// restorable folders.
|
||||
pub const PROFILE_DIR_NAME: &str = ".proton-drive-linux";
|
||||
|
||||
/// Format version of the profile document.
|
||||
///
|
||||
/// Bumped only for changes a *reader* cannot survive. Adding fields does not
|
||||
@@ -39,8 +50,9 @@ pub struct ProfileFolder {
|
||||
/// Absolute local path it was synced to. A *suggestion* on restore: another
|
||||
/// machine may have a different username, home, or mount layout.
|
||||
pub local_path: String,
|
||||
/// `mirror` or `ondemand`.
|
||||
pub mode: String,
|
||||
/// `mirror` or `ondemand`. Unknown values written by a newer client do not
|
||||
/// invalidate the rest of the profile.
|
||||
pub mode: MountMode,
|
||||
}
|
||||
|
||||
/// One pinned node (P5 pins), as `pins` stores it.
|
||||
@@ -114,7 +126,7 @@ mod tests {
|
||||
folders: vec![ProfileFolder {
|
||||
remote_uid: "vol~link".to_string(),
|
||||
local_path: "/home/nils/Documents".to_string(),
|
||||
mode: "mirror".to_string(),
|
||||
mode: MountMode::Mirror,
|
||||
}],
|
||||
pins: vec![ProfilePin {
|
||||
uid: "vol~pin".to_string(),
|
||||
@@ -133,6 +145,52 @@ mod tests {
|
||||
assert_eq!(Profile::parse(&p.to_bytes().unwrap()).unwrap(), p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_keeps_legacy_mode_bytes_and_parses_old_documents() {
|
||||
let bytes = sample().to_bytes().unwrap();
|
||||
let json = String::from_utf8(bytes).unwrap();
|
||||
assert!(json.contains(r#""mode": "mirror""#));
|
||||
|
||||
let old = br#"{
|
||||
"version": 1,
|
||||
"device_uid": "dev-1",
|
||||
"hostname": "old-client",
|
||||
"saved_at": 1,
|
||||
"folders": [
|
||||
{
|
||||
"remote_uid": "vol~mirror",
|
||||
"local_path": "/home/me/Mirror",
|
||||
"mode": "mirror"
|
||||
},
|
||||
{
|
||||
"remote_uid": "vol~ondemand",
|
||||
"local_path": "/home/me/Cloud",
|
||||
"mode": "ondemand"
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
let profile = Profile::parse(old).unwrap();
|
||||
assert_eq!(profile.folders[0].mode, MountMode::Mirror);
|
||||
assert_eq!(profile.folders[1].mode, MountMode::OnDemand);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_folder_mode_does_not_reject_profile() {
|
||||
let json = br#"{
|
||||
"version": 1,
|
||||
"device_uid": "dev-1",
|
||||
"hostname": "future-client",
|
||||
"saved_at": 1,
|
||||
"folders": [{
|
||||
"remote_uid": "vol~future",
|
||||
"local_path": "/home/me/Future",
|
||||
"mode": "streaming"
|
||||
}]
|
||||
}"#;
|
||||
let profile = Profile::parse(json).unwrap();
|
||||
assert_eq!(profile.folders[0].mode, MountMode::Unknown);
|
||||
}
|
||||
|
||||
/// A field added by a later client must not break this one's restore: the
|
||||
/// common subset is still usable, which is the whole reason `version` is
|
||||
/// reserved for breaking changes only.
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
//! A persistent [`CacheRepository`] for the SDK's Drive entity cache.
|
||||
//!
|
||||
//! The SDK caches decrypted node metadata (name, size, parent, the share whose
|
||||
//! membership signs operations on it) so navigating a tree does not re-fetch and
|
||||
//! re-decrypt what it already resolved. That cache defaults to memory, which
|
||||
//! means every daemon restart pays for the whole walk again. This backs it with
|
||||
//! SQLite so it survives one.
|
||||
//!
|
||||
//! **Its own database file, not `cache.db`.** The daemon's `Db` is a single
|
||||
//! `Mutex<Connection>` shared by every FUSE thread and the control socket; SDK
|
||||
//! cache traffic is frequent, small, and entirely reconstructible, so putting it
|
||||
//! on that mutex would add contention to the daemon's hottest lock (and to a
|
||||
//! write-ahead log whose growth is already sensitive to how long that lock is
|
||||
//! held) for no durability benefit. A separate file also means no schema
|
||||
//! migration: this store can be deleted and rebuilt at any time.
|
||||
//!
|
||||
//! **What it holds.** Decrypted metadata, never key material — the SDK keeps
|
||||
//! node keys and hash keys in a separate in-memory secret cache that nothing
|
||||
//! here can reach. The same names and sizes are already persisted in `cache.db`
|
||||
//! (`nodes`), so this file widens no exposure that the state directory did not
|
||||
//! already have; `AppDirs::ensure` is what keeps that directory private.
|
||||
//!
|
||||
//! **Staleness.** A persisted cache outlives the process, so an entry can
|
||||
//! describe a node another client has since changed. The daemon's event loop is
|
||||
//! what closes that: it resumes from a persisted cursor and calls the SDK's
|
||||
//! `invalidate_caches_for_event` for every event it replays, including the ones
|
||||
//! that happened while the daemon was down. The one case that leaves no trail is
|
||||
//! a *seeded* cursor — a first-ever mount, or a cursor the daemon lost — where
|
||||
//! nothing says what changed, so the caller [`clear`](SdkCache::clear)s this
|
||||
//! store instead of trusting it.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use parking_lot::Mutex;
|
||||
use proton_sdk::cache::CacheRepository;
|
||||
use proton_sdk::error::{ProtonError, Result as SdkResult};
|
||||
use rusqlite::{Connection, params};
|
||||
|
||||
use crate::config::AppDirs;
|
||||
use crate::error::Result;
|
||||
|
||||
/// Process-wide handle, so the daemon's event loop can clear the same store the
|
||||
/// client is writing through without threading it down every call.
|
||||
static SHARED: OnceLock<Arc<SdkCache>> = OnceLock::new();
|
||||
|
||||
/// SQLite-backed entity cache for the SDK.
|
||||
pub struct SdkCache {
|
||||
conn: Mutex<Connection>,
|
||||
}
|
||||
|
||||
impl SdkCache {
|
||||
/// Open (creating if needed) the entity cache under the app's state
|
||||
/// directory. The first call wins; later calls return the same handle, which
|
||||
/// is what lets the event loop clear the store the Drive client is using.
|
||||
pub fn shared(dirs: &AppDirs) -> Result<Arc<Self>> {
|
||||
if let Some(existing) = SHARED.get() {
|
||||
return Ok(existing.clone());
|
||||
}
|
||||
let cache = Arc::new(Self::open(&dirs.state_dir().join("sdk_cache.db"))?);
|
||||
Ok(SHARED.get_or_init(|| cache).clone())
|
||||
}
|
||||
|
||||
/// The already-open shared cache, if one was opened in this process.
|
||||
pub fn opened() -> Option<Arc<Self>> {
|
||||
SHARED.get().cloned()
|
||||
}
|
||||
|
||||
/// Open the store at `path`, creating its schema.
|
||||
pub fn open(path: &Path) -> Result<Self> {
|
||||
let conn = Connection::open(path)?;
|
||||
conn.pragma_update(None, "journal_mode", "WAL")?;
|
||||
conn.pragma_update(None, "synchronous", "NORMAL")?;
|
||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||
// Tags cascade with their entry, so a `set` that replaces a value cannot
|
||||
// leave the old value's tags behind pointing at the new one.
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS entry (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS tag (
|
||||
key TEXT NOT NULL REFERENCES entry(key) ON DELETE CASCADE,
|
||||
tag TEXT NOT NULL,
|
||||
PRIMARY KEY (key, tag)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tag_tag ON tag(tag);",
|
||||
)?;
|
||||
Ok(Self {
|
||||
conn: Mutex::new(conn),
|
||||
})
|
||||
}
|
||||
|
||||
/// Drop every entry. Used when the daemon cannot prove the cache is current
|
||||
/// — a seeded event cursor — and on logout.
|
||||
pub fn clear_now(&self) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute_batch("DELETE FROM tag; DELETE FROM entry;")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_now(&self, key: &str, value: &str, tags: &[String]) -> Result<()> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(
|
||||
"INSERT INTO entry (key, value) VALUES (?1, ?2)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
params![key, value],
|
||||
)?;
|
||||
tx.execute("DELETE FROM tag WHERE key = ?1", params![key])?;
|
||||
{
|
||||
let mut stmt = tx.prepare("INSERT INTO tag (key, tag) VALUES (?1, ?2)")?;
|
||||
for tag in tags {
|
||||
stmt.execute(params![key, tag])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_now(&self, key: &str) -> Result<Option<String>> {
|
||||
let conn = self.conn.lock();
|
||||
let mut stmt = conn.prepare("SELECT value FROM entry WHERE key = ?1")?;
|
||||
let mut rows = stmt.query(params![key])?;
|
||||
match rows.next()? {
|
||||
Some(row) => Ok(Some(row.get(0)?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_now(&self, key: &str) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute("DELETE FROM entry WHERE key = ?1", params![key])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_by_tag_now(&self, tag: &str) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"DELETE FROM entry WHERE key IN (SELECT key FROM tag WHERE tag = ?1)",
|
||||
params![tag],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Entries carrying **all** of `tags` — the set intersection the SDK's
|
||||
/// `get_by_tags` contract specifies, not a union.
|
||||
fn get_by_tags_now(&self, tags: &[String]) -> Result<Vec<(String, String)>> {
|
||||
if tags.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let conn = self.conn.lock();
|
||||
let placeholders = vec!["?"; tags.len()].join(",");
|
||||
let sql = format!(
|
||||
"SELECT e.key, e.value FROM entry e
|
||||
JOIN tag t ON t.key = e.key
|
||||
WHERE t.tag IN ({placeholders})
|
||||
GROUP BY e.key
|
||||
HAVING COUNT(DISTINCT t.tag) = {}",
|
||||
tags.len()
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(rusqlite::params_from_iter(tags), |r| {
|
||||
Ok((r.get(0)?, r.get(1)?))
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
out.push(row?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// A cache failure is never fatal to the operation that triggered it — the SDK
|
||||
/// treats this repository as best-effort — but it still has to come back as the
|
||||
/// SDK's error type.
|
||||
fn sdk_error(e: crate::error::Error) -> ProtonError {
|
||||
ProtonError::invalid_operation(format!("sdk entity cache: {e}"))
|
||||
}
|
||||
|
||||
// Every method is a local SQLite statement on its own connection: microseconds,
|
||||
// no network, no shared lock with the daemon's database. Running them inline on
|
||||
// the caller's task is cheaper than the thread hop that moving them off would
|
||||
// cost.
|
||||
#[async_trait]
|
||||
impl CacheRepository for SdkCache {
|
||||
async fn set(&self, key: &str, value: &str, tags: &[String]) -> SdkResult<()> {
|
||||
self.set_now(key, value, tags).map_err(sdk_error)
|
||||
}
|
||||
|
||||
async fn get(&self, key: &str) -> SdkResult<Option<String>> {
|
||||
self.get_now(key).map_err(sdk_error)
|
||||
}
|
||||
|
||||
async fn remove(&self, key: &str) -> SdkResult<()> {
|
||||
self.remove_now(key).map_err(sdk_error)
|
||||
}
|
||||
|
||||
async fn remove_by_tag(&self, tag: &str) -> SdkResult<()> {
|
||||
self.remove_by_tag_now(tag).map_err(sdk_error)
|
||||
}
|
||||
|
||||
async fn clear(&self) -> SdkResult<()> {
|
||||
self.clear_now().map_err(sdk_error)
|
||||
}
|
||||
|
||||
async fn get_by_tags(&self, tags: &[String]) -> SdkResult<Vec<(String, String)>> {
|
||||
self.get_by_tags_now(tags).map_err(sdk_error)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cache() -> SdkCache {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.pragma_update(None, "foreign_keys", "ON").unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE entry (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
||||
CREATE TABLE tag (
|
||||
key TEXT NOT NULL REFERENCES entry(key) ON DELETE CASCADE,
|
||||
tag TEXT NOT NULL,
|
||||
PRIMARY KEY (key, tag)
|
||||
);",
|
||||
)
|
||||
.unwrap();
|
||||
SdkCache {
|
||||
conn: Mutex::new(conn),
|
||||
}
|
||||
}
|
||||
|
||||
fn tags(values: &[&str]) -> Vec<String> {
|
||||
values.iter().map(|t| t.to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_replaces_the_value_and_its_tags() {
|
||||
let cache = cache();
|
||||
cache.set_now("k", "one", &tags(&["a", "b"])).unwrap();
|
||||
cache.set_now("k", "two", &tags(&["b"])).unwrap();
|
||||
|
||||
assert_eq!(cache.get_now("k").unwrap().as_deref(), Some("two"));
|
||||
// The tag dropped by the second write must not still select the entry —
|
||||
// that is how a stale invalidation would miss it.
|
||||
assert!(cache.get_by_tags_now(&tags(&["a"])).unwrap().is_empty());
|
||||
assert_eq!(cache.get_by_tags_now(&tags(&["b"])).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_by_tags_intersects_rather_than_unions() {
|
||||
let cache = cache();
|
||||
cache.set_now("both", "1", &tags(&["x", "y"])).unwrap();
|
||||
cache.set_now("one", "2", &tags(&["x"])).unwrap();
|
||||
|
||||
let hits = cache.get_by_tags_now(&tags(&["x", "y"])).unwrap();
|
||||
assert_eq!(hits, vec![("both".to_string(), "1".to_string())]);
|
||||
// An empty tag list selects nothing, matching the SDK contract.
|
||||
assert!(cache.get_by_tags_now(&[]).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_by_tag_takes_the_entry_and_its_tags() {
|
||||
let cache = cache();
|
||||
cache.set_now("k", "v", &tags(&["gone"])).unwrap();
|
||||
cache.remove_by_tag_now("gone").unwrap();
|
||||
|
||||
assert!(cache.get_now("k").unwrap().is_none());
|
||||
assert!(cache.get_by_tags_now(&tags(&["gone"])).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_empties_the_store() {
|
||||
let cache = cache();
|
||||
cache.set_now("a", "1", &tags(&["t"])).unwrap();
|
||||
cache.set_now("b", "2", &[]).unwrap();
|
||||
cache.clear_now().unwrap();
|
||||
|
||||
assert!(cache.get_now("a").unwrap().is_none());
|
||||
assert!(cache.get_now("b").unwrap().is_none());
|
||||
}
|
||||
}
|
||||
@@ -47,10 +47,19 @@ fn systemctl_query(args: &[&str]) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Enable the service so it survives reboots/logins and start it now. Called on
|
||||
/// successful login.
|
||||
/// Enable the service so it survives reboots/logins, and bring it up now.
|
||||
/// Called on successful login.
|
||||
///
|
||||
/// `restart` rather than `enable --now`: the unit may already be active but
|
||||
/// parked in the daemon's login-wait backoff (`pdfs daemon` sleeps between
|
||||
/// keyring checks). `--now` is a no-op on an active unit, so the daemon would
|
||||
/// keep sleeping and the mount would not appear until the next poll fired.
|
||||
/// Restarting makes the new session take effect immediately, and starts the
|
||||
/// unit when it is not running at all. `reenable` for the reason given on
|
||||
/// [`enable`].
|
||||
pub fn enable_start() -> bool {
|
||||
systemctl(&["enable", "--now"])
|
||||
let enabled = systemctl(&["reenable"]);
|
||||
systemctl(&["restart"]) && enabled
|
||||
}
|
||||
|
||||
/// Stop the running mount without disabling it. Called by the tray's
|
||||
@@ -89,8 +98,14 @@ pub fn is_enabled() -> bool {
|
||||
/// Enable the service so it auto-starts on future logins, without necessarily
|
||||
/// starting it now beyond what `enable` does. Used by the Settings "Start on
|
||||
/// login" switch; pairs with [`disable`](disable).
|
||||
///
|
||||
/// `reenable` rather than `enable`: older installs were enabled
|
||||
/// into `default.target.wants`, and a plain `enable` would add the new
|
||||
/// `graphical-session.target.wants` link while leaving the old one to start the
|
||||
/// unit too early. `reenable` drops every existing link and recreates them from
|
||||
/// the current `[Install]` section.
|
||||
pub fn enable() -> bool {
|
||||
systemctl(&["enable"])
|
||||
systemctl(&["reenable"])
|
||||
}
|
||||
|
||||
/// Disable auto-start without stopping the running mount. Used by the Settings
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
//! Telling photos apart by what they show rather than by their bytes.
|
||||
//!
|
||||
//! The server's content hash only matches byte-identical files. A photo that
|
||||
//! was resized, re-encoded or sent through a messenger is another file, so the
|
||||
//! similar-photo finder hashes each thumbnail instead: a 64-bit difference
|
||||
//! hash ("dHash"), which compares neighbouring brightness in a 9×8 grid. Two
|
||||
//! copies of one picture land a few bits apart, and different pictures about
|
||||
//! half of the bits apart.
|
||||
|
||||
/// Hashes this many bits apart or fewer show the same picture.
|
||||
pub const SIMILAR_BITS: u32 = 6;
|
||||
|
||||
/// Stored for a photo whose thumbnail was looked at and gave no usable hash
|
||||
/// (it couldn't be decoded, or is one flat colour), so it isn't looked at
|
||||
/// again. A real hash is never 0: a picture that would hash to 0 has no
|
||||
/// brightness changes, which [`difference_hash`] turns down as flat.
|
||||
pub const NO_HASH: u64 = 0;
|
||||
|
||||
/// Samples of the grid that differ by less than this in brightness, all of
|
||||
/// them, make a flat picture — a black frame, a blank page — whose hash is
|
||||
/// noise and would match every other flat picture.
|
||||
const FLAT: u8 = 8;
|
||||
|
||||
/// The difference hash of an 8-bit greyscale image `width` px wide, rows
|
||||
/// stored one after another in `luma`. `None` for an empty or flat image.
|
||||
pub fn difference_hash(width: usize, height: usize, luma: &[u8]) -> Option<u64> {
|
||||
if width == 0 || height == 0 || luma.len() < width * height {
|
||||
return None;
|
||||
}
|
||||
// Box-average down to 9×8, so every source pixel counts once and the hash
|
||||
// doesn't depend on which pixels a nearest-neighbour pick happens to hit.
|
||||
let mut grid = [[0u8; 9]; 8];
|
||||
for (gy, row) in grid.iter_mut().enumerate() {
|
||||
let (y0, y1) = span(gy, 8, height);
|
||||
for (gx, cell) in row.iter_mut().enumerate() {
|
||||
let (x0, x1) = span(gx, 9, width);
|
||||
let mut sum = 0u64;
|
||||
for y in y0..y1 {
|
||||
sum += luma[y * width + x0..y * width + x1]
|
||||
.iter()
|
||||
.map(|&v| u64::from(v))
|
||||
.sum::<u64>();
|
||||
}
|
||||
*cell = (sum / ((y1 - y0) * (x1 - x0)) as u64) as u8;
|
||||
}
|
||||
}
|
||||
let (min, max) = grid
|
||||
.iter()
|
||||
.flatten()
|
||||
.fold((u8::MAX, 0), |(min, max), &v| (min.min(v), max.max(v)));
|
||||
if max - min < FLAT {
|
||||
return None;
|
||||
}
|
||||
let mut hash = 0u64;
|
||||
for row in &grid {
|
||||
for pair in row.windows(2) {
|
||||
hash = hash << 1 | u64::from(pair[0] < pair[1]);
|
||||
}
|
||||
}
|
||||
(hash != NO_HASH).then_some(hash)
|
||||
}
|
||||
|
||||
/// The source pixels `from..to` that cell `i` of `cells` covers along an edge
|
||||
/// of `len` px; never empty, even for an edge shorter than the grid.
|
||||
fn span(i: usize, cells: usize, len: usize) -> (usize, usize) {
|
||||
let from = (i * len / cells).min(len - 1);
|
||||
let to = ((i + 1) * len / cells).max(from + 1);
|
||||
(from, to)
|
||||
}
|
||||
|
||||
/// Whether two hashes show the same picture.
|
||||
pub fn alike(a: u64, b: u64) -> bool {
|
||||
(a ^ b).count_ones() <= SIMILAR_BITS
|
||||
}
|
||||
|
||||
/// Group `hashes` into sets of alike ones, by index, each set in index order
|
||||
/// and the sets by their first index. A hash alike to none other is in no
|
||||
/// set. Alike is taken as catching: a burst of shots each a little different
|
||||
/// from the last is one set.
|
||||
pub fn sets(hashes: &[u64]) -> Vec<Vec<usize>> {
|
||||
// Two hashes at most SIMILAR_BITS apart agree on at least one of their
|
||||
// eight bytes, as long as SIMILAR_BITS < 8. So only hashes sharing some
|
||||
// byte are compared, not every pair of the library.
|
||||
const _: () = assert!(SIMILAR_BITS < 8);
|
||||
let mut parent: Vec<usize> = (0..hashes.len()).collect();
|
||||
fn root(parent: &mut [usize], mut i: usize) -> usize {
|
||||
while parent[i] != i {
|
||||
parent[i] = parent[parent[i]];
|
||||
i = parent[i];
|
||||
}
|
||||
i
|
||||
}
|
||||
for byte in 0..8 {
|
||||
let mut buckets: Vec<Vec<usize>> = vec![Vec::new(); 256];
|
||||
for (i, &hash) in hashes.iter().enumerate() {
|
||||
buckets[(hash >> (byte * 8)) as u8 as usize].push(i);
|
||||
}
|
||||
for bucket in &buckets {
|
||||
for (n, &a) in bucket.iter().enumerate() {
|
||||
for &b in &bucket[n + 1..] {
|
||||
if alike(hashes[a], hashes[b]) {
|
||||
let (ra, rb) = (root(&mut parent, a), root(&mut parent, b));
|
||||
if ra != rb {
|
||||
parent[ra.max(rb)] = ra.min(rb);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut by_root: std::collections::BTreeMap<usize, Vec<usize>> = Default::default();
|
||||
for i in 0..hashes.len() {
|
||||
let r = root(&mut parent, i);
|
||||
by_root.entry(r).or_default().push(i);
|
||||
}
|
||||
by_root.into_values().filter(|set| set.len() > 1).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A 90×80 picture: a bright disc on a gradient.
|
||||
fn picture(width: usize, height: usize) -> Vec<u8> {
|
||||
let mut luma = Vec::with_capacity(width * height);
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let (fx, fy) = (x as f64 / width as f64, y as f64 / height as f64);
|
||||
let disc = ((fx - 0.3).powi(2) + (fy - 0.6).powi(2)).sqrt() < 0.2;
|
||||
let v = if disc { 240.0 } else { 40.0 + 120.0 * fx * fy };
|
||||
luma.push(v as u8);
|
||||
}
|
||||
}
|
||||
luma
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_resized_copy_hashes_alike() {
|
||||
let big = difference_hash(900, 800, &picture(900, 800)).unwrap();
|
||||
let small = difference_hash(91, 79, &picture(91, 79)).unwrap();
|
||||
assert!(
|
||||
alike(big, small),
|
||||
"{} bits apart",
|
||||
(big ^ small).count_ones()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_different_picture_hashes_apart() {
|
||||
let one = difference_hash(90, 80, &picture(90, 80)).unwrap();
|
||||
let mirrored: Vec<u8> = picture(90, 80)
|
||||
.chunks(90)
|
||||
.flat_map(|row| row.iter().rev().copied().collect::<Vec<_>>())
|
||||
.collect();
|
||||
let other = difference_hash(90, 80, &mirrored).unwrap();
|
||||
assert!(!alike(one, other));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flat_picture_has_no_hash() {
|
||||
assert_eq!(difference_hash(64, 64, &[17; 64 * 64]), None);
|
||||
assert_eq!(difference_hash(0, 0, &[]), None);
|
||||
assert_eq!(difference_hash(4, 4, &[0; 3]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picture_smaller_than_the_grid_still_hashes() {
|
||||
let luma: Vec<u8> = (0..4 * 3).map(|i| (i * 20) as u8).collect();
|
||||
assert!(difference_hash(4, 3, &luma).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alike_hashes_form_sets_and_catch_on() {
|
||||
let a = 0x0123_4567_89ab_cdef_u64;
|
||||
let hashes = [
|
||||
a,
|
||||
!a,
|
||||
a ^ 0b111,
|
||||
a ^ 0b111 ^ (0b1111 << 40),
|
||||
0x5555_0000_ffff_aaaa,
|
||||
];
|
||||
assert_eq!(sets(&hashes), vec![vec![0, 2, 3]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hashes_apart_in_most_bytes_are_still_found() {
|
||||
// One bit apart in each of six bytes: six bits in all, and only the
|
||||
// top two bytes agree.
|
||||
let a = 1u64 << 63;
|
||||
let b = (0..6).fold(a, |h, byte| h | 1 << (byte * 8));
|
||||
assert_eq!(sets(&[a, b]), vec![vec![0, 1]]);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Selective-sync ignore rules for mirror folders (features.md 1.1).
|
||||
//! Selective-sync ignore rules for mirror folders.
|
||||
//!
|
||||
//! A mirror folder otherwise uploads every path beneath its root, which for a
|
||||
//! project directory means `node_modules/`, `.git/`, and `target/` — quota
|
||||
@@ -45,18 +45,30 @@ pub const DEFAULT_IGNORE_PATTERNS: &[&str] = &[
|
||||
"Thumbs.db",
|
||||
];
|
||||
|
||||
/// Suffixes of files that are, by convention, incomplete or throwaway while they
|
||||
/// wear the name: a download in flight, an editor's swap or backup, an atomic
|
||||
/// write's scratch copy. On an on-demand mount these should never seal a remote
|
||||
/// revision — the app renames the finished file to its real name, and only that
|
||||
/// belongs on Drive. See [`is_transient_name`] and docs/BUGS.md B70.
|
||||
/// Suffixes that mean a file is incomplete *by construction*: only the program
|
||||
/// writing it gives it this name, only while the write is in flight, and the
|
||||
/// finished article always arrives under a different one. On an on-demand mount
|
||||
/// these should never seal a remote revision — the app renames the finished file
|
||||
/// to its real name, and only that belongs on Drive. See [`is_transient_name`]
|
||||
/// and docs/BUGS.md B70.
|
||||
///
|
||||
/// `.tmp`/`.temp` are deliberately **not** here, though they are in
|
||||
/// [`DEFAULT_IGNORE_PATTERNS`]. The two lists answer different questions. Skipping
|
||||
/// a `report.tmp` during a mirror reconcile costs nothing — the file stays on
|
||||
/// disk, and a later pass picks it up if the rules change. Parking one on the
|
||||
/// on-demand mount is irreversible in practice: the only exit is a rename to a
|
||||
/// finished name, and a file that is simply *called* `index.tmp` — a static-site
|
||||
/// template, a tool's output — never gets one. It then never reaches Drive at
|
||||
/// all, silently, while its bytes pin `staging/`. That is what a real Takeout
|
||||
/// tree of `index.tmp`/`preview.tmp` gallery templates did: 43 creates parked
|
||||
/// forever. The generic-scratch case they were added for renames within
|
||||
/// milliseconds, so it never needed the park; the download suffixes below, which
|
||||
/// stay in flight for minutes, are what B70 was actually about.
|
||||
const TRANSIENT_SUFFIXES: &[&str] = &[
|
||||
".crdownload", // Chromium/Brave/Edge
|
||||
".part", // Firefox, wget, aria2c
|
||||
".partial", // some downloaders / older IE
|
||||
".download", // Safari / macOS
|
||||
".tmp", // generic scratch
|
||||
".temp", // generic scratch
|
||||
".swp", // vim swap
|
||||
".swx", // vim swap (secondary)
|
||||
];
|
||||
@@ -84,6 +96,26 @@ pub fn is_transient_name(name: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Whether `name` is scratch another program writes and throws away: the
|
||||
/// [`is_transient_name`] set, plus the save-by-rename temporaries of GTK, Qt,
|
||||
/// Chromium and Electron, generic `.tmp` files and Emacs lock files. None of
|
||||
/// them is a file the user made, so a front-end can hide them from a history
|
||||
/// of what happened to the user's files.
|
||||
pub fn is_scratch_name(name: &str) -> bool {
|
||||
if is_transient_name(name) {
|
||||
return true;
|
||||
}
|
||||
let lower = name.to_ascii_lowercase();
|
||||
lower.ends_with(".tmp")
|
||||
|| lower.ends_with(".temp")
|
||||
|| name.starts_with(".goutputstream-")
|
||||
|| name.starts_with(".org.chromium.")
|
||||
|| name.starts_with(".com.google.chrome.")
|
||||
|| name.starts_with(".#")
|
||||
|| (name.starts_with('#') && name.ends_with('#') && name.len() > 1)
|
||||
|| (name.starts_with('.') && name.contains(".tmp."))
|
||||
}
|
||||
|
||||
/// Filenames accepted as a folder's ignore file, in precedence order.
|
||||
const IGNORE_FILE_NAMES: &[&str] = &[".pdfsignore", ".protonignore"];
|
||||
|
||||
@@ -319,8 +351,6 @@ mod tests {
|
||||
"movie.mkv.part",
|
||||
"big.iso.partial",
|
||||
"photo.jpg.download",
|
||||
"export.xml.tmp",
|
||||
"notes.TEMP",
|
||||
".notes.txt.swp",
|
||||
"doc.txt~",
|
||||
".~lock.report.odt#",
|
||||
@@ -331,6 +361,25 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_by_rename_temporaries_are_scratch() {
|
||||
for name in [
|
||||
".goutputstream-4XK2B2",
|
||||
".org.chromium.Chromium.aBc123",
|
||||
"~$report.docx",
|
||||
".~lock.budget.ods#",
|
||||
".#notes.org",
|
||||
"#notes.org#",
|
||||
"cache.tmp",
|
||||
"page.html.crdownload",
|
||||
] {
|
||||
assert!(is_scratch_name(name), "{name}");
|
||||
}
|
||||
for name in ["report.docx", "notes.org", "#1 hits.txt", ".bashrc"] {
|
||||
assert!(!is_scratch_name(name), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finished_names_are_not_transient() {
|
||||
for name in [
|
||||
@@ -340,6 +389,10 @@ mod tests {
|
||||
"archive.partly.zip", // ".partly" is not ".part"
|
||||
"template.dotx",
|
||||
"readme",
|
||||
// A file that is *named* `.tmp` and never renamed: parking it would
|
||||
// keep it off Drive for good.
|
||||
"index.tmp",
|
||||
"notes.TEMP",
|
||||
] {
|
||||
assert!(!is_transient_name(name), "{name} should not be transient");
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,20 @@
|
||||
//! Talking to a running `pdfs-tray`.
|
||||
//!
|
||||
//! The tray binds [`AppDirs::tray_socket`] to keep a single instance. The same
|
||||
//! socket takes one command, [`QUIT`], so the desktop app can take the icon
|
||||
//! away when the user hides it or stops Proton Drive.
|
||||
|
||||
use std::io::Write;
|
||||
use std::os::unix::net::UnixStream;
|
||||
|
||||
use crate::config::AppDirs;
|
||||
|
||||
/// The line that asks the tray to exit.
|
||||
pub const QUIT: &str = "quit";
|
||||
|
||||
/// Ask a running tray to exit. False when no tray answers.
|
||||
pub fn quit(dirs: &AppDirs) -> bool {
|
||||
UnixStream::connect(dirs.tray_socket())
|
||||
.and_then(|mut stream| stream.write_all(format!("{QUIT}\n").as_bytes()))
|
||||
.is_ok()
|
||||
}
|
||||
@@ -5,16 +5,26 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
description = "FUSE on-demand filesystem for Proton Drive"
|
||||
|
||||
[dependencies]
|
||||
pdfs-core.workspace = true
|
||||
zip.workspace = true
|
||||
sha1.workspace = true
|
||||
proton-drive-rs.workspace = true
|
||||
futures.workspace = true
|
||||
async-trait.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
fuser.workspace = true
|
||||
libc.workspace = true
|
||||
serde_json.workspace = true
|
||||
base64.workspace = true
|
||||
image.workspace = true
|
||||
notify = "8"
|
||||
parking_lot.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tracing-subscriber.workspace = true
|
||||
@@ -0,0 +1,373 @@
|
||||
//! Photo albums: the album listing, and one album's contents.
|
||||
//!
|
||||
//! Both are persisted projections of what the photos volume reports, refreshed
|
||||
//! on the same stale-while-serving rule as the timeline
|
||||
//! ([`crate::photos`]): a listing already on disk is served immediately and
|
||||
//! refreshed behind the caller, and only an empty one waits on the network.
|
||||
//!
|
||||
//! An album is a folder node carrying album properties, so it has a name like
|
||||
//! any other node; the properties supply the photo count and the cover. Albums
|
||||
//! other people share with us come from a second listing and live on *their*
|
||||
//! photos volume — which is why an album's photos are persisted per album rather
|
||||
//! than looked up in our own timeline, where a shared album's photos never
|
||||
//! appear.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use pdfs_core::control::{AlbumInfo, PhotoItem, Topic};
|
||||
use pdfs_core::db::StoredAlbum;
|
||||
use pdfs_core::{CoreError, CoreResult};
|
||||
|
||||
use proton_drive_rs::NodeKind;
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
use tracing::warn;
|
||||
|
||||
use super::{
|
||||
ALBUM_SYNCED_PREFIX, ALBUMS_SYNCED_MS, Core, PHOTOS_AVAILABLE, PHOTOS_SYNCED_MS,
|
||||
TIMELINE_ENRICH_CHUNK, TIMELINE_TTL, now_ms, parse_uid,
|
||||
};
|
||||
|
||||
impl Core {
|
||||
/// The album listing, served from disk and refreshed behind the caller when
|
||||
/// stale. `None` means the account has no photos volume — the same signal
|
||||
/// [`Core::photos_timeline`] gives.
|
||||
pub(crate) fn albums(&self) -> CoreResult<Option<Vec<AlbumInfo>>> {
|
||||
let count = self.db.albums_count().map_err(CoreError::from)?;
|
||||
if count == 0 {
|
||||
// Nothing on disk, so this one request waits for the fetch — unless
|
||||
// the account is already known to have no photos volume.
|
||||
let known_empty = self.db.state_i64(PHOTOS_AVAILABLE).ok().flatten() == Some(0);
|
||||
if known_empty && !self.listing_stale(ALBUMS_SYNCED_MS, TIMELINE_TTL) {
|
||||
return Ok(None);
|
||||
}
|
||||
if !self.rt.block_on(self.refresh_albums())? {
|
||||
return Ok(None);
|
||||
}
|
||||
} else if self.listing_stale(ALBUMS_SYNCED_MS, TIMELINE_TTL) {
|
||||
self.spawn_albums_refresh();
|
||||
}
|
||||
|
||||
let albums = self.db.albums_list().map_err(CoreError::from)?;
|
||||
Ok(Some(albums.into_iter().map(album_info).collect()))
|
||||
}
|
||||
|
||||
/// Re-enumerate the albums — ours and the ones shared with us — and replace
|
||||
/// the persisted listing. Returns false when the account has no photos
|
||||
/// volume.
|
||||
pub(crate) async fn refresh_albums(&self) -> CoreResult<bool> {
|
||||
let photos = self.photos();
|
||||
if photos
|
||||
.get_photos_root()
|
||||
.await
|
||||
.map_err(|e| CoreError::from_api(&e, "photos root"))?
|
||||
.is_none()
|
||||
{
|
||||
let _ = self.db.set_state_i64(PHOTOS_AVAILABLE, 0);
|
||||
let _ = self.db.set_state_i64(PHOTOS_SYNCED_MS, now_ms());
|
||||
let _ = self.db.set_state_i64(ALBUMS_SYNCED_MS, now_ms());
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let mut uids = photos
|
||||
.enumerate_album_node_uids()
|
||||
.await
|
||||
.map_err(|e| CoreError::from_api(&e, "albums"))?;
|
||||
|
||||
// Shared-with-me albums are a separate listing on the sharer's volume.
|
||||
// Best-effort: an account whose sharing listing fails still gets its own
|
||||
// albums rather than an error page.
|
||||
let shared: HashSet<String> = match photos.enumerate_shared_with_me_album_uids().await {
|
||||
Ok(shared) => {
|
||||
let keys = shared.iter().map(|u| u.to_string()).collect();
|
||||
uids.extend(shared);
|
||||
keys
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = %e, "enumerating shared-with-me albums failed");
|
||||
HashSet::new()
|
||||
}
|
||||
};
|
||||
|
||||
// The listings carry uids only; the name, photo count and cover come from
|
||||
// the album nodes themselves. A chunk that fails to resolve drops those
|
||||
// albums from this refresh rather than failing the whole listing.
|
||||
let mut albums: Vec<StoredAlbum> = Vec::with_capacity(uids.len());
|
||||
for chunk in uids.chunks(TIMELINE_ENRICH_CHUNK) {
|
||||
match photos.enumerate_nodes(chunk).await {
|
||||
Ok(nodes) => {
|
||||
for node in nodes {
|
||||
let key = node.uid.to_string();
|
||||
let props = node.album.as_ref();
|
||||
albums.push(StoredAlbum {
|
||||
shared: shared.contains(&key),
|
||||
uid: key,
|
||||
name: node.name,
|
||||
photo_count: props.map_or(0, |a| a.photo_count.max(0) as usize),
|
||||
cover_uid: props
|
||||
.and_then(|a| a.cover_photo_uid.as_ref())
|
||||
.map(|u| u.to_string()),
|
||||
last_activity: props.and_then(|a| a.last_activity_time),
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => warn!(error = %e, "resolving an album chunk failed"),
|
||||
}
|
||||
}
|
||||
|
||||
// Newest activity first, and albums the server gave no activity time for
|
||||
// last — an album nobody has touched belongs below the ones in use. Ties
|
||||
// fall back to the name so the order is stable between refreshes.
|
||||
albums.sort_by(|a, b| {
|
||||
b.last_activity
|
||||
.cmp(&a.last_activity)
|
||||
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||
});
|
||||
|
||||
self.db.albums_replace(&albums).map_err(CoreError::from)?;
|
||||
let _ = self.db.set_state_i64(PHOTOS_AVAILABLE, 1);
|
||||
let _ = self.db.set_state_i64(ALBUMS_SYNCED_MS, now_ms());
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Refresh the album listing off the request path. At most one runs at a time.
|
||||
pub(crate) fn spawn_albums_refresh(&self) {
|
||||
if self.albums_refreshing.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
let core = self.clone();
|
||||
self.rt.spawn(async move {
|
||||
let refreshed = core.refresh_albums().await;
|
||||
core.albums_refreshing.store(false, Ordering::SeqCst);
|
||||
match refreshed {
|
||||
Ok(_) => core.events.publish(&[Topic::Photos]),
|
||||
Err(e) => warn!(error = %e, "background album refresh failed"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A page of one album's photos, newest capture first. The album's contents
|
||||
/// are fetched on first open and refreshed behind the caller afterwards, so
|
||||
/// paging never waits on the network twice.
|
||||
pub(crate) fn album_photos(
|
||||
&self,
|
||||
album: &NodeUid,
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
) -> CoreResult<Vec<PhotoItem>> {
|
||||
let key = album.to_string();
|
||||
let stamp = format!("{ALBUM_SYNCED_PREFIX}{key}");
|
||||
let count = self.db.album_photos_count(&key).map_err(CoreError::from)?;
|
||||
if count == 0 {
|
||||
self.rt.block_on(self.refresh_album(album))?;
|
||||
} else if self.listing_stale(&stamp, TIMELINE_TTL) {
|
||||
let core = self.clone();
|
||||
let album = album.clone();
|
||||
self.rt.spawn(async move {
|
||||
if let Err(e) = core.refresh_album(&album).await {
|
||||
warn!(%album, error = %e, "background album refresh failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let page = self
|
||||
.db
|
||||
.album_photos_page(&key, offset, limit)
|
||||
.map_err(CoreError::from)?;
|
||||
Ok(page.into_iter().map(|p| self.photo_item(p)).collect())
|
||||
}
|
||||
|
||||
/// Make sure every album of ours has its contents on disk, for a filter
|
||||
/// that asks which photos no album holds. Contents are otherwise fetched
|
||||
/// the first time an album is opened, so an album never opened would read
|
||||
/// as empty and its photos as unfiled.
|
||||
///
|
||||
/// Shared-with-me albums are skipped: their photos live on the sharer's
|
||||
/// volume and are never in our timeline. The freshness stamps are no guide
|
||||
/// here — any album change clears all of them — so only an album whose
|
||||
/// server count disagrees with an empty local copy is waited for.
|
||||
pub(crate) fn ensure_album_contents(&self) -> CoreResult<()> {
|
||||
if self.db.albums_count().map_err(CoreError::from)? == 0
|
||||
&& !self.rt.block_on(self.refresh_albums())?
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let albums = self.db.albums_list().map_err(CoreError::from)?;
|
||||
for album in albums.iter().filter(|a| !a.shared && a.photo_count > 0) {
|
||||
let stored = self
|
||||
.db
|
||||
.album_photos_count(&album.uid)
|
||||
.map_err(CoreError::from)?;
|
||||
if stored > 0 {
|
||||
continue;
|
||||
}
|
||||
let Some(uid) = parse_uid(&album.uid) else {
|
||||
continue;
|
||||
};
|
||||
if let Err(error) = self.rt.block_on(self.refresh_album(&uid)) {
|
||||
warn!(album = %album.uid, %error, "fetching an album's contents failed");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Re-enumerate one album's photos and replace its persisted contents.
|
||||
pub(crate) async fn refresh_album(&self, album: &NodeUid) -> CoreResult<()> {
|
||||
let photos = self.photos();
|
||||
let items = photos
|
||||
.enumerate_album(album)
|
||||
.await
|
||||
.map_err(|e| CoreError::from_api(&e, "album"))?;
|
||||
|
||||
// Same enrichment as the timeline: the listing carries a uid and a
|
||||
// capture time, and the gallery needs a name and media type to classify
|
||||
// each entry. Best-effort per chunk.
|
||||
let uids: Vec<NodeUid> = items.iter().map(|it| it.uid.clone()).collect();
|
||||
let mut meta: HashMap<String, (Option<String>, Option<String>)> = HashMap::new();
|
||||
for chunk in uids.chunks(TIMELINE_ENRICH_CHUNK) {
|
||||
match photos.enumerate_nodes(chunk).await {
|
||||
Ok(nodes) => {
|
||||
for node in nodes {
|
||||
let media_type = match &node.kind {
|
||||
NodeKind::File { media_type, .. } => Some(media_type.clone()),
|
||||
NodeKind::Folder => None,
|
||||
};
|
||||
meta.insert(node.uid.to_string(), (Some(node.name), media_type));
|
||||
}
|
||||
}
|
||||
Err(e) => warn!(error = %e, "resolving album photo metadata failed"),
|
||||
}
|
||||
}
|
||||
|
||||
let rows: Vec<(String, i64, Option<String>, Option<String>)> = items
|
||||
.iter()
|
||||
.map(|it| {
|
||||
let key = it.uid.to_string();
|
||||
let (name, media_type) = meta.get(&key).cloned().unwrap_or((None, None));
|
||||
(key, it.capture_time, name, media_type)
|
||||
})
|
||||
.collect();
|
||||
let key = album.to_string();
|
||||
self.db
|
||||
.album_photos_replace(&key, &rows)
|
||||
.map_err(CoreError::from)?;
|
||||
let _ = self
|
||||
.db
|
||||
.set_state_i64(&format!("{ALBUM_SYNCED_PREFIX}{key}"), now_ms());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create an album and return its uid. The listing is refreshed before
|
||||
/// answering, so the next [`Core::albums`] already shows it.
|
||||
pub(crate) fn create_album(&self, name: &str) -> CoreResult<String> {
|
||||
let uid = self
|
||||
.rt
|
||||
.block_on(self.photos().create_album(name))
|
||||
.map_err(|e| CoreError::from_api(&e, "create album"))?;
|
||||
self.after_album_change(None);
|
||||
Ok(uid.to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn rename_album(&self, album: &NodeUid, name: &str) -> CoreResult<()> {
|
||||
self.rt
|
||||
.block_on(self.photos().rename_album(album, name))
|
||||
.map_err(|e| CoreError::from_api(&e, "rename album"))?;
|
||||
self.after_album_change(None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete an album. Its photos stay in the timeline unless `delete_photos`,
|
||||
/// which only matters for photos that exist in the album alone.
|
||||
pub(crate) fn delete_album(&self, album: &NodeUid, delete_photos: bool) -> CoreResult<()> {
|
||||
self.rt
|
||||
.block_on(self.photos().delete_album(album, delete_photos))
|
||||
.map_err(|e| CoreError::from_api(&e, "delete album"))?;
|
||||
let key = album.to_string();
|
||||
if let Err(error) = self.db.album_photos_replace(&key, &[]) {
|
||||
warn!(%album, %error, "a deleted album's photos could not be dropped");
|
||||
}
|
||||
self.after_album_change(None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add photos to an album, per photo: `(added, failed)`.
|
||||
pub(crate) fn add_to_album(
|
||||
&self,
|
||||
album: &NodeUid,
|
||||
photos: &[NodeUid],
|
||||
) -> CoreResult<AlbumOutcome> {
|
||||
let outcomes = self
|
||||
.rt
|
||||
.block_on(self.photos().add_photos_to_album(album, photos))
|
||||
.map_err(|e| CoreError::from_api(&e, "add to album"))?;
|
||||
self.after_album_change(Some(album));
|
||||
Ok(album_outcome(outcomes))
|
||||
}
|
||||
|
||||
/// Take photos out of an album, per photo: `(removed, failed)`. The photos
|
||||
/// stay in the timeline.
|
||||
pub(crate) fn remove_from_album(
|
||||
&self,
|
||||
album: &NodeUid,
|
||||
photos: &[NodeUid],
|
||||
) -> CoreResult<AlbumOutcome> {
|
||||
let outcomes = self
|
||||
.rt
|
||||
.block_on(self.photos().remove_photos_from_album(album, photos))
|
||||
.map_err(|e| CoreError::from_api(&e, "remove from album"))?;
|
||||
self.after_album_change(Some(album));
|
||||
Ok(album_outcome(outcomes))
|
||||
}
|
||||
|
||||
/// Bring the persisted listing, and `album`'s contents when given, up to
|
||||
/// date after a change made here, so the reply is followed by a listing
|
||||
/// that shows it. A failed refresh only leaves them stale: the change
|
||||
/// itself went through.
|
||||
fn after_album_change(&self, album: Option<&NodeUid>) {
|
||||
self.invalidate_albums();
|
||||
if let Err(error) = self.rt.block_on(self.refresh_albums()) {
|
||||
warn!(%error, "album listing refresh after a change failed");
|
||||
}
|
||||
if let Some(album) = album
|
||||
&& let Err(error) = self.rt.block_on(self.refresh_album(album))
|
||||
{
|
||||
warn!(%album, %error, "album refresh after a change failed");
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop the album listing's and every album's freshness stamp, so the next
|
||||
/// request re-enumerates. Paired with [`Core::invalidate_photos`] — a change
|
||||
/// to the timeline can just as easily be a change to an album.
|
||||
pub(crate) fn invalidate_albums(&self) {
|
||||
let _ = self.db.clear_state(ALBUMS_SYNCED_MS);
|
||||
let _ = self.db.clear_state_prefix(ALBUM_SYNCED_PREFIX);
|
||||
}
|
||||
}
|
||||
|
||||
/// Photos an album change applied to, and the ones it failed for with why.
|
||||
pub(crate) type AlbumOutcome = (Vec<String>, Vec<(String, String)>);
|
||||
|
||||
fn album_outcome(outcomes: Vec<pdfs_core::batch::Outcome>) -> AlbumOutcome {
|
||||
let (done, failed) = pdfs_core::batch::split(outcomes);
|
||||
(
|
||||
done.iter().map(|uid| uid.to_string()).collect(),
|
||||
failed
|
||||
.into_iter()
|
||||
.map(|(uid, error)| (uid.to_string(), error.to_string()))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Project a persisted album into the wire item a front-end paints.
|
||||
fn album_info(album: StoredAlbum) -> AlbumInfo {
|
||||
AlbumInfo {
|
||||
uid: album.uid,
|
||||
name: album.name,
|
||||
photo_count: album.photo_count,
|
||||
// A cover uid the daemon cannot parse is no cover: the front-end would
|
||||
// only ask for a thumbnail that can never be resolved.
|
||||
cover_uid: album.cover_uid.filter(|uid| parse_uid(uid).is_some()),
|
||||
last_activity: album.last_activity,
|
||||
shared: album.shared,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,179 @@
|
||||
//! Listing and resolving `(sync-conflict …)` copies for the user.
|
||||
//!
|
||||
//! The sweep ([`crate::sweep`]) removes copies it can prove identical and flags
|
||||
//! the rest once in the activity feed. That leaves divergent copies sitting in
|
||||
//! folders with nothing but a name to find them by. This module lists them with
|
||||
//! the file they are a copy of, and resolves one the way the user chooses:
|
||||
//!
|
||||
//! - **Keep the original**: trash the copy.
|
||||
//! - **Keep the copy**: trash the original, then give the copy its name.
|
||||
//! - **Keep both**: rename the copy to a name of the user's choosing.
|
||||
//!
|
||||
//! Every resolution goes through the Proton trash, never a delete, so a wrong
|
||||
//! choice is undone by restoring from Trash. Resolution refuses to touch a file
|
||||
//! that is open, being written, or still owed an upload, for the same reason the
|
||||
//! sweep does: the node is about to change under the decision.
|
||||
//!
|
||||
//! Copies under My Files are named by their mountpoint-relative path. Copies
|
||||
//! inside a synced folder are named by their absolute local path, so a resolve
|
||||
//! request routes to the on-demand mount that serves them. A copy inside a
|
||||
//! mirror folder is listed too, but it is a local file first: the sync engine
|
||||
//! owns it, so it is resolved by editing the folder, not through here.
|
||||
|
||||
use super::*;
|
||||
use pdfs_core::control::{ConflictInfo, ConflictKeep};
|
||||
use sweep::conflict_base_name;
|
||||
|
||||
impl Core {
|
||||
/// Where the user finds a conflict copy: its mountpoint-relative path under
|
||||
/// My Files, or its absolute local path inside a synced folder. `None` for a
|
||||
/// node no location of this machine shows, such as one in another
|
||||
/// computer's device tree.
|
||||
///
|
||||
/// The sweep only flags what this can name, and the listing uses it too. A
|
||||
/// flagged copy the listing cannot see reads as resolved in the activity
|
||||
/// feed while it still sits in the folder (`docs/BUGS.md` B99).
|
||||
pub(crate) fn conflict_path(&self, roots: &SearchRoots, uid: &NodeUid) -> Option<String> {
|
||||
let uid = uid.to_string();
|
||||
if let Ok(Some(path)) = self
|
||||
.db
|
||||
.path_relative_to(&self.primary_root_uid.to_string(), &uid)
|
||||
{
|
||||
return Some(path);
|
||||
}
|
||||
let drive_path = self.db.node_path(&uid).ok().flatten()?;
|
||||
roots.resolve(&drive_path)
|
||||
}
|
||||
|
||||
/// Every conflict copy this machine shows, with the file it is a copy of.
|
||||
pub(crate) fn list_conflicts(&self) -> CoreResult<Vec<ConflictInfo>> {
|
||||
let stored = self
|
||||
.db
|
||||
.load_all()
|
||||
.map_err(|e| CoreError::internal(format!("listing conflicts: {e:?}")))?;
|
||||
let nodes: Vec<Node> = stored
|
||||
.into_iter()
|
||||
.map(|stored| stored.node)
|
||||
.filter(|node| !node.trashed && !node.is_folder() && self.is_own_or_virtual(&node.uid))
|
||||
.collect();
|
||||
let mut by_parent_name: HashMap<(Option<&NodeUid>, &str), &Node> = HashMap::new();
|
||||
for node in &nodes {
|
||||
by_parent_name.insert((node.parent_uid.as_ref(), node.name.as_str()), node);
|
||||
}
|
||||
let roots = self.search_roots();
|
||||
let mut items = Vec::new();
|
||||
for node in &nodes {
|
||||
let Some(base) = conflict_base_name(&node.name) else {
|
||||
continue;
|
||||
};
|
||||
let Some(path) = self.conflict_path(&roots, &node.uid) else {
|
||||
continue;
|
||||
};
|
||||
let original = by_parent_name
|
||||
.get(&(node.parent_uid.as_ref(), base.as_str()))
|
||||
.copied();
|
||||
items.push(ConflictInfo {
|
||||
original_path: sibling_path(&path, &base),
|
||||
original_exists: original.is_some(),
|
||||
size: node_size(node),
|
||||
modified: node.modification_time,
|
||||
original_size: original.map(node_size),
|
||||
original_modified: original.map(|o| o.modification_time),
|
||||
identical: original.is_some_and(|o| {
|
||||
node_size(o) == node_size(node)
|
||||
&& node_content_sha1(node)
|
||||
.is_some_and(|sha| node_content_sha1(o).as_deref() == Some(&sha))
|
||||
}),
|
||||
path,
|
||||
});
|
||||
}
|
||||
items.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
/// Resolve the conflict copy at mountpoint-relative `copy`. Returns a line
|
||||
/// for the user saying what happened.
|
||||
pub(crate) fn resolve_conflict(&self, copy: &Path, keep: &ConflictKeep) -> CoreResult<String> {
|
||||
let copy_name = copy
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.ok_or_else(|| CoreError::invalid("the conflict copy has no file name"))?;
|
||||
let base = conflict_base_name(copy_name)
|
||||
.ok_or_else(|| CoreError::invalid(format!("{copy_name} is not a conflict copy")))?;
|
||||
let original = copy.with_file_name(&base);
|
||||
self.require_settled(copy)?;
|
||||
let message = match keep {
|
||||
ConflictKeep::Original => {
|
||||
self.delete(copy)?;
|
||||
self.log_activity(ActivityKind::Trash, copy_name, format!("kept {base}"), true);
|
||||
format!("kept {base}; the copy is in Trash")
|
||||
}
|
||||
ConflictKeep::Copy => {
|
||||
self.require_settled(&original)?;
|
||||
self.delete(&original)?;
|
||||
// The original is already in Trash. A failed rename leaves the
|
||||
// copy under its conflict name, which loses nothing — say so
|
||||
// rather than leave the user guessing where their file went.
|
||||
self.rename(copy, &base).map_err(|e| {
|
||||
e.context(&format!(
|
||||
"{base} was moved to Trash, but the copy kept its conflict name"
|
||||
))
|
||||
})?;
|
||||
self.log_activity(
|
||||
ActivityKind::Rename,
|
||||
&base,
|
||||
format!("kept the conflict copy {copy_name}"),
|
||||
true,
|
||||
);
|
||||
format!("kept the copy as {base}; the previous version is in Trash")
|
||||
}
|
||||
ConflictKeep::Both { name } => {
|
||||
let name = name.trim();
|
||||
if name == base || name == copy_name {
|
||||
return Err(CoreError::invalid("choose a name that no other file has"));
|
||||
}
|
||||
self.rename(copy, name)?;
|
||||
self.log_activity(ActivityKind::Rename, name, format!("was {copy_name}"), true);
|
||||
format!("kept both; the copy is now {name}")
|
||||
}
|
||||
};
|
||||
Ok(message)
|
||||
}
|
||||
|
||||
/// Refuse to resolve around a file that is open, being written, or still
|
||||
/// owed an upload: the decision would be about content that is changing.
|
||||
fn require_settled(&self, rel: &Path) -> CoreResult<()> {
|
||||
let (_, uid) = self.resolve(rel)?;
|
||||
let queued = self.db.has_any_op(&uid.to_string()).unwrap_or(true);
|
||||
if queued || self.is_busy(&uid) {
|
||||
return Err(CoreError::conflict(format!(
|
||||
"{} is still being written or uploaded; try again once it has synced",
|
||||
rel.display()
|
||||
)));
|
||||
}
|
||||
self.conflict_notified.lock().remove(&uid);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The path of `name` next to the file at `path`.
|
||||
fn sibling_path(path: &str, name: &str) -> String {
|
||||
match path.rsplit_once('/') {
|
||||
Some((dir, _)) => format!("{dir}/{name}"),
|
||||
None => name.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_original_sits_next_to_its_copy() {
|
||||
assert_eq!(
|
||||
sibling_path("a/b/f (sync-conflict 1).txt", "f.txt"),
|
||||
"a/b/f.txt"
|
||||
);
|
||||
assert_eq!(sibling_path("f (sync-conflict 1).txt", "f.txt"), "f.txt");
|
||||
}
|
||||
}
|
||||
+1138
-108
File diff suppressed because it is too large.
Load diff
+688
-172
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,254 @@
|
||||
//! What the daemon is doing right now, for the case where it has stopped doing
|
||||
//! anything at all.
|
||||
//!
|
||||
//! On 2026-09-18 the daemon hung: it consumed 43 minutes of CPU over 20 hours,
|
||||
//! peaked at 5.2 GiB resident, went silent at 17:10, and only came back after a
|
||||
//! manual `systemctl --user restart`. There was no panic, so nothing in the
|
||||
//! journal said what it had been waiting on — and nothing in the daemon could be
|
||||
//! asked, either. This module is the answer to "what was it stuck on?".
|
||||
//!
|
||||
//! Everything here is built to work *while* the daemon is wedged, which rules
|
||||
//! out the obvious implementations: no lock that a stuck job might hold, no
|
||||
//! database query on the calling thread, no waiting on the worker pool. A
|
||||
//! diagnostic that blocks on the hang it is diagnosing reports nothing at
|
||||
//! exactly the moment it matters.
|
||||
|
||||
use std::sync::LazyLock;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Instant;
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use pdfs_core::control::{Diagnostics, InflightRequest, WorkerState};
|
||||
|
||||
use super::Core;
|
||||
|
||||
/// When this process started. Read at the first touch, which
|
||||
/// [`super::mount::mount`] makes happen during startup so the uptime is the
|
||||
/// daemon's, not the first diagnostics request's.
|
||||
static STARTED: LazyLock<Instant> = LazyLock::new(Instant::now);
|
||||
|
||||
/// Control requests currently being served, keyed by a monotonic ticket.
|
||||
///
|
||||
/// A `Vec` rather than a map because it holds at most `MAX_CONTROL_HANDLERS`
|
||||
/// entries, and the only operations are push, remove-by-ticket and read-all.
|
||||
static INFLIGHT: Mutex<Vec<(u64, String, Instant)>> = Mutex::new(Vec::new());
|
||||
static NEXT_TICKET: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Start the daemon's uptime clock. Called once, from the mount path.
|
||||
pub(crate) fn start_clock() {
|
||||
LazyLock::force(&STARTED);
|
||||
}
|
||||
|
||||
/// Registers one in-flight control request for as long as it is held.
|
||||
pub(crate) struct InflightGuard(u64);
|
||||
|
||||
impl InflightGuard {
|
||||
pub(crate) fn new(kind: String) -> Self {
|
||||
let ticket = NEXT_TICKET.fetch_add(1, Ordering::Relaxed);
|
||||
INFLIGHT.lock().push((ticket, kind, Instant::now()));
|
||||
Self(ticket)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for InflightGuard {
|
||||
fn drop(&mut self) {
|
||||
INFLIGHT.lock().retain(|(ticket, _, _)| *ticket != self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// The variant name of a serialised [`pdfs_core::control::Request`], without
|
||||
/// parsing it into one.
|
||||
///
|
||||
/// serde's external tagging makes this a property of the text: a unit variant is
|
||||
/// the bare string `"Status"`, anything else is an object whose single key is
|
||||
/// the variant name. Reading the name off the line rather than matching on 80
|
||||
/// variants keeps this cheap, and keeps it correct when a variant is added.
|
||||
pub(crate) fn request_kind(line: &str) -> String {
|
||||
let line = line.trim();
|
||||
let name = if let Some(rest) = line.strip_prefix('{') {
|
||||
rest.trim_start()
|
||||
.strip_prefix('"')
|
||||
.and_then(|rest| rest.split('"').next())
|
||||
} else {
|
||||
line.strip_prefix('"')
|
||||
.and_then(|rest| rest.split('"').next())
|
||||
};
|
||||
let name = name.unwrap_or("unknown");
|
||||
// A bare `Refresh` says nothing about which view wedged, and the three
|
||||
// scopes cost wildly different amounts of work: a directory refresh is two
|
||||
// cheap calls, a photos refresh can pull a whole library. The scope is one
|
||||
// more peek into the same line.
|
||||
if name == "Refresh"
|
||||
&& let Some(scope) = tagged_name_after(line, "\"scope\"")
|
||||
{
|
||||
return format!("Refresh({scope})");
|
||||
}
|
||||
name.to_string()
|
||||
}
|
||||
|
||||
/// The variant name serde wrote for the value of `key`, whether that value is a
|
||||
/// bare string (a unit variant) or an object whose single key is the name.
|
||||
fn tagged_name_after(line: &str, key: &str) -> Option<String> {
|
||||
let rest = line.split_once(key)?.1.trim_start();
|
||||
let rest = rest.strip_prefix(':')?.trim_start();
|
||||
let rest = rest.strip_prefix('{').map_or(rest, str::trim_start);
|
||||
rest.strip_prefix('"')?
|
||||
.split('"')
|
||||
.next()
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
/// Resident set size in bytes, or `0` when `/proc` did not answer.
|
||||
///
|
||||
/// From `VmRSS` in `/proc/self/status`, which is already in kB — `statm` would
|
||||
/// need the page size, and this needs no dependency at all.
|
||||
pub(crate) fn rss_bytes() -> u64 {
|
||||
let Ok(status) = std::fs::read_to_string("/proc/self/status") else {
|
||||
return 0;
|
||||
};
|
||||
parse_vm_rss(&status)
|
||||
}
|
||||
|
||||
/// The `VmRSS` line of a `/proc/<pid>/status`, in bytes.
|
||||
fn parse_vm_rss(status: &str) -> u64 {
|
||||
status
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("VmRSS:"))
|
||||
.and_then(|rest| rest.split_whitespace().next())
|
||||
.and_then(|kb| kb.parse::<u64>().ok())
|
||||
.map_or(0, |kb| kb.saturating_mul(1024))
|
||||
}
|
||||
|
||||
impl Core {
|
||||
/// Assemble a [`Diagnostics`] report.
|
||||
///
|
||||
/// The pending-op count is the one field that needs the database, so it is
|
||||
/// read on a throwaway thread with a short deadline: a daemon wedged *on*
|
||||
/// the database must still be able to report everything else about itself.
|
||||
pub(crate) fn diagnostics(&self) -> Diagnostics {
|
||||
let pool = self.workers.snapshot();
|
||||
let now = Instant::now();
|
||||
let mut inflight: Vec<InflightRequest> = INFLIGHT
|
||||
.try_lock()
|
||||
.map(|entries| {
|
||||
entries
|
||||
.iter()
|
||||
.map(|(_, kind, started)| InflightRequest {
|
||||
kind: kind.clone(),
|
||||
age_secs: now.duration_since(*started).as_secs(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
inflight.sort_by_key(|request| std::cmp::Reverse(request.age_secs));
|
||||
|
||||
Diagnostics {
|
||||
uptime_secs: STARTED.elapsed().as_secs(),
|
||||
rss_bytes: rss_bytes(),
|
||||
workers: pool
|
||||
.workers
|
||||
.into_iter()
|
||||
.map(|worker| WorkerState {
|
||||
name: worker.name,
|
||||
busy: worker.busy,
|
||||
label: worker.label,
|
||||
age_secs: worker.age_ms / 1000,
|
||||
})
|
||||
.collect(),
|
||||
meta_queued: pool.meta_queued,
|
||||
transfer_queued: pool.transfer_queued,
|
||||
meta_completed: pool.meta_completed,
|
||||
transfer_completed: pool.transfer_completed,
|
||||
inflight,
|
||||
control_handlers: super::control::active_handlers(),
|
||||
control_handler_limit: super::control::handler_limit(),
|
||||
pending_ops: self.pending_ops_quickly(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Queued mutations, or `0` if the database did not answer within
|
||||
/// [`PENDING_COUNT_DEADLINE`].
|
||||
fn pending_ops_quickly(&self) -> u64 {
|
||||
let db = self.db.clone();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
if std::thread::Builder::new()
|
||||
.name("pdfs-diagnostics".into())
|
||||
.spawn(move || {
|
||||
let counts = db.pending_op_counts().unwrap_or_default();
|
||||
let total = counts.uploads.max(0) as u64 + counts.changes.max(0) as u64;
|
||||
let _ = tx.send(total);
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
rx.recv_timeout(PENDING_COUNT_DEADLINE).unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// How long the report waits for the database before reporting without it.
|
||||
const PENDING_COUNT_DEADLINE: std::time::Duration = std::time::Duration::from_millis(500);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The request name is read off the wire format, so it must match both
|
||||
/// shapes serde produces.
|
||||
#[test]
|
||||
fn the_request_kind_comes_off_both_wire_shapes() {
|
||||
assert_eq!(request_kind("\"Status\""), "Status");
|
||||
assert_eq!(request_kind("{\"Pin\":{\"path\":\"a\"}}"), "Pin");
|
||||
assert_eq!(request_kind(" { \"Restore\" : {} } "), "Restore");
|
||||
assert_eq!(request_kind("garbage"), "unknown");
|
||||
}
|
||||
|
||||
/// A stuck `Refresh` is only actionable if the report says which view it
|
||||
/// was refreshing, so the scope rides along with the name.
|
||||
#[test]
|
||||
fn a_refresh_reports_its_scope() {
|
||||
assert_eq!(
|
||||
request_kind("{\"Refresh\":{\"scope\":{\"Photos\":{\"full\":true}}}}"),
|
||||
"Refresh(Photos)"
|
||||
);
|
||||
assert_eq!(
|
||||
request_kind("{\"Refresh\":{\"scope\":{\"Dir\":{\"path\":\"a\"}}}}"),
|
||||
"Refresh(Dir)"
|
||||
);
|
||||
assert_eq!(
|
||||
request_kind("{\"Refresh\":{\"scope\":\"Trash\"}}"),
|
||||
"Refresh(Trash)"
|
||||
);
|
||||
// A shape this does not recognise still names the request.
|
||||
assert_eq!(request_kind("{\"Refresh\":{}}"), "Refresh");
|
||||
}
|
||||
|
||||
/// A guard registers for its lifetime and nothing longer: a handler that
|
||||
/// returns must not leave a permanent "in flight" entry behind.
|
||||
#[test]
|
||||
fn a_guard_registers_only_while_it_lives() {
|
||||
let before = INFLIGHT.lock().len();
|
||||
{
|
||||
let _guard = InflightGuard::new("OpenFile".into());
|
||||
assert_eq!(INFLIGHT.lock().len(), before + 1);
|
||||
}
|
||||
assert_eq!(INFLIGHT.lock().len(), before);
|
||||
}
|
||||
|
||||
/// Whatever else it reports, the resident size has to be a real reading.
|
||||
#[test]
|
||||
fn rss_is_readable() {
|
||||
assert!(rss_bytes() > 0);
|
||||
}
|
||||
|
||||
/// `VmRSS` is in kB, and a status without one must read as unknown rather
|
||||
/// than as a daemon using no memory.
|
||||
#[test]
|
||||
fn vm_rss_is_kilobytes_and_absence_is_zero() {
|
||||
assert_eq!(
|
||||
parse_vm_rss("VmPeak:\t 100 kB\nVmRSS:\t 2048 kB\n"),
|
||||
2048 * 1024
|
||||
);
|
||||
assert_eq!(parse_vm_rss("VmPeak:\t 100 kB\n"), 0);
|
||||
}
|
||||
}
|
||||
+2721
-232
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,473 @@
|
||||
//! The calls the sync core makes to Drive, behind a trait.
|
||||
//!
|
||||
//! `Core` used to hold a concrete [`ProtonDriveClient`], so no test could make
|
||||
//! Drive answer late, answer stale, or not answer at all, and the interleavings
|
||||
//! behind most recent bugs could only be found on a real account over a slow
|
||||
//! link. [`DriveApi`] is the part of the
|
||||
//! client the mount, the drain, the event feed and the mirror engine use; the
|
||||
//! real client implements it by forwarding, and the simulation tests implement
|
||||
//! it with an in-memory Drive that injects Drive's known faults.
|
||||
//!
|
||||
//! Sharing, Photos, devices, invitations and public links stay on the concrete
|
||||
//! client (`Core::client`) until a test needs them faked.
|
||||
//!
|
||||
//! The method names match the client's own, so a call site reads the same
|
||||
//! whichever it goes through. The client's generic methods (a reader or writer
|
||||
//! of any type) cannot be part of an object-safe trait, so the trait takes them
|
||||
//! as trait objects and `impl dyn DriveApi` puts the generic signatures back.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures::StreamExt as _;
|
||||
use futures::stream::BoxStream;
|
||||
use proton_drive_rs::proton_sdk::account::Quota;
|
||||
use proton_drive_rs::proton_sdk::error::Result;
|
||||
use proton_drive_rs::proton_sdk::ids::{DriveEventId, NodeUid};
|
||||
use proton_drive_rs::{
|
||||
DriveEvent, DriveEventScopeId, Node, NodeMoveItem, ProtonDriveClient, RevisionReader, Thumbnail,
|
||||
};
|
||||
|
||||
/// One outcome per node of a batch request; see [`ProtonDriveClient::trash_nodes`].
|
||||
pub(crate) type NodeOutcomes = Vec<(NodeUid, Result<()>)>;
|
||||
|
||||
/// The outcomes of a streaming batch request, one item per node as it lands.
|
||||
pub(crate) type OutcomeStream<'a> = BoxStream<'a, Result<(NodeUid, Result<()>)>>;
|
||||
|
||||
/// Drive as the sync core sees it. See the module documentation.
|
||||
#[async_trait]
|
||||
pub(crate) trait DriveApi: Send + Sync {
|
||||
async fn get_my_files_folder(&self) -> Result<Node>;
|
||||
|
||||
async fn get_node(&self, uid: &NodeUid) -> Result<Option<Node>>;
|
||||
|
||||
async fn enumerate_nodes(&self, uids: &[NodeUid]) -> Result<Vec<Node>>;
|
||||
|
||||
/// As [`DriveApi::enumerate_nodes`], but files carry no claimed size or
|
||||
/// modification time; see [`ProtonDriveClient::enumerate_nodes_light`].
|
||||
async fn enumerate_nodes_light(&self, uids: &[NodeUid]) -> Result<Vec<Node>>;
|
||||
|
||||
async fn enumerate_folder_children_node_uids(
|
||||
&self,
|
||||
folder_uid: &NodeUid,
|
||||
) -> Result<Vec<NodeUid>>;
|
||||
|
||||
async fn enumerate_events(
|
||||
&self,
|
||||
scope: &DriveEventScopeId,
|
||||
cursor: Option<&DriveEventId>,
|
||||
) -> Result<Vec<DriveEvent>>;
|
||||
|
||||
async fn invalidate_caches_for_event(&self, event: &DriveEvent) -> Result<()>;
|
||||
|
||||
async fn create_folder(
|
||||
&self,
|
||||
parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
last_modification_time: Option<i64>,
|
||||
) -> Result<NodeUid>;
|
||||
|
||||
async fn upload_file(
|
||||
&self,
|
||||
parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
media_type: &str,
|
||||
contents: &[u8],
|
||||
) -> Result<NodeUid>;
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn upload_file_from_dyn(
|
||||
&self,
|
||||
parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
media_type: &str,
|
||||
reader: &mut (dyn Read + Send),
|
||||
intended_size: i64,
|
||||
thumbnails: Vec<Thumbnail>,
|
||||
last_modification_time: Option<i64>,
|
||||
aead: bool,
|
||||
) -> Result<NodeUid>;
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn upload_file_replacing_draft_from_dyn(
|
||||
&self,
|
||||
parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
media_type: &str,
|
||||
reader: &mut (dyn Read + Send),
|
||||
intended_size: i64,
|
||||
thumbnails: Vec<Thumbnail>,
|
||||
last_modification_time: Option<i64>,
|
||||
aead: bool,
|
||||
) -> Result<NodeUid>;
|
||||
|
||||
async fn upload_new_revision_from_dyn(
|
||||
&self,
|
||||
file_uid: &NodeUid,
|
||||
reader: &mut (dyn Read + Send),
|
||||
intended_size: i64,
|
||||
thumbnails: Vec<Thumbnail>,
|
||||
last_modification_time: Option<i64>,
|
||||
) -> Result<()>;
|
||||
|
||||
async fn open_revision(&self, uid: &NodeUid) -> Result<Arc<dyn RevisionRead>>;
|
||||
|
||||
async fn download_file_to_dyn(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
output: &mut (dyn Write + Send),
|
||||
) -> Result<()>;
|
||||
|
||||
async fn download_revision_to_dyn(
|
||||
&self,
|
||||
file_uid: &NodeUid,
|
||||
revision_id: &str,
|
||||
writer: &mut (dyn Write + Send),
|
||||
) -> Result<()>;
|
||||
|
||||
async fn rename_node(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
new_name: &str,
|
||||
new_media_type: Option<&str>,
|
||||
) -> Result<()>;
|
||||
|
||||
async fn move_node(&self, uid: &NodeUid, new_parent: &NodeUid) -> Result<()>;
|
||||
|
||||
fn move_nodes_streaming<'a>(
|
||||
&'a self,
|
||||
items: Vec<NodeMoveItem>,
|
||||
new_parent: NodeUid,
|
||||
) -> OutcomeStream<'a>;
|
||||
|
||||
async fn trash_nodes(&self, uids: &[NodeUid]) -> Result<NodeOutcomes>;
|
||||
|
||||
async fn restore_nodes(&self, uids: &[NodeUid]) -> Result<NodeOutcomes>;
|
||||
|
||||
fn restore_nodes_streaming<'a>(&'a self, uids: &[NodeUid]) -> OutcomeStream<'a>;
|
||||
|
||||
fn delete_nodes_streaming<'a>(&'a self, uids: &[NodeUid]) -> OutcomeStream<'a>;
|
||||
|
||||
async fn enumerate_trash_node_uids(&self) -> Result<Vec<NodeUid>>;
|
||||
|
||||
/// The account's storage, across every Proton product.
|
||||
async fn quota(&self) -> Result<Quota>;
|
||||
|
||||
/// Called by the drain once it has let go of the blob of an upload to
|
||||
/// `uid` that landed, or retired the create that made `uid`, before the
|
||||
/// tree has the node, so a simulation can hold it there. Blocks the drain
|
||||
/// worker it is called on.
|
||||
#[cfg(test)]
|
||||
fn landing(&self, _uid: &NodeUid) {}
|
||||
}
|
||||
|
||||
/// The generic signatures of the client, over the object-safe ones above.
|
||||
impl dyn DriveApi {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn upload_file_from<R: Read + Send>(
|
||||
&self,
|
||||
parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
media_type: &str,
|
||||
mut reader: R,
|
||||
intended_size: i64,
|
||||
thumbnails: Vec<Thumbnail>,
|
||||
last_modification_time: Option<i64>,
|
||||
aead: bool,
|
||||
) -> Result<NodeUid> {
|
||||
self.upload_file_from_dyn(
|
||||
parent_uid,
|
||||
name,
|
||||
media_type,
|
||||
&mut reader,
|
||||
intended_size,
|
||||
thumbnails,
|
||||
last_modification_time,
|
||||
aead,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn upload_file_replacing_draft_from<R: Read + Send>(
|
||||
&self,
|
||||
parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
media_type: &str,
|
||||
mut reader: R,
|
||||
intended_size: i64,
|
||||
thumbnails: Vec<Thumbnail>,
|
||||
last_modification_time: Option<i64>,
|
||||
aead: bool,
|
||||
) -> Result<NodeUid> {
|
||||
self.upload_file_replacing_draft_from_dyn(
|
||||
parent_uid,
|
||||
name,
|
||||
media_type,
|
||||
&mut reader,
|
||||
intended_size,
|
||||
thumbnails,
|
||||
last_modification_time,
|
||||
aead,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn upload_new_revision_from<R: Read + Send>(
|
||||
&self,
|
||||
file_uid: &NodeUid,
|
||||
mut reader: R,
|
||||
intended_size: i64,
|
||||
thumbnails: Vec<Thumbnail>,
|
||||
last_modification_time: Option<i64>,
|
||||
) -> Result<()> {
|
||||
self.upload_new_revision_from_dyn(
|
||||
file_uid,
|
||||
&mut reader,
|
||||
intended_size,
|
||||
thumbnails,
|
||||
last_modification_time,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn download_file_to<W: Write + Send>(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
output: &mut W,
|
||||
) -> Result<()> {
|
||||
self.download_file_to_dyn(uid, output).await
|
||||
}
|
||||
|
||||
pub(crate) async fn download_revision_to<W: Write + Send>(
|
||||
&self,
|
||||
file_uid: &NodeUid,
|
||||
revision_id: &str,
|
||||
writer: &mut W,
|
||||
) -> Result<()> {
|
||||
self.download_revision_to_dyn(file_uid, revision_id, writer)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
/// An open revision that block reads are served from; see [`RevisionReader`].
|
||||
#[async_trait]
|
||||
pub(crate) trait RevisionRead: Send + Sync {
|
||||
/// Plaintext size of the revision.
|
||||
fn size(&self) -> u64;
|
||||
|
||||
/// Plaintext size of each block, in block order.
|
||||
fn block_sizes(&self) -> &[u64];
|
||||
|
||||
async fn read_at(&self, offset: u64, length: u64) -> Result<Vec<u8>>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RevisionRead for RevisionReader {
|
||||
fn size(&self) -> u64 {
|
||||
RevisionReader::size(self)
|
||||
}
|
||||
|
||||
fn block_sizes(&self) -> &[u64] {
|
||||
RevisionReader::block_sizes(self)
|
||||
}
|
||||
|
||||
async fn read_at(&self, offset: u64, length: u64) -> Result<Vec<u8>> {
|
||||
RevisionReader::read_at(self, offset, length).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DriveApi for ProtonDriveClient {
|
||||
async fn get_my_files_folder(&self) -> Result<Node> {
|
||||
ProtonDriveClient::get_my_files_folder(self).await
|
||||
}
|
||||
|
||||
async fn get_node(&self, uid: &NodeUid) -> Result<Option<Node>> {
|
||||
ProtonDriveClient::get_node(self, uid).await
|
||||
}
|
||||
|
||||
async fn enumerate_nodes(&self, uids: &[NodeUid]) -> Result<Vec<Node>> {
|
||||
ProtonDriveClient::enumerate_nodes(self, uids).await
|
||||
}
|
||||
|
||||
async fn enumerate_nodes_light(&self, uids: &[NodeUid]) -> Result<Vec<Node>> {
|
||||
ProtonDriveClient::enumerate_nodes_light(self, uids).await
|
||||
}
|
||||
|
||||
async fn enumerate_folder_children_node_uids(
|
||||
&self,
|
||||
folder_uid: &NodeUid,
|
||||
) -> Result<Vec<NodeUid>> {
|
||||
ProtonDriveClient::enumerate_folder_children_node_uids(self, folder_uid).await
|
||||
}
|
||||
|
||||
async fn enumerate_events(
|
||||
&self,
|
||||
scope: &DriveEventScopeId,
|
||||
cursor: Option<&DriveEventId>,
|
||||
) -> Result<Vec<DriveEvent>> {
|
||||
ProtonDriveClient::enumerate_events(self, scope, cursor).await
|
||||
}
|
||||
|
||||
async fn invalidate_caches_for_event(&self, event: &DriveEvent) -> Result<()> {
|
||||
ProtonDriveClient::invalidate_caches_for_event(self, event).await
|
||||
}
|
||||
|
||||
async fn create_folder(
|
||||
&self,
|
||||
parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
last_modification_time: Option<i64>,
|
||||
) -> Result<NodeUid> {
|
||||
ProtonDriveClient::create_folder(self, parent_uid, name, last_modification_time).await
|
||||
}
|
||||
|
||||
async fn upload_file(
|
||||
&self,
|
||||
parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
media_type: &str,
|
||||
contents: &[u8],
|
||||
) -> Result<NodeUid> {
|
||||
ProtonDriveClient::upload_file(self, parent_uid, name, media_type, contents).await
|
||||
}
|
||||
|
||||
async fn upload_file_from_dyn(
|
||||
&self,
|
||||
parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
media_type: &str,
|
||||
reader: &mut (dyn Read + Send),
|
||||
intended_size: i64,
|
||||
thumbnails: Vec<Thumbnail>,
|
||||
last_modification_time: Option<i64>,
|
||||
aead: bool,
|
||||
) -> Result<NodeUid> {
|
||||
ProtonDriveClient::upload_file_from(
|
||||
self,
|
||||
parent_uid,
|
||||
name,
|
||||
media_type,
|
||||
reader,
|
||||
intended_size,
|
||||
thumbnails,
|
||||
last_modification_time,
|
||||
aead,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn upload_file_replacing_draft_from_dyn(
|
||||
&self,
|
||||
parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
media_type: &str,
|
||||
reader: &mut (dyn Read + Send),
|
||||
intended_size: i64,
|
||||
thumbnails: Vec<Thumbnail>,
|
||||
last_modification_time: Option<i64>,
|
||||
aead: bool,
|
||||
) -> Result<NodeUid> {
|
||||
ProtonDriveClient::upload_file_replacing_draft_from(
|
||||
self,
|
||||
parent_uid,
|
||||
name,
|
||||
media_type,
|
||||
reader,
|
||||
intended_size,
|
||||
thumbnails,
|
||||
last_modification_time,
|
||||
aead,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn upload_new_revision_from_dyn(
|
||||
&self,
|
||||
file_uid: &NodeUid,
|
||||
reader: &mut (dyn Read + Send),
|
||||
intended_size: i64,
|
||||
thumbnails: Vec<Thumbnail>,
|
||||
last_modification_time: Option<i64>,
|
||||
) -> Result<()> {
|
||||
ProtonDriveClient::upload_new_revision_from(
|
||||
self,
|
||||
file_uid,
|
||||
reader,
|
||||
intended_size,
|
||||
thumbnails,
|
||||
last_modification_time,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn open_revision(&self, uid: &NodeUid) -> Result<Arc<dyn RevisionRead>> {
|
||||
let reader = ProtonDriveClient::open_revision(self, uid).await?;
|
||||
Ok(Arc::new(reader))
|
||||
}
|
||||
|
||||
async fn download_file_to_dyn(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
output: &mut (dyn Write + Send),
|
||||
) -> Result<()> {
|
||||
ProtonDriveClient::download_file_to(self, uid, &mut &mut *output).await
|
||||
}
|
||||
|
||||
async fn download_revision_to_dyn(
|
||||
&self,
|
||||
file_uid: &NodeUid,
|
||||
revision_id: &str,
|
||||
writer: &mut (dyn Write + Send),
|
||||
) -> Result<()> {
|
||||
ProtonDriveClient::download_revision_to(self, file_uid, revision_id, &mut &mut *writer)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn rename_node(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
new_name: &str,
|
||||
new_media_type: Option<&str>,
|
||||
) -> Result<()> {
|
||||
ProtonDriveClient::rename_node(self, uid, new_name, new_media_type).await
|
||||
}
|
||||
|
||||
async fn move_node(&self, uid: &NodeUid, new_parent: &NodeUid) -> Result<()> {
|
||||
ProtonDriveClient::move_node(self, uid, new_parent).await
|
||||
}
|
||||
|
||||
fn move_nodes_streaming<'a>(
|
||||
&'a self,
|
||||
items: Vec<NodeMoveItem>,
|
||||
new_parent: NodeUid,
|
||||
) -> OutcomeStream<'a> {
|
||||
ProtonDriveClient::move_nodes_streaming(self, items, new_parent).boxed()
|
||||
}
|
||||
|
||||
async fn trash_nodes(&self, uids: &[NodeUid]) -> Result<NodeOutcomes> {
|
||||
ProtonDriveClient::trash_nodes(self, uids).await
|
||||
}
|
||||
|
||||
async fn restore_nodes(&self, uids: &[NodeUid]) -> Result<NodeOutcomes> {
|
||||
ProtonDriveClient::restore_nodes(self, uids).await
|
||||
}
|
||||
|
||||
fn restore_nodes_streaming<'a>(&'a self, uids: &[NodeUid]) -> OutcomeStream<'a> {
|
||||
ProtonDriveClient::restore_nodes_streaming(self, uids).boxed()
|
||||
}
|
||||
|
||||
fn delete_nodes_streaming<'a>(&'a self, uids: &[NodeUid]) -> OutcomeStream<'a> {
|
||||
ProtonDriveClient::delete_nodes_streaming(self, uids).boxed()
|
||||
}
|
||||
|
||||
async fn enumerate_trash_node_uids(&self) -> Result<Vec<NodeUid>> {
|
||||
ProtonDriveClient::enumerate_trash_node_uids(self).await
|
||||
}
|
||||
|
||||
async fn quota(&self) -> Result<Quota> {
|
||||
ProtonDriveClient::quota(self).await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
//! Push notifications for front-ends: the daemon's side of
|
||||
//! [`Request::Subscribe`](pdfs_core::control::Request::Subscribe).
|
||||
//!
|
||||
//! Front-ends used to poll. The app asked for the status every two seconds, the
|
||||
//! tray every three, and pages re-read themselves on a timer, whether or not
|
||||
//! anything had changed. Now each subscription owns a [`Mailbox`] in the
|
||||
//! [`EventHub`]. Code that changes something publishes a [`Topic`], and
|
||||
//! [`run_event_sampler`] watches the few things that change without passing
|
||||
//! through one place (the status, transfers, sync progress) and publishes them
|
||||
//! when they differ.
|
||||
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use parking_lot::{Condvar, Mutex, MutexGuard};
|
||||
use pdfs_core::control::{Event, JobItem, Response as CtlResponse, Topic, TransferItem};
|
||||
use tracing::warn;
|
||||
|
||||
use super::Core;
|
||||
|
||||
/// Most subscriptions served at once. Each holds a control handler for as long
|
||||
/// as it lasts; the app and the tray need one apiece, and the cap keeps a
|
||||
/// misbehaving client from spending the handlers that requests need.
|
||||
const MAX_SUBSCRIBERS: usize = 8;
|
||||
|
||||
/// How long a subscription waits after a change before it sends, so a burst
|
||||
/// (a folder of files trashed at once) goes out as one event.
|
||||
const COALESCE: Duration = Duration::from_millis(150);
|
||||
|
||||
/// How often [`run_event_sampler`] looks at transfers. Status and sync
|
||||
/// progress are looked at every other time.
|
||||
const SAMPLE_INTERVAL: Duration = Duration::from_millis(500);
|
||||
|
||||
/// Every open subscription's mailbox. Shared by every clone of [`Core`],
|
||||
/// on-demand forks included, so a change in any mount reaches every client.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct EventHub {
|
||||
mailboxes: Mutex<Vec<Arc<Mailbox>>>,
|
||||
}
|
||||
|
||||
/// What one subscription has yet to send.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Mailbox {
|
||||
inbox: Mutex<Inbox>,
|
||||
ready: Condvar,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Inbox {
|
||||
topics: BTreeSet<Topic>,
|
||||
/// The newest [`Event::Status`] line. A newer one replaces it unsent: a
|
||||
/// client wants the current status, not every status in between.
|
||||
status: Option<Arc<str>>,
|
||||
/// The newest [`Event::Transfers`] line, replaced the same way.
|
||||
transfers: Option<Arc<str>>,
|
||||
}
|
||||
|
||||
impl Inbox {
|
||||
fn is_empty(&self) -> bool {
|
||||
self.topics.is_empty() && self.status.is_none() && self.transfers.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// `event` as one line of the wire format, newline included.
|
||||
pub(crate) fn event_line(event: &Event) -> Option<Arc<str>> {
|
||||
match serde_json::to_string(event) {
|
||||
Ok(mut line) => {
|
||||
line.push('\n');
|
||||
Some(line.into())
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = %e, "events: serialize event failed");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EventHub {
|
||||
/// Tell every subscriber that `topics` changed.
|
||||
pub(crate) fn publish(&self, topics: &[Topic]) {
|
||||
self.deliver(|inbox| inbox.topics.extend(topics.iter().copied()));
|
||||
}
|
||||
|
||||
/// Hand every subscriber a fresh status.
|
||||
fn publish_status(&self, line: Arc<str>) {
|
||||
self.deliver(|inbox| inbox.status = Some(line.clone()));
|
||||
}
|
||||
|
||||
/// Hand every subscriber a fresh transfers snapshot.
|
||||
fn publish_transfers(&self, line: Arc<str>) {
|
||||
self.deliver(|inbox| inbox.transfers = Some(line.clone()));
|
||||
}
|
||||
|
||||
fn deliver(&self, put: impl Fn(&mut Inbox)) {
|
||||
for mailbox in self.mailboxes.lock().iter() {
|
||||
put(&mut mailbox.inbox.lock());
|
||||
mailbox.ready.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a mailbox, or `None` at [`MAX_SUBSCRIBERS`].
|
||||
pub(crate) fn subscribe(&self) -> Option<Arc<Mailbox>> {
|
||||
let mut mailboxes = self.mailboxes.lock();
|
||||
if mailboxes.len() >= MAX_SUBSCRIBERS {
|
||||
return None;
|
||||
}
|
||||
let mailbox = Arc::new(Mailbox::default());
|
||||
mailboxes.push(mailbox.clone());
|
||||
Some(mailbox)
|
||||
}
|
||||
|
||||
pub(crate) fn unsubscribe(&self, mailbox: &Arc<Mailbox>) {
|
||||
self.mailboxes
|
||||
.lock()
|
||||
.retain(|other| !Arc::ptr_eq(other, mailbox));
|
||||
}
|
||||
|
||||
fn has_subscribers(&self) -> bool {
|
||||
!self.mailboxes.lock().is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl Mailbox {
|
||||
/// Wait up to `timeout` for something to send, then take it as wire lines:
|
||||
/// the status first, then transfers, then the changed topics. Empty when
|
||||
/// nothing arrived in time.
|
||||
pub(crate) fn take(&self, timeout: Duration) -> Vec<Arc<str>> {
|
||||
let mut inbox = self.inbox.lock();
|
||||
if inbox.is_empty() {
|
||||
self.ready.wait_for(&mut inbox, timeout);
|
||||
if inbox.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
// Let the rest of a burst arrive before sending the first of it.
|
||||
MutexGuard::unlocked(&mut inbox, || std::thread::sleep(COALESCE));
|
||||
}
|
||||
let mut lines = Vec::new();
|
||||
lines.extend(inbox.status.take());
|
||||
lines.extend(inbox.transfers.take());
|
||||
if !inbox.topics.is_empty() {
|
||||
let topics = std::mem::take(&mut inbox.topics).into_iter().collect();
|
||||
lines.extend(event_line(&Event::Changed { topics }));
|
||||
}
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
/// The [`Event::Transfers`] line for a snapshot.
|
||||
pub(crate) fn transfers_line(items: Vec<TransferItem>, jobs: Vec<JobItem>) -> Option<Arc<str>> {
|
||||
event_line(&Event::Transfers { items, jobs })
|
||||
}
|
||||
|
||||
/// Watch what changes without a single place to publish it from, and publish it
|
||||
/// when it differs from the last look: the status (queue counts, cache use,
|
||||
/// online, pause), the transfers snapshot, the synced folders with their
|
||||
/// live progress, and which nodes have a sync issue. Looks only while someone
|
||||
/// is subscribed.
|
||||
pub(crate) fn run_event_sampler(core: Core, username: String, mountpoint: std::path::PathBuf) {
|
||||
let mut transfers: Option<Arc<str>> = None;
|
||||
let mut status: Option<String> = None;
|
||||
let mut queue: Option<String> = None;
|
||||
let mut locations: Option<String> = None;
|
||||
let mut issues: Option<HashMap<i64, String>> = None;
|
||||
let mut tick = 0u32;
|
||||
while core.shutdown.sleep(SAMPLE_INTERVAL) {
|
||||
if !core.events.has_subscribers() {
|
||||
// A new subscription starts from a snapshot of its own, so there is
|
||||
// nothing to compare against until then.
|
||||
transfers = None;
|
||||
status = None;
|
||||
queue = None;
|
||||
locations = None;
|
||||
issues = None;
|
||||
continue;
|
||||
}
|
||||
let line = transfers_line(core.transfers.snapshot(), core.jobs_snapshot());
|
||||
if line.is_some() && line != transfers {
|
||||
transfers = line.clone();
|
||||
if let Some(line) = line {
|
||||
core.events.publish_transfers(line);
|
||||
}
|
||||
}
|
||||
|
||||
tick = tick.wrapping_add(1);
|
||||
if !tick.is_multiple_of(2) {
|
||||
continue;
|
||||
}
|
||||
let now = super::control::status_response(&core, &username, &mountpoint);
|
||||
let (key, queue_key) = status_keys(&now);
|
||||
if status.as_ref().is_some_and(|last| *last != key)
|
||||
&& let Some(line) = event_line(&Event::Status(Box::new(now)))
|
||||
{
|
||||
core.events.publish_status(line);
|
||||
}
|
||||
status = Some(key);
|
||||
if queue.as_ref().is_some_and(|last| *last != queue_key) {
|
||||
core.events.publish(&[Topic::Queue]);
|
||||
}
|
||||
queue = Some(queue_key);
|
||||
|
||||
// An issue shows on its node in the file browser: raised, cleared,
|
||||
// landed or discarded, the listing is out of date.
|
||||
if let Ok(now) = core.db.node_issues() {
|
||||
if issues.as_ref().is_some_and(|last| *last != now) {
|
||||
core.events.publish(&[Topic::Files]);
|
||||
}
|
||||
issues = Some(now);
|
||||
}
|
||||
|
||||
if let Ok(now) = core.list_locations() {
|
||||
let key = serde_json::to_string(&now).unwrap_or_default();
|
||||
if locations.as_ref().is_some_and(|last| *last != key) {
|
||||
core.events.publish(&[Topic::Locations]);
|
||||
}
|
||||
locations = Some(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What makes two statuses different enough to send, and what makes their
|
||||
/// queues different. The age of the oldest staged write is left out of both:
|
||||
/// it grows every second while anything is staged, which is not news.
|
||||
fn status_keys(status: &CtlResponse) -> (String, String) {
|
||||
let CtlResponse::Status {
|
||||
parked_uploads,
|
||||
failing_ops,
|
||||
failing_error,
|
||||
staged_bytes,
|
||||
paused,
|
||||
paused_until,
|
||||
username,
|
||||
mountpoint,
|
||||
pinned,
|
||||
used,
|
||||
budget,
|
||||
online,
|
||||
session_expired,
|
||||
tokens_unsaved,
|
||||
pending_uploads,
|
||||
pending_changes,
|
||||
..
|
||||
} = status
|
||||
else {
|
||||
return (String::new(), String::new());
|
||||
};
|
||||
let queue = format!(
|
||||
"{pending_uploads}/{pending_changes}/{parked_uploads}/{failing_ops}/{failing_error:?}"
|
||||
);
|
||||
let key = format!(
|
||||
"{queue}/{staged_bytes}/{paused}/{paused_until:?}/{username}/{mountpoint}/{pinned}/\
|
||||
{used}/{budget}/{online}/{session_expired}/{tokens_unsaved}"
|
||||
);
|
||||
(key, queue)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn changed(line: &str) -> Vec<Topic> {
|
||||
match serde_json::from_str::<Event>(line.trim()) {
|
||||
Ok(Event::Changed { topics }) => topics,
|
||||
other => panic!("not a change event: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_burst_of_changes_arrives_as_one_event() {
|
||||
let hub = EventHub::default();
|
||||
let mailbox = hub.subscribe().expect("room for a subscriber");
|
||||
hub.publish(&[Topic::Trash, Topic::Files]);
|
||||
hub.publish(&[Topic::Files]);
|
||||
let lines = mailbox.take(Duration::from_secs(1));
|
||||
assert_eq!(lines.len(), 1);
|
||||
assert_eq!(changed(&lines[0]), vec![Topic::Files, Topic::Trash]);
|
||||
assert!(mailbox.take(Duration::from_millis(10)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_newer_status_replaces_one_not_yet_sent() {
|
||||
let hub = EventHub::default();
|
||||
let mailbox = hub.subscribe().expect("room for a subscriber");
|
||||
hub.publish_status("first\n".into());
|
||||
hub.publish_status("second\n".into());
|
||||
assert_eq!(
|
||||
mailbox.take(Duration::from_secs(1)),
|
||||
vec![Arc::<str>::from("second\n")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_closed_subscription_gets_nothing_more() {
|
||||
let hub = EventHub::default();
|
||||
let mailbox = hub.subscribe().expect("room for a subscriber");
|
||||
hub.unsubscribe(&mailbox);
|
||||
hub.publish(&[Topic::Files]);
|
||||
assert!(mailbox.take(Duration::from_millis(10)).is_empty());
|
||||
assert!(!hub.has_subscribers());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subscriptions_are_capped() {
|
||||
let hub = EventHub::default();
|
||||
let open: Vec<_> = (0..MAX_SUBSCRIBERS)
|
||||
.map(|_| hub.subscribe().expect("room for a subscriber"))
|
||||
.collect();
|
||||
assert!(hub.subscribe().is_none());
|
||||
hub.unsubscribe(&open[0]);
|
||||
assert!(hub.subscribe().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_age_of_a_staged_write_is_not_a_change() {
|
||||
let status = |staged_oldest_secs| CtlResponse::Status {
|
||||
parked_uploads: 0,
|
||||
failing_ops: 0,
|
||||
failing_error: None,
|
||||
staged_bytes: 10,
|
||||
staged_oldest_secs,
|
||||
paused: false,
|
||||
paused_until: None,
|
||||
username: "u".into(),
|
||||
mountpoint: "/m".into(),
|
||||
pinned: 0,
|
||||
used: 0,
|
||||
budget: 0,
|
||||
pins: Vec::new(),
|
||||
online: true,
|
||||
session_expired: false,
|
||||
tokens_unsaved: false,
|
||||
pending_uploads: 1,
|
||||
pending_changes: 0,
|
||||
};
|
||||
assert_eq!(status_keys(&status(5)), status_keys(&status(6)));
|
||||
}
|
||||
}
|
||||
+1061
-534
File diff suppressed because it is too large.
Load diff
+6315
-740
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,436 @@
|
||||
//! Noticing that the network went away mid-session, and that it came back.
|
||||
//!
|
||||
//! The mount used to learn it was offline only at startup: a daemon that had
|
||||
//! connected once kept `online` true for the rest of its life, so a Wi-Fi drop
|
||||
//! turned every create, mkdir, delete and rename into a two-minute wait for an
|
||||
//! `EIO`, while the queue that exists for exactly this sat unused. Now any
|
||||
//! remote call a handler waits on reports a failure to reach Proton through
|
||||
//! [`Core::lost_link`], which flips the mount offline; the handler then takes its
|
||||
//! queued path, and the probe thread here flips it back when Proton answers.
|
||||
|
||||
use std::future::Future;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use parking_lot::{Condvar, Mutex};
|
||||
use pdfs_core::error::session_revoked;
|
||||
use proton_drive_rs::proton_sdk::api::ResponseCode;
|
||||
use proton_drive_rs::proton_sdk::error::{ProtonApiError, ProtonError};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::{Core, ONLINE_PROBE_MAX, ONLINE_PROBE_MIN};
|
||||
|
||||
/// How long a remote call a FUSE handler is waiting on may take before the link
|
||||
/// is treated as down.
|
||||
///
|
||||
/// The SDK's own budget for one request is four attempts of up to 30 seconds
|
||||
/// each, so a dead link held the caller for about two minutes. A metadata call
|
||||
/// that has had no answer in 20 seconds is not going to be one the user wants to
|
||||
/// keep waiting for; queueing the change is the better answer.
|
||||
pub(crate) const INTERACTIVE_CALL_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
||||
/// How long one probe may wait for an answer. A probe is a single small request,
|
||||
/// and a probe stuck in the SDK's retry budget would delay noticing the link is
|
||||
/// back by as long as it waits.
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// First delay between probes after a drop seen mid-session, and the ceiling it
|
||||
/// doubles to while the outage is young. A blip should end in seconds, not in
|
||||
/// the minutes [`ONLINE_PROBE_MAX`] allows.
|
||||
const PROBE_AFTER_DROP: Duration = Duration::from_secs(2);
|
||||
const PROBE_YOUNG_MAX: Duration = Duration::from_secs(30);
|
||||
/// How long an outage counts as young. Past it the probe backs off to
|
||||
/// [`ONLINE_PROBE_MAX`], because a laptop can sit offline for days and each
|
||||
/// probe is a real API round trip.
|
||||
const YOUNG_OUTAGE: Duration = Duration::from_secs(10 * 60);
|
||||
|
||||
/// How often the idle probe thread checks for teardown while the link is up.
|
||||
const IDLE_CHECK: Duration = Duration::from_secs(1);
|
||||
|
||||
/// The shared half of the online flag: when the link went down, and a condvar
|
||||
/// that fires on every change so waiters need not poll.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Link {
|
||||
lost_at: Mutex<Option<Instant>>,
|
||||
changed: Condvar,
|
||||
/// Folders listed from the DB alone while offline, to relist once back.
|
||||
pub(crate) stale_listings: Mutex<std::collections::HashSet<u64>>,
|
||||
/// Proton refused the session itself ([`session_revoked`]). The mount is
|
||||
/// offline then, but no probe brings it back: only a new login does, and
|
||||
/// that restarts the daemon. Read by front-ends through `Response::Status`,
|
||||
/// so they can ask the user to sign in again rather than say "offline".
|
||||
pub(crate) session_expired: AtomicBool,
|
||||
/// Proton refused the session, but only because another process rotated
|
||||
/// it (`pdfs unlock`): the keyring holds the live tokens. The mount loop
|
||||
/// ends the mount for the daemon to mount again from the keyring.
|
||||
pub(crate) resume_stored: AtomicBool,
|
||||
}
|
||||
|
||||
impl Link {
|
||||
/// The link of a mount that comes up with Proton refusing its session, or
|
||||
/// not.
|
||||
pub(crate) fn starting(session_expired: bool) -> Self {
|
||||
Self {
|
||||
session_expired: AtomicBool::new(session_expired),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a failed call failed because Proton could not be reached, rather
|
||||
/// than because it answered and refused.
|
||||
///
|
||||
/// A transport error is the network. So is a request timeout: the SDK reports
|
||||
/// Proton's own "try again" codes that way, and [`no_answer`] reports our
|
||||
/// deadline that way. Anything else Proton actually said, and says again on a
|
||||
/// retry, so it must reach the caller rather than be queued.
|
||||
pub(crate) fn is_network_error(e: &ProtonError) -> bool {
|
||||
match e {
|
||||
ProtonError::Transport(_) => true,
|
||||
ProtonError::Api(api) => {
|
||||
matches!(
|
||||
api.code,
|
||||
ResponseCode::RequestTimeout | ResponseCode::Timeout
|
||||
)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The error a call reports when it runs past `after` without an answer.
|
||||
pub(crate) fn no_answer(after: Duration) -> ProtonError {
|
||||
ProtonError::Api(ProtonApiError {
|
||||
code: ResponseCode::RequestTimeout,
|
||||
http_status: 408,
|
||||
message: format!("no answer within {}s", after.as_secs()),
|
||||
details: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The slowest upload rate an upload's deadline allows for, past the
|
||||
/// metadata budget it starts with. Far below any link that works, so only a
|
||||
/// transfer that has stopped moving runs into it.
|
||||
const SLOWEST_UPLOAD: u64 = 32 * 1024;
|
||||
|
||||
/// How long an upload of `len` bytes may take before the link is treated as
|
||||
/// down: the budget of a metadata call, plus the bytes at [`SLOWEST_UPLOAD`].
|
||||
pub(crate) fn upload_deadline(len: u64) -> Duration {
|
||||
INTERACTIVE_CALL_TIMEOUT + Duration::from_secs(len / SLOWEST_UPLOAD)
|
||||
}
|
||||
|
||||
/// Run a remote call, giving up after [`INTERACTIVE_CALL_TIMEOUT`].
|
||||
pub(crate) async fn bounded<T>(
|
||||
call: impl Future<Output = Result<T, ProtonError>>,
|
||||
) -> Result<T, ProtonError> {
|
||||
bounded_by(INTERACTIVE_CALL_TIMEOUT, call).await
|
||||
}
|
||||
|
||||
/// Run a remote call, giving up after `limit`.
|
||||
pub(crate) async fn bounded_by<T>(
|
||||
limit: Duration,
|
||||
call: impl Future<Output = Result<T, ProtonError>>,
|
||||
) -> Result<T, ProtonError> {
|
||||
match tokio::time::timeout(limit, call).await {
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(no_answer(limit)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The next delay between probes, given the current one and how long the
|
||||
/// outage has lasted.
|
||||
fn next_probe_delay(delay: Duration, outage: Duration) -> Duration {
|
||||
let cap = if outage < YOUNG_OUTAGE {
|
||||
PROBE_YOUNG_MAX
|
||||
} else {
|
||||
ONLINE_PROBE_MAX
|
||||
};
|
||||
(delay * 2).min(cap)
|
||||
}
|
||||
|
||||
impl Core {
|
||||
/// Whether the mount currently believes Proton is reachable.
|
||||
pub(crate) fn is_online(&self) -> bool {
|
||||
self.online.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Whether a namespace change through the mount goes to Drive inside the
|
||||
/// syscall. Only with [`AppConfig::local_first`] off, and only online;
|
||||
/// otherwise it is queued and the drain sends it.
|
||||
///
|
||||
/// [`AppConfig::local_first`]: pdfs_core::config::AppConfig::local_first
|
||||
pub(crate) fn sends_inline(&self) -> bool {
|
||||
!self.local_first && self.is_online()
|
||||
}
|
||||
|
||||
/// Run a remote call a caller is waiting on, giving up after
|
||||
/// [`INTERACTIVE_CALL_TIMEOUT`]. The timer is built inside the runtime: a
|
||||
/// FUSE worker has no reactor of its own.
|
||||
pub(crate) fn block_on_bounded<T>(
|
||||
&self,
|
||||
call: impl Future<Output = Result<T, ProtonError>>,
|
||||
) -> Result<T, ProtonError> {
|
||||
self.rt.block_on(bounded(call))
|
||||
}
|
||||
|
||||
/// Run a remote call, giving up after `limit`: an upload, whose budget
|
||||
/// grows with its size ([`upload_deadline`]).
|
||||
pub(crate) fn block_on_within<T>(
|
||||
&self,
|
||||
limit: Duration,
|
||||
call: impl Future<Output = Result<T, ProtonError>>,
|
||||
) -> Result<T, ProtonError> {
|
||||
self.rt.block_on(bounded_by(limit, call))
|
||||
}
|
||||
|
||||
/// Whether Proton refused the session itself, so nothing reaches it until
|
||||
/// the user signs in again.
|
||||
pub(crate) fn session_expired(&self) -> bool {
|
||||
self.link.session_expired.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Report a failed remote call. Returns whether it failed for want of a
|
||||
/// network, or of a session, in which case the mount is now offline and the
|
||||
/// caller should take its queued path instead of failing.
|
||||
pub(crate) fn lost_link(&self, e: &ProtonError, what: &str) -> bool {
|
||||
if session_revoked(e) {
|
||||
self.mark_session_expired(what, e);
|
||||
return true;
|
||||
}
|
||||
if !is_network_error(e) {
|
||||
return false;
|
||||
}
|
||||
self.mark_offline(what, e);
|
||||
true
|
||||
}
|
||||
|
||||
/// Flip the mount offline for good: Proton refused the session, and only a
|
||||
/// new login brings it back. Changes stay queued for the daemon that login
|
||||
/// starts. Only the first caller logs it.
|
||||
///
|
||||
/// Unless the keyring holds a newer session than the refused one: then
|
||||
/// the mount ends, for the daemon to mount again on that one.
|
||||
pub(crate) fn mark_session_expired(&self, what: &str, error: &dyn std::fmt::Display) {
|
||||
if self.link.resume_stored.load(Ordering::Relaxed) {
|
||||
return self.mark_offline(what, error);
|
||||
}
|
||||
if pdfs_core::auth::stored_session_is_newer() {
|
||||
info!(during = what, %error,
|
||||
"Proton refused a session another process has since refreshed; resuming it");
|
||||
self.link.resume_stored.store(true, Ordering::Relaxed);
|
||||
return self.mark_offline(what, error);
|
||||
}
|
||||
if !self.link.session_expired.swap(true, Ordering::Relaxed) {
|
||||
warn!(during = what, %error,
|
||||
"Proton refused the session; run `pdfs login` to sign in again");
|
||||
}
|
||||
self.mark_offline(what, error);
|
||||
}
|
||||
|
||||
/// Flip the mount offline and wake the probe. Only the first caller of an
|
||||
/// outage logs it; the rest are the same outage.
|
||||
pub(crate) fn mark_offline(&self, what: &str, error: &dyn std::fmt::Display) {
|
||||
let mut lost_at = self.link.lost_at.lock();
|
||||
if self.online.swap(false, Ordering::Relaxed) {
|
||||
*lost_at = Some(Instant::now());
|
||||
warn!(during = what, %error,
|
||||
"lost the connection to Proton; queueing changes until it returns");
|
||||
self.link.changed.notify_all();
|
||||
}
|
||||
}
|
||||
|
||||
/// Flip the mount back online, wake everything waiting for it, and let the
|
||||
/// queue drain.
|
||||
fn mark_online(&self) {
|
||||
{
|
||||
let mut lost_at = self.link.lost_at.lock();
|
||||
self.online.store(true, Ordering::Relaxed);
|
||||
match lost_at.take() {
|
||||
Some(since) => {
|
||||
info!(
|
||||
offline_secs = since.elapsed().as_secs(),
|
||||
"connection to Proton is back"
|
||||
)
|
||||
}
|
||||
None => info!("back online"),
|
||||
}
|
||||
self.link.changed.notify_all();
|
||||
}
|
||||
self.relist_offline_listings();
|
||||
// Anything written while offline is queued and waiting on exactly this,
|
||||
// including ops that failed on the way down and are sitting out a
|
||||
// backoff sized for a server that keeps refusing.
|
||||
if let Err(e) = self.db.retry_failed_ops_now(crate::now_millis()) {
|
||||
warn!(error = %e, "moving backed-off ops up after a reconnect failed");
|
||||
}
|
||||
self.wake_drain();
|
||||
}
|
||||
|
||||
/// Wait up to `budget` for the link to come back. Returns whether it is up.
|
||||
pub(crate) fn wait_online(&self, budget: Duration) -> bool {
|
||||
let deadline = Instant::now() + budget;
|
||||
let mut lost_at = self.link.lost_at.lock();
|
||||
while !self.is_online() {
|
||||
if self.shutdown.is_stopping()
|
||||
|| self
|
||||
.link
|
||||
.changed
|
||||
.wait_until(&mut lost_at, deadline)
|
||||
.timed_out()
|
||||
{
|
||||
return self.is_online();
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Wait out `interval` before a background poll of Proton, and while the
|
||||
/// link is down, until it is back. Returns as soon as it comes back, so the
|
||||
/// poll catches up on what changed during the outage at once instead of
|
||||
/// one interval later. A poll into a dead link would only wait out the
|
||||
/// SDK's retries and fail.
|
||||
pub(crate) async fn next_poll(&self, interval: Duration) {
|
||||
let due = Instant::now() + interval;
|
||||
let mut was_offline = false;
|
||||
loop {
|
||||
if !self.is_online() {
|
||||
was_offline = true;
|
||||
} else if was_offline || Instant::now() >= due {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(IDLE_CHECK).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Watch the link for the life of the mount: sleep while it is up, and
|
||||
/// probe it back while it is down.
|
||||
///
|
||||
/// A mount that started from the cache is down from the first moment; one
|
||||
/// that started online goes down when [`Core::mark_offline`] says so.
|
||||
pub(crate) fn run_online_probe(&self) {
|
||||
// Only an offline start never had a chance to repair its share id.
|
||||
let mut repair_share_id = !self.is_online();
|
||||
loop {
|
||||
{
|
||||
let mut lost_at = self.link.lost_at.lock();
|
||||
while self.is_online() {
|
||||
if self.shutdown.is_stopping() {
|
||||
return;
|
||||
}
|
||||
self.link.changed.wait_for(&mut lost_at, IDLE_CHECK);
|
||||
}
|
||||
}
|
||||
if !self.probe_until_online() {
|
||||
return;
|
||||
}
|
||||
if std::mem::take(&mut repair_share_id) {
|
||||
self.repair_primary_share_id(&self.primary_root_uid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe until Proton answers, then flip the mount online. Returns `false`
|
||||
/// if the daemon is stopping.
|
||||
///
|
||||
/// A refused session is not probed: every probe would be refused the same
|
||||
/// way, forever (issue #35). The thread waits for the stop instead.
|
||||
fn probe_until_online(&self) -> bool {
|
||||
let started = self.link.lost_at.lock().unwrap_or_else(Instant::now);
|
||||
let mut delay = if started.elapsed() < YOUNG_OUTAGE {
|
||||
PROBE_AFTER_DROP
|
||||
} else {
|
||||
ONLINE_PROBE_MIN
|
||||
};
|
||||
loop {
|
||||
if !self.shutdown.sleep(delay) {
|
||||
return false;
|
||||
}
|
||||
if self.session_expired() {
|
||||
delay = ONLINE_PROBE_MAX;
|
||||
continue;
|
||||
}
|
||||
let probe = self.rt.block_on(async {
|
||||
match tokio::time::timeout(PROBE_TIMEOUT, self.drive.get_my_files_folder()).await {
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(no_answer(PROBE_TIMEOUT)),
|
||||
}
|
||||
});
|
||||
match probe {
|
||||
Ok(root) => {
|
||||
{
|
||||
let root_ino = self.root_ino();
|
||||
let mut st = self.state();
|
||||
if let Some(e) = st.entries.get_mut(&root_ino) {
|
||||
e.node = root.clone();
|
||||
}
|
||||
}
|
||||
if let Err(e) = self.db.upsert_node(&root) {
|
||||
warn!(error = %e, "refresh root after reconnect failed");
|
||||
}
|
||||
self.mark_online();
|
||||
return true;
|
||||
}
|
||||
Err(e) if session_revoked(&e) => self.mark_session_expired("online probe", &e),
|
||||
Err(e) => {
|
||||
debug!(error = %e, ?delay, "online probe failed; still offline");
|
||||
delay = next_probe_delay(delay, started.elapsed());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn api(code: ResponseCode) -> ProtonError {
|
||||
ProtonError::Api(ProtonApiError {
|
||||
code,
|
||||
http_status: 422,
|
||||
message: String::new(),
|
||||
details: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_an_unanswered_call_counts_as_a_lost_link() {
|
||||
assert!(is_network_error(&no_answer(INTERACTIVE_CALL_TIMEOUT)));
|
||||
assert!(is_network_error(&api(ResponseCode::Timeout)));
|
||||
assert!(!is_network_error(&api(ResponseCode::AlreadyExists)));
|
||||
assert!(!is_network_error(&api(ResponseCode::InvalidRequirements)));
|
||||
assert!(!is_network_error(&ProtonError::invalid_operation("bug")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_upload_gets_longer_the_bigger_it_is() {
|
||||
assert_eq!(upload_deadline(0), INTERACTIVE_CALL_TIMEOUT);
|
||||
assert_eq!(
|
||||
upload_deadline(1 << 30),
|
||||
INTERACTIVE_CALL_TIMEOUT + Duration::from_secs(32_768)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_call_past_its_deadline_reads_as_a_lost_link() {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_time()
|
||||
.build()
|
||||
.unwrap();
|
||||
let limit = Duration::from_millis(20);
|
||||
let result: Result<(), _> = rt.block_on(bounded_by(limit, std::future::pending()));
|
||||
assert!(result.is_err_and(|e| is_network_error(&e)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_young_outage_is_probed_often_and_an_old_one_rarely() {
|
||||
let young = Duration::from_secs(60);
|
||||
let old = YOUNG_OUTAGE + Duration::from_secs(1);
|
||||
assert_eq!(
|
||||
next_probe_delay(PROBE_AFTER_DROP, young),
|
||||
2 * PROBE_AFTER_DROP
|
||||
);
|
||||
assert_eq!(next_probe_delay(PROBE_YOUNG_MAX, young), PROBE_YOUNG_MAX);
|
||||
assert_eq!(next_probe_delay(PROBE_YOUNG_MAX, old), 2 * PROBE_YOUNG_MAX);
|
||||
assert_eq!(next_probe_delay(ONLINE_PROBE_MAX, old), ONLINE_PROBE_MAX);
|
||||
}
|
||||
}
|
||||
+673
-128
@@ -12,6 +12,10 @@ pub enum MountOutcome {
|
||||
/// The kernel mount ended on its own (e.g. an external `fusermount -u`).
|
||||
/// The caller may want to remount.
|
||||
Unmounted,
|
||||
/// Proton refused the session, and the keyring holds a newer one: another
|
||||
/// process (`pdfs unlock`) refreshed it, or a new login replaced it. We
|
||||
/// unmounted ourselves; the caller should mount again, which resumes it.
|
||||
ResumeStored,
|
||||
}
|
||||
|
||||
/// Whether `path` is a mountpoint whose FUSE connection is dead — the state a
|
||||
@@ -28,10 +32,101 @@ pub(crate) fn is_stale_mount(path: &Path) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// A secondary (on-demand sync folder) FUSE session, paired with its FUSE
|
||||
/// connection id so teardown can abort a mid-transfer connection rather than
|
||||
/// block on it. See [`Core::mounts`] and [`umount_session_unblocked`].
|
||||
pub(super) type SecondaryMount = (BackgroundSession, Option<u32>);
|
||||
/// A secondary (on-demand sync folder) FUSE session and the exact liveness flag
|
||||
/// published with its forked inode state.
|
||||
///
|
||||
/// Keeping these together makes every teardown mark the fork unroutable before
|
||||
/// unmounting, including paths that can block on an in-flight FUSE request.
|
||||
pub(super) struct SecondaryMount {
|
||||
session: BackgroundSession,
|
||||
conn: Option<u32>,
|
||||
session_live: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum SecondaryInsertRejection {
|
||||
Closed,
|
||||
Duplicate,
|
||||
}
|
||||
|
||||
/// Secondary sessions and the daemon-wide gate controlling whether newly
|
||||
/// spawned sessions may become visible. The gate and map share one lock so
|
||||
/// shutdown cannot race an insertion between checking and publishing.
|
||||
pub(super) struct SecondaryMountRegistry<T> {
|
||||
accepting: bool,
|
||||
entries: HashMap<i64, T>,
|
||||
}
|
||||
|
||||
impl<T> Default for SecondaryMountRegistry<T> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
accepting: false,
|
||||
entries: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> SecondaryMountRegistry<T> {
|
||||
pub(super) fn open(&mut self) {
|
||||
self.accepting = true;
|
||||
}
|
||||
|
||||
pub(super) fn is_accepting(&self) -> bool {
|
||||
self.accepting
|
||||
}
|
||||
|
||||
pub(super) fn close(&mut self) {
|
||||
self.accepting = false;
|
||||
}
|
||||
|
||||
pub(super) fn insert(
|
||||
&mut self,
|
||||
id: i64,
|
||||
mount: T,
|
||||
) -> Result<(), (SecondaryInsertRejection, T)> {
|
||||
if !self.accepting {
|
||||
return Err((SecondaryInsertRejection::Closed, mount));
|
||||
}
|
||||
if self.entries.contains_key(&id) {
|
||||
return Err((SecondaryInsertRejection::Duplicate, mount));
|
||||
}
|
||||
self.entries.insert(id, mount);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn remove(&mut self, id: &i64) -> Option<T> {
|
||||
self.entries.remove(id)
|
||||
}
|
||||
|
||||
pub(super) fn contains_key(&self, id: &i64) -> bool {
|
||||
self.entries.contains_key(id)
|
||||
}
|
||||
|
||||
pub(super) fn close_and_drain(&mut self) -> Vec<(i64, T)> {
|
||||
self.close();
|
||||
self.entries.drain().collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl SecondaryMount {
|
||||
pub(super) fn new(
|
||||
session: BackgroundSession,
|
||||
conn: Option<u32>,
|
||||
session_live: Arc<AtomicBool>,
|
||||
) -> Self {
|
||||
Self {
|
||||
session,
|
||||
conn,
|
||||
session_live,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn teardown(self) -> std::io::Result<()> {
|
||||
teardown_session(&self.session_live, || {
|
||||
umount_session_unblocked(self.session, self.conn)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The kernel's id for the FUSE connection backing the mount at `mountpoint` —
|
||||
/// the directory name under `/sys/fs/fuse/connections`, which is the minor
|
||||
@@ -102,7 +197,7 @@ pub(super) fn clear_stale_mount(mountpoint: &Path) {
|
||||
}
|
||||
|
||||
/// `sync_state` key holding the uid of the My Files root, so a later run can
|
||||
/// recover it from `nodes` without the API (offline.md Phase 1).
|
||||
/// recover it from `nodes` without the API.
|
||||
const ROOT_UID_KEY: &str = "root_uid";
|
||||
|
||||
/// The My Files root, and whether we got it from the API (`true`) or from the
|
||||
@@ -116,33 +211,63 @@ const ROOT_UID_KEY: &str = "root_uid";
|
||||
/// means an empty tree, and mounting that would show the user an empty Drive
|
||||
/// rather than an honest error.
|
||||
fn fetch_or_recall_root(
|
||||
client: &ProtonDriveClient,
|
||||
drive: &dyn DriveApi,
|
||||
rt: &tokio::runtime::Handle,
|
||||
db: &Db,
|
||||
) -> std::io::Result<(Node, bool)> {
|
||||
let err = match rt.block_on(client.get_my_files_folder()) {
|
||||
) -> std::io::Result<(Node, Start)> {
|
||||
let err = match rt.block_on(drive.get_my_files_folder()) {
|
||||
Ok(root) => {
|
||||
if let Err(e) = db.set_state_str(ROOT_UID_KEY, &root.uid.to_string()) {
|
||||
warn!(error = %e, "persist root uid failed");
|
||||
}
|
||||
return Ok((root, true));
|
||||
return Ok((root, Start::Online));
|
||||
}
|
||||
Err(e) => e,
|
||||
};
|
||||
// Still mounted from the cache, so cached files stay readable and changes
|
||||
// queue for the daemon the next login starts.
|
||||
let start = if pdfs_core::error::session_revoked(&err) {
|
||||
Start::SessionExpired
|
||||
} else {
|
||||
Start::Offline
|
||||
};
|
||||
if start == Start::SessionExpired && pdfs_core::auth::stored_session_is_newer() {
|
||||
return Err(std::io::Error::other(format!(
|
||||
"Proton refused a session another process has since refreshed ({err}); resuming it"
|
||||
)));
|
||||
}
|
||||
let cached = db
|
||||
.state_str(ROOT_UID_KEY)
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|uid| db.node_by_uid(&uid).ok().flatten());
|
||||
match cached {
|
||||
Some(root) if start == Start::SessionExpired => {
|
||||
warn!(error = %err,
|
||||
"Proton refused the session; mounting from cache until `pdfs login`");
|
||||
Ok((root, start))
|
||||
}
|
||||
Some(root) => {
|
||||
warn!(error = %err, "fetch My Files root failed; mounting from cache (offline)");
|
||||
Ok((root, false))
|
||||
Ok((root, start))
|
||||
}
|
||||
None if start == Start::SessionExpired => Err(std::io::Error::other(format!(
|
||||
"Proton refused the session ({err}); run `pdfs login`"
|
||||
))),
|
||||
None => Err(std::io::Error::other(format!("fetch My Files root: {err}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// How a mount comes up, from what fetching its root said.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Start {
|
||||
Online,
|
||||
/// Proton could not be reached: serve the cache and probe for the link.
|
||||
Offline,
|
||||
/// Proton refused the session: serve the cache until a new login.
|
||||
SessionExpired,
|
||||
}
|
||||
|
||||
/// Per-mount settings resolved from [`pdfs_core::config::AppConfig`] by the
|
||||
/// caller, so `mount` itself never reads config or the environment.
|
||||
pub struct MountOptions {
|
||||
@@ -150,6 +275,86 @@ pub struct MountOptions {
|
||||
pub username: String,
|
||||
/// What the background conflict sweep may do. See `docs/BUGS.md` B71.
|
||||
pub sweep_mode: SweepMode,
|
||||
/// Upload and download caps in bytes per second (`0` = no cap).
|
||||
pub upload_limit: u64,
|
||||
pub download_limit: u64,
|
||||
/// Namespace changes through the mount are queued, not sent in the
|
||||
/// syscall. See [`pdfs_core::config::AppConfig::local_first`].
|
||||
pub local_first: bool,
|
||||
}
|
||||
|
||||
/// Spawn one FUSE session rooted at an arbitrary remote node.
|
||||
///
|
||||
/// Primary My Files and secondary on-demand device locations use this same
|
||||
/// construction path so mount options, stale-mount recovery, registry
|
||||
/// liveness, and notifier ownership cannot drift apart. Each caller owns its
|
||||
/// state/path registration and performs it exactly once before calling here.
|
||||
pub(super) fn spawn_session(
|
||||
core: &Core,
|
||||
mountpoint: &Path,
|
||||
root: Node,
|
||||
) -> std::io::Result<BackgroundSession> {
|
||||
let mut config = Config::default();
|
||||
// `n_threads` stays at fuser's default of 1 (with `clone_fd` off) on
|
||||
// purpose, for now. A concurrent dispatch loop is worth having, but
|
||||
// `release` currently relies on being serialised against the `open`+`read`
|
||||
// an application issues right after `close(2)` — see the comment on
|
||||
// `Filesystem::release`. Raising it before that ordering is made explicit
|
||||
// (a per-node staging-in-flight barrier) reintroduces the acceptance
|
||||
// suite's "concurrent file 1 mismatch". Kernel-side caps that do *not*
|
||||
// depend on that ordering are negotiated in `ProtonFs::init`.
|
||||
config.mount_options = vec![
|
||||
MountOption::FSName("protondrive".to_string()),
|
||||
MountOption::Subtype("protondrive".to_string()),
|
||||
MountOption::DefaultPermissions,
|
||||
];
|
||||
|
||||
clear_stale_mount(mountpoint);
|
||||
info!(mountpoint = %mountpoint.display(), "mounting Proton Drive location");
|
||||
let fs = ProtonFs::new(core.clone(), root);
|
||||
if core.primary {
|
||||
// Only My Files rehydrates the daemon-wide node cache, once. A
|
||||
// secondary mount shares the tree it fills.
|
||||
core.hydrate();
|
||||
}
|
||||
let session = Session::new(fs, mountpoint, &config)?.spawn()?;
|
||||
let _ = core
|
||||
.notifier
|
||||
.set(KernelChannel::new(session.notifier(), core.root_ino()));
|
||||
core.session_live.store(true, Ordering::Release);
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
/// Make a session unroutable before beginning any teardown that may block.
|
||||
fn teardown_session<T>(session_live: &AtomicBool, teardown: impl FnOnce() -> T) -> T {
|
||||
session_live.store(false, Ordering::Release);
|
||||
teardown()
|
||||
}
|
||||
|
||||
impl Core {
|
||||
/// Resolve the primary share id away from startup and publish it only if the
|
||||
/// projected root has not changed while the request was in flight.
|
||||
pub(crate) fn repair_primary_share_id(&self, root_uid: &NodeUid) {
|
||||
match self.rt.block_on(self.client.context_share_id(root_uid)) {
|
||||
Ok(share_id) => {
|
||||
match self
|
||||
.db
|
||||
.mount_repair_my_files_share_id(&root_uid.to_string(), &share_id.to_string())
|
||||
{
|
||||
Ok(true) => debug!(%root_uid, "repaired My Files share id"),
|
||||
Ok(false) => {
|
||||
debug!(%root_uid, "discarded stale My Files share-id result");
|
||||
}
|
||||
Err(error) => {
|
||||
warn!(%root_uid, error = %error, "persist My Files share id failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
debug!(%root_uid, error = %error, "resolve My Files share id failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mount the filesystem at `mountpoint` and block until it is unmounted or the
|
||||
@@ -172,63 +377,146 @@ pub fn mount(
|
||||
control_socket: &Path,
|
||||
db: Arc<Db>,
|
||||
options: MountOptions,
|
||||
) -> std::io::Result<MountOutcome> {
|
||||
let drive = Arc::new(client.clone());
|
||||
mount_with(
|
||||
client,
|
||||
drive,
|
||||
rt,
|
||||
mountpoint,
|
||||
cache,
|
||||
control_socket,
|
||||
db,
|
||||
options,
|
||||
Host::Daemon,
|
||||
)
|
||||
}
|
||||
|
||||
/// What a mount runs in.
|
||||
pub(crate) enum Host {
|
||||
/// The daemon: stopped by SIGTERM or SIGINT, and indexing the user's home
|
||||
/// for the launcher prompt.
|
||||
Daemon,
|
||||
/// One of several simulated daemons in a test process (`sim`): stopped by
|
||||
/// a message on this channel, and with no home to index.
|
||||
#[cfg(test)]
|
||||
Simulation(std::sync::mpsc::Receiver<()>),
|
||||
}
|
||||
|
||||
/// [`mount`], reaching Drive through `drive` and hosted by `host`; `client`
|
||||
/// serves only what [`DriveApi`] does not cover.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn mount_with(
|
||||
client: ProtonDriveClient,
|
||||
drive: Arc<dyn DriveApi>,
|
||||
rt: tokio::runtime::Handle,
|
||||
mountpoint: &Path,
|
||||
cache: ContentCache,
|
||||
control_socket: &Path,
|
||||
db: Arc<Db>,
|
||||
options: MountOptions,
|
||||
host: Host,
|
||||
) -> std::io::Result<MountOutcome> {
|
||||
let MountOptions {
|
||||
username,
|
||||
sweep_mode,
|
||||
upload_limit,
|
||||
download_limit,
|
||||
local_first,
|
||||
} = options;
|
||||
let (root, online) = fetch_or_recall_root(&client, &rt, &db)?;
|
||||
// Start the uptime clock here rather than at the first `pdfs diagnostics`,
|
||||
// so the age it reports is the daemon's own.
|
||||
super::diagnostics::start_clock();
|
||||
let (root, start) = fetch_or_recall_root(drive.as_ref(), &rt, &db)?;
|
||||
let online = start == Start::Online;
|
||||
let scope = root.tree_event_scope_id();
|
||||
db.mount_upsert_my_files(&mountpoint.to_string_lossy(), &root.uid.to_string(), None)
|
||||
.map_err(|error| std::io::Error::other(format!("project My Files mount: {error}")))?;
|
||||
let share_access = db
|
||||
.all_share_access()
|
||||
.map_err(|error| std::io::Error::other(format!("load shared access: {error}")))?;
|
||||
|
||||
// The folder-sync engine (devices.md Phase 2) runs on its own thread and is
|
||||
// The folder-sync engine runs on its own thread and is
|
||||
// nudged over this channel; the sender lives in Core so control-socket
|
||||
// handlers can trigger reconciles.
|
||||
let (sync_tx, sync_rx) = std::sync::mpsc::channel::<sync::SyncMsg>();
|
||||
|
||||
let paused_until = pause::load_paused_until(&db);
|
||||
let core = Core {
|
||||
client: client.clone(),
|
||||
drive,
|
||||
client,
|
||||
rt: rt.clone(),
|
||||
state: Arc::new(Mutex::new(State {
|
||||
entries: HashMap::new(),
|
||||
by_uid: HashMap::new(),
|
||||
children: HashMap::new(),
|
||||
next_ino: 2,
|
||||
active_writes: HashMap::new(),
|
||||
handles: HashMap::new(),
|
||||
next_fh: 1,
|
||||
db: db.clone(),
|
||||
})),
|
||||
maintenance: Arc::new(Mutex::new(Default::default())),
|
||||
primary_root_uid: root.uid.clone(),
|
||||
primary: true,
|
||||
state: Arc::new(Mutex::new(State::new(db.clone(), share_access))),
|
||||
root: Arc::new(OnceLock::new()),
|
||||
cache: Arc::new(cache),
|
||||
readers: Arc::new(Mutex::new(HashMap::new())),
|
||||
stream_ring: Arc::new(Mutex::new(StreamRing::default())),
|
||||
block_ring: Arc::new(Mutex::new(BlockRing::default())),
|
||||
block_flight: Arc::new(BlockFlight::default()),
|
||||
content_repairs: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
|
||||
prefetch: Arc::new(Prefetch::default()),
|
||||
prefetch_budget: Arc::new(tokio::sync::Semaphore::new(PREFETCH_BUDGET)),
|
||||
workers: Arc::new(Workers::new(FUSE_WORKERS)?),
|
||||
db,
|
||||
shared_publication: Arc::new(Mutex::new(())),
|
||||
shared_generation: Arc::new(AtomicU64::new(0)),
|
||||
shared_refresh_deadlines: Arc::new(Mutex::new(SharedRefreshDeadlines::default())),
|
||||
online: Arc::new(AtomicBool::new(online)),
|
||||
link: Arc::new(link::Link::starting(start == Start::SessionExpired)),
|
||||
sync_paused_until: Arc::new(AtomicI64::new(paused_until)),
|
||||
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||
hidden: Arc::new(Mutex::new(HashSet::new())),
|
||||
creating: Arc::new(Mutex::new(HashMap::new())),
|
||||
sealed_unread: Arc::new(Mutex::new(HashMap::new())),
|
||||
landed: Arc::new(Mutex::new(HashMap::new())),
|
||||
unadopted: Arc::new(Mutex::new(HashMap::new())),
|
||||
drain_wake: Arc::new((Mutex::new(false), Condvar::new())),
|
||||
shutdown: Arc::new(crate::shutdown::Shutdown::default()),
|
||||
upload_times: Arc::new(Mutex::new(HashMap::new())),
|
||||
upload_cancel: Arc::new(Mutex::new(HashMap::new())),
|
||||
landing_uploads: Arc::new(AtomicU64::new(0)),
|
||||
timeline_refreshing: Arc::new(AtomicBool::new(false)),
|
||||
timeline_progress: Arc::new(Mutex::new(None)),
|
||||
similar_hashing: Arc::new(AtomicBool::new(false)),
|
||||
albums_refreshing: Arc::new(AtomicBool::new(false)),
|
||||
trash_refreshing: Arc::new(AtomicBool::new(false)),
|
||||
trash_progress: Arc::new(tokio::sync::Notify::new()),
|
||||
conflict_notified: Arc::new(Mutex::new(HashSet::new())),
|
||||
sweep_mode,
|
||||
own_sealed_revs: Arc::new(Mutex::new(HashMap::new())),
|
||||
local_first,
|
||||
self_changes: Arc::new(Mutex::new(HashMap::new())),
|
||||
thumb_gen: Arc::new(Mutex::new(HashSet::new())),
|
||||
no_thumbnail: Arc::new(Mutex::new(HashMap::new())),
|
||||
thumb_gen_budget: Arc::new(tokio::sync::Semaphore::new(
|
||||
super::photos::THUMB_GEN_CONCURRENCY,
|
||||
)),
|
||||
file_thumb_generation: Arc::new(AtomicU64::new(0)),
|
||||
file_thumb_cancel: Arc::new(tokio::sync::Notify::new()),
|
||||
thumbnail_build: Arc::new(Mutex::new(Default::default())),
|
||||
thumbnail_build_cancelled: Arc::new(AtomicBool::new(false)),
|
||||
thumbnail_build_cancel: Arc::new(tokio::sync::Notify::new()),
|
||||
thumbnail_misses: Arc::new(Mutex::new(Default::default())),
|
||||
quota: Arc::new(Mutex::new(None)),
|
||||
quota_refreshing: Arc::new(AtomicBool::new(false)),
|
||||
size_upgrades: Arc::new(Mutex::new(HashMap::new())),
|
||||
size_waiters: Arc::new(Default::default()),
|
||||
notifier: Arc::new(OnceLock::new()),
|
||||
session_live: Arc::new(AtomicBool::new(false)),
|
||||
transfers: TransferRegistry::new(),
|
||||
events: Arc::new(Default::default()),
|
||||
indexing: Arc::new(AtomicBool::new(false)),
|
||||
sync_progress: Arc::new(Mutex::new(HashMap::new())),
|
||||
sync_tx,
|
||||
mounts: Arc::new(Mutex::new(HashMap::new())),
|
||||
mounts: Arc::new(Mutex::new(SecondaryMountRegistry::default())),
|
||||
sync_locks: Arc::new(Mutex::new(HashMap::new())),
|
||||
sync_unscanned: Arc::new(Mutex::new(HashMap::new())),
|
||||
states: Arc::new(StateRegistry::default()),
|
||||
};
|
||||
// Before anything can queue work against it: the drain thread below reaches
|
||||
// every mount's inode space through this registry, not through `core.state`.
|
||||
core.register_state();
|
||||
// every mount's session through this registry.
|
||||
core.register_state(mountpoint);
|
||||
core.transfers.set_limits(upload_limit, download_limit);
|
||||
|
||||
// Writes queued by a previous run (or left behind by a crash) are still owed
|
||||
// an upload, and reads must be served from their staged blobs until they land.
|
||||
@@ -237,16 +525,36 @@ pub fn mount(
|
||||
// aside at open. After `hydrate_pending`, so a recovered partial write can
|
||||
// see an already-queued write to the same node.
|
||||
core.recover_fsynced_writes();
|
||||
{
|
||||
// And finally the staged blobs neither of those accounts for: bytes a
|
||||
// failed release left behind with nothing pointing at them.
|
||||
core.reconcile_staging();
|
||||
// Several drain workers over one queue. One thread meant a single large
|
||||
// upload held every queued rename, trash and small write behind it for as
|
||||
// long as it ran; the claim column (`Db::claim_next_due_op`) keeps the
|
||||
// workers off each other's nodes, which is the only ordering the queue
|
||||
// actually needs. Worker 0 additionally runs the queue's idle chores.
|
||||
//
|
||||
// Every long-lived thread started from here on is kept in `workers` and
|
||||
// joined at the end of this function. Leaving them running turned an
|
||||
// in-process remount into a second generation of drain workers, sync engines
|
||||
// and sweeps all operating on a mount that no longer exists (`docs/BUGS.md` B44).
|
||||
let mut workers: Vec<std::thread::JoinHandle<()>> = Vec::new();
|
||||
for worker in 0..DRAIN_WORKERS {
|
||||
let core = core.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("pdfs-drain".into())
|
||||
.spawn(move || core.run_pending_drain())?;
|
||||
workers.push(
|
||||
std::thread::Builder::new()
|
||||
.name(format!("pdfs-drain-{worker}"))
|
||||
.spawn(move || core.run_pending_drain(worker == 0))?,
|
||||
);
|
||||
}
|
||||
|
||||
// Start the folder-sync engine. It watches every mirror folder, polls the
|
||||
// remotes, and reconciles on its own thread — never in front of a FUSE call.
|
||||
sync::spawn(core.clone(), sync_rx);
|
||||
workers.extend(sync::spawn(core.clone(), sync_rx));
|
||||
// A timed pause persisted by an earlier run still has to end on time.
|
||||
if pause::paused_at(paused_until, now_secs()) && paused_until != pause::PAUSED_INDEFINITELY {
|
||||
core.arm_pause_timer(paused_until);
|
||||
}
|
||||
|
||||
// Reconcile leftover `(sync-conflict …)` copies: drop the ones that turned
|
||||
// out identical to the live file, surface the ones that genuinely diverge.
|
||||
@@ -258,54 +566,40 @@ pub fn mount(
|
||||
} else {
|
||||
info!(mode = ?sweep_mode, "conflict sweep enabled");
|
||||
let core = core.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("pdfs-conflict-sweep".into())
|
||||
.spawn(move || core.run_conflict_sweep_loop())?;
|
||||
workers.push(
|
||||
std::thread::Builder::new()
|
||||
.name("pdfs-conflict-sweep".into())
|
||||
.spawn(move || core.run_conflict_sweep_loop())?,
|
||||
);
|
||||
}
|
||||
|
||||
// Mounted from the cache: watch for the network coming back so the mount can
|
||||
// stop being read-only-ish without the user restarting the daemon.
|
||||
if !online {
|
||||
let core = core.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("pdfs-online-probe".into())
|
||||
.spawn(move || core.run_online_probe())?;
|
||||
}
|
||||
|
||||
// Re-establish on-demand mounts left over from a previous run (devices.md
|
||||
// Phase 4). On its own thread: each remount fetches a remote node, and we
|
||||
// must not block the main mount below on the network.
|
||||
// Watch the link for the life of the mount: a mount from the cache waits for
|
||||
// the network to come back, and an online one can lose it at any moment and
|
||||
// must notice it coming back just the same.
|
||||
{
|
||||
let core = core.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("pdfs-restore-ondemand".into())
|
||||
.spawn(move || core.restore_ondemand_mounts())?;
|
||||
workers.push(
|
||||
std::thread::Builder::new()
|
||||
.name("pdfs-online-probe".into())
|
||||
.spawn(move || core.run_online_probe())?,
|
||||
);
|
||||
}
|
||||
|
||||
// Keep the launcher prompt's "This computer" index fresh. Its own thread:
|
||||
// the walk is I/O-heavy and must never sit in front of a FUSE callback.
|
||||
{
|
||||
if matches!(host, Host::Daemon) {
|
||||
let db = core.db.clone();
|
||||
let indexing = core.indexing.clone();
|
||||
let transfers = core.transfers.clone();
|
||||
let mountpoint = mountpoint.to_path_buf();
|
||||
std::thread::Builder::new()
|
||||
.name("pdfs-localindex".into())
|
||||
.spawn(move || run_local_index(db, indexing, transfers, mountpoint))?;
|
||||
let shutdown = core.shutdown.clone();
|
||||
workers.push(
|
||||
std::thread::Builder::new()
|
||||
.name("pdfs-localindex".into())
|
||||
.spawn(move || run_local_index(db, indexing, transfers, mountpoint, shutdown))?,
|
||||
);
|
||||
}
|
||||
|
||||
let mut config = Config::default();
|
||||
config.mount_options = vec![
|
||||
MountOption::FSName("protondrive".to_string()),
|
||||
MountOption::Subtype("protondrive".to_string()),
|
||||
MountOption::DefaultPermissions,
|
||||
];
|
||||
|
||||
// A crashed previous run can leave the kernel mount in place, which makes
|
||||
// the fresh mount below fail with EBUSY. Lazily detach any leftover first.
|
||||
clear_stale_mount(mountpoint);
|
||||
info!(mountpoint = %mountpoint.display(), "mounting Proton Drive");
|
||||
|
||||
// Bind the control socket before the FUSE session takes over the thread. A
|
||||
// stale socket file from a previous run would block the bind, so clear it.
|
||||
let _ = std::fs::remove_file(control_socket);
|
||||
@@ -322,37 +616,198 @@ pub fn mount(
|
||||
"control socket permissions: {e}"
|
||||
)));
|
||||
}
|
||||
{
|
||||
let core = core.clone();
|
||||
let username = username.clone();
|
||||
let mountpoint = mountpoint.to_path_buf();
|
||||
std::thread::Builder::new()
|
||||
.name("pdfs-control".into())
|
||||
.spawn(move || run_control_socket(core, username, mountpoint, listener))?;
|
||||
}
|
||||
|
||||
let fs = ProtonFs::new(core.clone(), root);
|
||||
// Warm the in-memory maps from the DB so a cold start serves previously
|
||||
// seen metadata without re-hitting the API (plan.md P1).
|
||||
core.hydrate();
|
||||
|
||||
// Build the session explicitly (not `mount2`) so we can grab a `Notifier`
|
||||
// for the event task. `spawn` runs the session loop on its own background
|
||||
// thread; we then wait here for either a stop signal or the mount ending.
|
||||
let bg = Session::new(fs, mountpoint, &config)?.spawn()?;
|
||||
// Do not accept control requests until `spawn_session` has transitioned the
|
||||
// primary registration to live. The listener may queue a connection during
|
||||
// this short window, but its handler cannot observe `mounted = false`.
|
||||
let bg = match spawn_session(&core, mountpoint, root) {
|
||||
Ok(session) => session,
|
||||
Err(error) => {
|
||||
let _ = std::fs::remove_file(control_socket);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
core.mounts.lock().open();
|
||||
// The connection id, captured now while the mount is live, so a stop signal
|
||||
// mid-transfer can abort it rather than block `join` (see `abort_fuse_connection`).
|
||||
let main_conn = fuse_connection_id(mountpoint);
|
||||
let notifier = bg.notifier();
|
||||
// Same channel, kept on the `Core` so background work (a size upgrade, say)
|
||||
// can invalidate kernel-cached metadata without threading a handle through.
|
||||
let _ = core.notifier.set(notifier.clone());
|
||||
rt.spawn(run_event_sync(client, scope, core.clone()));
|
||||
{
|
||||
let control_core = core.clone();
|
||||
let username = username.clone();
|
||||
let mountpoint = mountpoint.to_path_buf();
|
||||
match std::thread::Builder::new()
|
||||
.name("pdfs-control".into())
|
||||
.spawn(move || run_control_socket(control_core, username, mountpoint, listener))
|
||||
{
|
||||
Ok(handle) => workers.push(handle),
|
||||
Err(error) => {
|
||||
// A failure here abandons the mount, so the background threads
|
||||
// started above have to be told to stop with it.
|
||||
stop_workers(&core, control_socket, workers);
|
||||
let secondaries = core.mounts.lock().close_and_drain();
|
||||
for (id, mount) in secondaries {
|
||||
if let Err(unmount_error) = mount.teardown() {
|
||||
warn!(id, error = %unmount_error, "secondary teardown after control startup failure failed");
|
||||
}
|
||||
}
|
||||
if let Err(unmount_error) = teardown_session(&core.session_live, || {
|
||||
umount_session_unblocked(bg, main_conn)
|
||||
}) {
|
||||
warn!(error = %unmount_error, "unmount after control startup failure failed");
|
||||
}
|
||||
let _ = std::fs::remove_file(control_socket);
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Publishes what changes without one place to publish it from (status,
|
||||
// transfers, sync progress) to the front-ends subscribed on that socket.
|
||||
{
|
||||
let sampler_core = core.clone();
|
||||
let username = username.clone();
|
||||
let mountpoint = mountpoint.to_path_buf();
|
||||
match std::thread::Builder::new()
|
||||
.name("pdfs-events".into())
|
||||
.spawn(move || events::run_event_sampler(sampler_core, username, mountpoint))
|
||||
{
|
||||
Ok(handle) => workers.push(handle),
|
||||
// Not fatal: front-ends still get every published change, only
|
||||
// not the sampled ones.
|
||||
Err(error) => warn!(error = %error, "could not start the event sampler"),
|
||||
}
|
||||
}
|
||||
// The supervisor watches the pool and answers the systemd watchdog. Started
|
||||
// after the listener, because its liveness probe is a round trip over that
|
||||
// socket.
|
||||
{
|
||||
let supervisor_core = core.clone();
|
||||
let socket = control_socket.to_path_buf();
|
||||
match std::thread::Builder::new()
|
||||
.name("pdfs-supervisor".into())
|
||||
.spawn(move || supervisor::run(supervisor_core, socket))
|
||||
{
|
||||
Ok(handle) => workers.push(handle),
|
||||
// Not fatal: the mount works without a supervisor, it just goes back
|
||||
// to being undiagnosable if it wedges.
|
||||
Err(error) => warn!(error = %error, "could not start the daemon supervisor"),
|
||||
}
|
||||
}
|
||||
// The mount is live and the control socket answers: this is the moment the
|
||||
// unit is actually usable, which is what `Type=notify` reports.
|
||||
systemd::ready();
|
||||
|
||||
// Re-establish on-demand mounts only after the primary session is live.
|
||||
// Keep the worker so shutdown can close publication, wait for any in-flight
|
||||
// fetch to observe that closure, and then drain a stable registry.
|
||||
let restore_worker = {
|
||||
let restore_core = core.clone();
|
||||
match std::thread::Builder::new()
|
||||
.name("pdfs-restore-ondemand".into())
|
||||
.spawn(move || restore_core.restore_ondemand_mounts())
|
||||
{
|
||||
Ok(worker) => Some(worker),
|
||||
Err(error) => {
|
||||
warn!(%error, "start on-demand restore worker failed");
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
rt.spawn(run_event_sync(core.drive.clone(), scope, core.clone()));
|
||||
// The photos volume has its own event stream; without it the gallery only
|
||||
// learns about a phone-side deletion when the timeline goes stale.
|
||||
rt.spawn(run_photos_event_sync(core.clone()));
|
||||
// Not in a simulation: `client` there reaches no API, and the thread, still
|
||||
// waiting on it after the stop, held the database a test opens next.
|
||||
if online && matches!(host, Host::Daemon) {
|
||||
let repair_core = core.clone();
|
||||
let root_uid = core.primary_root_uid.clone();
|
||||
if let Err(error) = std::thread::Builder::new()
|
||||
.name("pdfs-share-id".into())
|
||||
.spawn(move || repair_core.repair_primary_share_id(&root_uid))
|
||||
{
|
||||
warn!(%error, "start My Files share-id repair failed");
|
||||
}
|
||||
}
|
||||
|
||||
// Stop signals (SIGTERM from `systemctl --user stop`, SIGINT from Ctrl-C)
|
||||
// are delivered onto the async runtime; bridge them onto a sync channel so
|
||||
// the loop below can react without blocking a worker thread. A bounded
|
||||
// channel of 1 is enough — we only need to know that *a* stop arrived.
|
||||
let sig_rx = match host {
|
||||
Host::Daemon => signal_stop(&rt),
|
||||
#[cfg(test)]
|
||||
Host::Simulation(rx) => rx,
|
||||
};
|
||||
|
||||
// Wait for whichever happens first: a stop signal (→ we unmount ourselves
|
||||
// via the lazy MNT_DETACH path, clean even mid-download), or the kernel
|
||||
// mount ending on its own (→ the session thread finishes). Poll instead of
|
||||
// blocking on `join` so we can also notice the signal.
|
||||
let outcome = loop {
|
||||
match sig_rx.recv_timeout(Duration::from_millis(500)) {
|
||||
Ok(()) => {
|
||||
info!("stop requested; unmounting");
|
||||
core.mounts.lock().close();
|
||||
if let Err(e) = teardown_session(&core.session_live, || {
|
||||
umount_session_unblocked(bg, main_conn)
|
||||
}) {
|
||||
warn!(error = %e, "umount_and_join failed");
|
||||
}
|
||||
break MountOutcome::Shutdown;
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout)
|
||||
if core.link.resume_stored.load(Ordering::Relaxed) =>
|
||||
{
|
||||
info!("unmounting to resume the stored session");
|
||||
core.mounts.lock().close();
|
||||
if let Err(e) = teardown_session(&core.session_live, || {
|
||||
umount_session_unblocked(bg, main_conn)
|
||||
}) {
|
||||
warn!(error = %e, "umount_and_join failed");
|
||||
}
|
||||
break MountOutcome::ResumeStored;
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
|
||||
if bg.guard.is_finished() {
|
||||
info!("mount ended externally");
|
||||
core.mounts.lock().close();
|
||||
if let Err(e) = teardown_session(&core.session_live, || bg.join()) {
|
||||
warn!(error = %e, "session join failed");
|
||||
}
|
||||
break MountOutcome::Unmounted;
|
||||
}
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
|
||||
// Signal task gone (failed to install); fall back to join.
|
||||
core.mounts.lock().close();
|
||||
let _ = teardown_session(&core.session_live, || bg.join());
|
||||
break MountOutcome::Unmounted;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Unmount every on-demand sync folder too, or the kernel mounts linger as
|
||||
// stale and the next start fails with EBUSY.
|
||||
if let Some(worker) = restore_worker
|
||||
&& worker.join().is_err()
|
||||
{
|
||||
warn!("on-demand restore worker panicked");
|
||||
}
|
||||
let secondaries = core.mounts.lock().close_and_drain();
|
||||
for (id, mount) in secondaries {
|
||||
if let Err(e) = mount.teardown() {
|
||||
warn!(id, error = %e, "unmount on-demand folder failed");
|
||||
}
|
||||
}
|
||||
core.queue_unreleased_writes();
|
||||
|
||||
// Last, because the workers may legitimately be finishing an upload, and
|
||||
// nothing they can still do is harmful once the mounts are down.
|
||||
stop_workers(&core, control_socket, workers);
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// A channel that receives once SIGTERM or SIGINT arrives.
|
||||
fn signal_stop(rt: &tokio::runtime::Handle) -> std::sync::mpsc::Receiver<()> {
|
||||
let (sig_tx, sig_rx) = std::sync::mpsc::sync_channel::<()>(1);
|
||||
rt.spawn(async move {
|
||||
let mut sigterm =
|
||||
@@ -377,46 +832,136 @@ pub fn mount(
|
||||
}
|
||||
let _ = sig_tx.try_send(());
|
||||
});
|
||||
sig_rx
|
||||
}
|
||||
|
||||
// Wait for whichever happens first: a stop signal (→ we unmount ourselves
|
||||
// via the lazy MNT_DETACH path, clean even mid-download), or the kernel
|
||||
// mount ending on its own (→ the session thread finishes). Poll instead of
|
||||
// blocking on `join` so we can also notice the signal.
|
||||
let outcome = loop {
|
||||
match sig_rx.recv_timeout(Duration::from_millis(500)) {
|
||||
Ok(()) => {
|
||||
info!("stop requested; unmounting");
|
||||
if let Err(e) = umount_session_unblocked(bg, main_conn) {
|
||||
warn!(error = %e, "umount_and_join failed");
|
||||
}
|
||||
break MountOutcome::Shutdown;
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
|
||||
if bg.guard.is_finished() {
|
||||
info!("mount ended externally");
|
||||
if let Err(e) = bg.join() {
|
||||
warn!(error = %e, "session join failed");
|
||||
}
|
||||
break MountOutcome::Unmounted;
|
||||
}
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
|
||||
// Signal task gone (failed to install); fall back to join.
|
||||
let _ = bg.join();
|
||||
break MountOutcome::Unmounted;
|
||||
}
|
||||
/// How long teardown waits for the background workers and then the FUSE worker
|
||||
/// pool before proceeding without them. Long enough for an in-flight block to
|
||||
/// finish, short enough that `systemctl --user stop` still returns inside
|
||||
/// `TimeoutStopSec`.
|
||||
const WORKER_JOIN_DEADLINE: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Signal every background loop, wake the ones that are blocked, and join them.
|
||||
///
|
||||
/// The mount used to return with its drain workers, sync engine, sweep, online
|
||||
/// probe, indexer and control listener all still running on `Core` clones. The
|
||||
/// process usually exited straight afterwards and hid it; an in-process remount
|
||||
/// did not, and started a second full set on top of the first (`docs/BUGS.md` B44).
|
||||
///
|
||||
/// Two of the waits need waking rather than merely flagging: the drain workers
|
||||
/// block on their own condvar (`wake_drain`), and the control listener blocks
|
||||
/// inside `accept`, which only returns when a connection arrives — so it gets
|
||||
/// one, from us, after the flag is already set. A join that takes a while is
|
||||
/// expected and correct: a worker part-way through an upload finishes it rather
|
||||
/// than abandoning the user's bytes mid-flight. Up to [`WORKER_JOIN_DEADLINE`]:
|
||||
/// past it, systemd kills the daemon anyway, and a queued op is sent again.
|
||||
fn stop_workers(core: &Core, control_socket: &Path, workers: Vec<std::thread::JoinHandle<()>>) {
|
||||
// Say so before the joins: a teardown that takes a while must not be read as
|
||||
// a missed watchdog ping and turned into a kill.
|
||||
systemd::stopping();
|
||||
core.shutdown.stop();
|
||||
core.wake_drain();
|
||||
let _ = core.sync_tx.send(sync::SyncMsg::Stop);
|
||||
// Connect while the socket still exists — that is the whole point of the
|
||||
// poke — then unlink it so nothing else can arrive behind us. The listener
|
||||
// re-checks the flag after every accept, so even a connection that races the
|
||||
// unlink cannot put it back to sleep.
|
||||
let _ = std::os::unix::net::UnixStream::connect(control_socket);
|
||||
let _ = std::fs::remove_file(control_socket);
|
||||
let deadline = Instant::now() + WORKER_JOIN_DEADLINE;
|
||||
let running = join_until(workers, deadline);
|
||||
if !running.is_empty() {
|
||||
warn!(
|
||||
running = running.join(","),
|
||||
"background workers did not finish in time; shutting down around them"
|
||||
);
|
||||
}
|
||||
// The FUSE worker pool last, and *before* the caller drops the tokio
|
||||
// runtime: a worker still inside a job when the runtime goes away panics on
|
||||
// its next timer with "A Tokio 1.x context was found, but it is being
|
||||
// shutdown" (seen 2026-09-18 09:58:35). Bounded, so a worker wedged on the
|
||||
// network cannot hold the stop the user asked for.
|
||||
core.workers
|
||||
.stop_and_join(deadline.saturating_duration_since(Instant::now()));
|
||||
debug!("background workers stopped");
|
||||
}
|
||||
|
||||
/// Join `workers` until `deadline`, and name the ones still running then.
|
||||
///
|
||||
/// A stop used to wait for each one however long it took. The sync engine's
|
||||
/// mirror pass waits on Drive with no deadline, and a stop that waited for it
|
||||
/// was killed by systemd (`docs/BUGS.md` B165).
|
||||
fn join_until(workers: Vec<std::thread::JoinHandle<()>>, deadline: Instant) -> Vec<String> {
|
||||
let mut running = Vec::new();
|
||||
for worker in workers {
|
||||
while !worker.is_finished() && Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
};
|
||||
|
||||
// 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).
|
||||
let secondaries: Vec<_> = core.mounts.lock().drain().collect();
|
||||
for (id, (session, conn)) in secondaries {
|
||||
if let Err(e) = umount_session_unblocked(session, conn) {
|
||||
warn!(id, error = %e, "unmount on-demand folder failed");
|
||||
if !worker.is_finished() {
|
||||
running.push(worker.thread().name().unwrap_or("unnamed").to_string());
|
||||
} else if worker.join().is_err() {
|
||||
warn!("a background worker panicked before shutdown");
|
||||
}
|
||||
}
|
||||
running
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_file(control_socket);
|
||||
Ok(outcome)
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::{SecondaryInsertRejection, SecondaryMountRegistry, join_until, teardown_session};
|
||||
|
||||
#[test]
|
||||
fn a_stop_names_the_workers_still_running_at_its_deadline() {
|
||||
let (release, wait) = std::sync::mpsc::channel::<()>();
|
||||
let done = std::thread::Builder::new()
|
||||
.name("done".into())
|
||||
.spawn(|| {})
|
||||
.unwrap();
|
||||
let stuck = std::thread::Builder::new()
|
||||
.name("stuck".into())
|
||||
.spawn(move || {
|
||||
let _ = wait.recv();
|
||||
})
|
||||
.unwrap();
|
||||
let running = join_until(
|
||||
vec![done, stuck],
|
||||
Instant::now() + Duration::from_millis(100),
|
||||
);
|
||||
assert_eq!(running, ["stuck"]);
|
||||
drop(release);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_is_unroutable_before_teardown_starts() {
|
||||
let live = AtomicBool::new(true);
|
||||
teardown_session(&live, || {
|
||||
assert!(
|
||||
!live.load(Ordering::Acquire),
|
||||
"teardown must never run while the registry still reports mounted"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secondary_registry_rejects_closed_and_duplicate_insertions() {
|
||||
let mut mounts = SecondaryMountRegistry::default();
|
||||
assert_eq!(
|
||||
mounts.insert(7, "closed"),
|
||||
Err((SecondaryInsertRejection::Closed, "closed"))
|
||||
);
|
||||
|
||||
mounts.open();
|
||||
assert_eq!(mounts.insert(7, "first"), Ok(()));
|
||||
assert_eq!(
|
||||
mounts.insert(7, "duplicate"),
|
||||
Err((SecondaryInsertRejection::Duplicate, "duplicate"))
|
||||
);
|
||||
assert!(mounts.contains_key(&7));
|
||||
|
||||
assert_eq!(mounts.close_and_drain(), vec![(7, "first")]);
|
||||
assert!(!mounts.is_accepting());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
//! Pausing sync: the user's "stop uploading for a while".
|
||||
//!
|
||||
//! A pause holds back the two things that push local state to the remote — the
|
||||
//! drain worker's queued uploads and changes, and the mirror-folder reconcile.
|
||||
//! Nothing else stops: reads through the mount keep hydrating on demand (a read
|
||||
//! is not a sync), writes keep being accepted and staged, and the watcher and
|
||||
//! poll keep noticing changes. Resuming lets all of it catch up at once.
|
||||
//!
|
||||
//! The pause is persisted in the database, so a restart does not quietly undo
|
||||
//! it. A timed pause ends by itself; [`Core::set_sync_paused`] arms a timer for
|
||||
//! that, and [`Core::sync_paused`] compares against the clock anyway, so a
|
||||
//! deadline that passed while the daemon was down is already over on mount.
|
||||
//!
|
||||
//! A single mirror folder can be paused on its own as well: its reconcile is
|
||||
//! skipped, and with it any mode switch queued behind that reconcile, until the
|
||||
//! folder is resumed. On-demand folders cannot be paused alone — their writes
|
||||
//! go through the shared queue, which only the global pause holds back.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The `state` key holding the resume time, in Unix seconds.
|
||||
const PAUSED_UNTIL_KEY: &str = "sync_paused_until";
|
||||
|
||||
/// The `state` key prefix marking one synced folder paused; its id follows.
|
||||
const FOLDER_PAUSED_PREFIX: &str = "sync_folder_paused:";
|
||||
|
||||
fn folder_paused_key(id: i64) -> String {
|
||||
format!("{FOLDER_PAUSED_PREFIX}{id}")
|
||||
}
|
||||
|
||||
/// Whether synced folder `id` is paused on its own. A row that cannot be read
|
||||
/// counts as running, like the global pause.
|
||||
fn load_folder_paused(db: &Db, id: i64) -> bool {
|
||||
match db.state_i64(&folder_paused_key(id)) {
|
||||
Ok(value) => value.is_some_and(|v| v != 0),
|
||||
Err(error) => {
|
||||
warn!(id, %error, "reading a folder pause failed; treating it as running");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist synced folder `id`'s own pause, or clear it.
|
||||
fn store_folder_paused(db: &Db, id: i64, paused: bool) -> pdfs_core::Result<()> {
|
||||
match paused {
|
||||
true => db.set_state_i64(&folder_paused_key(id), 1),
|
||||
false => db.clear_state(&folder_paused_key(id)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A pause with no end time: it lasts until the user resumes.
|
||||
pub(crate) const PAUSED_INDEFINITELY: i64 = i64::MAX;
|
||||
|
||||
/// The persisted pause, or `0` when there is none (or it cannot be read — a
|
||||
/// broken state row must not keep sync paused forever).
|
||||
pub(crate) fn load_paused_until(db: &Db) -> i64 {
|
||||
match db.state_i64(PAUSED_UNTIL_KEY) {
|
||||
Ok(Some(until)) => until,
|
||||
Ok(None) => 0,
|
||||
Err(error) => {
|
||||
warn!(%error, "reading the sync pause failed; treating sync as running");
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a pause lasting until `until` is still in force at `now` (both Unix
|
||||
/// seconds). `0` is "not paused".
|
||||
pub(crate) fn paused_at(until: i64, now: i64) -> bool {
|
||||
until != 0 && now < until
|
||||
}
|
||||
|
||||
impl Core {
|
||||
/// True while the user has sync paused.
|
||||
pub(crate) fn sync_paused(&self) -> bool {
|
||||
paused_at(self.sync_paused_until.load(Ordering::Relaxed), now_secs())
|
||||
}
|
||||
|
||||
/// The pause's end, for status reporting: `None` when not paused,
|
||||
/// `Some(None)` when paused until resumed, `Some(Some(t))` when it ends at
|
||||
/// Unix second `t`.
|
||||
pub(crate) fn sync_pause(&self) -> Option<Option<i64>> {
|
||||
let until = self.sync_paused_until.load(Ordering::Relaxed);
|
||||
match paused_at(until, now_secs()) {
|
||||
false => None,
|
||||
true if until == PAUSED_INDEFINITELY => Some(None),
|
||||
true => Some(Some(until)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pause sync until Unix second `until` (`None`: until resumed), or resume
|
||||
/// it when `paused` is false.
|
||||
pub(crate) fn set_sync_paused(
|
||||
&self,
|
||||
paused: bool,
|
||||
until: Option<i64>,
|
||||
) -> Result<(), CoreError> {
|
||||
let value = match (paused, until) {
|
||||
(false, _) => 0,
|
||||
(true, None) => PAUSED_INDEFINITELY,
|
||||
(true, Some(until)) if until <= now_secs() => {
|
||||
return Err(CoreError::invalid("the pause would already be over"));
|
||||
}
|
||||
(true, Some(until)) => until,
|
||||
};
|
||||
let persisted = match value {
|
||||
0 => self.db.clear_state(PAUSED_UNTIL_KEY),
|
||||
value => self.db.set_state_i64(PAUSED_UNTIL_KEY, value),
|
||||
};
|
||||
if let Err(error) = persisted {
|
||||
return Err(CoreError::internal(format!(
|
||||
"saving the sync pause: {error}"
|
||||
)));
|
||||
}
|
||||
self.sync_paused_until.store(value, Ordering::Relaxed);
|
||||
match value {
|
||||
0 => {
|
||||
info!("sync resumed");
|
||||
self.resume_sync_work();
|
||||
}
|
||||
PAUSED_INDEFINITELY => info!("sync paused until resumed"),
|
||||
until => {
|
||||
info!(until, "sync paused");
|
||||
self.arm_pause_timer(until);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// True while the user has this one synced folder paused. A row that cannot
|
||||
/// be read counts as running, like the global pause.
|
||||
pub(crate) fn sync_folder_paused(&self, id: i64) -> bool {
|
||||
load_folder_paused(&self.db, id)
|
||||
}
|
||||
|
||||
/// Pause or resume one mirror folder's reconcile. Resuming reconciles it
|
||||
/// straight away to catch up on what the watcher saw meanwhile.
|
||||
pub(crate) fn set_sync_folder_paused(&self, id: i64, paused: bool) -> Result<(), CoreError> {
|
||||
let folder = self
|
||||
.db
|
||||
.sync_folder_get(id)
|
||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
|
||||
.ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?;
|
||||
if paused && folder.mode != "mirror" {
|
||||
return Err(CoreError::invalid(
|
||||
"only a mirrored folder can be paused on its own; an on-demand folder uploads \
|
||||
through the shared queue, so pause all sync instead",
|
||||
));
|
||||
}
|
||||
if let Err(error) = store_folder_paused(&self.db, id, paused) {
|
||||
return Err(CoreError::internal(format!(
|
||||
"saving the folder pause: {error}"
|
||||
)));
|
||||
}
|
||||
if paused {
|
||||
info!(id, path = %folder.local_path, "folder sync paused");
|
||||
} else {
|
||||
info!(id, path = %folder.local_path, "folder sync resumed");
|
||||
self.sync_now(Some(id));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Forget a removed folder's pause, so a later folder reusing its id does
|
||||
/// not start out paused.
|
||||
pub(crate) fn clear_sync_folder_paused(&self, id: i64) {
|
||||
if let Err(error) = store_folder_paused(&self.db, id, false) {
|
||||
warn!(id, %error, "clearing a removed folder's pause failed");
|
||||
}
|
||||
}
|
||||
|
||||
/// Wake everything a pause held back: the drain worker, and a full
|
||||
/// reconcile to pick up whatever the watcher saw meanwhile.
|
||||
fn resume_sync_work(&self) {
|
||||
self.wake_drain();
|
||||
self.sync_now(None);
|
||||
}
|
||||
|
||||
/// End a timed pause when its time comes. A later pause or resume replaces
|
||||
/// the stored deadline, which the timer checks before acting, so a stale
|
||||
/// timer does nothing.
|
||||
pub(crate) fn arm_pause_timer(&self, until: i64) {
|
||||
let core = self.clone();
|
||||
let wait = Duration::from_secs(until.saturating_sub(now_secs()).max(0) as u64);
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("pdfs-pause".into())
|
||||
.spawn(move || {
|
||||
if !core.shutdown.sleep(wait) {
|
||||
return;
|
||||
}
|
||||
if core
|
||||
.sync_paused_until
|
||||
.compare_exchange(until, 0, Ordering::Relaxed, Ordering::Relaxed)
|
||||
.is_ok()
|
||||
{
|
||||
if let Err(error) = core.db.clear_state(PAUSED_UNTIL_KEY) {
|
||||
warn!(%error, "clearing an expired sync pause failed");
|
||||
}
|
||||
info!("sync pause ended");
|
||||
core.resume_sync_work();
|
||||
}
|
||||
});
|
||||
if let Err(error) = spawned {
|
||||
// The drain and the reconcile still read the clock, so the pause
|
||||
// still ends — just at the next idle poll instead of on time.
|
||||
warn!(%error, "starting the sync pause timer failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_pause_is_in_force_until_its_deadline() {
|
||||
assert!(!paused_at(0, 100));
|
||||
assert!(paused_at(200, 100));
|
||||
assert!(!paused_at(200, 200));
|
||||
assert!(!paused_at(200, 300));
|
||||
assert!(paused_at(PAUSED_INDEFINITELY, i64::MAX - 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_pause_is_kept_per_folder_until_cleared() {
|
||||
let db = Db::open(std::path::Path::new(":memory:")).unwrap();
|
||||
assert!(!load_folder_paused(&db, 3));
|
||||
store_folder_paused(&db, 3, true).unwrap();
|
||||
assert!(load_folder_paused(&db, 3));
|
||||
assert!(!load_folder_paused(&db, 4));
|
||||
assert!(!load_folder_paused(&db, 33));
|
||||
store_folder_paused(&db, 3, false).unwrap();
|
||||
assert!(!load_folder_paused(&db, 3));
|
||||
}
|
||||
}
|
||||
+2086
-72
File diff suppressed because it is too large.
Load diff
+199
-38
@@ -1,5 +1,5 @@
|
||||
//! Reading and writing this machine's `profile.json` in its device root, and
|
||||
//! the restore that rebuilds a machine from one (features.md 5.2 and 5.3).
|
||||
//! the restore that rebuilds a machine from one.
|
||||
//!
|
||||
//! The document itself is defined in [`pdfs_core::profile`]; this is the half
|
||||
//! that talks to Drive. Saving is best-effort and never blocks a user action:
|
||||
@@ -12,7 +12,10 @@ use std::sync::OnceLock;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use pdfs_core::control::RestorableFolder;
|
||||
use pdfs_core::profile::{PROFILE_FILE_NAME, PROFILE_VERSION, Profile, ProfileFolder, ProfilePin};
|
||||
use pdfs_core::mounts::MountMode;
|
||||
use pdfs_core::profile::{
|
||||
PROFILE_DIR_NAME, PROFILE_FILE_NAME, PROFILE_VERSION, Profile, ProfileFolder, ProfilePin,
|
||||
};
|
||||
use pdfs_core::{CoreError, CoreResult};
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
use tracing::{info, warn};
|
||||
@@ -63,8 +66,9 @@ impl Core {
|
||||
});
|
||||
}
|
||||
|
||||
/// Serialize this machine's arrangement and upload it to `profile.json` in
|
||||
/// the device root, replacing any existing one.
|
||||
/// Serialize this machine's arrangement and upload it to `profile.json`
|
||||
/// inside the device's `.proton-drive-linux` folder, replacing any existing
|
||||
/// one.
|
||||
///
|
||||
/// Does nothing when no device is registered: a machine that has never
|
||||
/// synced a folder has nothing to describe, and registering a device just to
|
||||
@@ -85,15 +89,19 @@ impl Core {
|
||||
let bytes = profile.to_bytes().map_err(CoreError::internal)?;
|
||||
let len = bytes.len() as i64;
|
||||
|
||||
// The device root itself rejects file creation (B78), so the profile
|
||||
// lives one level down, in a folder created on first save.
|
||||
let dir_uid = self.ensure_profile_dir(&root_uid)?;
|
||||
|
||||
// Replace the existing document rather than adding a second file of the
|
||||
// same name: the profile is a single mutable record, and a device root
|
||||
// with `profile.json` and `profile (1).json` in it teaches the restore
|
||||
// same name: the profile is a single mutable record, and a folder with
|
||||
// `profile.json` and `profile (1).json` in it teaches the restore
|
||||
// nothing about which to trust.
|
||||
let existing = self.find_device_child_file(&root_uid, PROFILE_FILE_NAME)?;
|
||||
let existing = self.find_device_child_file(&dir_uid, PROFILE_FILE_NAME)?;
|
||||
match existing {
|
||||
Some(uid) => self
|
||||
.rt
|
||||
.block_on(self.client.upload_new_revision_from(
|
||||
.block_on(self.drive.upload_new_revision_from(
|
||||
&uid,
|
||||
Cursor::new(bytes),
|
||||
len,
|
||||
@@ -104,8 +112,8 @@ impl Core {
|
||||
.map_err(|e| CoreError::from_api(&e, "upload profile revision"))?,
|
||||
None => {
|
||||
self.rt
|
||||
.block_on(self.client.upload_file_replacing_draft_from(
|
||||
&root_uid,
|
||||
.block_on(self.drive.upload_file_replacing_draft_from(
|
||||
&dir_uid,
|
||||
PROFILE_FILE_NAME,
|
||||
"application/json",
|
||||
Cursor::new(bytes),
|
||||
@@ -133,7 +141,7 @@ impl Core {
|
||||
local_path: f.local_path,
|
||||
// A queued switch is where the user asked the folder to be, so
|
||||
// that is what the next machine should set up.
|
||||
mode: f.pending_mode.unwrap_or(f.mode),
|
||||
mode: MountMode::from(f.pending_mode.unwrap_or(f.mode).as_str()),
|
||||
})
|
||||
.collect();
|
||||
let pins = self
|
||||
@@ -141,10 +149,10 @@ impl Core {
|
||||
.pin_list()
|
||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
|
||||
.into_iter()
|
||||
.map(|(uid, path, recursive)| ProfilePin {
|
||||
uid,
|
||||
path,
|
||||
recursive,
|
||||
.map(|row| ProfilePin {
|
||||
uid: row.uid,
|
||||
path: row.path,
|
||||
recursive: row.recursive,
|
||||
})
|
||||
.collect();
|
||||
let cfg = pdfs_core::config::AppDirs::new()
|
||||
@@ -163,25 +171,25 @@ impl Core {
|
||||
})
|
||||
}
|
||||
|
||||
/// An untrashed *file* named `name` directly under the device root.
|
||||
/// An untrashed *file* named `name` directly under `parent_uid`.
|
||||
///
|
||||
/// The sibling of `find_device_child_folder`; kept separate because a folder
|
||||
/// named `profile.json` must not be mistaken for the profile.
|
||||
fn find_device_child_file(
|
||||
&self,
|
||||
root_uid: &NodeUid,
|
||||
parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
) -> CoreResult<Option<NodeUid>> {
|
||||
let uids = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_folder_children_node_uids(root_uid))
|
||||
.block_on(self.drive.enumerate_folder_children_node_uids(parent_uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list device root"))?;
|
||||
if uids.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let nodes = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_nodes(&uids))
|
||||
.block_on(self.drive.enumerate_nodes(&uids))
|
||||
.map_err(|e| CoreError::from_api(&e, "resolve device root children"))?;
|
||||
Ok(nodes
|
||||
.into_iter()
|
||||
@@ -189,29 +197,70 @@ impl Core {
|
||||
.map(|n| n.uid))
|
||||
}
|
||||
|
||||
/// Download and parse the profile in the device root, if there is one.
|
||||
/// The device's `.proton-drive-linux` folder, creating it if this is the
|
||||
/// first save on this device.
|
||||
///
|
||||
/// Reused by stable identity rather than recreated: a second folder of the
|
||||
/// same name would split the profile across two documents, and the restore
|
||||
/// would have no way to tell which is current.
|
||||
fn ensure_profile_dir(&self, root_uid: &NodeUid) -> CoreResult<NodeUid> {
|
||||
if let Some(uid) = self.find_device_child_folder(root_uid, PROFILE_DIR_NAME)? {
|
||||
return Ok(uid);
|
||||
}
|
||||
let uid = self
|
||||
.rt
|
||||
.block_on(
|
||||
self.drive
|
||||
.create_folder(root_uid, PROFILE_DIR_NAME, Some(now_secs())),
|
||||
)
|
||||
.map_err(|e| CoreError::from_api(&e, "create profile folder"))?;
|
||||
info!(name = PROFILE_DIR_NAME, "created device profile folder");
|
||||
Ok(uid)
|
||||
}
|
||||
|
||||
/// Download and parse this device's profile, if there is one.
|
||||
///
|
||||
/// Looks in `.proton-drive-linux/` first and falls back to the device root,
|
||||
/// where clients before the B78 fix wrote it. The fallback is read-only: the
|
||||
/// next save writes to the folder, and the legacy document is left alone
|
||||
/// rather than trashed, so an older client on another machine keeps working.
|
||||
fn load_profile(&self, root_uid: &NodeUid) -> CoreResult<Option<Profile>> {
|
||||
let Some(uid) = self.find_device_child_file(root_uid, PROFILE_FILE_NAME)? else {
|
||||
return Ok(None);
|
||||
let current = match self.find_device_child_folder(root_uid, PROFILE_DIR_NAME)? {
|
||||
Some(dir_uid) => self.find_device_child_file(&dir_uid, PROFILE_FILE_NAME)?,
|
||||
None => None,
|
||||
};
|
||||
let uid = match current {
|
||||
Some(uid) => uid,
|
||||
None => match self.find_device_child_file(root_uid, PROFILE_FILE_NAME)? {
|
||||
Some(uid) => {
|
||||
info!("reading legacy profile from the device root");
|
||||
uid
|
||||
}
|
||||
None => return Ok(None),
|
||||
},
|
||||
};
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
self.rt
|
||||
.block_on(self.client.download_file_to(&uid, &mut buf))
|
||||
.block_on(self.drive.download_file_to(&uid, &mut buf))
|
||||
.map_err(|e| CoreError::from_api(&e, "download profile"))?;
|
||||
Profile::parse(&buf).map(Some).map_err(CoreError::invalid)
|
||||
}
|
||||
|
||||
// ---- restore (features.md 5.2) ----------------------------------------
|
||||
// ---- restore ----------------------------------------
|
||||
|
||||
/// The folders under this machine's device that could be synced here, each
|
||||
/// carrying a proposed local path.
|
||||
/// The folders under a device that could be synced here, each carrying a
|
||||
/// proposed local path. `device` names another registered device by uid;
|
||||
/// `None` is this machine's own.
|
||||
///
|
||||
/// The proposal comes from the profile when one exists and its path is
|
||||
/// usable on this machine, and from `~/<name>` otherwise — a restored
|
||||
/// `/home/other-user/Documents` would be wrong on a machine where that user
|
||||
/// does not exist, and silently creating it would be worse.
|
||||
pub(crate) fn list_restorable_folders(&self) -> CoreResult<Vec<RestorableFolder>> {
|
||||
let device = self.ensure_device()?;
|
||||
pub(crate) fn list_restorable_folders(
|
||||
&self,
|
||||
device: Option<&str>,
|
||||
) -> CoreResult<Vec<RestorableFolder>> {
|
||||
let device = self.restore_source(device)?;
|
||||
let root_uid = parse_uid(&device.root_uid).ok_or_else(|| {
|
||||
CoreError::internal(format!("bad device root uid: {}", device.root_uid))
|
||||
})?;
|
||||
@@ -228,13 +277,13 @@ impl Core {
|
||||
|
||||
let uids = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_folder_children_node_uids(&root_uid))
|
||||
.block_on(self.drive.enumerate_folder_children_node_uids(&root_uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list device root"))?;
|
||||
let nodes = if uids.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
self.rt
|
||||
.block_on(self.client.enumerate_nodes(&uids))
|
||||
.block_on(self.drive.enumerate_nodes(&uids))
|
||||
.map_err(|e| CoreError::from_api(&e, "resolve device root children"))?
|
||||
};
|
||||
|
||||
@@ -251,7 +300,10 @@ impl Core {
|
||||
|
||||
Ok(nodes
|
||||
.into_iter()
|
||||
.filter(|n| n.is_folder() && !n.trashed)
|
||||
// `.proton-drive-linux` holds the profile itself, not user data:
|
||||
// offering it as a restorable folder would sync the bookkeeping
|
||||
// back onto the machine it describes.
|
||||
.filter(|n| n.is_folder() && !n.trashed && n.name != PROFILE_DIR_NAME)
|
||||
.map(|n| {
|
||||
let uid = n.uid.to_string();
|
||||
let saved = profile
|
||||
@@ -267,7 +319,7 @@ impl Core {
|
||||
name: n.name,
|
||||
local_path: proposed.to_string_lossy().to_string(),
|
||||
mode: saved
|
||||
.map(|f| f.mode.clone())
|
||||
.map(|f| f.mode.to_string())
|
||||
.unwrap_or_else(|| "mirror".into()),
|
||||
}
|
||||
})
|
||||
@@ -286,11 +338,12 @@ impl Core {
|
||||
pub(crate) fn restore_sync_folders(
|
||||
&self,
|
||||
items: &[pdfs_core::control::RestoreItem],
|
||||
device: Option<&str>,
|
||||
) -> CoreResult<String> {
|
||||
if items.is_empty() {
|
||||
return Err(CoreError::invalid("nothing to restore"));
|
||||
}
|
||||
let device = self.ensure_device()?;
|
||||
let device = self.restore_source(device)?;
|
||||
let existing = self
|
||||
.db
|
||||
.sync_folder_list()
|
||||
@@ -319,10 +372,10 @@ impl Core {
|
||||
skipped.push(format!("{}: {e}", item.local_path));
|
||||
continue;
|
||||
}
|
||||
let mode = match item.mode.as_str() {
|
||||
"mirror" | "ondemand" => item.mode.as_str(),
|
||||
other => {
|
||||
skipped.push(format!("{}: unknown mode {other}", item.local_path));
|
||||
let mode = match restore_mode(&item.mode) {
|
||||
Some(mode) => mode,
|
||||
None => {
|
||||
skipped.push(format!("{}: unknown mode {}", item.local_path, item.mode));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
@@ -333,7 +386,7 @@ impl Core {
|
||||
// `ondemand` is applied through the normal request path, which
|
||||
// mounts the FUSE session and handles the folder being busy — the
|
||||
// restore does not get its own copy of that logic.
|
||||
if mode == "ondemand" {
|
||||
if mode == MountMode::OnDemand {
|
||||
let _ = self.request_sync_folder_mode(id, "ondemand");
|
||||
}
|
||||
let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id));
|
||||
@@ -350,6 +403,17 @@ impl Core {
|
||||
}
|
||||
}
|
||||
|
||||
/// Accept only modes this build can safely restore. `MountMode` itself is
|
||||
/// tolerant so a future value does not reject the whole profile; the folder
|
||||
/// carrying that value is skipped here with the message assembled above.
|
||||
fn restore_mode(value: &str) -> Option<MountMode> {
|
||||
match MountMode::from(value) {
|
||||
MountMode::Mirror => Some(MountMode::Mirror),
|
||||
MountMode::OnDemand => Some(MountMode::OnDemand),
|
||||
MountMode::Unknown => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a path recorded on another machine can be reused verbatim here.
|
||||
///
|
||||
/// Requires the *parent* to exist: `~/Documents` on a machine with the same
|
||||
@@ -382,6 +446,103 @@ mod tests {
|
||||
assert!(!path_is_usable_here(Path::new("relative/path")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_profile_mode_is_skipped_without_rejecting_known_modes() {
|
||||
assert_eq!(restore_mode("mirror"), Some(MountMode::Mirror));
|
||||
assert_eq!(restore_mode("ondemand"), Some(MountMode::OnDemand));
|
||||
assert_eq!(restore_mode("streaming"), None);
|
||||
assert_eq!(restore_mode("unknown"), None);
|
||||
}
|
||||
|
||||
/// The device root refuses file creation (B78), so every upload in
|
||||
/// `save_profile` must be addressed to the profile folder. Checked against
|
||||
/// the source because both branches are pure network calls.
|
||||
#[test]
|
||||
fn the_profile_is_never_uploaded_to_the_device_root() {
|
||||
let source = include_str!("profile.rs");
|
||||
let save = function_source(source, "fn save_profile(");
|
||||
assert!(
|
||||
save.contains("ensure_profile_dir(&root_uid)"),
|
||||
"save_profile resolves the profile folder"
|
||||
);
|
||||
for upload in [
|
||||
"upload_new_revision_from",
|
||||
"upload_file_replacing_draft_from",
|
||||
] {
|
||||
let call = save.find(upload).expect("upload call exists");
|
||||
let args = &save[call..];
|
||||
let root = args.find("&root_uid").unwrap_or(usize::MAX);
|
||||
let dir = args.find("&dir_uid").unwrap_or(usize::MAX);
|
||||
assert!(dir < root, "{upload} must target the profile folder");
|
||||
}
|
||||
}
|
||||
|
||||
/// A read has to keep finding profiles written before the fix, which sat in
|
||||
/// the device root.
|
||||
#[test]
|
||||
fn loading_falls_back_to_a_legacy_device_root_profile() {
|
||||
let source = include_str!("profile.rs");
|
||||
let load = function_source(source, "fn load_profile(");
|
||||
assert!(load.contains("PROFILE_DIR_NAME"), "prefers the folder");
|
||||
assert!(
|
||||
load.find("PROFILE_DIR_NAME").unwrap()
|
||||
< load.find("legacy profile").expect("legacy fallback exists"),
|
||||
"the folder is consulted before the device root"
|
||||
);
|
||||
}
|
||||
|
||||
/// The profile folder is the client's own bookkeeping: neither the restore
|
||||
/// picker nor `add_sync_folder` may treat it as a user folder.
|
||||
#[test]
|
||||
fn the_profile_folder_is_not_offered_as_user_data() {
|
||||
let restorable = function_source(include_str!("profile.rs"), "fn list_restorable_folders(");
|
||||
assert!(
|
||||
restorable.contains("n.name != PROFILE_DIR_NAME"),
|
||||
"restorable folders exclude the profile folder"
|
||||
);
|
||||
let add = function_source(include_str!("devices.rs"), "fn add_sync_folder(");
|
||||
assert_before(add, "PROFILE_DIR_NAME", "ensure_device()");
|
||||
}
|
||||
|
||||
/// Restoring another computer's folders reads that device, but must not
|
||||
/// rewrite the cached device row: this machine would then back up as the
|
||||
/// other computer.
|
||||
#[test]
|
||||
fn restoring_from_another_device_keeps_this_machines_identity() {
|
||||
let source = function_source(include_str!("devices.rs"), "fn restore_source(");
|
||||
assert!(source.contains("ensure_device()"), "None is this machine");
|
||||
assert!(
|
||||
!source.contains("device_set"),
|
||||
"the cached row is untouched"
|
||||
);
|
||||
}
|
||||
|
||||
fn function_source<'a>(source: &'a str, signature: &str) -> &'a str {
|
||||
let start = source.find(signature).expect("function signature exists");
|
||||
let body = &source[start..];
|
||||
let open = body.find('{').expect("function body starts");
|
||||
let mut depth = 0usize;
|
||||
for (offset, byte) in body.as_bytes()[open..].iter().enumerate() {
|
||||
match byte {
|
||||
b'{' => depth += 1,
|
||||
b'}' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
return &body[..open + offset + 1];
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
panic!("function body closes")
|
||||
}
|
||||
|
||||
fn assert_before(source: &str, first: &str, second: &str) {
|
||||
let first_at = source.find(first).expect("first operation exists");
|
||||
let second_at = source.find(second).expect("second operation exists");
|
||||
assert!(first_at < second_at, "{first} must precede {second}");
|
||||
}
|
||||
|
||||
/// A path whose parent exists but which does not itself is exactly the
|
||||
/// restore case — the directory is about to be created.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
//! The user's view of the pending-op queue: what has not reached the remote
|
||||
//! yet, why, and a way to stop waiting out a backoff.
|
||||
//!
|
||||
//! Read-only apart from "retry now", which only moves a row's next-attempt time,
|
||||
//! "export", which copies a staged blob out, and "discard". Only a discard the
|
||||
//! user asks for drops a queued op: its staged blob is the only copy of the
|
||||
//! user's write (see `docs/RECOVERY.md`), so it is the user's call, made after
|
||||
//! an export or for a change with no content.
|
||||
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
use pdfs_core::control::{PendingOpInfo, SyncIssue};
|
||||
use pdfs_core::db::{FAILING_ATTEMPTS, PARK_UNTIL};
|
||||
|
||||
impl Core {
|
||||
/// Every queued op, oldest first, with a path a person can read.
|
||||
pub(crate) fn pending_op_infos(&self) -> CoreResult<Vec<PendingOpInfo>> {
|
||||
let ops = self
|
||||
.db
|
||||
.pending_ops()
|
||||
.map_err(|e| CoreError::internal(format!("listing the queue: {e}")))?;
|
||||
let issues = self
|
||||
.db
|
||||
.op_issues()
|
||||
.map_err(|e| CoreError::internal(format!("listing sync issues: {e}")))?;
|
||||
Ok(ops
|
||||
.into_iter()
|
||||
.map(|op| {
|
||||
let parked = op.next_attempt_at >= PARK_UNTIL;
|
||||
let exportable = exportable(&op).is_some();
|
||||
let path = self
|
||||
.db
|
||||
.node_path(&op.uid)
|
||||
.ok()
|
||||
.flatten()
|
||||
.filter(|path| !path.is_empty())
|
||||
.or_else(|| op.name.clone())
|
||||
.unwrap_or_else(|| op.uid.clone());
|
||||
PendingOpInfo {
|
||||
id: op.id,
|
||||
kind: op.kind,
|
||||
path,
|
||||
attempts: op.attempts,
|
||||
last_error: op.last_error,
|
||||
queued_at: op.created_at / 1000,
|
||||
next_attempt_at: (!parked).then_some(op.next_attempt_at / 1000),
|
||||
parked,
|
||||
failing: op.attempts >= FAILING_ATTEMPTS || issues.contains_key(&op.id),
|
||||
issue: issues.get(&op.id).map(|issue| SyncIssue::parse(issue)),
|
||||
exportable,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Retry one backed-off op now, or every failed one when `id` is `None`.
|
||||
/// Returns how many ops were moved up.
|
||||
pub(crate) fn retry_pending_ops(&self, id: Option<i64>) -> CoreResult<usize> {
|
||||
let now = now_millis();
|
||||
let moved = match id {
|
||||
Some(id) => self.db.retry_op_now(id, now).map(usize::from),
|
||||
None => self.db.retry_failed_ops_now(now),
|
||||
}
|
||||
.map_err(|e| CoreError::internal(format!("retrying the queue: {e}")))?;
|
||||
if moved > 0 {
|
||||
self.wake_drain();
|
||||
}
|
||||
Ok(moved)
|
||||
}
|
||||
|
||||
/// Copy the content of queued op `id` to `dest`, which must not exist yet.
|
||||
/// The op stays queued. Returns the bytes written.
|
||||
pub(crate) fn export_pending_op(&self, id: i64, dest: &Path) -> CoreResult<u64> {
|
||||
if !dest.is_absolute() {
|
||||
return Err(CoreError::invalid(format!(
|
||||
"destination must be an absolute path: {}",
|
||||
dest.display()
|
||||
)));
|
||||
}
|
||||
let ops = self
|
||||
.db
|
||||
.pending_ops()
|
||||
.map_err(|e| CoreError::internal(format!("listing the queue: {e}")))?;
|
||||
let op = ops
|
||||
.iter()
|
||||
.find(|op| op.id == id)
|
||||
.ok_or_else(|| CoreError::not_found(format!("no queued change {id}")))?;
|
||||
let blob = exportable(op).ok_or_else(|| {
|
||||
CoreError::invalid(format!("queued change {id} carries no file content"))
|
||||
})?;
|
||||
let written = copy_to_new_file(blob, dest)?;
|
||||
info!(id, dest = %dest.display(), written, "exported a queued change's content");
|
||||
Ok(written)
|
||||
}
|
||||
|
||||
/// Drop queued op `id` and undo the local change it stood for. Returns the
|
||||
/// node's path.
|
||||
///
|
||||
/// A node only made here goes, as if it had been deleted before it was
|
||||
/// sent. A node Drive has goes back to what Drive has: every change queued
|
||||
/// on it goes, since each was made on top of the one before. What is queued
|
||||
/// below it stays.
|
||||
pub(crate) fn discard_pending_op(&self, id: i64) -> CoreResult<String> {
|
||||
let ops = self
|
||||
.db
|
||||
.pending_ops()
|
||||
.map_err(|e| CoreError::internal(format!("listing the queue: {e}")))?;
|
||||
let op = ops
|
||||
.into_iter()
|
||||
.find(|op| op.id == id)
|
||||
.ok_or_else(|| CoreError::not_found(format!("no queued change {id}")))?;
|
||||
let uid = parse_node_uid(&op.uid)
|
||||
.ok_or_else(|| CoreError::internal(format!("queued change {id} names {}", op.uid)))?;
|
||||
let path = self
|
||||
.db
|
||||
.node_path(&op.uid)
|
||||
.ok()
|
||||
.flatten()
|
||||
.filter(|path| !path.is_empty())
|
||||
.or(op.name)
|
||||
.unwrap_or_else(|| op.uid.clone());
|
||||
if self.is_open_anywhere(&uid) {
|
||||
return Err(CoreError::conflict(format!(
|
||||
"{path} is open; close it first"
|
||||
)));
|
||||
}
|
||||
if is_local_uid(&uid) {
|
||||
self.discard_local_node(&uid, &path)?;
|
||||
} else {
|
||||
self.discard_remote_change(&uid, &path)?;
|
||||
}
|
||||
info!(id, %uid, path, "discarded a queued change");
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Forget a node made here, and the ops that would have sent it.
|
||||
fn discard_local_node(&self, uid: &NodeUid, path: &str) -> CoreResult<()> {
|
||||
// Its children's ops go with its own, and their content with them.
|
||||
if self
|
||||
.db
|
||||
.has_children(uid)
|
||||
.map_err(|e| CoreError::internal(format!("listing {path}: {e}")))?
|
||||
{
|
||||
return Err(CoreError::conflict(format!(
|
||||
"{path} is not empty; discard what is in it first"
|
||||
)));
|
||||
}
|
||||
self.discard_queued_ops(uid)
|
||||
.map_err(|_| CoreError::internal(format!("dropping the changes to {path}")))?;
|
||||
self.for_each_mount(|st, notify| {
|
||||
background::forget_and_notify(st, notify, uid);
|
||||
});
|
||||
self.cache.evict(uid);
|
||||
self.evict_reader(uid);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Put a node Drive has back as Drive has it.
|
||||
fn discard_remote_change(&self, uid: &NodeUid, path: &str) -> CoreResult<()> {
|
||||
// Asked first: the change is only dropped once what replaces it is known.
|
||||
let node = self
|
||||
.fetch_node_remote(uid)
|
||||
.map_err(|e| CoreError::from_api(&e, "reading the node from Drive"))?
|
||||
.filter(|node| !node.trashed);
|
||||
self.cancel_upload(uid);
|
||||
let blobs = self
|
||||
.db
|
||||
.discard_ops_on_node(&uid.to_string())
|
||||
.map_err(|e| CoreError::internal(format!("dropping the changes to {path}: {e}")))?
|
||||
.ok_or_else(|| {
|
||||
CoreError::conflict(format!("{path} is being sent right now; try again"))
|
||||
})?;
|
||||
self.release_dropped_ops(uid, blobs);
|
||||
self.hidden.lock().remove(uid);
|
||||
match node {
|
||||
Some(node) => self.for_each_mount(|st, notify| {
|
||||
put_back(st, notify, node);
|
||||
}),
|
||||
None => self.for_each_mount(|st, notify| {
|
||||
background::forget_and_notify(st, notify, uid);
|
||||
}),
|
||||
}
|
||||
self.cache.evict(uid);
|
||||
self.evict_reader(uid);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Show `node` as Drive has it, where Drive has it, under the inode it has.
|
||||
fn put_back(st: &mut State, notify: &mut NotifyBatch, node: Node) {
|
||||
let parent = node.parent_uid.as_ref().and_then(|parent| {
|
||||
st.by_uid
|
||||
.get(parent)
|
||||
.copied()
|
||||
.map(|ino| (ino, parent.clone()))
|
||||
});
|
||||
let here = st.by_uid.get(&node.uid).and_then(|ino| {
|
||||
st.entries
|
||||
.get(ino)
|
||||
.map(|entry| (*ino, entry.parent, entry.node.name.clone()))
|
||||
});
|
||||
match (here, parent) {
|
||||
(Some((ino, from, name)), Some((to, to_uid))) => {
|
||||
st.relocate(ino, from, to, &to_uid, &node.name);
|
||||
st.write_through(node.clone());
|
||||
st.intern_mem(to, node);
|
||||
notify.inval_entry(from, name);
|
||||
notify.inval_inode(ino);
|
||||
}
|
||||
(here, parent) => {
|
||||
if let Some((_, from, name)) = here {
|
||||
st.forget_mem(&node.uid);
|
||||
st.invalidate_listing(from);
|
||||
notify.inval_entry(from, name);
|
||||
}
|
||||
if let Some((to, _)) = parent {
|
||||
st.invalidate_listing(to);
|
||||
notify.inval_entry(to, node.name.clone());
|
||||
}
|
||||
st.write_through(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy `blob` to `dest`, which must not exist: never replace something the
|
||||
/// user already has.
|
||||
fn copy_to_new_file(blob: &Path, dest: &Path) -> CoreResult<u64> {
|
||||
let mut source = File::open(blob)
|
||||
.map_err(|e| CoreError::internal(format!("open {}: {e}", blob.display())))?;
|
||||
let mut out = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(dest)
|
||||
.map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::AlreadyExists => {
|
||||
CoreError::invalid(format!("{} already exists", dest.display()))
|
||||
}
|
||||
_ => CoreError::internal(format!("create {}: {e}", dest.display())),
|
||||
})?;
|
||||
std::io::copy(&mut source, &mut out)
|
||||
.and_then(|written| out.flush().and(out.sync_all()).map(|()| written))
|
||||
.map_err(|e| CoreError::internal(format!("write {}: {e}", dest.display())))
|
||||
}
|
||||
|
||||
/// The staged blob of an op that carries file content.
|
||||
fn exportable(op: &PendingOp) -> Option<&Path> {
|
||||
matches!(op.kind.as_str(), OP_CREATE | OP_REVISION)
|
||||
.then_some(op.blob_path.as_deref())
|
||||
.flatten()
|
||||
.map(Path::new)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn op(kind: &str, blob: Option<&str>) -> PendingOp {
|
||||
PendingOp {
|
||||
id: 1,
|
||||
kind: kind.to_string(),
|
||||
uid: "vol~a".to_string(),
|
||||
parent_uid: None,
|
||||
name: None,
|
||||
blob_path: blob.map(str::to_string),
|
||||
meta_json: None,
|
||||
created_at: 0,
|
||||
attempts: 0,
|
||||
last_error: None,
|
||||
next_attempt_at: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_change_that_carries_file_content_can_be_exported() {
|
||||
assert!(exportable(&op(OP_CREATE, Some("/staging/a"))).is_some());
|
||||
assert!(exportable(&op(OP_REVISION, Some("/staging/a"))).is_some());
|
||||
assert!(exportable(&op(OP_CREATE, None)).is_none());
|
||||
assert!(exportable(&op(OP_RENAME, None)).is_none());
|
||||
assert!(exportable(&op(OP_TRASH, None)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_export_copies_the_bytes_and_never_replaces_a_file() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfs-export-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let blob = dir.join("blob");
|
||||
std::fs::write(&blob, b"queued bytes").unwrap();
|
||||
|
||||
let dest = dir.join("copy.txt");
|
||||
assert_eq!(copy_to_new_file(&blob, &dest).unwrap(), 12);
|
||||
assert_eq!(std::fs::read(&dest).unwrap(), b"queued bytes");
|
||||
|
||||
std::fs::write(&dest, b"the user's own").unwrap();
|
||||
assert!(copy_to_new_file(&blob, &dest).is_err());
|
||||
assert_eq!(std::fs::read(&dest).unwrap(), b"the user's own");
|
||||
// The staged blob is the only copy of the change: an export leaves it.
|
||||
assert_eq!(std::fs::read(&blob).unwrap(), b"queued bytes");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
+885
-134
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,329 @@
|
||||
//! Repairing photos whose capture time is when they were *imported* rather than
|
||||
//! when they were taken.
|
||||
//!
|
||||
//! A Google Photos Takeout whose metadata sidecars did not match — a localized
|
||||
//! `.supplemental-metadata` spelling, say — imports with no capture time at all,
|
||||
//! and the SDK then seals "now" into every revision. A decade of photos lands on
|
||||
//! one day.
|
||||
//!
|
||||
//! Proton has no API for editing a sealed capture time, so the repair is a
|
||||
//! rewrite: download the photo, upload it again with the capture time its file
|
||||
//! name implies, put the new copy back into the albums and favorites the old
|
||||
//! one was in, then **trash** (not delete) the original. Trash rather than
|
||||
//! delete so a bad run is recoverable from Proton's own trash for 30 days.
|
||||
//!
|
||||
//! A photo is a candidate only when its name carries a date that disagrees with
|
||||
//! its stored capture time by more than [`DRIFT`]. That is deliberately narrow:
|
||||
//! a photo whose name says nothing about its date is left alone, because there
|
||||
//! is nothing better to set it to.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use pdfs_core::control::{RedateSummary, TransferDirection};
|
||||
use pdfs_core::takeout::{capture_time_from_name, media_type_of};
|
||||
use pdfs_core::{CoreError, CoreResult};
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
use proton_drive_rs::{PhotoTag, PhotoTagsUpdate, PhotoUploadMetadata};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use super::Core;
|
||||
use crate::parse_uid as parse_node_uid;
|
||||
use crate::transfers::CountingReader;
|
||||
|
||||
/// How far a stored capture time may sit from the one the file name implies
|
||||
/// before the photo counts as mis-dated. A day, because a name usually carries
|
||||
/// only the date and the stored time carries a clock — and because a timezone
|
||||
/// can legitimately move a photo by hours.
|
||||
const DRIFT: i64 = 86_400;
|
||||
|
||||
/// How many examples a report carries.
|
||||
const SAMPLES: usize = 10;
|
||||
|
||||
/// The one re-date this daemon may be running, and how the last one ended.
|
||||
/// Process-global for the same reasons as the Takeout import's state: one run
|
||||
/// per daemon however many `Core` clones exist, and a front-end asking after it
|
||||
/// finished must still find the report.
|
||||
static REDATE: std::sync::LazyLock<parking_lot::Mutex<RedateState>> =
|
||||
std::sync::LazyLock::new(|| parking_lot::Mutex::new(RedateState::default()));
|
||||
|
||||
#[derive(Default)]
|
||||
struct RedateState {
|
||||
running: bool,
|
||||
cancel: Option<Arc<AtomicBool>>,
|
||||
last: Option<RedateSummary>,
|
||||
}
|
||||
|
||||
/// Whether a re-date is running, and the last one's report.
|
||||
pub(crate) fn redate_status() -> (bool, Option<RedateSummary>) {
|
||||
let state = REDATE.lock();
|
||||
(state.running, state.last.clone())
|
||||
}
|
||||
|
||||
/// Ask the running re-date to stop. False when none is running.
|
||||
pub(crate) fn cancel_redate() -> bool {
|
||||
match &REDATE.lock().cancel {
|
||||
Some(flag) => {
|
||||
flag.store(true, Ordering::Relaxed);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// One photo to repair.
|
||||
struct Candidate {
|
||||
uid: NodeUid,
|
||||
name: String,
|
||||
/// The capture time it carries now.
|
||||
stored: i64,
|
||||
/// The capture time its name implies.
|
||||
wanted: i64,
|
||||
favorite: bool,
|
||||
/// Album node uids the original is filed under.
|
||||
albums: Vec<String>,
|
||||
}
|
||||
|
||||
impl Core {
|
||||
/// Re-date mis-dated photos. Blocking; run on its own thread.
|
||||
///
|
||||
/// `cancel` stops the run at the next photo boundary. A photo is only ever
|
||||
/// trashed after its replacement has uploaded, so a cancel — or a crash —
|
||||
/// can leave a duplicate, never a hole.
|
||||
pub(crate) fn redate_photos(
|
||||
&self,
|
||||
dry_run: bool,
|
||||
limit: Option<usize>,
|
||||
range: Option<(i64, i64)>,
|
||||
cancel: Arc<AtomicBool>,
|
||||
) -> CoreResult<RedateSummary> {
|
||||
let job = self.transfers.begin_job("Fixing photo dates");
|
||||
job.detail("looking for mis-dated photos");
|
||||
|
||||
let stored = self
|
||||
.db
|
||||
.photos_all()
|
||||
.map_err(|e| CoreError::internal(format!("read timeline: {e}")))?;
|
||||
let mut summary = RedateSummary {
|
||||
examined: stored.len(),
|
||||
dry_run,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut candidates = Vec::new();
|
||||
for photo in stored {
|
||||
if let Some((from, to)) = range
|
||||
&& !(from..to).contains(&photo.capture_time)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(name) = photo.name.filter(|n| !n.is_empty()) else {
|
||||
continue;
|
||||
};
|
||||
let Some(wanted) = capture_time_from_name(&name) else {
|
||||
continue;
|
||||
};
|
||||
if (wanted - photo.capture_time).abs() <= DRIFT {
|
||||
continue;
|
||||
}
|
||||
let Some(uid) = parse_node_uid(&photo.uid) else {
|
||||
continue;
|
||||
};
|
||||
candidates.push(Candidate {
|
||||
uid,
|
||||
name,
|
||||
stored: photo.capture_time,
|
||||
wanted,
|
||||
favorite: photo.favorite,
|
||||
albums: self.db.albums_of_photo(&photo.uid).unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
if let Some(limit) = limit {
|
||||
candidates.truncate(limit);
|
||||
}
|
||||
summary.candidates = candidates.len();
|
||||
// Sampled after the limit, so the examples are of photos the run would
|
||||
// actually touch.
|
||||
summary.samples = candidates
|
||||
.iter()
|
||||
.take(SAMPLES)
|
||||
.map(|c| (c.name.clone(), c.stored, c.wanted))
|
||||
.collect();
|
||||
job.set_total(candidates.len() as u64);
|
||||
info!(
|
||||
examined = summary.examined,
|
||||
candidates = summary.candidates,
|
||||
dry_run,
|
||||
"photo re-date scan complete"
|
||||
);
|
||||
|
||||
if dry_run || candidates.is_empty() {
|
||||
return Ok(summary);
|
||||
}
|
||||
|
||||
for candidate in candidates {
|
||||
if cancel.load(Ordering::Relaxed) || self.shutdown.is_stopping() {
|
||||
break;
|
||||
}
|
||||
job.detail(candidate.name.clone());
|
||||
if let Err(e) = self.redate_one(&candidate, &mut summary) {
|
||||
summary.failed += 1;
|
||||
warn!(name = candidate.name, error = %e, "cannot re-date photo");
|
||||
}
|
||||
job.step();
|
||||
}
|
||||
|
||||
self.spawn_timeline_refresh();
|
||||
summary.cancelled = cancel.load(Ordering::Relaxed);
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
/// Rewrite one photo at the capture time its name implies.
|
||||
///
|
||||
/// Order is load-bearing: upload first, file the new copy, and only then
|
||||
/// trash the original. Every earlier step failing leaves the account exactly
|
||||
/// as it was.
|
||||
fn redate_one(&self, candidate: &Candidate, summary: &mut RedateSummary) -> CoreResult<()> {
|
||||
let photos = self.photos();
|
||||
let path = self.open_photo(&candidate.uid)?;
|
||||
let size = std::fs::metadata(&path)
|
||||
.map_err(|e| CoreError::internal(format!("stat {}: {e}", path.display())))?
|
||||
.len();
|
||||
let media_type = media_type_of(&candidate.name).unwrap_or("application/octet-stream");
|
||||
|
||||
let guard =
|
||||
self.transfers
|
||||
.begin(candidate.name.clone(), "", TransferDirection::Upload, size);
|
||||
let file = std::fs::File::open(&path)
|
||||
.map_err(|e| CoreError::internal(format!("open {}: {e}", path.display())))?;
|
||||
let reader = CountingReader::new(std::io::BufReader::new(file), &guard);
|
||||
let metadata = PhotoUploadMetadata {
|
||||
capture_time: Some(candidate.wanted),
|
||||
..Default::default()
|
||||
};
|
||||
let uploaded = self.rt.block_on(photos.upload_photo_from(
|
||||
&candidate.name,
|
||||
media_type,
|
||||
reader,
|
||||
size as i64,
|
||||
Vec::new(),
|
||||
metadata,
|
||||
false,
|
||||
));
|
||||
drop(guard);
|
||||
let new_uid = uploaded.map_err(|e| CoreError::from_api(&e, "re-upload photo"))?;
|
||||
|
||||
// Favorite and albums before the trash: a failure here leaves both
|
||||
// copies on the account, which is recoverable by hand. Losing the
|
||||
// original first would not be.
|
||||
if candidate.favorite {
|
||||
let update = PhotoTagsUpdate {
|
||||
node_uid: new_uid.clone(),
|
||||
tags_to_add: vec![PhotoTag::Favorite],
|
||||
tags_to_remove: Vec::new(),
|
||||
};
|
||||
match self.rt.block_on(photos.update_photos(&[update])) {
|
||||
// As in `set_photo_favorite`: claim our own echo, so the tag we
|
||||
// just set does not come back as a remote change.
|
||||
Ok(_) => self.note_self_change(&new_uid),
|
||||
Err(e) => {
|
||||
warn!(name = candidate.name, error = %e, "cannot re-favorite re-dated photo")
|
||||
}
|
||||
}
|
||||
}
|
||||
for album in &candidate.albums {
|
||||
let Some(album_uid) = parse_node_uid(album) else {
|
||||
continue;
|
||||
};
|
||||
match self
|
||||
.rt
|
||||
.block_on(photos.add_photos_to_album(&album_uid, std::slice::from_ref(&new_uid)))
|
||||
{
|
||||
Ok(_) => summary.album_links += 1,
|
||||
Err(e) => {
|
||||
warn!(name = candidate.name, error = %e, "cannot re-file re-dated photo")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let outcomes = self
|
||||
.rt
|
||||
.block_on(self.drive.trash_nodes(std::slice::from_ref(&candidate.uid)))
|
||||
.map_err(|e| CoreError::from_api(&e, "trash the mis-dated original"))?;
|
||||
if let Some((_, Err(e))) = outcomes.into_iter().next() {
|
||||
return Err(CoreError::from_api(&e, "trash the mis-dated original"));
|
||||
}
|
||||
|
||||
summary.redated += 1;
|
||||
summary.bytes += size;
|
||||
info!(
|
||||
name = candidate.name,
|
||||
from = candidate.stored,
|
||||
to = candidate.wanted,
|
||||
"photo re-dated"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start a re-date on its own thread. Errors when one is already running.
|
||||
pub(crate) fn spawn_redate(
|
||||
&self,
|
||||
dry_run: bool,
|
||||
limit: Option<usize>,
|
||||
range: Option<(i64, i64)>,
|
||||
) -> CoreResult<()> {
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
{
|
||||
let mut state = REDATE.lock();
|
||||
if state.running {
|
||||
return Err(CoreError::conflict(
|
||||
"a photo re-date is already running; wait for it to finish or cancel it",
|
||||
));
|
||||
}
|
||||
state.running = true;
|
||||
state.cancel = Some(cancel.clone());
|
||||
state.last = None;
|
||||
}
|
||||
|
||||
let core = self.clone();
|
||||
std::thread::spawn(move || {
|
||||
let outcome = core.redate_photos(dry_run, limit, range, cancel);
|
||||
let mut state = REDATE.lock();
|
||||
state.running = false;
|
||||
state.cancel = None;
|
||||
state.last = outcome.as_ref().ok().cloned();
|
||||
match &outcome {
|
||||
Ok(summary) => info!(
|
||||
candidates = summary.candidates,
|
||||
redated = summary.redated,
|
||||
failed = summary.failed,
|
||||
"photo re-date finished"
|
||||
),
|
||||
Err(e) => warn!(error = %e, "photo re-date failed"),
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_uid_without_both_halves_is_not_a_node() {
|
||||
assert!(parse_node_uid("vol~link").is_some());
|
||||
assert!(parse_node_uid("vol~").is_none());
|
||||
assert!(parse_node_uid("~link").is_none());
|
||||
assert!(parse_node_uid("nothing").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_disagreement_larger_than_a_day_counts() {
|
||||
// The name says 2023-02-19; a stored time inside that day is fine.
|
||||
let wanted = capture_time_from_name("IMG-20230219-WA0001.jpg").unwrap();
|
||||
assert!((wanted - (wanted + 3_600)).abs() <= DRIFT);
|
||||
// An import three years later is not.
|
||||
assert!((wanted - (wanted + 3 * 365 * 86_400)).abs() > DRIFT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,611 @@
|
||||
//! Moving a file or folder between two locations without moving its content.
|
||||
//!
|
||||
//! `~/ProtonDrive` and every on-demand synced folder are separate FUSE
|
||||
//! sessions, and a mirror folder is a plain local directory. The kernel refuses
|
||||
//! `rename(2)` between two mounts with `EXDEV` before the daemon hears of it, so
|
||||
//! `mv` falls back to copy and delete: every file is downloaded, uploaded again,
|
||||
//! and the original is trashed.
|
||||
//!
|
||||
//! On Proton Drive the same move is one metadata change, because My files and
|
||||
//! this computer's device folders all live on the main volume. So a move
|
||||
//! request that names two absolute paths is resolved here, against every
|
||||
//! location this daemon knows, and done as a server-side move. The local side
|
||||
//! is then brought level: a mount forgets the node and re-lists, and a mirror
|
||||
//! folder either renames its copy along or drops it once Drive has it.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use pdfs_core::db::{StoredSyncEntry, StoredSyncFolder};
|
||||
use pdfs_core::{CoreError, CoreResult};
|
||||
use proton_drive_rs::NodeKind;
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use super::sync::mirror_subtree_unsynced;
|
||||
use super::{Core, is_local_uid, parse_uid};
|
||||
|
||||
/// How long a move waits for a mirror folder's sync pass to finish before it
|
||||
/// reports the folder busy. A pass can run for hours on a first sync, so the
|
||||
/// move does not wait it out.
|
||||
const MIRROR_LOCK_WAIT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Where one side of a move lives.
|
||||
enum Place {
|
||||
/// My files or an on-demand folder: a live FUSE mount. `core` is rooted at
|
||||
/// that mount and `rel` is relative to its mountpoint.
|
||||
Mounted { core: Box<Core>, rel: PathBuf },
|
||||
/// A mirror folder: a local directory kept in step by the sync engine.
|
||||
/// `rel` is `/`-separated, relative to the folder, as the baseline keys it.
|
||||
Mirror {
|
||||
folder: StoredSyncFolder,
|
||||
rel: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Place {
|
||||
fn mirror_id(&self) -> Option<i64> {
|
||||
match self {
|
||||
Place::Mirror { folder, .. } => Some(folder.id),
|
||||
Place::Mounted { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_root(&self) -> bool {
|
||||
match self {
|
||||
Place::Mounted { rel, .. } => rel.as_os_str().is_empty(),
|
||||
Place::Mirror { rel, .. } => rel.is_empty(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The mirror folder that holds `abs`, and `abs` relative to it. The deepest
|
||||
/// folder wins, although folders are not expected to nest.
|
||||
fn mirror_covering(
|
||||
folders: Vec<StoredSyncFolder>,
|
||||
abs: &Path,
|
||||
) -> Option<(StoredSyncFolder, String)> {
|
||||
let folder = folders
|
||||
.into_iter()
|
||||
.filter(|f| f.mode == "mirror" && abs.starts_with(&f.local_path))
|
||||
.max_by_key(|f| Path::new(&f.local_path).components().count())?;
|
||||
let rel = abs.strip_prefix(&folder.local_path).ok()?;
|
||||
let parts = rel
|
||||
.components()
|
||||
.map(|c| match c {
|
||||
Component::Normal(part) => part.to_str(),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
Some((folder, parts.join("/")))
|
||||
}
|
||||
|
||||
/// `name` inside the folder at `parent`, in baseline form.
|
||||
fn join_rel(parent: &str, name: &str) -> String {
|
||||
if parent.is_empty() {
|
||||
name.to_string()
|
||||
} else {
|
||||
format!("{parent}/{name}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Rename `from` to `to`, failing with `EEXIST` rather than replacing
|
||||
/// whatever appeared at `to` since it was checked. A filesystem that cannot
|
||||
/// promise that gets a plain rename after one more look.
|
||||
fn rename_noreplace(from: &Path, to: &Path) -> std::io::Result<()> {
|
||||
use std::ffi::CString;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
|
||||
let cstr = |p: &Path| {
|
||||
CString::new(p.as_os_str().as_bytes())
|
||||
.map_err(|_| std::io::Error::from_raw_os_error(libc::EINVAL))
|
||||
};
|
||||
let (c_from, c_to) = (cstr(from)?, cstr(to)?);
|
||||
// SAFETY: both are valid NUL-terminated paths that outlive the call.
|
||||
let rc = unsafe {
|
||||
libc::syscall(
|
||||
libc::SYS_renameat2,
|
||||
libc::AT_FDCWD,
|
||||
c_from.as_ptr(),
|
||||
libc::AT_FDCWD,
|
||||
c_to.as_ptr(),
|
||||
libc::RENAME_NOREPLACE,
|
||||
)
|
||||
};
|
||||
if rc == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let err = std::io::Error::last_os_error();
|
||||
match err.raw_os_error() {
|
||||
Some(libc::EINVAL | libc::ENOSYS) => {
|
||||
if std::fs::symlink_metadata(to).is_ok() {
|
||||
return Err(std::io::Error::from_raw_os_error(libc::EEXIST));
|
||||
}
|
||||
std::fs::rename(from, to)
|
||||
}
|
||||
_ => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `uid` is `root` or lies under it, as far as the nodes table knows.
|
||||
fn under(core: &Core, root: &NodeUid, uid: &str) -> bool {
|
||||
let root = root.to_string();
|
||||
uid == root || matches!(core.db.path_relative_to(&root, uid), Ok(Some(_)))
|
||||
}
|
||||
|
||||
impl Core {
|
||||
/// Move the file or folder at `src` into the folder at `dest_parent`. Both
|
||||
/// are absolute local paths and may be in different locations: My files,
|
||||
/// an on-demand folder, or a mirror folder. Returns the moved name.
|
||||
///
|
||||
/// Within one mount this is [`Core::move_to`]. Across locations it
|
||||
/// is a server-side move followed by local bookkeeping; see the module
|
||||
/// documentation. Anything the move could lose is refused up front.
|
||||
pub(crate) fn move_between(&self, src: &Path, dest_parent: &Path) -> CoreResult<String> {
|
||||
let source = self.locate(src)?;
|
||||
let dest = self.locate(dest_parent)?;
|
||||
if let (
|
||||
Place::Mounted { core, rel },
|
||||
Place::Mounted {
|
||||
core: dest_core,
|
||||
rel: dest_rel,
|
||||
},
|
||||
) = (&source, &dest)
|
||||
&& Arc::ptr_eq(&core.root, &dest_core.root)
|
||||
{
|
||||
return core.move_to(rel, dest_rel);
|
||||
}
|
||||
if source.is_root() {
|
||||
return Err(CoreError::invalid(format!(
|
||||
"{} is the root of a synced location and cannot be moved",
|
||||
src.display()
|
||||
)));
|
||||
}
|
||||
if dest_parent.starts_with(src) {
|
||||
return Err(CoreError::invalid("cannot move a folder into itself"));
|
||||
}
|
||||
let name = src
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.ok_or_else(|| CoreError::invalid(format!("{} has no usable name", src.display())))?
|
||||
.to_string();
|
||||
|
||||
// Hold every mirror folder involved, lowest id first, so no sync pass
|
||||
// sees the move half done and two moves cannot deadlock.
|
||||
let mut ids: Vec<i64> = [source.mirror_id(), dest.mirror_id()]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
let locks: Vec<Arc<Mutex<()>>> = ids.iter().map(|&id| self.sync_lock(id)).collect();
|
||||
let mut guards = Vec::with_capacity(locks.len());
|
||||
for lock in &locks {
|
||||
guards.push(lock.try_lock_for(MIRROR_LOCK_WAIT).ok_or_else(|| {
|
||||
CoreError::conflict("a synced folder is busy syncing; try again when it is done")
|
||||
})?);
|
||||
}
|
||||
|
||||
let mut baselines: HashMap<i64, HashMap<String, StoredSyncEntry>> = HashMap::new();
|
||||
for &id in &ids {
|
||||
let baseline = self
|
||||
.db
|
||||
.sync_entries(id)
|
||||
.map_err(|e| CoreError::internal(format!("db: {e}")))?;
|
||||
baselines.insert(id, baseline);
|
||||
}
|
||||
|
||||
let src_uid = self.source_uid(&source, &baselines)?;
|
||||
let dest_uid = self.dest_uid(&dest, &baselines, &name)?;
|
||||
if src_uid.volume_id != dest_uid.volume_id {
|
||||
return Err(CoreError::invalid(
|
||||
"the two locations are on different Proton Drive volumes; copy it instead",
|
||||
));
|
||||
}
|
||||
if under(self, &src_uid, &dest_uid.to_string()) {
|
||||
return Err(CoreError::invalid("cannot move a folder into itself"));
|
||||
}
|
||||
|
||||
// Mirror to mirror renames the local copy along, so it needs nothing
|
||||
// proven about it. Another filesystem makes that impossible, and the
|
||||
// pair then drops the source copy like any other move out of a mirror.
|
||||
let mut renamed = None;
|
||||
if let (
|
||||
Place::Mirror { folder, rel },
|
||||
Place::Mirror {
|
||||
folder: dest_folder,
|
||||
rel: dest_rel,
|
||||
},
|
||||
) = (&source, &dest)
|
||||
{
|
||||
let from = src.to_path_buf();
|
||||
let to = dest_parent.join(&name);
|
||||
match rename_noreplace(&from, &to) {
|
||||
Ok(()) => renamed = Some((from, to)),
|
||||
Err(e) if e.raw_os_error() == Some(libc::EXDEV) => {
|
||||
info!(from = %folder.local_path, to = %dest_folder.local_path,
|
||||
"move: mirror folders are on different filesystems");
|
||||
}
|
||||
Err(e) if e.raw_os_error() == Some(libc::EEXIST) => {
|
||||
return Err(CoreError::conflict(format!(
|
||||
"{name} already exists in {}",
|
||||
dest_parent.display()
|
||||
)));
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(CoreError::internal(format!(
|
||||
"could not move {} to {}: {e}",
|
||||
rel,
|
||||
join_rel(dest_rel, &name)
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if renamed.is_none()
|
||||
&& let Place::Mirror { folder, rel } = &source
|
||||
&& let Some(reason) =
|
||||
mirror_subtree_unsynced(Path::new(&folder.local_path), rel, &baselines[&folder.id])
|
||||
{
|
||||
return Err(CoreError::conflict(format!(
|
||||
"{name} is not fully synced yet ({reason}); wait for the sync to finish"
|
||||
)));
|
||||
}
|
||||
|
||||
if let Err(e) = self.move_remote(&src_uid, &dest_uid) {
|
||||
if let Some((from, to)) = &renamed
|
||||
&& let Err(back) = std::fs::rename(to, from)
|
||||
{
|
||||
warn!(from = %to.display(), to = %from.display(), error = %back,
|
||||
"move: could not put the local copy back after the move failed");
|
||||
}
|
||||
return Err(CoreError::from_api(&e, "move"));
|
||||
}
|
||||
|
||||
self.settle_source(
|
||||
&source,
|
||||
&src_uid,
|
||||
renamed.is_some(),
|
||||
&dest,
|
||||
&dest_uid,
|
||||
&name,
|
||||
src,
|
||||
);
|
||||
self.settle_dest(&dest, &dest_uid, &name);
|
||||
drop(guards);
|
||||
if renamed.is_none()
|
||||
&& let Place::Mirror { folder, .. } = &dest
|
||||
{
|
||||
// Nothing local exists yet; the folder's next pass downloads it.
|
||||
self.sync_now(Some(folder.id));
|
||||
}
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
/// Which location `abs` is in. A mount is looked for first: a mirror folder
|
||||
/// cannot live inside a FUSE mount, and a mount is what the kernel sees.
|
||||
fn locate(&self, abs: &Path) -> CoreResult<Place> {
|
||||
if let Some((core, rel)) = self.rooted_at(abs) {
|
||||
if !rel
|
||||
.components()
|
||||
.all(|c| matches!(c, Component::Normal(_) | Component::CurDir))
|
||||
{
|
||||
return Err(CoreError::invalid(format!(
|
||||
"{} escapes its location",
|
||||
abs.display()
|
||||
)));
|
||||
}
|
||||
return Ok(Place::Mounted {
|
||||
core: Box::new(core),
|
||||
rel,
|
||||
});
|
||||
}
|
||||
let folders = self
|
||||
.db
|
||||
.sync_folder_list()
|
||||
.map_err(|e| CoreError::internal(format!("db: {e}")))?;
|
||||
mirror_covering(folders, abs)
|
||||
.map(|(folder, rel)| Place::Mirror { folder, rel })
|
||||
.ok_or_else(|| {
|
||||
CoreError::invalid(format!(
|
||||
"{} is not in a Proton Drive location",
|
||||
abs.display()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// The node being moved, once everything that must hold before it leaves
|
||||
/// its location has been checked.
|
||||
fn source_uid(
|
||||
&self,
|
||||
source: &Place,
|
||||
baselines: &HashMap<i64, HashMap<String, StoredSyncEntry>>,
|
||||
) -> CoreResult<NodeUid> {
|
||||
match source {
|
||||
Place::Mounted { core, rel } => {
|
||||
let (ino, uid) = core.resolve(rel)?;
|
||||
if is_local_uid(&uid) {
|
||||
return Err(CoreError::conflict(
|
||||
"it is not uploaded yet; wait for the upload to finish",
|
||||
));
|
||||
}
|
||||
let old_parent = core.source_parent_uid(ino, rel)?;
|
||||
for authority in [&uid, &old_parent] {
|
||||
core.require_uid_writable(authority)
|
||||
.map_err(|e| core.errno_error(e, "move access"))?;
|
||||
}
|
||||
if self.has_pending_work_under(&uid)? {
|
||||
return Err(CoreError::conflict(
|
||||
"it has changes that are not uploaded yet; wait for the upload to finish",
|
||||
));
|
||||
}
|
||||
Ok(uid)
|
||||
}
|
||||
Place::Mirror { folder, rel } => baselines[&folder.id]
|
||||
.get(rel)
|
||||
.and_then(|entry| entry.remote_uid.as_deref())
|
||||
.and_then(parse_uid)
|
||||
.ok_or_else(|| {
|
||||
CoreError::conflict(
|
||||
"it is not on Proton Drive yet; wait for the sync to finish",
|
||||
)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// The folder the node moves into. Refuses one that already holds `name`
|
||||
/// on this disk; Drive refuses a clash on its side by itself.
|
||||
fn dest_uid(
|
||||
&self,
|
||||
dest: &Place,
|
||||
baselines: &HashMap<i64, HashMap<String, StoredSyncEntry>>,
|
||||
name: &str,
|
||||
) -> CoreResult<NodeUid> {
|
||||
let uid = match dest {
|
||||
Place::Mounted { core, rel } => {
|
||||
let (ino, uid) = core.resolve(rel)?;
|
||||
let is_folder = core
|
||||
.state()
|
||||
.entries
|
||||
.get(&ino)
|
||||
.is_some_and(|entry| matches!(entry.node.kind, NodeKind::Folder));
|
||||
if !is_folder {
|
||||
return Err(CoreError::invalid("the destination is not a folder"));
|
||||
}
|
||||
core.require_uid_writable(&uid)
|
||||
.map_err(|e| core.errno_error(e, "move access"))?;
|
||||
uid
|
||||
}
|
||||
Place::Mirror { folder, rel } => {
|
||||
let local = Path::new(&folder.local_path).join(rel);
|
||||
if !local.is_dir() {
|
||||
return Err(CoreError::invalid("the destination is not a folder"));
|
||||
}
|
||||
if std::fs::symlink_metadata(local.join(name)).is_ok() {
|
||||
return Err(CoreError::conflict(format!(
|
||||
"{name} already exists in the destination"
|
||||
)));
|
||||
}
|
||||
let uid = if rel.is_empty() {
|
||||
Some(folder.remote_uid.as_str())
|
||||
} else {
|
||||
baselines[&folder.id]
|
||||
.get(rel)
|
||||
.and_then(|entry| entry.remote_uid.as_deref())
|
||||
};
|
||||
uid.and_then(parse_uid).ok_or_else(|| {
|
||||
CoreError::conflict(
|
||||
"the destination is not on Proton Drive yet; wait for the sync to finish",
|
||||
)
|
||||
})?
|
||||
}
|
||||
};
|
||||
if is_local_uid(&uid) {
|
||||
return Err(CoreError::conflict(
|
||||
"the destination is not uploaded yet; wait for the upload to finish",
|
||||
));
|
||||
}
|
||||
Ok(uid)
|
||||
}
|
||||
|
||||
/// Whether the queue owes Drive anything for `uid` or a node under it, or a
|
||||
/// file under it is open for writing. Moving such a node away would strand
|
||||
/// the pending change against a path that no longer leads to it.
|
||||
fn has_pending_work_under(&self, uid: &NodeUid) -> CoreResult<bool> {
|
||||
let ops = self
|
||||
.db
|
||||
.pending_ops()
|
||||
.map_err(|e| CoreError::internal(format!("db: {e}")))?;
|
||||
if ops.iter().any(|op| {
|
||||
under(self, uid, &op.uid)
|
||||
|| op
|
||||
.parent_uid
|
||||
.as_deref()
|
||||
.is_some_and(|p| under(self, uid, p))
|
||||
}) {
|
||||
return Ok(true);
|
||||
}
|
||||
// Collected first so no State lock is held across the nodes-table reads.
|
||||
let mut writing = Vec::new();
|
||||
self.for_each_state(|st| {
|
||||
writing.extend(st.active_writes.values().map(|w| w.uid.to_string()));
|
||||
});
|
||||
Ok(writing.iter().any(|w| under(self, uid, w)))
|
||||
}
|
||||
|
||||
/// Bring the source location level with a move Drive has made.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn settle_source(
|
||||
&self,
|
||||
source: &Place,
|
||||
uid: &NodeUid,
|
||||
renamed: bool,
|
||||
dest: &Place,
|
||||
dest_uid: &NodeUid,
|
||||
name: &str,
|
||||
src: &Path,
|
||||
) {
|
||||
match source {
|
||||
Place::Mounted { core, rel } => {
|
||||
// Moved everywhere, so no mount keeps serving it under the old
|
||||
// path and its row stays (`docs/BUGS.md` B117). The content
|
||||
// cache is keyed by uid and stays valid.
|
||||
self.relocate_everywhere(uid, dest_uid, name);
|
||||
core.invalidate_parent_listing(rel);
|
||||
}
|
||||
Place::Mirror { folder, rel } if renamed => {
|
||||
let Place::Mirror {
|
||||
folder: dest_folder,
|
||||
rel: dest_rel,
|
||||
} = dest
|
||||
else {
|
||||
unreachable!("a local rename needs a mirror destination");
|
||||
};
|
||||
if let Err(e) = self.db.sync_entries_move(
|
||||
folder.id,
|
||||
rel,
|
||||
dest_folder.id,
|
||||
&join_rel(dest_rel, name),
|
||||
) {
|
||||
// The next passes see a copy with no baseline on one side
|
||||
// and a baseline with no copy on the other. That uploads
|
||||
// the files again and trashes the moved node, but loses
|
||||
// nothing.
|
||||
warn!(error = ?e, "move: could not carry the sync baseline along");
|
||||
}
|
||||
}
|
||||
Place::Mirror { folder, rel } => {
|
||||
// Drive had all of it when the move was checked, so the local
|
||||
// copy is only a stale duplicate now. Something written into it
|
||||
// since would go with it, so look again first; if anything is
|
||||
// new, the copy stays and the next pass uploads it as new.
|
||||
let changed = match self.db.sync_entries(folder.id) {
|
||||
Ok(baseline) => {
|
||||
mirror_subtree_unsynced(Path::new(&folder.local_path), rel, &baseline)
|
||||
}
|
||||
Err(e) => Some(format!("db: {e}")),
|
||||
};
|
||||
if let Err(e) = self.db.sync_entries_remove_subtree(folder.id, rel) {
|
||||
warn!(error = ?e, "move: could not drop the sync baseline");
|
||||
}
|
||||
if let Some(reason) = changed {
|
||||
warn!(path = %src.display(), reason,
|
||||
"move: the local copy changed during the move; keeping it");
|
||||
return;
|
||||
}
|
||||
let removed = if src.is_dir() {
|
||||
std::fs::remove_dir_all(src)
|
||||
} else {
|
||||
std::fs::remove_file(src)
|
||||
};
|
||||
if let Err(e) = removed {
|
||||
warn!(path = %src.display(), error = %e,
|
||||
"move: could not remove the local copy after the move");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bring the destination location level with a move Drive has made.
|
||||
fn settle_dest(&self, dest: &Place, uid: &NodeUid, name: &str) {
|
||||
if let Place::Mounted { .. } = dest {
|
||||
self.invalidate_children_of(uid);
|
||||
// A lookup that missed before the move may be cached as absent.
|
||||
self.for_each_mount(|st, notify| {
|
||||
if let Some(&ino) = st.by_uid.get(uid) {
|
||||
notify.inval_entry(ino, name.to_string());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn folder(id: i64, local_path: &str, mode: &str) -> StoredSyncFolder {
|
||||
StoredSyncFolder {
|
||||
id,
|
||||
local_path: local_path.to_string(),
|
||||
remote_uid: format!("vol~root{id}"),
|
||||
remote_share_id: String::new(),
|
||||
mode: mode.to_string(),
|
||||
pending_mode: None,
|
||||
state: "idle".to_string(),
|
||||
last_sync: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_covering_finds_the_mirror_folder_and_the_path_inside_it() {
|
||||
let folders = vec![folder(1, "/home/u/Docs", "mirror")];
|
||||
let (found, rel) = mirror_covering(folders, Path::new("/home/u/Docs/a/b.txt")).unwrap();
|
||||
assert_eq!(found.id, 1);
|
||||
assert_eq!(rel, "a/b.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_covering_gives_an_empty_path_for_the_folder_itself() {
|
||||
let folders = vec![folder(1, "/home/u/Docs", "mirror")];
|
||||
let (_, rel) = mirror_covering(folders, Path::new("/home/u/Docs")).unwrap();
|
||||
assert_eq!(rel, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_covering_prefers_the_deepest_folder() {
|
||||
let folders = vec![
|
||||
folder(1, "/home/u/Docs", "mirror"),
|
||||
folder(2, "/home/u/Docs/Work", "mirror"),
|
||||
];
|
||||
let (found, rel) = mirror_covering(folders, Path::new("/home/u/Docs/Work/x")).unwrap();
|
||||
assert_eq!(found.id, 2);
|
||||
assert_eq!(rel, "x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_covering_skips_on_demand_folders_and_name_prefixed_siblings() {
|
||||
let folders = || {
|
||||
vec![
|
||||
folder(1, "/home/u/Docs", "mirror"),
|
||||
folder(2, "/home/u/Media", "ondemand"),
|
||||
]
|
||||
};
|
||||
assert!(mirror_covering(folders(), Path::new("/home/u/Media/x")).is_none());
|
||||
assert!(mirror_covering(folders(), Path::new("/home/u/Docs2/x")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_covering_refuses_a_path_that_climbs_out() {
|
||||
let folders = vec![folder(1, "/home/u/Docs", "mirror")];
|
||||
assert!(mirror_covering(folders, Path::new("/home/u/Docs/../etc")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_rel_names_a_child_of_the_folder_root_without_a_slash() {
|
||||
assert_eq!(join_rel("", "a"), "a");
|
||||
assert_eq!(join_rel("x/y", "a"), "x/y/a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_local_rename_never_replaces_what_is_already_there() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfs-relocate-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (a, b, c) = (dir.join("a"), dir.join("b"), dir.join("c"));
|
||||
std::fs::write(&a, b"moved").unwrap();
|
||||
std::fs::write(&b, b"kept").unwrap();
|
||||
|
||||
let err = rename_noreplace(&a, &b).unwrap_err();
|
||||
assert_eq!(err.raw_os_error(), Some(libc::EEXIST));
|
||||
assert_eq!(std::fs::read(&b).unwrap(), b"kept");
|
||||
assert!(a.exists());
|
||||
|
||||
rename_noreplace(&a, &c).unwrap();
|
||||
assert!(!a.exists());
|
||||
assert_eq!(std::fs::read(&c).unwrap(), b"moved");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
//! File version history: listing a file's revisions, restoring one, deleting
|
||||
//! one, and writing an old one out to a local path.
|
||||
//!
|
||||
//! Proton Drive keeps every revision a client ever committed, and until now the
|
||||
//! daemon only ever addressed the active one — a file that a sync pass or a
|
||||
//! `(sync-conflict)` fork overwrote could only be recovered from whatever the
|
||||
//! local `recovery/` directory happened to hold. This is the remote equivalent,
|
||||
//! and it is the reason a *restore* here is not a download-then-upload: the
|
||||
//! server re-points the file at an existing revision, so no content crosses the
|
||||
//! wire and nothing lands in the drain queue.
|
||||
//!
|
||||
//! Two things about the SDK surface leak into every method here:
|
||||
//!
|
||||
//! - **A restore is asynchronous.** The server answers 202 and swaps the active
|
||||
//! revision in the background, so the daemon drops its cached content for the
|
||||
//! file rather than trying to describe the new one. The next read repopulates
|
||||
//! from whatever is live by then.
|
||||
//! - **The active revision cannot be deleted**, and the server — not this code —
|
||||
//! is what enforces that. [`Core::delete_revision_for_uid`] refuses locally
|
||||
//! too, so the user gets a sentence instead of an API code.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use pdfs_core::control::{ActivityKind, RevisionInfo};
|
||||
use pdfs_core::{CoreError, CoreResult};
|
||||
use proton_drive_rs::Revision;
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
use tracing::info;
|
||||
|
||||
use super::Core;
|
||||
|
||||
/// A [`Revision`] as the control protocol reports it.
|
||||
fn revision_info(revision: Revision) -> RevisionInfo {
|
||||
RevisionInfo {
|
||||
is_active: revision.is_active(),
|
||||
id: revision.revision_id,
|
||||
created: revision.creation_time,
|
||||
size_on_storage: revision.size_on_storage,
|
||||
claimed_size: revision.claimed_size,
|
||||
claimed_modified: revision.claimed_modification_time,
|
||||
// An empty signer means the node key signed it anonymously, which is not
|
||||
// an address a front-end can show.
|
||||
signed_by: revision.signature_email.filter(|email| !email.is_empty()),
|
||||
has_thumbnails: revision.has_thumbnails,
|
||||
}
|
||||
}
|
||||
|
||||
impl Core {
|
||||
/// The version history of the file at mountpoint-relative `rel`.
|
||||
pub(crate) fn list_revisions(&self, rel: &Path) -> CoreResult<Vec<RevisionInfo>> {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
self.list_revisions_for_uid(&uid)
|
||||
}
|
||||
|
||||
/// [`Core::list_revisions`] for a node addressed through any daemon location.
|
||||
pub(crate) fn list_revisions_by_uid(&self, uid: &str) -> CoreResult<Vec<RevisionInfo>> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
self.list_revisions_for_uid(&uid)
|
||||
}
|
||||
|
||||
fn list_revisions_for_uid(&self, uid: &NodeUid) -> CoreResult<Vec<RevisionInfo>> {
|
||||
let revisions = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_revisions(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list revisions"))?;
|
||||
Ok(revisions.into_iter().map(revision_info).collect())
|
||||
}
|
||||
|
||||
/// Make `revision_id` the current content of the file at `rel`.
|
||||
pub(crate) fn restore_revision(&self, rel: &Path, revision_id: &str) -> CoreResult<()> {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
let name = rel
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| uid.to_string());
|
||||
self.restore_revision_for_uid(&uid, revision_id, &name)?;
|
||||
// The size and mtime a listing shows describe the revision that was
|
||||
// active a moment ago.
|
||||
self.invalidate_parent_listing(rel);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// [`Core::restore_revision`] for a node addressed through any daemon
|
||||
/// location.
|
||||
pub(crate) fn restore_revision_by_uid(&self, uid: &str, revision_id: &str) -> CoreResult<()> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
let name = uid.to_string();
|
||||
self.restore_revision_for_uid(&uid, revision_id, &name)
|
||||
}
|
||||
|
||||
fn restore_revision_for_uid(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
revision_id: &str,
|
||||
name: &str,
|
||||
) -> CoreResult<()> {
|
||||
// Restoring rewrites the file's content: the same authority a write
|
||||
// needs, not the one a read needs.
|
||||
self.require_uid_writable(uid)
|
||||
.map_err(|error| self.errno_error(error, "restore revision"))?;
|
||||
if let Err(e) = self
|
||||
.rt
|
||||
.block_on(self.client.restore_revision(uid, revision_id))
|
||||
{
|
||||
let error = CoreError::from_api(&e, "restore revision");
|
||||
self.log_activity(ActivityKind::Restore, name, &error, false);
|
||||
return Err(error);
|
||||
}
|
||||
// The active revision is changing behind our back — anything describing
|
||||
// the old one (cached blocks, an open reader, the size in a listing) is
|
||||
// stale from here on, and the server may not have applied the swap yet.
|
||||
self.cache.evict(uid);
|
||||
self.evict_reader(uid);
|
||||
self.log_activity(
|
||||
ActivityKind::Restore,
|
||||
name,
|
||||
format!("restored version {revision_id}"),
|
||||
true,
|
||||
);
|
||||
info!(%uid, revision_id, name, "restored an earlier revision");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Permanently delete `revision_id` from the history of the file at `rel`.
|
||||
pub(crate) fn delete_revision(&self, rel: &Path, revision_id: &str) -> CoreResult<()> {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
let name = rel
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| uid.to_string());
|
||||
self.delete_revision_for_uid(&uid, revision_id, &name)
|
||||
}
|
||||
|
||||
/// [`Core::delete_revision`] for a node addressed through any daemon
|
||||
/// location.
|
||||
pub(crate) fn delete_revision_by_uid(&self, uid: &str, revision_id: &str) -> CoreResult<()> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
let name = uid.to_string();
|
||||
self.delete_revision_for_uid(&uid, revision_id, &name)
|
||||
}
|
||||
|
||||
fn delete_revision_for_uid(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
revision_id: &str,
|
||||
name: &str,
|
||||
) -> CoreResult<()> {
|
||||
self.require_uid_writable(uid)
|
||||
.map_err(|error| self.errno_error(error, "delete revision"))?;
|
||||
// The server rejects deleting the active revision; saying so here is a
|
||||
// sentence the user can act on instead of an API code, and it costs one
|
||||
// listing the front-end already made to show the version list.
|
||||
let revisions = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_revisions(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list revisions"))?;
|
||||
let target = revisions
|
||||
.iter()
|
||||
.find(|r| r.revision_id == revision_id)
|
||||
.ok_or_else(|| CoreError::not_found(format!("no version {revision_id} on {name}")))?;
|
||||
if target.is_active() {
|
||||
return Err(CoreError::invalid(format!(
|
||||
"version {revision_id} is the current content of {name}; restore another version first"
|
||||
)));
|
||||
}
|
||||
self.rt
|
||||
.block_on(self.client.delete_revision(uid, revision_id))
|
||||
.map_err(|e| CoreError::from_api(&e, "delete revision"))?;
|
||||
self.log_activity(
|
||||
ActivityKind::DeleteForever,
|
||||
name,
|
||||
format!("deleted version {revision_id}"),
|
||||
true,
|
||||
);
|
||||
info!(%uid, revision_id, name, "deleted a revision");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write `revision_id` of the file at `rel` to the absolute local path
|
||||
/// `dest`, leaving the file itself untouched.
|
||||
pub(crate) fn save_revision_as(
|
||||
&self,
|
||||
rel: &Path,
|
||||
revision_id: &str,
|
||||
dest: &Path,
|
||||
) -> CoreResult<u64> {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
let name = rel
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| uid.to_string());
|
||||
self.save_revision_for_uid(&uid, revision_id, &name, dest)
|
||||
}
|
||||
|
||||
/// [`Core::save_revision_as`] for a node addressed through any daemon
|
||||
/// location.
|
||||
pub(crate) fn save_revision_as_by_uid(
|
||||
&self,
|
||||
uid: &str,
|
||||
revision_id: &str,
|
||||
dest: &Path,
|
||||
) -> CoreResult<u64> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
let name = uid.to_string();
|
||||
self.save_revision_for_uid(&uid, revision_id, &name, dest)
|
||||
}
|
||||
|
||||
fn save_revision_for_uid(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
revision_id: &str,
|
||||
name: &str,
|
||||
dest: &Path,
|
||||
) -> CoreResult<u64> {
|
||||
if !dest.is_absolute() {
|
||||
return Err(CoreError::invalid(format!(
|
||||
"destination must be an absolute path: {}",
|
||||
dest.display()
|
||||
)));
|
||||
}
|
||||
// Never silently replace something the user already has: an old version
|
||||
// written over the wrong file is exactly the loss this feature exists to
|
||||
// undo.
|
||||
if dest.exists() {
|
||||
return Err(CoreError::invalid(format!(
|
||||
"{} already exists",
|
||||
dest.display()
|
||||
)));
|
||||
}
|
||||
let file = std::fs::File::create(dest)
|
||||
.map_err(|e| CoreError::internal(format!("create {}: {e}", dest.display())))?;
|
||||
let mut out = std::io::BufWriter::new(file);
|
||||
let written = match self
|
||||
.rt
|
||||
.block_on(self.drive.download_revision_to(uid, revision_id, &mut out))
|
||||
.map_err(|e| CoreError::from_api(&e, "download revision"))
|
||||
.and_then(|()| {
|
||||
use std::io::Write as _;
|
||||
out.flush()
|
||||
.map_err(|e| CoreError::internal(format!("write {}: {e}", dest.display())))?;
|
||||
let written = std::fs::metadata(dest)
|
||||
.map(|m| m.len())
|
||||
.map_err(|e| CoreError::internal(format!("stat {}: {e}", dest.display())))?;
|
||||
Ok(written)
|
||||
}) {
|
||||
Ok(written) => written,
|
||||
Err(error) => {
|
||||
// A half-written file looks like a successful export to every
|
||||
// tool that opens it afterwards.
|
||||
let _ = std::fs::remove_file(dest);
|
||||
self.log_activity(ActivityKind::Download, name, &error, false);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
self.log_activity(
|
||||
ActivityKind::Download,
|
||||
name,
|
||||
format!("saved version {revision_id} to {}", dest.display()),
|
||||
true,
|
||||
);
|
||||
info!(%uid, revision_id, dest = %dest.display(), "wrote an earlier revision to disk");
|
||||
Ok(written)
|
||||
}
|
||||
}
|
||||
+387
-60
@@ -12,17 +12,26 @@ use pdfs_core::control::{
|
||||
BookmarkInfo, DirEntry, InvitationInfo, PublicLinkInfo, ShareEntry, ShareEntryKind, SharedItem,
|
||||
};
|
||||
use pdfs_core::{CoreError, CoreResult};
|
||||
use tracing::debug;
|
||||
|
||||
use crate::QUOTA_TTL;
|
||||
use proton_drive_rs::proton_sdk::crypto::VerificationStatus;
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
use proton_drive_rs::{MemberRole, NodeKind};
|
||||
|
||||
use proton_drive_rs::Node;
|
||||
|
||||
use super::{Core, ROOT_INO, node_size, parse_uid, public_link_info, role_from_str, role_to_str};
|
||||
use super::{Core, node_size, parse_uid, public_link_info, role_from_str, role_to_str};
|
||||
|
||||
/// A node from a *shared* listing as a [`DirEntry`]. Shared nodes live outside
|
||||
/// the mount, so the entry is uid-addressed: `path` is empty, and the local-state
|
||||
/// flags (`pinned`, `cached`) are false — they describe my own tree, not this one.
|
||||
fn shared_entry(n: Node) -> DirEntry {
|
||||
/// A node from a *shared* listing as a [`DirEntry`].
|
||||
///
|
||||
/// Still uid-addressed — that is the only handle a foreign-volume node is
|
||||
/// guaranteed to have — but `path` is filled in when the node is resident under
|
||||
/// the synthetic `Shared with me/` directory, which is what lets a front-end
|
||||
/// offer path-keyed actions on it. The local-state flags (`pinned`, `cached`)
|
||||
/// stay false: they describe content this account owns a local copy policy for,
|
||||
/// which a share on someone else's volume is not.
|
||||
fn shared_entry_of(core: &Core, n: Node) -> DirEntry {
|
||||
let is_dir = n.is_folder();
|
||||
let size = match &n.kind {
|
||||
NodeKind::File {
|
||||
@@ -32,6 +41,25 @@ fn shared_entry(n: Node) -> DirEntry {
|
||||
} => claimed_size.unwrap_or(*total_size_on_storage).max(0) as u64,
|
||||
NodeKind::Folder => 0,
|
||||
};
|
||||
let role = n
|
||||
.membership
|
||||
.as_ref()
|
||||
.and_then(|m| m.role_exact())
|
||||
.map(role_to_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let invitation = n.membership.as_ref();
|
||||
let shared_by = invitation
|
||||
.and_then(|m| m.inviter_email.clone())
|
||||
.unwrap_or_default();
|
||||
let shared_at = invitation.and_then(|m| m.invite_time).unwrap_or(0);
|
||||
// Only a signature that is present and wrong is a warning. An invitation
|
||||
// that could not be checked — unsigned, or no keys for the claimed inviter —
|
||||
// is not evidence of forgery, and flagging every such share would teach the
|
||||
// user to ignore the flag.
|
||||
let shared_by_unverified = invitation
|
||||
.and_then(|m| m.inviter_verification)
|
||||
.is_some_and(|status| status == VerificationStatus::Failed);
|
||||
DirEntry {
|
||||
name: n.name,
|
||||
is_dir,
|
||||
@@ -39,8 +67,15 @@ fn shared_entry(n: Node) -> DirEntry {
|
||||
modified: n.modification_time,
|
||||
pinned: false,
|
||||
cached: false,
|
||||
path: core.rel_path_for_uid(&n.uid).unwrap_or_default(),
|
||||
uid: n.uid.to_string(),
|
||||
path: String::new(),
|
||||
role,
|
||||
shared_by,
|
||||
shared_at,
|
||||
shared_by_unverified,
|
||||
trashed_at: 0,
|
||||
trashed_from: None,
|
||||
issue: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,11 +92,33 @@ impl Core {
|
||||
message: Option<&str>,
|
||||
) -> CoreResult<(usize, usize)> {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
self.share_node_for_uid(&uid, emails, role, message)
|
||||
}
|
||||
|
||||
/// [`Core::share_node`] for a node addressed through any daemon location.
|
||||
pub(crate) fn share_node_by_uid(
|
||||
&self,
|
||||
uid: &str,
|
||||
emails: &[String],
|
||||
role: &str,
|
||||
message: Option<&str>,
|
||||
) -> CoreResult<(usize, usize)> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
self.share_node_for_uid(&uid, emails, role, message)
|
||||
}
|
||||
|
||||
fn share_node_for_uid(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
emails: &[String],
|
||||
role: &str,
|
||||
message: Option<&str>,
|
||||
) -> CoreResult<(usize, usize)> {
|
||||
let role = role_from_str(role)?;
|
||||
let invitees: Vec<(String, MemberRole)> =
|
||||
emails.iter().map(|e| (e.clone(), role)).collect();
|
||||
self.rt
|
||||
.block_on(self.client.invite_users(&uid, &invitees, message))
|
||||
.block_on(self.client.invite_users(uid, &invitees, message))
|
||||
.map_err(|e| CoreError::from_api(&e, "share"))
|
||||
}
|
||||
|
||||
@@ -71,11 +128,26 @@ impl Core {
|
||||
rel: &Path,
|
||||
) -> CoreResult<(Vec<ShareEntry>, Option<PublicLinkInfo>)> {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
self.list_share_for_uid(&uid)
|
||||
}
|
||||
|
||||
/// [`Core::list_share`] for a node addressed through any daemon location.
|
||||
pub(crate) fn list_share_by_uid(
|
||||
&self,
|
||||
uid: &str,
|
||||
) -> CoreResult<(Vec<ShareEntry>, Option<PublicLinkInfo>)> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
self.list_share_for_uid(&uid)
|
||||
}
|
||||
|
||||
fn list_share_for_uid(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
) -> CoreResult<(Vec<ShareEntry>, Option<PublicLinkInfo>)> {
|
||||
let mut entries = Vec::new();
|
||||
for m in self
|
||||
.rt
|
||||
.block_on(self.client.list_share_members(&uid))
|
||||
.block_on(self.client.list_share_members(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list members"))?
|
||||
{
|
||||
entries.push(ShareEntry {
|
||||
@@ -87,7 +159,7 @@ impl Core {
|
||||
}
|
||||
for inv in self
|
||||
.rt
|
||||
.block_on(self.client.list_share_invitations(&uid))
|
||||
.block_on(self.client.list_share_invitations(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list invitations"))?
|
||||
{
|
||||
entries.push(ShareEntry {
|
||||
@@ -99,7 +171,7 @@ impl Core {
|
||||
}
|
||||
for ext in self
|
||||
.rt
|
||||
.block_on(self.client.list_external_invitations(&uid))
|
||||
.block_on(self.client.list_external_invitations(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list external invitations"))?
|
||||
{
|
||||
entries.push(ShareEntry {
|
||||
@@ -112,7 +184,7 @@ impl Core {
|
||||
|
||||
let link = self
|
||||
.rt
|
||||
.block_on(self.client.get_public_link(&uid))
|
||||
.block_on(self.client.get_public_link(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "get public link"))?
|
||||
.map(public_link_info);
|
||||
|
||||
@@ -129,12 +201,35 @@ impl Core {
|
||||
role: &str,
|
||||
) -> CoreResult<()> {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
self.update_share_role_for_uid(&uid, id, kind, role)
|
||||
}
|
||||
|
||||
/// [`Core::update_share_role`] for a node addressed through any daemon
|
||||
/// location.
|
||||
pub(crate) fn update_share_role_by_uid(
|
||||
&self,
|
||||
uid: &str,
|
||||
id: &str,
|
||||
kind: ShareEntryKind,
|
||||
role: &str,
|
||||
) -> CoreResult<()> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
self.update_share_role_for_uid(&uid, id, kind, role)
|
||||
}
|
||||
|
||||
fn update_share_role_for_uid(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
id: &str,
|
||||
kind: ShareEntryKind,
|
||||
role: &str,
|
||||
) -> CoreResult<()> {
|
||||
let role = role_from_str(role)?;
|
||||
match kind {
|
||||
ShareEntryKind::Member => {
|
||||
let member = self
|
||||
.rt
|
||||
.block_on(self.client.list_share_members(&uid))
|
||||
.block_on(self.client.list_share_members(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list members"))?
|
||||
.into_iter()
|
||||
.find(|m| m.membership_id.to_string() == id)
|
||||
@@ -146,7 +241,7 @@ impl Core {
|
||||
ShareEntryKind::ProtonInvite => {
|
||||
let inv = self
|
||||
.rt
|
||||
.block_on(self.client.list_share_invitations(&uid))
|
||||
.block_on(self.client.list_share_invitations(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list invitations"))?
|
||||
.into_iter()
|
||||
.find(|i| i.invitation_id == id)
|
||||
@@ -170,11 +265,32 @@ impl Core {
|
||||
kind: ShareEntryKind,
|
||||
) -> CoreResult<()> {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
self.remove_share_entry_for_uid(&uid, id, kind)
|
||||
}
|
||||
|
||||
/// [`Core::remove_share_entry`] for a node addressed through any daemon
|
||||
/// location.
|
||||
pub(crate) fn remove_share_entry_by_uid(
|
||||
&self,
|
||||
uid: &str,
|
||||
id: &str,
|
||||
kind: ShareEntryKind,
|
||||
) -> CoreResult<()> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
self.remove_share_entry_for_uid(&uid, id, kind)
|
||||
}
|
||||
|
||||
fn remove_share_entry_for_uid(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
id: &str,
|
||||
kind: ShareEntryKind,
|
||||
) -> CoreResult<()> {
|
||||
match kind {
|
||||
ShareEntryKind::Member => {
|
||||
let member = self
|
||||
.rt
|
||||
.block_on(self.client.list_share_members(&uid))
|
||||
.block_on(self.client.list_share_members(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list members"))?
|
||||
.into_iter()
|
||||
.find(|m| m.membership_id.to_string() == id)
|
||||
@@ -186,7 +302,7 @@ impl Core {
|
||||
ShareEntryKind::ProtonInvite => {
|
||||
let inv = self
|
||||
.rt
|
||||
.block_on(self.client.list_share_invitations(&uid))
|
||||
.block_on(self.client.list_share_invitations(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list invitations"))?
|
||||
.into_iter()
|
||||
.find(|i| i.invitation_id == id)
|
||||
@@ -198,7 +314,7 @@ impl Core {
|
||||
ShareEntryKind::ExternalInvite => {
|
||||
let ext = self
|
||||
.rt
|
||||
.block_on(self.client.list_external_invitations(&uid))
|
||||
.block_on(self.client.list_external_invitations(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list external invitations"))?
|
||||
.into_iter()
|
||||
.find(|i| i.invitation_id == id)
|
||||
@@ -219,13 +335,33 @@ impl Core {
|
||||
expires: Option<i64>,
|
||||
) -> CoreResult<PublicLinkInfo> {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
self.create_public_link_for_uid(&uid, role, password, expires)
|
||||
}
|
||||
|
||||
/// [`Core::create_public_link`] for a node addressed through any daemon
|
||||
/// location.
|
||||
pub(crate) fn create_public_link_by_uid(
|
||||
&self,
|
||||
uid: &str,
|
||||
role: &str,
|
||||
password: Option<&str>,
|
||||
expires: Option<i64>,
|
||||
) -> CoreResult<PublicLinkInfo> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
self.create_public_link_for_uid(&uid, role, password, expires)
|
||||
}
|
||||
|
||||
fn create_public_link_for_uid(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
role: &str,
|
||||
password: Option<&str>,
|
||||
expires: Option<i64>,
|
||||
) -> CoreResult<PublicLinkInfo> {
|
||||
let role = role_from_str(role)?;
|
||||
let link = self
|
||||
.rt
|
||||
.block_on(
|
||||
self.client
|
||||
.create_public_link(&uid, role, password, expires),
|
||||
)
|
||||
.block_on(self.client.create_public_link(uid, role, password, expires))
|
||||
.map_err(|e| CoreError::from_api(&e, "create public link"))?;
|
||||
Ok(public_link_info(link))
|
||||
}
|
||||
@@ -233,9 +369,20 @@ impl Core {
|
||||
/// Remove the public link `id` from the node at `rel`.
|
||||
pub(crate) fn remove_public_link(&self, rel: &Path, id: &str) -> CoreResult<()> {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
self.remove_public_link_for_uid(&uid, id)
|
||||
}
|
||||
|
||||
/// [`Core::remove_public_link`] for a node addressed through any daemon
|
||||
/// location.
|
||||
pub(crate) fn remove_public_link_by_uid(&self, uid: &str, id: &str) -> CoreResult<()> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
self.remove_public_link_for_uid(&uid, id)
|
||||
}
|
||||
|
||||
fn remove_public_link_for_uid(&self, uid: &NodeUid, id: &str) -> CoreResult<()> {
|
||||
let link = self
|
||||
.rt
|
||||
.block_on(self.client.get_public_link(&uid))
|
||||
.block_on(self.client.get_public_link(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "get public link"))?
|
||||
.filter(|l| l.public_link_id == id)
|
||||
.ok_or_else(|| CoreError::not_found("public link not found"))?;
|
||||
@@ -244,6 +391,66 @@ impl Core {
|
||||
.map_err(|e| CoreError::from_api(&e, "remove public link"))
|
||||
}
|
||||
|
||||
/// Stop sharing the node at `rel`: remove its public link, then every
|
||||
/// external invitation, Proton invitation and member. The SDK has no call
|
||||
/// that deletes a share outright, so this composes the per-entry ones.
|
||||
pub(crate) fn stop_sharing(&self, rel: &Path) -> CoreResult<StopSharingOutcome> {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
self.stop_sharing_for_uid(&uid)
|
||||
}
|
||||
|
||||
/// [`Core::stop_sharing`] for a node addressed through any daemon location.
|
||||
pub(crate) fn stop_sharing_by_uid(&self, uid: &str) -> CoreResult<StopSharingOutcome> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
self.stop_sharing_for_uid(&uid)
|
||||
}
|
||||
|
||||
/// Each step runs even when an earlier one failed, so a single stuck
|
||||
/// member does not leave the public link up. The link goes first: it is the
|
||||
/// widest exposure. Only a failure to list what is there fails the whole
|
||||
/// request; a failed removal is counted in the outcome.
|
||||
fn stop_sharing_for_uid(&self, uid: &NodeUid) -> CoreResult<StopSharingOutcome> {
|
||||
let mut outcome = StopSharingOutcome::default();
|
||||
let link = self
|
||||
.rt
|
||||
.block_on(self.client.get_public_link(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "get public link"))?;
|
||||
if let Some(link) = link {
|
||||
let result = self.rt.block_on(self.client.remove_public_link(&link));
|
||||
outcome.record("the public link", result);
|
||||
}
|
||||
let external = self
|
||||
.rt
|
||||
.block_on(self.client.list_external_invitations(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list external invitations"))?;
|
||||
for ext in external {
|
||||
let result = self
|
||||
.rt
|
||||
.block_on(self.client.delete_external_invitation(&ext));
|
||||
outcome.record(&ext.invitee_email, result);
|
||||
}
|
||||
let invitations = self
|
||||
.rt
|
||||
.block_on(self.client.list_share_invitations(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list invitations"))?;
|
||||
for inv in invitations {
|
||||
let result = self.rt.block_on(self.client.delete_invitation(&inv));
|
||||
outcome.record(&inv.invitee_email, result);
|
||||
}
|
||||
let members = self
|
||||
.rt
|
||||
.block_on(self.client.list_share_members(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list members"))?;
|
||||
for member in members {
|
||||
let result = self.rt.block_on(self.client.remove_member(&member));
|
||||
outcome.record(&member.email, result);
|
||||
}
|
||||
if !outcome.failed.is_empty() {
|
||||
debug!(%uid, failed = ?outcome.failed, "stop sharing: some entries remain");
|
||||
}
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
// ---- shared with me ---------------------------------------------------
|
||||
|
||||
/// List nodes shared with me that I have accepted.
|
||||
@@ -257,9 +464,12 @@ impl Core {
|
||||
}
|
||||
let nodes = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_nodes(&uids))
|
||||
.block_on(self.drive.enumerate_nodes(&uids))
|
||||
.map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?;
|
||||
Ok(nodes.into_iter().map(shared_entry).collect())
|
||||
Ok(nodes
|
||||
.into_iter()
|
||||
.map(|node| shared_entry_of(self, node))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// List the children of a folder shared with me, addressed by uid.
|
||||
@@ -272,16 +482,19 @@ impl Core {
|
||||
parse_uid(uid).ok_or_else(|| CoreError::invalid(format!("invalid uid: {uid}")))?;
|
||||
let child_uids = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_folder_children_node_uids(&uid))
|
||||
.block_on(self.drive.enumerate_folder_children_node_uids(&uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "enumerate shared children"))?;
|
||||
if child_uids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let nodes = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_nodes(&child_uids))
|
||||
.block_on(self.drive.enumerate_nodes(&child_uids))
|
||||
.map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?;
|
||||
Ok(nodes.into_iter().map(shared_entry).collect())
|
||||
Ok(nodes
|
||||
.into_iter()
|
||||
.map(|node| shared_entry_of(self, node))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Download a file shared with me into the content cache, returning its
|
||||
@@ -297,7 +510,7 @@ impl Core {
|
||||
parse_uid(uid).ok_or_else(|| CoreError::invalid(format!("invalid uid: {uid}")))?;
|
||||
let node = self
|
||||
.rt
|
||||
.block_on(self.client.get_node(&uid))
|
||||
.block_on(self.drive.get_node(&uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "get shared node"))?
|
||||
.ok_or_else(|| CoreError::not_found("shared node not found"))?;
|
||||
if !node.is_file() {
|
||||
@@ -398,13 +611,48 @@ impl Core {
|
||||
/// Proton products (not Drive-only). A remote round-trip; nothing here is
|
||||
/// cached.
|
||||
pub(crate) fn account_quota(&self) -> CoreResult<(i64, i64)> {
|
||||
let q = self
|
||||
.rt
|
||||
.block_on(self.client.quota())
|
||||
.map_err(|e| CoreError::from_api(&e, "account quota"))?;
|
||||
let q = self.block_on_bounded(self.drive.quota()).map_err(|e| {
|
||||
self.lost_link(&e, "account quota");
|
||||
CoreError::from_api(&e, "account quota")
|
||||
})?;
|
||||
*self.quota.lock() = Some((std::time::Instant::now(), q.max_space, q.used_space));
|
||||
Ok((q.max_space, q.used_space))
|
||||
}
|
||||
|
||||
/// The account quota as `statfs(2)` needs it: cached for [`QUOTA_TTL`],
|
||||
/// refetched after that, and `None` when there is no usable answer at all —
|
||||
/// offline with nothing cached.
|
||||
///
|
||||
/// Never blocks on the network for a *fresh* value when a recent one exists,
|
||||
/// because the callers are `df` and every "is there room?" preflight, and a
|
||||
/// stale byte count is worth more to them than a stalled syscall. Offline,
|
||||
/// the last known figures keep being served rather than degrading to zero.
|
||||
pub(crate) fn account_quota_cached(&self) -> Option<(i64, i64)> {
|
||||
if let Some((at, max_space, used_space)) = *self.quota.lock()
|
||||
&& at.elapsed() < QUOTA_TTL
|
||||
{
|
||||
return Some((max_space, used_space));
|
||||
}
|
||||
let last = || self.quota.lock().map(|(_, max, used)| (max, used));
|
||||
if !self.is_online()
|
||||
|| self
|
||||
.quota_refreshing
|
||||
.swap(true, std::sync::atomic::Ordering::AcqRel)
|
||||
{
|
||||
return last();
|
||||
}
|
||||
let refreshed = self.account_quota();
|
||||
self.quota_refreshing
|
||||
.store(false, std::sync::atomic::Ordering::Release);
|
||||
match refreshed {
|
||||
Ok(q) => Some(q),
|
||||
Err(error) => {
|
||||
debug!(%error, "account quota refresh failed; serving the last known figures");
|
||||
last()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- shared by me -----------------------------------------------------
|
||||
|
||||
/// List the nodes I have shared with others, each with a summary of its share
|
||||
@@ -412,6 +660,12 @@ impl Core {
|
||||
/// the shared uids; the per-node detail is then gathered best-effort — a single
|
||||
/// node racing with an unshare drops from the list rather than failing the whole
|
||||
/// request.
|
||||
///
|
||||
/// A share with no member, no invitation and no link is left out: it is
|
||||
/// shared with no one. Stop Sharing leaves exactly that behind, since the SDK
|
||||
/// cannot delete a share, and Drive keeps listing it until its backend gets
|
||||
/// round to removing it, minutes later. A share whose details could not all
|
||||
/// be read stays listed, so a failed lookup never hides a live share.
|
||||
pub(crate) fn list_shared_by_me(&self) -> CoreResult<Vec<SharedItem>> {
|
||||
let uids = self
|
||||
.rt
|
||||
@@ -422,7 +676,7 @@ impl Core {
|
||||
}
|
||||
let nodes = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_nodes(&uids))
|
||||
.block_on(self.drive.enumerate_nodes(&uids))
|
||||
.map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?;
|
||||
let mut items = Vec::with_capacity(nodes.len());
|
||||
for n in nodes {
|
||||
@@ -430,27 +684,30 @@ impl Core {
|
||||
let members = self
|
||||
.rt
|
||||
.block_on(self.client.list_share_members(&uid))
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0);
|
||||
.map(|m| m.len());
|
||||
let proton_invites = self
|
||||
.rt
|
||||
.block_on(self.client.list_share_invitations(&uid))
|
||||
.map(|i| i.len())
|
||||
.unwrap_or(0);
|
||||
.map(|i| i.len());
|
||||
let external_invites = self
|
||||
.rt
|
||||
.block_on(self.client.list_external_invitations(&uid))
|
||||
.map(|i| i.len())
|
||||
.unwrap_or(0);
|
||||
let link = self
|
||||
.rt
|
||||
.block_on(self.client.get_public_link(&uid))
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(public_link_info);
|
||||
.map(|i| i.len());
|
||||
let link = self.rt.block_on(self.client.get_public_link(&uid));
|
||||
if let (Ok(0), Ok(0), Ok(0), Ok(None)) =
|
||||
(&members, &proton_invites, &external_invites, &link)
|
||||
{
|
||||
debug!(%uid, "shared by me: skipping an emptied share");
|
||||
continue;
|
||||
}
|
||||
let members = members.unwrap_or(0);
|
||||
let proton_invites = proton_invites.unwrap_or(0);
|
||||
let external_invites = external_invites.unwrap_or(0);
|
||||
let link = link.ok().flatten().map(public_link_info);
|
||||
items.push(SharedItem {
|
||||
uid: uid.to_string(),
|
||||
is_dir: n.is_folder(),
|
||||
modified: n.modification_time,
|
||||
name: n.name,
|
||||
path: self.rel_path_for_uid(&uid).unwrap_or_default(),
|
||||
member_count: members,
|
||||
@@ -466,33 +723,103 @@ impl Core {
|
||||
/// been seen through the mount (e.g. shared but not browsed to this session) —
|
||||
/// the caller then leaves the path empty.
|
||||
pub(crate) fn rel_path_for_uid(&self, uid: &NodeUid) -> Option<String> {
|
||||
// Searched across every mount: a node browsed through a sync folder is
|
||||
// interned in that fork's inode space, so the primary state alone had no
|
||||
// entry for it and the caller silently showed an empty path
|
||||
// (`docs/BUGS.md` B74). The path is relative to whichever mount holds
|
||||
// it, which is the one the user reached it through.
|
||||
let mut path = None;
|
||||
self.for_each_state(|st| {
|
||||
if path.is_some() {
|
||||
return;
|
||||
}
|
||||
path = walk_to_root(st, uid);
|
||||
});
|
||||
path
|
||||
// Relative to whichever root the node sits under: a node browsed
|
||||
// through a sync folder is under that fork's root, not My Files
|
||||
// (`docs/BUGS.md` B74).
|
||||
walk_to_root(&self.state(), uid)
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk one inode space from `uid` up to its root, building the path. `None` if
|
||||
/// that mount has never interned the node, or if the chain breaks partway —
|
||||
/// What [`Core::stop_sharing`] managed to remove.
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct StopSharingOutcome {
|
||||
/// Entries removed (the link, invitations and members alike).
|
||||
pub(crate) removed: usize,
|
||||
/// What could not be removed, by email (or "the public link"), with why.
|
||||
pub(crate) failed: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl StopSharingOutcome {
|
||||
fn record<E: std::fmt::Display>(&mut self, what: &str, result: Result<(), E>) {
|
||||
match result {
|
||||
Ok(()) => self.removed += 1,
|
||||
Err(e) => self.failed.push((what.to_string(), e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// The reply for the control socket: a sentence for a full success, an
|
||||
/// error naming what is left otherwise.
|
||||
pub(crate) fn into_result(self) -> Result<String, String> {
|
||||
if self.failed.is_empty() {
|
||||
return Ok(match self.removed {
|
||||
0 => "it was not shared".to_string(),
|
||||
1 => "stopped sharing: removed 1 entry".to_string(),
|
||||
n => format!("stopped sharing: removed {n} entries"),
|
||||
});
|
||||
}
|
||||
let left = self
|
||||
.failed
|
||||
.iter()
|
||||
.map(|(what, why)| format!("{what} ({why})"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
Err(format!(
|
||||
"removed {} of {}; still shared: {left}",
|
||||
self.removed,
|
||||
self.removed + self.failed.len()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk the tree from `uid` up to the root it sits under, building the path.
|
||||
/// `None` if the node has never been interned, or if the chain breaks partway —
|
||||
/// a broken chain is a missing path, never a partial one.
|
||||
fn walk_to_root(st: &super::State, uid: &NodeUid) -> Option<String> {
|
||||
let mut ino = *st.by_uid.get(uid)?;
|
||||
let mut parts = Vec::new();
|
||||
while ino != ROOT_INO {
|
||||
while !st.roots.contains(&ino) {
|
||||
let entry = st.entries.get(&ino)?;
|
||||
// An orphan is its own parent without being a root.
|
||||
if entry.parent == ino {
|
||||
return None;
|
||||
}
|
||||
parts.push(entry.node.name.clone());
|
||||
ino = entry.parent;
|
||||
}
|
||||
parts.reverse();
|
||||
Some(parts.join("/"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn stopping_a_share_that_is_gone_says_so() {
|
||||
let outcome = StopSharingOutcome::default();
|
||||
assert_eq!(outcome.into_result().unwrap(), "it was not shared");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_removal_names_what_is_still_shared() {
|
||||
let mut outcome = StopSharingOutcome::default();
|
||||
outcome.record::<String>("the public link", Ok(()));
|
||||
outcome.record("bob@example.com", Err("forbidden"));
|
||||
outcome.record::<String>("carol@example.com", Ok(()));
|
||||
assert_eq!(
|
||||
outcome.into_result().unwrap_err(),
|
||||
"removed 2 of 3; still shared: bob@example.com (forbidden)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_removal_counts_the_entries() {
|
||||
let mut outcome = StopSharingOutcome::default();
|
||||
outcome.record::<String>("the public link", Ok(()));
|
||||
outcome.record::<String>("bob@example.com", Ok(()));
|
||||
assert_eq!(
|
||||
outcome.into_result().unwrap(),
|
||||
"stopped sharing: removed 2 entries"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//! One shutdown signal shared by every long-lived worker in the daemon.
|
||||
//!
|
||||
//! The mount used to end by unmounting and returning, leaving the drain
|
||||
//! workers, the sync engine, the conflict sweep, the online probe and the local
|
||||
//! indexer running on `Core` clones. In a process that exits immediately that is
|
||||
//! invisible; in an in-process remount — which is how the daemon recovers from a
|
||||
//! mount going away underneath it — it leaks a whole generation of threads that
|
||||
//! keep writing to the database and calling the API on behalf of a mount that no
|
||||
//! longer exists (`docs/BUGS.md` B44).
|
||||
//!
|
||||
//! The primitive is deliberately small: a flag plus a condvar. Every loop that
|
||||
//! would otherwise call `thread::sleep` calls [`Shutdown::sleep`] instead, which
|
||||
//! returns early — and reports `false` — the moment [`Shutdown::stop`] is
|
||||
//! called, so teardown does not have to wait out a five-minute sweep interval to
|
||||
//! join a thread.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use parking_lot::{Condvar, Mutex};
|
||||
|
||||
/// The daemon's stop signal. Set once, never cleared: a `Core` that has been
|
||||
/// told to stop belongs to a mount that is going away.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Shutdown {
|
||||
stopping: Mutex<bool>,
|
||||
changed: Condvar,
|
||||
}
|
||||
|
||||
impl Shutdown {
|
||||
/// Whether teardown has begun. Loops check this where they would otherwise
|
||||
/// start another unit of work.
|
||||
pub(crate) fn is_stopping(&self) -> bool {
|
||||
*self.stopping.lock()
|
||||
}
|
||||
|
||||
/// Begin teardown and wake everything waiting in [`sleep`](Self::sleep).
|
||||
/// Idempotent, because more than one path can reach teardown (a signal, the
|
||||
/// kernel mount ending, a failed startup).
|
||||
pub(crate) fn stop(&self) {
|
||||
let mut stopping = self.stopping.lock();
|
||||
*stopping = true;
|
||||
self.changed.notify_all();
|
||||
}
|
||||
|
||||
/// Wait up to `duration`, or until teardown starts.
|
||||
///
|
||||
/// Returns `false` if the daemon is stopping — including when it was already
|
||||
/// stopping on entry, so `while shutdown.sleep(interval)` is a complete and
|
||||
/// correct loop condition and no separate check is needed at the top.
|
||||
pub(crate) fn sleep(&self, duration: Duration) -> bool {
|
||||
let mut stopping = self.stopping.lock();
|
||||
if !*stopping {
|
||||
self.changed.wait_for(&mut stopping, duration);
|
||||
}
|
||||
!*stopping
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
#[test]
|
||||
fn sleeping_returns_early_once_stopped() {
|
||||
let shutdown = Arc::new(Shutdown::default());
|
||||
assert!(!shutdown.is_stopping());
|
||||
|
||||
let waker = shutdown.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
waker.stop();
|
||||
});
|
||||
|
||||
let started = Instant::now();
|
||||
// An hour's worth of interval, cut short by the stop.
|
||||
assert!(!shutdown.sleep(Duration::from_secs(3600)));
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_secs(5),
|
||||
"stop must interrupt the wait, not wait it out"
|
||||
);
|
||||
assert!(shutdown.is_stopping());
|
||||
// Already stopping: the next wait does not sleep at all.
|
||||
let started = Instant::now();
|
||||
assert!(!shutdown.sleep(Duration::from_secs(3600)));
|
||||
assert!(started.elapsed() < Duration::from_secs(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sleeping_runs_to_the_timeout_while_running() {
|
||||
let shutdown = Shutdown::default();
|
||||
assert!(shutdown.sleep(Duration::from_millis(10)));
|
||||
assert!(!shutdown.is_stopping());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,9 @@
|
||||
//! The simulation tests of `docs/TESTING.md`: a fake Drive with
|
||||
//! the faults the real one has, and the tools to drive daemons against it.
|
||||
|
||||
pub(crate) mod daemon;
|
||||
pub(crate) mod fake_drive;
|
||||
pub(crate) mod model;
|
||||
pub(crate) mod rng;
|
||||
pub(crate) mod run;
|
||||
pub(crate) mod watchdog;
|
||||
@@ -0,0 +1,445 @@
|
||||
//! The reference file system of the simulation (`docs/TESTING.md`,
|
||||
//! "Simulation runs", property 1): what each syscall a simulated client makes should
|
||||
//! answer, and the tree it should leave, on a POSIX file system.
|
||||
//!
|
||||
//! A [`Model`] covers one client's own folder; paths are relative to it. The
|
||||
//! same type is used for the trees read back from a mount and from Drive.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::rng::Rng;
|
||||
|
||||
/// A tree by path: `None` is a folder, `Some` a file's content.
|
||||
pub(crate) type Tree = BTreeMap<String, Option<Vec<u8>>>;
|
||||
|
||||
/// One file system call, or the few a program makes in a row.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum FsOp {
|
||||
/// `open(O_CREAT | O_TRUNC)`, write `data`, close.
|
||||
Create {
|
||||
path: String,
|
||||
data: Vec<u8>,
|
||||
},
|
||||
/// Open an existing file, write `data` at `offset` (past the end leaves
|
||||
/// a hole), close.
|
||||
Write {
|
||||
path: String,
|
||||
offset: u64,
|
||||
data: Vec<u8>,
|
||||
},
|
||||
/// Open an existing file and `ftruncate` it to `len`.
|
||||
Truncate {
|
||||
path: String,
|
||||
len: u64,
|
||||
},
|
||||
Mkdir {
|
||||
path: String,
|
||||
},
|
||||
Rename {
|
||||
from: String,
|
||||
to: String,
|
||||
},
|
||||
Unlink {
|
||||
path: String,
|
||||
},
|
||||
/// Open a file, unlink it, append `data`, read it all back, close.
|
||||
UnlinkOpen {
|
||||
path: String,
|
||||
data: Vec<u8>,
|
||||
},
|
||||
Rmdir {
|
||||
path: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl FsOp {
|
||||
/// The paths the op names, for the log.
|
||||
pub(crate) fn describe(&self) -> String {
|
||||
match self {
|
||||
FsOp::Create { path, data } => format!("create {path} ({} bytes)", data.len()),
|
||||
FsOp::Write { path, offset, data } => {
|
||||
format!("write {path} at {offset} ({} bytes)", data.len())
|
||||
}
|
||||
FsOp::Truncate { path, len } => format!("truncate {path} to {len}"),
|
||||
FsOp::Mkdir { path } => format!("mkdir {path}"),
|
||||
FsOp::Rename { from, to } => format!("rename {from} -> {to}"),
|
||||
FsOp::Unlink { path } => format!("unlink {path}"),
|
||||
FsOp::UnlinkOpen { path, data } => {
|
||||
format!("unlink {path} while open, then append {} bytes", data.len())
|
||||
}
|
||||
FsOp::Rmdir { path } => format!("rmdir {path}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The tree one client should have, and what its syscalls should answer.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct Model {
|
||||
tree: Tree,
|
||||
}
|
||||
|
||||
/// Names are drawn from small pools, so ops collide: a create over an existing
|
||||
/// file, a rename over another, a mkdir of a name that is taken.
|
||||
const FILE_NAMES: [&str; 6] = ["a.txt", "b.txt", "c.bin", "d", "e.md", "f.txt"];
|
||||
const DIR_NAMES: [&str; 3] = ["x", "y", "z"];
|
||||
|
||||
impl Model {
|
||||
pub(crate) fn tree(&self) -> &Tree {
|
||||
&self.tree
|
||||
}
|
||||
|
||||
pub(crate) fn content(&self, path: &str) -> Option<&Vec<u8>> {
|
||||
self.tree.get(path)?.as_ref()
|
||||
}
|
||||
|
||||
/// What `op` should answer on a POSIX file system, applying it on success.
|
||||
pub(crate) fn apply(&mut self, op: &FsOp) -> Result<(), i32> {
|
||||
match op {
|
||||
FsOp::Create { path, data } => {
|
||||
self.check_parent(path)?;
|
||||
match self.tree.get(path) {
|
||||
Some(None) => Err(libc::EISDIR),
|
||||
_ => {
|
||||
self.tree.insert(path.clone(), Some(data.clone()));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
FsOp::Write { path, offset, data } => {
|
||||
let content = self.file_mut(path)?;
|
||||
// Writing nothing changes nothing, even past the end.
|
||||
if data.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let end = *offset as usize + data.len();
|
||||
if content.len() < end {
|
||||
content.resize(end, 0);
|
||||
}
|
||||
content[*offset as usize..end].copy_from_slice(data);
|
||||
Ok(())
|
||||
}
|
||||
FsOp::Truncate { path, len } => {
|
||||
self.file_mut(path)?.resize(*len as usize, 0);
|
||||
Ok(())
|
||||
}
|
||||
FsOp::Mkdir { path } => {
|
||||
self.check_parent(path)?;
|
||||
if self.tree.contains_key(path) {
|
||||
return Err(libc::EEXIST);
|
||||
}
|
||||
self.tree.insert(path.clone(), None);
|
||||
Ok(())
|
||||
}
|
||||
FsOp::Rename { from, to } => self.rename(from, to),
|
||||
FsOp::Unlink { path } | FsOp::UnlinkOpen { path, .. } => {
|
||||
self.check_parent(path)?;
|
||||
match self.tree.get(path) {
|
||||
None => Err(libc::ENOENT),
|
||||
Some(None) => Err(libc::EISDIR),
|
||||
Some(Some(_)) => {
|
||||
self.tree.remove(path);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
FsOp::Rmdir { path } => {
|
||||
self.check_parent(path)?;
|
||||
match self.tree.get(path) {
|
||||
None => Err(libc::ENOENT),
|
||||
Some(Some(_)) => Err(libc::ENOTDIR),
|
||||
Some(None) if self.has_children(path) => Err(libc::ENOTEMPTY),
|
||||
Some(None) => {
|
||||
self.tree.remove(path);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rename(&mut self, from: &str, to: &str) -> Result<(), i32> {
|
||||
self.check_parent(from)?;
|
||||
let Some(moving) = self.tree.get(from).cloned() else {
|
||||
return Err(libc::ENOENT);
|
||||
};
|
||||
self.check_parent(to)?;
|
||||
if from == to {
|
||||
return Ok(());
|
||||
}
|
||||
if is_below(to, from) {
|
||||
return Err(libc::EINVAL);
|
||||
}
|
||||
match (&moving, self.tree.get(to)) {
|
||||
(None, Some(Some(_))) => return Err(libc::ENOTDIR),
|
||||
(Some(_), Some(None)) => return Err(libc::EISDIR),
|
||||
(None, Some(None)) if self.has_children(to) => return Err(libc::ENOTEMPTY),
|
||||
_ => {}
|
||||
}
|
||||
let below: Vec<(String, Option<Vec<u8>>)> = self
|
||||
.tree
|
||||
.iter()
|
||||
.filter(|(path, _)| is_below(path, from))
|
||||
.map(|(path, entry)| (path.clone(), entry.clone()))
|
||||
.collect();
|
||||
self.tree.remove(from);
|
||||
self.tree.insert(to.to_owned(), moving);
|
||||
for (path, entry) in below {
|
||||
self.tree.remove(&path);
|
||||
self.tree
|
||||
.insert(format!("{to}{}", &path[from.len()..]), entry);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn file_mut(&mut self, path: &str) -> Result<&mut Vec<u8>, i32> {
|
||||
self.check_parent(path)?;
|
||||
match self.tree.get_mut(path) {
|
||||
None => Err(libc::ENOENT),
|
||||
Some(None) => Err(libc::EISDIR),
|
||||
Some(Some(content)) => Ok(content),
|
||||
}
|
||||
}
|
||||
|
||||
fn check_parent(&self, path: &str) -> Result<(), i32> {
|
||||
let mut at = String::new();
|
||||
let mut names = path.split('/').peekable();
|
||||
while let Some(name) = names.next() {
|
||||
if names.peek().is_none() {
|
||||
break;
|
||||
}
|
||||
if !at.is_empty() {
|
||||
at.push('/');
|
||||
}
|
||||
at.push_str(name);
|
||||
match self.tree.get(&at) {
|
||||
None => return Err(libc::ENOENT),
|
||||
Some(Some(_)) => return Err(libc::ENOTDIR),
|
||||
Some(None) => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn has_children(&self, path: &str) -> bool {
|
||||
self.tree.keys().any(|other| is_below(other, path))
|
||||
}
|
||||
|
||||
pub(crate) fn files(&self) -> Vec<&String> {
|
||||
self.tree
|
||||
.iter()
|
||||
.filter(|(_, entry)| entry.is_some())
|
||||
.map(|(path, _)| path)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Folders, the client's own root (`""`) first.
|
||||
fn dirs(&self) -> Vec<String> {
|
||||
std::iter::once(String::new())
|
||||
.chain(
|
||||
self.tree
|
||||
.iter()
|
||||
.filter(|(_, entry)| entry.is_none())
|
||||
.map(|(path, _)| path.clone()),
|
||||
)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A random op, mostly one that should succeed.
|
||||
pub(crate) fn next_op(&self, rng: &mut Rng) -> FsOp {
|
||||
let dirs = self.dirs();
|
||||
let files = self.files();
|
||||
let in_dir = |rng: &mut Rng, names: &[&str]| {
|
||||
let parent = rng.pick(&dirs).expect("the root is always there");
|
||||
join(parent, rng.pick(names).expect("names"))
|
||||
};
|
||||
let a_file = |rng: &mut Rng| rng.pick(&files).map(|path| (*path).clone());
|
||||
let roll = rng.below(100);
|
||||
let op = match roll {
|
||||
0..30 => Some(FsOp::Create {
|
||||
path: in_dir(rng, &FILE_NAMES),
|
||||
data: data(rng),
|
||||
}),
|
||||
30..45 => a_file(rng).map(|path| {
|
||||
let len = self.content(&path).map_or(0, Vec::len) as u64;
|
||||
FsOp::Write {
|
||||
offset: rng.below(len + 8),
|
||||
path,
|
||||
data: data(rng),
|
||||
}
|
||||
}),
|
||||
45..50 => a_file(rng).map(|path| FsOp::Truncate {
|
||||
len: rng.below(32),
|
||||
path,
|
||||
}),
|
||||
50..60 => Some(FsOp::Mkdir {
|
||||
path: in_dir(rng, &DIR_NAMES),
|
||||
}),
|
||||
// A rename in place, over another file as often as not.
|
||||
60..72 => a_file(rng).map(|from| FsOp::Rename {
|
||||
to: join(parent_of(&from), rng.pick(&FILE_NAMES).expect("names")),
|
||||
from,
|
||||
}),
|
||||
// A move to another folder, renamed or not.
|
||||
72..82 => a_file(rng).map(|from| {
|
||||
let name = if rng.chance(0.5) {
|
||||
name_of(&from)
|
||||
} else {
|
||||
rng.pick(&FILE_NAMES).expect("names")
|
||||
};
|
||||
let parent = rng.pick(&dirs).expect("the root is always there");
|
||||
FsOp::Rename {
|
||||
to: join(parent, name),
|
||||
from,
|
||||
}
|
||||
}),
|
||||
// A folder renamed or moved, with what is in it.
|
||||
82..86 => {
|
||||
let from = rng.pick(&dirs[1..]).cloned();
|
||||
from.map(|from| FsOp::Rename {
|
||||
to: in_dir(rng, &DIR_NAMES),
|
||||
from,
|
||||
})
|
||||
}
|
||||
86..94 => a_file(rng).map(|path| FsOp::Unlink { path }),
|
||||
94..97 => a_file(rng).map(|path| FsOp::UnlinkOpen {
|
||||
path,
|
||||
data: data(rng),
|
||||
}),
|
||||
_ => rng
|
||||
.pick(&dirs[1..])
|
||||
.cloned()
|
||||
.map(|path| FsOp::Rmdir { path }),
|
||||
};
|
||||
op.unwrap_or_else(|| FsOp::Create {
|
||||
path: in_dir(rng, &FILE_NAMES),
|
||||
data: data(rng),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Content for a write: short and recognisable, now and then empty.
|
||||
pub(crate) fn data(rng: &mut Rng) -> Vec<u8> {
|
||||
let len = if rng.chance(0.1) {
|
||||
0
|
||||
} else {
|
||||
rng.between(1, 48)
|
||||
};
|
||||
let tag = rng.next_u64();
|
||||
(0..len).map(|i| b'a' + ((tag + i) % 26) as u8).collect()
|
||||
}
|
||||
|
||||
pub(crate) fn join(parent: &str, name: &str) -> String {
|
||||
if parent.is_empty() {
|
||||
name.to_owned()
|
||||
} else {
|
||||
format!("{parent}/{name}")
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parent_of(path: &str) -> &str {
|
||||
path.rsplit_once('/').map_or("", |(parent, _)| parent)
|
||||
}
|
||||
|
||||
pub(crate) fn name_of(path: &str) -> &str {
|
||||
path.rsplit_once('/').map_or(path, |(_, name)| name)
|
||||
}
|
||||
|
||||
fn is_below(path: &str, ancestor: &str) -> bool {
|
||||
path.len() > ancestor.len() + 1
|
||||
&& path.starts_with(ancestor)
|
||||
&& path.as_bytes()[ancestor.len()] == b'/'
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn create(path: &str, data: &[u8]) -> FsOp {
|
||||
FsOp::Create {
|
||||
path: path.into(),
|
||||
data: data.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rename_over_a_file_replaces_it_and_a_folder_moves_with_its_contents() {
|
||||
let mut model = Model::default();
|
||||
model.apply(&FsOp::Mkdir { path: "x".into() }).unwrap();
|
||||
model.apply(&create("x/a", b"1")).unwrap();
|
||||
model.apply(&create("b", b"2")).unwrap();
|
||||
model
|
||||
.apply(&FsOp::Rename {
|
||||
from: "b".into(),
|
||||
to: "x/a".into(),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(model.content("x/a"), Some(&b"2".to_vec()));
|
||||
model
|
||||
.apply(&FsOp::Rename {
|
||||
from: "x".into(),
|
||||
to: "y".into(),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(model.tree().keys().collect::<Vec<_>>(), vec!["y", "y/a"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calls_fail_the_way_posix_says() {
|
||||
let mut model = Model::default();
|
||||
model.apply(&FsOp::Mkdir { path: "x".into() }).unwrap();
|
||||
model.apply(&create("x/a", b"1")).unwrap();
|
||||
assert_eq!(
|
||||
model.apply(&FsOp::Mkdir { path: "x".into() }),
|
||||
Err(libc::EEXIST)
|
||||
);
|
||||
assert_eq!(model.apply(&create("x", b"")), Err(libc::EISDIR));
|
||||
assert_eq!(model.apply(&create("x/a/b", b"")), Err(libc::ENOTDIR));
|
||||
assert_eq!(model.apply(&create("q/a", b"")), Err(libc::ENOENT));
|
||||
assert_eq!(
|
||||
model.apply(&FsOp::Rmdir { path: "x".into() }),
|
||||
Err(libc::ENOTEMPTY)
|
||||
);
|
||||
assert_eq!(
|
||||
model.apply(&FsOp::Unlink { path: "x".into() }),
|
||||
Err(libc::EISDIR)
|
||||
);
|
||||
let into_itself = FsOp::Rename {
|
||||
from: "x".into(),
|
||||
to: "x/y".into(),
|
||||
};
|
||||
assert_eq!(model.apply(&into_itself), Err(libc::EINVAL));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_write_past_the_end_leaves_a_hole() {
|
||||
let mut model = Model::default();
|
||||
model.apply(&create("a", b"ab")).unwrap();
|
||||
let write = FsOp::Write {
|
||||
path: "a".into(),
|
||||
offset: 4,
|
||||
data: b"z".to_vec(),
|
||||
};
|
||||
model.apply(&write).unwrap();
|
||||
assert_eq!(model.content("a"), Some(&b"ab\0\0z".to_vec()));
|
||||
let nothing = FsOp::Write {
|
||||
path: "a".into(),
|
||||
offset: 9,
|
||||
data: Vec::new(),
|
||||
};
|
||||
model.apply(¬hing).unwrap();
|
||||
assert_eq!(model.content("a"), Some(&b"ab\0\0z".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_ops_mostly_succeed() {
|
||||
let mut rng = Rng::new(9);
|
||||
let mut model = Model::default();
|
||||
let ok = (0..500)
|
||||
.filter(|_| {
|
||||
let op = model.next_op(&mut rng);
|
||||
model.apply(&op).is_ok()
|
||||
})
|
||||
.count();
|
||||
assert!(ok > 350, "{ok} of 500 succeeded");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//! A small seeded random number generator, so a failing simulation is a seed
|
||||
//! that can be replayed. SplitMix64: fast, good enough for choosing operations
|
||||
//! and faults, and no dependency.
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
pub(crate) fn new(seed: u64) -> Self {
|
||||
Self(seed)
|
||||
}
|
||||
|
||||
pub(crate) fn next_u64(&mut self) -> u64 {
|
||||
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
let mut z = self.0;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
}
|
||||
|
||||
/// A value in `0..n`; `0` when `n` is `0`.
|
||||
pub(crate) fn below(&mut self, n: u64) -> u64 {
|
||||
if n == 0 { 0 } else { self.next_u64() % n }
|
||||
}
|
||||
|
||||
/// A value in `lo..=hi`.
|
||||
pub(crate) fn between(&mut self, lo: u64, hi: u64) -> u64 {
|
||||
lo + self.below(hi.saturating_sub(lo) + 1)
|
||||
}
|
||||
|
||||
/// True with probability `p`.
|
||||
pub(crate) fn chance(&mut self, p: f64) -> bool {
|
||||
p > 0.0 && (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64 <= p
|
||||
}
|
||||
|
||||
pub(crate) fn pick<'a, T>(&mut self, items: &'a [T]) -> Option<&'a T> {
|
||||
items.get(self.below(items.len() as u64) as usize)
|
||||
}
|
||||
|
||||
/// A child generator, so one consumer's draws do not shift another's.
|
||||
pub(crate) fn fork(&mut self) -> Self {
|
||||
Self::new(self.next_u64())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Rng;
|
||||
|
||||
#[test]
|
||||
fn a_seed_replays_the_same_sequence() {
|
||||
let (mut a, mut b) = (Rng::new(7), Rng::new(7));
|
||||
for _ in 0..100 {
|
||||
assert_eq!(a.next_u64(), b.next_u64());
|
||||
}
|
||||
assert_ne!(Rng::new(7).next_u64(), Rng::new(8).next_u64());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_draws_stay_in_range() {
|
||||
let mut rng = Rng::new(1);
|
||||
for _ in 0..1000 {
|
||||
assert!(rng.below(5) < 5);
|
||||
assert!((3..=9).contains(&rng.between(3, 9)));
|
||||
}
|
||||
assert_eq!(rng.below(0), 0);
|
||||
assert!(!rng.chance(0.0));
|
||||
assert!(rng.chance(1.0));
|
||||
assert!([1, 2, 3].contains(rng.pick(&[1, 2, 3]).unwrap()));
|
||||
assert_eq!(rng.pick::<u8>(&[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fork_draws_apart_from_its_parent() {
|
||||
let mut parent = Rng::new(3);
|
||||
let mut child = parent.fork();
|
||||
assert_ne!(parent.next_u64(), child.next_u64());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,766 @@
|
||||
//! The simulation runs described in `docs/TESTING.md`.
|
||||
//!
|
||||
//! A run starts one or more daemons on a shared [`FakeDrive`], each owning a
|
||||
//! folder `c<n>` that only it writes, so there is one writer per file. A seed
|
||||
//! draws a sequence of steps: syscalls through a client's mount, the link of a
|
||||
//! client going down or up, a client restarting, a pause, a settle (every
|
||||
//! queue must drain within a budget), or a file deleted while the drain holds
|
||||
//! a write to it. Every syscall is checked against the
|
||||
//! client's [`Model`] as it happens. After the sequence every link comes up
|
||||
//! and the run checks:
|
||||
//!
|
||||
//! 1. **No loss** and **POSIX**: Drive's copy of each client's folder is what
|
||||
//! its model says.
|
||||
//! 2. **No false conflicts**: no conflict copy anywhere, and the drain never
|
||||
//! takes a client's own delete for another device's.
|
||||
//! 3. **Convergence**: each mount shows Drive's tree.
|
||||
//! 4. **Liveness**: no syscall took longer than its budget, settles drained in
|
||||
//! theirs, and every queue drained once the links were up. A call that
|
||||
//! never returns is caught by the [`Watchdog`], which keeps the stacks.
|
||||
//!
|
||||
//! A restart here is a stop and a start: the next run finds what the last
|
||||
//! one wrote down, but in-flight work is let finish rather than cut. A kill
|
||||
//! at an arbitrary point needs the daemon in a process of its own.
|
||||
//!
|
||||
//! Any of these fails the seed. No open bug is tolerated: the matchers that
|
||||
//! counted the ones still open instead (B125 to B134) went once a run on
|
||||
//! 812eb3d passed every profile without them.
|
||||
//!
|
||||
//! The runs are ignored by default because they mount FUSE; run them with
|
||||
//! `cargo test -p pdfs-fuse --lib sim::run -- --ignored --test-threads=1`.
|
||||
//! `PDFS_SIM_SEEDS=n` sets how many seeds each runs, `PDFS_SIM_SEED=s` replays
|
||||
//! one, and `RUST_LOG=pdfs_fuse=debug` with `--nocapture` shows the daemons'
|
||||
//! logs.
|
||||
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::{self, Read, Seek, SeekFrom, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::daemon::{Daemon, scratch, take_logged, wait_until};
|
||||
use super::fake_drive::{Entry, FakeDrive, Faults};
|
||||
use super::model::{FsOp, Model, Tree, data, join};
|
||||
use super::rng::Rng;
|
||||
use super::watchdog::Watchdog;
|
||||
|
||||
/// What a run looks like.
|
||||
pub(crate) struct Profile {
|
||||
pub(crate) name: &'static str,
|
||||
pub(crate) clients: usize,
|
||||
pub(crate) steps: usize,
|
||||
pub(crate) faults: fn() -> Faults,
|
||||
/// Whether links go down and come back during the sequence.
|
||||
pub(crate) link_flaps: bool,
|
||||
pub(crate) restarts: bool,
|
||||
/// Whether files are deleted while the drain holds a write to them.
|
||||
pub(crate) deletes_mid_upload: bool,
|
||||
/// How long a settle may take; `None` leaves settles out.
|
||||
pub(crate) settle_budget: Option<Duration>,
|
||||
/// How long one syscall may take.
|
||||
pub(crate) syscall_budget: Duration,
|
||||
/// How long the queues may take to drain at the end.
|
||||
pub(crate) drain_budget: Duration,
|
||||
}
|
||||
|
||||
/// How long the mounts may take to show Drive's tree at the end: one event
|
||||
/// poll (`POLL_INTERVAL`) and change.
|
||||
const CONVERGE_BUDGET: Duration = Duration::from_secs(30);
|
||||
|
||||
/// How long the drain may take to pick up a write: its debounce, and then
|
||||
/// some.
|
||||
const PICKUP_BUDGET: Duration = Duration::from_secs(8);
|
||||
|
||||
/// Steps shown before a failure. All of them are written to `steps.log` in
|
||||
/// the state the failure keeps.
|
||||
const LOG_TAIL: usize = 40;
|
||||
|
||||
/// How long past its own budget a step, or the checks after the last one, may
|
||||
/// run before the watchdog takes it for hung. Longer than a restart may wait
|
||||
/// for the last run and the mount.
|
||||
const STALL: Duration = Duration::from_secs(120);
|
||||
|
||||
struct Client {
|
||||
daemon: Option<Daemon>,
|
||||
model: Model,
|
||||
online: bool,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
fn daemon(&self) -> &Daemon {
|
||||
self.daemon
|
||||
.as_ref()
|
||||
.expect("a client always has a daemon between steps")
|
||||
}
|
||||
|
||||
fn root(&self, index: usize) -> PathBuf {
|
||||
self.daemon().mountpoint.join(owned_folder(index))
|
||||
}
|
||||
}
|
||||
|
||||
struct Run {
|
||||
seed: u64,
|
||||
profile: &'static Profile,
|
||||
drive: std::sync::Arc<FakeDrive>,
|
||||
clients: Vec<Client>,
|
||||
log: Vec<String>,
|
||||
started: Instant,
|
||||
/// What the daemons logged at `INFO` and above so far.
|
||||
logged: Vec<String>,
|
||||
watchdog: Watchdog,
|
||||
}
|
||||
|
||||
/// Run `profile` on `seed`. It answers a report of what went wrong, if
|
||||
/// anything did.
|
||||
pub(crate) fn run(seed: u64, profile: &'static Profile) -> Result<(), String> {
|
||||
let dir = scratch(&format!("{}-{seed}", profile.name));
|
||||
let mut rng = Rng::new(seed);
|
||||
let drive = FakeDrive::new();
|
||||
let mut run = Run {
|
||||
seed,
|
||||
profile,
|
||||
drive: drive.clone(),
|
||||
clients: Vec::new(),
|
||||
log: Vec::new(),
|
||||
started: Instant::now(),
|
||||
logged: Vec::new(),
|
||||
watchdog: Watchdog::new(&dir),
|
||||
};
|
||||
let outcome = run.execute(&dir, &mut rng);
|
||||
run.watchdog.stop();
|
||||
// A hang is what failed, whatever the aborted call answered.
|
||||
let outcome = match run.watchdog.fired() {
|
||||
Some(hung) => Err(format!("liveness: {}", hung.what)),
|
||||
None => outcome,
|
||||
};
|
||||
for client in &mut run.clients {
|
||||
if let Some(daemon) = client.daemon.take() {
|
||||
let _ = daemon.stop();
|
||||
}
|
||||
}
|
||||
match outcome {
|
||||
Ok(()) => {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
Ok(())
|
||||
}
|
||||
Err(failure) => Err(run.report(&failure, &dir)),
|
||||
}
|
||||
}
|
||||
|
||||
impl Run {
|
||||
fn execute(&mut self, dir: &Path, rng: &mut Rng) -> Result<(), String> {
|
||||
for index in 0..self.profile.clients {
|
||||
let client = self.drive.client(rng.next_u64(), (self.profile.faults)());
|
||||
let daemon = Daemon::start(&dir.join(format!("client{index}")), client)
|
||||
.map_err(|error| format!("client {index} did not mount: {error}"))?;
|
||||
let folder = daemon.mountpoint.join(owned_folder(index));
|
||||
std::fs::create_dir(&folder)
|
||||
.map_err(|error| format!("client {index}: mkdir {}: {error}", folder.display()))?;
|
||||
self.clients.push(Client {
|
||||
daemon: Some(daemon),
|
||||
model: Model::default(),
|
||||
online: true,
|
||||
});
|
||||
}
|
||||
for step in 0..self.profile.steps {
|
||||
let _watching = self.watchdog.arm(self.step_limit(), format!("step {step}"));
|
||||
self.step(rng)?;
|
||||
}
|
||||
let _watching = self.watchdog.arm(
|
||||
self.profile.drain_budget + STALL,
|
||||
"the checks after the last step",
|
||||
);
|
||||
self.finish()
|
||||
}
|
||||
|
||||
/// How long a step may run before the watchdog takes it for hung.
|
||||
fn step_limit(&self) -> Duration {
|
||||
let budget = self.profile.settle_budget.unwrap_or_default();
|
||||
budget.max(self.profile.syscall_budget) + STALL
|
||||
}
|
||||
|
||||
fn step(&mut self, rng: &mut Rng) -> Result<(), String> {
|
||||
let index = rng.below(self.clients.len() as u64) as usize;
|
||||
let roll = rng.below(100);
|
||||
match roll {
|
||||
0..4 if self.profile.link_flaps => {
|
||||
let client = &mut self.clients[index];
|
||||
client.online = !client.online;
|
||||
client.daemon().client.set_online(client.online);
|
||||
let state = if client.online { "up" } else { "down" };
|
||||
self.note(format!("client {index}: link {state}"));
|
||||
}
|
||||
4..6 if self.profile.restarts => {
|
||||
self.note(format!("client {index}: restart"));
|
||||
let daemon = self.clients[index].daemon.take().expect("daemon");
|
||||
let daemon = daemon
|
||||
.restart()
|
||||
.map_err(|error| format!("client {index} did not come back: {error}"))?;
|
||||
self.clients[index].daemon = Some(daemon);
|
||||
}
|
||||
6..12 => {
|
||||
let ms = rng.below(400);
|
||||
self.note(format!("pause {ms} ms"));
|
||||
std::thread::sleep(Duration::from_millis(ms));
|
||||
}
|
||||
12..15 => {
|
||||
if let Some(budget) = self.profile.settle_budget
|
||||
&& self.clients.iter().all(|client| client.online)
|
||||
{
|
||||
self.settle(budget)?;
|
||||
}
|
||||
}
|
||||
15..25 if self.profile.deletes_mid_upload => self.delete_mid_upload(index, rng)?,
|
||||
_ => {
|
||||
let op = self.clients[index].model.next_op(rng);
|
||||
self.syscall(index, op)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Wait until every queue is empty and Drive holds what the models say.
|
||||
fn settle(&mut self, budget: Duration) -> Result<(), String> {
|
||||
self.note("settle".into());
|
||||
let start = Instant::now();
|
||||
// What the drain logs meanwhile is what explains a known bug's damage.
|
||||
if !wait_until(budget, || {
|
||||
self.logged.extend(take_logged());
|
||||
self.landed()
|
||||
}) {
|
||||
return Err(format!(
|
||||
"liveness: the queues did not drain within {budget:?}\n{}",
|
||||
self.difference()
|
||||
));
|
||||
}
|
||||
self.note(format!("settled in {:?}", start.elapsed()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write a file Drive holds and delete it once the drain has picked the
|
||||
/// write up. The drain's read of the node is held until the delete has
|
||||
/// landed and a read shows it, so the drain finds the file trashed while
|
||||
/// it holds a write that is no longer queued (B105). Half the time the
|
||||
/// file is made again meanwhile, which queues a write under the op id the
|
||||
/// delete freed (B133).
|
||||
fn delete_mid_upload(&mut self, index: usize, rng: &mut Rng) -> Result<(), String> {
|
||||
let file = rng
|
||||
.pick(&self.clients[index].model.files())
|
||||
.map(|path| (*path).clone());
|
||||
let (Some(path), Some(budget), true) = (
|
||||
file,
|
||||
self.profile.settle_budget,
|
||||
self.clients.iter().all(|client| client.online),
|
||||
) else {
|
||||
let op = self.clients[index].model.next_op(rng);
|
||||
return self.syscall(index, op);
|
||||
};
|
||||
self.settle(budget)?;
|
||||
let Some(uid) = self.drive.lookup(&join(&owned_folder(index), &path)) else {
|
||||
return Err(format!(
|
||||
"no loss: client {index}'s {path} is not on Drive after a settle"
|
||||
));
|
||||
};
|
||||
let held = self.clients[index].daemon().client.hold_next_read(&uid);
|
||||
self.note(format!("client {index}: hold the next read of {path}"));
|
||||
let mut bytes = data(rng);
|
||||
bytes.push(b'.');
|
||||
self.syscall(
|
||||
index,
|
||||
FsOp::Write {
|
||||
path: path.clone(),
|
||||
offset: 0,
|
||||
data: bytes,
|
||||
},
|
||||
)?;
|
||||
if !wait_until(PICKUP_BUDGET, || held.reached()) {
|
||||
self.note(format!("client {index}: nothing read {path} back"));
|
||||
return Ok(());
|
||||
}
|
||||
self.syscall(index, FsOp::Unlink { path: path.clone() })?;
|
||||
if rng.chance(0.5) {
|
||||
self.syscall(
|
||||
index,
|
||||
FsOp::Create {
|
||||
path: path.clone(),
|
||||
data: data(rng),
|
||||
},
|
||||
)?;
|
||||
}
|
||||
let faults = (self.profile.faults)();
|
||||
let latency = Duration::from_millis(faults.latency_ms.1);
|
||||
std::thread::sleep(faults.listing_lag + latency);
|
||||
drop(held);
|
||||
self.note(format!("client {index}: the read of {path} goes on"));
|
||||
// Nothing else is queued before the drain has answered.
|
||||
std::thread::sleep(2 * latency + Duration::from_millis(50));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn syscall(&mut self, index: usize, op: FsOp) -> Result<(), String> {
|
||||
let client = &mut self.clients[index];
|
||||
let root = client.root(index);
|
||||
let before = client.model.content(target(&op)).cloned();
|
||||
let expected = client.model.apply(&op);
|
||||
self.logged.extend(take_logged());
|
||||
let start = Instant::now();
|
||||
let actual = perform(&root, &op, before.as_deref());
|
||||
let took = start.elapsed();
|
||||
self.note(format!(
|
||||
"client {index}: {} = {} in {took:?}",
|
||||
op.describe(),
|
||||
outcome(&actual)
|
||||
));
|
||||
let actual = actual?;
|
||||
self.logged.extend(take_logged());
|
||||
if actual != expected {
|
||||
return Err(format!(
|
||||
"posix: client {index}: {} answered {}, expected {}",
|
||||
op.describe(),
|
||||
errno(actual),
|
||||
errno(expected)
|
||||
));
|
||||
}
|
||||
if took > self.profile.syscall_budget {
|
||||
return Err(format!(
|
||||
"liveness: client {index}: {} took {took:?}, over {:?}",
|
||||
op.describe(),
|
||||
self.profile.syscall_budget
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finish(&mut self) -> Result<(), String> {
|
||||
for client in &mut self.clients {
|
||||
client.online = true;
|
||||
client.daemon().client.set_online(true);
|
||||
}
|
||||
self.note("every link up; waiting for the queues".into());
|
||||
if !wait_until(self.profile.drain_budget, || {
|
||||
self.logged.extend(take_logged());
|
||||
self.landed()
|
||||
}) {
|
||||
return Err(format!(
|
||||
"no loss: Drive does not hold what the clients wrote, {:?} after the links came up\n{}",
|
||||
self.profile.drain_budget,
|
||||
self.difference()
|
||||
));
|
||||
}
|
||||
// Each client's folder is its own, so a file of it gone from Drive was
|
||||
// deleted by the client.
|
||||
if let Some(line) = self.logged.iter().find(|line| is_own_delete_kept(line)) {
|
||||
return Err(format!(
|
||||
"no false conflicts: the drain took a client's own delete for another device's: {line}"
|
||||
));
|
||||
}
|
||||
let drive = self.drive_tree();
|
||||
let conflicts: Vec<String> = drive
|
||||
.into_keys()
|
||||
.filter(|path| is_conflict_copy(path))
|
||||
.collect();
|
||||
if !conflicts.is_empty() {
|
||||
return Err(format!("no false conflicts: {conflicts:?}"));
|
||||
}
|
||||
for index in 0..self.clients.len() {
|
||||
let mut last = Tree::new();
|
||||
let converged = wait_until(CONVERGE_BUDGET, || {
|
||||
last = read_tree(&self.clients[index].daemon().mountpoint);
|
||||
last == self.drive_tree()
|
||||
});
|
||||
if !converged {
|
||||
return Err(format!(
|
||||
"convergence: client {index}'s mount differs from Drive\n{}",
|
||||
diff(&last, &self.drive_tree(), "mount", "Drive")
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether every client's queue is empty and Drive holds what its model
|
||||
/// says.
|
||||
fn landed(&self) -> bool {
|
||||
let drive = self.drive_tree();
|
||||
self.clients.iter().enumerate().all(|(index, client)| {
|
||||
client
|
||||
.daemon()
|
||||
.pending()
|
||||
.is_ok_and(|items| items.is_empty())
|
||||
&& subtree(&drive, &owned_folder(index)) == *client.model.tree()
|
||||
})
|
||||
}
|
||||
|
||||
fn difference(&self) -> String {
|
||||
let drive = self.drive_tree();
|
||||
let mut out = String::new();
|
||||
for (index, client) in self.clients.iter().enumerate() {
|
||||
let pending = client.daemon().pending().unwrap_or_default();
|
||||
for item in pending {
|
||||
out.push_str(&format!(
|
||||
" client {index} still owes: {} {} (attempts {}, last error {:?})\n",
|
||||
item.kind, item.path, item.attempts, item.last_error
|
||||
));
|
||||
}
|
||||
out.push_str(&diff(
|
||||
client.model.tree(),
|
||||
&subtree(&drive, &owned_folder(index)),
|
||||
&format!("client {index}'s model"),
|
||||
"Drive",
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn drive_tree(&self) -> Tree {
|
||||
self.drive
|
||||
.tree()
|
||||
.into_iter()
|
||||
.map(|(path, entry)| {
|
||||
let content = match entry {
|
||||
Entry::Folder => None,
|
||||
Entry::File(content) => Some(content.as_ref().clone()),
|
||||
};
|
||||
(path, content)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn note(&mut self, line: String) {
|
||||
let line = format!("{:>8.3}s {line}", self.started.elapsed().as_secs_f64());
|
||||
self.log.push(line);
|
||||
}
|
||||
|
||||
fn report(&self, failure: &str, dir: &Path) -> String {
|
||||
let _ = std::fs::write(dir.join("steps.log"), self.log.join("\n") + "\n");
|
||||
let tail = &self.log[self.log.len().saturating_sub(LOG_TAIL)..];
|
||||
format!(
|
||||
"{} seed {} failed: {failure}\nreplay with PDFS_SIM_SEED={}; state kept in {}\nlast steps:\n{}",
|
||||
self.profile.name,
|
||||
self.seed,
|
||||
self.seed,
|
||||
dir.display(),
|
||||
tail.join("\n")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Make `op` under `root`. The outer error is a check that failed on the
|
||||
/// way (the content an open file reads back), the inner one the errno.
|
||||
fn perform(root: &Path, op: &FsOp, before: Option<&[u8]>) -> Result<Result<(), i32>, String> {
|
||||
let at = |path: &str| root.join(path);
|
||||
let result: io::Result<()> = match op {
|
||||
FsOp::Create { path, data } => std::fs::write(at(path), data),
|
||||
FsOp::Write { path, offset, data } => OpenOptions::new()
|
||||
.write(true)
|
||||
.open(at(path))
|
||||
.and_then(|mut file| {
|
||||
file.seek(SeekFrom::Start(*offset))?;
|
||||
file.write_all(data)
|
||||
}),
|
||||
FsOp::Truncate { path, len } => OpenOptions::new()
|
||||
.write(true)
|
||||
.open(at(path))
|
||||
.and_then(|file| file.set_len(*len)),
|
||||
FsOp::Mkdir { path } => std::fs::create_dir(at(path)),
|
||||
FsOp::Rename { from, to } => std::fs::rename(at(from), at(to)),
|
||||
FsOp::Unlink { path } => std::fs::remove_file(at(path)),
|
||||
FsOp::Rmdir { path } => std::fs::remove_dir(at(path)),
|
||||
FsOp::UnlinkOpen { path, data } => {
|
||||
let mut read = Vec::new();
|
||||
let result = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(at(path))
|
||||
.and_then(|mut file| {
|
||||
std::fs::remove_file(at(path))?;
|
||||
file.seek(SeekFrom::End(0))?;
|
||||
file.write_all(data)?;
|
||||
file.seek(SeekFrom::Start(0))?;
|
||||
file.read_to_end(&mut read).map(|_| ())
|
||||
});
|
||||
if result.is_ok() {
|
||||
let expected = [before.unwrap_or_default(), data].concat();
|
||||
if read != expected {
|
||||
return Err(format!(
|
||||
"posix: {} read back {:?}, expected {:?}",
|
||||
op.describe(),
|
||||
String::from_utf8_lossy(&read),
|
||||
String::from_utf8_lossy(&expected)
|
||||
));
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
};
|
||||
Ok(result.map_err(|error| error.raw_os_error().unwrap_or(libc::EIO)))
|
||||
}
|
||||
|
||||
fn target(op: &FsOp) -> &str {
|
||||
match op {
|
||||
FsOp::Create { path, .. }
|
||||
| FsOp::Write { path, .. }
|
||||
| FsOp::Truncate { path, .. }
|
||||
| FsOp::Mkdir { path }
|
||||
| FsOp::Unlink { path }
|
||||
| FsOp::UnlinkOpen { path, .. }
|
||||
| FsOp::Rmdir { path } => path,
|
||||
FsOp::Rename { from, .. } => from,
|
||||
}
|
||||
}
|
||||
|
||||
/// The tree a mount shows, read through it, without the folders it makes up
|
||||
/// that are not on Drive.
|
||||
fn read_tree(mountpoint: &Path) -> Tree {
|
||||
fn walk(dir: &Path, prefix: &str, tree: &mut Tree) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if prefix.is_empty() && name == crate::r#virtual::SHARED_WITH_ME_BASE {
|
||||
continue;
|
||||
}
|
||||
let path = super::model::join(prefix, &name);
|
||||
if entry.file_type().is_ok_and(|kind| kind.is_dir()) {
|
||||
tree.insert(path.clone(), None);
|
||||
walk(&entry.path(), &path, tree);
|
||||
} else {
|
||||
tree.insert(path, std::fs::read(entry.path()).ok());
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut tree = Tree::new();
|
||||
walk(mountpoint, "", &mut tree);
|
||||
tree
|
||||
}
|
||||
|
||||
/// The part of `tree` below `folder`, relative to it.
|
||||
fn subtree(tree: &Tree, folder: &str) -> Tree {
|
||||
let prefix = format!("{folder}/");
|
||||
tree.iter()
|
||||
.filter_map(|(path, entry)| Some((path.strip_prefix(&prefix)?.to_owned(), entry.clone())))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn owned_folder(index: usize) -> String {
|
||||
format!("c{index}")
|
||||
}
|
||||
|
||||
/// Whether `line` is the drain keeping a queued write as a conflict copy
|
||||
/// because Drive no longer holds the file, or holds it in the trash.
|
||||
fn is_own_delete_kept(line: &str) -> bool {
|
||||
line.starts_with("queued write conflicts; keeping a conflict copy")
|
||||
&& (line.contains(" reason=\"the file was trashed remotely\"")
|
||||
|| line.contains(" reason=\"the file no longer exists remotely\""))
|
||||
}
|
||||
|
||||
fn is_conflict_copy(path: &str) -> bool {
|
||||
path.split('/')
|
||||
.any(|name| name.contains("(sync-conflict") || name.starts_with("recovered-"))
|
||||
}
|
||||
|
||||
fn diff(left: &Tree, right: &Tree, left_name: &str, right_name: &str) -> String {
|
||||
let show = |entry: &Option<Vec<u8>>| match entry {
|
||||
None => "folder".to_owned(),
|
||||
Some(content) => format!("{:?}", String::from_utf8_lossy(content)),
|
||||
};
|
||||
let mut out = String::new();
|
||||
for (path, entry) in left {
|
||||
match right.get(path) {
|
||||
None => out.push_str(&format!(
|
||||
" {path}: only in {left_name} ({})\n",
|
||||
show(entry)
|
||||
)),
|
||||
Some(other) if other != entry => out.push_str(&format!(
|
||||
" {path}: {left_name} has {}, {right_name} has {}\n",
|
||||
show(entry),
|
||||
show(other)
|
||||
)),
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
for (path, entry) in right {
|
||||
if !left.contains_key(path) {
|
||||
out.push_str(&format!(
|
||||
" {path}: only in {right_name} ({})\n",
|
||||
show(entry)
|
||||
));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn errno(outcome: Result<(), i32>) -> String {
|
||||
match outcome {
|
||||
Ok(()) => "ok".into(),
|
||||
Err(code) => io::Error::from_raw_os_error(code).to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn outcome(actual: &Result<Result<(), i32>, String>) -> String {
|
||||
match actual {
|
||||
Ok(result) => errno(*result),
|
||||
Err(check) => check.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `profile` over the seeds the environment asks for: `PDFS_SIM_SEED`
|
||||
/// alone, or `PDFS_SIM_SEEDS` of them (default `default_seeds`) from 1.
|
||||
pub(crate) fn run_seeds(profile: &'static Profile, default_seeds: u64) {
|
||||
let seeds: Vec<u64> = match std::env::var("PDFS_SIM_SEED") {
|
||||
Ok(seed) => vec![seed.parse().expect("PDFS_SIM_SEED is a number")],
|
||||
Err(_) => {
|
||||
let count = std::env::var("PDFS_SIM_SEEDS")
|
||||
.ok()
|
||||
.and_then(|count| count.parse().ok())
|
||||
.unwrap_or(default_seeds);
|
||||
(1..=count).collect()
|
||||
}
|
||||
};
|
||||
let failures: Vec<String> = seeds
|
||||
.into_iter()
|
||||
.filter_map(|seed| run(seed, profile).err())
|
||||
.collect();
|
||||
assert!(failures.is_empty(), "\n{}", failures.join("\n\n"));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
static ONE_CLIENT_LAN: Profile = Profile {
|
||||
name: "one-client-lan",
|
||||
clients: 1,
|
||||
steps: 80,
|
||||
faults: Faults::lan,
|
||||
link_flaps: false,
|
||||
restarts: false,
|
||||
deletes_mid_upload: false,
|
||||
settle_budget: Some(Duration::from_secs(4)),
|
||||
syscall_budget: Duration::from_secs(2),
|
||||
drain_budget: Duration::from_secs(20),
|
||||
};
|
||||
|
||||
static ONE_CLIENT_DELETES: Profile = Profile {
|
||||
name: "one-client-deletes",
|
||||
clients: 1,
|
||||
steps: 30,
|
||||
faults: Faults::lan,
|
||||
link_flaps: false,
|
||||
restarts: false,
|
||||
deletes_mid_upload: true,
|
||||
settle_budget: Some(Duration::from_secs(4)),
|
||||
syscall_budget: Duration::from_secs(2),
|
||||
drain_budget: Duration::from_secs(20),
|
||||
};
|
||||
|
||||
static ONE_CLIENT_ECHOES: Profile = Profile {
|
||||
name: "one-client-echoes",
|
||||
clients: 1,
|
||||
steps: 60,
|
||||
faults: Faults::echoes,
|
||||
link_flaps: false,
|
||||
restarts: false,
|
||||
deletes_mid_upload: false,
|
||||
settle_budget: Some(Duration::from_secs(6)),
|
||||
syscall_budget: Duration::from_secs(2),
|
||||
drain_budget: Duration::from_secs(20),
|
||||
};
|
||||
|
||||
static ONE_CLIENT_FLAKY: Profile = Profile {
|
||||
name: "one-client-flaky",
|
||||
clients: 1,
|
||||
steps: 80,
|
||||
faults: Faults::wifi,
|
||||
link_flaps: true,
|
||||
restarts: true,
|
||||
deletes_mid_upload: false,
|
||||
settle_budget: None,
|
||||
syscall_budget: Duration::from_secs(10),
|
||||
drain_budget: Duration::from_secs(120),
|
||||
};
|
||||
|
||||
/// Wi-Fi's latency, lag and lost replies without the outages: the queue
|
||||
/// has to drain within a settle, not merely by the end.
|
||||
static ONE_CLIENT_WIFI: Profile = Profile {
|
||||
name: "one-client-wifi",
|
||||
clients: 1,
|
||||
steps: 80,
|
||||
faults: Faults::wifi,
|
||||
link_flaps: false,
|
||||
restarts: false,
|
||||
deletes_mid_upload: false,
|
||||
settle_budget: Some(Duration::from_secs(30)),
|
||||
syscall_budget: Duration::from_secs(10),
|
||||
drain_budget: Duration::from_secs(120),
|
||||
};
|
||||
|
||||
static THREE_CLIENTS: Profile = Profile {
|
||||
name: "three-clients",
|
||||
clients: 3,
|
||||
steps: 90,
|
||||
faults: Faults::lan,
|
||||
link_flaps: true,
|
||||
restarts: true,
|
||||
deletes_mid_upload: false,
|
||||
settle_budget: Some(Duration::from_secs(4)),
|
||||
syscall_budget: Duration::from_secs(2),
|
||||
drain_budget: Duration::from_secs(60),
|
||||
};
|
||||
|
||||
#[test]
|
||||
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse --lib sim::run -- --ignored`"]
|
||||
fn one_client_on_a_good_link() {
|
||||
run_seeds(&ONE_CLIENT_LAN, 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse --lib sim::run -- --ignored`"]
|
||||
fn one_client_deleting_files_as_they_upload() {
|
||||
run_seeds(&ONE_CLIENT_DELETES, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse --lib sim::run -- --ignored`"]
|
||||
fn one_client_with_stale_hashes_and_reordered_echoes() {
|
||||
run_seeds(&ONE_CLIENT_ECHOES, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse --lib sim::run -- --ignored`"]
|
||||
fn one_client_on_a_flaky_link() {
|
||||
run_seeds(&ONE_CLIENT_FLAKY, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse --lib sim::run -- --ignored`"]
|
||||
fn one_client_on_a_slow_link() {
|
||||
run_seeds(&ONE_CLIENT_WIFI, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse --lib sim::run -- --ignored`"]
|
||||
fn three_clients_one_writer_each() {
|
||||
run_seeds(&THREE_CLIENTS, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conflict_copies_are_recognised_by_name() {
|
||||
assert!(is_conflict_copy("c0/x/a (sync-conflict 1790000000).txt"));
|
||||
assert!(is_conflict_copy("recovered-XrPUSW5W"));
|
||||
assert!(!is_conflict_copy("c0/x/a.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_write_kept_because_its_file_is_gone_is_recognised() {
|
||||
let kept = "queued write conflicts; keeping a conflict copy uid=simvol~L10 \
|
||||
name=\"f.txt\" reason=\"the file was trashed remotely\"";
|
||||
assert!(is_own_delete_kept(kept));
|
||||
assert!(is_own_delete_kept(
|
||||
&kept.replace("was trashed", "no longer exists")
|
||||
));
|
||||
assert!(!is_own_delete_kept(
|
||||
&kept.replace("the file was trashed remotely", "revision changed")
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
//! Turns a call through a mount that never returns into a failed seed.
|
||||
//!
|
||||
//! A deadlock in a daemon leaves the run's own thread blocked in the kernel,
|
||||
//! where no budget check is reached, and a CI job then waits for its timeout.
|
||||
//! The watchdog runs beside the run. When what it was armed for outlives its
|
||||
//! limit, it aborts the FUSE connections of the run's mounts, so the blocked
|
||||
//! call fails and the run reports the hang, and writes where every thread was
|
||||
//! to `stacks.txt`.
|
||||
//!
|
||||
//! The stacks are taken in the process: no tracer can stop a thread that
|
||||
//! waits on a FUSE request, so `gdb` and `eu-stack` wait as long as the hang.
|
||||
//! Each thread is sent [`stack_signal`] and captures its own backtrace in the
|
||||
//! handler. A thread in the kernel takes the signal only once it leaves, so
|
||||
//! the mounts are aborted after the others have answered, and its backtrace
|
||||
//! is the call it was blocked in. Its kernel wait channel is recorded first.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use parking_lot::Mutex;
|
||||
|
||||
use super::daemon::wait_until;
|
||||
|
||||
/// How long the threads that are not blocked in the kernel have to answer,
|
||||
/// and the blocked ones once the mounts are aborted.
|
||||
const ANSWER: Duration = Duration::from_secs(5);
|
||||
|
||||
pub(crate) struct Watchdog {
|
||||
shared: Arc<Shared>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
struct Shared {
|
||||
/// The run's directory: its mounts are below it, and the stacks go in it.
|
||||
dir: PathBuf,
|
||||
armed: Mutex<Option<Armed>>,
|
||||
/// What hung, once something has.
|
||||
fired: Mutex<Option<Hung>>,
|
||||
stop: AtomicBool,
|
||||
}
|
||||
|
||||
struct Armed {
|
||||
what: String,
|
||||
limit: Duration,
|
||||
since: Instant,
|
||||
}
|
||||
|
||||
/// What hung, and where every thread was then.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct Hung {
|
||||
pub(crate) what: String,
|
||||
pub(crate) stacks: String,
|
||||
}
|
||||
|
||||
/// Disarms the watchdog when dropped.
|
||||
pub(crate) struct Watching(Arc<Shared>);
|
||||
|
||||
impl Watchdog {
|
||||
pub(crate) fn new(dir: &Path) -> Self {
|
||||
let shared = Arc::new(Shared {
|
||||
dir: dir.to_path_buf(),
|
||||
armed: Mutex::new(None),
|
||||
fired: Mutex::new(None),
|
||||
stop: AtomicBool::new(false),
|
||||
});
|
||||
let thread = {
|
||||
let shared = shared.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("sim-watchdog".into())
|
||||
.spawn(move || watch(&shared))
|
||||
.expect("spawn the simulation watchdog")
|
||||
};
|
||||
Self {
|
||||
shared,
|
||||
thread: Some(thread),
|
||||
}
|
||||
}
|
||||
|
||||
/// Watch `what` until the guard is dropped, and abort the mounts if it is
|
||||
/// still going after `limit`.
|
||||
pub(crate) fn arm(&self, limit: Duration, what: impl Into<String>) -> Watching {
|
||||
*self.shared.armed.lock() = Some(Armed {
|
||||
what: what.into(),
|
||||
limit,
|
||||
since: Instant::now(),
|
||||
});
|
||||
Watching(self.shared.clone())
|
||||
}
|
||||
|
||||
/// What hung, if something did. Call [`Watchdog::stop`] first, or the
|
||||
/// stacks may still be on their way.
|
||||
pub(crate) fn fired(&self) -> Option<Hung> {
|
||||
self.shared.fired.lock().clone()
|
||||
}
|
||||
|
||||
/// Stop watching, once the stacks of a hang that was caught are taken.
|
||||
pub(crate) fn stop(&mut self) {
|
||||
self.shared.stop.store(true, Ordering::Relaxed);
|
||||
if let Some(thread) = self.thread.take() {
|
||||
let _ = thread.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Watching {
|
||||
fn drop(&mut self) {
|
||||
*self.0.armed.lock() = None;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Watchdog {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
|
||||
fn watch(shared: &Shared) {
|
||||
while !shared.stop.load(Ordering::Relaxed) {
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
let hung = shared
|
||||
.armed
|
||||
.lock()
|
||||
.as_ref()
|
||||
.filter(|armed| armed.since.elapsed() > armed.limit)
|
||||
.map(|armed| format!("{} has not returned after {:?}", armed.what, armed.limit));
|
||||
let Some(hung) = hung else { continue };
|
||||
*shared.armed.lock() = None;
|
||||
let path = shared.dir.join("stacks.txt");
|
||||
// Before anything is let go: the run reports the hang, not whatever
|
||||
// the aborted call answers.
|
||||
*shared.fired.lock() = Some(Hung {
|
||||
what: format!("{hung}; the stacks are in {}", path.display()),
|
||||
stacks: String::new(),
|
||||
});
|
||||
let stacks = take_stacks(|| abort_mounts_below(&shared.dir));
|
||||
let what = match std::fs::write(&path, &stacks) {
|
||||
Ok(()) => format!("{hung}; the stacks are in {}", path.display()),
|
||||
Err(error) => format!("{hung}; the stacks could not be kept: {error}"),
|
||||
};
|
||||
*shared.fired.lock() = Some(Hung { what, stacks });
|
||||
}
|
||||
}
|
||||
|
||||
/// One thread of this process as the kernel sees it.
|
||||
struct Task {
|
||||
tid: i32,
|
||||
name: String,
|
||||
/// `D` while it waits in the kernel uninterruptibly.
|
||||
state: String,
|
||||
wchan: String,
|
||||
}
|
||||
|
||||
/// Where every other thread of this process is, with `release` called once
|
||||
/// those not blocked in the kernel have answered.
|
||||
fn take_stacks(release: impl FnOnce()) -> String {
|
||||
let own = gettid();
|
||||
let tasks = tasks();
|
||||
STACKS.lock().clear();
|
||||
install_stack_handler();
|
||||
for task in &tasks {
|
||||
if task.tid != own {
|
||||
// SAFETY: tgkill only sends a signal, whose handler is installed.
|
||||
unsafe {
|
||||
libc::syscall(
|
||||
libc::SYS_tgkill,
|
||||
std::process::id() as i32,
|
||||
task.tid,
|
||||
stack_signal(),
|
||||
)
|
||||
};
|
||||
}
|
||||
}
|
||||
let answered = |blocked: bool| {
|
||||
let stacks = STACKS.lock();
|
||||
tasks
|
||||
.iter()
|
||||
.filter(|task| task.tid != own && (blocked || task.state != "D"))
|
||||
.all(|task| stacks.contains_key(&task.tid))
|
||||
};
|
||||
wait_until(ANSWER, || answered(false));
|
||||
release();
|
||||
wait_until(ANSWER, || answered(true));
|
||||
let stacks = std::mem::take(&mut *STACKS.lock());
|
||||
let mut out = String::new();
|
||||
for task in tasks.iter().filter(|task| task.tid != own) {
|
||||
out.push_str(&format!(
|
||||
"thread {} {:?}, state {}, waiting in {}\n",
|
||||
task.tid, task.name, task.state, task.wchan
|
||||
));
|
||||
match stacks.get(&task.tid) {
|
||||
Some(stack) => out.push_str(&format!("{stack}\n")),
|
||||
None => out.push_str(" (no answer)\n\n"),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn tasks() -> Vec<Task> {
|
||||
let Ok(entries) = std::fs::read_dir("/proc/self/task") else {
|
||||
return Vec::new();
|
||||
};
|
||||
let read = |dir: &Path, file: &str| {
|
||||
std::fs::read_to_string(dir.join(file))
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_owned()
|
||||
};
|
||||
entries
|
||||
.flatten()
|
||||
.filter_map(|entry| {
|
||||
let tid = entry.file_name().to_str()?.parse().ok()?;
|
||||
let dir = entry.path();
|
||||
// The state follows the name in parentheses, which may hold spaces.
|
||||
let stat = read(&dir, "stat");
|
||||
let state = stat.rsplit_once(") ")?.1.split(' ').next()?.to_owned();
|
||||
Some(Task {
|
||||
tid,
|
||||
name: read(&dir, "comm"),
|
||||
state,
|
||||
wchan: read(&dir, "wchan"),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The backtraces the threads captured, by thread id.
|
||||
static STACKS: Mutex<BTreeMap<i32, std::backtrace::Backtrace>> = Mutex::new(BTreeMap::new());
|
||||
|
||||
/// The signal that asks a thread for its stack: one of the real-time signals
|
||||
/// that nothing in the daemon uses.
|
||||
fn stack_signal() -> i32 {
|
||||
libc::SIGRTMIN() + 7
|
||||
}
|
||||
|
||||
fn install_stack_handler() {
|
||||
static INSTALLED: std::sync::Once = std::sync::Once::new();
|
||||
extern "C" fn handler(_: i32) {
|
||||
// Not async-signal-safe: the capture allocates. The process is being
|
||||
// taken down for a hang anyway, and a thread interrupted inside the
|
||||
// allocator only fails to answer.
|
||||
let stack = std::backtrace::Backtrace::force_capture();
|
||||
STACKS.lock().insert(gettid(), stack);
|
||||
}
|
||||
INSTALLED.call_once(|| {
|
||||
// SAFETY: a zeroed sigaction with a handler and SA_RESTART is valid,
|
||||
// and the handler only touches `STACKS`.
|
||||
unsafe {
|
||||
let mut action: libc::sigaction = std::mem::zeroed();
|
||||
action.sa_sigaction = handler as extern "C" fn(i32) as usize;
|
||||
action.sa_flags = libc::SA_RESTART;
|
||||
libc::sigemptyset(&mut action.sa_mask);
|
||||
libc::sigaction(stack_signal(), &action, std::ptr::null_mut());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn gettid() -> i32 {
|
||||
// SAFETY: gettid has no arguments and cannot fail.
|
||||
unsafe { libc::gettid() }
|
||||
}
|
||||
|
||||
/// Abort the FUSE connection of every mount below `dir`. Its connection is
|
||||
/// named by the mount's device minor, and the mount's owner may abort it.
|
||||
fn abort_mounts_below(dir: &Path) {
|
||||
let Ok(mountinfo) = std::fs::read_to_string("/proc/self/mountinfo") else {
|
||||
return;
|
||||
};
|
||||
for line in mountinfo.lines() {
|
||||
let fields: Vec<&str> = line.split(' ').collect();
|
||||
let (Some(device), Some(mountpoint)) = (fields.get(2), fields.get(4)) else {
|
||||
continue;
|
||||
};
|
||||
if !Path::new(mountpoint).starts_with(dir) {
|
||||
continue;
|
||||
}
|
||||
if let Some((_, minor)) = device.split_once(':') {
|
||||
let _ = std::fs::write(format!("/sys/fs/fuse/connections/{minor}/abort"), "1");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::sim::daemon::scratch;
|
||||
|
||||
#[test]
|
||||
#[ignore = "signals every thread of the test process: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
||||
fn a_call_past_its_limit_is_reported_with_the_stacks() {
|
||||
let dir = scratch("watchdog");
|
||||
let mut watchdog = Watchdog::new(&dir);
|
||||
{
|
||||
let _watching = watchdog.arm(Duration::from_millis(100), "a nap");
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
}
|
||||
watchdog.stop();
|
||||
let hung = watchdog.fired().expect("the watchdog fired");
|
||||
assert!(
|
||||
hung.what.starts_with("a nap has not returned after"),
|
||||
"{hung:?}"
|
||||
);
|
||||
assert!(hung.stacks.contains("a_call_past_its_limit"), "{hung:?}");
|
||||
let kept = std::fs::read_to_string(dir.join("stacks.txt")).unwrap_or_default();
|
||||
assert_eq!(kept, hung.stacks);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_call_within_its_limit_is_not() {
|
||||
let dir = scratch("watchdog-quiet");
|
||||
let watchdog = Watchdog::new(&dir);
|
||||
drop(watchdog.arm(Duration::from_millis(100), "a quick call"));
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
assert_eq!(watchdog.fired(), None);
|
||||
drop(watchdog);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
+1707
-49
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,229 @@
|
||||
//! The thread that watches the daemon for the thing the daemon cannot report
|
||||
//! about itself: that it has stopped making progress.
|
||||
//!
|
||||
//! Three jobs, all cheap, all on one timer:
|
||||
//!
|
||||
//! 1. **Say so in the journal.** A worker that has held one job for minutes, a
|
||||
//! queue that is not draining, a control request that never returned — each
|
||||
//! gets a WARN naming it. The 2026-09-18 hang left nothing in the journal at
|
||||
//! all; this is what turns the next one into a one-line diagnosis.
|
||||
//! 2. **Ping the systemd watchdog**, but only after a real round trip over the
|
||||
//! control socket. Pinging unconditionally from a thread that does nothing
|
||||
//! else would keep the unit "healthy" through exactly the hang it is meant to
|
||||
//! catch: the probe is what makes the ping mean "a client can still talk to
|
||||
//! this daemon".
|
||||
//! 3. **Sample memory.** That hang peaked at 5.2 GiB resident, and nobody knew
|
||||
//! until it was over.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write as _};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use pdfs_core::control::Request as CtlRequest;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use super::Core;
|
||||
use super::systemd;
|
||||
|
||||
/// How often the supervisor looks. Fast enough that a stall is named while the
|
||||
/// user is still watching it, slow enough to cost nothing.
|
||||
const TICK: Duration = Duration::from_secs(15);
|
||||
|
||||
/// How long one job may hold a worker, or one control request may run, before it
|
||||
/// is called out. Generous: a large upload chunk or a cold folder listing on a
|
||||
/// slow link is minutes of legitimate work, and a false WARN every few minutes
|
||||
/// would train everyone to ignore the real one.
|
||||
const STALL_AFTER: Duration = Duration::from_secs(120);
|
||||
|
||||
/// How often resident size goes into the log.
|
||||
const RSS_EVERY: Duration = Duration::from_secs(300);
|
||||
|
||||
/// How long the liveness probe waits for the daemon to answer its own control
|
||||
/// socket. Well under the watchdog period, so a probe that times out still
|
||||
/// leaves time for the manager to hear nothing and act.
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Run until shutdown. Spawned once per mount, from [`super::mount::mount`].
|
||||
pub(crate) fn run(core: Core, control_socket: PathBuf) {
|
||||
let watchdog = systemd::watchdog_period();
|
||||
if let Some(period) = watchdog {
|
||||
info!(period_secs = period.as_secs(), "systemd watchdog enabled");
|
||||
}
|
||||
|
||||
let mut last_rss = Instant::now();
|
||||
let mut last_ping = Instant::now();
|
||||
// Per-lane completion counts from the previous tick, to tell a queue that is
|
||||
// being worked through from one that is not moving at all.
|
||||
let mut last_completed = (0_u64, 0_u64);
|
||||
|
||||
while core.shutdown.sleep(TICK) {
|
||||
let report = core.diagnostics();
|
||||
let stalled = report_stalls(&report, last_completed);
|
||||
last_completed = (report.meta_completed, report.transfer_completed);
|
||||
|
||||
if last_rss.elapsed() >= RSS_EVERY {
|
||||
info!(
|
||||
rss_bytes = report.rss_bytes,
|
||||
pending_ops = report.pending_ops,
|
||||
meta_queued = report.meta_queued,
|
||||
transfer_queued = report.transfer_queued,
|
||||
stalled,
|
||||
"daemon resource sample"
|
||||
);
|
||||
last_rss = Instant::now();
|
||||
}
|
||||
|
||||
// A stalled lane is deliberately *not* a reason to withhold the ping:
|
||||
// the mount may still be answering everything else, and a restart would
|
||||
// throw away in-flight uploads. The probe decides that; a stall only
|
||||
// gets said out loud.
|
||||
if let Some(period) = watchdog
|
||||
&& last_ping.elapsed() >= period
|
||||
{
|
||||
if probe(&control_socket) {
|
||||
systemd::watchdog_ping();
|
||||
} else {
|
||||
warn!(
|
||||
"daemon did not answer its own control socket; withholding the watchdog ping"
|
||||
);
|
||||
}
|
||||
last_ping = Instant::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// WARN about anything that has not moved for [`STALL_AFTER`]. Returns whether
|
||||
/// anything was reported, so the caller can weigh it.
|
||||
fn report_stalls(report: &pdfs_core::control::Diagnostics, last_completed: (u64, u64)) -> bool {
|
||||
let threshold = STALL_AFTER.as_secs();
|
||||
let mut stalled = false;
|
||||
|
||||
for worker in &report.workers {
|
||||
if worker.busy && worker.age_secs >= threshold {
|
||||
stalled = true;
|
||||
warn!(
|
||||
worker = %worker.name,
|
||||
job = %worker.label,
|
||||
age_secs = worker.age_secs,
|
||||
"a fuse worker has held the same job for a long time"
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(oldest) = report.inflight.first()
|
||||
&& oldest.age_secs >= threshold
|
||||
{
|
||||
stalled = true;
|
||||
warn!(
|
||||
request = %oldest.kind,
|
||||
age_secs = oldest.age_secs,
|
||||
in_flight = report.inflight.len(),
|
||||
"a control request has been running for a long time"
|
||||
);
|
||||
}
|
||||
// A backed-up lane is normal; a backed-up lane that finished nothing since
|
||||
// the last tick is not.
|
||||
if report.meta_queued > 0 && report.meta_completed == last_completed.0 {
|
||||
stalled = true;
|
||||
warn!(
|
||||
queued = report.meta_queued,
|
||||
"the metadata lane has not completed a job since the last check"
|
||||
);
|
||||
}
|
||||
if report.transfer_queued > 0 && report.transfer_completed == last_completed.1 {
|
||||
stalled = true;
|
||||
warn!(
|
||||
queued = report.transfer_queued,
|
||||
"the transfer lane has not completed a job since the last check"
|
||||
);
|
||||
}
|
||||
stalled
|
||||
}
|
||||
|
||||
/// One round trip over the control socket: connect, ask for the cheapest
|
||||
/// request there is, read the answer.
|
||||
///
|
||||
/// This is the liveness the watchdog ping stands for. It covers the accept loop,
|
||||
/// the handler pool and the reply path — everything a `pdfs` command or the GUI
|
||||
/// depends on — without touching the network or the database.
|
||||
fn probe(control_socket: &Path) -> bool {
|
||||
let Ok(stream) = UnixStream::connect(control_socket) else {
|
||||
return false;
|
||||
};
|
||||
if stream.set_read_timeout(Some(PROBE_TIMEOUT)).is_err()
|
||||
|| stream.set_write_timeout(Some(PROBE_TIMEOUT)).is_err()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let Ok(request) = serde_json::to_string(&CtlRequest::Diagnostics) else {
|
||||
return false;
|
||||
};
|
||||
let mut stream = stream;
|
||||
if stream.write_all(format!("{request}\n").as_bytes()).is_err() {
|
||||
return false;
|
||||
}
|
||||
let mut line = String::new();
|
||||
BufReader::new(stream).read_line(&mut line).is_ok() && !line.trim().is_empty()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pdfs_core::control::{Diagnostics, InflightRequest, WorkerState};
|
||||
|
||||
fn busy_worker(age_secs: u64) -> WorkerState {
|
||||
WorkerState {
|
||||
name: "pdfs-fuse-3".into(),
|
||||
busy: true,
|
||||
label: "read".into(),
|
||||
age_secs,
|
||||
}
|
||||
}
|
||||
|
||||
/// A worker doing normal work must not be reported: a WARN every tick is a
|
||||
/// WARN nobody reads.
|
||||
#[test]
|
||||
fn ordinary_work_is_not_a_stall() {
|
||||
let report = Diagnostics {
|
||||
workers: vec![busy_worker(5)],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!report_stalls(&report, (0, 0)));
|
||||
}
|
||||
|
||||
/// A worker on the same job past the threshold is the shape of the hang.
|
||||
#[test]
|
||||
fn a_long_held_job_is_a_stall() {
|
||||
let report = Diagnostics {
|
||||
workers: vec![busy_worker(STALL_AFTER.as_secs() + 1)],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(report_stalls(&report, (0, 0)));
|
||||
}
|
||||
|
||||
/// So is a control request that never returned.
|
||||
#[test]
|
||||
fn a_long_running_request_is_a_stall() {
|
||||
let report = Diagnostics {
|
||||
inflight: vec![InflightRequest {
|
||||
kind: "OpenFile".into(),
|
||||
age_secs: STALL_AFTER.as_secs() + 1,
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(report_stalls(&report, (0, 0)));
|
||||
}
|
||||
|
||||
/// A queue that is draining is busy, not stalled — that is what comparing
|
||||
/// the completion counts is for.
|
||||
#[test]
|
||||
fn a_draining_queue_is_not_a_stall() {
|
||||
let report = Diagnostics {
|
||||
transfer_queued: 12,
|
||||
transfer_completed: 40,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!report_stalls(&report, (0, 30)));
|
||||
assert!(report_stalls(&report, (0, 40)));
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use pdfs_core::batch;
|
||||
use pdfs_core::config::SweepMode;
|
||||
use pdfs_core::control::ActivityKind;
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
@@ -66,10 +67,16 @@ impl Core {
|
||||
/// Sweep for stale conflict copies forever, on its own thread. See the module
|
||||
/// docs for the identical/divergent policy.
|
||||
pub(crate) fn run_conflict_sweep_loop(&self) {
|
||||
std::thread::sleep(CONFLICT_SWEEP_WARMUP);
|
||||
// Both waits are interruptible, so teardown does not have to sit out a
|
||||
// five-minute interval to join this thread (`docs/BUGS.md` B44).
|
||||
if !self.shutdown.sleep(CONFLICT_SWEEP_WARMUP) {
|
||||
return;
|
||||
}
|
||||
loop {
|
||||
self.sweep_conflicts_once();
|
||||
std::thread::sleep(CONFLICT_SWEEP_INTERVAL);
|
||||
if !self.shutdown.sleep(CONFLICT_SWEEP_INTERVAL) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +89,11 @@ impl Core {
|
||||
return;
|
||||
}
|
||||
};
|
||||
let nodes: Vec<_> = stored.into_iter().map(|s| s.node).collect();
|
||||
let nodes: Vec<_> = stored
|
||||
.into_iter()
|
||||
.map(|stored| stored.node)
|
||||
.filter(|node| self.is_own_or_virtual(&node.uid))
|
||||
.collect();
|
||||
|
||||
// Index live (non-trashed) files by (parent, name) for sibling lookup.
|
||||
let mut by_parent_name: HashMap<(String, &str), &_> = HashMap::new();
|
||||
@@ -98,6 +109,7 @@ impl Core {
|
||||
by_parent_name.insert((parent, node.name.as_str()), node);
|
||||
}
|
||||
|
||||
let roots = self.search_roots();
|
||||
let online = self.online.load(std::sync::atomic::Ordering::Relaxed);
|
||||
for node in &nodes {
|
||||
if node.trashed || node.is_folder() {
|
||||
@@ -121,7 +133,16 @@ impl Core {
|
||||
self.remove_conflict_copy(node, &base);
|
||||
}
|
||||
}
|
||||
SweepAction::Flag(detail) => self.flag_conflict(&node.uid, &node.name, detail),
|
||||
// Only flag what the conflict listing can show. Anything else
|
||||
// would sit in the feed as "resolved" with no way to act on
|
||||
// it (`docs/BUGS.md` B99).
|
||||
SweepAction::Flag(detail) => {
|
||||
if self.conflict_path(&roots, &node.uid).is_some() {
|
||||
self.flag_conflict(&node.uid, &node.name, detail);
|
||||
} else {
|
||||
debug!(uid = %node.uid, name = %node.name, "conflict sweep: copy is outside every location; not flagged");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,9 +154,9 @@ impl Core {
|
||||
/// enumerated every node up front and has been doing network I/O since. Before
|
||||
/// touching anything remote this re-reads the node and refuses to act unless
|
||||
/// it is *still* the same revision, still untrashed, still unwritten and still
|
||||
/// unopened. Without that, a user edit landing inside the window is destroyed
|
||||
/// — `discard_queued_ops` below drops the queued write *and its staged blob*,
|
||||
/// which may hold the only copy of those bytes (`docs/BUGS.md` B71).
|
||||
/// unopened (`docs/BUGS.md` B71). Nothing on this path discards a queued write
|
||||
/// or its staged blob: a write that arrives after that check is left for the
|
||||
/// drain to resolve against the now-trashed node.
|
||||
fn remove_conflict_copy(&self, snapshot: &proton_drive_rs::Node, base: &str) {
|
||||
let uid = &snapshot.uid;
|
||||
let name = snapshot.name.as_str();
|
||||
@@ -144,7 +165,8 @@ impl Core {
|
||||
}
|
||||
match self
|
||||
.rt
|
||||
.block_on(self.client.trash_nodes(std::slice::from_ref(uid)))
|
||||
.block_on(self.drive.trash_nodes(std::slice::from_ref(uid)))
|
||||
.and_then(batch::into_unit)
|
||||
{
|
||||
Ok(()) => {}
|
||||
Err(e) if super::is_gone(&e) => {}
|
||||
@@ -153,14 +175,19 @@ impl Core {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Every mount, not just the primary: a conflict copy inside a sync
|
||||
// folder lives in that fork's inode space, and leaving it hooked up
|
||||
// there keeps a node we have just trashed remotely visible and
|
||||
// readable there (`docs/BUGS.md` B74).
|
||||
// Leaving a conflict copy hooked up in the tree keeps a node we have
|
||||
// just trashed remotely visible and readable in whichever mount shows
|
||||
// it (`docs/BUGS.md` B74).
|
||||
self.for_each_state(|st| {
|
||||
st.forget_or_unlink(uid);
|
||||
});
|
||||
self.discard_queued_ops(uid);
|
||||
// Deliberately *not* `discard_queued_ops`: `duplicate_still_removable`
|
||||
// proved the queue empty before the trash above, but that proof is stale
|
||||
// the moment `trash_nodes` goes to the network. A `close(2)` landing in
|
||||
// that window stages bytes, and discarding here would unlink the staged
|
||||
// blob holding the only copy of them. Leaving the op queued is the
|
||||
// non-destructive outcome: the drain re-reads the node, sees it trashed,
|
||||
// and `revision_conflict` routes it to `keep_as_conflict_copy`.
|
||||
self.cache.evict(uid);
|
||||
self.evict_reader(uid);
|
||||
if let Err(e) = self.db.delete_node(uid) {
|
||||
@@ -185,8 +212,8 @@ impl Core {
|
||||
let uid = &snapshot.uid;
|
||||
let name = snapshot.name.as_str();
|
||||
|
||||
// A queued op means bytes are still owed an upload. `discard_queued_ops`
|
||||
// would throw those away along with the staged blob holding them.
|
||||
// A queued op means bytes are still owed an upload; trashing the node
|
||||
// under them turns an ordinary upload into a conflict fork.
|
||||
match self.db.has_any_op(&uid.to_string()) {
|
||||
Ok(false) => {}
|
||||
Ok(true) => {
|
||||
@@ -243,7 +270,7 @@ impl Core {
|
||||
/// is tracked in that fork's `State` — so consulting `self.state` alone
|
||||
/// answered "idle" for every such file and let the sweep delete one with a
|
||||
/// writer attached (`docs/BUGS.md` B74). Busy anywhere means busy.
|
||||
fn is_busy(&self, uid: &NodeUid) -> bool {
|
||||
pub(crate) fn is_busy(&self, uid: &NodeUid) -> bool {
|
||||
let mut busy = false;
|
||||
self.for_each_state(|st| {
|
||||
let Some(&ino) = st.by_uid.get(uid) else {
|
||||
@@ -255,12 +282,21 @@ impl Core {
|
||||
busy
|
||||
}
|
||||
|
||||
/// Surface a conflict copy that needs the user's attention, at most once per
|
||||
/// daemon run so the activity feed is not spammed each pass.
|
||||
/// Surface a conflict copy that needs the user's attention, once. The
|
||||
/// in-memory set spares the database each pass; the activity log itself is
|
||||
/// what keeps a restart from logging the same copy again (`docs/BUGS.md`
|
||||
/// B99). A copy's name carries its own timestamp, so the name identifies it.
|
||||
fn flag_conflict(&self, uid: &NodeUid, name: &str, detail: String) {
|
||||
if !self.conflict_notified.lock().insert(uid.clone()) {
|
||||
return;
|
||||
}
|
||||
match self.db.activity_has(ActivityKind::Conflict, name) {
|
||||
Ok(false) => {}
|
||||
Ok(true) => return,
|
||||
// Cannot tell: a repeat entry is harmless, a missing one hides a
|
||||
// conflict.
|
||||
Err(e) => warn!(%uid, name, error = ?e, "conflict sweep: activity check failed"),
|
||||
}
|
||||
debug!(%uid, name, detail, "conflict sweep: flagged divergent conflict copy");
|
||||
self.log_activity(ActivityKind::Conflict, name, detail, false);
|
||||
}
|
||||
@@ -346,7 +382,12 @@ mod tests {
|
||||
is_shared: false,
|
||||
is_shared_publicly: false,
|
||||
signature_email: None,
|
||||
membership: None,
|
||||
photo: None,
|
||||
album: None,
|
||||
verification: Default::default(),
|
||||
direct_role: None,
|
||||
share_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1387
-141
File diff suppressed because it is too large.
Load diff
@@ -32,17 +32,24 @@ pub(crate) enum SyncMsg {
|
||||
ReconcileAll,
|
||||
/// Re-read the folder set and adjust the filesystem watches (after add/remove).
|
||||
Rewatch,
|
||||
/// The daemon is tearing down: finish the current pass and end the engine
|
||||
/// thread, so the mount can join it (`docs/BUGS.md` B44).
|
||||
Stop,
|
||||
}
|
||||
|
||||
/// Start the sync engine: a control thread that owns the filesystem watcher and
|
||||
/// serialises reconcile passes, plus a periodic poll thread. The engine receives
|
||||
/// on `rx`; senders live in [`Core::sync_tx`].
|
||||
pub(crate) fn spawn(core: Core, rx: Receiver<SyncMsg>) {
|
||||
if let Err(e) = std::thread::Builder::new()
|
||||
pub(crate) fn spawn(core: Core, rx: Receiver<SyncMsg>) -> Option<std::thread::JoinHandle<()>> {
|
||||
match std::thread::Builder::new()
|
||||
.name("pdfs-sync".into())
|
||||
.spawn(move || engine_loop(core, rx))
|
||||
{
|
||||
warn!(error = %e, "failed to start sync engine");
|
||||
Ok(handle) => Some(handle),
|
||||
Err(e) => {
|
||||
warn!(error = %e, "failed to start sync engine");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,9 +57,10 @@ pub(crate) fn spawn(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
|
||||
// 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 watched_events = watched.clone();
|
||||
let mut watcher =
|
||||
match notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
|
||||
let Ok(event) = res else { return };
|
||||
@@ -60,7 +68,8 @@ fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
|
||||
if matches!(event.kind, notify::EventKind::Access(_)) {
|
||||
return;
|
||||
}
|
||||
let _ = tx_events.send(SyncMsg::ReconcileAll);
|
||||
let msg = event_target(&watched_events.lock(), &event.paths);
|
||||
let _ = tx_events.send(msg);
|
||||
}) {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
@@ -74,11 +83,11 @@ fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
|
||||
// Periodic remote poll.
|
||||
{
|
||||
let tx = core.sync_tx.clone();
|
||||
let shutdown = core.shutdown.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("pdfs-sync-poll".into())
|
||||
.spawn(move || {
|
||||
loop {
|
||||
std::thread::sleep(POLL_INTERVAL);
|
||||
while shutdown.sleep(POLL_INTERVAL) {
|
||||
if tx.send(SyncMsg::ReconcileAll).is_err() {
|
||||
break;
|
||||
}
|
||||
@@ -94,8 +103,12 @@ fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
|
||||
let mut ids: HashSet<i64> = HashSet::new();
|
||||
let mut all = false;
|
||||
let mut do_rewatch = false;
|
||||
classify(msg, &mut ids, &mut all, &mut do_rewatch);
|
||||
settle(&rx, &mut ids, &mut all, &mut do_rewatch);
|
||||
if !classify(msg, &mut ids, &mut all, &mut do_rewatch)
|
||||
|| !settle(&rx, &mut ids, &mut all, &mut do_rewatch)
|
||||
{
|
||||
info!("sync engine stopping");
|
||||
return;
|
||||
}
|
||||
|
||||
if do_rewatch {
|
||||
rewatch(&core, &mut watcher, &watched);
|
||||
@@ -104,6 +117,9 @@ fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
|
||||
reconcile_all(&core);
|
||||
} else {
|
||||
for id in ids {
|
||||
if core.sync_paused() {
|
||||
break;
|
||||
}
|
||||
if let Ok(Some(folder)) = core.db.sync_folder_get(id) {
|
||||
core.reconcile_folder(&folder);
|
||||
}
|
||||
@@ -116,9 +132,9 @@ fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
|
||||
fn poll_only_loop(core: &Core, rx: Receiver<SyncMsg>) {
|
||||
{
|
||||
let tx = core.sync_tx.clone();
|
||||
let shutdown = core.shutdown.clone();
|
||||
std::thread::spawn(move || {
|
||||
loop {
|
||||
std::thread::sleep(POLL_INTERVAL);
|
||||
while shutdown.sleep(POLL_INTERVAL) {
|
||||
if tx.send(SyncMsg::ReconcileAll).is_err() {
|
||||
break;
|
||||
}
|
||||
@@ -130,12 +146,19 @@ fn poll_only_loop(core: &Core, rx: Receiver<SyncMsg>) {
|
||||
let mut ids: HashSet<i64> = HashSet::new();
|
||||
let mut all = false;
|
||||
let mut do_rewatch = false;
|
||||
classify(msg, &mut ids, &mut all, &mut do_rewatch);
|
||||
settle(&rx, &mut ids, &mut all, &mut do_rewatch);
|
||||
if !classify(msg, &mut ids, &mut all, &mut do_rewatch)
|
||||
|| !settle(&rx, &mut ids, &mut all, &mut do_rewatch)
|
||||
{
|
||||
info!("sync engine stopping");
|
||||
return;
|
||||
}
|
||||
if all {
|
||||
reconcile_all(core);
|
||||
} else {
|
||||
for id in ids {
|
||||
if core.sync_paused() {
|
||||
break;
|
||||
}
|
||||
if let Ok(Some(folder)) = core.db.sync_folder_get(id) {
|
||||
core.reconcile_folder(&folder);
|
||||
}
|
||||
@@ -158,7 +181,7 @@ pub(super) fn settle_with(
|
||||
do_rewatch: &mut bool,
|
||||
quiet: Duration,
|
||||
cap: Duration,
|
||||
) {
|
||||
) -> bool {
|
||||
let deadline = Instant::now() + cap;
|
||||
loop {
|
||||
let now = Instant::now();
|
||||
@@ -170,32 +193,59 @@ pub(super) fn settle_with(
|
||||
match rx.recv_timeout(wait) {
|
||||
// Another event: the burst is still going, so the quiet window
|
||||
// starts again from here.
|
||||
Ok(m) => classify(m, ids, all, do_rewatch),
|
||||
// A stop lands mid-burst: give up the rest of the burst rather
|
||||
// than settling for it, and let the caller end the loop.
|
||||
Ok(m) => {
|
||||
if !classify(m, ids, all, do_rewatch) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Quiet for a full window: whatever was being written has stopped.
|
||||
Err(RecvTimeoutError::Timeout) => break,
|
||||
// Every sender is gone; the caller's loop is about to end anyway.
|
||||
Err(RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// [`settle_with`] at the real timings.
|
||||
fn settle(rx: &Receiver<SyncMsg>, ids: &mut HashSet<i64>, all: &mut bool, do_rewatch: &mut bool) {
|
||||
settle_with(rx, ids, all, do_rewatch, DEBOUNCE, MAX_SETTLE);
|
||||
fn settle(
|
||||
rx: &Receiver<SyncMsg>,
|
||||
ids: &mut HashSet<i64>,
|
||||
all: &mut bool,
|
||||
do_rewatch: &mut bool,
|
||||
) -> bool {
|
||||
settle_with(rx, ids, all, do_rewatch, DEBOUNCE, MAX_SETTLE)
|
||||
}
|
||||
|
||||
pub(super) fn classify(msg: SyncMsg, ids: &mut HashSet<i64>, all: &mut bool, rewatch: &mut bool) {
|
||||
/// Fold one message into the pending work of the current burst. Returns `false`
|
||||
/// for [`SyncMsg::Stop`], which is not work but an instruction to end the loop.
|
||||
pub(super) fn classify(
|
||||
msg: SyncMsg,
|
||||
ids: &mut HashSet<i64>,
|
||||
all: &mut bool,
|
||||
rewatch: &mut bool,
|
||||
) -> bool {
|
||||
match msg {
|
||||
SyncMsg::Reconcile(id) => {
|
||||
ids.insert(id);
|
||||
}
|
||||
SyncMsg::ReconcileAll => *all = true,
|
||||
SyncMsg::Rewatch => *rewatch = true,
|
||||
SyncMsg::Stop => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Reconcile every mirror folder in turn.
|
||||
/// Reconcile every mirror folder in turn. Skipped while sync is paused: the
|
||||
/// resume sends a fresh [`SyncMsg::ReconcileAll`], which walks everything the
|
||||
/// watcher reported meanwhile.
|
||||
fn reconcile_all(core: &Core) {
|
||||
if core.sync_paused() {
|
||||
debug!("sync: paused, skipping reconcile");
|
||||
return;
|
||||
}
|
||||
let folders = match core.db.sync_folder_list() {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
@@ -208,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:
|
||||
/// 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(
|
||||
core: &Core,
|
||||
watcher: &mut notify::RecommendedWatcher,
|
||||
@@ -226,7 +299,7 @@ fn rewatch(
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut have = watched.lock();
|
||||
let mut have = watched.lock().clone();
|
||||
// Drop watches no longer wanted.
|
||||
have.retain(|(path, _)| {
|
||||
if want.iter().any(|(p, _)| p == path) {
|
||||
@@ -246,6 +319,7 @@ fn rewatch(
|
||||
Err(e) => warn!(path = %path.display(), error = %e, "sync: watch failed"),
|
||||
}
|
||||
}
|
||||
*watched.lock() = have;
|
||||
}
|
||||
|
||||
// ---- reconcile ------------------------------------------------------------
|
||||
@@ -2,9 +2,73 @@
|
||||
|
||||
use super::*;
|
||||
|
||||
/// What a reconcile pass remembers about the local side of one path: enough to
|
||||
/// recognise the same content again, and no more.
|
||||
///
|
||||
/// `mtime_ns` is the whole reason this is a struct rather than a pair — see
|
||||
/// [`StoredSyncEntry::local_mtime_ns`](pdfs_core::db::StoredSyncEntry).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) struct LocalSig {
|
||||
pub(super) mtime: i64,
|
||||
pub(super) mtime_ns: Option<i64>,
|
||||
pub(super) size: i64,
|
||||
}
|
||||
|
||||
impl LocalSig {
|
||||
/// Whether these two observations describe the same local content.
|
||||
///
|
||||
/// Nanoseconds decide it when both sides have them; otherwise the comparison
|
||||
/// falls back to whole seconds, which is what a baseline row written before
|
||||
/// schema 28 — or a filesystem that reports no sub-second time — can support
|
||||
/// (`docs/BUGS.md` B25).
|
||||
pub(super) fn same_content(&self, other: &Self) -> bool {
|
||||
if self.size != other.size {
|
||||
return false;
|
||||
}
|
||||
match (self.mtime_ns, other.mtime_ns) {
|
||||
(Some(a), Some(b)) => a == b,
|
||||
_ => self.mtime == other.mtime,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&LocalItem> for LocalSig {
|
||||
fn from(item: &LocalItem) -> Self {
|
||||
Self {
|
||||
mtime: item.mtime,
|
||||
mtime_ns: item.mtime_ns,
|
||||
size: item.size,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&std::fs::Metadata> for LocalSig {
|
||||
fn from(meta: &std::fs::Metadata) -> Self {
|
||||
Self {
|
||||
mtime: system_mtime(meta),
|
||||
mtime_ns: system_mtime_ns(meta),
|
||||
size: meta.len() as i64,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The baseline's record of the local side, as [`LocalSig`] compares it.
|
||||
impl From<&StoredSyncEntry> for LocalSig {
|
||||
fn from(entry: &StoredSyncEntry) -> Self {
|
||||
Self {
|
||||
mtime: entry.local_mtime,
|
||||
mtime_ns: entry.local_mtime_ns,
|
||||
size: entry.local_size,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct LocalItem {
|
||||
pub(super) is_dir: bool,
|
||||
pub(super) mtime: i64,
|
||||
/// [`LocalItem::mtime`] to nanosecond precision, when the filesystem
|
||||
/// reports one.
|
||||
pub(super) mtime_ns: Option<i64>,
|
||||
pub(super) size: i64,
|
||||
/// True when another process holds this file open for writing (detected via
|
||||
/// `/proc/*/fd`). An open-for-write file is kept in the map — so it is not
|
||||
@@ -34,6 +98,11 @@ pub(super) struct Outcome {
|
||||
pub(super) errors: usize,
|
||||
/// Files skipped because another process held them open for writing.
|
||||
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 {
|
||||
@@ -44,6 +113,7 @@ impl Outcome {
|
||||
Applied::Uploaded => self.uploaded += 1,
|
||||
Applied::Downloaded => self.downloaded += 1,
|
||||
Applied::Conflict => self.conflicts += 1,
|
||||
Applied::Vanished => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,11 +161,18 @@ pub(super) enum Pending {
|
||||
UploadRevision { rel: String, uid: NodeUid },
|
||||
/// Download remote `uid` to the local path, stamping `mtime`. `size` is the
|
||||
/// remote's reported size, used as the transfer's expected total.
|
||||
///
|
||||
/// `local` is what the planning walk saw at this path, or `None` if nothing
|
||||
/// was there. A download can run for minutes, so the apply step revalidates
|
||||
/// against it immediately before it renames over the destination: a writer
|
||||
/// that finished an edit in the meantime must not be overwritten without a
|
||||
/// conflict copy (`docs/BUGS.md` B39).
|
||||
Download {
|
||||
rel: String,
|
||||
uid: NodeUid,
|
||||
mtime: i64,
|
||||
size: i64,
|
||||
local: Option<LocalSig>,
|
||||
},
|
||||
/// Both sides changed: set the local copy aside, then download remote `uid`.
|
||||
Conflict {
|
||||
@@ -155,6 +232,10 @@ pub(super) enum PassAbort {
|
||||
Interrupted,
|
||||
/// The pass could not establish its diff (a walk or the baseline load failed).
|
||||
Failed(String),
|
||||
/// The daemon is stopping. What the pass sent before the stop has landed or
|
||||
/// will be cancelled with the runtime; nothing more is sent, and the next
|
||||
/// start's pass picks up from the baseline.
|
||||
Stopped,
|
||||
}
|
||||
|
||||
impl From<String> for PassAbort {
|
||||
|
||||
@@ -90,6 +90,96 @@ pub(super) fn filter_baseline<'a, B>(
|
||||
.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.
|
||||
///
|
||||
/// When every baseline path is absent locally, the likely cause is an unavailable
|
||||
@@ -218,7 +308,10 @@ pub(super) fn plan_file(
|
||||
}
|
||||
|
||||
let local_changed = local.is_some_and(|item| {
|
||||
baseline.is_none_or(|base| base.local_mtime != item.mtime || base.local_size != item.size)
|
||||
// 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
|
||||
// left the file the same length, so it was never uploaded.
|
||||
baseline.is_none_or(|base| !LocalSig::from(item).same_content(&LocalSig::from(base)))
|
||||
});
|
||||
let remote_changed = remote.is_some_and(|item| {
|
||||
baseline.is_none_or(|base| remote_sig(base) != Some((item.mtime, item.size)))
|
||||
@@ -252,6 +345,7 @@ mod file_plan_tests {
|
||||
|
||||
fn local(mtime: i64, size: i64) -> LocalItem {
|
||||
LocalItem {
|
||||
mtime_ns: None,
|
||||
is_dir: false,
|
||||
mtime,
|
||||
size,
|
||||
@@ -278,12 +372,49 @@ mod file_plan_tests {
|
||||
rel_path: "file".into(),
|
||||
remote_uid: Some("v~l".into()),
|
||||
local_mtime,
|
||||
local_mtime_ns: None,
|
||||
local_size,
|
||||
remote_rev: Some(remote_mtime.to_string()),
|
||||
remote_hash: Some(remote_size.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whole seconds cannot see an edit made inside the same second that leaves the
|
||||
/// file the same length, so a mirror silently stopped uploading it. With a
|
||||
/// nanosecond time on both sides it is an ordinary local change (`docs/BUGS.md` B25).
|
||||
#[test]
|
||||
fn a_same_second_same_size_edit_is_still_a_local_change() {
|
||||
let mut base = baseline(10, 20, 30, 40);
|
||||
base.local_mtime_ns = Some(10_000_000_000);
|
||||
let edited = LocalItem {
|
||||
mtime_ns: Some(10_400_000_000),
|
||||
..local(10, 20)
|
||||
};
|
||||
let remote = remote(30, 40);
|
||||
assert_eq!(
|
||||
plan_file(Some(&edited), Some(&remote), Some(&base)),
|
||||
FilePlan::UploadRevision
|
||||
);
|
||||
|
||||
// The same file untouched still reads as unchanged.
|
||||
let same = LocalItem {
|
||||
mtime_ns: Some(10_000_000_000),
|
||||
..local(10, 20)
|
||||
};
|
||||
assert_eq!(
|
||||
plan_file(Some(&same), Some(&remote), Some(&base)),
|
||||
FilePlan::Unchanged
|
||||
);
|
||||
|
||||
// And a row from before the column exists compares on seconds, exactly
|
||||
// as it did when it was written — no mass re-upload on upgrade.
|
||||
let old = baseline(10, 20, 30, 40);
|
||||
assert_eq!(
|
||||
plan_file(Some(&edited), Some(&remote), Some(&old)),
|
||||
FilePlan::Unchanged
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_plan_covers_every_presence_and_change_shape() {
|
||||
let same_local = local(10, 20);
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
//! The `sd_notify` half of the service contract, without linking libsystemd.
|
||||
//!
|
||||
//! The protocol is one datagram of `KEY=VALUE` lines to the socket in
|
||||
//! `$NOTIFY_SOCKET`. That is little enough to write directly, and writing it
|
||||
//! directly keeps the daemon buildable on a machine without systemd headers —
|
||||
//! everything here degrades to "no socket, do nothing" off systemd.
|
||||
|
||||
use std::os::unix::net::UnixDatagram;
|
||||
use std::time::Duration;
|
||||
|
||||
use tracing::debug;
|
||||
|
||||
/// Send one notification. `false` when there is no manager to send it to, or the
|
||||
/// send failed — a caller has nothing useful to do about either.
|
||||
pub(crate) fn notify(message: &str) -> bool {
|
||||
let Ok(socket_path) = std::env::var("NOTIFY_SOCKET") else {
|
||||
return false;
|
||||
};
|
||||
if socket_path.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let Ok(socket) = UnixDatagram::unbound() else {
|
||||
return false;
|
||||
};
|
||||
if let Some(name) = socket_path.strip_prefix('@') {
|
||||
// Abstract namespace: the address is a NUL byte followed by the name,
|
||||
// which `std`'s path-based API cannot express.
|
||||
send_abstract(&socket, name, message)
|
||||
} else {
|
||||
socket.send_to(message.as_bytes(), &socket_path).is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// `sendto` on an abstract-namespace address, which needs a hand-built
|
||||
/// `sockaddr_un` because the first byte of the name is NUL.
|
||||
fn send_abstract(socket: &UnixDatagram, name: &str, message: &str) -> bool {
|
||||
use std::os::fd::AsRawFd;
|
||||
|
||||
let mut addr: libc::sockaddr_un = unsafe { std::mem::zeroed() };
|
||||
addr.sun_family = libc::AF_UNIX as libc::sa_family_t;
|
||||
let bytes = name.as_bytes();
|
||||
// One byte for the leading NUL, and the path must still be addressable.
|
||||
if bytes.len() + 1 > addr.sun_path.len() {
|
||||
return false;
|
||||
}
|
||||
for (slot, byte) in addr.sun_path.iter_mut().skip(1).zip(bytes) {
|
||||
*slot = *byte as libc::c_char;
|
||||
}
|
||||
let len = std::mem::size_of::<libc::sa_family_t>() + 1 + bytes.len();
|
||||
let sent = unsafe {
|
||||
libc::sendto(
|
||||
socket.as_raw_fd(),
|
||||
message.as_ptr().cast(),
|
||||
message.len(),
|
||||
0,
|
||||
std::ptr::from_ref(&addr).cast(),
|
||||
len as libc::socklen_t,
|
||||
)
|
||||
};
|
||||
sent >= 0
|
||||
}
|
||||
|
||||
/// Tell the manager the daemon is up and serving. Called once the mount is live,
|
||||
/// so `systemctl --user start` returns when the mount actually works rather than
|
||||
/// when the process exists.
|
||||
pub(crate) fn ready() {
|
||||
if notify("READY=1\n") {
|
||||
debug!("notified systemd: ready");
|
||||
}
|
||||
}
|
||||
|
||||
/// Tell the manager the daemon is still healthy.
|
||||
pub(crate) fn watchdog_ping() {
|
||||
let _ = notify("WATCHDOG=1\n");
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// for a hang by the watchdog it is racing.
|
||||
pub(crate) fn stopping() {
|
||||
let _ = notify("STOPPING=1\n");
|
||||
}
|
||||
|
||||
/// How often the manager expects a ping, from `$WATCHDOG_USEC`, or `None` when
|
||||
/// the watchdog is off or belongs to another process.
|
||||
///
|
||||
/// Reports *half* the configured interval, the interval systemd documents for
|
||||
/// pinging: a ping period equal to the deadline would restart the daemon on the
|
||||
/// first late scheduling.
|
||||
pub(crate) fn watchdog_period() -> Option<Duration> {
|
||||
// `WATCHDOG_PID` is set when the manager wants a specific process to answer;
|
||||
// in that case a ping from anyone else is not the one it is waiting for.
|
||||
if let Ok(pid) = std::env::var("WATCHDOG_PID")
|
||||
&& pid.parse::<u32>().ok() != Some(std::process::id())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let usec: u64 = std::env::var("WATCHDOG_USEC").ok()?.parse().ok()?;
|
||||
(usec > 0).then(|| Duration::from_micros(usec / 2))
|
||||
}
|
||||
@@ -0,0 +1,632 @@
|
||||
//! Importing a Google Photos Takeout export into Proton Photos.
|
||||
//!
|
||||
//! [`pdfs_core::takeout`] says what is in the archives; this is the part that
|
||||
//! talks to Proton. One import runs on its own thread and moves through four
|
||||
//! phases, reporting into the transfer registry as a job so the GUI's transfer
|
||||
//! list shows it without a bespoke widget:
|
||||
//!
|
||||
//! 1. **Scan** — read the archives' central directories and sidecars.
|
||||
//! 2. **Deduplicate against the account** — ask the photos volume which of the
|
||||
//! names already exist ([`ProtonPhotosClient::name_collisions`]), hash only
|
||||
//! those files, then confirm with the content digest. A library that is
|
||||
//! already half-synced by Proton Photos on a phone therefore costs one cheap
|
||||
//! request per 150 names and reads no media at all for the misses.
|
||||
//! 3. **Upload** the survivors, streaming each entry straight out of its zip —
|
||||
//! no extraction to disk, so the import needs no scratch space.
|
||||
//! 4. **Albums** — create the albums the export names that the account lacks,
|
||||
//! then add every photo of each album, the ones that were already on the
|
||||
//! account included.
|
||||
//!
|
||||
//! Re-running an interrupted import is the resume story: phase 2 finds
|
||||
//! everything the previous run uploaded and skips it. Nothing is persisted
|
||||
//! between runs.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::io::Read;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use pdfs_core::control::{ActivityKind, ImportSummary, TransferDirection};
|
||||
use pdfs_core::takeout::{Bucket, TakeoutPhoto, TakeoutScan, scan_archives};
|
||||
use pdfs_core::{CoreError, CoreResult};
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
use proton_drive_rs::{PhotoTag, PhotoTagsUpdate, PhotoUploadMetadata};
|
||||
use sha1::{Digest, Sha1};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use super::Core;
|
||||
use crate::transfers::CountingReader;
|
||||
|
||||
/// The one import this daemon may be running, and how the last one ended.
|
||||
///
|
||||
/// Process-global rather than a [`Core`] field because it must be *one* import
|
||||
/// per daemon however many `Core` clones exist (the forked on-demand mounts each
|
||||
/// hold one), and because a front-end asking for the result after the import
|
||||
/// finished must still find it.
|
||||
static IMPORT: std::sync::LazyLock<parking_lot::Mutex<ImportState>> =
|
||||
std::sync::LazyLock::new(|| parking_lot::Mutex::new(ImportState::default()));
|
||||
|
||||
#[derive(Default)]
|
||||
struct ImportState {
|
||||
running: bool,
|
||||
cancel: Option<Arc<AtomicBool>>,
|
||||
last: Option<ImportSummary>,
|
||||
}
|
||||
|
||||
/// Whether an import is running, and the last one's report.
|
||||
pub(crate) fn import_status() -> (bool, Option<ImportSummary>) {
|
||||
let state = IMPORT.lock();
|
||||
(state.running, state.last.clone())
|
||||
}
|
||||
|
||||
/// Ask the running import to stop. False when none is running.
|
||||
pub(crate) fn cancel_import() -> bool {
|
||||
match &IMPORT.lock().cancel {
|
||||
Some(flag) => {
|
||||
flag.store(true, Ordering::Relaxed);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// One photo to import, after the copies Google keeps per album are folded into
|
||||
/// a single upload that joins several albums.
|
||||
struct Planned {
|
||||
photo: TakeoutPhoto,
|
||||
/// Every album this photo belongs to, by export title.
|
||||
albums: Vec<String>,
|
||||
/// Filled in phase 2/3: where the photo ended up on the account.
|
||||
uid: Option<NodeUid>,
|
||||
}
|
||||
|
||||
/// Fold the scan into one entry per distinct photo.
|
||||
///
|
||||
/// Google stores a full copy of a photo in every album folder it appears in, so
|
||||
/// the same file is in the export two or three times over. They are folded on
|
||||
/// `(name, size)`: a byte-identical copy under the same name is the same photo,
|
||||
/// and the album memberships of the copies are unioned onto the survivor. Two
|
||||
/// genuinely different photos sharing both a name and an exact size would be
|
||||
/// merged — vanishingly unlikely, and the cost of being wrong is one photo
|
||||
/// filed under an extra album rather than lost data.
|
||||
fn plan(scan: &TakeoutScan) -> Vec<Planned> {
|
||||
let mut order: Vec<(String, u64)> = Vec::new();
|
||||
let mut by_key: HashMap<(String, u64), Planned> = HashMap::new();
|
||||
|
||||
for photo in &scan.photos {
|
||||
let key = (photo.name.clone(), photo.size);
|
||||
let entry = by_key.entry(key.clone()).or_insert_with(|| {
|
||||
order.push(key.clone());
|
||||
Planned {
|
||||
photo: photo.clone(),
|
||||
albums: Vec::new(),
|
||||
uid: None,
|
||||
}
|
||||
});
|
||||
if let Bucket::Album(title) = &photo.bucket
|
||||
&& !entry.albums.iter().any(|existing| existing == title)
|
||||
{
|
||||
entry.albums.push(title.clone());
|
||||
}
|
||||
// A copy that carries a capture time wins over one that does not: the
|
||||
// album copy and the year-folder copy do not always both have a sidecar.
|
||||
if entry.photo.capture_time.is_none() && photo.capture_time.is_some() {
|
||||
entry.photo.capture_time = photo.capture_time;
|
||||
}
|
||||
entry.photo.favorite |= photo.favorite;
|
||||
}
|
||||
|
||||
order
|
||||
.into_iter()
|
||||
.filter_map(|key| by_key.remove(&key))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Open one archive and stream `entry` through `sink`.
|
||||
fn read_entry<R>(
|
||||
archive: &mut zip::ZipArchive<std::io::BufReader<std::fs::File>>,
|
||||
entry: &str,
|
||||
sink: impl FnOnce(&mut (dyn Read + Send)) -> R,
|
||||
) -> CoreResult<R> {
|
||||
let mut file = archive
|
||||
.by_name(entry)
|
||||
.map_err(|e| CoreError::invalid(format!("{entry}: {e}")))?;
|
||||
Ok(sink(&mut file))
|
||||
}
|
||||
|
||||
impl Core {
|
||||
/// Import Google Photos Takeout archives. Blocking; run on its own thread.
|
||||
///
|
||||
/// `cancel` stops the run at the next photo boundary — an upload already on
|
||||
/// the wire is allowed to finish, so a cancelled import never leaves a
|
||||
/// half-written revision behind.
|
||||
pub(crate) fn import_takeout(
|
||||
&self,
|
||||
archives: Vec<PathBuf>,
|
||||
dry_run: bool,
|
||||
cancel: Arc<AtomicBool>,
|
||||
) -> CoreResult<ImportSummary> {
|
||||
let job = self.transfers.begin_job("Importing Google Photos");
|
||||
job.detail("reading archives");
|
||||
|
||||
let scan = scan_archives(&archives)?;
|
||||
let mut planned = plan(&scan);
|
||||
let mut summary = ImportSummary {
|
||||
found: planned.len(),
|
||||
skipped_trashed: scan.skipped_trashed,
|
||||
..Default::default()
|
||||
};
|
||||
job.set_total(planned.len() as u64);
|
||||
info!(
|
||||
archives = archives.len(),
|
||||
photos = summary.found,
|
||||
albums = scan.albums.len(),
|
||||
trashed = summary.skipped_trashed,
|
||||
"takeout scan complete"
|
||||
);
|
||||
|
||||
let photos = self.photos();
|
||||
if self
|
||||
.rt
|
||||
.block_on(photos.get_photos_root())
|
||||
.map_err(|e| CoreError::from_api(&e, "resolve photos library"))?
|
||||
.is_none()
|
||||
{
|
||||
return Err(CoreError::invalid(
|
||||
"this account has no Proton Photos library yet; open Proton Photos once to create it, then import again",
|
||||
));
|
||||
}
|
||||
|
||||
// Phase 2: which of these are already on the account.
|
||||
job.detail("checking for photos you already have");
|
||||
self.mark_existing(&mut planned, &archives, &mut summary, &cancel)?;
|
||||
|
||||
if dry_run {
|
||||
summary.duplicates = planned.iter().filter(|p| p.uid.is_some()).count();
|
||||
return Ok(summary);
|
||||
}
|
||||
|
||||
// Phase 3: upload what is left, one archive at a time so each zip's
|
||||
// central directory is parsed once.
|
||||
self.upload_planned(&mut planned, &archives, &mut summary, &cancel, &job)?;
|
||||
|
||||
// Phase 4: albums. Attempted even after a cancel — the photos that did
|
||||
// upload should still land in their albums.
|
||||
if !scan.albums.is_empty() {
|
||||
job.detail("sorting photos into albums");
|
||||
if let Err(e) = self.link_albums(&planned, &scan.albums, &mut summary) {
|
||||
warn!(error = %e, "takeout album linking failed");
|
||||
}
|
||||
}
|
||||
|
||||
self.spawn_timeline_refresh();
|
||||
summary.cancelled = cancel.load(Ordering::Relaxed);
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
/// Fill in `uid` for every planned photo the account already holds.
|
||||
///
|
||||
/// Two passes on purpose: the name-only query is one request per 150 photos
|
||||
/// and needs no file contents, so the expensive SHA-1 pass only runs for the
|
||||
/// handful of names that actually collide.
|
||||
fn mark_existing(
|
||||
&self,
|
||||
planned: &mut [Planned],
|
||||
archives: &[PathBuf],
|
||||
summary: &mut ImportSummary,
|
||||
cancel: &AtomicBool,
|
||||
) -> CoreResult<()> {
|
||||
let photos = self.photos();
|
||||
let names: Vec<String> = planned.iter().map(|p| p.photo.name.clone()).collect();
|
||||
let collisions = self
|
||||
.rt
|
||||
.block_on(photos.name_collisions(&names))
|
||||
.map_err(|e| CoreError::from_api(&e, "check for existing photos"))?;
|
||||
|
||||
// Hash the colliding files, grouped by archive so each zip opens once.
|
||||
let mut by_archive: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
|
||||
for (index, planned) in planned.iter().enumerate() {
|
||||
if collisions.get(index).copied().unwrap_or(false) {
|
||||
by_archive
|
||||
.entry(planned.photo.archive)
|
||||
.or_default()
|
||||
.push(index);
|
||||
}
|
||||
}
|
||||
|
||||
// Kept as a pair list, not a map: the duplicate lookup answers in input
|
||||
// order, so the order these were built in is what maps answers back to
|
||||
// planned photos.
|
||||
let mut digests: Vec<(usize, String)> = Vec::new();
|
||||
for (archive_index, indices) in by_archive {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
let mut archive = open_archive(archives, archive_index)?;
|
||||
for index in indices {
|
||||
let entry = planned[index].photo.entry.clone();
|
||||
match read_entry(&mut archive, &entry, sha1_of) {
|
||||
Ok(Ok(digest)) => digests.push((index, digest)),
|
||||
Ok(Err(e)) | Err(e) => {
|
||||
warn!(entry, error = %e, "cannot hash takeout entry");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if digests.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let items: Vec<(String, String)> = digests
|
||||
.iter()
|
||||
.map(|(index, digest)| (planned[*index].photo.name.clone(), digest.clone()))
|
||||
.collect();
|
||||
let matches = self
|
||||
.rt
|
||||
.block_on(photos.find_duplicates_many(&items))
|
||||
.map_err(|e| CoreError::from_api(&e, "check for existing photos"))?;
|
||||
|
||||
for (slot, (index, _)) in digests.iter().enumerate() {
|
||||
if let Some(uid) = matches.get(slot).and_then(|uids| uids.first()) {
|
||||
planned[*index].uid = Some(uid.clone());
|
||||
summary.duplicates += 1;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Upload every planned photo that is not already on the account.
|
||||
fn upload_planned(
|
||||
&self,
|
||||
planned: &mut [Planned],
|
||||
archives: &[PathBuf],
|
||||
summary: &mut ImportSummary,
|
||||
cancel: &AtomicBool,
|
||||
job: &crate::transfers::JobGuard,
|
||||
) -> CoreResult<()> {
|
||||
let photos = self.photos();
|
||||
let mut favorites: Vec<NodeUid> = Vec::new();
|
||||
|
||||
let mut by_archive: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
|
||||
for (index, item) in planned.iter().enumerate() {
|
||||
if item.uid.is_none() {
|
||||
by_archive
|
||||
.entry(item.photo.archive)
|
||||
.or_default()
|
||||
.push(index);
|
||||
}
|
||||
}
|
||||
|
||||
for (archive_index, indices) in by_archive {
|
||||
if cancel.load(Ordering::Relaxed) || self.shutdown.is_stopping() {
|
||||
break;
|
||||
}
|
||||
let mut archive = open_archive(archives, archive_index)?;
|
||||
|
||||
for index in indices {
|
||||
if cancel.load(Ordering::Relaxed) || self.shutdown.is_stopping() {
|
||||
break;
|
||||
}
|
||||
let photo = planned[index].photo.clone();
|
||||
job.detail(photo.name.clone());
|
||||
|
||||
let guard = self.transfers.begin(
|
||||
photo.name.clone(),
|
||||
"",
|
||||
TransferDirection::Upload,
|
||||
photo.size,
|
||||
);
|
||||
let metadata = PhotoUploadMetadata {
|
||||
capture_time: photo.capture_time,
|
||||
..Default::default()
|
||||
};
|
||||
let uploaded = read_entry(&mut archive, &photo.entry, |reader| {
|
||||
let counting = CountingReader::new(reader, &guard);
|
||||
self.rt.block_on(photos.upload_photo_from(
|
||||
&photo.name,
|
||||
&photo.media_type,
|
||||
counting,
|
||||
photo.size as i64,
|
||||
Vec::new(),
|
||||
metadata,
|
||||
false,
|
||||
))
|
||||
});
|
||||
drop(guard);
|
||||
job.step();
|
||||
|
||||
match uploaded {
|
||||
Ok(Ok(uid)) => {
|
||||
summary.uploaded += 1;
|
||||
summary.bytes += photo.size;
|
||||
if photo.favorite {
|
||||
favorites.push(uid.clone());
|
||||
}
|
||||
planned[index].uid = Some(uid);
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
summary.failed += 1;
|
||||
warn!(name = photo.name, error = %e, "takeout photo upload failed");
|
||||
}
|
||||
Err(e) => {
|
||||
summary.failed += 1;
|
||||
warn!(name = photo.name, error = %e, "cannot read takeout entry");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Favorites are a separate call: the upload seal takes classification
|
||||
// tags, but `Favorite` is not one of them — it goes through the photos
|
||||
// volume's dedicated favorite endpoint.
|
||||
if !favorites.is_empty() {
|
||||
let updates: Vec<PhotoTagsUpdate> = favorites
|
||||
.into_iter()
|
||||
.map(|node_uid| PhotoTagsUpdate {
|
||||
node_uid,
|
||||
tags_to_add: vec![PhotoTag::Favorite],
|
||||
tags_to_remove: Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
if let Err(e) = self.rt.block_on(photos.update_photos(&updates)) {
|
||||
warn!(error = %e, "marking imported favorites failed");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create the albums the export names that the account lacks, then add each
|
||||
/// album's photos to it.
|
||||
fn link_albums(
|
||||
&self,
|
||||
planned: &[Planned],
|
||||
albums: &[String],
|
||||
summary: &mut ImportSummary,
|
||||
) -> CoreResult<()> {
|
||||
let photos = self.photos();
|
||||
|
||||
// Existing albums by decrypted name, so a second import of the same
|
||||
// export adds to the album it made the first time instead of a twin.
|
||||
let mut existing: HashMap<String, NodeUid> = HashMap::new();
|
||||
let uids = self
|
||||
.rt
|
||||
.block_on(photos.enumerate_album_node_uids())
|
||||
.map_err(|e| CoreError::from_api(&e, "list albums"))?;
|
||||
if !uids.is_empty() {
|
||||
let albums_nodes = self
|
||||
.rt
|
||||
.block_on(photos.enumerate_nodes(&uids))
|
||||
.map_err(|e| CoreError::from_api(&e, "read albums"))?;
|
||||
for node in albums_nodes {
|
||||
existing.entry(node.name.clone()).or_insert(node.uid);
|
||||
}
|
||||
}
|
||||
|
||||
for title in albums {
|
||||
let members: Vec<NodeUid> = planned
|
||||
.iter()
|
||||
.filter(|item| item.albums.iter().any(|album| album == title))
|
||||
.filter_map(|item| item.uid.clone())
|
||||
.collect();
|
||||
if members.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let album_uid = match existing.get(title) {
|
||||
Some(uid) => uid.clone(),
|
||||
None => match self.rt.block_on(photos.create_album(title)) {
|
||||
Ok(uid) => {
|
||||
summary.albums_created += 1;
|
||||
existing.insert(title.clone(), uid.clone());
|
||||
uid
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(album = title, error = %e, "cannot create album");
|
||||
continue;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
match self
|
||||
.rt
|
||||
.block_on(photos.add_photos_to_album(&album_uid, &members))
|
||||
{
|
||||
Ok(outcomes) => {
|
||||
for (uid, outcome) in outcomes {
|
||||
match outcome {
|
||||
Ok(()) => summary.album_links += 1,
|
||||
// A photo already in the album is the expected
|
||||
// outcome of a re-run, not a failure worth counting.
|
||||
Err(e) => {
|
||||
warn!(album = title, %uid, error = %e, "cannot add photo to album")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => warn!(album = title, error = %e, "album addition failed"),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run an import on a background thread, logging its outcome to the activity
|
||||
/// feed. Returns immediately.
|
||||
///
|
||||
/// Refuses while one is already running: two imports of the same export
|
||||
/// would race each other's duplicate detection and upload everything twice.
|
||||
pub(crate) fn spawn_takeout_import(
|
||||
&self,
|
||||
archives: Vec<PathBuf>,
|
||||
dry_run: bool,
|
||||
) -> CoreResult<()> {
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
{
|
||||
let mut state = IMPORT.lock();
|
||||
if state.running {
|
||||
return Err(CoreError::conflict(
|
||||
"an import is already running; wait for it to finish or cancel it",
|
||||
));
|
||||
}
|
||||
state.running = true;
|
||||
state.cancel = Some(cancel.clone());
|
||||
state.last = None;
|
||||
}
|
||||
|
||||
let core = self.clone();
|
||||
std::thread::spawn(move || {
|
||||
let outcome = core.import_takeout(archives, dry_run, cancel);
|
||||
{
|
||||
let mut state = IMPORT.lock();
|
||||
state.running = false;
|
||||
state.cancel = None;
|
||||
state.last = outcome.as_ref().ok().cloned();
|
||||
}
|
||||
match outcome {
|
||||
Ok(summary) => {
|
||||
let mut parts = vec![format!("{} uploaded", summary.uploaded)];
|
||||
if summary.duplicates > 0 {
|
||||
parts.push(format!("{} already there", summary.duplicates));
|
||||
}
|
||||
if summary.albums_created > 0 {
|
||||
parts.push(format!("{} albums", summary.albums_created));
|
||||
}
|
||||
if summary.failed > 0 {
|
||||
parts.push(format!("{} failed", summary.failed));
|
||||
}
|
||||
if summary.cancelled {
|
||||
parts.push("cancelled".to_string());
|
||||
}
|
||||
core.log_activity(
|
||||
ActivityKind::Upload,
|
||||
"Google Photos import",
|
||||
parts.join(" · "),
|
||||
summary.failed == 0 && !summary.cancelled,
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = %e, "takeout import failed");
|
||||
core.log_activity(ActivityKind::Upload, "Google Photos import", &e, false);
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Open one of the import's archives by index.
|
||||
fn open_archive(
|
||||
archives: &[PathBuf],
|
||||
index: usize,
|
||||
) -> CoreResult<zip::ZipArchive<std::io::BufReader<std::fs::File>>> {
|
||||
let path = archives
|
||||
.get(index)
|
||||
.ok_or_else(|| CoreError::internal(format!("archive {index} is not in the import")))?;
|
||||
let file = std::fs::File::open(path)
|
||||
.map_err(|e| CoreError::invalid(format!("cannot open {}: {e}", path.display())))?;
|
||||
zip::ZipArchive::new(std::io::BufReader::new(file))
|
||||
.map_err(|e| CoreError::invalid(format!("{} is not a readable zip: {e}", path.display())))
|
||||
}
|
||||
|
||||
/// The lowercase-hex SHA-1 of everything `reader` yields — the digest Proton's
|
||||
/// photo duplicate detection is keyed on.
|
||||
pub(crate) fn sha1_of(reader: &mut (dyn Read + Send)) -> CoreResult<String> {
|
||||
let mut hasher = Sha1::new();
|
||||
let mut buffer = vec![0u8; 256 * 1024];
|
||||
loop {
|
||||
let read = reader
|
||||
.read(&mut buffer)
|
||||
.map_err(|e| CoreError::invalid(format!("read failed: {e}")))?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buffer[..read]);
|
||||
}
|
||||
Ok(hex_lower(&hasher.finalize()))
|
||||
}
|
||||
|
||||
fn hex_lower(bytes: &[u8]) -> String {
|
||||
let mut out = String::with_capacity(bytes.len() * 2);
|
||||
for byte in bytes {
|
||||
out.push_str(&format!("{byte:02x}"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pdfs_core::takeout::TakeoutPhoto;
|
||||
|
||||
fn photo(name: &str, size: u64, bucket: Bucket) -> TakeoutPhoto {
|
||||
TakeoutPhoto {
|
||||
archive: 0,
|
||||
entry: format!("Takeout/Google Photos/x/{name}"),
|
||||
name: name.to_string(),
|
||||
size,
|
||||
media_type: "image/jpeg".to_string(),
|
||||
capture_time: None,
|
||||
favorite: false,
|
||||
bucket,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn album_copies_of_one_photo_fold_into_a_single_upload() {
|
||||
let scan = TakeoutScan {
|
||||
photos: vec![
|
||||
photo("IMG_1.jpg", 100, Bucket::Timeline),
|
||||
photo("IMG_1.jpg", 100, Bucket::Album("Iceland".into())),
|
||||
photo("IMG_1.jpg", 100, Bucket::Album("Best of".into())),
|
||||
photo("IMG_2.jpg", 200, Bucket::Timeline),
|
||||
],
|
||||
albums: vec!["Iceland".into(), "Best of".into()],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let planned = plan(&scan);
|
||||
assert_eq!(planned.len(), 2, "the three copies are one upload");
|
||||
assert_eq!(planned[0].albums, vec!["Iceland", "Best of"]);
|
||||
assert!(planned[1].albums.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_name_different_size_stays_two_photos() {
|
||||
let scan = TakeoutScan {
|
||||
photos: vec![
|
||||
photo("IMG_1.jpg", 100, Bucket::Timeline),
|
||||
photo("IMG_1.jpg", 101, Bucket::Album("Iceland".into())),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(plan(&scan).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_from_any_copy_survives_the_fold() {
|
||||
// Only the album copy carries a sidecar; the fold must not lose it.
|
||||
let mut bare = photo("IMG_1.jpg", 100, Bucket::Timeline);
|
||||
bare.capture_time = None;
|
||||
let mut rich = photo("IMG_1.jpg", 100, Bucket::Album("Iceland".into()));
|
||||
rich.capture_time = Some(1_560_000_000);
|
||||
rich.favorite = true;
|
||||
|
||||
let planned = plan(&TakeoutScan {
|
||||
photos: vec![bare, rich],
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
assert_eq!(planned[0].photo.capture_time, Some(1_560_000_000));
|
||||
assert!(planned[0].photo.favorite);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sha1_matches_the_known_digest_of_abc() {
|
||||
let mut reader: &[u8] = b"abc";
|
||||
assert_eq!(
|
||||
sha1_of(&mut reader).unwrap(),
|
||||
"a9993e364706816aba3e25717850c26c9cd0d89d"
|
||||
);
|
||||
}
|
||||
}
|
||||
Loaded 100 of 247 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user