mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b46abfc07c | ||
|
|
fd78f088ec | ||
|
|
005f7ded1f | ||
|
|
3e4886f965 | ||
|
|
aac53e3139 | ||
|
|
c2bf87596f | ||
|
|
69d4feaa07 | ||
|
|
d481dcc15c | ||
|
|
e0442c56b9 | ||
|
|
ee76f4ac60 | ||
|
|
f6c651b06b | ||
|
|
8b7d7ae87f | ||
|
|
160626b29d | ||
|
|
f1f1f8ad4d | ||
|
|
bb28a18f49 | ||
|
|
a3c0c9ee9b | ||
|
|
91fa1614ed | ||
|
|
87361a4573 | ||
|
|
6d0b6ad553 | ||
|
|
170c3f01ff | ||
|
|
976ad2aa04 | ||
|
|
b73cd087aa | ||
|
|
e402981708 | ||
|
|
453c439b74 | ||
|
|
ceba0f5849 | ||
|
|
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 | ||
|
|
32817870a5 | ||
|
|
b93e41efc5 | ||
|
|
082ef8522a | ||
|
|
2e95655244 | ||
|
|
942cb9f583 | ||
|
|
8470662c54 | ||
|
|
a091c2f34f | ||
|
|
bca0fe34bc | ||
|
|
6df757006a | ||
|
|
6f70eca78e | ||
|
|
0d8a474442 | ||
|
|
8be0f04b61 | ||
|
|
d5165b36a2 | ||
|
|
a76f36bdff | ||
|
|
e9009dd47c | ||
|
|
d0b49d3c8c | ||
|
|
25f578161d | ||
|
|
ea56818a80 | ||
|
|
39ab4aae48 | ||
|
|
6cba77d3cb | ||
|
|
26375885ed | ||
|
|
544d0913e8 | ||
|
|
714b3048dc | ||
|
|
bef39f75cf | ||
|
|
0a1fa95eb4 | ||
|
|
242a28bb8f | ||
|
|
bd17d506f8 | ||
|
|
1cd6a26be3 | ||
|
|
fe75998cea | ||
|
|
9d55ffb9f6 | ||
|
|
2275004f79 | ||
|
|
914ff2b5ae | ||
|
|
01677f2670 | ||
|
|
3f9d36607a | ||
|
|
f1399aa661 | ||
|
|
a3d9d05777 | ||
|
|
bf625bf8d9 | ||
|
|
e0f0c7e0b7 | ||
|
|
a6a925667a | ||
|
|
e259016a00 | ||
|
|
471f57b8e1 | ||
|
|
3ade754ea2 | ||
|
|
661ae3cf7a | ||
|
|
f803bf3cd7 | ||
|
|
084740ad95 | ||
|
|
65eed0cc72 | ||
|
|
1e0e284d5f | ||
|
|
f0fbd0775c | ||
|
|
d25a573e8f | ||
|
|
eafe1307b8 | ||
|
|
056ae0463f | ||
|
|
b2b0fe7b99 | ||
|
|
d13042b550 | ||
|
|
cb67d97b0f | ||
|
|
0323fdc44d | ||
|
|
2b2a49c679 | ||
|
|
ed129f7c71 | ||
|
|
b39d09fb9d | ||
|
|
c8f57ed5c9 | ||
|
|
b90b7e4b9c | ||
|
|
cd437dff9c | ||
|
|
681db981f5 | ||
|
|
45bd48fa5f | ||
|
|
4a2643163b | ||
|
|
829f0ee643 | ||
|
|
532628456e | ||
|
|
f9250211b8 | ||
|
|
e8f11f7c42 | ||
|
|
717521195c | ||
|
|
190ea3e9cb | ||
|
|
1d5fdfb6b9 | ||
|
|
a90cf83ff5 | ||
|
|
7766628098 | ||
|
|
232b3b5348 | ||
|
|
d58dc46708 | ||
|
|
38aca3cd48 | ||
|
|
c555315998 | ||
|
|
81e7133003 | ||
|
|
a293593f4a | ||
|
|
1f8f1936a7 | ||
|
|
4720b2a5fb | ||
|
|
50c96cbbf4 | ||
|
|
dcc62ba420 | ||
|
|
fb39594453 | ||
|
|
68e07ed426 | ||
|
|
bb9804498c | ||
|
|
a38d0371ec | ||
|
|
cc744d56c7 | ||
|
|
ae0d374ec0 | ||
|
|
c52d829a43 | ||
|
|
30039c43d7 | ||
|
|
6c67226ab0 | ||
|
|
6f7269b9b6 | ||
|
|
dacacf51bb | ||
|
|
8f28f5fd22 | ||
|
|
924e07e85f | ||
|
|
e77ab0ffa9 | ||
|
|
d131d3dca8 | ||
|
|
5766bc4ed1 | ||
|
|
35bc957cd5 | ||
|
|
f9000f8540 | ||
|
|
d77d7ed817 | ||
|
|
56f2b35aca | ||
|
|
56abe3fe14 | ||
|
|
5e71148fec | ||
|
|
2812e0c893 | ||
|
|
03dd8d8496 | ||
|
|
5e151bbfc5 |
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
|
||||
@@ -0,0 +1,146 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
rust:
|
||||
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'
|
||||
components: rustfmt, clippy
|
||||
cache: false
|
||||
|
||||
# Tags can only restore caches saved on main, so only main saves one. The
|
||||
# release workflow restores this key for its quality gates.
|
||||
- uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: debug
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Formatting
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Lints
|
||||
run: cargo clippy --workspace --all-targets -- -D warnings
|
||||
|
||||
- name: Unit tests
|
||||
run: cargo test --workspace --locked
|
||||
|
||||
- name: Account-free filesystem acceptance
|
||||
run: scripts/fuse-acceptance.sh --offline-only
|
||||
|
||||
- name: Acceptance runner safety guard
|
||||
run: |
|
||||
if scripts/fuse-acceptance.sh /tmp >acceptance.out 2>&1; then
|
||||
echo "runner unexpectedly accepted a non-FUSE path"
|
||||
exit 1
|
||||
fi
|
||||
grep -q "refusing primary non-FUSE path" acceptance.out
|
||||
|
||||
# Real Proton credentials and /dev/fuse deliberately do not run on pull
|
||||
# requests. Follow docs/TESTING.md on a dedicated test-account runner.
|
||||
|
||||
# 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
|
||||
@@ -0,0 +1,59 @@
|
||||
name: Nix
|
||||
|
||||
# Builds the flake (docs/INSTALL.md#nixos-flake) whenever its recipe, the lock
|
||||
# files or the code change, so a NixOS user's `nix build` keeps working. Run it
|
||||
# from the Actions tab for any branch to get a build to try.
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'flake.nix'
|
||||
- 'flake.lock'
|
||||
- 'packaging/nix/**'
|
||||
- 'Cargo.lock'
|
||||
- 'Cargo.toml'
|
||||
- 'crates/**'
|
||||
- 'po/**'
|
||||
- '.github/workflows/nix.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'flake.nix'
|
||||
- 'flake.lock'
|
||||
- 'packaging/nix/**'
|
||||
- 'Cargo.lock'
|
||||
- '.github/workflows/nix.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: nix-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
|
||||
# Pulls from the project's binary cache, and pushes what this run built
|
||||
# when the token is there; a fork's pull request has none and only pulls.
|
||||
- uses: cachix/cachix-action@v17
|
||||
with:
|
||||
name: proton-drive-linux
|
||||
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
|
||||
|
||||
- name: Check the flake
|
||||
run: nix flake check --print-build-logs --no-build
|
||||
|
||||
- name: Build the package
|
||||
run: nix build --print-build-logs .#proton-drive-linux
|
||||
|
||||
- name: Smoke test
|
||||
run: |
|
||||
./result/bin/pdfs --version
|
||||
for bin in pdfs-app pdfs-tray pdfs-prompt; do test -x "result/bin/$bin"; done
|
||||
grep -q "ExecStart=$(readlink -f result)/bin/pdfs" result/lib/systemd/user/proton-drive.service
|
||||
ls result/share/locale/*/LC_MESSAGES/pdfs.mo
|
||||
+201
-16
@@ -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: |
|
||||
@@ -26,17 +26,78 @@ jobs:
|
||||
libfuse3-dev \
|
||||
libgtk-4-dev \
|
||||
libadwaita-1-dev \
|
||||
libwebkitgtk-6.0-dev \
|
||||
libsecret-1-dev \
|
||||
libdbus-1-dev \
|
||||
desktop-file-utils
|
||||
libdbus-1-dev
|
||||
|
||||
- name: Set up Rust
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
toolchain: '1.96'
|
||||
components: rustfmt, clippy
|
||||
cache: false
|
||||
|
||||
# A tag can read the caches of main but its own caches are useless to the next
|
||||
# tag, so this only restores what CI saved on main.
|
||||
- name: Cache Cargo target
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: debug
|
||||
save-if: false
|
||||
|
||||
- name: Verify tag and manifest versions
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
shell: bash
|
||||
run: |
|
||||
tag_version="${GITHUB_REF_NAME#v}"
|
||||
manifest_version="$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -n1)"
|
||||
pkgbuild_version="$(sed -n 's/^pkgver=//p' packaging/PKGBUILD | head -n1)"
|
||||
test "$tag_version" = "$manifest_version"
|
||||
test "$tag_version" = "$pkgbuild_version"
|
||||
|
||||
- name: Release quality gates
|
||||
run: |
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo test --workspace --locked
|
||||
scripts/fuse-acceptance.sh --offline-only
|
||||
|
||||
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: |
|
||||
@@ -62,6 +123,8 @@ jobs:
|
||||
cp target/release/pdfs dist/binaries/
|
||||
cp target/release/pdfs-tray dist/binaries/
|
||||
cp target/release/pdfs-app dist/binaries/
|
||||
cp target/release/pdfs-prompt dist/binaries/
|
||||
po/build.sh dist/binaries/locale
|
||||
|
||||
# Create tarball
|
||||
tar -czf proton-drive-linux-${{ env.VERSION }}-x86_64.tar.gz -C dist/binaries .
|
||||
@@ -73,16 +136,25 @@ jobs:
|
||||
mkdir -p deb/usr/share/applications
|
||||
mkdir -p deb/usr/share/icons/hicolor/scalable/apps
|
||||
mkdir -p deb/usr/share/licenses/proton-drive-linux
|
||||
mkdir -p deb/usr/lib/systemd/user
|
||||
mkdir -p deb/etc/xdg/autostart
|
||||
|
||||
# Copy binaries
|
||||
cp target/release/pdfs deb/usr/bin/
|
||||
cp target/release/pdfs-tray deb/usr/bin/
|
||||
cp target/release/pdfs-app deb/usr/bin/
|
||||
cp target/release/pdfs-prompt deb/usr/bin/
|
||||
|
||||
# Copy desktop integration files
|
||||
cp packaging/io.narl.proton-drive-linux.desktop deb/usr/share/applications/
|
||||
cp packaging/io.narl.proton-drive-linux-tray.desktop deb/etc/xdg/autostart/
|
||||
cp packaging/io.narl.proton-drive-linux.svg deb/usr/share/icons/hicolor/scalable/apps/
|
||||
cp packaging/proton-drive.service deb/usr/lib/systemd/user/
|
||||
cp LICENSE deb/usr/share/licenses/proton-drive-linux/
|
||||
po/build.sh deb/usr/share/locale
|
||||
|
||||
# Stops the running daemon before removal, so the mount stops mirroring deletes
|
||||
install -m755 packaging/deb/prerm deb/DEBIAN/prerm
|
||||
|
||||
# Calculate Installed-Size in KB for the control file
|
||||
INSTALLED_SIZE=$(du -sk deb | cut -f1)
|
||||
@@ -95,35 +167,148 @@ jobs:
|
||||
Priority: optional
|
||||
Architecture: amd64
|
||||
Maintainer: Nils Pukropp <contact@narl.io>
|
||||
Depends: fuse3, libgtk-4-1 (>= 4.12.0), libadwaita-1-0 (>= 1.5.0), libsecret-1-0
|
||||
Depends: fuse3, libgtk-4-1 (>= 4.12.0), libadwaita-1-0 (>= 1.5.0), libsecret-1-0, libwebkitgtk-6.0-4, libimage-exiftool-perl
|
||||
Installed-Size: ${INSTALLED_SIZE}
|
||||
Description: Proton Drive client for Linux
|
||||
Proton Drive client for Linux featuring:
|
||||
- FUSE files-on-demand mount
|
||||
- Command-line interface (CLI)
|
||||
- GTK4 system tray and desktop application
|
||||
Homepage: https://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
|
||||
dpkg-deb --build deb proton-drive-linux_${{ env.VERSION }}_amd64.deb
|
||||
|
||||
- name: Package Fedora package (.rpm)
|
||||
run: |
|
||||
# Sanitize version (replace hyphens with dots for RPM compatibility)
|
||||
RPM_VERSION=$(echo "${{ env.VERSION }}" | tr '-' '.')
|
||||
|
||||
# Create RPM build directories
|
||||
mkdir -p rpmbuild/{BUILD,RPMS,SOURCES,SPECS,SRPMS,BUILDROOT,db}
|
||||
|
||||
# Build RPM package
|
||||
rpmbuild -bb \
|
||||
--nodeps \
|
||||
--define "_topdir $PWD/rpmbuild" \
|
||||
--define "_sourcedir $PWD" \
|
||||
--define "git_dir $PWD" \
|
||||
--define "prebuilt 1" \
|
||||
--define "version ${RPM_VERSION}" \
|
||||
--define "_dbpath $PWD/rpmbuild/db" \
|
||||
packaging/proton-drive-linux.spec
|
||||
|
||||
# Copy built RPM to root directory
|
||||
cp rpmbuild/RPMS/x86_64/*.rpm .
|
||||
|
||||
- name: Sign Fedora package (.rpm)
|
||||
env:
|
||||
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||
GPG_KEY_NAME: ${{ secrets.GPG_KEY_NAME }}
|
||||
GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
|
||||
run: |
|
||||
if [ -n "$GPG_PRIVATE_KEY" ] && [ -n "$GPG_KEY_NAME" ]; then
|
||||
echo "$GPG_PRIVATE_KEY" | gpg --batch --pinentry-mode loopback --passphrase "$GPG_PASSPHRASE" --import
|
||||
|
||||
cat <<EOF > ~/.rpmmacros
|
||||
%_signature gpg
|
||||
%_gpg_name ${GPG_KEY_NAME}
|
||||
%__gpg_sign_cmd %{__gpg} gpg --batch --verbose --no-armor --pinentry-mode loopback --passphrase %{_gpg_passphrase} --no-secmem-warning -u "%{_gpg_name}" -sbo %{__signature_filename} --digest-algo sha256 %{__plaintext_filename}
|
||||
EOF
|
||||
|
||||
rpm --addsign --define "_gpg_passphrase $GPG_PASSPHRASE" *.rpm
|
||||
else
|
||||
echo "Skipping RPM signing: GPG_PRIVATE_KEY or GPG_KEY_NAME secret is not set."
|
||||
fi
|
||||
|
||||
|
||||
- name: Upload Workflow Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: proton-drive-linux-builds
|
||||
path: |
|
||||
*.deb
|
||||
*.tar.gz
|
||||
*.rpm
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: softprops/action-gh-release@v2
|
||||
# The flake is the Nix release: a tag is what `github:narrrl/proton-drive-linux/vX.Y.Z`
|
||||
# pins, so it has to build before the release is announced.
|
||||
nix:
|
||||
name: Build the Nix package
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
|
||||
# Pulls from the project's binary cache, and pushes what this run built
|
||||
# when the token is there; a fork's pull request has none and only pulls.
|
||||
- uses: cachix/cachix-action@v17
|
||||
with:
|
||||
name: proton-drive-linux
|
||||
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
|
||||
|
||||
- name: Build the flake
|
||||
run: nix build --print-build-logs .#proton-drive-linux
|
||||
|
||||
- name: Check the version
|
||||
run: |
|
||||
built="$(./result/bin/pdfs --version)"
|
||||
echo "$built"
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) grep -qF "${GITHUB_REF_NAME#v}" <<< "$built" ;;
|
||||
esac
|
||||
|
||||
publish:
|
||||
name: Create GitHub Release
|
||||
needs: [checks, build-release, nix]
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Download packages
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: proton-drive-linux-builds
|
||||
|
||||
# The release notes are this version's section of the changelog, from its
|
||||
# heading up to the next one.
|
||||
- name: Extract release notes
|
||||
run: |
|
||||
version="${GITHUB_REF_NAME#v}"
|
||||
awk -v heading="## [$version]" \
|
||||
'index($0, heading) == 1 { found = 1; print; next }
|
||||
found && /^## \[/ { exit }
|
||||
found' docs/CHANGELOG.md > release-notes.md
|
||||
if [ ! -s release-notes.md ]; then
|
||||
echo "::error::docs/CHANGELOG.md has no section for $version"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
body_path: release-notes.md
|
||||
files: |
|
||||
*.deb
|
||||
*.tar.gz
|
||||
*.rpm
|
||||
draft: false
|
||||
prerelease: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
aur:
|
||||
name: Publish AUR packages
|
||||
needs: publish
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: ./.github/workflows/aur.yml
|
||||
with:
|
||||
version: ${{ github.ref_name }}
|
||||
secrets:
|
||||
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||
@@ -0,0 +1,121 @@
|
||||
name: Test build
|
||||
|
||||
# On-demand Fedora RPM of any branch, for someone to try a fix before it ships.
|
||||
# Run it from the Actions tab ("Run workflow", pick the branch). Built inside a
|
||||
# Fedora container so the binaries link against Fedora's own GTK, libadwaita and
|
||||
# WebKitGTK, not the Ubuntu ones the release RPM is built against.
|
||||
#
|
||||
# The RPM is unsigned and versioned `<Cargo version>-1.test.<sha>`, so it sorts
|
||||
# below the release it precedes and `dnf` replaces it when that ships.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
fedora:
|
||||
description: 'Fedora release to build on'
|
||||
required: false
|
||||
default: '44'
|
||||
type: string
|
||||
prerelease:
|
||||
description: 'Also publish a pre-release anyone can download without a GitHub login'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: test-build-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
rpm:
|
||||
name: Fedora ${{ inputs.fedora }} RPM
|
||||
runs-on: ubuntu-24.04
|
||||
container: fedora:${{ inputs.fedora }}
|
||||
steps:
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
dnf install -y --setopt=install_weak_deps=False \
|
||||
git rpm-build gcc gcc-c++ make gettext curl \
|
||||
pkgconf-pkg-config fuse3-devel gtk4-devel libadwaita-devel \
|
||||
libsecret-devel dbus-devel glib2-devel webkitgtk6.0-devel
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
# Fedora's packaged Rust can trail the workspace's minimum (1.96).
|
||||
- name: Set up Rust
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: '1.96'
|
||||
cache: false
|
||||
|
||||
- name: Cache Cargo target
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: fedora-test-build
|
||||
save-if: false
|
||||
|
||||
- name: Build release binaries
|
||||
run: cargo build --release --locked
|
||||
|
||||
- name: Package RPM
|
||||
run: |
|
||||
git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
VERSION=$(grep -m1 '^version' Cargo.toml | cut -d'"' -f2)
|
||||
SHORT=${GITHUB_SHA::7}
|
||||
mkdir -p rpmbuild/{BUILD,RPMS,SOURCES,SPECS,SRPMS,BUILDROOT,db}
|
||||
rpmbuild -bb \
|
||||
--nodeps \
|
||||
--define "_topdir $PWD/rpmbuild" \
|
||||
--define "_sourcedir $PWD" \
|
||||
--define "git_dir $PWD" \
|
||||
--define "prebuilt 1" \
|
||||
--define "version ${VERSION}" \
|
||||
--define "dist .test.${SHORT}" \
|
||||
--define "_dbpath $PWD/rpmbuild/db" \
|
||||
packaging/proton-drive-linux.spec
|
||||
mkdir -p out
|
||||
cp rpmbuild/RPMS/x86_64/*.rpm out/
|
||||
# The bare binaries, for trying one without installing anything.
|
||||
tar -czf "out/proton-drive-linux-${VERSION}-${SHORT}-fedora${{ inputs.fedora }}-x86_64.tar.gz" \
|
||||
-C target/release pdfs pdfs-tray pdfs-app pdfs-prompt
|
||||
ls -l out
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: proton-drive-linux-fedora${{ inputs.fedora }}-${{ github.sha }}
|
||||
path: out/*
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
|
||||
prerelease:
|
||||
name: Publish pre-release
|
||||
needs: rpm
|
||||
if: inputs.prerelease
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download build
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: out
|
||||
merge-multiple: true
|
||||
|
||||
# One rolling pre-release per branch: a new run replaces the last one, so
|
||||
# the link a tester was given keeps pointing at the newest build.
|
||||
- name: Publish
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
TAG="test-$(echo "${GITHUB_REF_NAME}" | tr -c 'A-Za-z0-9._\n' '-')"
|
||||
gh release delete "$TAG" --cleanup-tag --yes 2>/dev/null || true
|
||||
gh release create "$TAG" out/* \
|
||||
--target "$GITHUB_SHA" \
|
||||
--prerelease \
|
||||
--title "Test build of ${GITHUB_REF_NAME} (${GITHUB_SHA::7})" \
|
||||
--notes "Unsigned test build of branch \`${GITHUB_REF_NAME}\` at ${GITHUB_SHA}. Not a release; replaced by the next test build of this branch."
|
||||
@@ -2,3 +2,6 @@
|
||||
**/*.rs.bk
|
||||
*.pdb
|
||||
.env
|
||||
/rpmbuild
|
||||
/result
|
||||
/result-*
|
||||
@@ -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
+1839
-798
File diff suppressed because it is too large.
Load diff
+37
-8
@@ -3,16 +3,20 @@ resolver = "2"
|
||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.0"
|
||||
version = "3.4.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||
rust-version = "1.96"
|
||||
repository = "https://github.com/narrrl/proton-drive-linux"
|
||||
homepage = "https://proton-drive.narl.io"
|
||||
readme = "README.md"
|
||||
keywords = ["proton", "drive", "fuse", "sync", "linux"]
|
||||
categories = ["filesystem", "command-line-utilities"]
|
||||
|
||||
[workspace.dependencies]
|
||||
# Local Proton SDK crates (path deps until published; switch to versioned later).
|
||||
proton-sdk = "0.1"
|
||||
proton-drive-rs = "0.1"
|
||||
proton-sdk = { version = "0.8.2" }
|
||||
proton-drive-rs = { version = "0.8.2" }
|
||||
|
||||
# Internal crates.
|
||||
pdfs-core = { path = "crates/pdfs-core" }
|
||||
@@ -21,14 +25,39 @@ 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
|
||||
# wedge every later acquisition of it: `Workers` catches the unwind to keep the
|
||||
# pool alive, and `std::sync::Mutex` would defeat that by poisoning the state
|
||||
# the recovered worker needs.
|
||||
parking_lot = "0.12"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
directories = "5"
|
||||
sha2 = "0.10"
|
||||
keyring = { version = "3", features = ["sync-secret-service", "crypto-rust"] }
|
||||
directories = "6"
|
||||
ignore = "0.4"
|
||||
sha2 = "0.11"
|
||||
# Google Takeout exports are zip sets; the importer reads their central
|
||||
# directories and streams entries out without extracting to disk first.
|
||||
zip = { version = "8", default-features = false, features = ["deflate"] }
|
||||
# Proton's photo duplicate detection is keyed on the plaintext SHA-1, so an
|
||||
# import has to speak SHA-1 whatever its age.
|
||||
sha1 = "0.11"
|
||||
# Thumbnail generation for photos the server has none for. Decoders only for the
|
||||
# formats a camera or phone actually produces — the rest of the crate's codecs
|
||||
# would be dead weight in the daemon.
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "tiff", "bmp", "rayon"] }
|
||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||
# keyring 4 split into `keyring-core` plus per-platform stores. The libdbus
|
||||
# Secret Service store is the one keyring 3's `sync-secret-service` wrapped, and
|
||||
# with no `target` it matches items v3 wrote, so existing logins survive.
|
||||
keyring-core = "1"
|
||||
dbus-secret-service-keyring-store = { version = "1", features = ["crypto-rust"] }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
rpassword = "7"
|
||||
fuser = "0.17"
|
||||
async-trait = "0.1"
|
||||
futures = "0.3"
|
||||
fuser = "0.18"
|
||||
libc = "0.2"
|
||||
@@ -1,111 +1,179 @@
|
||||
# Proton Drive client for Linux (unofficial)
|
||||
<div align="center">
|
||||
|
||||
A fast, unofficial Proton Drive client for Linux. This client provides a FUSE files-on-demand virtual mount, a command-line interface (CLI), and a GTK4 desktop application with system tray integration.
|
||||
<img src="images/logo.svg" alt="" width="112" height="112">
|
||||
|
||||
# Proton Drive for Linux
|
||||
|
||||
**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.
|
||||
- **Command-Line Interface (CLI)**: Manage your drive, authenticate, and monitor sync status directly from the terminal.
|
||||
- **GTK4 Desktop App**: Log in, browse files, and configure sync options through a modern, native Linux GUI.
|
||||
- **System Tray Integration**: Background indicator for status monitoring, quick actions, and fast sync controls.
|
||||
- **Secure Credential Storage**: Integrates with the system Secret Service (GNOME Keyring, KWallet, etc.) for safe token storage.
|
||||
- **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).
|
||||
**Files**
|
||||
|
||||
- **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.
|
||||
|
||||
**Desktop app**
|
||||
|
||||
- **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.
|
||||
|
||||
**Command line**
|
||||
|
||||
- About 55 `pdfs` commands cover files, sync, sharing, Photos, Trash, versions and diagnostics.
|
||||
Query commands print JSON with `--json`.
|
||||
|
||||
## Install
|
||||
|
||||
| Distribution | Package |
|
||||
|---|---|
|
||||
| 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 |
|
||||
| NixOS | The flake in this repository: see [Install → NixOS](docs/INSTALL.md#nixos-flake) |
|
||||
| Anything else | [Build from source](docs/INSTALL.md#build-from-source) |
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
Then open **Proton Drive** from your applications menu and sign in. The app starts the background
|
||||
service, and your files appear in `~/ProtonDrive`.
|
||||
|
||||
Signing in from a terminal instead? `pdfs login` stores the session but does not start the
|
||||
service. Enable it once:
|
||||
|
||||
```bash
|
||||
pdfs login
|
||||
systemctl --user enable --now proton-drive.service
|
||||
pdfs status
|
||||
```
|
||||
|
||||
[docs/INSTALL.md](docs/INSTALL.md) has the requirements, optional extras (tray on GNOME, RAW and
|
||||
video thumbnails), headless use and uninstalling.
|
||||
|
||||
## Documentation
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| [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 |
|
||||
| [Security model](docs/SECURITY_MODEL.md) | What protects your files, what stays on this computer, what the client connects to |
|
||||
| [Changelog](docs/CHANGELOG.md) | What changed in each release |
|
||||
|
||||
For contributors: [development guide](docs/DEVELOPMENT.md), [architecture](docs/ARCHITECTURE.md),
|
||||
[testing](docs/TESTING.md), [translating](docs/TRANSLATING.md), [roadmap](docs/ROADMAP.md) and the
|
||||
[bug ledger](docs/BUGS.md). The [documentation index](docs/README.md) lists everything.
|
||||
|
||||
## Screenshots
|
||||
|
||||
### GUI Application
|
||||
<table>
|
||||
<tr>
|
||||
<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/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/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_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>
|
||||
|
||||
| Login Screen | Account Overview |
|
||||
| :---: | :---: |
|
||||
|  |  |
|
||||
## How it works
|
||||
|
||||
| Files (Grid View) | Files (List View) |
|
||||
| :---: | :---: |
|
||||
|  |  |
|
||||
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.
|
||||
|
||||
---
|
||||
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).
|
||||
|
||||
## Prerequisites
|
||||
## Contributing
|
||||
|
||||
To compile the application from source or run the built binaries, ensure you have the following system libraries installed on your distribution:
|
||||
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.
|
||||
|
||||
### Ubuntu / Debian (24.04+)
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
pkg-config \
|
||||
libfuse3-dev \
|
||||
libgtk-4-dev \
|
||||
libadwaita-1-dev \
|
||||
libsecret-1-dev \
|
||||
libdbus-1-dev
|
||||
```
|
||||
## Support
|
||||
|
||||
### Arch Linux
|
||||
```bash
|
||||
sudo pacman -S --needed pkgconf fuse3 gtk4 libadwaita libsecret dbus
|
||||
```
|
||||
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).
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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. **Build Process**:
|
||||
- Spawns an Ubuntu runner and installs GTK4, Libadwaita, FUSE3, and Secret Service packages.
|
||||
- Sets up the Rust compiler and caches build targets to speed up runs.
|
||||
- Compiles the workspace members in release mode.
|
||||
3. **Artifact Packaging**:
|
||||
- Generates a `.tar.gz` containing the raw binaries (`pdfs`, `pdfs-app`, `pdfs-tray`).
|
||||
- Packs them into a Debian package (`.deb`).
|
||||
4. **Publishing**:
|
||||
- Creates a GitHub Release matching the pushed tag and uploads the `.deb` and `.tar.gz` packages as release assets.
|
||||
- For manual runs, compiles and exposes the packages as workflow run artifacts for testing.
|
||||
|
||||
---
|
||||
<a href="https://buymeacoffee.com/narl"><img src="https://img.shields.io/badge/Buy%20me%20a%20coffee-FFDD00?logo=buymeacoffee&logoColor=black" alt="Buy me a coffee"></a>
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the [MIT License](LICENSE).
|
||||
Released under the [MIT License](LICENSE).
|
||||
|
||||
The Places map uses [GeoNames](https://www.geonames.org/) data (CC BY 4.0) and
|
||||
[Natural Earth](https://www.naturalearthdata.com/) outlines (public domain). The optional street
|
||||
map comes from [OpenFreeMap](https://openfreemap.org/), © [OpenStreetMap](https://www.openstreetmap.org/copyright)
|
||||
contributors.
|
||||
|
||||
Proton Drive is a service of Proton AG. This project is an independent client and carries no
|
||||
affiliation with or endorsement from Proton AG.
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported versions
|
||||
|
||||
Fixes land on the latest release. Please reproduce on the most recent version before reporting.
|
||||
|
||||
For what the client protects and what it does not, written for users, see the
|
||||
[security model](docs/SECURITY_MODEL.md).
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
**Do not open a public issue.** Report privately through GitHub's
|
||||
[private vulnerability reporting](https://github.com/narrrl/proton-drive-linux/security/advisories/new),
|
||||
or by email to <contact@narl.io>.
|
||||
|
||||
Please include the version (`pdfs --version`), your distribution and desktop environment, what an
|
||||
attacker gains, and a reproduction if you have one. You can expect an acknowledgement within a few
|
||||
days; a fix ships in the next release, and you are credited in the advisory unless you ask
|
||||
otherwise.
|
||||
|
||||
## Scope
|
||||
|
||||
This client holds Proton account credentials and decrypted file content on the local machine, so
|
||||
the interesting boundaries are:
|
||||
|
||||
- **Credentials.** Sessions and the mailbox password live in the system Secret Service. Anything
|
||||
that writes them elsewhere — logs, temp files, crash dumps — is a vulnerability.
|
||||
- **The control socket.** Anything that can connect to `control.sock` has the daemon's
|
||||
authenticated session. It is `0600`, and the state, cache, and config directories must be real,
|
||||
current-user-owned `0700` directories; the daemon fails closed otherwise. Reports that defeat
|
||||
those checks are in scope.
|
||||
- **Local cache and staging.** Decrypted block content and staged writes on disk.
|
||||
- **Cryptography.** Key derivation, signature handling, and anything that could publish plaintext
|
||||
or the wrong key material to Proton.
|
||||
|
||||
What the client deliberately keeps in plaintext on disk, and why, is documented in the
|
||||
[threat model](docs/ARCHITECTURE.md#10-threat-model-what-this-client-writes-to-disk-in-plaintext).
|
||||
Behaviour described there is known and not a vulnerability by itself; a way to make it worse
|
||||
than described is.
|
||||
|
||||
Out of scope: vulnerabilities in Proton's own service (report those to Proton AG), and issues that
|
||||
require an attacker who already has your user account on the machine.
|
||||
@@ -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
|
||||
+3540
-81
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]
|
||||
@@ -16,5 +18,15 @@ serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
directories.workspace = true
|
||||
ignore.workspace = true
|
||||
sha2.workspace = true
|
||||
keyring.workspace = true
|
||||
sha1.workspace = true
|
||||
zip.workspace = true
|
||||
rusqlite.workspace = true
|
||||
keyring-core.workspace = true
|
||||
dbus-secret-service-keyring-store.workspace = true
|
||||
parking_lot.workspace = true
|
||||
async-trait.workspace = true
|
||||
libc.workspace = true
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1158
-38
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"));
|
||||
}
|
||||
}
|
||||
+3027
-208
File diff suppressed because it is too large.
Load diff
+675
-27
@@ -1,16 +1,30 @@
|
||||
//! Static client identity and per-user filesystem paths.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use directories::ProjectDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use 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";
|
||||
@@ -19,18 +33,279 @@ pub const KEYRING_SERVICE: &str = "proton-drive-linux";
|
||||
/// blobs back under this; pinned files are exempt. See [`crate::cache`].
|
||||
pub const DEFAULT_CACHE_BUDGET_BYTES: u64 = 5 * 1024 * 1024 * 1024;
|
||||
|
||||
/// Configuration structure allowing the user to customize client identification headers.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct AppConfig {
|
||||
pub app_version: String,
|
||||
pub user_agent: String,
|
||||
/// Environment override for [`AppConfig::resolved_conflict_sweep`]. Exists so a
|
||||
/// support case can disable the sweep without editing the config file or
|
||||
/// reinstalling — it wins over the stored setting.
|
||||
pub const CONFLICT_SWEEP_ENV: &str = "PDFS_CONFLICT_SWEEP";
|
||||
|
||||
/// What the background conflict sweep (`pdfs-fuse`'s `pdfs-conflict-sweep`
|
||||
/// thread) is allowed to do about a `(sync-conflict …)` copy it believes is a
|
||||
/// redundant duplicate.
|
||||
///
|
||||
/// Defaults to [`SweepMode::Report`]: the sweep's whole reason to exist is that
|
||||
/// the *previous* revision-identity check was wrong (B69), so the replacement is
|
||||
/// not trusted with deletion until it has a release cycle of field evidence
|
||||
/// behind it. See `docs/BUGS.md` B71.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SweepMode {
|
||||
/// Do not run the sweep at all — the thread is never spawned.
|
||||
Off,
|
||||
/// Run, and log what it *would* remove, but never trash anything. Divergent
|
||||
/// and orphaned copies are still surfaced to the activity feed as usual.
|
||||
#[default]
|
||||
Report,
|
||||
/// Trash copies proven identical to their live sibling.
|
||||
Enforce,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
impl SweepMode {
|
||||
/// Parse an operator-supplied value ([`CONFLICT_SWEEP_ENV`] or a CLI flag).
|
||||
/// `None` for anything unrecognised, so a typo falls back to the stored
|
||||
/// setting rather than silently picking a mode the operator did not ask for.
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"off" | "no" | "false" | "0" | "disabled" => Some(Self::Off),
|
||||
"report" | "dry-run" | "dryrun" | "observe" => Some(Self::Report),
|
||||
"enforce" | "on" | "yes" | "true" | "1" => Some(Self::Enforce),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this mode is allowed to trash a duplicate.
|
||||
pub fn is_enforcing(self) -> bool {
|
||||
matches!(self, Self::Enforce)
|
||||
}
|
||||
}
|
||||
|
||||
/// Precedence for [`AppConfig::resolved_conflict_sweep`], split out from the
|
||||
/// environment read so it can be unit-tested without mutating process state.
|
||||
fn resolve_sweep_mode(env: Option<&str>, stored: Option<SweepMode>) -> SweepMode {
|
||||
env.and_then(SweepMode::parse)
|
||||
.or(stored)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Configuration structure allowing the user to customize client identification headers.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
|
||||
pub struct AppConfig {
|
||||
/// 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.
|
||||
#[serde(default)]
|
||||
pub mountpoint: Option<String>,
|
||||
/// Ignore patterns (gitignore syntax) applied to every mirror sync folder,
|
||||
/// on top of each folder's own `.pdfsignore`. `None` means "use
|
||||
/// [`DEFAULT_IGNORE_PATTERNS`]"; an explicit empty list disables the
|
||||
/// defaults. Defaulted for configs predating the field.
|
||||
#[serde(default)]
|
||||
pub ignore_patterns: Option<Vec<String>>,
|
||||
/// Device uid this machine has been *adopted* into, overriding the hostname
|
||||
/// heuristic in `ensure_device`. Lives here rather than in `cache.db` so it
|
||||
/// survives a fresh install — on a new machine the DB is empty by
|
||||
/// definition, which is exactly when the pin matters. `None` means "resolve
|
||||
/// by hostname". Defaulted for configs predating the field.
|
||||
#[serde(default)]
|
||||
pub device_uid: Option<String>,
|
||||
/// What the background conflict sweep may do. `None` means "use
|
||||
/// [`SweepMode::default`]" (report-only). Defaulted for configs predating
|
||||
/// the field, and overridable at runtime via [`CONFLICT_SWEEP_ENV`].
|
||||
#[serde(default)]
|
||||
pub conflict_sweep: Option<SweepMode>,
|
||||
/// How files opened from the prompt/browser are launched. `None` means
|
||||
/// "hand everything to `xdg-open`", which is what the client did before
|
||||
/// this existed. See [`crate::opener`]. Defaulted for configs predating the
|
||||
/// field.
|
||||
#[serde(default)]
|
||||
pub open_with: Option<OpenWith>,
|
||||
/// `pdfs-prompt` behaviour, including the external launcher used by
|
||||
/// `--dmenu`. See [`crate::menu`]. Defaulted for configs predating the
|
||||
/// field.
|
||||
#[serde(default)]
|
||||
pub prompt: Option<PromptConfig>,
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
/// 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(),
|
||||
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`].
|
||||
///
|
||||
/// The environment wins so a support case can stop a destructive background
|
||||
/// loop without editing config or reinstalling (`docs/BUGS.md` B71). An
|
||||
/// unparseable value is ignored rather than treated as "off", so a typo
|
||||
/// cannot silently change behaviour in either direction.
|
||||
pub fn resolved_conflict_sweep(&self) -> SweepMode {
|
||||
resolve_sweep_mode(
|
||||
std::env::var(CONFLICT_SWEEP_ENV).ok().as_deref(),
|
||||
self.conflict_sweep,
|
||||
)
|
||||
}
|
||||
|
||||
/// The effective global sync ignore patterns: the user's explicit list, or
|
||||
/// [`DEFAULT_IGNORE_PATTERNS`] when unset. An explicit empty list is
|
||||
/// honoured as "no global patterns", which is why this is not a
|
||||
/// `unwrap_or_default`.
|
||||
pub fn resolved_ignore_patterns(&self) -> Vec<String> {
|
||||
match &self.ignore_patterns {
|
||||
Some(patterns) => patterns.clone(),
|
||||
None => DEFAULT_IGNORE_PATTERNS
|
||||
.iter()
|
||||
.map(|s| (*s).to_string())
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,11 +330,21 @@ impl AppDirs {
|
||||
/// Load config from disk, creating default if missing.
|
||||
pub fn load_config(&self) -> AppConfig {
|
||||
let path = self.config_path();
|
||||
if path.exists()
|
||||
&& let Ok(content) = std::fs::read_to_string(&path)
|
||||
&& let Ok(config) = serde_json::from_str::<AppConfig>(&content)
|
||||
{
|
||||
return config;
|
||||
if path.exists() {
|
||||
match std::fs::read_to_string(&path)
|
||||
.map_err(Error::from)
|
||||
.and_then(|content| {
|
||||
serde_json::from_str::<AppConfig>(&content).map_err(Error::from)
|
||||
}) {
|
||||
Ok(config) => return config,
|
||||
Err(e) => {
|
||||
// Preserve the original bytes for diagnosis/recovery. In
|
||||
// particular, never replace a truncated config with defaults
|
||||
// and silently lose mount, device, or ignore settings.
|
||||
tracing::error!(path = %path.display(), error = %e, "config is unreadable; using in-memory defaults without overwriting it");
|
||||
return AppConfig::default();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let default_config = AppConfig::default();
|
||||
@@ -70,6 +355,34 @@ impl AppDirs {
|
||||
default_config
|
||||
}
|
||||
|
||||
/// Persist `config` to [`config_path`](Self::config_path), creating the
|
||||
/// config dir if missing. The Settings page calls this after editing the
|
||||
/// cache budget or mountpoint; the daemon re-reads the file on its next mount.
|
||||
pub fn save_config(&self, config: &AppConfig) -> Result<()> {
|
||||
let path = self.config_path();
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| Error::Other("configuration path has no parent".into()))?;
|
||||
std::fs::create_dir_all(parent)?;
|
||||
let nonce = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_nanos())
|
||||
.unwrap_or(0);
|
||||
let tmp = path.with_extension(format!("json.tmp-{}-{nonce}", std::process::id()));
|
||||
use std::os::unix::fs::OpenOptionsExt as _;
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(&tmp)?;
|
||||
use std::io::Write as _;
|
||||
file.write_all(&serde_json::to_vec_pretty(config)?)?;
|
||||
file.sync_all()?;
|
||||
std::fs::rename(&tmp, &path)?;
|
||||
std::fs::File::open(parent)?.sync_all()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Persistent state: inode map / cache index DB lives here.
|
||||
pub fn state_dir(&self) -> PathBuf {
|
||||
// `state_dir` is Linux-only in `directories`; fall back to data dir.
|
||||
@@ -94,6 +407,12 @@ impl AppDirs {
|
||||
self.state_dir().join("pins.json")
|
||||
}
|
||||
|
||||
/// Unified SQLite metadata cache (inodes, FTS, cache LRU, pins). Lives in
|
||||
/// persistent state next to `control.sock`; only the daemon writes it.
|
||||
pub fn db_path(&self) -> PathBuf {
|
||||
self.state_dir().join("cache.db")
|
||||
}
|
||||
|
||||
/// Unix domain socket the mount daemon listens on for CLI control commands.
|
||||
pub fn control_socket(&self) -> PathBuf {
|
||||
self.state_dir().join("control.sock")
|
||||
@@ -104,6 +423,12 @@ impl AppDirs {
|
||||
self.state_dir().join("tray.sock")
|
||||
}
|
||||
|
||||
/// The user's home directory — the root the daemon indexes for local
|
||||
/// (non-Drive) file search. `None` if it cannot be resolved.
|
||||
pub fn home_dir(&self) -> Option<PathBuf> {
|
||||
directories::UserDirs::new().map(|u| u.home_dir().to_path_buf())
|
||||
}
|
||||
|
||||
/// Default mountpoint when the user does not pass one explicitly.
|
||||
pub fn default_mountpoint(&self) -> PathBuf {
|
||||
directories::UserDirs::new()
|
||||
@@ -111,35 +436,358 @@ impl AppDirs {
|
||||
.unwrap_or_else(|| PathBuf::from("/tmp/ProtonDrive"))
|
||||
}
|
||||
|
||||
/// Create state + cache dirs if missing.
|
||||
/// The effective mountpoint: the config's explicit path, or
|
||||
/// [`default_mountpoint`](Self::default_mountpoint) when unset.
|
||||
pub fn resolved_mountpoint(&self, config: &AppConfig) -> PathBuf {
|
||||
config
|
||||
.mountpoint
|
||||
.as_ref()
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| self.default_mountpoint())
|
||||
}
|
||||
|
||||
/// Create state + cache dirs if missing, owner-only.
|
||||
///
|
||||
/// The mode is set explicitly rather than left to the umask. These
|
||||
/// directories hold decrypted file content, a plaintext index of every node
|
||||
/// name in the Drive, and the control socket — and `create_dir_all` under a
|
||||
/// typical umask makes them `0755`. Until this was fixed, the only thing
|
||||
/// keeping another local user out was `~/.cache` and `~/.local/state`
|
||||
/// happening to be `0700`, which is a convention of the user's system and
|
||||
/// not something this client established (`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
|
||||
/// modes, or created by an older build — is tightened here.
|
||||
pub fn ensure(&self) -> Result<()> {
|
||||
std::fs::create_dir_all(self.state_dir())?;
|
||||
std::fs::create_dir_all(self.cache_dir())?;
|
||||
let _ = std::fs::create_dir_all(self.dirs.config_dir());
|
||||
for dir in [self.state_dir(), self.cache_dir()] {
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
restrict_dir(&dir)?;
|
||||
}
|
||||
let config_dir = self.dirs.config_dir().to_path_buf();
|
||||
std::fs::create_dir_all(&config_dir)?;
|
||||
restrict_dir(&config_dir)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Make `dir` owner-only (`0700`). These directories contain decrypted bytes,
|
||||
/// plaintext names, and an authenticated control socket, so failure is fatal.
|
||||
fn restrict_dir(dir: &Path) -> Result<()> {
|
||||
use std::os::unix::fs::{MetadataExt as _, PermissionsExt};
|
||||
let before = std::fs::symlink_metadata(dir)?;
|
||||
if before.file_type().is_symlink() || !before.is_dir() {
|
||||
return Err(Error::Other(format!(
|
||||
"sensitive path is not a real directory: {}",
|
||||
dir.display()
|
||||
)));
|
||||
}
|
||||
if before.uid() != unsafe { libc::geteuid() } {
|
||||
return Err(Error::Other(format!(
|
||||
"sensitive directory is not owned by the current user: {}",
|
||||
dir.display()
|
||||
)));
|
||||
}
|
||||
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
|
||||
let after = std::fs::symlink_metadata(dir)?;
|
||||
if after.permissions().mode() & 0o777 != 0o700 {
|
||||
return Err(Error::Other(format!(
|
||||
"sensitive directory did not become owner-only: {}",
|
||||
dir.display()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Make a just-bound Unix socket owner-only (`0600`).
|
||||
///
|
||||
/// Connecting to the control socket drives the daemon with its authenticated
|
||||
/// session — enumerate, read, upload, trash, share — without presenting any
|
||||
/// credential, since the daemon already holds one. It is an authority boundary,
|
||||
/// not merely private data, and `UnixListener::bind` applies the umask like any
|
||||
/// other file (`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
|
||||
/// directory: [`Dirs::ensure`] has already made the state directory `0700`, so
|
||||
/// nothing else can reach the socket to exploit the window.
|
||||
pub fn restrict_socket(path: &Path) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
fn mode_of(p: &Path) -> u32 {
|
||||
std::fs::metadata(p).unwrap().permissions().mode() & 0o777
|
||||
}
|
||||
|
||||
/// `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]
|
||||
fn restrict_dir_makes_a_directory_owner_only() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"pdfs-cfg-{}-{:?}",
|
||||
std::process::id(),
|
||||
std::thread::current().id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
// Start deliberately world-readable, as `create_dir_all` under a typical
|
||||
// umask would leave it.
|
||||
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
restrict_dir(&dir).unwrap();
|
||||
|
||||
assert_eq!(mode_of(&dir), 0o700, "group and other must have no access");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restrict_dir_refuses_a_symlink() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"pdfs-cfg-link-{}-{:?}",
|
||||
std::process::id(),
|
||||
std::thread::current().id()
|
||||
));
|
||||
let target = root.join("target");
|
||||
let link = root.join("state");
|
||||
std::fs::create_dir_all(&target).unwrap();
|
||||
std::os::unix::fs::symlink(&target, &link).unwrap();
|
||||
|
||||
assert!(restrict_dir(&link).is_err());
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// Connecting to the control socket confers the daemon's authenticated
|
||||
/// session with no credential, so it is an authority boundary rather than
|
||||
/// merely private data.
|
||||
#[test]
|
||||
fn restrict_socket_makes_a_socket_owner_only() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"pdfs-sock-{}-{:?}",
|
||||
std::process::id(),
|
||||
std::thread::current().id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let sock = dir.join("control.sock");
|
||||
// chmod semantics are identical for a socket path and a regular file;
|
||||
// use a file so this regression also runs in sandboxes that prohibit
|
||||
// creating Unix sockets.
|
||||
let listener = std::fs::File::create(&sock).unwrap();
|
||||
|
||||
restrict_socket(&sock).unwrap();
|
||||
|
||||
assert_eq!(mode_of(&sock), 0o600, "no other local user may connect");
|
||||
drop(listener);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_app_config_serialization() {
|
||||
let config = AppConfig {
|
||||
app_version: "external-drive-test-client@1.0.0".to_string(),
|
||||
user_agent: "test-agent/1.0".to_string(),
|
||||
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]
|
||||
fn sweep_mode_defaults_to_report_only() {
|
||||
// The sweep trashes user files, so an installed config that has never
|
||||
// heard of the setting must not get the enforcing behaviour.
|
||||
assert_eq!(SweepMode::default(), SweepMode::Report);
|
||||
assert!(!SweepMode::default().is_enforcing());
|
||||
assert_eq!(resolve_sweep_mode(None, None), SweepMode::Report);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sweep_mode_parses_operator_spellings() {
|
||||
for s in ["off", "OFF", " no ", "false", "0", "disabled"] {
|
||||
assert_eq!(SweepMode::parse(s), Some(SweepMode::Off), "{s}");
|
||||
}
|
||||
for s in ["report", "dry-run", "dryrun", "observe"] {
|
||||
assert_eq!(SweepMode::parse(s), Some(SweepMode::Report), "{s}");
|
||||
}
|
||||
for s in ["enforce", "on", "yes", "true", "1"] {
|
||||
assert_eq!(SweepMode::parse(s), Some(SweepMode::Enforce), "{s}");
|
||||
}
|
||||
// A typo must not resolve to anything, so it falls through to the config.
|
||||
assert_eq!(SweepMode::parse("offf"), None);
|
||||
assert_eq!(SweepMode::parse(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sweep_mode_env_overrides_config_but_a_typo_does_not() {
|
||||
let stored = Some(SweepMode::Enforce);
|
||||
// The point of the override: kill a destructive loop without editing config.
|
||||
assert_eq!(resolve_sweep_mode(Some("off"), stored), SweepMode::Off);
|
||||
// Unrecognised value falls back to the stored choice rather than guessing.
|
||||
assert_eq!(resolve_sweep_mode(Some("nope"), stored), SweepMode::Enforce);
|
||||
assert_eq!(resolve_sweep_mode(None, stored), SweepMode::Enforce);
|
||||
// No env, no stored value: report-only.
|
||||
assert_eq!(resolve_sweep_mode(Some("nope"), None), SweepMode::Report);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_config_predating_conflict_sweep_still_loads() {
|
||||
let json = r#"{"app_version":"v","user_agent":"u"}"#;
|
||||
let decoded: AppConfig = serde_json::from_str(json).unwrap();
|
||||
assert!(decoded.conflict_sweep.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sweep_mode_round_trips_through_the_config_file() {
|
||||
let config = AppConfig {
|
||||
conflict_sweep: Some(SweepMode::Enforce),
|
||||
..Default::default()
|
||||
};
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
assert!(json.contains(r#""conflict_sweep":"enforce""#), "{json}");
|
||||
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(decoded.conflict_sweep, Some(SweepMode::Enforce));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_config_predating_ignore_patterns_still_loads() {
|
||||
// Every installed config on disk looks like this; the field must not be
|
||||
// required or the daemon would fall back to a default config on upgrade.
|
||||
let json = r#"{"app_version":"v","user_agent":"u"}"#;
|
||||
let decoded: AppConfig = serde_json::from_str(json).unwrap();
|
||||
assert!(decoded.ignore_patterns.is_none());
|
||||
assert!(!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(),
|
||||
"an absent list means the defaults apply"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_empty_ignore_list_disables_the_defaults() {
|
||||
let config = AppConfig {
|
||||
ignore_patterns: Some(Vec::new()),
|
||||
..AppConfig::default()
|
||||
};
|
||||
assert!(
|
||||
config.resolved_ignore_patterns().is_empty(),
|
||||
"opting out must be expressible, so this cannot be unwrap_or_default"
|
||||
);
|
||||
}
|
||||
}
|
||||
+3689
-12
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,77 @@
|
||||
//! The activity feed: a bounded, newest-first log of what the daemon did.
|
||||
|
||||
use rusqlite::params;
|
||||
|
||||
use super::Db;
|
||||
use crate::Result;
|
||||
use crate::control::{ActivityEntry, ActivityKind};
|
||||
|
||||
/// How many activity rows to keep. Older rows are pruned on insert, so the feed
|
||||
/// stays a bounded "recent history" rather than growing without limit.
|
||||
pub(super) const ACTIVITY_KEEP: i64 = 2000;
|
||||
|
||||
impl Db {
|
||||
pub fn activity_add(&self, entry: &ActivityEntry) -> Result<()> {
|
||||
let kind = serde_json::to_string(&entry.kind)?;
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(
|
||||
"INSERT INTO activity (time, kind, target, detail, ok) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![entry.time, kind, entry.target, entry.detail, entry.ok],
|
||||
)?;
|
||||
tx.execute(
|
||||
"DELETE FROM activity WHERE id <= (
|
||||
SELECT id FROM activity ORDER BY id DESC LIMIT 1 OFFSET ?1
|
||||
)",
|
||||
params![ACTIVITY_KEEP],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT time, kind, target, detail, ok FROM activity
|
||||
ORDER BY id DESC LIMIT ?1",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![limit as i64], |r| {
|
||||
Ok((
|
||||
r.get::<_, i64>(0)?,
|
||||
r.get::<_, String>(1)?,
|
||||
r.get::<_, String>(2)?,
|
||||
r.get::<_, String>(3)?,
|
||||
r.get::<_, bool>(4)?,
|
||||
))
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
let (time, kind, target, detail, ok) = row?;
|
||||
let Ok(kind) = serde_json::from_str::<ActivityKind>(&kind) else {
|
||||
continue;
|
||||
};
|
||||
out.push(ActivityEntry {
|
||||
time,
|
||||
kind,
|
||||
target,
|
||||
detail,
|
||||
ok,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
//! The content-cache index: one row per cached blob, carrying the access stamp
|
||||
//! the LRU eviction pass sorts on.
|
||||
|
||||
use rusqlite::params;
|
||||
|
||||
use super::Db;
|
||||
use crate::Result;
|
||||
|
||||
pub struct CacheEntryInput<'a> {
|
||||
/// On-disk filename of the blob or block.
|
||||
pub key: &'a str,
|
||||
/// `'blob'` or `'block'` — the two byte budgets are counted separately.
|
||||
pub kind: &'a str,
|
||||
pub size: u64,
|
||||
/// Initial LRU key, in unix milliseconds.
|
||||
pub last_accessed: i64,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
pub fn cache_touch(&self, key: &str, kind: &str, size: u64, now: i64) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"INSERT INTO cache_entries (cache_key, kind, size_bytes, last_accessed)
|
||||
VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT(cache_key) DO UPDATE SET
|
||||
kind = excluded.kind,
|
||||
size_bytes = excluded.size_bytes,
|
||||
last_accessed = excluded.last_accessed",
|
||||
params![key, kind, size as i64, now],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bump only `last_accessed` on a cache hit (LRU ordering). A missing row is
|
||||
/// a no-op — best effort, mirroring the old best-effort mtime touch.
|
||||
pub fn cache_accessed(&self, key: &str, now: i64) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"UPDATE cache_entries SET last_accessed = ?2 WHERE cache_key = ?1",
|
||||
params![key, now],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bump `last_accessed` for many keys in one transaction.
|
||||
///
|
||||
/// The read path touches a key per 128 KiB the kernel asks for — 32 times
|
||||
/// per 4 MiB block — and each of those was its own write transaction under
|
||||
/// the single connection mutex, contending with every `lookup` and
|
||||
/// `readdir`. Callers accumulate touches in memory and flush them here.
|
||||
pub fn cache_accessed_batch(&self, touches: &[(String, i64)]) -> Result<()> {
|
||||
if touches.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
{
|
||||
let mut stmt = tx.prepare_cached(
|
||||
"UPDATE cache_entries SET last_accessed = ?2 WHERE cache_key = ?1",
|
||||
)?;
|
||||
for (key, at) in touches {
|
||||
stmt.execute(params![key, at])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop a single cache-entry row (one evicted blob or block).
|
||||
pub fn cache_remove(&self, key: &str) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"DELETE FROM cache_entries WHERE cache_key = ?1",
|
||||
params![key],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop the whole-file blob row for `key` plus every derived row for the
|
||||
/// same uid — blocks (`<key>.b<idx>`) and thumbnails (`<key>.t<type>`).
|
||||
/// Called by `ContentCache::evict`, which removes the blob, all of a uid's
|
||||
/// cached blocks and all of its thumbnails at once; a row left behind here
|
||||
/// would keep counting bytes that are no longer on disk.
|
||||
pub fn cache_remove_all(&self, key: &str) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"DELETE FROM cache_entries
|
||||
WHERE cache_key = ?1 OR cache_key LIKE ?2 OR cache_key LIKE ?3",
|
||||
params![key, format!("{key}.b%"), format!("{key}.t%")],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Total bytes held by entries of `kind`.
|
||||
///
|
||||
/// The budget enforcers' first question, and almost always their last: the
|
||||
/// common case is "under budget, evict nothing". Answering it with a `SUM`
|
||||
/// costs one index range rather than materializing every row, which matters
|
||||
/// because `enforce_block_budget` runs on every cached block — once per
|
||||
/// 4 MiB of every cold read — while holding the shared connection.
|
||||
pub fn cache_total_bytes(&self, kind: &str) -> Result<u64> {
|
||||
let conn = self.read();
|
||||
let total: i64 = conn.query_row(
|
||||
"SELECT COALESCE(SUM(size_bytes), 0) FROM cache_entries WHERE kind = ?1",
|
||||
params![kind],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
Ok(total.max(0) as u64)
|
||||
}
|
||||
|
||||
/// The `limit` least-recently-accessed entries of `kind`, oldest first.
|
||||
///
|
||||
/// Eviction wants a handful of victims, not the whole table. Bounded so a
|
||||
/// pass over a large cache does not read every row to drop a few, and so the
|
||||
/// `(kind, last_accessed)` index can answer it as a range scan with an early
|
||||
/// stop.
|
||||
pub fn cache_eviction_candidates(
|
||||
&self,
|
||||
kind: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<(String, u64)>> {
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"SELECT cache_key, size_bytes FROM cache_entries
|
||||
WHERE kind = ?1 ORDER BY last_accessed ASC LIMIT ?2",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![kind, limit as i64], |r| {
|
||||
Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.max(0) as u64))
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
out.push(r?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Every cache entry of `kind`, ordered least-recently-accessed first, as
|
||||
/// `(cache_key, size_bytes)`. Used by the whole-cache passes
|
||||
/// (`clear_unpinned`); incremental eviction wants
|
||||
/// [`cache_eviction_candidates`](Self::cache_eviction_candidates).
|
||||
pub fn cache_entries_by_kind(&self, kind: &str) -> Result<Vec<(String, u64)>> {
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT cache_key, size_bytes FROM cache_entries
|
||||
WHERE kind = ?1 ORDER BY last_accessed ASC",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![kind], |r| {
|
||||
Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.max(0) as u64))
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
out.push(r?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Replace the whole cache index with `entries`, as one transaction. The
|
||||
/// daemon calls this on open, so a crash or an external file deletion can
|
||||
/// never leave a phantom row inflating the budget total.
|
||||
///
|
||||
/// The clear and the refill are the same commit deliberately: they are one
|
||||
/// operation — "the index now describes what is on disk" — and a crash
|
||||
/// between them would otherwise leave an empty index against a full cache
|
||||
/// directory, i.e. a budget total of zero and nothing eviction can see.
|
||||
///
|
||||
/// One commit also matters for speed. A mount is the only caller and it runs
|
||||
/// on the open path, so a cache of thousands of blobs used to pay thousands
|
||||
/// of autocommit fsyncs before the mountpoint appeared.
|
||||
pub fn cache_rebuild(&self, entries: &[CacheEntryInput<'_>]) -> Result<()> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute("DELETE FROM cache_entries", [])?;
|
||||
{
|
||||
let mut stmt = tx.prepare(
|
||||
"INSERT INTO cache_entries (cache_key, kind, size_bytes, last_accessed)
|
||||
VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT(cache_key) DO UPDATE SET
|
||||
kind = excluded.kind,
|
||||
size_bytes = excluded.size_bytes,
|
||||
last_accessed = excluded.last_accessed",
|
||||
)?;
|
||||
for e in entries {
|
||||
stmt.execute(params![e.key, e.kind, e.size as i64, e.last_accessed])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Pins (P5) --------------------------------------------------------
|
||||
//
|
||||
// The pin registry, formerly `pins.json`. One row per directly-pinned node,
|
||||
// keyed by uid display string. `recursive` marks a folder pin: its whole
|
||||
// subtree counts as pinned, resolved against `nodes` via a CTE so a new
|
||||
// descendant is covered the moment it lands in the node cache.
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
//! This machine as a registered Proton Drive device.
|
||||
|
||||
use rusqlite::{OptionalExtension, params};
|
||||
|
||||
use super::Db;
|
||||
use crate::Result;
|
||||
|
||||
pub struct StoredDevice {
|
||||
pub uid: String,
|
||||
pub share_id: String,
|
||||
pub root_uid: String,
|
||||
pub name: String,
|
||||
pub created: i64,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
pub fn device_get(&self) -> Result<Option<StoredDevice>> {
|
||||
let conn = self.read();
|
||||
conn.query_row(
|
||||
"SELECT uid, share_id, root_uid, name, created FROM device LIMIT 1",
|
||||
[],
|
||||
|r| {
|
||||
Ok(StoredDevice {
|
||||
uid: r.get(0)?,
|
||||
share_id: r.get(1)?,
|
||||
root_uid: r.get(2)?,
|
||||
name: r.get(3)?,
|
||||
created: r.get(4)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Persist (or replace) this machine's device. The table holds a single row.
|
||||
pub fn device_set(&self, dev: &StoredDevice) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute("DELETE FROM device", [])?;
|
||||
conn.execute(
|
||||
"INSERT INTO device (uid, share_id, root_uid, name, created)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![dev.uid, dev.share_id, dev.root_uid, dev.name, dev.created],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,255 @@
|
||||
//! The local-file index: a generation-stamped scan of the mount, so search can
|
||||
//! answer over files the daemon has on disk as well as the remote tree.
|
||||
|
||||
use rusqlite::{OptionalExtension, params};
|
||||
|
||||
use super::Db;
|
||||
use crate::Result;
|
||||
use crate::localindex::LocalEntry;
|
||||
|
||||
use super::nodes::candidate_trigrams;
|
||||
use super::utils::{TRIGRAM_MIN, like_escape};
|
||||
|
||||
/// One hit from [`Db::search_local`]: an indexed file on the machine itself, not
|
||||
/// in Drive. `path` is absolute.
|
||||
pub struct LocalFileHit {
|
||||
pub path: String,
|
||||
pub name: String,
|
||||
pub is_dir: bool,
|
||||
pub size: i64,
|
||||
pub mtime: i64,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
pub fn local_begin_scan(&self) -> Result<i64> {
|
||||
let conn = self.conn.lock();
|
||||
let current: i64 = conn
|
||||
.query_row(
|
||||
"SELECT value FROM sync_state WHERE key = 'local_scan_gen'",
|
||||
[],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.optional()?
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0);
|
||||
let next = current + 1;
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (key, value) VALUES ('local_scan_gen', ?1)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
[next.to_string()],
|
||||
)?;
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
/// Write one batch of walked entries under scan generation `generation`,
|
||||
/// keeping the FTS index in step with them.
|
||||
///
|
||||
/// The index is maintained incrementally rather than rebuilt at the end of
|
||||
/// the scan, because a rebuild reads and re-tokenises every row in the table
|
||||
/// — trigrams over a few hundred thousand paths — inside one transaction on
|
||||
/// the single shared connection, which stalls the whole mount for as long as
|
||||
/// it takes. Incremental maintenance is possible here for a reason specific
|
||||
/// to this table: `local_fts` covers `name` and `path`, `path` is the
|
||||
/// table's key, and `name` is derived from it, so an existing row's indexed
|
||||
/// content can never change. Only insertions and deletions touch the index;
|
||||
/// a re-scan that finds the same files does no index work at all.
|
||||
///
|
||||
/// Hence the update-then-insert shape below rather than an upsert: the
|
||||
/// distinction between "already indexed" and "new row" is exactly what
|
||||
/// decides whether the index needs a write.
|
||||
pub fn local_upsert_batch(&self, generation: i64, entries: &[LocalEntry]) -> Result<()> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
{
|
||||
let mut update = tx.prepare_cached(
|
||||
"UPDATE local_files
|
||||
SET name = ?2, is_dir = ?3, size = ?4, mtime = ?5, scan_gen = ?6
|
||||
WHERE path = ?1",
|
||||
)?;
|
||||
let mut insert = tx.prepare_cached(
|
||||
"INSERT INTO local_files (path, name, is_dir, size, mtime, scan_gen)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
)?;
|
||||
let mut index =
|
||||
tx.prepare_cached("INSERT INTO local_fts (rowid, name, path) VALUES (?1, ?2, ?3)")?;
|
||||
for e in entries {
|
||||
let row = params![e.path, e.name, e.is_dir as i64, e.size, e.mtime, generation];
|
||||
if update.execute(row)? == 0 {
|
||||
insert.execute(row)?;
|
||||
index.execute(params![tx.last_insert_rowid(), e.name, e.path])?;
|
||||
}
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Close scan generation `generation`: drop every row an older scan wrote
|
||||
/// (those paths are gone from disk), drop their FTS entries, and stamp the
|
||||
/// completion time. Returns the number of indexed entries.
|
||||
///
|
||||
/// The FTS deletes name the old values explicitly, which is how an
|
||||
/// external-content FTS5 table is told to retract a row — see
|
||||
/// [`local_upsert_batch`](Self::local_upsert_batch) for why this is done
|
||||
/// per-row instead of by rebuilding the index.
|
||||
pub fn local_finish_scan(&self, generation: i64, finished_at: i64) -> Result<i64> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(
|
||||
"INSERT INTO local_fts (local_fts, rowid, name, path)
|
||||
SELECT 'delete', id, name, path FROM local_files WHERE scan_gen != ?1",
|
||||
params![generation],
|
||||
)?;
|
||||
tx.execute(
|
||||
"DELETE FROM local_files WHERE scan_gen != ?1",
|
||||
params![generation],
|
||||
)?;
|
||||
tx.execute(
|
||||
"INSERT INTO sync_state (key, value) VALUES ('local_indexed_at', ?1)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
[finished_at.to_string()],
|
||||
)?;
|
||||
let count: i64 = tx.query_row("SELECT COUNT(*) FROM local_files", [], |r| r.get(0))?;
|
||||
tx.commit()?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// When the last local scan completed (epoch seconds), or `None` if the index
|
||||
/// has never been built. The daemon uses this to decide whether a fresh mount
|
||||
/// needs an immediate rescan or can serve the existing index.
|
||||
pub fn local_indexed_at(&self) -> Result<Option<i64>> {
|
||||
let conn = self.read();
|
||||
Ok(conn
|
||||
.query_row(
|
||||
"SELECT value FROM sync_state WHERE key = 'local_indexed_at'",
|
||||
[],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.optional()?
|
||||
.and_then(|v| v.parse().ok()))
|
||||
}
|
||||
|
||||
/// Substring search over indexed local file names, newest-modified first
|
||||
/// within a relevance tier. Mirrors [`search`](Self::search): the trigram
|
||||
/// index handles queries of at least [`TRIGRAM_MIN`] chars, shorter ones fall
|
||||
/// back to a `LIKE` scan.
|
||||
pub fn search_local(&self, query: &str, limit: usize) -> Result<Vec<LocalFileHit>> {
|
||||
let query = query.trim();
|
||||
if query.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let conn = self.read();
|
||||
let mut stmt;
|
||||
let rows = if query.chars().count() < TRIGRAM_MIN {
|
||||
let pat = format!("%{}%", like_escape(query));
|
||||
stmt = conn.prepare(
|
||||
"SELECT path, name, is_dir, size, mtime FROM local_files
|
||||
WHERE name LIKE ?1 ESCAPE '\\'
|
||||
ORDER BY mtime DESC LIMIT ?2",
|
||||
)?;
|
||||
stmt.query_map(params![pat, limit as i64], local_hit)?
|
||||
} else {
|
||||
let phrase = query
|
||||
.split_whitespace()
|
||||
.map(|word| format!("\"{}\"", word.replace('"', "\"\"")))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" AND ");
|
||||
stmt = conn.prepare(
|
||||
"SELECT f.path, f.name, f.is_dir, f.size, f.mtime
|
||||
FROM local_fts x JOIN local_files f ON f.id = x.rowid
|
||||
WHERE x.name MATCH ?1
|
||||
ORDER BY x.rank LIMIT ?2",
|
||||
)?;
|
||||
stmt.query_map(params![phrase, limit as i64], local_hit)?
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
out.push(r?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Bounded candidates for the fuzzy scorer, including matches in the full
|
||||
/// local path. ORed trigrams admit misspellings while the FTS index prevents
|
||||
/// this from becoming an unbounded filesystem-table scan.
|
||||
pub fn search_local_candidates(&self, query: &str, limit: usize) -> Result<Vec<LocalFileHit>> {
|
||||
let query = query.trim();
|
||||
if query.is_empty() || limit == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let conn = self.read();
|
||||
let terms = candidate_trigrams(query);
|
||||
let lane_limit = limit.div_ceil(2);
|
||||
let mut rows: Vec<LocalFileHit> = if terms.is_empty() {
|
||||
let pat = format!("%{}%", like_escape(query));
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT path, name, is_dir, size, mtime FROM local_files
|
||||
WHERE name LIKE ?1 ESCAPE '\\' OR path LIKE ?1 ESCAPE '\\'
|
||||
ORDER BY mtime DESC LIMIT ?2",
|
||||
)?;
|
||||
stmt.query_map(params![pat, limit as i64], local_hit)?
|
||||
.collect::<std::result::Result<_, _>>()?
|
||||
} else {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT f.path, f.name, f.is_dir, f.size, f.mtime
|
||||
FROM local_fts x JOIN local_files f ON f.id = x.rowid
|
||||
WHERE local_fts MATCH ?1
|
||||
ORDER BY x.rank LIMIT ?2",
|
||||
)?;
|
||||
stmt.query_map(params![terms.join(" OR "), lane_limit as i64], local_hit)?
|
||||
.collect::<std::result::Result<_, _>>()?
|
||||
};
|
||||
if !terms.is_empty()
|
||||
&& let Some(first) = query.chars().next()
|
||||
{
|
||||
// Trigram rank says nothing about how well a name *starts* with the
|
||||
// query, so on a home directory full of dependency trees the pool
|
||||
// fills with vendored files before the obvious answer is reached
|
||||
// (measured: 73% of the 500 best-ranked candidates for "test" came
|
||||
// from one Go module cache). Give whole-query prefix matches their
|
||||
// own lane, newest first, and keep the single-character lane —
|
||||
// which recovers a typo that destroyed every trigram — behind it.
|
||||
let mut lanes = vec![(
|
||||
format!("{}%", like_escape(query)),
|
||||
"ORDER BY mtime DESC LIMIT ?2",
|
||||
)];
|
||||
lanes.push((
|
||||
format!("{}%", like_escape(&first.to_string())),
|
||||
"ORDER BY name COLLATE NOCASE LIMIT ?2",
|
||||
));
|
||||
let mut extra = Vec::new();
|
||||
for (pattern, order) in lanes {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT path, name, is_dir, size, mtime FROM local_files
|
||||
WHERE name COLLATE NOCASE LIKE ?1 ESCAPE '\\' {order}"
|
||||
))?;
|
||||
extra.extend(
|
||||
stmt.query_map(params![pattern, lane_limit as i64], local_hit)?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?,
|
||||
);
|
||||
}
|
||||
// Prefix hits go in front: `rows` is truncated to `limit` below, and
|
||||
// the lane exists precisely because trigram rank was burying them.
|
||||
let trigram = std::mem::take(&mut rows);
|
||||
for hit in extra.into_iter().chain(trigram) {
|
||||
if !rows.iter().any(|existing| existing.path == hit.path) {
|
||||
rows.push(hit);
|
||||
}
|
||||
}
|
||||
rows.truncate(limit);
|
||||
}
|
||||
Ok(rows)
|
||||
}
|
||||
}
|
||||
|
||||
/// Row → [`LocalFileHit`]. Every local-search query selects the same
|
||||
/// columns in the same order.
|
||||
pub(super) fn local_hit(row: &rusqlite::Row) -> rusqlite::Result<LocalFileHit> {
|
||||
Ok(LocalFileHit {
|
||||
path: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
is_dir: row.get::<_, i64>(2)? != 0,
|
||||
size: row.get(3)?,
|
||||
mtime: row.get(4)?,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
//! 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
|
||||
//! what it holds. The daemon never calls these on its own — they are what
|
||||
//! `pdfs cache inspect`, `pdfs cache vacuum`, and `pdfs diagnose` are built on,
|
||||
//! so a user with a suspect install can get an answer without being told to
|
||||
//! delete their state directory and re-sync from scratch.
|
||||
|
||||
use rusqlite::OptionalExtension;
|
||||
|
||||
use super::Db;
|
||||
use crate::Result;
|
||||
|
||||
/// A point-in-time picture of the database's size and contents.
|
||||
///
|
||||
/// Sizes are computed from SQLite's own page accounting rather than by stat'ing
|
||||
/// the file, so they stay right when the file is sparse or a checkpoint is
|
||||
/// pending.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DbStats {
|
||||
/// Schema version the file is currently at.
|
||||
pub schema_version: i64,
|
||||
/// Total pages allocated to the database.
|
||||
pub page_count: i64,
|
||||
/// Pages allocated but unused — what a `VACUUM` would reclaim.
|
||||
pub free_pages: i64,
|
||||
/// Bytes per page.
|
||||
pub page_size: i64,
|
||||
/// Row counts for the tables worth reporting, in display order.
|
||||
pub tables: Vec<(String, i64)>,
|
||||
}
|
||||
|
||||
impl DbStats {
|
||||
/// Bytes the database file accounts for.
|
||||
pub fn total_bytes(&self) -> u64 {
|
||||
(self.page_count * self.page_size).max(0) as u64
|
||||
}
|
||||
|
||||
/// Bytes a `VACUUM` could hand back to the filesystem.
|
||||
pub fn reclaimable_bytes(&self) -> u64 {
|
||||
(self.free_pages * self.page_size).max(0) as u64
|
||||
}
|
||||
}
|
||||
|
||||
/// What a maintenance run reclaimed.
|
||||
///
|
||||
/// Deliberately does not carry an orphaned-row count: the content cache already
|
||||
/// rebuilds its index from what is actually on disk every time it opens (see
|
||||
/// [`crate::cache::ContentCache`]'s `reconcile`), so a second pruning pass here
|
||||
/// would be a duplicate of that logic with a worse view of the truth.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct VacuumOutcome {
|
||||
/// Database bytes before the run.
|
||||
pub before_bytes: u64,
|
||||
/// Database bytes after it.
|
||||
pub after_bytes: u64,
|
||||
/// WAL frames written back into the database by the checkpoint.
|
||||
pub wal_frames_checkpointed: i64,
|
||||
}
|
||||
|
||||
impl VacuumOutcome {
|
||||
/// Bytes handed back to the filesystem. Saturating: a database can legitimately
|
||||
/// grow across a vacuum (a checkpoint folds WAL frames in), and reporting that
|
||||
/// as a huge positive number via wraparound would be worse than reporting zero.
|
||||
pub fn freed_bytes(&self) -> u64 {
|
||||
self.before_bytes.saturating_sub(self.after_bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// The tables `inspect` reports on, in the order a reader wants them.
|
||||
const REPORTED_TABLES: &[&str] = &[
|
||||
"nodes",
|
||||
"cache_entries",
|
||||
"pins",
|
||||
"photos",
|
||||
"trash",
|
||||
"device",
|
||||
"sync_folder",
|
||||
"mount",
|
||||
"sync_entry",
|
||||
"pending_op",
|
||||
"activity",
|
||||
"local_files",
|
||||
"share_access",
|
||||
];
|
||||
|
||||
impl Db {
|
||||
/// SQLite's own consistency check.
|
||||
///
|
||||
/// Returns an empty vector when the database is sound; otherwise each entry
|
||||
/// is one problem SQLite found. Read-only and safe against a live daemon,
|
||||
/// though it reads every page, so it is not free on a large file.
|
||||
pub fn integrity_check(&self) -> Result<Vec<String>> {
|
||||
let conn = self.conn.lock();
|
||||
let mut stmt = conn.prepare("PRAGMA integrity_check")?;
|
||||
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
|
||||
let mut problems = Vec::new();
|
||||
for row in rows {
|
||||
let row = row?;
|
||||
// A sound database reports the single line "ok"; anything else is a
|
||||
// finding.
|
||||
if row != "ok" {
|
||||
problems.push(row);
|
||||
}
|
||||
}
|
||||
Ok(problems)
|
||||
}
|
||||
|
||||
/// Size and row-count snapshot for `pdfs cache inspect`.
|
||||
pub fn stats(&self) -> Result<DbStats> {
|
||||
let conn = self.conn.lock();
|
||||
let page_count: i64 = conn.query_row("PRAGMA page_count", [], |r| r.get(0))?;
|
||||
let free_pages: i64 = conn.query_row("PRAGMA freelist_count", [], |r| r.get(0))?;
|
||||
let page_size: i64 = conn.query_row("PRAGMA page_size", [], |r| r.get(0))?;
|
||||
// The schema version lives in the `sync_state` key/value table, not in
|
||||
// `PRAGMA user_version` — see `migrations`.
|
||||
let schema_version: i64 = conn
|
||||
.query_row(
|
||||
"SELECT value FROM sync_state WHERE key = 'schema_version'",
|
||||
[],
|
||||
|r| r.get::<_, String>(0),
|
||||
)
|
||||
.optional()?
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
let mut tables = Vec::new();
|
||||
for name in REPORTED_TABLES {
|
||||
// A table listed here may not exist yet on an older schema, and a
|
||||
// report is not worth failing over a missing one.
|
||||
let exists: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name=?1",
|
||||
[name],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
if exists.is_none() {
|
||||
continue;
|
||||
}
|
||||
let count: i64 =
|
||||
conn.query_row(&format!("SELECT count(*) FROM \"{name}\""), [], |r| {
|
||||
r.get(0)
|
||||
})?;
|
||||
tables.push(((*name).to_string(), count));
|
||||
}
|
||||
|
||||
Ok(DbStats {
|
||||
schema_version,
|
||||
page_count,
|
||||
free_pages,
|
||||
page_size,
|
||||
tables,
|
||||
})
|
||||
}
|
||||
|
||||
/// Fold the WAL back into the database and compact the file.
|
||||
///
|
||||
/// `VACUUM` rewrites the whole database into a fresh file, so it needs room
|
||||
/// for a second copy and takes a write lock for the duration. That makes it
|
||||
/// a deliberate, user-invoked operation rather than something the daemon
|
||||
/// does on a timer.
|
||||
///
|
||||
/// The checkpoint runs *after* the vacuum. Checkpointing only beforehand
|
||||
/// folds a log that the vacuum is about to rewrite anyway, and leaves the
|
||||
/// rewrite itself sitting in the WAL: measured on a live install, a 170 MiB
|
||||
/// database vacuumed to 136 MiB while its WAL grew 67 MiB → 143 MiB,
|
||||
/// erasing the reclaim in net disk terms and reporting `0 frames`
|
||||
/// checkpointed because it ran when there was nothing to fold.
|
||||
///
|
||||
/// Ordering it afterwards is strictly better but is *not* a proven fix for
|
||||
/// that inflation. The live case inflates because the daemon's other
|
||||
/// connections hold read transactions, and a `TRUNCATE` checkpoint waits on
|
||||
/// readers — so under a busy daemon this can still return having moved
|
||||
/// little. `journal_size_limit` remains the backstop that caps the file.
|
||||
/// Confirming the fix needs a measurement against a live daemon, not a
|
||||
/// single-connection test, which folds the WAL on its own either way.
|
||||
pub fn vacuum(&self) -> Result<VacuumOutcome> {
|
||||
let before = self.stats()?.total_bytes();
|
||||
|
||||
{
|
||||
let conn = self.conn.lock();
|
||||
conn.execute_batch("VACUUM")?;
|
||||
}
|
||||
|
||||
let wal_frames_checkpointed = self.checkpoint_wal()?;
|
||||
|
||||
let after = self.stats()?.total_bytes();
|
||||
Ok(VacuumOutcome {
|
||||
before_bytes: before,
|
||||
after_bytes: after,
|
||||
wal_frames_checkpointed,
|
||||
})
|
||||
}
|
||||
|
||||
/// Checkpoint the WAL and truncate it, returning the number of frames
|
||||
/// written back into the database.
|
||||
///
|
||||
/// `TRUNCATE` blocks on readers rather than giving up part-way, which is
|
||||
/// what makes the result meaningful: a `PASSIVE` checkpoint against a busy
|
||||
/// daemon can legitimately move nothing and report success.
|
||||
pub fn checkpoint_wal(&self) -> Result<i64> {
|
||||
let conn = self.conn.lock();
|
||||
// Columns are (busy, log frames, frames checkpointed).
|
||||
let frames: i64 = conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |r| r.get(2))?;
|
||||
Ok(frames.max(0))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_fresh_database_passes_its_integrity_check() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
assert!(db.integrity_check().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stats_report_the_schema_and_the_known_tables() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
let stats = db.stats().unwrap();
|
||||
|
||||
assert!(stats.schema_version > 0, "migrations set a user_version");
|
||||
assert!(stats.page_size > 0);
|
||||
assert!(
|
||||
stats.tables.iter().any(|(name, _)| name == "nodes"),
|
||||
"the nodes table should be reported; got {:?}",
|
||||
stats.tables
|
||||
);
|
||||
// A fresh database has no rows in any of them.
|
||||
assert!(stats.tables.iter().all(|(_, count)| *count == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_and_reclaimable_bytes_derive_from_the_page_counts() {
|
||||
let stats = DbStats {
|
||||
schema_version: 1,
|
||||
page_count: 10,
|
||||
free_pages: 4,
|
||||
page_size: 4096,
|
||||
tables: Vec::new(),
|
||||
};
|
||||
assert_eq!(stats.total_bytes(), 40960);
|
||||
assert_eq!(stats.reclaimable_bytes(), 16384);
|
||||
}
|
||||
|
||||
/// A vacuum that leaves the file larger (a checkpoint folded WAL frames in)
|
||||
/// must report nothing freed, not an underflowed enormous number.
|
||||
#[test]
|
||||
fn freed_bytes_does_not_underflow_when_the_file_grew() {
|
||||
let outcome = VacuumOutcome {
|
||||
before_bytes: 100,
|
||||
after_bytes: 400,
|
||||
..VacuumOutcome::default()
|
||||
};
|
||||
assert_eq!(outcome.freed_bytes(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freed_bytes_reports_the_difference_when_the_file_shrank() {
|
||||
let outcome = VacuumOutcome {
|
||||
before_bytes: 4096,
|
||||
after_bytes: 1024,
|
||||
..VacuumOutcome::default()
|
||||
};
|
||||
assert_eq!(outcome.freed_bytes(), 3072);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vacuuming_a_fresh_database_leaves_it_sound() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
db.vacuum().unwrap();
|
||||
assert!(db.integrity_check().unwrap().is_empty());
|
||||
}
|
||||
|
||||
/// A unique temp directory removed on drop; avoids a dev-dependency, as in
|
||||
/// [`crate::cache`]'s tests. Needed here because an in-memory database has
|
||||
/// no WAL file to measure.
|
||||
struct TempDir(std::path::PathBuf);
|
||||
impl TempDir {
|
||||
fn new() -> Self {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static N: AtomicU64 = AtomicU64::new(0);
|
||||
let p = std::env::temp_dir().join(format!(
|
||||
"pdfs-maint-{}-{}",
|
||||
std::process::id(),
|
||||
N.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
TempDir(p)
|
||||
}
|
||||
}
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// A file-backed vacuum reclaims space and leaves the file sound.
|
||||
///
|
||||
/// Note what this does *not* pin: the WAL-inflation behaviour that motivated
|
||||
/// the checkpoint ordering in [`Db::vacuum`]. Measured here, both orderings
|
||||
/// report `frames=0` and leave a 0-byte WAL, because a single-connection
|
||||
/// test lets SQLite fold the log itself. The live case inflated only because
|
||||
/// the daemon holds concurrent readers that block a checkpoint, and
|
||||
/// reproducing that needs a second connection parked in a read transaction.
|
||||
/// An assertion on frame count or WAL size here would pass under both
|
||||
/// orderings and so would be decoration — see the note in `vacuum`.
|
||||
#[test]
|
||||
fn a_file_backed_vacuum_reclaims_space_and_stays_sound() {
|
||||
let dir = TempDir::new();
|
||||
let path = dir.0.join("cache.db");
|
||||
let db = Db::open(&path).unwrap();
|
||||
|
||||
for i in 0..2000 {
|
||||
db.set_state_str(&format!("k{i}"), &"v".repeat(256))
|
||||
.unwrap();
|
||||
}
|
||||
let full = db.stats().unwrap().total_bytes();
|
||||
for i in 0..2000 {
|
||||
db.set_state_str(&format!("k{i}"), "").unwrap();
|
||||
}
|
||||
|
||||
let outcome = db.vacuum().unwrap();
|
||||
|
||||
assert!(
|
||||
outcome.after_bytes < full,
|
||||
"vacuum did not reclaim: {} → {} (peak {full})",
|
||||
outcome.before_bytes,
|
||||
outcome.after_bytes
|
||||
);
|
||||
assert!(db.integrity_check().unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,464 @@
|
||||
//! Unified SQLite metadata cache — the single persistence layer behind FUSE
|
||||
//! inode bookkeeping, full-text search, content-cache LRU tracking, and pins.
|
||||
//!
|
||||
//! Only the daemon (`pdfs-fuse`) opens this for writes; the GUI and CLI reach
|
||||
//! the same data through the control socket. There is one write connection
|
||||
//! behind a `Mutex` — SQLite allows exactly one writer, and the single-instance
|
||||
//! `flock` makes sure it is ours — and a small pool of read-only connections
|
||||
//! behind [`Db::read`], which every `SELECT`-only method uses.
|
||||
//!
|
||||
//! The pool is what makes WAL worth having. The original justification for a
|
||||
//! single connection was that "FUSE callbacks already serialize behind the
|
||||
//! `State` lock"; that stopped being true when reads moved onto an 11-thread
|
||||
//! worker pool, and a lookup then queued behind whatever listing happened to be
|
||||
//! committing. Under WAL a reader never blocks the writer and vice versa, so the
|
||||
//! only thing left serialising them was this process's own mutex.
|
||||
//!
|
||||
//! This module is the P0 foundation: it opens the database, enables WAL, and
|
||||
//! applies the forward-only schema migrations. Write-through of nodes, the
|
||||
//! event cursor, FTS, and the cache index land in later phases on this schema.
|
||||
//!
|
||||
//! The surface is one `Db` type; its methods live in a submodule per table group
|
||||
//! (`nodes`, `photos`, `pins`, …) as separate `impl Db` blocks. Everything those
|
||||
//! modules define is re-exported here, so callers keep saying `db::StoredPhoto`
|
||||
//! and never name a submodule.
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::{Error, Result};
|
||||
use std::fs::File;
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
mod activity;
|
||||
mod albums;
|
||||
mod cache;
|
||||
mod devices;
|
||||
mod local;
|
||||
mod maintenance;
|
||||
mod migrations;
|
||||
mod mounts;
|
||||
mod nodes;
|
||||
mod ops;
|
||||
mod photos;
|
||||
mod pins;
|
||||
mod share_access;
|
||||
mod state;
|
||||
mod sync;
|
||||
mod trash;
|
||||
mod utils;
|
||||
|
||||
pub use albums::StoredAlbum;
|
||||
pub use cache::CacheEntryInput;
|
||||
pub use devices::StoredDevice;
|
||||
pub use local::LocalFileHit;
|
||||
pub use maintenance::{DbStats, VacuumOutcome};
|
||||
pub use nodes::{PublishedSharedRoot, SearchHit, StoredNode};
|
||||
pub use ops::{
|
||||
AttachedBlob, 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, StoredPlace, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN, TimelineRow};
|
||||
pub use pins::PinRow;
|
||||
pub use sync::{StoredSyncEntry, StoredSyncFolder};
|
||||
pub use trash::StoredTrash;
|
||||
|
||||
/// Size the WAL is truncated back to after a checkpoint. Comfortably above the
|
||||
/// steady-state working set (a few MB), so the truncation only claws back the
|
||||
/// outliers rather than fighting the normal write path for disk.
|
||||
const WAL_SIZE_LIMIT: i64 = 64 * 1024 * 1024;
|
||||
|
||||
/// How long a statement waits for a database lock before giving up.
|
||||
///
|
||||
/// Long enough to outlast any transaction this daemon writes (the largest, a
|
||||
/// full-index FTS rebuild, is well under a second), short enough that a
|
||||
/// genuinely stuck holder surfaces as an error rather than an apparent hang.
|
||||
const BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
/// Page cache, in KiB (applied negated, which is how SQLite reads a size in KiB
|
||||
/// rather than in pages). 32 MiB against a database that is typically tens of
|
||||
/// MiB: large enough to hold the working set of a listing-heavy session, small
|
||||
/// enough to be unremarkable in a desktop daemon's RSS.
|
||||
const CACHE_SIZE_KIB: i64 = 32 * 1024;
|
||||
|
||||
/// Ceiling on the memory-mapped window over the database file. 256 MiB covers
|
||||
/// any realistic index while keeping the mapping bounded.
|
||||
const MMAP_SIZE: i64 = 256 * 1024 * 1024;
|
||||
|
||||
/// WAL pages between automatic checkpoints (default 1000, i.e. ~4 MiB). Halved
|
||||
/// so the synchronous checkpoint a committing thread inherits stays short.
|
||||
const WAL_AUTOCHECKPOINT_PAGES: i64 = 500;
|
||||
|
||||
/// Handle to the unified metadata database.
|
||||
///
|
||||
/// Cheap to wrap in an `Arc`; clone the `Arc`, not this. All access goes through
|
||||
/// the inner `Mutex<Connection>`.
|
||||
pub struct Db {
|
||||
conn: Mutex<Connection>,
|
||||
/// Read-only connections for `SELECT`-only methods. `None` for an in-memory
|
||||
/// database, where a second connection would open a different, empty one —
|
||||
/// those fall back to the write connection.
|
||||
readers: Option<ReadPool>,
|
||||
/// The single-writer lock, held open for as long as this handle lives.
|
||||
///
|
||||
/// Never read; dropping the `File` is what releases the `flock`, so it has
|
||||
/// to be owned here rather than by `open`'s stack frame. `None` for
|
||||
/// in-memory databases, which nothing else can reach.
|
||||
_single_writer: Option<File>,
|
||||
}
|
||||
|
||||
/// Idle read-only connections, opened on demand and kept for reuse.
|
||||
///
|
||||
/// Not a bounded pool: the number in flight is bounded by the number of threads
|
||||
/// that can be inside a read at once (the FUSE worker lanes plus the control
|
||||
/// handlers), and blocking one of those on a permit would reintroduce exactly
|
||||
/// the queueing this exists to remove. Only the *idle* set is capped, so a burst
|
||||
/// does not leave a connection per thread parked forever.
|
||||
struct ReadPool {
|
||||
path: PathBuf,
|
||||
idle: Mutex<Vec<Connection>>,
|
||||
}
|
||||
|
||||
/// How many idle read connections to keep. Enough that the steady state never
|
||||
/// reopens, small enough to be unremarkable.
|
||||
const MAX_IDLE_READERS: usize = 4;
|
||||
|
||||
impl ReadPool {
|
||||
fn take(&self) -> Option<Connection> {
|
||||
if let Some(conn) = self.idle.lock().pop() {
|
||||
return Some(conn);
|
||||
}
|
||||
match open_read_only(&self.path) {
|
||||
Ok(conn) => Some(conn),
|
||||
Err(error) => {
|
||||
// Falling back to the write connection is correct, just slower,
|
||||
// so this is a warning and not a failed read.
|
||||
tracing::warn!(%error, "could not open a read-only connection");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn put(&self, conn: Connection) {
|
||||
let mut idle = self.idle.lock();
|
||||
if idle.len() < MAX_IDLE_READERS {
|
||||
idle.push(conn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A connection to read through: one borrowed from [`ReadPool`], or the write
|
||||
/// connection when there is no pool (in-memory) or opening one failed.
|
||||
///
|
||||
/// Derefs to `Connection`, so a method converts by swapping `self.conn.lock()`
|
||||
/// for `self.read()` and nothing else.
|
||||
pub(super) struct Reader<'a> {
|
||||
pooled: Option<Connection>,
|
||||
writer: Option<parking_lot::MutexGuard<'a, Connection>>,
|
||||
pool: Option<&'a ReadPool>,
|
||||
}
|
||||
|
||||
impl std::ops::Deref for Reader<'_> {
|
||||
type Target = Connection;
|
||||
|
||||
fn deref(&self) -> &Connection {
|
||||
self.pooled
|
||||
.as_ref()
|
||||
.or(self.writer.as_deref())
|
||||
.expect("a Reader always holds one of the two")
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Reader<'_> {
|
||||
fn drop(&mut self) {
|
||||
if let (Some(conn), Some(pool)) = (self.pooled.take(), self.pool) {
|
||||
pool.put(conn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a second connection to the same file for reads only.
|
||||
///
|
||||
/// `SQLITE_OPEN_READ_ONLY` plus `query_only` is belt and braces: the flag stops
|
||||
/// a routing mistake at the file, the pragma stops it at the statement. The rest
|
||||
/// mirror [`Db::open_configured`] — a read connection has its own page cache,
|
||||
/// its own temp store and its own busy timeout, and gets nothing from the
|
||||
/// writer's.
|
||||
///
|
||||
/// `journal_mode` is deliberately absent: it is a property of the database file,
|
||||
/// which the writer already set to WAL, and a read-only connection cannot change
|
||||
/// it anyway.
|
||||
fn open_read_only(path: &Path) -> Result<Connection> {
|
||||
use rusqlite::OpenFlags;
|
||||
let conn = Connection::open_with_flags(
|
||||
path,
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
)?;
|
||||
conn.busy_timeout(BUSY_TIMEOUT)?;
|
||||
conn.pragma_update(None, "cache_size", -CACHE_SIZE_KIB)?;
|
||||
conn.pragma_update(None, "temp_store", "MEMORY")?;
|
||||
conn.pragma_update(None, "mmap_size", MMAP_SIZE)?;
|
||||
conn.pragma_update(None, "query_only", true)?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
impl Db {
|
||||
/// Open (creating if absent) the database at `path`, enable WAL, and bring
|
||||
/// the schema up to [`SCHEMA_VERSION`].
|
||||
///
|
||||
/// Takes the single-instance lock first (see
|
||||
/// [`acquire_single_writer_lock`]), and rebuilds from empty if what is on
|
||||
/// disk turns out not to be a readable database (see [`is_corrupt`]).
|
||||
pub fn open(path: &Path) -> Result<Self> {
|
||||
let single_writer = acquire_single_writer_lock(path)?;
|
||||
let conn = match Self::open_configured(path) {
|
||||
Ok(conn) => conn,
|
||||
Err(e) if is_corrupt(&e) => {
|
||||
// Every byte in here is derived from the account or from the
|
||||
// content cache directory, so the recovery is to start over —
|
||||
// loudly, and keeping the damaged file for a post-mortem rather
|
||||
// than deleting evidence.
|
||||
tracing::error!(
|
||||
db = %path.display(),
|
||||
error = %e,
|
||||
"cache database is corrupt — moving it aside and rebuilding \
|
||||
(metadata will be re-fetched; no user data is stored here)"
|
||||
);
|
||||
quarantine_corrupt_db(path)?;
|
||||
Self::open_configured(path)?
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
let db = Self {
|
||||
conn: Mutex::new(conn),
|
||||
// No pool for `:memory:`. A second connection to that name is a
|
||||
// second, empty database rather than another view of this one, so
|
||||
// every routed read would find a schema that does not exist.
|
||||
readers: (!is_in_memory(path)).then(|| ReadPool {
|
||||
path: path.to_path_buf(),
|
||||
idle: Mutex::new(Vec::new()),
|
||||
}),
|
||||
_single_writer: single_writer,
|
||||
};
|
||||
// A drain claim names a worker thread in the process that took it, and
|
||||
// the single-writer lock above says that process is not running. Any
|
||||
// claim still on disk is therefore a crashed run's, and leaving it would
|
||||
// hide those ops from every worker for good.
|
||||
match db.clear_op_claims() {
|
||||
Ok(0) => {}
|
||||
Ok(n) => tracing::info!(ops = n, "released drain claims left by a previous run"),
|
||||
Err(e) => tracing::warn!(error = %e, "clearing stale drain claims failed"),
|
||||
}
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
/// A connection for a `SELECT`-only method.
|
||||
///
|
||||
/// Reads see the last committed state, which is the same thing they saw
|
||||
/// through the write connection: every method here commits before it
|
||||
/// returns, and nothing holds a transaction open across calls.
|
||||
pub(super) fn read(&self) -> Reader<'_> {
|
||||
if let Some(pool) = &self.readers
|
||||
&& let Some(conn) = pool.take()
|
||||
{
|
||||
return Reader {
|
||||
pooled: Some(conn),
|
||||
writer: None,
|
||||
pool: Some(pool),
|
||||
};
|
||||
}
|
||||
Reader {
|
||||
pooled: None,
|
||||
writer: Some(self.conn.lock()),
|
||||
pool: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the file, apply every PRAGMA, and migrate. Split out of
|
||||
/// [`open`](Self::open) so the corruption path can retry it verbatim.
|
||||
fn open_configured(path: &Path) -> Result<Connection> {
|
||||
let conn = Connection::open(path)?;
|
||||
// WAL: readers never block the single writer. NORMAL sync is the
|
||||
// standard durability/throughput tradeoff for WAL.
|
||||
conn.pragma_update(None, "journal_mode", "WAL")?;
|
||||
conn.pragma_update(None, "synchronous", "NORMAL")?;
|
||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||
// Hand the WAL's disk back after a checkpoint. Without a limit SQLite
|
||||
// reuses the file in place but never shrinks it, so a single large
|
||||
// transaction (a full-index FTS rebuild in `local_finish_scan` is the
|
||||
// one that reaches this size) leaves the WAL at its high-water mark
|
||||
// forever — a multi-GB file next to a database two orders of magnitude
|
||||
// smaller. Checkpointing is unaffected; only the file is truncated back.
|
||||
conn.pragma_update(None, "journal_size_limit", WAL_SIZE_LIMIT)?;
|
||||
// Wait for a lock rather than failing instantly on SQLITE_BUSY.
|
||||
//
|
||||
// This is deliberately explicit and deliberately redundant: rusqlite
|
||||
// already applies a five-second busy timeout of its own, so this changes
|
||||
// nothing today. It is here so the value is *ours* — a behaviour the
|
||||
// daemon depends on should not be an inherited default that a dependency
|
||||
// bump can silently change. `open_bounds_the_wal_size` pins it.
|
||||
//
|
||||
// (Bare SQLite does default to 0, which is where the belief that this
|
||||
// was unset came from. That default is not what we get.)
|
||||
conn.busy_timeout(BUSY_TIMEOUT)?;
|
||||
// Page cache. The default is 2 MiB, against a schema whose hot table
|
||||
// stores a JSON blob per node — a listing of a few thousand files
|
||||
// evicts itself while it is being read.
|
||||
conn.pragma_update(None, "cache_size", -CACHE_SIZE_KIB)?;
|
||||
// Sorts and recursive CTEs materialise their intermediate results.
|
||||
// Every `ORDER BY … COLLATE NOCASE` listing and every ancestor walk
|
||||
// spills those to a file in `/tmp` by default; they are small and
|
||||
// short-lived, so memory is both faster and less surprising.
|
||||
conn.pragma_update(None, "temp_store", "MEMORY")?;
|
||||
// Read the database through the page cache of the OS rather than
|
||||
// `pread` per page. Bounded rather than unlimited so the daemon's RSS
|
||||
// does not track the database size.
|
||||
conn.pragma_update(None, "mmap_size", MMAP_SIZE)?;
|
||||
// Checkpointing is synchronous in whichever thread happens to commit
|
||||
// the transaction that crosses the threshold — often a FUSE callback.
|
||||
// A smaller autocheckpoint makes that debt smaller and more frequent
|
||||
// rather than rare and multi-second.
|
||||
conn.pragma_update(None, "wal_autocheckpoint", WAL_AUTOCHECKPOINT_PAGES)?;
|
||||
|
||||
let db = Self {
|
||||
conn: Mutex::new(conn),
|
||||
readers: None,
|
||||
_single_writer: None,
|
||||
};
|
||||
db.migrate()?;
|
||||
Ok(db.into_conn())
|
||||
}
|
||||
|
||||
/// Unwrap the connection back out of a temporary handle. Only
|
||||
/// [`open_configured`](Self::open_configured) uses it, to run `migrate`
|
||||
/// (which is written against `&self`) before the real handle exists.
|
||||
fn into_conn(self) -> Connection {
|
||||
self.conn.into_inner()
|
||||
}
|
||||
|
||||
/// Open an in-memory database. For tests.
|
||||
#[cfg(test)]
|
||||
pub fn open_in_memory() -> Result<Self> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
// Keep relational behavior identical to `open`: V18 relies on the
|
||||
// `mount.sync_folder_id` cascade, and SQLite disables foreign keys per
|
||||
// connection unless explicitly enabled.
|
||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||
let db = Self {
|
||||
conn: Mutex::new(conn),
|
||||
readers: None,
|
||||
_single_writer: None,
|
||||
};
|
||||
db.migrate()?;
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
/// Run a closure with the locked connection. Escape hatch for callers that
|
||||
/// need a query no typed method covers yet.
|
||||
pub fn with_conn<T>(&self, f: impl FnOnce(&Connection) -> Result<T>) -> Result<T> {
|
||||
let conn = self.conn.lock();
|
||||
f(&conn)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `path` names the in-memory database rather than a file. Both
|
||||
/// spellings SQLite accepts are checked, because neither has a lock file or a
|
||||
/// corruption story.
|
||||
fn is_in_memory(path: &Path) -> bool {
|
||||
let p = path.as_os_str();
|
||||
p == ":memory:" || p.is_empty()
|
||||
}
|
||||
|
||||
/// Take the single-instance write lock for the database at `path`.
|
||||
///
|
||||
/// The daemon is the only writer by design — the front-ends go through the
|
||||
/// control socket — but nothing stopped a hand-run `pdfs mount` from starting
|
||||
/// alongside the systemd unit, at which point two processes own the same inode
|
||||
/// space, the same content cache, and the same drain queue. SQLite's own
|
||||
/// locking makes each *statement* safe and does nothing about that.
|
||||
///
|
||||
/// `flock` on a sibling `.lock` file rather than on the database itself:
|
||||
/// SQLite uses POSIX record locks, so the two never interact, and a lock file
|
||||
/// left behind carries no state — the lock lives in the kernel and dies with
|
||||
/// the process, however it exits.
|
||||
fn acquire_single_writer_lock(path: &Path) -> Result<Option<File>> {
|
||||
if is_in_memory(path) {
|
||||
return Ok(None);
|
||||
}
|
||||
let lock_path = lock_path_for(path);
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.read(true)
|
||||
.truncate(false)
|
||||
.write(true)
|
||||
.open(&lock_path)?;
|
||||
// SAFETY: `file` owns the descriptor and outlives the call.
|
||||
let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
|
||||
if rc != 0 {
|
||||
let e = std::io::Error::last_os_error();
|
||||
if e.raw_os_error() == Some(libc::EWOULDBLOCK) {
|
||||
return Err(Error::Other(format!(
|
||||
"another Proton Drive daemon is already using {} — \
|
||||
stop it first (`systemctl --user stop proton-drive.service`)",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
return Err(Error::Io(e));
|
||||
}
|
||||
Ok(Some(file))
|
||||
}
|
||||
|
||||
fn lock_path_for(path: &Path) -> PathBuf {
|
||||
let mut name = path.as_os_str().to_owned();
|
||||
name.push(".lock");
|
||||
PathBuf::from(name)
|
||||
}
|
||||
|
||||
/// Whether `e` says the file is not a usable database, as opposed to any other
|
||||
/// failure. These are the only two conditions rebuilding fixes; a disk error, a
|
||||
/// permission problem or a newer-than-supported schema must all still fail.
|
||||
fn is_corrupt(e: &Error) -> bool {
|
||||
matches!(
|
||||
e,
|
||||
Error::Db(rusqlite::Error::SqliteFailure(inner, _))
|
||||
if inner.code == rusqlite::ErrorCode::DatabaseCorrupt
|
||||
|| inner.code == rusqlite::ErrorCode::NotADatabase
|
||||
)
|
||||
}
|
||||
|
||||
/// Move a damaged database (and its WAL sidecars) out of the way so the next
|
||||
/// open starts from empty.
|
||||
///
|
||||
/// Renamed rather than deleted: this is a cache, but it is also the only
|
||||
/// evidence of what went wrong, and it costs one file the user can remove.
|
||||
fn quarantine_corrupt_db(path: &Path) -> Result<()> {
|
||||
let stamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
for suffix in ["", "-wal", "-shm"] {
|
||||
let mut from = path.as_os_str().to_owned();
|
||||
from.push(suffix);
|
||||
let from = PathBuf::from(from);
|
||||
if !from.exists() {
|
||||
continue;
|
||||
}
|
||||
let mut to = path.as_os_str().to_owned();
|
||||
to.push(format!(".corrupt-{stamp}{suffix}"));
|
||||
let to = PathBuf::from(to);
|
||||
// A failure to rename the sidecars is survivable — SQLite discards a
|
||||
// WAL whose database header no longer matches — but failing to move the
|
||||
// database itself is not, because the retry would hit the same file.
|
||||
match std::fs::rename(&from, &to) {
|
||||
Ok(()) => tracing::warn!(from = %from.display(), to = %to.display(), "quarantined"),
|
||||
Err(e) if suffix.is_empty() => return Err(Error::Io(e)),
|
||||
Err(e) => tracing::warn!(file = %from.display(), error = %e, "could not quarantine"),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,955 @@
|
||||
//! The photos timeline: a flat, date-ordered projection of the photo share,
|
||||
//! persisted so the gallery opens instantly instead of re-enumerating on launch.
|
||||
|
||||
use rusqlite::params;
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use super::Db;
|
||||
use crate::Result;
|
||||
|
||||
/// A photo whose thumbnail state is not known yet: it has never been asked for.
|
||||
pub const THUMB_UNKNOWN: i64 = 0;
|
||||
/// A thumbnail exists for this photo — served by the server, or generated locally
|
||||
/// from the full file when the server had none.
|
||||
pub const THUMB_HAVE: i64 = 1;
|
||||
/// This photo can never be given a thumbnail: the server has none and the bytes
|
||||
/// could not be decoded locally either. Never retried.
|
||||
pub const THUMB_NONE: i64 = 2;
|
||||
|
||||
/// One photo of the persisted timeline. The timeline itself is server-ordered
|
||||
/// (newest first) and stored with that order in `seq`; `ratio` and `thumb_state`
|
||||
/// are locally learned and survive a refresh of the timeline.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct StoredPhoto {
|
||||
pub uid: String,
|
||||
pub capture_time: i64,
|
||||
pub name: Option<String>,
|
||||
/// Aspect ratio (w/h), known once a thumbnail has been decoded.
|
||||
pub ratio: Option<f64>,
|
||||
/// One of [`THUMB_UNKNOWN`] / [`THUMB_HAVE`] / [`THUMB_NONE`].
|
||||
pub thumb_state: i64,
|
||||
/// Which Photos-page tab this entry belongs to, derived from its name and
|
||||
/// media type when the timeline was last replaced.
|
||||
pub kind: crate::control::PhotoKind,
|
||||
/// Whether the photo carries Proton's `Favorite` tag.
|
||||
pub favorite: bool,
|
||||
/// How many photos this photo's group holds — one shot taken as RAW+JPEG is
|
||||
/// two files and one group. `1` for a photo that stands on its own.
|
||||
pub group_size: usize,
|
||||
/// Whether any member of the group is a camera raw file. The grid badges
|
||||
/// that, because the JPEG it shows is not the whole of what is stored.
|
||||
pub has_raw: bool,
|
||||
}
|
||||
|
||||
/// A town photos were taken in: its GeoNames id (see [`crate::places`]), how
|
||||
/// many photos it holds, and the newest of them.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct StoredPlace {
|
||||
pub id: u32,
|
||||
pub count: usize,
|
||||
pub cover: StoredPhoto,
|
||||
}
|
||||
|
||||
/// One row of a timeline replacement. Everything but the uid and the capture
|
||||
/// time is "what this refresh learned": a `None` keeps whatever is already
|
||||
/// stored rather than clearing it, because the timeline DTO carries only the uid
|
||||
/// and the capture time and resolving the node can fail.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct TimelineRow {
|
||||
pub uid: String,
|
||||
pub capture_time: i64,
|
||||
pub name: Option<String>,
|
||||
pub media_type: Option<String>,
|
||||
pub favorite: Option<bool>,
|
||||
/// The server's duplicate-detection hash for the plaintext.
|
||||
pub content_hash: Option<String>,
|
||||
/// The main photo this one is *related* to (a live-photo video, a burst
|
||||
/// sibling), when the server says it belongs to one.
|
||||
pub main_uid: Option<String>,
|
||||
/// Where the photo was taken, as `(latitude, longitude)` in decimal degrees.
|
||||
pub location: Option<(f64, f64)>,
|
||||
/// When this refresh resolved the photo's node, if it did at all. `Some`
|
||||
/// makes every other field authoritative — including a `None` that means
|
||||
/// "the server no longer reports one" — while `None` means the refresh
|
||||
/// skipped this photo and whatever is stored must be kept.
|
||||
pub resolved_at: Option<i64>,
|
||||
}
|
||||
|
||||
impl TimelineRow {
|
||||
/// A row carrying only what the timeline listing itself gives.
|
||||
pub fn new(uid: impl Into<String>, capture_time: i64) -> Self {
|
||||
Self {
|
||||
uid: uid.into(),
|
||||
capture_time,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a previous refresh learned about a photo and a new one must not lose.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct Learned {
|
||||
name: Option<String>,
|
||||
ratio: Option<f64>,
|
||||
thumb_state: i64,
|
||||
media_type: Option<String>,
|
||||
favorite: bool,
|
||||
content_hash: Option<String>,
|
||||
main_uid: Option<String>,
|
||||
location: Option<(f64, f64)>,
|
||||
/// What its thumbnail looks like (see [`crate::similar`]).
|
||||
similar_hash: Option<i64>,
|
||||
/// When this photo's node was last resolved, kept so a refresh that skips it
|
||||
/// does not make it look unresolved again.
|
||||
resolved_at: Option<i64>,
|
||||
}
|
||||
|
||||
/// The local calendar day a capture time falls on, as a `YYYY-MM-DD` string.
|
||||
/// Grouping by day rather than by exact second is deliberate: a camera writes
|
||||
/// the RAW and the JPEG of one shot a moment apart, and two shots of the same
|
||||
/// subject on different days are not one photo.
|
||||
fn capture_day(conn: &rusqlite::Connection, capture_time: i64) -> Option<String> {
|
||||
conn.query_row(
|
||||
"SELECT strftime('%Y-%m-%d', ?1, 'unixepoch', 'localtime')",
|
||||
params![capture_time],
|
||||
|r| r.get::<_, Option<String>>(0),
|
||||
)
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// The file name without its extension, lowercased — `IMG_1234.CR2` and
|
||||
/// `IMG_1234.JPG` share `img_1234` — with the camera's own part-of-a-shot
|
||||
/// marker removed, so the two files a raw capture writes share a stem too.
|
||||
fn name_stem(name: Option<&str>) -> Option<String> {
|
||||
let name = name?;
|
||||
let stem = match name.rsplit_once('.') {
|
||||
Some((stem, _)) if !stem.is_empty() => stem,
|
||||
_ => name,
|
||||
};
|
||||
let stem = stem.to_ascii_lowercase();
|
||||
Some(strip_shot_marker(&stem).to_string())
|
||||
}
|
||||
|
||||
/// Drop a `.RAW-NN.ROLE` marker from a stem.
|
||||
///
|
||||
/// Pixel stores one raw capture as `PXL_20260919_000625670.RAW-01.COVER.jpg`
|
||||
/// and `PXL_20260919_000625670.RAW-02.ORIGINAL.dng`: one shot, two files, two
|
||||
/// stems that differ in exactly the part naming which file of the shot this is.
|
||||
/// Cutting the marker off is what lets rule 2 see the pair. The check is narrow
|
||||
/// on purpose — digits, then a single role word — so an ordinary name that
|
||||
/// happens to contain `.raw-` keeps its stem.
|
||||
fn strip_shot_marker(stem: &str) -> &str {
|
||||
let Some(at) = stem.rfind(".raw-") else {
|
||||
return stem;
|
||||
};
|
||||
let rest = &stem[at + ".raw-".len()..];
|
||||
let Some((index, role)) = rest.split_once('.') else {
|
||||
return stem;
|
||||
};
|
||||
let marked = !index.is_empty()
|
||||
&& index.bytes().all(|b| b.is_ascii_digit())
|
||||
&& !role.is_empty()
|
||||
&& role.bytes().all(|b| b.is_ascii_alphabetic());
|
||||
if marked { &stem[..at] } else { stem }
|
||||
}
|
||||
|
||||
/// One entry of the grouping pass: what rule 2 needs, plus what choosing a
|
||||
/// representative needs.
|
||||
struct Grouped {
|
||||
uid: String,
|
||||
kind: crate::control::PhotoKind,
|
||||
main_uid: Option<String>,
|
||||
day: Option<String>,
|
||||
stem: Option<String>,
|
||||
}
|
||||
|
||||
/// Which group each photo belongs to, and which member of it the gallery shows.
|
||||
///
|
||||
/// Precedence, as agreed:
|
||||
///
|
||||
/// 1. The server's own relation — a photo with a `main_photo_uid` joins that
|
||||
/// photo's group. The server knows about live photos and bursts; we do not
|
||||
/// have to guess at them.
|
||||
/// 2. Otherwise, same capture day **and** same file-name stem **and** one of the
|
||||
/// two is a camera raw while the other is not. That is the RAW+JPEG pair a
|
||||
/// camera writes, and the differing-kind clause keeps two JPEGs that happen
|
||||
/// to share a name apart.
|
||||
/// 3. Otherwise the photo is its own group.
|
||||
///
|
||||
/// The representative is the non-raw member when the group has one — a JPEG
|
||||
/// decodes in milliseconds and is what the person expects to see — and the
|
||||
/// earliest member in server order otherwise.
|
||||
fn group_photos(entries: &[Grouped]) -> Vec<String> {
|
||||
let mut parent: Vec<usize> = (0..entries.len()).collect();
|
||||
fn find(parent: &mut [usize], mut i: usize) -> usize {
|
||||
while parent[i] != i {
|
||||
parent[i] = parent[parent[i]];
|
||||
i = parent[i];
|
||||
}
|
||||
i
|
||||
}
|
||||
let union = |parent: &mut [usize], a: usize, b: usize| {
|
||||
let (a, b) = (find(parent, a), find(parent, b));
|
||||
if a != b {
|
||||
parent[b] = a;
|
||||
}
|
||||
};
|
||||
|
||||
let index: HashMap<&str, usize> = entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, e)| (e.uid.as_str(), i))
|
||||
.collect();
|
||||
for (i, entry) in entries.iter().enumerate() {
|
||||
if let Some(main) = entry.main_uid.as_deref()
|
||||
&& let Some(&j) = index.get(main)
|
||||
{
|
||||
union(&mut parent, j, i);
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 2 needs only one pass: everything sharing a day and a stem lands in
|
||||
// the same bucket, and the raw / non-raw split is checked pairwise there.
|
||||
let mut by_name: HashMap<(&str, &str), Vec<usize>> = HashMap::new();
|
||||
for (i, entry) in entries.iter().enumerate() {
|
||||
if let (Some(day), Some(stem)) = (entry.day.as_deref(), entry.stem.as_deref()) {
|
||||
by_name.entry((day, stem)).or_default().push(i);
|
||||
}
|
||||
}
|
||||
let is_raw = |i: usize| entries[i].kind == crate::control::PhotoKind::Raw;
|
||||
for bucket in by_name.values() {
|
||||
for (n, &i) in bucket.iter().enumerate() {
|
||||
for &j in &bucket[n + 1..] {
|
||||
if is_raw(i) != is_raw(j) {
|
||||
union(&mut parent, i, j);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Representative per root, then the key every member carries.
|
||||
let mut representative: HashMap<usize, usize> = HashMap::new();
|
||||
for i in 0..entries.len() {
|
||||
let root = find(&mut parent, i);
|
||||
match representative.get(&root).copied() {
|
||||
Some(current) if !is_raw(current) || is_raw(i) => {}
|
||||
_ => {
|
||||
representative.insert(root, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
(0..entries.len())
|
||||
.map(|i| {
|
||||
let root = find(&mut parent, i);
|
||||
entries[representative[&root]].uid.clone()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The columns every photo read selects, with the group aggregates joined in.
|
||||
/// `g.n` is how many photos the group holds and `g.raw` whether any of them is a
|
||||
/// camera raw file — one query rather than a second lookup per tile.
|
||||
///
|
||||
/// `COALESCE(group_key, uid)` is what makes a row written before schema v30 its
|
||||
/// own group: the column is `NULL` until the next refresh computes it, and a
|
||||
/// `NULL` would otherwise match nothing and drop the photo off the page.
|
||||
const PHOTO_SELECT: &str = "SELECT p.uid, p.capture_time, p.name, p.ratio, p.thumb_state, \
|
||||
p.kind, p.favorite, g.n, g.raw \
|
||||
FROM photos p JOIN (SELECT COALESCE(group_key, uid) AS k, COUNT(*) AS n, \
|
||||
MAX(kind = 2) AS raw FROM photos GROUP BY k) g ON g.k = COALESCE(p.group_key, p.uid)";
|
||||
|
||||
/// Read one row of [`PHOTO_SELECT`].
|
||||
fn stored_photo(r: &rusqlite::Row<'_>) -> rusqlite::Result<StoredPhoto> {
|
||||
Ok(StoredPhoto {
|
||||
uid: r.get(0)?,
|
||||
capture_time: r.get(1)?,
|
||||
name: r.get(2)?,
|
||||
ratio: r.get(3)?,
|
||||
thumb_state: r.get(4)?,
|
||||
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
||||
favorite: r.get::<_, i64>(6)? != 0,
|
||||
group_size: r.get::<_, i64>(7)?.max(1) as usize,
|
||||
has_raw: r.get::<_, i64>(8)? != 0,
|
||||
})
|
||||
}
|
||||
|
||||
impl Db {
|
||||
/// Replace the timeline wholesale. A row whose `resolved_at` is `Some` was
|
||||
/// read from the server in this pass, so its fields are taken as they are —
|
||||
/// a `None` there means the server no longer reports that value. A row whose
|
||||
/// `resolved_at` is `None` was skipped or failed to resolve, and keeps
|
||||
/// whatever is already stored rather than having it silently cleared.
|
||||
pub fn photos_replace(&self, items: &[TimelineRow]) -> Result<()> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
// `name` and `media_type` are learned-and-kept like the ratio and thumb
|
||||
// verdict: the timeline DTO carries only the uid and capture time, so the
|
||||
// daemon may not know them when it replaces the timeline — because the
|
||||
// refresh skipped an already-resolved photo, or because resolving it
|
||||
// failed. Keep any previously learned value so the tab split and the
|
||||
// RAW+JPEG grouping survive a refresh instead of collapsing to nothing:
|
||||
// both are derived from the name. The photo relation is kept for the same
|
||||
// reason — losing it would break a group up until the next resolve.
|
||||
let learned: HashMap<String, Learned> = {
|
||||
let mut stmt = tx.prepare(
|
||||
"SELECT uid, name, ratio, thumb_state, media_type, favorite, content_hash, \
|
||||
main_uid, resolved_at, latitude, longitude, similar_hash FROM photos",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, String>(0)?,
|
||||
Learned {
|
||||
name: r.get(1)?,
|
||||
ratio: r.get(2)?,
|
||||
thumb_state: r.get(3)?,
|
||||
media_type: r.get(4)?,
|
||||
favorite: r.get::<_, i64>(5)? != 0,
|
||||
content_hash: r.get(6)?,
|
||||
main_uid: r.get(7)?,
|
||||
resolved_at: r.get(8)?,
|
||||
location: r
|
||||
.get::<_, Option<f64>>(9)?
|
||||
.zip(r.get::<_, Option<f64>>(10)?),
|
||||
similar_hash: r.get(11)?,
|
||||
},
|
||||
))
|
||||
})?;
|
||||
rows.collect::<rusqlite::Result<_>>()?
|
||||
};
|
||||
|
||||
// Everything the insert and the grouping need, resolved once per photo.
|
||||
let resolved: Vec<(&TimelineRow, Learned, crate::control::PhotoKind)> = items
|
||||
.iter()
|
||||
.map(|row| {
|
||||
let mut carried = learned.get(&row.uid).cloned().unwrap_or(Learned {
|
||||
thumb_state: THUMB_UNKNOWN,
|
||||
..Learned::default()
|
||||
});
|
||||
if row.resolved_at.is_some() {
|
||||
// This pass read the node itself, so what it says is the
|
||||
// truth — including what it no longer says. A photo unlinked
|
||||
// from its main on another device loses its `main_uid` here,
|
||||
// which a carry could never express.
|
||||
carried.name = row.name.clone();
|
||||
carried.media_type = row.media_type.clone();
|
||||
carried.favorite = row.favorite.unwrap_or(false);
|
||||
// New bytes may show another picture: hash it again.
|
||||
if row.content_hash != carried.content_hash {
|
||||
carried.similar_hash = None;
|
||||
}
|
||||
carried.content_hash = row.content_hash.clone();
|
||||
carried.main_uid = row.main_uid.clone();
|
||||
carried.location = row.location;
|
||||
} else {
|
||||
carried.name = row.name.clone().or(carried.name);
|
||||
carried.media_type = row.media_type.clone().or(carried.media_type);
|
||||
carried.favorite = row.favorite.unwrap_or(carried.favorite);
|
||||
carried.content_hash = row.content_hash.clone().or(carried.content_hash);
|
||||
carried.main_uid = row.main_uid.clone().or(carried.main_uid);
|
||||
carried.location = row.location.or(carried.location);
|
||||
}
|
||||
carried.resolved_at = row.resolved_at.or(carried.resolved_at);
|
||||
// The tab this photo lands in is derived here, once, so a page or
|
||||
// count query is a plain indexed `WHERE kind = ?` rather than a
|
||||
// reclassification of every row.
|
||||
let kind = crate::control::PhotoKind::classify(
|
||||
carried.name.as_deref(),
|
||||
carried.media_type.as_deref(),
|
||||
);
|
||||
(row, carried, kind)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let entries: Vec<Grouped> = resolved
|
||||
.iter()
|
||||
.map(|(row, carried, kind)| Grouped {
|
||||
uid: row.uid.clone(),
|
||||
kind: *kind,
|
||||
main_uid: carried.main_uid.clone(),
|
||||
day: capture_day(&tx, row.capture_time),
|
||||
stem: name_stem(carried.name.as_deref()),
|
||||
})
|
||||
.collect();
|
||||
let group_keys = group_photos(&entries);
|
||||
|
||||
tx.execute("DELETE FROM photos", [])?;
|
||||
{
|
||||
let mut stmt = tx.prepare(
|
||||
"INSERT INTO photos
|
||||
(uid, capture_time, name, ratio, thumb_state, seq, media_type, kind, favorite,
|
||||
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, ((row, carried, kind), group_key)) in
|
||||
resolved.iter().zip(group_keys.iter()).enumerate()
|
||||
{
|
||||
stmt.execute(params![
|
||||
row.uid,
|
||||
row.capture_time,
|
||||
carried.name,
|
||||
carried.ratio,
|
||||
carried.thumb_state,
|
||||
seq as i64,
|
||||
carried.media_type,
|
||||
kind.as_i64(),
|
||||
carried.favorite as i64,
|
||||
carried.content_hash,
|
||||
carried.main_uid,
|
||||
group_key,
|
||||
carried.resolved_at,
|
||||
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,
|
||||
])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The uids whose node metadata is already resolved, so a refresh can skip
|
||||
/// reading their nodes again. Everything else — a photo never seen before, or
|
||||
/// one a remote event marked stale — is resolved. That is what keeps a
|
||||
/// 12k-photo library off 63 round-trips of `enumerate_nodes` every refresh.
|
||||
pub fn photos_resolved(&self) -> Result<Vec<String>> {
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare("SELECT uid FROM photos WHERE resolved_at IS NOT NULL")?;
|
||||
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
|
||||
let mut uids = Vec::new();
|
||||
for row in rows {
|
||||
uids.push(row?);
|
||||
}
|
||||
Ok(uids)
|
||||
}
|
||||
|
||||
/// Mark photos as needing a fresh metadata resolve, and report how many rows
|
||||
/// that matched. This is how a remote change is recorded: the event feed says
|
||||
/// *which* node changed but never *what* changed about it, so the uid is
|
||||
/// written down and the next refresh reads the node.
|
||||
pub fn photos_mark_unresolved(&self, uids: &[String]) -> Result<usize> {
|
||||
if uids.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
let mut marked = 0;
|
||||
{
|
||||
let mut stmt = tx.prepare("UPDATE photos SET resolved_at = NULL WHERE uid = ?1")?;
|
||||
for uid in uids {
|
||||
marked += stmt.execute(params![uid])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(marked)
|
||||
}
|
||||
|
||||
/// The same for the whole timeline: every photo is resolved again on the next
|
||||
/// refresh. For a continuity loss, where the daemon cannot know what it
|
||||
/// missed, and for an explicit `refresh photos --full`.
|
||||
pub fn photos_mark_all_unresolved(&self) -> Result<usize> {
|
||||
let conn = self.conn.lock();
|
||||
Ok(conn.execute("UPDATE photos SET resolved_at = NULL", [])?)
|
||||
}
|
||||
|
||||
/// Forget photos that have been trashed, so the gallery does not show them
|
||||
/// until the next timeline refresh would have caught up.
|
||||
///
|
||||
/// Album membership goes with them: a trashed photo is gone from every album
|
||||
/// that held it, and leaving the rows behind would keep it on the album
|
||||
/// pages and in the album covers.
|
||||
pub fn photos_delete(&self, uids: &[String]) -> Result<usize> {
|
||||
if uids.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
let mut removed = 0;
|
||||
{
|
||||
let mut photos = tx.prepare("DELETE FROM photos WHERE uid = ?1")?;
|
||||
let mut albums = tx.prepare("DELETE FROM album_photos WHERE uid = ?1")?;
|
||||
for uid in uids {
|
||||
removed += photos.execute([uid])?;
|
||||
albums.execute([uid])?;
|
||||
}
|
||||
}
|
||||
// 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.read();
|
||||
// Built up so any combination of the optional filters is one indexed
|
||||
// query rather than a statement per case.
|
||||
let mut sql = String::from(PHOTO_SELECT);
|
||||
let mut binds: Vec<i64> = Vec::new();
|
||||
let mut conds: Vec<String> = Vec::new();
|
||||
if kind != Some(crate::control::PhotoKind::Raw) {
|
||||
conds.push("p.uid = COALESCE(p.group_key, p.uid)".to_string());
|
||||
}
|
||||
if let Some(k) = kind {
|
||||
binds.push(k.as_i64());
|
||||
conds.push(format!("p.kind = ?{}", binds.len()));
|
||||
}
|
||||
if let Some((from, to)) = range {
|
||||
binds.push(from);
|
||||
conds.push(format!("p.capture_time >= ?{}", binds.len()));
|
||||
binds.push(to);
|
||||
conds.push(format!("p.capture_time < ?{}", binds.len()));
|
||||
}
|
||||
if favorites {
|
||||
conds.push("p.favorite = 1".to_string());
|
||||
}
|
||||
// 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 ");
|
||||
sql.push_str(&conds.join(" AND "));
|
||||
}
|
||||
binds.push(limit as i64);
|
||||
let limit_pos = binds.len();
|
||||
binds.push(offset as i64);
|
||||
let offset_pos = binds.len();
|
||||
sql.push_str(&format!(
|
||||
" ORDER BY p.seq LIMIT ?{limit_pos} OFFSET ?{offset_pos}"
|
||||
));
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt
|
||||
.query_map(rusqlite::params_from_iter(binds), stored_photo)?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Every photo of one photo's group, in server order — the RAW and the JPEG
|
||||
/// of a shot, or just the photo itself when it stands alone. The lightbox
|
||||
/// switches between these, and deleting a tile trashes all of them.
|
||||
pub fn photos_group(&self, uid: &str) -> Result<Vec<StoredPhoto>> {
|
||||
let conn = self.read();
|
||||
let sql = format!(
|
||||
"{PHOTO_SELECT} WHERE COALESCE(p.group_key, p.uid) = \
|
||||
(SELECT COALESCE(group_key, uid) FROM photos WHERE uid = ?1) ORDER BY p.seq"
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt
|
||||
.query_map(params![uid], stored_photo)?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// The whole persisted timeline, in stored order — every file, not one row
|
||||
/// per group. Only for whole-library passes (the photo re-date); the gallery
|
||||
/// pages with [`Db::photos_page`].
|
||||
pub fn photos_all(&self) -> Result<Vec<StoredPhoto>> {
|
||||
let conn = self.read();
|
||||
let sql = format!("{PHOTO_SELECT} ORDER BY p.seq");
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt
|
||||
.query_map([], stored_photo)?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// The months the timeline spans, newest first, each with how many photos it
|
||||
/// holds — the data behind the date scrubber. Buckets are local-time
|
||||
/// `(year, month)` so they line up with the day headings the gallery draws
|
||||
/// and with the boundaries a front-end computes when it jumps to one. `kind`
|
||||
/// scopes the counts to one tab when set.
|
||||
pub fn photos_months(
|
||||
&self,
|
||||
kind: Option<crate::control::PhotoKind>,
|
||||
) -> Result<Vec<(i32, i32, usize)>> {
|
||||
let conn = self.read();
|
||||
// The scrubber has to agree with the page it scrubs, so it counts what
|
||||
// the page shows: groups everywhere except on the Raw tab.
|
||||
let (filter, binds): (&str, Vec<i64>) = match kind {
|
||||
Some(crate::control::PhotoKind::Raw) => (" WHERE kind = 2", Vec::new()),
|
||||
Some(k) => (
|
||||
" WHERE uid = COALESCE(group_key, uid) AND kind = ?1",
|
||||
vec![k.as_i64()],
|
||||
),
|
||||
None => (" WHERE uid = COALESCE(group_key, uid)", Vec::new()),
|
||||
};
|
||||
let sql = format!(
|
||||
"SELECT CAST(strftime('%Y', capture_time, 'unixepoch', 'localtime') AS INTEGER) AS y, \
|
||||
CAST(strftime('%m', capture_time, 'unixepoch', 'localtime') AS INTEGER) AS m, \
|
||||
COUNT(*) \
|
||||
FROM photos{filter} GROUP BY y, m ORDER BY y DESC, m DESC"
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt
|
||||
.query_map(rusqlite::params_from_iter(binds), |r| {
|
||||
Ok((
|
||||
r.get::<_, i32>(0)?,
|
||||
r.get::<_, i32>(1)?,
|
||||
r.get::<_, i64>(2)? as usize,
|
||||
))
|
||||
})?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Photos taken on `today`'s calendar day in earlier years, newest first —
|
||||
/// the "On this day" strip. `today` is epoch seconds: its local month and day
|
||||
/// pick the date, and its own year is left out, because today's photos are
|
||||
/// what the timeline already opens on. One row per group, like the timeline.
|
||||
pub fn photos_on_this_day(&self, today: i64, limit: usize) -> Result<Vec<StoredPhoto>> {
|
||||
let conn = self.read();
|
||||
let sql = format!(
|
||||
"{PHOTO_SELECT} WHERE p.uid = COALESCE(p.group_key, p.uid) \
|
||||
AND strftime('%m-%d', p.capture_time, 'unixepoch', 'localtime') \
|
||||
= strftime('%m-%d', ?1, 'unixepoch', 'localtime') \
|
||||
AND strftime('%Y', p.capture_time, 'unixepoch', 'localtime') \
|
||||
< strftime('%Y', ?1, 'unixepoch', 'localtime') \
|
||||
ORDER BY p.seq LIMIT ?2"
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt
|
||||
.query_map(params![today, limit as i64], stored_photo)?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Every town photos were taken in, with how many and the newest one as its
|
||||
/// cover, most photographed first. Counted per group, like the timeline;
|
||||
/// photos without a location, or taken far from any town, are in none.
|
||||
pub fn photo_places(&self) -> Result<Vec<StoredPlace>> {
|
||||
let rows: Vec<(u32, usize, String)> = {
|
||||
let conn = self.read();
|
||||
// SQLite fills the bare `uid` from the row `MIN(seq)` picked, which
|
||||
// is the newest photo of the place.
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT place_id, COUNT(*), uid, MIN(seq) FROM photos \
|
||||
WHERE uid = COALESCE(group_key, uid) AND place_id IS NOT NULL \
|
||||
GROUP BY place_id ORDER BY COUNT(*) DESC, MIN(seq)",
|
||||
)?;
|
||||
stmt.query_map([], |r| {
|
||||
Ok((r.get(0)?, r.get::<_, i64>(1)? as usize, r.get(2)?))
|
||||
})?
|
||||
.collect::<rusqlite::Result<_>>()?
|
||||
};
|
||||
let uids: Vec<String> = rows.iter().map(|(_, _, uid)| uid.clone()).collect();
|
||||
let mut covers: HashMap<String, StoredPhoto> = self
|
||||
.photos_by_uid(&uids)?
|
||||
.into_iter()
|
||||
.map(|photo| (photo.uid.clone(), photo))
|
||||
.collect();
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.filter_map(|(id, count, uid)| {
|
||||
Some(StoredPlace {
|
||||
id,
|
||||
count,
|
||||
cover: covers.remove(&uid)?,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// One page of the photos taken in a town, newest first, one per group.
|
||||
pub fn photos_at_place(
|
||||
&self,
|
||||
id: u32,
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
) -> Result<Vec<StoredPhoto>> {
|
||||
let conn = self.read();
|
||||
let sql = format!(
|
||||
"{PHOTO_SELECT} WHERE p.uid = COALESCE(p.group_key, p.uid) AND p.place_id = ?1 \
|
||||
ORDER BY p.seq LIMIT ?2 OFFSET ?3"
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt
|
||||
.query_map(params![id, limit as i64, offset as i64], stored_photo)?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Every photo taken in a town, one per group as in [`Self::photos_at_place`],
|
||||
/// with where exactly it was taken. A group's own location is that of the
|
||||
/// photo standing for it.
|
||||
pub fn photo_locations_at_place(&self, id: u32) -> Result<Vec<(StoredPhoto, (f64, f64))>> {
|
||||
let photos = self.photos_at_place(id, 0, usize::MAX >> 1)?;
|
||||
let located: HashMap<String, (f64, f64)> = {
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT uid, latitude, longitude FROM photos \
|
||||
WHERE place_id = ?1 AND latitude IS NOT NULL AND longitude IS NOT NULL",
|
||||
)?;
|
||||
stmt.query_map(params![id], |r| Ok((r.get(0)?, (r.get(1)?, r.get(2)?))))?
|
||||
.collect::<rusqlite::Result<_>>()?
|
||||
};
|
||||
Ok(photos
|
||||
.into_iter()
|
||||
.filter_map(|photo| {
|
||||
let at = *located.get(&photo.uid)?;
|
||||
Some((photo, at))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Sets of photos with the same content hash, each set ordered with the
|
||||
/// copy worth keeping first, and the sets newest first.
|
||||
///
|
||||
/// The copy to keep is the one in the most albums, then the one that is part
|
||||
/// of a larger group (the JPEG's RAW is filed with it, a stray re-upload is
|
||||
/// not), then the earliest in server order. At most one file per group is
|
||||
/// listed: the files of one RAW+JPEG shot are not copies of each other, and a
|
||||
/// set left with one member is no set.
|
||||
pub fn photo_duplicates(&self) -> Result<Vec<Vec<StoredPhoto>>> {
|
||||
let rows: Vec<(String, String, String)> = {
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT p.uid, p.content_hash, COALESCE(p.group_key, p.uid) AS k FROM photos p \
|
||||
WHERE p.content_hash IN (SELECT content_hash FROM photos \
|
||||
WHERE content_hash IS NOT NULL GROUP BY content_hash HAVING COUNT(*) > 1) \
|
||||
ORDER BY p.content_hash, \
|
||||
(SELECT COUNT(*) FROM album_photos a WHERE a.uid = p.uid) DESC, \
|
||||
(SELECT COUNT(*) FROM photos m WHERE COALESCE(m.group_key, m.uid) = COALESCE(p.group_key, p.uid)) DESC, \
|
||||
p.seq",
|
||||
)?;
|
||||
stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?
|
||||
.collect::<rusqlite::Result<_>>()?
|
||||
};
|
||||
let uids: Vec<String> = rows.iter().map(|(uid, _, _)| uid.clone()).collect();
|
||||
let mut stored: HashMap<String, StoredPhoto> = self
|
||||
.photos_by_uid(&uids)?
|
||||
.into_iter()
|
||||
.map(|photo| (photo.uid.clone(), photo))
|
||||
.collect();
|
||||
|
||||
let mut sets: Vec<Vec<StoredPhoto>> = Vec::new();
|
||||
let mut hash_of_set: Option<&str> = None;
|
||||
let mut groups_in_set: HashSet<&str> = HashSet::new();
|
||||
for (uid, hash, group) in &rows {
|
||||
if hash_of_set != Some(hash.as_str()) {
|
||||
hash_of_set = Some(hash);
|
||||
groups_in_set.clear();
|
||||
sets.push(Vec::new());
|
||||
}
|
||||
if !groups_in_set.insert(group) {
|
||||
continue;
|
||||
}
|
||||
if let (Some(set), Some(photo)) = (sets.last_mut(), stored.remove(uid)) {
|
||||
set.push(photo);
|
||||
}
|
||||
}
|
||||
sets.retain(|set| set.len() > 1);
|
||||
sets.sort_by_key(|set| std::cmp::Reverse(set.iter().map(|p| p.capture_time).max()));
|
||||
Ok(sets)
|
||||
}
|
||||
|
||||
/// Shots whose look hasn't been hashed yet, by the uid of the photo the
|
||||
/// grid shows for each, newest first, at most `limit`. Videos, and photos
|
||||
/// with no thumbnail to hash, are left out. A shot counts as hashed once
|
||||
/// any of its files is, since they all show one picture.
|
||||
pub fn photos_unhashed(&self, limit: usize) -> Result<Vec<StoredPhoto>> {
|
||||
let uids: Vec<String> = {
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT COALESCE(group_key, uid) AS k FROM photos \
|
||||
GROUP BY k \
|
||||
HAVING MAX(similar_hash) IS NULL \
|
||||
AND MAX(kind != ?1 AND thumb_state != ?2) \
|
||||
ORDER BY MAX(capture_time) DESC LIMIT ?3",
|
||||
)?;
|
||||
stmt.query_map(
|
||||
params![
|
||||
crate::control::PhotoKind::Video.as_i64(),
|
||||
THUMB_NONE,
|
||||
limit as i64
|
||||
],
|
||||
|r| r.get(0),
|
||||
)?
|
||||
.collect::<rusqlite::Result<_>>()?
|
||||
};
|
||||
self.photos_by_uid(&uids)
|
||||
}
|
||||
|
||||
/// Record the look of photo `uid`: its difference hash, or
|
||||
/// [`crate::similar::NO_HASH`] when its thumbnail gave none.
|
||||
pub fn photo_set_similar_hash(&self, uid: &str, hash: u64) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"UPDATE photos SET similar_hash = ?2 WHERE uid = ?1",
|
||||
params![uid, hash as i64],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// How many shots have been hashed, out of how many could be.
|
||||
pub fn similar_progress(&self) -> Result<(usize, usize)> {
|
||||
let conn = self.read();
|
||||
let (hashed, total): (i64, i64) = conn.query_row(
|
||||
"SELECT COUNT(*) FILTER (WHERE hashed), COUNT(*) FROM \
|
||||
(SELECT MAX(similar_hash) IS NOT NULL AS hashed FROM photos \
|
||||
GROUP BY COALESCE(group_key, uid) \
|
||||
HAVING MAX(similar_hash) IS NOT NULL \
|
||||
OR MAX(kind != ?1 AND thumb_state != ?2))",
|
||||
params![crate::control::PhotoKind::Video.as_i64(), THUMB_NONE],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)?;
|
||||
Ok((hashed.max(0) as usize, total.max(0) as usize))
|
||||
}
|
||||
|
||||
/// Sets of shots that look alike (see [`crate::similar`]), one photo per
|
||||
/// shot in capture order, and the sets newest first. A set whose photos
|
||||
/// are all byte-identical copies is left to [`Self::photo_duplicates`].
|
||||
pub fn photo_similar(&self) -> Result<Vec<Vec<StoredPhoto>>> {
|
||||
let rows: Vec<(String, u64, Option<String>)> = {
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT COALESCE(group_key, uid) AS k, MAX(similar_hash), \
|
||||
MAX(content_hash) FILTER (WHERE uid = COALESCE(group_key, uid)) \
|
||||
FROM photos GROUP BY k \
|
||||
HAVING MAX(similar_hash) IS NOT NULL AND MAX(similar_hash) != ?1",
|
||||
)?;
|
||||
stmt.query_map(params![crate::similar::NO_HASH as i64], |r| {
|
||||
Ok((r.get(0)?, r.get::<_, i64>(1)? as u64, r.get(2)?))
|
||||
})?
|
||||
.collect::<rusqlite::Result<_>>()?
|
||||
};
|
||||
let hashes: Vec<u64> = rows.iter().map(|(_, hash, _)| *hash).collect();
|
||||
let found: Vec<Vec<usize>> = crate::similar::sets(&hashes)
|
||||
.into_iter()
|
||||
.filter(|set| {
|
||||
let first = &rows[set[0]].2;
|
||||
first.is_none() || set.iter().any(|&i| rows[i].2 != *first)
|
||||
})
|
||||
.collect();
|
||||
let uids: Vec<String> = found.iter().flatten().map(|&i| rows[i].0.clone()).collect();
|
||||
let mut stored: HashMap<String, StoredPhoto> = self
|
||||
.photos_by_uid(&uids)?
|
||||
.into_iter()
|
||||
.map(|photo| (photo.uid.clone(), photo))
|
||||
.collect();
|
||||
let mut sets: Vec<Vec<StoredPhoto>> = found
|
||||
.iter()
|
||||
.map(|set| {
|
||||
let mut photos: Vec<StoredPhoto> = set
|
||||
.iter()
|
||||
.filter_map(|&i| stored.remove(&rows[i].0))
|
||||
.collect();
|
||||
photos.sort_by_key(|p| p.capture_time);
|
||||
photos
|
||||
})
|
||||
.filter(|set| set.len() > 1)
|
||||
.collect();
|
||||
sets.sort_by_key(|set| std::cmp::Reverse(set.iter().map(|p| p.capture_time).max()));
|
||||
Ok(sets)
|
||||
}
|
||||
|
||||
/// Per-tab counts for the Photos page subtitle: `(photos, videos, raw)`.
|
||||
pub fn photos_counts(&self) -> Result<(usize, usize, usize)> {
|
||||
use crate::control::PhotoKind;
|
||||
let conn = self.read();
|
||||
// Photos and Videos count groups — one shot is one entry, however many
|
||||
// files it was stored as. Raw counts files, because the Raw tab lists
|
||||
// them individually.
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT kind, COUNT(*) FROM photos \
|
||||
WHERE uid = COALESCE(group_key, uid) OR kind = 2 GROUP BY kind",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?;
|
||||
let (mut photos, mut videos, mut raw) = (0usize, 0usize, 0usize);
|
||||
for row in rows {
|
||||
let (kind, n) = row?;
|
||||
match PhotoKind::from_i64(kind) {
|
||||
PhotoKind::Photo => photos = n as usize,
|
||||
PhotoKind::Video => videos = n as usize,
|
||||
PhotoKind::Raw => raw = n as usize,
|
||||
}
|
||||
}
|
||||
Ok((photos, videos, raw))
|
||||
}
|
||||
|
||||
/// The stored photos for `uids`, in no particular order. Used by the thumbnail
|
||||
/// path, which needs each photo's capture time (the cache validity tag) and
|
||||
/// its thumbnail verdict.
|
||||
pub fn photos_by_uid(&self, uids: &[String]) -> Result<Vec<StoredPhoto>> {
|
||||
if uids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let conn = self.read();
|
||||
let placeholders = vec!["?"; uids.len()].join(",");
|
||||
let sql = format!("{PHOTO_SELECT} WHERE p.uid IN ({placeholders})");
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(rusqlite::params_from_iter(uids), stored_photo)?;
|
||||
let mut photos = Vec::new();
|
||||
for row in rows {
|
||||
photos.push(row?);
|
||||
}
|
||||
Ok(photos)
|
||||
}
|
||||
|
||||
/// Record a photo's 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.
|
||||
pub fn photo_set_thumb(&self, uid: &str, state: i64, ratio: Option<f64>) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"UPDATE photos SET thumb_state = ?2, ratio = COALESCE(?3, ratio) WHERE uid = ?1",
|
||||
params![uid, state, ratio],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Number of photos in the persisted timeline.
|
||||
pub fn photos_count(&self) -> Result<usize> {
|
||||
let conn = self.read();
|
||||
let n: i64 = conn.query_row("SELECT COUNT(*) FROM photos", [], |r| r.get(0))?;
|
||||
Ok(n.max(0) as usize)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
//! Pinned nodes: files and folders the user has asked to keep on disk, exempt
|
||||
//! from LRU eviction. A recursive pin covers a whole subtree.
|
||||
|
||||
use rusqlite::{OptionalExtension, params};
|
||||
|
||||
use super::Db;
|
||||
use super::utils::MAX_PATH_DEPTH;
|
||||
use crate::Result;
|
||||
|
||||
/// One row of [`Db::pin_list`]. `recursive` is pin *policy* — whether the whole
|
||||
/// subtree is kept — while `is_dir` is the node's kind as the node cache knows
|
||||
/// it, or `None` for a pin whose node is not cached. Reading the former as the
|
||||
/// latter is what made a non-recursively pinned folder render as a file.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PinRow {
|
||||
pub uid: String,
|
||||
pub path: String,
|
||||
pub recursive: bool,
|
||||
pub is_dir: Option<bool>,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
pub fn pin_add(&self, uid: &str, path: &str, recursive: bool) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"INSERT INTO pins (uid, path, recursive) VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(uid) DO UPDATE SET
|
||||
path = excluded.path, recursive = excluded.recursive",
|
||||
params![uid, path, recursive as i64],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop `uid`'s pin row. Returns whether a row existed.
|
||||
pub fn pin_remove(&self, uid: &str) -> Result<bool> {
|
||||
let conn = self.conn.lock();
|
||||
let n = conn.execute("DELETE FROM pins WHERE uid = ?1", params![uid])?;
|
||||
Ok(n > 0)
|
||||
}
|
||||
|
||||
/// Every directly-pinned entry, ordered by uid.
|
||||
pub fn pin_list(&self) -> Result<Vec<PinRow>> {
|
||||
let conn = self.read();
|
||||
// `recursive` is pin policy; the node's kind comes from `nodes`, and is
|
||||
// NULL for a pin whose node was never cached.
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT p.uid, p.path, p.recursive, n.is_dir
|
||||
FROM pins p LEFT JOIN nodes n ON n.uid = p.uid
|
||||
ORDER BY p.uid",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok(PinRow {
|
||||
uid: r.get(0)?,
|
||||
path: r.get(1)?,
|
||||
recursive: r.get::<_, i64>(2)? != 0,
|
||||
is_dir: r.get::<_, Option<i64>>(3)?.map(|is_dir| is_dir != 0),
|
||||
})
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
out.push(r?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Whether `uid` is pinned — directly, or because a strict ancestor folder
|
||||
/// is pinned recursively. The ancestor check walks `parent_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.read();
|
||||
let direct: Option<i64> = conn
|
||||
.query_row("SELECT 1 FROM pins WHERE uid = ?1", params![uid], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.optional()?;
|
||||
if direct.is_some() {
|
||||
return Ok(true);
|
||||
}
|
||||
let anc: Option<i64> = conn
|
||||
.query_row(
|
||||
&format!(
|
||||
"WITH RECURSIVE anc(uid, parent_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),
|
||||
)
|
||||
.optional()?;
|
||||
Ok(anc.is_some())
|
||||
}
|
||||
|
||||
/// Every uid that is pinned, directly or transitively: all direct pins plus
|
||||
/// every descendant of a recursively-pinned folder. Used to build the
|
||||
/// eviction-exempt set (the budget enforcer hashes these into cache keys).
|
||||
pub fn pinned_uids(&self) -> Result<Vec<String>> {
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"WITH RECURSIVE sub(uid, 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, n.lid FROM nodes n JOIN sub ON n.parent_lid = sub.lid
|
||||
)
|
||||
SELECT uid FROM sub
|
||||
UNION
|
||||
SELECT uid FROM pins",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
out.push(r?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Every descendant uid of `folder` (all depths), via a `parent_lid` CTE.
|
||||
/// Used to evict a recursively-pinned subtree's cached blobs on unpin.
|
||||
pub fn descendants(&self, folder: &str) -> Result<Vec<String>> {
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"WITH RECURSIVE sub(uid, 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, n.lid FROM nodes n JOIN sub ON n.parent_lid = sub.lid
|
||||
)
|
||||
SELECT uid FROM sub",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![folder], |r| r.get::<_, String>(0))?;
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
out.push(r?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ---- local (non-Drive) file index (schema v6) ----
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//! The `state` key/value table: small scalars that outlive a mount — the volume
|
||||
//! event cursor, listing freshness stamps, the local-index generation.
|
||||
|
||||
use rusqlite::{OptionalExtension, params};
|
||||
|
||||
use super::Db;
|
||||
use crate::Result;
|
||||
|
||||
impl Db {
|
||||
pub fn get_event_cursor(&self) -> Result<Option<String>> {
|
||||
let conn = self.read();
|
||||
let v = conn
|
||||
.query_row(
|
||||
"SELECT value FROM sync_state WHERE key = 'event_cursor'",
|
||||
[],
|
||||
|r| r.get::<_, String>(0),
|
||||
)
|
||||
.optional()?;
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// Persist the incremental-sync cursor after a batch of events is applied.
|
||||
pub fn set_event_cursor(&self, cursor: &str) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (key, value) VALUES ('event_cursor', ?1)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
params![cursor],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a `sync_state` value as a string.
|
||||
pub fn state_str(&self, key: &str) -> Result<Option<String>> {
|
||||
let conn = self.read();
|
||||
let v = conn
|
||||
.query_row(
|
||||
"SELECT value FROM sync_state WHERE key = ?1",
|
||||
params![key],
|
||||
|r| r.get::<_, String>(0),
|
||||
)
|
||||
.optional()?;
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// Write a `sync_state` string value.
|
||||
pub fn set_state_str(&self, key: &str, value: &str) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (key, value) VALUES (?1, ?2)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
params![key, value],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Queue an op to be performed by the drain worker, returning its row id.
|
||||
///
|
||||
/// One *revision* op per node: a second write to the same file before the
|
||||
/// first has drained replaces it, since the newer blob already contains
|
||||
/// everything the older one did. The superseded blob's path is returned so
|
||||
/// the caller can delete it.
|
||||
///
|
||||
/// Rows supersede only their own kind, and only the kinds
|
||||
/// [`op_supersedes`] names. A create op for the same uid must survive —
|
||||
/// dropping it would leave a file that exists nowhere but this machine with
|
||||
/// nothing left to create it. (Writes to a node that is itself still queued
|
||||
pub fn state_i64(&self, key: &str) -> Result<Option<i64>> {
|
||||
let conn = self.read();
|
||||
let v: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT value FROM sync_state WHERE key = ?1",
|
||||
params![key],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
Ok(v.and_then(|v| v.parse().ok()))
|
||||
}
|
||||
|
||||
/// Write a `sync_state` integer value.
|
||||
pub fn set_state_i64(&self, key: &str, value: i64) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (key, value) VALUES (?1, ?2)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
params![key, value.to_string()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop a `sync_state` key, so whatever it stamped counts as never fetched.
|
||||
pub fn clear_state(&self, key: &str) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute("DELETE FROM sync_state WHERE key = ?1", params![key])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop every `sync_state` key starting with `prefix` — the per-entity
|
||||
/// stamps (one album, one shared folder) that are keyed by uid and so have
|
||||
/// no fixed name to clear individually.
|
||||
pub fn clear_state_prefix(&self, prefix: &str) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
// `\` escapes the LIKE wildcards, so a prefix containing `%` or `_`
|
||||
// (a uid never does today, but this is not the place to rely on that)
|
||||
// matches literally instead of sweeping unrelated keys.
|
||||
let pattern = format!(
|
||||
"{}%",
|
||||
prefix
|
||||
.replace('\\', "\\\\")
|
||||
.replace('%', "\\%")
|
||||
.replace('_', "\\_")
|
||||
);
|
||||
conn.execute(
|
||||
"DELETE FROM sync_state WHERE key LIKE ?1 ESCAPE '\\'",
|
||||
params![pattern],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
//! Folder-sync bookkeeping: the folders this device mirrors, and the per-file
|
||||
//! state each pass reconciles against.
|
||||
|
||||
use rusqlite::{OptionalExtension, params};
|
||||
|
||||
use super::Db;
|
||||
use crate::Result;
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub struct StoredSyncFolder {
|
||||
pub id: i64,
|
||||
pub local_path: String,
|
||||
pub remote_uid: String,
|
||||
pub remote_share_id: String,
|
||||
/// `mirror` (full local copy, two-way synced) or `ondemand` (FUSE mount).
|
||||
pub mode: String,
|
||||
/// A mode the user asked for that could not be applied on the spot — the
|
||||
/// folder was mid-pass, or had un-uploaded changes. The engine applies it
|
||||
/// once the folder is safe to switch, so the request is queued rather than
|
||||
/// rejected. `None` when the folder is where the user wants it.
|
||||
pub pending_mode: Option<String>,
|
||||
/// `idle` | `syncing` | `error` | `conflict`.
|
||||
pub state: String,
|
||||
pub last_sync: i64,
|
||||
}
|
||||
|
||||
/// One per-file sync baseline row: what a path looked like on both sides at the
|
||||
/// last successful sync, so the next reconcile can tell which side changed.
|
||||
/// `remote_rev`/`remote_hash` hold the remote signature —
|
||||
/// its modification time and size as strings — since no cheap content hash is
|
||||
/// exposed; change detection is `(mtime, size)` on each side.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct StoredSyncEntry {
|
||||
pub rel_path: String,
|
||||
pub remote_uid: Option<String>,
|
||||
pub local_mtime: i64,
|
||||
pub local_size: i64,
|
||||
/// The same modification time to nanosecond precision, when it is known.
|
||||
///
|
||||
/// `local_mtime` alone cannot tell an edit that landed inside the same
|
||||
/// second as the last sync — and left the file the same length — from no
|
||||
/// edit at all, so those never uploaded (`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>,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
pub fn sync_folder_add(
|
||||
&self,
|
||||
local_path: &str,
|
||||
remote_uid: &str,
|
||||
remote_share_id: &str,
|
||||
) -> Result<i64> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"INSERT INTO sync_folder (local_path, remote_uid, remote_share_id)
|
||||
VALUES (?1, ?2, ?3)",
|
||||
params![local_path, remote_uid, remote_share_id],
|
||||
)?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
/// Every synced folder, oldest first.
|
||||
pub fn sync_folder_list(&self) -> Result<Vec<StoredSyncFolder>> {
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync
|
||||
FROM sync_folder ORDER BY id",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok(StoredSyncFolder {
|
||||
id: r.get(0)?,
|
||||
local_path: r.get(1)?,
|
||||
remote_uid: r.get(2)?,
|
||||
remote_share_id: r.get(3)?,
|
||||
mode: r.get(4)?,
|
||||
pending_mode: r.get(5)?,
|
||||
state: r.get(6)?,
|
||||
last_sync: r.get(7)?,
|
||||
})
|
||||
})?;
|
||||
let mut items = Vec::new();
|
||||
for row in rows {
|
||||
items.push(row?);
|
||||
}
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
/// Look up one synced folder by id.
|
||||
pub fn sync_folder_get(&self, id: i64) -> Result<Option<StoredSyncFolder>> {
|
||||
let conn = self.read();
|
||||
conn.query_row(
|
||||
"SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync
|
||||
FROM sync_folder WHERE id = ?1",
|
||||
params![id],
|
||||
|r| {
|
||||
Ok(StoredSyncFolder {
|
||||
id: r.get(0)?,
|
||||
local_path: r.get(1)?,
|
||||
remote_uid: r.get(2)?,
|
||||
remote_share_id: r.get(3)?,
|
||||
mode: r.get(4)?,
|
||||
pending_mode: r.get(5)?,
|
||||
state: r.get(6)?,
|
||||
last_sync: r.get(7)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Remove a synced folder and its per-file baseline.
|
||||
pub fn sync_folder_remove(&self, id: i64) -> Result<bool> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute("DELETE FROM sync_entry WHERE folder_id = ?1", params![id])?;
|
||||
let n = tx.execute("DELETE FROM sync_folder WHERE id = ?1", params![id])?;
|
||||
tx.commit()?;
|
||||
Ok(n > 0)
|
||||
}
|
||||
|
||||
/// Update a synced folder's mode (`mirror`/`ondemand`). A queued request for
|
||||
/// that mode is satisfied and cleared in the same write. An opposite pending
|
||||
/// mode arrived after this transition started, so preserve it as the newer
|
||||
/// last-request intent.
|
||||
pub fn sync_folder_set_mode(&self, id: i64, mode: &str) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"UPDATE sync_folder
|
||||
SET mode = ?2,
|
||||
pending_mode = CASE WHEN pending_mode = ?2 THEN NULL ELSE pending_mode END
|
||||
WHERE id = ?1",
|
||||
params![id, mode],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"UPDATE sync_folder SET pending_mode = ?2 WHERE id = ?1",
|
||||
params![id, mode],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Withdraw a queued mode only if it is still the intent the caller
|
||||
/// inspected. A newer request must survive an older settlement attempt.
|
||||
pub fn sync_folder_clear_pending_mode_if(&self, id: i64, expected: &str) -> Result<bool> {
|
||||
let conn = self.conn.lock();
|
||||
let changed = conn.execute(
|
||||
"UPDATE sync_folder SET pending_mode = NULL
|
||||
WHERE id = ?1 AND pending_mode = ?2",
|
||||
params![id, expected],
|
||||
)?;
|
||||
Ok(changed > 0)
|
||||
}
|
||||
|
||||
/// Update a synced folder's state and stamp `last_sync` to now.
|
||||
pub fn sync_folder_set_state(&self, id: i64, state: &str, last_sync: i64) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"UPDATE sync_folder SET state = ?2, last_sync = ?3 WHERE id = ?1",
|
||||
params![id, state, last_sync],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The whole per-file sync baseline for a folder, keyed by relative path.
|
||||
pub fn sync_entries(&self, folder_id: i64) -> Result<HashMap<String, StoredSyncEntry>> {
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash,
|
||||
local_mtime_ns
|
||||
FROM sync_entry WHERE folder_id = ?1",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![folder_id], |r| {
|
||||
Ok(StoredSyncEntry {
|
||||
rel_path: r.get(0)?,
|
||||
remote_uid: r.get(1)?,
|
||||
local_mtime: r.get(2)?,
|
||||
local_size: r.get(3)?,
|
||||
remote_rev: r.get(4)?,
|
||||
remote_hash: r.get(5)?,
|
||||
local_mtime_ns: r.get(6)?,
|
||||
})
|
||||
})?;
|
||||
let mut map = HashMap::new();
|
||||
for row in rows {
|
||||
let e = row?;
|
||||
map.insert(e.rel_path.clone(), e);
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
/// Whether `uid` is represented by a mirror location.
|
||||
///
|
||||
/// An on-demand location proves residency through its live inode state.
|
||||
/// Mirror locations have no FUSE state, so their folder root comes from
|
||||
/// `sync_folder` and descendants come from the last successful
|
||||
/// `sync_entry` baseline.
|
||||
pub fn mirror_contains_uid(&self, uid: &str) -> Result<bool> {
|
||||
let conn = self.read();
|
||||
conn.query_row(
|
||||
"SELECT EXISTS(
|
||||
SELECT 1
|
||||
FROM sync_folder AS folder
|
||||
LEFT JOIN sync_entry AS entry ON entry.folder_id = folder.id
|
||||
WHERE folder.mode = 'mirror'
|
||||
AND (folder.remote_uid = ?1 OR entry.remote_uid = ?1)
|
||||
)",
|
||||
params![uid],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Insert or replace one baseline row.
|
||||
pub fn sync_entry_upsert(&self, folder_id: i64, e: &StoredSyncEntry) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"INSERT INTO sync_entry
|
||||
(folder_id, rel_path, remote_uid, local_mtime, local_size, remote_rev,
|
||||
remote_hash, local_mtime_ns)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
||||
ON CONFLICT(folder_id, rel_path) DO UPDATE SET
|
||||
remote_uid = excluded.remote_uid,
|
||||
local_mtime = excluded.local_mtime,
|
||||
local_size = excluded.local_size,
|
||||
remote_rev = excluded.remote_rev,
|
||||
remote_hash = excluded.remote_hash,
|
||||
local_mtime_ns = excluded.local_mtime_ns",
|
||||
params![
|
||||
folder_id,
|
||||
e.rel_path,
|
||||
e.remote_uid,
|
||||
e.local_mtime,
|
||||
e.local_size,
|
||||
e.remote_rev,
|
||||
e.remote_hash,
|
||||
e.local_mtime_ns,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop one baseline row (a path that left the sync set on both sides).
|
||||
pub fn sync_entry_remove(&self, folder_id: i64, rel_path: &str) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"DELETE FROM sync_entry WHERE folder_id = ?1 AND rel_path = ?2",
|
||||
params![folder_id, rel_path],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop the 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.
|
||||
pub fn sync_entries_clear(&self, folder_id: i64) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"DELETE FROM sync_entry WHERE folder_id = ?1",
|
||||
params![folder_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,106 @@
|
||||
//! Trashed nodes, persisted so the trash view opens without a round-trip.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use rusqlite::params;
|
||||
|
||||
use super::Db;
|
||||
use crate::Result;
|
||||
|
||||
pub struct StoredTrash {
|
||||
pub uid: String,
|
||||
pub name: String,
|
||||
pub is_dir: bool,
|
||||
pub size: i64,
|
||||
pub mtime: i64,
|
||||
/// The folder this node was trashed from, when it is known. `None` for a
|
||||
/// row written before schema v29, and for a node whose parent the server
|
||||
/// did not report.
|
||||
pub parent_uid: Option<String>,
|
||||
/// 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 {
|
||||
pub fn trash_replace(&self, items: &[StoredTrash]) -> Result<()> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute("DELETE FROM trash", [])?;
|
||||
{
|
||||
let mut stmt = tx.prepare(
|
||||
"INSERT INTO trash (uid, name, is_dir, size, mtime, parent_uid, trashed_at) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
)?;
|
||||
for item in items {
|
||||
stmt.execute(params![
|
||||
item.uid,
|
||||
item.name,
|
||||
item.is_dir as i64,
|
||||
item.size,
|
||||
item.mtime,
|
||||
item.parent_uid,
|
||||
item.trashed_at
|
||||
])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The persisted trash listing, folders first then by name — the order the
|
||||
/// Trash page shows it in.
|
||||
pub fn trash_list(&self) -> Result<Vec<StoredTrash>> {
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT uid, name, is_dir, size, mtime, parent_uid, trashed_at FROM trash
|
||||
ORDER BY is_dir DESC, name COLLATE NOCASE",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok(StoredTrash {
|
||||
uid: r.get(0)?,
|
||||
name: r.get(1)?,
|
||||
is_dir: r.get::<_, i64>(2)? != 0,
|
||||
size: r.get(3)?,
|
||||
mtime: r.get(4)?,
|
||||
parent_uid: r.get(5)?,
|
||||
trashed_at: r.get(6)?,
|
||||
})
|
||||
})?;
|
||||
let mut items = Vec::new();
|
||||
for row in rows {
|
||||
items.push(row?);
|
||||
}
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
/// Every trashed node paired with the folder it was trashed from, for the
|
||||
/// restore's tree walk. Kept separate from [`Db::trash_list`] because the
|
||||
/// restore wants only the links, not the names, sizes or ordering.
|
||||
pub fn trash_parents(&self) -> Result<Vec<(String, Option<String>)>> {
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare("SELECT uid, parent_uid FROM trash")?;
|
||||
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?;
|
||||
let mut pairs = Vec::new();
|
||||
for row in rows {
|
||||
pairs.push(row?);
|
||||
}
|
||||
Ok(pairs)
|
||||
}
|
||||
|
||||
/// 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 -----------------------------------------
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//! Small query helpers shared across the table modules.
|
||||
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
|
||||
use crate::Result;
|
||||
|
||||
/// Below this length the trigram tokenizer indexes nothing (it needs 3-char
|
||||
/// grams), so short queries fall back to a `LIKE` scan over `nodes.name`.
|
||||
pub(super) const TRIGRAM_MIN: usize = 3;
|
||||
|
||||
/// One raw search row: the node's JSON, its uid, and its stored path — `None`
|
||||
/// only for a row written before the `path` column existed, which
|
||||
/// [`path_of`] repairs by walking.
|
||||
pub(super) type HitRow = (String, String, Option<String>);
|
||||
|
||||
pub(super) fn hit_row(row: &rusqlite::Row) -> rusqlite::Result<HitRow> {
|
||||
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
|
||||
}
|
||||
|
||||
/// Drain a `query_map` of [`hit_row`] rows into a `Vec`, propagating row errors.
|
||||
pub(super) fn collect_hits(
|
||||
rows: impl Iterator<Item = rusqlite::Result<HitRow>>,
|
||||
) -> Result<Vec<HitRow>> {
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
out.push(r?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Escape `LIKE` wildcards in a user query so `%` and `_` match literally
|
||||
/// (paired with `ESCAPE '\'` in the statement).
|
||||
pub(super) fn like_escape(s: &str) -> String {
|
||||
s.replace('\\', "\\\\")
|
||||
.replace('%', "\\%")
|
||||
.replace('_', "\\_")
|
||||
}
|
||||
|
||||
/// How deep the parent walk may go before it gives up. Real Drive trees are
|
||||
/// nowhere near this; a parent *cycle* is unbounded, and this query is on the
|
||||
/// path of every search result, including the `LIKE` lane that reads `nodes`
|
||||
/// directly and so never passed through the index's cycle check.
|
||||
pub(super) const MAX_PATH_DEPTH: usize = 256;
|
||||
|
||||
/// Append `name` to a parent's stored path. An empty parent path is the root,
|
||||
/// whose name is the mount and so contributes nothing.
|
||||
pub(super) fn join_path(parent_path: &str, name: &str) -> String {
|
||||
if parent_path.is_empty() {
|
||||
name.to_string()
|
||||
} else {
|
||||
format!("{parent_path}/{name}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a node's mountpoint-relative path. The root (the node with no
|
||||
/// parent) is excluded, so a top-level file `report.pdf` yields
|
||||
/// `"report.pdf"`, not `"My Files/report.pdf"`.
|
||||
///
|
||||
/// Reads the stored `nodes.path`, which the write path maintains (see
|
||||
/// `upsert_node_tx`). Falls back to [`walk_path_of`] for a row that predates the
|
||||
/// column or was never written through — a search serving a keystroke used to
|
||||
/// run that walk once per candidate, which is the reason the column exists.
|
||||
pub(super) fn path_of(conn: &Connection, uid: &str) -> Result<String> {
|
||||
let stored: Option<Option<String>> = conn
|
||||
.query_row("SELECT path FROM nodes WHERE uid = ?1", params![uid], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.optional()?;
|
||||
match stored.flatten() {
|
||||
Some(path) => Ok(path),
|
||||
None => walk_path_of(conn, uid),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a path by walking `parent_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.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"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![uid], |r| {
|
||||
Ok((r.get::<_, String>(0)?, r.get::<_, Option<String>>(1)?))
|
||||
})?;
|
||||
let mut parts = Vec::new();
|
||||
for r in rows {
|
||||
let (name, parent_uid) = r?;
|
||||
// Skip the root node (no parent); its name is the mount itself.
|
||||
if parent_uid.is_some() {
|
||||
parts.push(name);
|
||||
}
|
||||
}
|
||||
Ok(parts.join("/"))
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
//! Crate-wide error type.
|
||||
|
||||
use proton_sdk::api::HumanVerification;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::control::ErrorKind;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum Error {
|
||||
#[error("proton api: {0}")]
|
||||
Proton(#[from] proton_sdk::ProtonError),
|
||||
|
||||
#[error("keyring: {0}")]
|
||||
Keyring(#[from] keyring::Error),
|
||||
Keyring(#[from] keyring_core::Error),
|
||||
|
||||
#[error("serialization: {0}")]
|
||||
Serde(#[from] serde_json::Error),
|
||||
@@ -16,12 +19,389 @@ pub enum Error {
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("database: {0}")]
|
||||
Db(#[from] rusqlite::Error),
|
||||
|
||||
/// No persisted session found — caller must run an interactive login.
|
||||
#[error("not logged in (no saved session)")]
|
||||
NotLoggedIn,
|
||||
|
||||
/// The stored session can no longer unlock the account key chain: reading
|
||||
/// the key salts needs the `locked` scope, which a refreshed access token
|
||||
/// does not have. Only a fresh password login restores it.
|
||||
#[error("saved session can no longer unlock your keys — run `pdfs login` again")]
|
||||
ReloginRequired,
|
||||
|
||||
/// The 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)).
|
||||
///
|
||||
/// Carries the challenge rather than just a message so a front-end can
|
||||
/// present it; a front-end that cannot (no webview) still has prose to show.
|
||||
#[error("sign-in needs human verification — complete the CAPTCHA to continue")]
|
||||
HumanVerificationRequired(Box<HumanVerification>),
|
||||
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl Error {
|
||||
/// Whether this is the local disk being full (`ENOSPC`), as opposed to any
|
||||
/// other I/O failure.
|
||||
///
|
||||
/// Worth distinguishing because it is the one storage failure the user can
|
||||
/// act on, and because the caller's response differs: a full disk is met by
|
||||
/// evicting cached blobs and answering `ENOSPC`, where `EIO` would read as a
|
||||
/// broken file. `std::io::ErrorKind::StorageFull` is still unstable, so this
|
||||
/// compares the raw code.
|
||||
pub fn is_disk_full(&self) -> bool {
|
||||
match self {
|
||||
Error::Io(e) => e.raw_os_error() == Some(libc::ENOSPC),
|
||||
// SQLite reports it as its own code rather than surfacing the errno.
|
||||
Error::Db(rusqlite::Error::SqliteFailure(e, _)) => {
|
||||
e.code == rusqlite::ErrorCode::DiskFull
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
/// [`ErrorKind`] the front-end actually branches on.
|
||||
///
|
||||
/// This is what the daemon's request-serving methods return, as opposed to
|
||||
/// [`Error`] above, which is the plumbing error of the `pdfs-core` layer. The
|
||||
/// two meet at the `From` impls below: a `rusqlite` or `io` failure carries no
|
||||
/// information a user can act on, so it arrives here as
|
||||
/// [`ErrorKind::Internal`] and keeps its text only for the log.
|
||||
///
|
||||
/// Classification happens where the failure is *understood* — at the API
|
||||
/// boundary that knows a 404 from a 403, at the path resolver that knows an
|
||||
/// `ENOENT` — never at the call site, which by then has only a string.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CoreError {
|
||||
pub kind: ErrorKind,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl CoreError {
|
||||
pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The API is unreachable. Carries fixed wording because there is only ever
|
||||
/// one thing to say about it.
|
||||
pub fn offline() -> Self {
|
||||
Self::new(ErrorKind::Offline, "the Proton Drive API is unreachable")
|
||||
}
|
||||
|
||||
pub fn not_found(what: impl Into<String>) -> Self {
|
||||
Self::new(ErrorKind::NotFound, what)
|
||||
}
|
||||
|
||||
pub fn denied(what: impl Into<String>) -> Self {
|
||||
Self::new(ErrorKind::Denied, what)
|
||||
}
|
||||
|
||||
pub fn conflict(what: impl Into<String>) -> Self {
|
||||
Self::new(ErrorKind::Conflict, what)
|
||||
}
|
||||
|
||||
pub fn invalid(what: impl Into<String>) -> Self {
|
||||
Self::new(ErrorKind::Invalid, what)
|
||||
}
|
||||
|
||||
pub fn remote(what: impl Into<String>) -> Self {
|
||||
Self::new(ErrorKind::Remote, what)
|
||||
}
|
||||
|
||||
pub fn internal(what: impl Into<String>) -> Self {
|
||||
Self::new(ErrorKind::Internal, what)
|
||||
}
|
||||
|
||||
/// Classify a failure from an SDK call, prefixing `what` for the log.
|
||||
///
|
||||
/// Takes `&dyn Error` rather than `&ProtonError` so it reads the same
|
||||
/// through the boxes the drain deals in, where the concrete type survives
|
||||
/// but the static one does not.
|
||||
///
|
||||
/// A transport error, a timeout or a TLS failure is what being offline
|
||||
/// looks like from here, so it classifies as [`ErrorKind::Offline`] rather
|
||||
/// than [`ErrorKind::Remote`]: the request never reached Proton, and
|
||||
/// telling the user their connection is down beats telling them the
|
||||
/// server refused. The SDK's other failures say what went wrong instead
|
||||
/// (see [`proton_kind`]).
|
||||
pub fn from_api(e: &(dyn std::error::Error + 'static), what: &str) -> Self {
|
||||
let kind = match e.downcast_ref::<proton_sdk::ProtonError>() {
|
||||
Some(proton) => proton_kind(proton),
|
||||
// Not the SDK's at all: a timeout around the call, or the socket.
|
||||
None => ErrorKind::Offline,
|
||||
};
|
||||
Self::new(kind, format!("{what}: {e}"))
|
||||
}
|
||||
|
||||
/// Prefix the message while keeping the classification.
|
||||
///
|
||||
/// For adding the caller's context to an already-classified failure — the
|
||||
/// kind was decided by whoever understood the error and must survive being
|
||||
/// passed up.
|
||||
pub fn context(mut self, what: &str) -> Self {
|
||||
self.message = format!("{what}: {}", self.message);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CoreError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CoreError {}
|
||||
|
||||
impl From<rusqlite::Error> for CoreError {
|
||||
fn from(e: rusqlite::Error) -> Self {
|
||||
Self::internal(format!("database: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for CoreError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
Self::internal(format!("io: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for CoreError {
|
||||
fn from(e: serde_json::Error) -> Self {
|
||||
Self::internal(format!("serialization: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Error> for CoreError {
|
||||
fn from(e: Error) -> Self {
|
||||
let kind = match &e {
|
||||
Error::NotLoggedIn
|
||||
| Error::ReloginRequired
|
||||
| Error::Locked
|
||||
| Error::WrongMailboxPassword
|
||||
| Error::HumanVerificationRequired(_) => ErrorKind::Denied,
|
||||
Error::Proton(proton) => proton_kind(proton),
|
||||
_ => ErrorKind::Internal,
|
||||
};
|
||||
Self::new(kind, e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// What a failed SDK call means to a front-end.
|
||||
///
|
||||
/// Only a transport failure is the connection. The rest happened on this
|
||||
/// machine, and calling them "offline" sent the user to check a network that
|
||||
/// worked (issue #38: a photo upload on an account without a photo library
|
||||
/// read "You're offline").
|
||||
fn proton_kind(e: &proton_sdk::ProtonError) -> ErrorKind {
|
||||
use proton_sdk::ProtonError as P;
|
||||
match e {
|
||||
P::Api(api) => api_kind(api.code),
|
||||
P::Transport(_) => ErrorKind::Offline,
|
||||
// Proton answered, but not with anything this client can read.
|
||||
P::Serialization(_) => ErrorKind::Remote,
|
||||
// The SDK refused the call before sending it, such as an upload to a
|
||||
// photo library the account does not have.
|
||||
P::InvalidOperation(_) => ErrorKind::Invalid,
|
||||
P::Crypto(_) | P::KeysLocked => ErrorKind::Internal,
|
||||
}
|
||||
}
|
||||
|
||||
/// What an API response code means to a front-end.
|
||||
///
|
||||
/// The grouping is by *what the user can do about it*, which is what
|
||||
/// [`ErrorKind`] exists to express — not by HTTP-ish similarity. So a 403 and a
|
||||
/// `NotEnoughPermissions` land together despite being different numbers, while
|
||||
/// `AlreadyExists` and `DoesNotExist` stay apart despite both being 25xx.
|
||||
fn api_kind(code: proton_sdk::api::ResponseCode) -> ErrorKind {
|
||||
use proton_sdk::api::ResponseCode as C;
|
||||
match code {
|
||||
// The account may not do this, and repeating it will not help.
|
||||
C::Unauthorized
|
||||
| C::Forbidden
|
||||
| C::NotEnoughPermissions
|
||||
| C::NotEnoughPermissionsToGrantPermissions
|
||||
| C::InsufficientScope
|
||||
| C::IncorrectLoginCredentials
|
||||
| C::InvalidRefreshToken
|
||||
| C::AccountDeleted
|
||||
| C::AccountDisabled
|
||||
| C::NoActiveSubscription => ErrorKind::Denied,
|
||||
|
||||
C::DoesNotExist => ErrorKind::NotFound,
|
||||
|
||||
// The remote moved underneath the request; the caller has to decide.
|
||||
C::AlreadyExists | C::IncompatibleState => ErrorKind::Conflict,
|
||||
|
||||
// The request was malformed or asks for something structurally
|
||||
// impossible. A bug in the caller, not a condition to retry.
|
||||
C::InvalidRequirements
|
||||
| C::InvalidValue
|
||||
| C::InvalidEncryptedIdFormat
|
||||
| C::InvalidApp
|
||||
| C::TooManyChildren
|
||||
| C::NestingTooDeep => ErrorKind::Invalid,
|
||||
|
||||
C::InsufficientQuota | C::InsufficientSpace | C::InsufficientVolumeQuota => {
|
||||
ErrorKind::Quota
|
||||
}
|
||||
|
||||
// Reached Proton, but it could not answer *now*. Retrying is exactly
|
||||
// the right response, so these are Offline rather than Remote — the
|
||||
// front-end already words that as "not right now".
|
||||
C::RequestTimeout
|
||||
| C::Timeout
|
||||
| C::TooManyRequests
|
||||
| C::ServiceUnavailable
|
||||
| C::Offline => ErrorKind::Offline,
|
||||
|
||||
// Reached Proton and it refused for a reason we have no specific
|
||||
// handling for. Retryable, because an unchanged retry can legitimately
|
||||
// succeed against a transient server-side condition.
|
||||
_ => ErrorKind::Remote,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use proton_sdk::api::ResponseCode as C;
|
||||
use proton_sdk::{ProtonApiError, ProtonError};
|
||||
|
||||
fn api(code: C) -> ProtonError {
|
||||
ProtonError::Api(ProtonApiError {
|
||||
code,
|
||||
http_status: 422,
|
||||
message: "nope".into(),
|
||||
details: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_codes_classify_by_what_the_user_can_do() {
|
||||
assert_eq!(api_kind(C::Forbidden), ErrorKind::Denied);
|
||||
assert_eq!(api_kind(C::NotEnoughPermissions), ErrorKind::Denied);
|
||||
assert_eq!(api_kind(C::DoesNotExist), ErrorKind::NotFound);
|
||||
assert_eq!(api_kind(C::AlreadyExists), ErrorKind::Conflict);
|
||||
assert_eq!(api_kind(C::InvalidValue), ErrorKind::Invalid);
|
||||
assert_eq!(api_kind(C::InsufficientSpace), ErrorKind::Quota);
|
||||
assert_eq!(api_kind(C::TooManyRequests), ErrorKind::Offline);
|
||||
// Nothing specific known about it, but Proton did answer.
|
||||
assert_eq!(api_kind(C::ProtonDriveUnknown), ErrorKind::Remote);
|
||||
}
|
||||
|
||||
#[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.
|
||||
#[test]
|
||||
fn a_forbidden_response_is_not_offered_as_retryable() {
|
||||
let e = CoreError::from_api(&api(C::Forbidden), "rename");
|
||||
assert_eq!(e.kind, ErrorKind::Denied);
|
||||
assert!(!e.kind.retryable());
|
||||
assert!(e.message.starts_with("rename: "));
|
||||
}
|
||||
|
||||
/// Being full is not a transient condition, so retrying an upload that did
|
||||
/// not fit must not be offered as a fix.
|
||||
#[test]
|
||||
fn running_out_of_space_is_not_retryable() {
|
||||
assert!(
|
||||
!CoreError::from_api(&api(C::InsufficientQuota), "upload")
|
||||
.kind
|
||||
.retryable()
|
||||
);
|
||||
}
|
||||
|
||||
/// A failure that never reached the API is the user's connection, not
|
||||
/// Proton refusing — the front-end words those differently.
|
||||
#[test]
|
||||
fn a_transport_failure_reads_as_offline() {
|
||||
let io = std::io::Error::other("connection reset");
|
||||
assert_eq!(CoreError::from_api(&io, "list").kind, ErrorKind::Offline);
|
||||
}
|
||||
|
||||
/// Issue #38: the SDK refused a photo upload before sending it, and the
|
||||
/// app said "You're offline" while every other call went through.
|
||||
#[test]
|
||||
fn a_call_the_sdk_refuses_to_send_is_not_offline() {
|
||||
let refused = ProtonError::invalid_operation("account has no photos volume");
|
||||
let e = CoreError::from_api(&refused, "upload photo failed");
|
||||
assert_eq!(e.kind, ErrorKind::Invalid);
|
||||
assert!(e.message.ends_with("account has no photos volume"));
|
||||
let e: CoreError = Error::Proton(ProtonError::KeysLocked).into();
|
||||
assert_ne!(e.kind, ErrorKind::Offline);
|
||||
}
|
||||
|
||||
/// `Error` is the plumbing type; converting it must classify as sharply as
|
||||
/// the direct path, or the two disagree about the same failure.
|
||||
#[test]
|
||||
fn converting_from_the_plumbing_error_classifies_the_api_code() {
|
||||
let e: CoreError = Error::Proton(api(C::DoesNotExist)).into();
|
||||
assert_eq!(e.kind, ErrorKind::NotFound);
|
||||
}
|
||||
}
|
||||
@@ -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,14 +2,32 @@
|
||||
//! 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 error::{Error, Result};
|
||||
pub use access::{Access, access_for, perm_bits};
|
||||
pub use error::{CoreError, CoreResult, Error, Result};
|
||||
|
||||
// Re-export the SDK surface downstream crates need, so they depend on
|
||||
// `pdfs-core` rather than pinning the SDK directly.
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
//! Filesystem walker behind the daemon's index of *local* (non-Drive) files.
|
||||
//!
|
||||
//! The launcher prompt searches Proton Drive and the machine's own files side by
|
||||
//! side. Drive names come from the `nodes` FTS index; local names come from the
|
||||
//! `local_files` index this module feeds. The daemon runs [`scan`] on a
|
||||
//! background thread — never on a FUSE or control-socket thread — and streams
|
||||
//! batches into [`crate::db::Db::local_upsert_batch`].
|
||||
//!
|
||||
//! Walking is deliberately shallow on cost: we stat each entry once (the walker
|
||||
//! already has the `DirEntry` metadata) and skip the directories that dominate a
|
||||
//! home directory's inode count without ever being interesting to search
|
||||
//! (`node_modules`, `target`, caches, VCS internals). The Drive mountpoint is
|
||||
//! always excluded — walking it would fault in every remote node through FUSE.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use ignore::{WalkBuilder, WalkState};
|
||||
|
||||
/// Directory names skipped anywhere in the tree. These hold build artefacts,
|
||||
/// dependency trees, and caches: high inode count, no search value.
|
||||
const SKIP_DIRS: &[&str] = &[
|
||||
"node_modules",
|
||||
"target",
|
||||
"__pycache__",
|
||||
"venv",
|
||||
".venv",
|
||||
"vendor",
|
||||
"dist",
|
||||
"dist-newstyle",
|
||||
"build",
|
||||
"Trash",
|
||||
];
|
||||
|
||||
/// Directory *paths* skipped when they end with one of these, for caches whose
|
||||
/// own name is too generic to blacklist (`mod`). The Go module cache was 63,399
|
||||
/// of the 110,187 entries indexed on the machine this was measured on — 58% of
|
||||
/// the index, crowding real files out of the bounded candidate pool the fuzzy
|
||||
/// scorer draws from. The other big dependency caches (`.cargo`, `.rustup`,
|
||||
/// `.gradle`, `.m2`) are already skipped by the hidden-file filter.
|
||||
const SKIP_PATH_SUFFIXES: &[&str] = &["go/pkg/mod"];
|
||||
|
||||
/// Hard cap on indexed entries, so a pathological home directory cannot grow the
|
||||
/// database without bound. Reaching it stops the walk early.
|
||||
const MAX_ENTRIES: usize = 400_000;
|
||||
|
||||
/// Rows handed to the sink at a time. Large enough that the per-transaction cost
|
||||
/// amortises, small enough that the writer lock is released often (FUSE
|
||||
/// write-through shares the same connection).
|
||||
const BATCH: usize = 2_000;
|
||||
|
||||
/// One indexed local file or directory.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LocalEntry {
|
||||
/// Absolute path.
|
||||
pub path: String,
|
||||
/// Final path component.
|
||||
pub name: String,
|
||||
pub is_dir: bool,
|
||||
/// Size in bytes (0 for directories).
|
||||
pub size: i64,
|
||||
/// Modification time, epoch seconds.
|
||||
pub mtime: i64,
|
||||
}
|
||||
|
||||
/// Walk `roots` in parallel, calling `sink` with batches of entries.
|
||||
///
|
||||
/// Hidden files and anything under `excludes` (the Drive mountpoint, our own
|
||||
/// state/cache dirs) are skipped, as are the [`SKIP_DIRS`] names at any depth.
|
||||
/// Symlinks are not followed, so a link loop cannot hang the scan. Returns the
|
||||
/// number of entries handed to `sink`.
|
||||
///
|
||||
/// `sink` is called from the walker's worker threads, serialized by an internal
|
||||
/// mutex; it must not block for long.
|
||||
pub fn scan(
|
||||
roots: &[PathBuf],
|
||||
excludes: &[PathBuf],
|
||||
sink: impl FnMut(Vec<LocalEntry>) + Send,
|
||||
) -> usize {
|
||||
let Some((first, rest)) = roots.split_first() else {
|
||||
return 0;
|
||||
};
|
||||
|
||||
let mut builder = WalkBuilder::new(first);
|
||||
for root in rest {
|
||||
builder.add(root);
|
||||
}
|
||||
let excludes: Vec<PathBuf> = excludes.to_vec();
|
||||
builder
|
||||
// `standard_filters` would honour .gitignore/.ignore files: a source
|
||||
// tree's ignored-but-present files are still files the user may want to
|
||||
// find, so only the hidden filter stays on.
|
||||
.standard_filters(false)
|
||||
.hidden(true)
|
||||
.follow_links(false)
|
||||
.same_file_system(true)
|
||||
.threads(std::thread::available_parallelism().map_or(4, |n| n.get().min(8)))
|
||||
.filter_entry(move |entry| {
|
||||
let path = entry.path();
|
||||
if excludes.iter().any(|e| path.starts_with(e)) {
|
||||
return false;
|
||||
}
|
||||
if matches!(entry.file_name().to_str(), Some(name) if SKIP_DIRS.contains(&name)) {
|
||||
return false;
|
||||
}
|
||||
!is_skipped_path(path)
|
||||
});
|
||||
|
||||
let state = parking_lot::Mutex::new(SinkState {
|
||||
buf: Vec::with_capacity(BATCH),
|
||||
total: 0,
|
||||
sink,
|
||||
});
|
||||
|
||||
builder.build_parallel().run(|| {
|
||||
Box::new(|result| {
|
||||
let Ok(entry) = result else {
|
||||
return WalkState::Continue;
|
||||
};
|
||||
// Depth 0 is a root itself; indexing it adds nothing to search.
|
||||
if entry.depth() == 0 {
|
||||
return WalkState::Continue;
|
||||
}
|
||||
let Some(local) = to_entry(&entry) else {
|
||||
return WalkState::Continue;
|
||||
};
|
||||
|
||||
let mut state = state.lock();
|
||||
state.push(local);
|
||||
if state.total >= MAX_ENTRIES {
|
||||
return WalkState::Quit;
|
||||
}
|
||||
WalkState::Continue
|
||||
})
|
||||
});
|
||||
|
||||
let mut state = state.into_inner();
|
||||
state.flush();
|
||||
state.total
|
||||
}
|
||||
|
||||
/// Batching buffer shared by the walker's worker threads.
|
||||
struct SinkState<F: FnMut(Vec<LocalEntry>)> {
|
||||
buf: Vec<LocalEntry>,
|
||||
total: usize,
|
||||
sink: F,
|
||||
}
|
||||
|
||||
impl<F: FnMut(Vec<LocalEntry>)> SinkState<F> {
|
||||
fn push(&mut self, entry: LocalEntry) {
|
||||
self.buf.push(entry);
|
||||
self.total += 1;
|
||||
if self.buf.len() >= BATCH {
|
||||
self.flush();
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&mut self) {
|
||||
if !self.buf.is_empty() {
|
||||
let batch = std::mem::take(&mut self.buf);
|
||||
(self.sink)(batch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `path` is (or is inside) one of the [`SKIP_PATH_SUFFIXES`] caches.
|
||||
/// Matched on the path's own components so a directory merely *named* `mod`
|
||||
/// keeps being indexed.
|
||||
fn is_skipped_path(path: &Path) -> bool {
|
||||
let path = path.to_string_lossy();
|
||||
SKIP_PATH_SUFFIXES.iter().any(|suffix| {
|
||||
path.strip_suffix(suffix)
|
||||
.is_some_and(|head| head.is_empty() || head.ends_with('/'))
|
||||
})
|
||||
}
|
||||
|
||||
/// Convert a walker entry into a [`LocalEntry`], reusing the metadata the walker
|
||||
/// already fetched. Entries with non-UTF-8 paths are dropped: the index (and the
|
||||
/// JSON wire format) is UTF-8 only.
|
||||
fn to_entry(entry: &ignore::DirEntry) -> Option<LocalEntry> {
|
||||
let path = entry.path().to_str()?.to_string();
|
||||
let name = entry.file_name().to_str()?.to_string();
|
||||
let meta = entry.metadata().ok()?;
|
||||
let is_dir = meta.is_dir();
|
||||
let mtime = meta
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
|
||||
.map_or(0, |d| d.as_secs() as i64);
|
||||
Some(LocalEntry {
|
||||
path,
|
||||
name,
|
||||
is_dir,
|
||||
size: if is_dir { 0 } else { meta.len() as i64 },
|
||||
mtime,
|
||||
})
|
||||
}
|
||||
|
||||
/// The paths a scan should never descend into: the Drive mountpoint (walking it
|
||||
/// would fault every remote node in through FUSE) plus our own state and cache
|
||||
/// dirs, which hold blobs no user searches for by name.
|
||||
///
|
||||
/// Callers should extend this with [`nested_mount_points`] for each scan root —
|
||||
/// see that function for why `same_file_system(true)` is not enough on its own.
|
||||
pub fn default_excludes(mountpoint: &Path, state_dir: &Path, cache_dir: &Path) -> Vec<PathBuf> {
|
||||
vec![
|
||||
mountpoint.to_path_buf(),
|
||||
state_dir.to_path_buf(),
|
||||
cache_dir.to_path_buf(),
|
||||
]
|
||||
}
|
||||
|
||||
/// Every mountpoint strictly below `root`, read from `/proc/self/mounts`.
|
||||
///
|
||||
/// [`scan`] already asks the walker to stay on one filesystem, but that check is
|
||||
/// a *stat of the mountpoint* (`ignore`'s `is_same_file_system`), performed after
|
||||
/// the directory is queued — so the walker has to touch a foreign mount to learn
|
||||
/// it should skip it. On a dead network FUSE mount (an sshfs whose server is
|
||||
/// gone, an unresponsive rclone) that stat blocks in `request_wait_answer` with
|
||||
/// nobody left to answer it, and blocks *uninterruptibly*: the walker thread then
|
||||
/// ignores SIGKILL, so the daemon can never exit, systemd's stop times out, and
|
||||
/// the half-dead process keeps the cache.db lock that the next start needs
|
||||
/// (docs/BUGS.md B90).
|
||||
///
|
||||
/// Excluding the mountpoints by path avoids that stat entirely. It does not
|
||||
/// change what ends up indexed: a nested mount is a different device, so
|
||||
/// `same_file_system(true)` was already going to skip it.
|
||||
pub fn nested_mount_points(root: &Path) -> Vec<PathBuf> {
|
||||
let Ok(mounts) = std::fs::read_to_string("/proc/self/mounts") else {
|
||||
return Vec::new();
|
||||
};
|
||||
parse_nested_mount_points(&mounts, root)
|
||||
}
|
||||
|
||||
/// The mountpoint-field parse behind [`nested_mount_points`], split out so it can
|
||||
/// be tested without a real `/proc`.
|
||||
fn parse_nested_mount_points(mounts: &str, root: &Path) -> Vec<PathBuf> {
|
||||
mounts
|
||||
.lines()
|
||||
.filter_map(|line| line.split_whitespace().nth(1))
|
||||
.map(|field| PathBuf::from(unescape_mount_field(field)))
|
||||
// Strictly below: `root` itself being a mountpoint must not exclude the
|
||||
// whole scan.
|
||||
.filter(|path| path.starts_with(root) && path != root)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Undo the octal escaping `/proc/self/mounts` applies to space, tab, newline and
|
||||
/// backslash in path fields. An un-unescaped `\040` would simply fail to match a
|
||||
/// real path, silently putting a mount with a space in its name back in the walk.
|
||||
fn unescape_mount_field(field: &str) -> String {
|
||||
let mut out = String::with_capacity(field.len());
|
||||
let mut rest = field;
|
||||
while let Some(at) = rest.find('\\') {
|
||||
out.push_str(&rest[..at]);
|
||||
let escape = rest[at + 1..].get(..3);
|
||||
match escape.and_then(|digits| u8::from_str_radix(digits, 8).ok()) {
|
||||
Some(byte) => {
|
||||
out.push(byte as char);
|
||||
rest = &rest[at + 4..];
|
||||
}
|
||||
// Not an octal escape after all — keep the backslash verbatim.
|
||||
None => {
|
||||
out.push('\\');
|
||||
rest = &rest[at + 1..];
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A scan indexes plain files, skips the excluded subtree and the junk dirs,
|
||||
/// and reports names/paths the search index can match on.
|
||||
#[test]
|
||||
fn scan_indexes_files_and_honours_excludes() {
|
||||
let tmp = std::env::temp_dir().join(format!("pdfs-scan-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
std::fs::create_dir_all(tmp.join("docs")).unwrap();
|
||||
std::fs::create_dir_all(tmp.join("node_modules/pkg")).unwrap();
|
||||
std::fs::create_dir_all(tmp.join("mnt")).unwrap();
|
||||
std::fs::write(tmp.join("docs/report.pdf"), b"x").unwrap();
|
||||
std::fs::write(tmp.join("node_modules/pkg/index.js"), b"x").unwrap();
|
||||
std::fs::write(tmp.join("mnt/remote.txt"), b"x").unwrap();
|
||||
|
||||
let mut got = Vec::new();
|
||||
let n = scan(std::slice::from_ref(&tmp), &[tmp.join("mnt")], |batch| {
|
||||
got.extend(batch)
|
||||
});
|
||||
|
||||
let names: Vec<&str> = got.iter().map(|e| e.name.as_str()).collect();
|
||||
assert_eq!(n, got.len());
|
||||
assert!(names.contains(&"report.pdf"));
|
||||
assert!(names.contains(&"docs"));
|
||||
// Junk dir pruned, mountpoint-style exclude pruned.
|
||||
assert!(!names.contains(&"index.js"));
|
||||
assert!(!names.contains(&"node_modules"));
|
||||
assert!(!names.contains(&"remote.txt"));
|
||||
|
||||
let report = got.iter().find(|e| e.name == "report.pdf").unwrap();
|
||||
assert!(!report.is_dir);
|
||||
assert_eq!(report.size, 1);
|
||||
assert!(report.path.ends_with("docs/report.pdf"));
|
||||
|
||||
std::fs::remove_dir_all(&tmp).unwrap();
|
||||
}
|
||||
|
||||
/// The Go module cache is the single largest source of index noise on a
|
||||
/// developer's machine (58% of the entries on the one this was measured
|
||||
/// on), and it cannot be pruned by directory name: `mod` is far too
|
||||
/// common a name to blacklist outright.
|
||||
#[test]
|
||||
fn the_go_module_cache_is_pruned_but_a_plain_mod_dir_is_not() {
|
||||
let tmp = std::env::temp_dir().join(format!("pdfs-scan-gomod-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
std::fs::create_dir_all(tmp.join("go/pkg/mod/example.com/lib")).unwrap();
|
||||
std::fs::create_dir_all(tmp.join("kernel/mod")).unwrap();
|
||||
std::fs::write(tmp.join("go/pkg/mod/example.com/lib/vendored.go"), b"x").unwrap();
|
||||
std::fs::write(tmp.join("kernel/mod/mine.conf"), b"x").unwrap();
|
||||
|
||||
let mut got = Vec::new();
|
||||
scan(std::slice::from_ref(&tmp), &[], |batch| got.extend(batch));
|
||||
|
||||
let names: Vec<&str> = got.iter().map(|e| e.name.as_str()).collect();
|
||||
assert!(!names.contains(&"vendored.go"), "{names:?}");
|
||||
assert!(names.contains(&"mine.conf"), "{names:?}");
|
||||
|
||||
std::fs::remove_dir_all(&tmp).unwrap();
|
||||
}
|
||||
|
||||
/// A dead network FUSE mount under the scan root must be skipped by *path*,
|
||||
/// before anything stats it — see `nested_mount_points`. The scan root itself
|
||||
/// being a mountpoint must not exclude the entire walk.
|
||||
#[test]
|
||||
fn nested_mount_points_are_excluded_but_the_root_mount_is_not() {
|
||||
let mounts = "\
|
||||
/dev/sda2 /home ext4 rw 0 0
|
||||
narl@narl.io:/opt /home/narl/remote/narl.io fuse.sshfs rw 0 0
|
||||
google_drive: /home/narl/remote/gdrive fuse.rclone rw 0 0
|
||||
tmpfs /run/user/1000 tmpfs rw 0 0
|
||||
";
|
||||
let nested = parse_nested_mount_points(mounts, Path::new("/home/narl"));
|
||||
assert_eq!(
|
||||
nested,
|
||||
vec![
|
||||
PathBuf::from("/home/narl/remote/narl.io"),
|
||||
PathBuf::from("/home/narl/remote/gdrive"),
|
||||
]
|
||||
);
|
||||
|
||||
// `/home` is an ancestor, not a descendant: excluding it would empty the
|
||||
// scan. `/run/user/1000` is unrelated.
|
||||
assert!(parse_nested_mount_points(mounts, Path::new("/home")).len() == 2);
|
||||
}
|
||||
|
||||
/// `/proc/self/mounts` octal-escapes a space; leaving it escaped would put a
|
||||
/// mount whose path contains one back into the walk.
|
||||
#[test]
|
||||
fn mount_paths_are_unescaped() {
|
||||
let mounts = "srv:/x /home/narl/My\\040Cloud fuse.sshfs rw 0 0\n";
|
||||
assert_eq!(
|
||||
parse_nested_mount_points(mounts, Path::new("/home/narl")),
|
||||
vec![PathBuf::from("/home/narl/My Cloud")]
|
||||
);
|
||||
assert_eq!(unescape_mount_field("/a\\134b"), "/a\\b");
|
||||
assert_eq!(unescape_mount_field("/plain/path"), "/plain/path");
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
//! 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,
|
||||
//! the remote data survives but the *arrangement* of it does not: which folders
|
||||
//! were synced, where they lived locally, which were on-demand, what was pinned.
|
||||
//!
|
||||
//! So the daemon mirrors that arrangement into a `profile.json` in its device's
|
||||
//! root folder on Drive. It goes up the normal upload path, which means it is
|
||||
//! end-to-end encrypted with the account keys like any other file — 5.3's
|
||||
//! "encrypt with the user's Proton keys" is satisfied by *not* special-casing
|
||||
//! it. It lives under the device rather than the Drive root because it describes
|
||||
//! that device; adopting the device is what makes it relevant.
|
||||
//!
|
||||
//! This module is pure data. The upload/download and the restore flow live in
|
||||
//! `pdfs-fuse`, next to the client that performs them.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::mounts::MountMode;
|
||||
|
||||
/// File name of the profile.
|
||||
pub const PROFILE_FILE_NAME: &str = "profile.json";
|
||||
|
||||
/// Folder, directly under the device root, that holds the profile.
|
||||
///
|
||||
/// The API refuses `Cannot create file at the root of a device` (HTTP 422), so
|
||||
/// the profile cannot live at the device root itself even though that is where
|
||||
/// it logically belongs (docs/BUGS.md B68/B78). A dotted name keeps it out of
|
||||
/// the way in the web UI, and the restore ignores it when listing the device's
|
||||
/// restorable folders.
|
||||
pub const PROFILE_DIR_NAME: &str = ".proton-drive-linux";
|
||||
|
||||
/// Format version of the profile document.
|
||||
///
|
||||
/// Bumped only for changes a *reader* cannot survive. Adding fields does not
|
||||
/// qualify — unknown fields are ignored on the way in, so an older client reads
|
||||
/// a newer profile's common subset. A newer `version` than this is refused
|
||||
/// outright rather than half-applied: a restore that silently drops folders it
|
||||
/// did not understand is worse than one that tells you to upgrade.
|
||||
pub const PROFILE_VERSION: u32 = 1;
|
||||
|
||||
/// One synced folder, as it was arranged on the machine that wrote the profile.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProfileFolder {
|
||||
/// Uid of the folder's remote root, under the device root. The stable
|
||||
/// identity — the local path may not exist on the restoring machine, but
|
||||
/// this does.
|
||||
pub remote_uid: String,
|
||||
/// Absolute local path it was synced to. A *suggestion* on restore: another
|
||||
/// machine may have a different username, home, or mount layout.
|
||||
pub local_path: String,
|
||||
/// `mirror` or `ondemand`. Unknown values written by a newer client do not
|
||||
/// invalidate the rest of the profile.
|
||||
pub mode: MountMode,
|
||||
}
|
||||
|
||||
/// One pinned node (P5 pins), as `pins` stores it.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProfilePin {
|
||||
pub uid: String,
|
||||
/// Mountpoint-relative path, kept for display: a pin whose node has been
|
||||
/// deleted should still be nameable in a restore summary.
|
||||
pub path: String,
|
||||
pub recursive: bool,
|
||||
}
|
||||
|
||||
/// The full arrangement of one machine.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Profile {
|
||||
pub version: u32,
|
||||
/// The device this profile describes.
|
||||
pub device_uid: String,
|
||||
/// Hostname of the machine that wrote it, so a restore prompt can say
|
||||
/// *"restore settings from laptop-xyz?"* rather than quoting a uid.
|
||||
pub hostname: String,
|
||||
/// When it was written, epoch seconds.
|
||||
pub saved_at: i64,
|
||||
#[serde(default)]
|
||||
pub folders: Vec<ProfileFolder>,
|
||||
#[serde(default)]
|
||||
pub pins: Vec<ProfilePin>,
|
||||
/// Global sync ignore patterns. Safe to restore as-is.
|
||||
#[serde(default)]
|
||||
pub ignore_patterns: Option<Vec<String>>,
|
||||
/// Machine-shaped settings. Carried, but never applied without asking: a
|
||||
/// laptop's 5 GB cache budget and `~/ProtonDrive` mountpoint are not
|
||||
/// obviously right on the next machine.
|
||||
#[serde(default)]
|
||||
pub cache_budget: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub mountpoint: Option<String>,
|
||||
}
|
||||
|
||||
impl Profile {
|
||||
/// Parse a profile document, refusing one written by a newer client.
|
||||
pub fn parse(bytes: &[u8]) -> Result<Self, String> {
|
||||
let profile: Profile =
|
||||
serde_json::from_slice(bytes).map_err(|e| format!("malformed profile.json: {e}"))?;
|
||||
if profile.version > PROFILE_VERSION {
|
||||
return Err(format!(
|
||||
"profile.json was written by a newer client (version {}, this client understands {PROFILE_VERSION}); upgrade to restore it",
|
||||
profile.version
|
||||
));
|
||||
}
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
/// Serialize for upload. Pretty-printed: it is a small file a user may well
|
||||
/// open in the web UI to see what their machine had.
|
||||
pub fn to_bytes(&self) -> Result<Vec<u8>, String> {
|
||||
serde_json::to_vec_pretty(self).map_err(|e| format!("serialize profile: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample() -> Profile {
|
||||
Profile {
|
||||
version: PROFILE_VERSION,
|
||||
device_uid: "dev-1".to_string(),
|
||||
hostname: "laptop-xyz".to_string(),
|
||||
saved_at: 1_700_000_000,
|
||||
folders: vec![ProfileFolder {
|
||||
remote_uid: "vol~link".to_string(),
|
||||
local_path: "/home/nils/Documents".to_string(),
|
||||
mode: MountMode::Mirror,
|
||||
}],
|
||||
pins: vec![ProfilePin {
|
||||
uid: "vol~pin".to_string(),
|
||||
path: "Photos/2024".to_string(),
|
||||
recursive: true,
|
||||
}],
|
||||
ignore_patterns: Some(vec!["target/".to_string()]),
|
||||
cache_budget: Some(5_000_000_000),
|
||||
mountpoint: Some("/home/nils/ProtonDrive".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips() {
|
||||
let p = sample();
|
||||
assert_eq!(Profile::parse(&p.to_bytes().unwrap()).unwrap(), p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_keeps_legacy_mode_bytes_and_parses_old_documents() {
|
||||
let bytes = sample().to_bytes().unwrap();
|
||||
let json = String::from_utf8(bytes).unwrap();
|
||||
assert!(json.contains(r#""mode": "mirror""#));
|
||||
|
||||
let old = br#"{
|
||||
"version": 1,
|
||||
"device_uid": "dev-1",
|
||||
"hostname": "old-client",
|
||||
"saved_at": 1,
|
||||
"folders": [
|
||||
{
|
||||
"remote_uid": "vol~mirror",
|
||||
"local_path": "/home/me/Mirror",
|
||||
"mode": "mirror"
|
||||
},
|
||||
{
|
||||
"remote_uid": "vol~ondemand",
|
||||
"local_path": "/home/me/Cloud",
|
||||
"mode": "ondemand"
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
let profile = Profile::parse(old).unwrap();
|
||||
assert_eq!(profile.folders[0].mode, MountMode::Mirror);
|
||||
assert_eq!(profile.folders[1].mode, MountMode::OnDemand);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_folder_mode_does_not_reject_profile() {
|
||||
let json = br#"{
|
||||
"version": 1,
|
||||
"device_uid": "dev-1",
|
||||
"hostname": "future-client",
|
||||
"saved_at": 1,
|
||||
"folders": [{
|
||||
"remote_uid": "vol~future",
|
||||
"local_path": "/home/me/Future",
|
||||
"mode": "streaming"
|
||||
}]
|
||||
}"#;
|
||||
let profile = Profile::parse(json).unwrap();
|
||||
assert_eq!(profile.folders[0].mode, MountMode::Unknown);
|
||||
}
|
||||
|
||||
/// A field added by a later client must not break this one's restore: the
|
||||
/// common subset is still usable, which is the whole reason `version` is
|
||||
/// reserved for breaking changes only.
|
||||
#[test]
|
||||
fn unknown_fields_are_ignored() {
|
||||
let json = br#"{
|
||||
"version": 1,
|
||||
"device_uid": "dev-1",
|
||||
"hostname": "laptop-xyz",
|
||||
"saved_at": 1,
|
||||
"folders": [],
|
||||
"something_from_the_future": {"a": 1}
|
||||
}"#;
|
||||
let p = Profile::parse(json).unwrap();
|
||||
assert_eq!(p.device_uid, "dev-1");
|
||||
assert!(p.folders.is_empty());
|
||||
}
|
||||
|
||||
/// Missing optional sections read as empty rather than failing: a profile
|
||||
/// written before pins existed is still a valid folder mapping.
|
||||
#[test]
|
||||
fn absent_sections_default() {
|
||||
let json = br#"{"version":1,"device_uid":"d","hostname":"h","saved_at":0}"#;
|
||||
let p = Profile::parse(json).unwrap();
|
||||
assert!(p.pins.is_empty());
|
||||
assert!(p.ignore_patterns.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn future_version_is_refused() {
|
||||
let mut p = sample();
|
||||
p.version = PROFILE_VERSION + 1;
|
||||
let err = Profile::parse(&p.to_bytes().unwrap()).unwrap_err();
|
||||
assert!(err.contains("newer client"), "{err}");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
//! Shared, deterministic search-result relevance scoring.
|
||||
//!
|
||||
//! The scorer deliberately operates on a small candidate set rather than
|
||||
//! replacing an index. Callers should use SQLite (or another index) to find
|
||||
//! candidates, then use this module to put local and remote results on the
|
||||
//! same relevance scale.
|
||||
|
||||
/// Scores a candidate against a user query.
|
||||
///
|
||||
/// Every query term must match either `basename` or `parent_path`. Basename
|
||||
/// matches are weighted twice as strongly as path matches. The returned score
|
||||
/// is stable and only meaningful when compared with scores produced by this
|
||||
/// function.
|
||||
#[must_use]
|
||||
pub fn relevance_score(query: &str, basename: &str, parent_path: &str) -> Option<i64> {
|
||||
let query = normalize(query);
|
||||
if query.is_empty() {
|
||||
return Some(0);
|
||||
}
|
||||
|
||||
let name = normalize(basename);
|
||||
let path = normalize(parent_path);
|
||||
let query_terms: Vec<_> = query.split_whitespace().collect();
|
||||
let name_words: Vec<_> = name.split_whitespace().collect();
|
||||
let path_words: Vec<_> = path.split_whitespace().collect();
|
||||
|
||||
let mut total = 0_i64;
|
||||
for term in query_terms {
|
||||
let name_score = field_term_score(term, &name, &name_words).map(|score| score * 2);
|
||||
let path_score = field_term_score(term, &path, &path_words);
|
||||
total += name_score.into_iter().chain(path_score).max()?;
|
||||
}
|
||||
|
||||
// Whole-field bonuses make ordering intuitive without changing whether a
|
||||
// candidate matches. They dominate small differences between term scores.
|
||||
if name == query {
|
||||
total += 20_000;
|
||||
} else if name.starts_with(&query) {
|
||||
total += 10_000;
|
||||
} else if name.contains(&query) {
|
||||
total += 4_000;
|
||||
}
|
||||
|
||||
Some(total)
|
||||
}
|
||||
|
||||
fn normalize(value: &str) -> String {
|
||||
let mut normalized = String::with_capacity(value.len());
|
||||
let mut last_was_space = true;
|
||||
|
||||
for character in value.chars().flat_map(char::to_lowercase) {
|
||||
if character.is_alphanumeric() {
|
||||
normalized.push(character);
|
||||
last_was_space = false;
|
||||
} else if !last_was_space {
|
||||
normalized.push(' ');
|
||||
last_was_space = true;
|
||||
}
|
||||
}
|
||||
|
||||
if last_was_space {
|
||||
normalized.pop();
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
fn field_term_score(term: &str, field: &str, words: &[&str]) -> Option<i64> {
|
||||
if field == term {
|
||||
return Some(10_000);
|
||||
}
|
||||
if field.starts_with(term) {
|
||||
return Some(8_500);
|
||||
}
|
||||
if words.contains(&term) {
|
||||
return Some(8_000);
|
||||
}
|
||||
if words.iter().any(|word| word.starts_with(term)) {
|
||||
return Some(7_000);
|
||||
}
|
||||
if field.contains(term) {
|
||||
return Some(6_000);
|
||||
}
|
||||
|
||||
// Subsequence matching supports useful abbreviations such as "qtrrpt".
|
||||
let subsequence = std::iter::once(field)
|
||||
.chain(words.iter().copied())
|
||||
.filter_map(|word| subsequence_gap(term, word))
|
||||
.min();
|
||||
if let Some(gap) = subsequence {
|
||||
return Some(4_500 - i64::try_from(gap.min(500)).unwrap_or(500));
|
||||
}
|
||||
|
||||
// Typo matching is deliberately restricted to substantial terms to avoid
|
||||
// noisy matches for one- and two-letter searches.
|
||||
if term.chars().count() >= 4
|
||||
&& words
|
||||
.iter()
|
||||
.any(|word| bounded_damerau_levenshtein(term, word, typo_limit(term)))
|
||||
{
|
||||
return Some(3_500);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn subsequence_gap(needle: &str, haystack: &str) -> Option<usize> {
|
||||
let needle_length = needle.chars().count();
|
||||
let mut needle = needle.chars();
|
||||
let mut wanted = needle.next()?;
|
||||
let mut first = None;
|
||||
|
||||
for (index, character) in haystack.chars().enumerate() {
|
||||
if character == wanted {
|
||||
first.get_or_insert(index);
|
||||
match needle.next() {
|
||||
Some(next) => wanted = next,
|
||||
None => return Some(index + 1 - first.unwrap_or(index) - needle_length),
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn typo_limit(term: &str) -> usize {
|
||||
usize::from(term.chars().count() >= 5) + 1
|
||||
}
|
||||
|
||||
/// A bounded optimal-string-alignment distance check. This treats a single
|
||||
/// adjacent transposition (for example, `vidoe`) as one edit.
|
||||
fn bounded_damerau_levenshtein(left: &str, right: &str, limit: usize) -> bool {
|
||||
let left: Vec<_> = left.chars().collect();
|
||||
let right: Vec<_> = right.chars().collect();
|
||||
if left.len().abs_diff(right.len()) > limit || left.len().max(right.len()) > 64 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut distances = vec![vec![0_usize; right.len() + 1]; left.len() + 1];
|
||||
for (index, row) in distances.iter_mut().enumerate() {
|
||||
row[0] = index;
|
||||
}
|
||||
for (index, distance) in distances[0].iter_mut().enumerate() {
|
||||
*distance = index;
|
||||
}
|
||||
|
||||
for i in 1..=left.len() {
|
||||
for j in 1..=right.len() {
|
||||
let cost = usize::from(left[i - 1] != right[j - 1]);
|
||||
distances[i][j] = (distances[i - 1][j] + 1)
|
||||
.min(distances[i][j - 1] + 1)
|
||||
.min(distances[i - 1][j - 1] + cost);
|
||||
if i > 1 && j > 1 && left[i - 1] == right[j - 2] && left[i - 2] == right[j - 1] {
|
||||
distances[i][j] = distances[i][j].min(distances[i - 2][j - 2] + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
distances[left.len()][right.len()] <= limit
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::relevance_score;
|
||||
|
||||
#[test]
|
||||
fn exact_name_beats_prefix_and_substring() {
|
||||
let exact = relevance_score("report", "report", "work").unwrap();
|
||||
let prefix = relevance_score("report", "report final", "work").unwrap();
|
||||
let substring = relevance_score("report", "annual report", "work").unwrap();
|
||||
assert!(exact > prefix);
|
||||
assert!(prefix > substring);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basename_is_weighted_above_parent_path() {
|
||||
let name = relevance_score("budget", "budget notes", "archive").unwrap();
|
||||
let path = relevance_score("budget", "notes", "work/budget").unwrap();
|
||||
assert!(name > path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_typos_and_adjacent_transpositions() {
|
||||
assert!(relevance_score("vedio", "video.mp4", "media").is_some());
|
||||
assert!(relevance_score("vidoe", "video.mp4", "media").is_some());
|
||||
assert!(relevance_score("presentaton", "presentation.pdf", "work").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separators_form_searchable_word_boundaries() {
|
||||
assert!(relevance_score("final draft", "Final_Draft-v2.pdf", "work").is_some());
|
||||
assert!(relevance_score("quarter report", "quarterly-report.pdf", "work").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_case_folding_preserves_accented_matches() {
|
||||
assert!(relevance_score("CAFÉ", "Café Photos", "Trips").is_some());
|
||||
assert!(relevance_score("ångström", "ÅNGSTRÖM notes", "Research").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_terms_across_name_and_path() {
|
||||
assert!(relevance_score("tax invoice", "invoice-1042.pdf", "archive/tax/2025").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordered_subsequence_supports_abbreviations() {
|
||||
assert!(relevance_score("qtrrpt", "quarter-report.pdf", "finance").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unrelated_candidates_and_short_typos() {
|
||||
assert_eq!(relevance_score("video", "holiday.jpg", "photos"), None);
|
||||
assert_eq!(relevance_score("xy", "xz.txt", "misc"), None);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -79,6 +88,32 @@ pub fn is_failed() -> bool {
|
||||
systemctl_query(&["is-failed"])
|
||||
}
|
||||
|
||||
/// Whether the unit is enabled to start on login/boot. `systemctl is-enabled`
|
||||
/// exits 0 only for an enabled unit, so the Settings page can render the
|
||||
/// "Start on login" switch in its true state.
|
||||
pub fn is_enabled() -> bool {
|
||||
systemctl_query(&["is-enabled"])
|
||||
}
|
||||
|
||||
/// Enable the service so it auto-starts on future logins, without necessarily
|
||||
/// starting it now beyond what `enable` does. Used by the Settings "Start on
|
||||
/// login" switch; pairs with [`disable`](disable).
|
||||
///
|
||||
/// `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(&["reenable"])
|
||||
}
|
||||
|
||||
/// Disable auto-start without stopping the running mount. Used by the Settings
|
||||
/// "Start on login" switch turning off; the current session stays mounted.
|
||||
pub fn disable() -> bool {
|
||||
systemctl(&["disable"])
|
||||
}
|
||||
|
||||
/// Disable the service so it no longer auto-starts, and stop it now. Called on
|
||||
/// logout.
|
||||
pub fn disable_stop() -> bool {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
//! File-manager integration: right-click actions inside the mounted Drive.
|
||||
//!
|
||||
//! Nautilus (and Nemo/Caja, which read the same directories) runs any executable
|
||||
//! in its scripts folder with the selected paths in the environment, and shows it
|
||||
//! under a "Scripts" submenu. That gives us pin/unpin from the file manager
|
||||
//! without shipping a Python extension or a GNOME-version-specific plugin.
|
||||
//!
|
||||
//! Installation is idempotent and best-effort: the scripts are (re)written on
|
||||
//! every app start so an upgraded `pdfs` never leaves a stale script behind, and
|
||||
//! any failure is logged rather than surfaced — a missing file manager is not an
|
||||
//! error the user needs to act on.
|
||||
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Scripts shipped into the file manager: `(file name, `pdfs` subcommand)`.
|
||||
const SCRIPTS: [(&str, &str); 2] = [
|
||||
("Keep offline (Proton Drive)", "pin"),
|
||||
("Remove offline copy (Proton Drive)", "unpin"),
|
||||
];
|
||||
|
||||
/// The script body. Nautilus passes the selection as newline-separated paths in
|
||||
/// `NAUTILUS_SCRIPT_SELECTED_FILE_PATHS`; the positional args carry the same
|
||||
/// selection when the script runs on a local (non-URI) folder, so we prefer the
|
||||
/// environment and fall back to `"$@"` for the Nemo/Caja variants.
|
||||
fn script_body(subcommand: &str) -> String {
|
||||
format!(
|
||||
"#!/bin/sh\n\
|
||||
# Installed by proton-drive-linux. Regenerated on every app start.\n\
|
||||
set -eu\n\
|
||||
\n\
|
||||
paths=\"${{NAUTILUS_SCRIPT_SELECTED_FILE_PATHS:-}}\"\n\
|
||||
if [ -z \"$paths\" ]; then\n\
|
||||
\x20 paths=\"${{NEMO_SCRIPT_SELECTED_FILE_PATHS:-}}\"\n\
|
||||
fi\n\
|
||||
if [ -z \"$paths\" ]; then\n\
|
||||
\x20 for arg in \"$@\"; do\n\
|
||||
\x20 paths=\"$paths$arg\\n\"\n\
|
||||
\x20 done\n\
|
||||
fi\n\
|
||||
\n\
|
||||
printf '%s' \"$paths\" | while IFS= read -r path; do\n\
|
||||
\x20 [ -n \"$path\" ] || continue\n\
|
||||
\x20 pdfs {subcommand} \"$path\" || exit 1\n\
|
||||
done\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// The scripts directories to populate, for whichever file managers are present.
|
||||
fn script_dirs() -> Vec<PathBuf> {
|
||||
let Some(data) = dirs_data_home() else {
|
||||
return Vec::new();
|
||||
};
|
||||
["nautilus/scripts", "nemo/scripts"]
|
||||
.iter()
|
||||
.map(|rel| data.join(rel))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `$XDG_DATA_HOME`, or `~/.local/share`.
|
||||
fn dirs_data_home() -> Option<PathBuf> {
|
||||
if let Some(dir) = std::env::var_os("XDG_DATA_HOME").filter(|d| !d.is_empty()) {
|
||||
return Some(PathBuf::from(dir));
|
||||
}
|
||||
std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share"))
|
||||
}
|
||||
|
||||
/// Install (or refresh) the pin/unpin scripts. Best-effort: logs and moves on.
|
||||
pub fn install_file_manager_scripts() {
|
||||
for dir in script_dirs() {
|
||||
// Only populate a file manager the user actually has: creating
|
||||
// `~/.local/share/nemo` on a GNOME box would be litter.
|
||||
if !dir.parent().is_some_and(|p| p.exists()) {
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = write_scripts(&dir) {
|
||||
tracing::warn!("couldn't install file-manager scripts in {dir:?}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write both scripts into `dir`, creating it if needed, and make them
|
||||
/// executable — a script without the exec bit is silently ignored by Nautilus.
|
||||
fn write_scripts(dir: &PathBuf) -> io::Result<()> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
for (name, subcommand) in SCRIPTS {
|
||||
let path = dir.join(name);
|
||||
std::fs::write(&path, script_body(subcommand))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))?;
|
||||
}
|
||||
}
|
||||
tracing::debug!("installed file-manager scripts in {dir:?}");
|
||||
Ok(())
|
||||
}
|
||||
@@ -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]]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
//! 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
|
||||
//! spent on files nobody wants on Drive, and enough API traffic to invite rate
|
||||
//! limiting.
|
||||
//!
|
||||
//! Rules come from two places, unioned:
|
||||
//!
|
||||
//! * `.pdfsignore` (or `.protonignore`) at the root of the synced folder,
|
||||
//! gitignore syntax.
|
||||
//! * A global pattern list from the config, applied to every synced folder.
|
||||
//!
|
||||
//! Both are read fresh at the start of each reconcile pass, so editing the file
|
||||
//! takes effect on the next pass without restarting the daemon.
|
||||
//!
|
||||
//! # Ignoring is not deleting
|
||||
//!
|
||||
//! A path that becomes ignored after it was already synced keeps its remote
|
||||
//! copy and its baseline row. The sync engine drops ignored paths from
|
||||
//! classification entirely rather than treating them as locally deleted —
|
||||
//! see the union filter in `pdfs-fuse`'s `do_reconcile`. Editing a text file
|
||||
//! must never trash data on Drive.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use ignore::gitignore::{Gitignore, GitignoreBuilder};
|
||||
|
||||
/// Patterns applied to every synced folder when the config does not override
|
||||
/// them. Version-control metadata, dependency and build trees, editor
|
||||
/// leftovers, and OS turds — none of which belong on Drive, all of which are
|
||||
/// large or rewritten constantly.
|
||||
pub const DEFAULT_IGNORE_PATTERNS: &[&str] = &[
|
||||
".git/",
|
||||
".hg/",
|
||||
".svn/",
|
||||
"node_modules/",
|
||||
"target/",
|
||||
".venv/",
|
||||
"__pycache__/",
|
||||
"*~",
|
||||
"*.swp",
|
||||
"*.tmp",
|
||||
".DS_Store",
|
||||
"Thumbs.db",
|
||||
];
|
||||
|
||||
/// Suffixes 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
|
||||
".swp", // vim swap
|
||||
".swx", // vim swap (secondary)
|
||||
];
|
||||
|
||||
/// Whether `name` is a transient scratch/in-flight file whose bytes should not
|
||||
/// be uploaded on an on-demand mount until it is renamed to its finished name.
|
||||
///
|
||||
/// Recognises browser download temps, editor swap/backup files, and lock files.
|
||||
/// Case-insensitive on the suffix so `FOO.CRDOWNLOAD` counts too. This is the
|
||||
/// on-demand-write counterpart to [`DEFAULT_IGNORE_PATTERNS`] (which only covers
|
||||
/// mirror-folder reconcile); keep the two conceptually in step.
|
||||
pub fn is_transient_name(name: &str) -> bool {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
if TRANSIENT_SUFFIXES.iter().any(|s| lower.ends_with(s)) {
|
||||
return true;
|
||||
}
|
||||
// Emacs/gedit/kate backup copies.
|
||||
if name.ends_with('~') {
|
||||
return true;
|
||||
}
|
||||
// LibreOffice lock files (`.~lock.<doc>#`) and MS Office temps (`~$doc`).
|
||||
if name.starts_with(".~lock.") || name.starts_with("~$") {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 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"];
|
||||
|
||||
/// Compiled ignore rules for one synced folder.
|
||||
pub struct IgnoreRules {
|
||||
matcher: Option<Gitignore>,
|
||||
}
|
||||
|
||||
impl IgnoreRules {
|
||||
/// Build the rules for a folder rooted at `root`: `globals` first, then any
|
||||
/// ignore file found at the root, so a folder-local rule can negate a
|
||||
/// global one with `!pattern`.
|
||||
///
|
||||
/// Malformed patterns are skipped individually rather than failing the
|
||||
/// load — a typo in `.pdfsignore` must not take the folder's sync down.
|
||||
pub fn load(root: &Path, globals: &[String]) -> Self {
|
||||
let mut builder = GitignoreBuilder::new(root);
|
||||
let mut any = false;
|
||||
|
||||
for pattern in globals {
|
||||
if builder.add_line(None, pattern).is_ok() {
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
|
||||
for name in IGNORE_FILE_NAMES {
|
||||
let path = root.join(name);
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
// `add` returns the first parse error but still takes the lines it
|
||||
// could read, so a bad line costs that line and nothing more.
|
||||
let _ = builder.add(&path);
|
||||
any = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if !any {
|
||||
return Self { matcher: None };
|
||||
}
|
||||
Self {
|
||||
matcher: builder.build().ok(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rules matching nothing, for callers with no folder context.
|
||||
pub fn empty() -> Self {
|
||||
Self { matcher: None }
|
||||
}
|
||||
|
||||
/// Whether any rule is loaded. When false, [`is_ignored`](Self::is_ignored)
|
||||
/// is always false and callers can skip filtering entirely.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.matcher.is_none()
|
||||
}
|
||||
|
||||
/// Whether `rel` — a `/`-joined path relative to the folder root — is
|
||||
/// ignored.
|
||||
///
|
||||
/// A path under an ignored directory is ignored too. `Gitignore` matches
|
||||
/// one path against one rule set and does not walk parents itself, so each
|
||||
/// ancestor is tested as a directory. This is what makes filtering the
|
||||
/// classification union sound: the engine can ask about any path in
|
||||
/// isolation, in any order, without having walked its parents first.
|
||||
pub fn is_ignored(&self, rel: &str, is_dir: bool) -> bool {
|
||||
let Some(matcher) = &self.matcher else {
|
||||
return false;
|
||||
};
|
||||
if rel.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut prefix = String::new();
|
||||
let mut parts = rel.split('/').peekable();
|
||||
while let Some(part) = parts.next() {
|
||||
if !prefix.is_empty() {
|
||||
prefix.push('/');
|
||||
}
|
||||
prefix.push_str(part);
|
||||
let last = parts.peek().is_none();
|
||||
// Every ancestor is a directory; only the final component's kind
|
||||
// is the caller's to say.
|
||||
let as_dir = if last { is_dir } else { true };
|
||||
if matcher.matched(&prefix, as_dir).is_ignore() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for IgnoreRules {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("IgnoreRules")
|
||||
.field("active", &!self.is_empty())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A unique temp directory removed on drop; avoids a dev-dependency, as in
|
||||
/// [`crate::cache`]'s tests.
|
||||
struct TempDir(std::path::PathBuf);
|
||||
impl TempDir {
|
||||
fn new() -> Self {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static N: AtomicU64 = AtomicU64::new(0);
|
||||
let p = std::env::temp_dir().join(format!(
|
||||
"pdfs-ignore-{}-{}",
|
||||
std::process::id(),
|
||||
N.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
TempDir(p)
|
||||
}
|
||||
fn path(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn globals() -> Vec<String> {
|
||||
DEFAULT_IGNORE_PATTERNS
|
||||
.iter()
|
||||
.map(|s| (*s).to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn write(root: &Path, name: &str, body: &str) {
|
||||
std::fs::write(root.join(name), body).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_rules_at_all_ignores_nothing() {
|
||||
let dir = TempDir::new();
|
||||
let rules = IgnoreRules::load(dir.path(), &[]);
|
||||
assert!(rules.is_empty());
|
||||
assert!(!rules.is_ignored("anything", false));
|
||||
assert!(!rules.is_ignored("deep/nested/path.rs", false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_defaults_match_without_an_ignore_file() {
|
||||
let dir = TempDir::new();
|
||||
let rules = IgnoreRules::load(dir.path(), &globals());
|
||||
assert!(rules.is_ignored("node_modules", true));
|
||||
assert!(rules.is_ignored("target", true));
|
||||
assert!(rules.is_ignored("notes.txt~", false));
|
||||
assert!(!rules.is_ignored("src/main.rs", false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_under_an_ignored_directory_is_ignored() {
|
||||
let dir = TempDir::new();
|
||||
let rules = IgnoreRules::load(dir.path(), &globals());
|
||||
// The engine asks about deep paths in isolation, so each must answer
|
||||
// correctly without its parents having been tested first.
|
||||
assert!(rules.is_ignored("node_modules/left-pad/index.js", false));
|
||||
assert!(rules.is_ignored("node_modules/left-pad", true));
|
||||
assert!(rules.is_ignored("target/debug/build/x/out", false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_ignore_file_adds_to_the_globals() {
|
||||
let dir = TempDir::new();
|
||||
write(dir.path(), ".pdfsignore", "secrets/\n*.log\n");
|
||||
let rules = IgnoreRules::load(dir.path(), &globals());
|
||||
assert!(rules.is_ignored("secrets/key.pem", false));
|
||||
assert!(rules.is_ignored("run.log", false));
|
||||
// Globals still apply alongside the file.
|
||||
assert!(rules.is_ignored("node_modules", true));
|
||||
assert!(!rules.is_ignored("run.txt", false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_rule_can_negate_a_global_one() {
|
||||
let dir = TempDir::new();
|
||||
write(dir.path(), ".pdfsignore", "!keep.tmp\n");
|
||||
let rules = IgnoreRules::load(dir.path(), &globals());
|
||||
assert!(rules.is_ignored("scratch.tmp", false));
|
||||
assert!(!rules.is_ignored("keep.tmp", false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protonignore_is_accepted_as_an_alias() {
|
||||
let dir = TempDir::new();
|
||||
write(dir.path(), ".protonignore", "build/\n");
|
||||
let rules = IgnoreRules::load(dir.path(), &[]);
|
||||
assert!(rules.is_ignored("build/out.o", false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdfsignore_wins_when_both_files_exist() {
|
||||
let dir = TempDir::new();
|
||||
write(dir.path(), ".pdfsignore", "from-pdfs/\n");
|
||||
write(dir.path(), ".protonignore", "from-proton/\n");
|
||||
let rules = IgnoreRules::load(dir.path(), &[]);
|
||||
assert!(rules.is_ignored("from-pdfs/x", false));
|
||||
assert!(!rules.is_ignored("from-proton/x", false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_malformed_pattern_does_not_take_the_rest_down() {
|
||||
let dir = TempDir::new();
|
||||
// An unclosed character class is not a valid glob.
|
||||
write(dir.path(), ".pdfsignore", "[unclosed\nvalid/\n");
|
||||
let rules = IgnoreRules::load(dir.path(), &globals());
|
||||
assert!(rules.is_ignored("valid/x", false));
|
||||
assert!(rules.is_ignored("node_modules", true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_root_itself_is_never_ignored() {
|
||||
let dir = TempDir::new();
|
||||
write(dir.path(), ".pdfsignore", "*\n");
|
||||
let rules = IgnoreRules::load(dir.path(), &[]);
|
||||
// A blanket rule must not classify the folder root, or the pass would
|
||||
// have nothing to reconcile against.
|
||||
assert!(!rules.is_ignored("", true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transient_download_and_editor_names_are_recognised() {
|
||||
for name in [
|
||||
"Unconfirmed 540477.crdownload",
|
||||
"movie.mkv.part",
|
||||
"big.iso.partial",
|
||||
"photo.jpg.download",
|
||||
".notes.txt.swp",
|
||||
"doc.txt~",
|
||||
".~lock.report.odt#",
|
||||
"~$budget.xlsx",
|
||||
"FOO.CRDOWNLOAD",
|
||||
] {
|
||||
assert!(is_transient_name(name), "{name} should be transient");
|
||||
}
|
||||
}
|
||||
|
||||
#[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 [
|
||||
"teamspeak.tar.gz",
|
||||
"nils.zip",
|
||||
"report.xml",
|
||||
"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,13 +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");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,490 @@
|
||||
//! The calls the sync core makes to Drive, behind a trait.
|
||||
//!
|
||||
//! `Core` used to hold a concrete [`ProtonDriveClient`], so no test could make
|
||||
//! Drive answer late, answer stale, or not answer at all, and the interleavings
|
||||
//! behind most recent bugs could only be found on a real account over a slow
|
||||
//! link. [`DriveApi`] is the part of the
|
||||
//! client the mount, the drain, the event feed and the mirror engine use; the
|
||||
//! real client implements it by forwarding, and the simulation tests implement
|
||||
//! it with an in-memory Drive that injects Drive's known faults.
|
||||
//!
|
||||
//! Sharing, Photos, invitations, public links and the rest of the device calls
|
||||
//! stay on the concrete client (`Core::client`) until a test needs them faked.
|
||||
//! Listing and registering devices are here, so a simulated daemon can sync a
|
||||
//! local folder.
|
||||
//!
|
||||
//! The method names match the client's own, so a call site reads the same
|
||||
//! whichever it goes through. The client's generic methods (a reader or writer
|
||||
//! of any type) cannot be part of an object-safe trait, so the trait takes them
|
||||
//! as trait objects and `impl dyn DriveApi` puts the generic signatures back.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures::StreamExt as _;
|
||||
use futures::stream::BoxStream;
|
||||
use proton_drive_rs::proton_sdk::account::Quota;
|
||||
use proton_drive_rs::proton_sdk::error::Result;
|
||||
use proton_drive_rs::proton_sdk::ids::{DriveEventId, NodeUid};
|
||||
use proton_drive_rs::{
|
||||
Device, DeviceType, DriveEvent, DriveEventScopeId, Node, NodeMoveItem, ProtonDriveClient,
|
||||
RevisionReader, Thumbnail,
|
||||
};
|
||||
|
||||
/// One outcome per node of a batch request; see [`ProtonDriveClient::trash_nodes`].
|
||||
pub(crate) type NodeOutcomes = Vec<(NodeUid, Result<()>)>;
|
||||
|
||||
/// The outcomes of a streaming batch request, one item per node as it lands.
|
||||
pub(crate) type OutcomeStream<'a> = BoxStream<'a, Result<(NodeUid, Result<()>)>>;
|
||||
|
||||
/// Drive as the sync core sees it. See the module documentation.
|
||||
#[async_trait]
|
||||
pub(crate) trait DriveApi: Send + Sync {
|
||||
async fn get_my_files_folder(&self) -> Result<Node>;
|
||||
|
||||
async fn get_node(&self, uid: &NodeUid) -> Result<Option<Node>>;
|
||||
|
||||
async fn enumerate_nodes(&self, uids: &[NodeUid]) -> Result<Vec<Node>>;
|
||||
|
||||
/// As [`DriveApi::enumerate_nodes`], but files carry no claimed size or
|
||||
/// modification time; see [`ProtonDriveClient::enumerate_nodes_light`].
|
||||
async fn enumerate_nodes_light(&self, uids: &[NodeUid]) -> Result<Vec<Node>>;
|
||||
|
||||
async fn enumerate_folder_children_node_uids(
|
||||
&self,
|
||||
folder_uid: &NodeUid,
|
||||
) -> Result<Vec<NodeUid>>;
|
||||
|
||||
async fn enumerate_events(
|
||||
&self,
|
||||
scope: &DriveEventScopeId,
|
||||
cursor: Option<&DriveEventId>,
|
||||
) -> Result<Vec<DriveEvent>>;
|
||||
|
||||
async fn invalidate_caches_for_event(&self, event: &DriveEvent) -> Result<()>;
|
||||
|
||||
async fn create_folder(
|
||||
&self,
|
||||
parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
last_modification_time: Option<i64>,
|
||||
) -> Result<NodeUid>;
|
||||
|
||||
async fn upload_file(
|
||||
&self,
|
||||
parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
media_type: &str,
|
||||
contents: &[u8],
|
||||
) -> Result<NodeUid>;
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn upload_file_from_dyn(
|
||||
&self,
|
||||
parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
media_type: &str,
|
||||
reader: &mut (dyn Read + Send),
|
||||
intended_size: i64,
|
||||
thumbnails: Vec<Thumbnail>,
|
||||
last_modification_time: Option<i64>,
|
||||
aead: bool,
|
||||
) -> Result<NodeUid>;
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn upload_file_replacing_draft_from_dyn(
|
||||
&self,
|
||||
parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
media_type: &str,
|
||||
reader: &mut (dyn Read + Send),
|
||||
intended_size: i64,
|
||||
thumbnails: Vec<Thumbnail>,
|
||||
last_modification_time: Option<i64>,
|
||||
aead: bool,
|
||||
) -> Result<NodeUid>;
|
||||
|
||||
async fn upload_new_revision_from_dyn(
|
||||
&self,
|
||||
file_uid: &NodeUid,
|
||||
reader: &mut (dyn Read + Send),
|
||||
intended_size: i64,
|
||||
thumbnails: Vec<Thumbnail>,
|
||||
last_modification_time: Option<i64>,
|
||||
) -> Result<()>;
|
||||
|
||||
async fn open_revision(&self, uid: &NodeUid) -> Result<Arc<dyn RevisionRead>>;
|
||||
|
||||
async fn download_file_to_dyn(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
output: &mut (dyn Write + Send),
|
||||
) -> Result<()>;
|
||||
|
||||
async fn download_revision_to_dyn(
|
||||
&self,
|
||||
file_uid: &NodeUid,
|
||||
revision_id: &str,
|
||||
writer: &mut (dyn Write + Send),
|
||||
) -> Result<()>;
|
||||
|
||||
async fn rename_node(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
new_name: &str,
|
||||
new_media_type: Option<&str>,
|
||||
) -> Result<()>;
|
||||
|
||||
async fn move_node(&self, uid: &NodeUid, new_parent: &NodeUid) -> Result<()>;
|
||||
|
||||
fn move_nodes_streaming<'a>(
|
||||
&'a self,
|
||||
items: Vec<NodeMoveItem>,
|
||||
new_parent: NodeUid,
|
||||
) -> OutcomeStream<'a>;
|
||||
|
||||
async fn trash_nodes(&self, uids: &[NodeUid]) -> Result<NodeOutcomes>;
|
||||
|
||||
async fn restore_nodes(&self, uids: &[NodeUid]) -> Result<NodeOutcomes>;
|
||||
|
||||
fn restore_nodes_streaming<'a>(&'a self, uids: &[NodeUid]) -> OutcomeStream<'a>;
|
||||
|
||||
fn delete_nodes_streaming<'a>(&'a self, uids: &[NodeUid]) -> OutcomeStream<'a>;
|
||||
|
||||
async fn enumerate_trash_node_uids(&self) -> Result<Vec<NodeUid>>;
|
||||
|
||||
/// The account's storage, across every Proton product.
|
||||
async fn quota(&self) -> Result<Quota>;
|
||||
|
||||
/// Every computer that backs up to the account.
|
||||
async fn enumerate_devices(&self) -> Result<Vec<Device>>;
|
||||
|
||||
/// Register a computer, with a root folder of its own named `name`.
|
||||
async fn create_device(&self, name: &str, device_type: DeviceType) -> Result<Device>;
|
||||
|
||||
/// Called by the drain once it has let go of the blob of an upload to
|
||||
/// `uid` that landed, or retired the create that made `uid`, before the
|
||||
/// tree has the node, so a simulation can hold it there. Blocks the drain
|
||||
/// worker it is called on.
|
||||
#[cfg(test)]
|
||||
fn landing(&self, _uid: &NodeUid) {}
|
||||
}
|
||||
|
||||
/// The generic signatures of the client, over the object-safe ones above.
|
||||
impl dyn DriveApi {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn upload_file_from<R: Read + Send>(
|
||||
&self,
|
||||
parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
media_type: &str,
|
||||
mut reader: R,
|
||||
intended_size: i64,
|
||||
thumbnails: Vec<Thumbnail>,
|
||||
last_modification_time: Option<i64>,
|
||||
aead: bool,
|
||||
) -> Result<NodeUid> {
|
||||
self.upload_file_from_dyn(
|
||||
parent_uid,
|
||||
name,
|
||||
media_type,
|
||||
&mut reader,
|
||||
intended_size,
|
||||
thumbnails,
|
||||
last_modification_time,
|
||||
aead,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn upload_file_replacing_draft_from<R: Read + Send>(
|
||||
&self,
|
||||
parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
media_type: &str,
|
||||
mut reader: R,
|
||||
intended_size: i64,
|
||||
thumbnails: Vec<Thumbnail>,
|
||||
last_modification_time: Option<i64>,
|
||||
aead: bool,
|
||||
) -> Result<NodeUid> {
|
||||
self.upload_file_replacing_draft_from_dyn(
|
||||
parent_uid,
|
||||
name,
|
||||
media_type,
|
||||
&mut reader,
|
||||
intended_size,
|
||||
thumbnails,
|
||||
last_modification_time,
|
||||
aead,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn upload_new_revision_from<R: Read + Send>(
|
||||
&self,
|
||||
file_uid: &NodeUid,
|
||||
mut reader: R,
|
||||
intended_size: i64,
|
||||
thumbnails: Vec<Thumbnail>,
|
||||
last_modification_time: Option<i64>,
|
||||
) -> Result<()> {
|
||||
self.upload_new_revision_from_dyn(
|
||||
file_uid,
|
||||
&mut reader,
|
||||
intended_size,
|
||||
thumbnails,
|
||||
last_modification_time,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn download_file_to<W: Write + Send>(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
output: &mut W,
|
||||
) -> Result<()> {
|
||||
self.download_file_to_dyn(uid, output).await
|
||||
}
|
||||
|
||||
pub(crate) async fn download_revision_to<W: Write + Send>(
|
||||
&self,
|
||||
file_uid: &NodeUid,
|
||||
revision_id: &str,
|
||||
writer: &mut W,
|
||||
) -> Result<()> {
|
||||
self.download_revision_to_dyn(file_uid, revision_id, writer)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
/// An open revision that block reads are served from; see [`RevisionReader`].
|
||||
#[async_trait]
|
||||
pub(crate) trait RevisionRead: Send + Sync {
|
||||
/// Plaintext size of the revision.
|
||||
fn size(&self) -> u64;
|
||||
|
||||
/// Plaintext size of each block, in block order.
|
||||
fn block_sizes(&self) -> &[u64];
|
||||
|
||||
async fn read_at(&self, offset: u64, length: u64) -> Result<Vec<u8>>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RevisionRead for RevisionReader {
|
||||
fn size(&self) -> u64 {
|
||||
RevisionReader::size(self)
|
||||
}
|
||||
|
||||
fn block_sizes(&self) -> &[u64] {
|
||||
RevisionReader::block_sizes(self)
|
||||
}
|
||||
|
||||
async fn read_at(&self, offset: u64, length: u64) -> Result<Vec<u8>> {
|
||||
RevisionReader::read_at(self, offset, length).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DriveApi for ProtonDriveClient {
|
||||
async fn get_my_files_folder(&self) -> Result<Node> {
|
||||
ProtonDriveClient::get_my_files_folder(self).await
|
||||
}
|
||||
|
||||
async fn get_node(&self, uid: &NodeUid) -> Result<Option<Node>> {
|
||||
ProtonDriveClient::get_node(self, uid).await
|
||||
}
|
||||
|
||||
async fn enumerate_nodes(&self, uids: &[NodeUid]) -> Result<Vec<Node>> {
|
||||
ProtonDriveClient::enumerate_nodes(self, uids).await
|
||||
}
|
||||
|
||||
async fn enumerate_nodes_light(&self, uids: &[NodeUid]) -> Result<Vec<Node>> {
|
||||
ProtonDriveClient::enumerate_nodes_light(self, uids).await
|
||||
}
|
||||
|
||||
async fn enumerate_folder_children_node_uids(
|
||||
&self,
|
||||
folder_uid: &NodeUid,
|
||||
) -> Result<Vec<NodeUid>> {
|
||||
ProtonDriveClient::enumerate_folder_children_node_uids(self, folder_uid).await
|
||||
}
|
||||
|
||||
async fn enumerate_events(
|
||||
&self,
|
||||
scope: &DriveEventScopeId,
|
||||
cursor: Option<&DriveEventId>,
|
||||
) -> Result<Vec<DriveEvent>> {
|
||||
ProtonDriveClient::enumerate_events(self, scope, cursor).await
|
||||
}
|
||||
|
||||
async fn invalidate_caches_for_event(&self, event: &DriveEvent) -> Result<()> {
|
||||
ProtonDriveClient::invalidate_caches_for_event(self, event).await
|
||||
}
|
||||
|
||||
async fn create_folder(
|
||||
&self,
|
||||
parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
last_modification_time: Option<i64>,
|
||||
) -> Result<NodeUid> {
|
||||
ProtonDriveClient::create_folder(self, parent_uid, name, last_modification_time).await
|
||||
}
|
||||
|
||||
async fn upload_file(
|
||||
&self,
|
||||
parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
media_type: &str,
|
||||
contents: &[u8],
|
||||
) -> Result<NodeUid> {
|
||||
ProtonDriveClient::upload_file(self, parent_uid, name, media_type, contents).await
|
||||
}
|
||||
|
||||
async fn upload_file_from_dyn(
|
||||
&self,
|
||||
parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
media_type: &str,
|
||||
reader: &mut (dyn Read + Send),
|
||||
intended_size: i64,
|
||||
thumbnails: Vec<Thumbnail>,
|
||||
last_modification_time: Option<i64>,
|
||||
aead: bool,
|
||||
) -> Result<NodeUid> {
|
||||
ProtonDriveClient::upload_file_from(
|
||||
self,
|
||||
parent_uid,
|
||||
name,
|
||||
media_type,
|
||||
reader,
|
||||
intended_size,
|
||||
thumbnails,
|
||||
last_modification_time,
|
||||
aead,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn upload_file_replacing_draft_from_dyn(
|
||||
&self,
|
||||
parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
media_type: &str,
|
||||
reader: &mut (dyn Read + Send),
|
||||
intended_size: i64,
|
||||
thumbnails: Vec<Thumbnail>,
|
||||
last_modification_time: Option<i64>,
|
||||
aead: bool,
|
||||
) -> Result<NodeUid> {
|
||||
ProtonDriveClient::upload_file_replacing_draft_from(
|
||||
self,
|
||||
parent_uid,
|
||||
name,
|
||||
media_type,
|
||||
reader,
|
||||
intended_size,
|
||||
thumbnails,
|
||||
last_modification_time,
|
||||
aead,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn upload_new_revision_from_dyn(
|
||||
&self,
|
||||
file_uid: &NodeUid,
|
||||
reader: &mut (dyn Read + Send),
|
||||
intended_size: i64,
|
||||
thumbnails: Vec<Thumbnail>,
|
||||
last_modification_time: Option<i64>,
|
||||
) -> Result<()> {
|
||||
ProtonDriveClient::upload_new_revision_from(
|
||||
self,
|
||||
file_uid,
|
||||
reader,
|
||||
intended_size,
|
||||
thumbnails,
|
||||
last_modification_time,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn open_revision(&self, uid: &NodeUid) -> Result<Arc<dyn RevisionRead>> {
|
||||
let reader = ProtonDriveClient::open_revision(self, uid).await?;
|
||||
Ok(Arc::new(reader))
|
||||
}
|
||||
|
||||
async fn download_file_to_dyn(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
output: &mut (dyn Write + Send),
|
||||
) -> Result<()> {
|
||||
ProtonDriveClient::download_file_to(self, uid, &mut &mut *output).await
|
||||
}
|
||||
|
||||
async fn download_revision_to_dyn(
|
||||
&self,
|
||||
file_uid: &NodeUid,
|
||||
revision_id: &str,
|
||||
writer: &mut (dyn Write + Send),
|
||||
) -> Result<()> {
|
||||
ProtonDriveClient::download_revision_to(self, file_uid, revision_id, &mut &mut *writer)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn rename_node(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
new_name: &str,
|
||||
new_media_type: Option<&str>,
|
||||
) -> Result<()> {
|
||||
ProtonDriveClient::rename_node(self, uid, new_name, new_media_type).await
|
||||
}
|
||||
|
||||
async fn move_node(&self, uid: &NodeUid, new_parent: &NodeUid) -> Result<()> {
|
||||
ProtonDriveClient::move_node(self, uid, new_parent).await
|
||||
}
|
||||
|
||||
fn move_nodes_streaming<'a>(
|
||||
&'a self,
|
||||
items: Vec<NodeMoveItem>,
|
||||
new_parent: NodeUid,
|
||||
) -> OutcomeStream<'a> {
|
||||
ProtonDriveClient::move_nodes_streaming(self, items, new_parent).boxed()
|
||||
}
|
||||
|
||||
async fn trash_nodes(&self, uids: &[NodeUid]) -> Result<NodeOutcomes> {
|
||||
ProtonDriveClient::trash_nodes(self, uids).await
|
||||
}
|
||||
|
||||
async fn restore_nodes(&self, uids: &[NodeUid]) -> Result<NodeOutcomes> {
|
||||
ProtonDriveClient::restore_nodes(self, uids).await
|
||||
}
|
||||
|
||||
fn restore_nodes_streaming<'a>(&'a self, uids: &[NodeUid]) -> OutcomeStream<'a> {
|
||||
ProtonDriveClient::restore_nodes_streaming(self, uids).boxed()
|
||||
}
|
||||
|
||||
fn delete_nodes_streaming<'a>(&'a self, uids: &[NodeUid]) -> OutcomeStream<'a> {
|
||||
ProtonDriveClient::delete_nodes_streaming(self, uids).boxed()
|
||||
}
|
||||
|
||||
async fn enumerate_trash_node_uids(&self) -> Result<Vec<NodeUid>> {
|
||||
ProtonDriveClient::enumerate_trash_node_uids(self).await
|
||||
}
|
||||
|
||||
async fn quota(&self) -> Result<Quota> {
|
||||
ProtonDriveClient::quota(self).await
|
||||
}
|
||||
|
||||
async fn enumerate_devices(&self) -> Result<Vec<Device>> {
|
||||
ProtonDriveClient::enumerate_devices(self).await
|
||||
}
|
||||
|
||||
async fn create_device(&self, name: &str, device_type: DeviceType) -> Result<Device> {
|
||||
ProtonDriveClient::create_device(self, name, device_type).await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
//! Push notifications for front-ends: the daemon's side of
|
||||
//! [`Request::Subscribe`](pdfs_core::control::Request::Subscribe).
|
||||
//!
|
||||
//! Front-ends used to poll. The app asked for the status every two seconds, the
|
||||
//! tray every three, and pages re-read themselves on a timer, whether or not
|
||||
//! anything had changed. Now each subscription owns a [`Mailbox`] in the
|
||||
//! [`EventHub`]. Code that changes something publishes a [`Topic`], and
|
||||
//! [`run_event_sampler`] watches the few things that change without passing
|
||||
//! through one place (the status, transfers, sync progress) and publishes them
|
||||
//! when they differ.
|
||||
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use parking_lot::{Condvar, Mutex, MutexGuard};
|
||||
use pdfs_core::control::{Event, JobItem, Response as CtlResponse, Topic, TransferItem};
|
||||
use tracing::warn;
|
||||
|
||||
use super::Core;
|
||||
|
||||
/// Most subscriptions served at once. Each holds a control handler for as long
|
||||
/// as it lasts; the app and the tray need one apiece, and the cap keeps a
|
||||
/// misbehaving client from spending the handlers that requests need.
|
||||
const MAX_SUBSCRIBERS: usize = 8;
|
||||
|
||||
/// How long a subscription waits after a change before it sends, so a burst
|
||||
/// (a folder of files trashed at once) goes out as one event.
|
||||
const COALESCE: Duration = Duration::from_millis(150);
|
||||
|
||||
/// How often [`run_event_sampler`] looks at transfers. Status and sync
|
||||
/// progress are looked at every other time.
|
||||
const SAMPLE_INTERVAL: Duration = Duration::from_millis(500);
|
||||
|
||||
/// Every open subscription's mailbox. Shared by every clone of [`Core`],
|
||||
/// on-demand forks included, so a change in any mount reaches every client.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct EventHub {
|
||||
mailboxes: Mutex<Vec<Arc<Mailbox>>>,
|
||||
}
|
||||
|
||||
/// What one subscription has yet to send.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Mailbox {
|
||||
inbox: Mutex<Inbox>,
|
||||
ready: Condvar,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Inbox {
|
||||
topics: BTreeSet<Topic>,
|
||||
/// The newest [`Event::Status`] line. A newer one replaces it unsent: a
|
||||
/// client wants the current status, not every status in between.
|
||||
status: Option<Arc<str>>,
|
||||
/// The newest [`Event::Transfers`] line, replaced the same way.
|
||||
transfers: Option<Arc<str>>,
|
||||
}
|
||||
|
||||
impl Inbox {
|
||||
fn is_empty(&self) -> bool {
|
||||
self.topics.is_empty() && self.status.is_none() && self.transfers.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// `event` as one line of the wire format, newline included.
|
||||
pub(crate) fn event_line(event: &Event) -> Option<Arc<str>> {
|
||||
match serde_json::to_string(event) {
|
||||
Ok(mut line) => {
|
||||
line.push('\n');
|
||||
Some(line.into())
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = %e, "events: serialize event failed");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EventHub {
|
||||
/// Tell every subscriber that `topics` changed.
|
||||
pub(crate) fn publish(&self, topics: &[Topic]) {
|
||||
self.deliver(|inbox| inbox.topics.extend(topics.iter().copied()));
|
||||
}
|
||||
|
||||
/// Hand every subscriber a fresh status.
|
||||
fn publish_status(&self, line: Arc<str>) {
|
||||
self.deliver(|inbox| inbox.status = Some(line.clone()));
|
||||
}
|
||||
|
||||
/// Hand every subscriber a fresh transfers snapshot.
|
||||
fn publish_transfers(&self, line: Arc<str>) {
|
||||
self.deliver(|inbox| inbox.transfers = Some(line.clone()));
|
||||
}
|
||||
|
||||
fn deliver(&self, put: impl Fn(&mut Inbox)) {
|
||||
for mailbox in self.mailboxes.lock().iter() {
|
||||
put(&mut mailbox.inbox.lock());
|
||||
mailbox.ready.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a mailbox, or `None` at [`MAX_SUBSCRIBERS`].
|
||||
pub(crate) fn subscribe(&self) -> Option<Arc<Mailbox>> {
|
||||
let mut mailboxes = self.mailboxes.lock();
|
||||
if mailboxes.len() >= MAX_SUBSCRIBERS {
|
||||
return None;
|
||||
}
|
||||
let mailbox = Arc::new(Mailbox::default());
|
||||
mailboxes.push(mailbox.clone());
|
||||
Some(mailbox)
|
||||
}
|
||||
|
||||
pub(crate) fn unsubscribe(&self, mailbox: &Arc<Mailbox>) {
|
||||
self.mailboxes
|
||||
.lock()
|
||||
.retain(|other| !Arc::ptr_eq(other, mailbox));
|
||||
}
|
||||
|
||||
fn has_subscribers(&self) -> bool {
|
||||
!self.mailboxes.lock().is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl Mailbox {
|
||||
/// Wait up to `timeout` for something to send, then take it as wire lines:
|
||||
/// the status first, then transfers, then the changed topics. Empty when
|
||||
/// nothing arrived in time.
|
||||
pub(crate) fn take(&self, timeout: Duration) -> Vec<Arc<str>> {
|
||||
let mut inbox = self.inbox.lock();
|
||||
if inbox.is_empty() {
|
||||
self.ready.wait_for(&mut inbox, timeout);
|
||||
if inbox.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
// Let the rest of a burst arrive before sending the first of it.
|
||||
MutexGuard::unlocked(&mut inbox, || std::thread::sleep(COALESCE));
|
||||
}
|
||||
let mut lines = Vec::new();
|
||||
lines.extend(inbox.status.take());
|
||||
lines.extend(inbox.transfers.take());
|
||||
if !inbox.topics.is_empty() {
|
||||
let topics = std::mem::take(&mut inbox.topics).into_iter().collect();
|
||||
lines.extend(event_line(&Event::Changed { topics }));
|
||||
}
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
/// The [`Event::Transfers`] line for a snapshot.
|
||||
pub(crate) fn transfers_line(items: Vec<TransferItem>, jobs: Vec<JobItem>) -> Option<Arc<str>> {
|
||||
event_line(&Event::Transfers { items, jobs })
|
||||
}
|
||||
|
||||
/// Watch what changes without a single place to publish it from, and publish it
|
||||
/// when it differs from the last look: the status (queue counts, cache use,
|
||||
/// online, pause), the transfers snapshot, the synced folders with their
|
||||
/// live progress, and which nodes have a sync issue. Looks only while someone
|
||||
/// is subscribed.
|
||||
pub(crate) fn run_event_sampler(core: Core, username: String, mountpoint: std::path::PathBuf) {
|
||||
let mut transfers: Option<Arc<str>> = None;
|
||||
let mut status: Option<String> = None;
|
||||
let mut queue: Option<String> = None;
|
||||
let mut locations: Option<String> = None;
|
||||
let mut issues: Option<HashMap<i64, String>> = None;
|
||||
let mut tick = 0u32;
|
||||
while core.shutdown.sleep(SAMPLE_INTERVAL) {
|
||||
if !core.events.has_subscribers() {
|
||||
// A new subscription starts from a snapshot of its own, so there is
|
||||
// nothing to compare against until then.
|
||||
transfers = None;
|
||||
status = None;
|
||||
queue = None;
|
||||
locations = None;
|
||||
issues = None;
|
||||
continue;
|
||||
}
|
||||
let line = transfers_line(core.transfers.snapshot(), core.jobs_snapshot());
|
||||
if line.is_some() && line != transfers {
|
||||
transfers = line.clone();
|
||||
if let Some(line) = line {
|
||||
core.events.publish_transfers(line);
|
||||
}
|
||||
}
|
||||
|
||||
tick = tick.wrapping_add(1);
|
||||
if !tick.is_multiple_of(2) {
|
||||
continue;
|
||||
}
|
||||
let now = super::control::status_response(&core, &username, &mountpoint);
|
||||
let (key, queue_key) = status_keys(&now);
|
||||
if status.as_ref().is_some_and(|last| *last != key)
|
||||
&& let Some(line) = event_line(&Event::Status(Box::new(now)))
|
||||
{
|
||||
core.events.publish_status(line);
|
||||
}
|
||||
status = Some(key);
|
||||
if queue.as_ref().is_some_and(|last| *last != queue_key) {
|
||||
core.events.publish(&[Topic::Queue]);
|
||||
}
|
||||
queue = Some(queue_key);
|
||||
|
||||
// An issue shows on its node in the file browser: raised, cleared,
|
||||
// landed or discarded, the listing is out of date.
|
||||
if let Ok(now) = core.db.node_issues() {
|
||||
if issues.as_ref().is_some_and(|last| *last != now) {
|
||||
core.events.publish(&[Topic::Files]);
|
||||
}
|
||||
issues = Some(now);
|
||||
}
|
||||
|
||||
if let Ok(now) = core.list_locations() {
|
||||
let key = serde_json::to_string(&now).unwrap_or_default();
|
||||
if locations.as_ref().is_some_and(|last| *last != key) {
|
||||
core.events.publish(&[Topic::Locations]);
|
||||
}
|
||||
locations = Some(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What makes two statuses different enough to send, and what makes their
|
||||
/// queues different. The age of the oldest staged write is left out of both:
|
||||
/// it grows every second while anything is staged, which is not news.
|
||||
fn status_keys(status: &CtlResponse) -> (String, String) {
|
||||
let CtlResponse::Status {
|
||||
parked_uploads,
|
||||
failing_ops,
|
||||
failing_error,
|
||||
staged_bytes,
|
||||
paused,
|
||||
paused_until,
|
||||
username,
|
||||
mountpoint,
|
||||
pinned,
|
||||
used,
|
||||
budget,
|
||||
online,
|
||||
session_expired,
|
||||
tokens_unsaved,
|
||||
pending_uploads,
|
||||
pending_changes,
|
||||
..
|
||||
} = status
|
||||
else {
|
||||
return (String::new(), String::new());
|
||||
};
|
||||
let queue = format!(
|
||||
"{pending_uploads}/{pending_changes}/{parked_uploads}/{failing_ops}/{failing_error:?}"
|
||||
);
|
||||
let key = format!(
|
||||
"{queue}/{staged_bytes}/{paused}/{paused_until:?}/{username}/{mountpoint}/{pinned}/\
|
||||
{used}/{budget}/{online}/{session_expired}/{tokens_unsaved}"
|
||||
);
|
||||
(key, queue)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn changed(line: &str) -> Vec<Topic> {
|
||||
match serde_json::from_str::<Event>(line.trim()) {
|
||||
Ok(Event::Changed { topics }) => topics,
|
||||
other => panic!("not a change event: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_burst_of_changes_arrives_as_one_event() {
|
||||
let hub = EventHub::default();
|
||||
let mailbox = hub.subscribe().expect("room for a subscriber");
|
||||
hub.publish(&[Topic::Trash, Topic::Files]);
|
||||
hub.publish(&[Topic::Files]);
|
||||
let lines = mailbox.take(Duration::from_secs(1));
|
||||
assert_eq!(lines.len(), 1);
|
||||
assert_eq!(changed(&lines[0]), vec![Topic::Files, Topic::Trash]);
|
||||
assert!(mailbox.take(Duration::from_millis(10)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_newer_status_replaces_one_not_yet_sent() {
|
||||
let hub = EventHub::default();
|
||||
let mailbox = hub.subscribe().expect("room for a subscriber");
|
||||
hub.publish_status("first\n".into());
|
||||
hub.publish_status("second\n".into());
|
||||
assert_eq!(
|
||||
mailbox.take(Duration::from_secs(1)),
|
||||
vec![Arc::<str>::from("second\n")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_closed_subscription_gets_nothing_more() {
|
||||
let hub = EventHub::default();
|
||||
let mailbox = hub.subscribe().expect("room for a subscriber");
|
||||
hub.unsubscribe(&mailbox);
|
||||
hub.publish(&[Topic::Files]);
|
||||
assert!(mailbox.take(Duration::from_millis(10)).is_empty());
|
||||
assert!(!hub.has_subscribers());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subscriptions_are_capped() {
|
||||
let hub = EventHub::default();
|
||||
let open: Vec<_> = (0..MAX_SUBSCRIBERS)
|
||||
.map(|_| hub.subscribe().expect("room for a subscriber"))
|
||||
.collect();
|
||||
assert!(hub.subscribe().is_none());
|
||||
hub.unsubscribe(&open[0]);
|
||||
assert!(hub.subscribe().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_age_of_a_staged_write_is_not_a_change() {
|
||||
let status = |staged_oldest_secs| CtlResponse::Status {
|
||||
parked_uploads: 0,
|
||||
failing_ops: 0,
|
||||
failing_error: None,
|
||||
staged_bytes: 10,
|
||||
staged_oldest_secs,
|
||||
paused: false,
|
||||
paused_until: None,
|
||||
username: "u".into(),
|
||||
mountpoint: "/m".into(),
|
||||
pinned: 0,
|
||||
used: 0,
|
||||
budget: 0,
|
||||
pins: Vec::new(),
|
||||
online: true,
|
||||
session_expired: false,
|
||||
tokens_unsaved: false,
|
||||
version: "3.3.0".into(),
|
||||
pending_uploads: 1,
|
||||
pending_changes: 0,
|
||||
};
|
||||
assert_eq!(status_keys(&status(5)), status_keys(&status(6)));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+9376
-1747
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,967 @@
|
||||
//! Mount lifecycle and background-service orchestration.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Why a [`mount`] call returned. Lets the daemon decide whether to exit (clean
|
||||
/// shutdown) or remount (the mount went away under it).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MountOutcome {
|
||||
/// The daemon was asked to stop (SIGTERM/SIGINT) and we lazily unmounted
|
||||
/// ourselves. The caller should exit cleanly.
|
||||
Shutdown,
|
||||
/// The kernel mount ended on its own (e.g. an external `fusermount -u`).
|
||||
/// The caller may want to remount.
|
||||
Unmounted,
|
||||
/// 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
|
||||
/// daemon killed before it could unmount leaves behind.
|
||||
///
|
||||
/// The kernel answers every operation on such a path with `ENOTCONN`, which the
|
||||
/// ordinary existence checks (`is_dir`, `exists`) report as plain `false`: the
|
||||
/// path looks *absent* rather than broken. Callers that would otherwise treat
|
||||
/// that as "nothing to mount here" use this to tell the two apart.
|
||||
pub(crate) fn is_stale_mount(path: &Path) -> bool {
|
||||
matches!(
|
||||
std::fs::metadata(path).map_err(|e| e.raw_os_error()),
|
||||
Err(Some(libc::ENOTCONN))
|
||||
)
|
||||
}
|
||||
|
||||
/// A secondary (on-demand sync folder) FUSE session and the exact liveness flag
|
||||
/// published with its forked inode state.
|
||||
///
|
||||
/// Keeping these together makes every teardown mark the fork unroutable before
|
||||
/// unmounting, including paths that can block on an in-flight FUSE request.
|
||||
pub(super) struct SecondaryMount {
|
||||
session: BackgroundSession,
|
||||
conn: Option<u32>,
|
||||
session_live: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum SecondaryInsertRejection {
|
||||
Closed,
|
||||
Duplicate,
|
||||
}
|
||||
|
||||
/// Secondary sessions and the daemon-wide gate controlling whether newly
|
||||
/// spawned sessions may become visible. The gate and map share one lock so
|
||||
/// shutdown cannot race an insertion between checking and publishing.
|
||||
pub(super) struct SecondaryMountRegistry<T> {
|
||||
accepting: bool,
|
||||
entries: HashMap<i64, T>,
|
||||
}
|
||||
|
||||
impl<T> Default for SecondaryMountRegistry<T> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
accepting: false,
|
||||
entries: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> SecondaryMountRegistry<T> {
|
||||
pub(super) fn open(&mut self) {
|
||||
self.accepting = true;
|
||||
}
|
||||
|
||||
pub(super) fn is_accepting(&self) -> bool {
|
||||
self.accepting
|
||||
}
|
||||
|
||||
pub(super) fn close(&mut self) {
|
||||
self.accepting = false;
|
||||
}
|
||||
|
||||
pub(super) fn insert(
|
||||
&mut self,
|
||||
id: i64,
|
||||
mount: T,
|
||||
) -> Result<(), (SecondaryInsertRejection, T)> {
|
||||
if !self.accepting {
|
||||
return Err((SecondaryInsertRejection::Closed, mount));
|
||||
}
|
||||
if self.entries.contains_key(&id) {
|
||||
return Err((SecondaryInsertRejection::Duplicate, mount));
|
||||
}
|
||||
self.entries.insert(id, mount);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn remove(&mut self, id: &i64) -> Option<T> {
|
||||
self.entries.remove(id)
|
||||
}
|
||||
|
||||
pub(super) fn contains_key(&self, id: &i64) -> bool {
|
||||
self.entries.contains_key(id)
|
||||
}
|
||||
|
||||
pub(super) fn close_and_drain(&mut self) -> Vec<(i64, T)> {
|
||||
self.close();
|
||||
self.entries.drain().collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl SecondaryMount {
|
||||
pub(super) fn new(
|
||||
session: BackgroundSession,
|
||||
conn: Option<u32>,
|
||||
session_live: Arc<AtomicBool>,
|
||||
) -> Self {
|
||||
Self {
|
||||
session,
|
||||
conn,
|
||||
session_live,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn teardown(self) -> std::io::Result<()> {
|
||||
teardown_session(&self.session_live, || {
|
||||
umount_session_unblocked(self.session, self.conn)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The kernel's id for the FUSE connection backing the mount at `mountpoint` —
|
||||
/// the directory name under `/sys/fs/fuse/connections`, which is the minor
|
||||
/// number of the mountpoint's device. Must be read *while still mounted*; after
|
||||
/// unmount the path resolves to the underlying directory on another device.
|
||||
/// `None` when the path can't be stat'd.
|
||||
pub(super) fn fuse_connection_id(mountpoint: &Path) -> Option<u32> {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
let dev = std::fs::metadata(mountpoint).ok()?.dev();
|
||||
Some(libc::minor(dev))
|
||||
}
|
||||
|
||||
/// Force the kernel to abort FUSE connection `id`, erroring every in-flight
|
||||
/// request.
|
||||
///
|
||||
/// On a stop signal we unmount lazily (`MNT_DETACH`) so the call succeeds even
|
||||
/// mid-transfer — but detach only removes the mountpoint; it does *not* end the
|
||||
/// connection while a request is still in flight. fuser's session loop then
|
||||
/// blocks on `/dev/fuse` waiting for a next request that never comes, so `join`
|
||||
/// hangs — long enough during a transfer that systemd's stop timeout SIGKILLs
|
||||
/// the daemon mid-unmount, stranding the on-demand mounts as dead endpoints.
|
||||
/// Writing the connection's `abort` file makes the pending reads fail with
|
||||
/// `ENODEV`, so the loop returns and `join` completes at once. Best-effort:
|
||||
/// there is nothing more to do on the shutdown path if it fails.
|
||||
pub(super) fn abort_fuse_connection(id: u32) {
|
||||
let path = format!("/sys/fs/fuse/connections/{id}/abort");
|
||||
if std::fs::write(&path, b"1").is_ok() {
|
||||
info!(id, "aborted FUSE connection to unblock unmount");
|
||||
}
|
||||
}
|
||||
|
||||
/// Unmount a background session that may be mid-transfer without wedging: abort
|
||||
/// its connection first (so the session loop exits promptly), then lazily
|
||||
/// unmount and join. `conn` is the id captured at mount time; `None` skips the
|
||||
/// abort and just unmounts (a healthy idle mount joins on its own `Destroy`).
|
||||
pub(super) fn umount_session_unblocked(
|
||||
session: BackgroundSession,
|
||||
conn: Option<u32>,
|
||||
) -> std::io::Result<()> {
|
||||
if let Some(id) = conn {
|
||||
abort_fuse_connection(id);
|
||||
}
|
||||
session.umount_and_join()
|
||||
}
|
||||
|
||||
/// Best-effort teardown of a stale mount left behind by a crashed daemon. A
|
||||
/// previous run that died without unmounting leaves the kernel mount in place,
|
||||
/// so the fresh `Session::new` below would fail with EBUSY ("Device or resource
|
||||
/// busy"). `fusermount3 -u -z` is the lazy (detach) unmount, which succeeds even
|
||||
/// when the old mount is still busy. Swallow all output/errors: if there is no
|
||||
/// stale mount this is simply a no-op.
|
||||
pub(super) fn clear_stale_mount(mountpoint: &Path) {
|
||||
for bin in ["fusermount3", "fusermount"] {
|
||||
let ok = std::process::Command::new(bin)
|
||||
.arg("-u")
|
||||
.arg("-z")
|
||||
.arg(mountpoint)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
if ok {
|
||||
info!(mountpoint = %mountpoint.display(), "cleared stale mount before remount");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `sync_state` key holding the uid of the My Files root, so a later run can
|
||||
/// recover it from `nodes` without the API.
|
||||
const ROOT_UID_KEY: &str = "root_uid";
|
||||
|
||||
/// The My Files root, and whether we got it from the API (`true`) or from the
|
||||
/// cache because the API was unreachable (`false`).
|
||||
///
|
||||
/// A successful fetch also records the root's uid, which is what makes the
|
||||
/// fallback possible on a later run: `nodes` is keyed by uid, so without this we
|
||||
/// would have the root's row on disk and no way to tell which one it is.
|
||||
///
|
||||
/// Failing to fetch is only fatal on a genuinely cold start — no cached root
|
||||
/// means an empty tree, and mounting that would show the user an empty Drive
|
||||
/// rather than an honest error.
|
||||
fn fetch_or_recall_root(
|
||||
drive: &dyn DriveApi,
|
||||
rt: &tokio::runtime::Handle,
|
||||
db: &Db,
|
||||
) -> 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, 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, 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 {
|
||||
/// Proton account the session belongs to, for display and per-user paths.
|
||||
pub username: String,
|
||||
/// What the background conflict sweep may do. See `docs/BUGS.md` B71.
|
||||
pub sweep_mode: SweepMode,
|
||||
/// 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
|
||||
/// daemon is asked to stop.
|
||||
///
|
||||
/// Resolves the My Files root up front — from the API, or from the cached tree
|
||||
/// when the API is unreachable (`fetch_or_recall_root`) — then spawns the
|
||||
/// Phase 2 event-sync task, the
|
||||
/// Phase 4 control socket, and runs the FUSE session on its own thread while
|
||||
/// this thread waits for either a stop signal (SIGTERM/SIGINT) or the kernel
|
||||
/// mount ending on its own. On a stop signal we lazily unmount ourselves
|
||||
/// (`umount_and_join`, the MNT_DETACH path that succeeds even while downloads
|
||||
/// are in flight), so `systemctl --user stop` is always a clean teardown.
|
||||
/// `rt` must be a handle to a *running* multi-threaded runtime.
|
||||
pub fn mount(
|
||||
client: ProtonDriveClient,
|
||||
rt: tokio::runtime::Handle,
|
||||
mountpoint: &Path,
|
||||
cache: ContentCache,
|
||||
control_socket: &Path,
|
||||
db: Arc<Db>,
|
||||
options: MountOptions,
|
||||
) -> std::io::Result<MountOutcome> {
|
||||
let 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;
|
||||
// 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 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 {
|
||||
drive,
|
||||
client,
|
||||
rt: rt.clone(),
|
||||
maintenance: Arc::new(Mutex::new(Default::default())),
|
||||
primary_root_uid: root.uid.clone(),
|
||||
primary: true,
|
||||
state: Arc::new(Mutex::new(State::new(db.clone(), share_access))),
|
||||
root: Arc::new(OnceLock::new()),
|
||||
cache: Arc::new(cache),
|
||||
readers: Arc::new(Mutex::new(HashMap::new())),
|
||||
block_ring: Arc::new(Mutex::new(BlockRing::default())),
|
||||
block_flight: Arc::new(BlockFlight::default()),
|
||||
content_repairs: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
|
||||
prefetch: Arc::new(Prefetch::default()),
|
||||
prefetch_budget: Arc::new(tokio::sync::Semaphore::new(PREFETCH_BUDGET)),
|
||||
workers: Arc::new(Workers::new(FUSE_WORKERS)?),
|
||||
db,
|
||||
shared_publication: Arc::new(Mutex::new(())),
|
||||
shared_generation: Arc::new(AtomicU64::new(0)),
|
||||
shared_refresh_deadlines: Arc::new(Mutex::new(SharedRefreshDeadlines::default())),
|
||||
online: Arc::new(AtomicBool::new(online)),
|
||||
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,
|
||||
local_first,
|
||||
self_changes: Arc::new(Mutex::new(HashMap::new())),
|
||||
thumb_gen: Arc::new(Mutex::new(HashSet::new())),
|
||||
thumb_gen_budget: Arc::new(tokio::sync::Semaphore::new(
|
||||
super::photos::THUMB_GEN_CONCURRENCY,
|
||||
)),
|
||||
file_thumb_generation: Arc::new(AtomicU64::new(0)),
|
||||
file_thumb_cancel: Arc::new(tokio::sync::Notify::new()),
|
||||
thumbnail_build: Arc::new(Mutex::new(Default::default())),
|
||||
thumbnail_build_cancelled: Arc::new(AtomicBool::new(false)),
|
||||
thumbnail_build_cancel: Arc::new(tokio::sync::Notify::new()),
|
||||
thumbnail_misses: Arc::new(Mutex::new(Default::default())),
|
||||
quota: Arc::new(Mutex::new(None)),
|
||||
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(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 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.
|
||||
core.hydrate_pending();
|
||||
// Then the writes that were fsynced but never closed, which the cache moved
|
||||
// aside at open. After `hydrate_pending`, so a recovered partial write can
|
||||
// see an already-queued write to the same node.
|
||||
core.recover_fsynced_writes();
|
||||
// And finally the staged blobs neither of those accounts for: bytes a
|
||||
// failed release left behind with nothing pointing at them.
|
||||
core.reconcile_staging();
|
||||
// Several drain workers over one queue. One thread meant a single large
|
||||
// upload held every queued rename, trash and small write behind it for as
|
||||
// long as it ran; the claim column (`Db::claim_next_due_op`) keeps the
|
||||
// workers off each other's nodes, which is the only ordering the queue
|
||||
// actually needs. Worker 0 additionally runs the queue's idle chores.
|
||||
//
|
||||
// Every long-lived thread started from here on is kept in `workers` and
|
||||
// joined at the end of this function. Leaving them running turned an
|
||||
// in-process remount into a second generation of drain workers, sync engines
|
||||
// and sweeps all operating on a mount that no longer exists (`docs/BUGS.md` B44).
|
||||
let mut workers: Vec<std::thread::JoinHandle<()>> = Vec::new();
|
||||
for worker in 0..DRAIN_WORKERS {
|
||||
let core = core.clone();
|
||||
workers.push(
|
||||
std::thread::Builder::new()
|
||||
.name(format!("pdfs-drain-{worker}"))
|
||||
.spawn(move || core.run_pending_drain(worker == 0))?,
|
||||
);
|
||||
}
|
||||
|
||||
// Start the folder-sync engine. It watches every mirror folder, polls the
|
||||
// remotes, and reconciles on its own thread — never in front of a FUSE call.
|
||||
workers.extend(sync::spawn(core.clone(), sync_rx));
|
||||
// A timed pause persisted by an earlier run still has to end on time.
|
||||
if pause::paused_at(paused_until, now_secs()) && paused_until != pause::PAUSED_INDEFINITELY {
|
||||
core.arm_pause_timer(paused_until);
|
||||
}
|
||||
|
||||
// Reconcile leftover `(sync-conflict …)` copies: drop the ones that turned
|
||||
// out identical to the live file, surface the ones that genuinely diverge.
|
||||
// Its own thread — it enumerates and trashes, and must never block a FUSE call.
|
||||
// `Off` skips the thread entirely rather than starting an idle one, so the
|
||||
// setting is verifiable from the outside (`ls /proc/<pid>/task/*/comm`).
|
||||
if sweep_mode == SweepMode::Off {
|
||||
info!("conflict sweep disabled by configuration");
|
||||
} else {
|
||||
info!(mode = ?sweep_mode, "conflict sweep enabled");
|
||||
let core = core.clone();
|
||||
workers.push(
|
||||
std::thread::Builder::new()
|
||||
.name("pdfs-conflict-sweep".into())
|
||||
.spawn(move || core.run_conflict_sweep_loop())?,
|
||||
);
|
||||
}
|
||||
|
||||
// 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();
|
||||
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();
|
||||
let shutdown = core.shutdown.clone();
|
||||
workers.push(
|
||||
std::thread::Builder::new()
|
||||
.name("pdfs-localindex".into())
|
||||
.spawn(move || run_local_index(db, indexing, transfers, mountpoint, shutdown))?,
|
||||
);
|
||||
}
|
||||
|
||||
// Bind the control socket before the FUSE session takes over the thread. A
|
||||
// stale socket file from a previous run would block the bind, so clear it.
|
||||
let _ = std::fs::remove_file(control_socket);
|
||||
let old_umask = unsafe { libc::umask(0o77) };
|
||||
let listener_res = UnixListener::bind(control_socket);
|
||||
unsafe { libc::umask(old_umask) };
|
||||
let listener = listener_res?;
|
||||
// Owner-only before anything can connect: a peer on this socket commands the
|
||||
// daemon's authenticated session without a credential of its own (B6).
|
||||
if let Err(e) = pdfs_core::config::restrict_socket(control_socket) {
|
||||
error!(error = %e, "cannot restrict control socket permissions; refusing to serve");
|
||||
let _ = std::fs::remove_file(control_socket);
|
||||
return Err(std::io::Error::other(format!(
|
||||
"control socket permissions: {e}"
|
||||
)));
|
||||
}
|
||||
// Do not accept control requests until `spawn_session` has transitioned the
|
||||
// primary registration to live. The listener may queue a connection during
|
||||
// this short window, but its handler cannot observe `mounted = false`.
|
||||
let bg = match spawn_session(&core, mountpoint, root) {
|
||||
Ok(session) => session,
|
||||
Err(error) => {
|
||||
let _ = std::fs::remove_file(control_socket);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
core.mounts.lock().open();
|
||||
// The connection id, captured now while the mount is live, so a stop signal
|
||||
// mid-transfer can abort it rather than block `join` (see `abort_fuse_connection`).
|
||||
let main_conn = fuse_connection_id(mountpoint);
|
||||
{
|
||||
let control_core = core.clone();
|
||||
let username = username.clone();
|
||||
let mountpoint = mountpoint.to_path_buf();
|
||||
match std::thread::Builder::new()
|
||||
.name("pdfs-control".into())
|
||||
.spawn(move || run_control_socket(control_core, username, mountpoint, listener))
|
||||
{
|
||||
Ok(handle) => workers.push(handle),
|
||||
Err(error) => {
|
||||
// A failure here abandons the mount, so the background threads
|
||||
// started above have to be told to stop with it.
|
||||
stop_workers(&core, control_socket, workers);
|
||||
let secondaries = core.mounts.lock().close_and_drain();
|
||||
for (id, mount) in secondaries {
|
||||
if let Err(unmount_error) = mount.teardown() {
|
||||
warn!(id, error = %unmount_error, "secondary teardown after control startup failure failed");
|
||||
}
|
||||
}
|
||||
if let Err(unmount_error) = teardown_session(&core.session_live, || {
|
||||
umount_session_unblocked(bg, main_conn)
|
||||
}) {
|
||||
warn!(error = %unmount_error, "unmount after control startup failure failed");
|
||||
}
|
||||
let _ = std::fs::remove_file(control_socket);
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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 =
|
||||
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "install SIGTERM handler failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut sigint =
|
||||
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt()) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "install SIGINT handler failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
tokio::select! {
|
||||
_ = sigterm.recv() => info!("received SIGTERM"),
|
||||
_ = sigint.recv() => info!("received SIGINT"),
|
||||
}
|
||||
let _ = sig_tx.try_send(());
|
||||
});
|
||||
sig_rx
|
||||
}
|
||||
|
||||
/// How long teardown waits for the background workers and then the FUSE worker
|
||||
/// pool before proceeding without them. Long enough for an in-flight block to
|
||||
/// finish, short enough that `systemctl --user stop` still returns inside
|
||||
/// `TimeoutStopSec`.
|
||||
const WORKER_JOIN_DEADLINE: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Signal every background loop, wake the ones that are blocked, and join them.
|
||||
///
|
||||
/// The mount used to return with its drain workers, sync engine, sweep, online
|
||||
/// probe, indexer and control listener all still running on `Core` clones. The
|
||||
/// process usually exited straight afterwards and hid it; an in-process remount
|
||||
/// did not, and started a second full set on top of the first (`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));
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,555 @@
|
||||
//! Reading and writing this machine's `profile.json` in its device root, and
|
||||
//! 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:
|
||||
//! the profile is a convenience for a *future* machine, so failing to write it
|
||||
//! must not fail the folder add that triggered it.
|
||||
|
||||
use std::io::Cursor;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use pdfs_core::control::RestorableFolder;
|
||||
use pdfs_core::mounts::MountMode;
|
||||
use pdfs_core::profile::{
|
||||
PROFILE_DIR_NAME, PROFILE_FILE_NAME, PROFILE_VERSION, Profile, ProfileFolder, ProfilePin,
|
||||
};
|
||||
use pdfs_core::{CoreError, CoreResult};
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use super::sync;
|
||||
use super::{Core, now_secs, parse_uid, this_hostname};
|
||||
|
||||
/// Coalescing flags for background profile saves.
|
||||
///
|
||||
/// A single burst of user actions (adding three folders, pinning a tree) should
|
||||
/// cost one upload, not one per action. `SAVING` admits one worker at a time and
|
||||
/// `DIRTY` records that something changed while it was busy, so the worker takes
|
||||
/// exactly one more lap afterwards.
|
||||
///
|
||||
/// Process-global rather than a [`Core`] field because on-demand mounts fork
|
||||
/// their own `Core` (`Core::fork_state`) and all of them describe the same
|
||||
/// machine — there is one profile per daemon, so there is one flag pair.
|
||||
static SAVE_STATE: OnceLock<(AtomicBool, AtomicBool)> = OnceLock::new();
|
||||
|
||||
fn save_state() -> &'static (AtomicBool, AtomicBool) {
|
||||
SAVE_STATE.get_or_init(|| (AtomicBool::new(false), AtomicBool::new(false)))
|
||||
}
|
||||
|
||||
impl Core {
|
||||
/// Note that the arrangement this machine's profile describes has changed,
|
||||
/// and upload a fresh profile in the background.
|
||||
///
|
||||
/// Fire-and-forget by design: callers are mutation paths (add/remove a sync
|
||||
/// folder, switch a mode, pin a node, save settings) whose success does not
|
||||
/// depend on the backup succeeding.
|
||||
pub(crate) fn touch_profile(&self) {
|
||||
let (saving, dirty) = save_state();
|
||||
dirty.store(true, Ordering::SeqCst);
|
||||
// Someone is already uploading; they will see DIRTY and take another lap.
|
||||
if saving.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
let core = self.clone();
|
||||
std::thread::spawn(move || {
|
||||
let (saving, dirty) = save_state();
|
||||
while dirty.swap(false, Ordering::SeqCst) {
|
||||
if let Err(e) = core.save_profile() {
|
||||
warn!(error = %e.message, "profile backup failed");
|
||||
break;
|
||||
}
|
||||
}
|
||||
saving.store(false, Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
|
||||
/// Serialize this machine's arrangement and upload it to `profile.json`
|
||||
/// inside the device's `.proton-drive-linux` folder, replacing any existing
|
||||
/// one.
|
||||
///
|
||||
/// Does nothing when no device is registered: a machine that has never
|
||||
/// synced a folder has nothing to describe, and registering a device just to
|
||||
/// hold an empty profile would put a stray computer in the user's Drive.
|
||||
pub(crate) fn save_profile(&self) -> CoreResult<()> {
|
||||
let Some(device) = self
|
||||
.db
|
||||
.device_get()
|
||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let root_uid = parse_uid(&device.root_uid).ok_or_else(|| {
|
||||
CoreError::internal(format!("bad device root uid: {}", device.root_uid))
|
||||
})?;
|
||||
|
||||
let profile = self.build_profile(&device.uid)?;
|
||||
let bytes = profile.to_bytes().map_err(CoreError::internal)?;
|
||||
let len = bytes.len() as i64;
|
||||
|
||||
// The device root itself rejects file creation (B78), so the profile
|
||||
// lives one level down, in a folder created on first save.
|
||||
let dir_uid = self.ensure_profile_dir(&root_uid)?;
|
||||
|
||||
// Replace the existing document rather than adding a second file of the
|
||||
// same name: the profile is a single mutable record, and a folder with
|
||||
// `profile.json` and `profile (1).json` in it teaches the restore
|
||||
// nothing about which to trust.
|
||||
let existing = self.find_device_child_file(&dir_uid, PROFILE_FILE_NAME)?;
|
||||
match existing {
|
||||
Some(uid) => self
|
||||
.rt
|
||||
.block_on(self.drive.upload_new_revision_from(
|
||||
&uid,
|
||||
Cursor::new(bytes),
|
||||
len,
|
||||
Vec::new(),
|
||||
Some(now_secs()),
|
||||
))
|
||||
.map(|_| ())
|
||||
.map_err(|e| CoreError::from_api(&e, "upload profile revision"))?,
|
||||
None => {
|
||||
self.rt
|
||||
.block_on(self.drive.upload_file_replacing_draft_from(
|
||||
&dir_uid,
|
||||
PROFILE_FILE_NAME,
|
||||
"application/json",
|
||||
Cursor::new(bytes),
|
||||
len,
|
||||
Vec::new(),
|
||||
Some(now_secs()),
|
||||
false,
|
||||
))
|
||||
.map_err(|e| CoreError::from_api(&e, "upload profile"))?;
|
||||
}
|
||||
}
|
||||
info!(folders = profile.folders.len(), "profile backed up");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Snapshot the local arrangement into a [`Profile`].
|
||||
fn build_profile(&self, device_uid: &str) -> CoreResult<Profile> {
|
||||
let folders = self
|
||||
.db
|
||||
.sync_folder_list()
|
||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
|
||||
.into_iter()
|
||||
.map(|f| ProfileFolder {
|
||||
remote_uid: f.remote_uid,
|
||||
local_path: f.local_path,
|
||||
// A queued switch is where the user asked the folder to be, so
|
||||
// that is what the next machine should set up.
|
||||
mode: MountMode::from(f.pending_mode.unwrap_or(f.mode).as_str()),
|
||||
})
|
||||
.collect();
|
||||
let pins = self
|
||||
.db
|
||||
.pin_list()
|
||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
|
||||
.into_iter()
|
||||
.map(|row| ProfilePin {
|
||||
uid: row.uid,
|
||||
path: row.path,
|
||||
recursive: row.recursive,
|
||||
})
|
||||
.collect();
|
||||
let cfg = pdfs_core::config::AppDirs::new()
|
||||
.map(|d| d.load_config())
|
||||
.unwrap_or_default();
|
||||
Ok(Profile {
|
||||
version: PROFILE_VERSION,
|
||||
device_uid: device_uid.to_string(),
|
||||
hostname: this_hostname(),
|
||||
saved_at: now_secs(),
|
||||
folders,
|
||||
pins,
|
||||
ignore_patterns: cfg.ignore_patterns,
|
||||
cache_budget: cfg.cache_budget,
|
||||
mountpoint: cfg.mountpoint,
|
||||
})
|
||||
}
|
||||
|
||||
/// An untrashed *file* named `name` directly under `parent_uid`.
|
||||
///
|
||||
/// The sibling of `find_device_child_folder`; kept separate because a folder
|
||||
/// named `profile.json` must not be mistaken for the profile.
|
||||
fn find_device_child_file(
|
||||
&self,
|
||||
parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
) -> CoreResult<Option<NodeUid>> {
|
||||
let uids = self
|
||||
.rt
|
||||
.block_on(self.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.drive.enumerate_nodes(&uids))
|
||||
.map_err(|e| CoreError::from_api(&e, "resolve device root children"))?;
|
||||
Ok(nodes
|
||||
.into_iter()
|
||||
.find(|n| !n.is_folder() && !n.trashed && n.name == name)
|
||||
.map(|n| n.uid))
|
||||
}
|
||||
|
||||
/// The device's `.proton-drive-linux` folder, creating it if this is the
|
||||
/// first save on this device.
|
||||
///
|
||||
/// Reused by stable identity rather than recreated: a second folder of the
|
||||
/// same name would split the profile across two documents, and the restore
|
||||
/// would have no way to tell which is current.
|
||||
fn ensure_profile_dir(&self, root_uid: &NodeUid) -> CoreResult<NodeUid> {
|
||||
if let Some(uid) = self.find_device_child_folder(root_uid, PROFILE_DIR_NAME)? {
|
||||
return Ok(uid);
|
||||
}
|
||||
let uid = self
|
||||
.rt
|
||||
.block_on(
|
||||
self.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 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.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 ----------------------------------------
|
||||
|
||||
/// 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,
|
||||
device: Option<&str>,
|
||||
) -> CoreResult<Vec<RestorableFolder>> {
|
||||
let device = self.restore_source(device)?;
|
||||
let root_uid = parse_uid(&device.root_uid).ok_or_else(|| {
|
||||
CoreError::internal(format!("bad device root uid: {}", device.root_uid))
|
||||
})?;
|
||||
|
||||
let profile = match self.load_profile(&root_uid) {
|
||||
Ok(p) => p,
|
||||
// A missing or unreadable profile is not fatal: the remote tree
|
||||
// alone is enough to offer a restore, just with plainer defaults.
|
||||
Err(e) => {
|
||||
warn!(error = %e.message, "could not read profile; proposing defaults");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let uids = self
|
||||
.rt
|
||||
.block_on(self.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.drive.enumerate_nodes(&uids))
|
||||
.map_err(|e| CoreError::from_api(&e, "resolve device root children"))?
|
||||
};
|
||||
|
||||
// Folders already synced here are not restorable — they are already
|
||||
// restored — but they are still listed, flagged, so the picker can show
|
||||
// the whole device rather than a confusing subset.
|
||||
let existing: Vec<String> = self
|
||||
.db
|
||||
.sync_folder_list()
|
||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
|
||||
.into_iter()
|
||||
.map(|f| f.remote_uid)
|
||||
.collect();
|
||||
|
||||
Ok(nodes
|
||||
.into_iter()
|
||||
// `.proton-drive-linux` holds the profile itself, not user data:
|
||||
// offering it as a restorable folder would sync the bookkeeping
|
||||
// back onto the machine it describes.
|
||||
.filter(|n| n.is_folder() && !n.trashed && n.name != PROFILE_DIR_NAME)
|
||||
.map(|n| {
|
||||
let uid = n.uid.to_string();
|
||||
let saved = profile
|
||||
.as_ref()
|
||||
.and_then(|p| p.folders.iter().find(|f| f.remote_uid == uid));
|
||||
let proposed = saved
|
||||
.map(|f| PathBuf::from(&f.local_path))
|
||||
.filter(|p| path_is_usable_here(p))
|
||||
.unwrap_or_else(|| default_local_path(&n.name));
|
||||
RestorableFolder {
|
||||
already_synced: existing.iter().any(|e| e == &uid),
|
||||
remote_uid: uid,
|
||||
name: n.name,
|
||||
local_path: proposed.to_string_lossy().to_string(),
|
||||
mode: saved
|
||||
.map(|f| f.mode.to_string())
|
||||
.unwrap_or_else(|| "mirror".into()),
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Attach remote device folders to local paths and let the sync engine fill
|
||||
/// them in.
|
||||
///
|
||||
/// The download is not written here: a new sync-folder row starts with an
|
||||
/// empty baseline, and an empty baseline against an empty local directory
|
||||
/// and a populated remote already classifies as "download everything". The
|
||||
/// restore is therefore just bookkeeping plus a `Reconcile`, and it reuses
|
||||
/// the engine's conflict handling for the case where the local directory
|
||||
/// turns out not to be empty after all.
|
||||
pub(crate) fn restore_sync_folders(
|
||||
&self,
|
||||
items: &[pdfs_core::control::RestoreItem],
|
||||
device: Option<&str>,
|
||||
) -> CoreResult<String> {
|
||||
if items.is_empty() {
|
||||
return Err(CoreError::invalid("nothing to restore"));
|
||||
}
|
||||
let device = self.restore_source(device)?;
|
||||
let existing = self
|
||||
.db
|
||||
.sync_folder_list()
|
||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
||||
|
||||
let mut restored = 0usize;
|
||||
let mut skipped: Vec<String> = Vec::new();
|
||||
for item in items {
|
||||
let local = PathBuf::from(&item.local_path);
|
||||
if !local.is_absolute() {
|
||||
skipped.push(format!("{}: local path must be absolute", item.local_path));
|
||||
continue;
|
||||
}
|
||||
if existing.iter().any(|f| f.remote_uid == item.remote_uid) {
|
||||
skipped.push(format!("{}: already synced", item.local_path));
|
||||
continue;
|
||||
}
|
||||
if existing
|
||||
.iter()
|
||||
.any(|f| Path::new(&f.local_path) == local.as_path())
|
||||
{
|
||||
skipped.push(format!("{}: path already in use", item.local_path));
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = std::fs::create_dir_all(&local) {
|
||||
skipped.push(format!("{}: {e}", item.local_path));
|
||||
continue;
|
||||
}
|
||||
let mode = match restore_mode(&item.mode) {
|
||||
Some(mode) => mode,
|
||||
None => {
|
||||
skipped.push(format!("{}: unknown mode {}", item.local_path, item.mode));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let id = self
|
||||
.db
|
||||
.sync_folder_add(&local.to_string_lossy(), &item.remote_uid, &device.share_id)
|
||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
||||
// `ondemand` is applied through the normal request path, which
|
||||
// mounts the FUSE session and handles the folder being busy — the
|
||||
// restore does not get its own copy of that logic.
|
||||
if mode == MountMode::OnDemand {
|
||||
let _ = self.request_sync_folder_mode(id, "ondemand");
|
||||
}
|
||||
let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id));
|
||||
restored += 1;
|
||||
}
|
||||
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
|
||||
|
||||
let mut message = format!("restored {restored} folder(s)");
|
||||
if !skipped.is_empty() {
|
||||
message.push_str(&format!("; skipped: {}", skipped.join(", ")));
|
||||
}
|
||||
info!(restored, skipped = skipped.len(), "restore complete");
|
||||
Ok(message)
|
||||
}
|
||||
}
|
||||
|
||||
/// Accept only modes this build can safely restore. `MountMode` itself is
|
||||
/// tolerant so a future value does not reject the whole profile; the folder
|
||||
/// carrying that value is skipped here with the message assembled above.
|
||||
fn restore_mode(value: &str) -> Option<MountMode> {
|
||||
match MountMode::from(value) {
|
||||
MountMode::Mirror => Some(MountMode::Mirror),
|
||||
MountMode::OnDemand => Some(MountMode::OnDemand),
|
||||
MountMode::Unknown => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a path recorded on another machine can be reused verbatim here.
|
||||
///
|
||||
/// Requires the *parent* to exist: `~/Documents` on a machine with the same
|
||||
/// username is a fine proposal even though the directory itself is gone (the
|
||||
/// restore creates it), but `/home/someone-else/Documents` is not.
|
||||
fn path_is_usable_here(path: &Path) -> bool {
|
||||
path.is_absolute() && path.parent().is_some_and(|p| p.is_dir())
|
||||
}
|
||||
|
||||
/// `~/<name>`, or `/<name>` if the home directory cannot be determined.
|
||||
fn default_local_path(name: &str) -> PathBuf {
|
||||
match pdfs_core::config::AppDirs::new()
|
||||
.ok()
|
||||
.and_then(|d| d.home_dir())
|
||||
{
|
||||
Some(home) => home.join(name),
|
||||
None => PathBuf::from("/").join(name),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn foreign_home_is_not_reused() {
|
||||
assert!(!path_is_usable_here(Path::new(
|
||||
"/home/definitely-not-a-user-here/Documents"
|
||||
)));
|
||||
assert!(!path_is_usable_here(Path::new("relative/path")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_profile_mode_is_skipped_without_rejecting_known_modes() {
|
||||
assert_eq!(restore_mode("mirror"), Some(MountMode::Mirror));
|
||||
assert_eq!(restore_mode("ondemand"), Some(MountMode::OnDemand));
|
||||
assert_eq!(restore_mode("streaming"), None);
|
||||
assert_eq!(restore_mode("unknown"), None);
|
||||
}
|
||||
|
||||
/// The device root refuses file creation (B78), so every upload in
|
||||
/// `save_profile` must be addressed to the profile folder. Checked against
|
||||
/// the source because both branches are pure network calls.
|
||||
#[test]
|
||||
fn the_profile_is_never_uploaded_to_the_device_root() {
|
||||
let source = include_str!("profile.rs");
|
||||
let save = function_source(source, "fn save_profile(");
|
||||
assert!(
|
||||
save.contains("ensure_profile_dir(&root_uid)"),
|
||||
"save_profile resolves the profile folder"
|
||||
);
|
||||
for upload in [
|
||||
"upload_new_revision_from",
|
||||
"upload_file_replacing_draft_from",
|
||||
] {
|
||||
let call = save.find(upload).expect("upload call exists");
|
||||
let args = &save[call..];
|
||||
let root = args.find("&root_uid").unwrap_or(usize::MAX);
|
||||
let dir = args.find("&dir_uid").unwrap_or(usize::MAX);
|
||||
assert!(dir < root, "{upload} must target the profile folder");
|
||||
}
|
||||
}
|
||||
|
||||
/// A read has to keep finding profiles written before the fix, which sat in
|
||||
/// the device root.
|
||||
#[test]
|
||||
fn loading_falls_back_to_a_legacy_device_root_profile() {
|
||||
let source = include_str!("profile.rs");
|
||||
let load = function_source(source, "fn load_profile(");
|
||||
assert!(load.contains("PROFILE_DIR_NAME"), "prefers the folder");
|
||||
assert!(
|
||||
load.find("PROFILE_DIR_NAME").unwrap()
|
||||
< load.find("legacy profile").expect("legacy fallback exists"),
|
||||
"the folder is consulted before the device root"
|
||||
);
|
||||
}
|
||||
|
||||
/// The profile folder is the client's own bookkeeping: neither the restore
|
||||
/// picker nor `add_sync_folder` may treat it as a user folder.
|
||||
#[test]
|
||||
fn the_profile_folder_is_not_offered_as_user_data() {
|
||||
let restorable = function_source(include_str!("profile.rs"), "fn list_restorable_folders(");
|
||||
assert!(
|
||||
restorable.contains("n.name != PROFILE_DIR_NAME"),
|
||||
"restorable folders exclude the profile folder"
|
||||
);
|
||||
let add = function_source(include_str!("devices.rs"), "fn add_sync_folder(");
|
||||
assert_before(add, "PROFILE_DIR_NAME", "ensure_device()");
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn missing_leaf_under_existing_parent_is_reused() {
|
||||
let dir = std::env::temp_dir().join("pdfs-restore-probe");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
assert!(path_is_usable_here(&dir.join("Documents")));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
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,637 @@
|
||||
//! Moving a file or folder between two locations without moving its content.
|
||||
//!
|
||||
//! `~/ProtonDrive` and every on-demand synced folder are separate FUSE
|
||||
//! sessions, and a mirror folder is a plain local directory. The kernel refuses
|
||||
//! `rename(2)` between two mounts with `EXDEV` before the daemon hears of it, so
|
||||
//! `mv` falls back to copy and delete: every file is downloaded, uploaded again,
|
||||
//! and the original is trashed.
|
||||
//!
|
||||
//! On Proton Drive the same move is one metadata change, because My files and
|
||||
//! this computer's device folders all live on the main volume. So a move
|
||||
//! request that names two absolute paths is resolved here, against every
|
||||
//! location this daemon knows, and done as a server-side move. The local side
|
||||
//! is then brought level: a mount forgets the node and re-lists, and a mirror
|
||||
//! folder either renames its copy along or drops it once Drive has it.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use pdfs_core::db::{StoredSyncEntry, StoredSyncFolder};
|
||||
use pdfs_core::{CoreError, CoreResult};
|
||||
use proton_drive_rs::NodeKind;
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use super::state::State;
|
||||
use super::sync::mirror_subtree_unsynced;
|
||||
use super::{Core, is_local_uid, parse_uid};
|
||||
|
||||
/// How long a move waits for a mirror folder's sync pass to finish before it
|
||||
/// reports the folder busy. A pass can run for hours on a first sync, so the
|
||||
/// move does not wait it out.
|
||||
const MIRROR_LOCK_WAIT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Where one side of a move lives.
|
||||
enum Place {
|
||||
/// My files or an on-demand folder: a live FUSE mount. `core` is rooted at
|
||||
/// that mount and `rel` is relative to its mountpoint.
|
||||
Mounted { core: Box<Core>, rel: PathBuf },
|
||||
/// A mirror folder: a local directory kept in step by the sync engine.
|
||||
/// `rel` is `/`-separated, relative to the folder, as the baseline keys it.
|
||||
Mirror {
|
||||
folder: StoredSyncFolder,
|
||||
rel: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Place {
|
||||
fn mirror_id(&self) -> Option<i64> {
|
||||
match self {
|
||||
Place::Mirror { folder, .. } => Some(folder.id),
|
||||
Place::Mounted { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_root(&self) -> bool {
|
||||
match self {
|
||||
Place::Mounted { rel, .. } => rel.as_os_str().is_empty(),
|
||||
Place::Mirror { rel, .. } => rel.is_empty(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The mirror folder that holds `abs`, and `abs` relative to it. The deepest
|
||||
/// folder wins, although folders are not expected to nest.
|
||||
fn mirror_covering(
|
||||
folders: Vec<StoredSyncFolder>,
|
||||
abs: &Path,
|
||||
) -> Option<(StoredSyncFolder, String)> {
|
||||
let folder = folders
|
||||
.into_iter()
|
||||
.filter(|f| f.mode == "mirror" && abs.starts_with(&f.local_path))
|
||||
.max_by_key(|f| Path::new(&f.local_path).components().count())?;
|
||||
let rel = abs.strip_prefix(&folder.local_path).ok()?;
|
||||
let parts = rel
|
||||
.components()
|
||||
.map(|c| match c {
|
||||
Component::Normal(part) => part.to_str(),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
Some((folder, parts.join("/")))
|
||||
}
|
||||
|
||||
/// `name` inside the folder at `parent`, in baseline form.
|
||||
fn join_rel(parent: &str, name: &str) -> String {
|
||||
if parent.is_empty() {
|
||||
name.to_string()
|
||||
} else {
|
||||
format!("{parent}/{name}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Rename `from` to `to`, failing with `EEXIST` rather than replacing
|
||||
/// whatever appeared at `to` since it was checked. A filesystem that cannot
|
||||
/// promise that gets a plain rename after one more look.
|
||||
fn rename_noreplace(from: &Path, to: &Path) -> std::io::Result<()> {
|
||||
use std::ffi::CString;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
|
||||
let cstr = |p: &Path| {
|
||||
CString::new(p.as_os_str().as_bytes())
|
||||
.map_err(|_| std::io::Error::from_raw_os_error(libc::EINVAL))
|
||||
};
|
||||
let (c_from, c_to) = (cstr(from)?, cstr(to)?);
|
||||
// SAFETY: both are valid NUL-terminated paths that outlive the call.
|
||||
let rc = unsafe {
|
||||
libc::syscall(
|
||||
libc::SYS_renameat2,
|
||||
libc::AT_FDCWD,
|
||||
c_from.as_ptr(),
|
||||
libc::AT_FDCWD,
|
||||
c_to.as_ptr(),
|
||||
libc::RENAME_NOREPLACE,
|
||||
)
|
||||
};
|
||||
if rc == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let err = std::io::Error::last_os_error();
|
||||
match err.raw_os_error() {
|
||||
Some(libc::EINVAL | libc::ENOSYS) => {
|
||||
if std::fs::symlink_metadata(to).is_ok() {
|
||||
return Err(std::io::Error::from_raw_os_error(libc::EEXIST));
|
||||
}
|
||||
std::fs::rename(from, to)
|
||||
}
|
||||
_ => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether inode `ino` is `root` or lies under it in the tree. Bounded, so a
|
||||
/// parent cycle in corrupt data cannot hang the caller.
|
||||
fn ino_is_under(st: &State, mut ino: u64, root: u64) -> bool {
|
||||
for _ in 0..MAX_TREE_DEPTH {
|
||||
if ino == root {
|
||||
return true;
|
||||
}
|
||||
match st.entries.get(&ino) {
|
||||
Some(entry) if entry.parent != ino => ino = entry.parent,
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// How deep [`ino_is_under`] walks before it gives up.
|
||||
const MAX_TREE_DEPTH: usize = 1024;
|
||||
|
||||
/// Whether `uid` is `root` or lies under it, as far as the nodes table knows.
|
||||
fn under(core: &Core, root: &NodeUid, uid: &str) -> bool {
|
||||
let root = root.to_string();
|
||||
uid == root || matches!(core.db.path_relative_to(&root, uid), Ok(Some(_)))
|
||||
}
|
||||
|
||||
impl Core {
|
||||
/// Move the file or folder at `src` into the folder at `dest_parent`. Both
|
||||
/// are absolute local paths and may be in different locations: My files,
|
||||
/// an on-demand folder, or a mirror folder. Returns the moved name.
|
||||
///
|
||||
/// Within one mount this is [`Core::move_to`]. Across locations it
|
||||
/// is a server-side move followed by local bookkeeping; see the module
|
||||
/// documentation. Anything the move could lose is refused up front.
|
||||
pub(crate) fn move_between(&self, src: &Path, dest_parent: &Path) -> CoreResult<String> {
|
||||
let source = self.locate(src)?;
|
||||
let dest = self.locate(dest_parent)?;
|
||||
if let (
|
||||
Place::Mounted { core, rel },
|
||||
Place::Mounted {
|
||||
core: dest_core,
|
||||
rel: dest_rel,
|
||||
},
|
||||
) = (&source, &dest)
|
||||
&& Arc::ptr_eq(&core.root, &dest_core.root)
|
||||
{
|
||||
return core.move_to(rel, dest_rel);
|
||||
}
|
||||
if source.is_root() {
|
||||
return Err(CoreError::invalid(format!(
|
||||
"{} is the root of a synced location and cannot be moved",
|
||||
src.display()
|
||||
)));
|
||||
}
|
||||
if dest_parent.starts_with(src) {
|
||||
return Err(CoreError::invalid("cannot move a folder into itself"));
|
||||
}
|
||||
let name = src
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.ok_or_else(|| CoreError::invalid(format!("{} has no usable name", src.display())))?
|
||||
.to_string();
|
||||
|
||||
// Hold every mirror folder involved, lowest id first, so no sync pass
|
||||
// sees the move half done and two moves cannot deadlock.
|
||||
let mut ids: Vec<i64> = [source.mirror_id(), dest.mirror_id()]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
let locks: Vec<Arc<Mutex<()>>> = ids.iter().map(|&id| self.sync_lock(id)).collect();
|
||||
let mut guards = Vec::with_capacity(locks.len());
|
||||
for lock in &locks {
|
||||
guards.push(lock.try_lock_for(MIRROR_LOCK_WAIT).ok_or_else(|| {
|
||||
CoreError::conflict("a synced folder is busy syncing; try again when it is done")
|
||||
})?);
|
||||
}
|
||||
|
||||
let mut baselines: HashMap<i64, HashMap<String, StoredSyncEntry>> = HashMap::new();
|
||||
for &id in &ids {
|
||||
let baseline = self
|
||||
.db
|
||||
.sync_entries(id)
|
||||
.map_err(|e| CoreError::internal(format!("db: {e}")))?;
|
||||
baselines.insert(id, baseline);
|
||||
}
|
||||
|
||||
let src_uid = self.source_uid(&source, &baselines)?;
|
||||
let dest_uid = self.dest_uid(&dest, &baselines, &name)?;
|
||||
if src_uid.volume_id != dest_uid.volume_id {
|
||||
return Err(CoreError::invalid(
|
||||
"the two locations are on different Proton Drive volumes; copy it instead",
|
||||
));
|
||||
}
|
||||
if under(self, &src_uid, &dest_uid.to_string()) {
|
||||
return Err(CoreError::invalid("cannot move a folder into itself"));
|
||||
}
|
||||
|
||||
// Mirror to mirror renames the local copy along, so it needs nothing
|
||||
// proven about it. Another filesystem makes that impossible, and the
|
||||
// pair then drops the source copy like any other move out of a mirror.
|
||||
let mut renamed = None;
|
||||
if let (
|
||||
Place::Mirror { folder, rel },
|
||||
Place::Mirror {
|
||||
folder: dest_folder,
|
||||
rel: dest_rel,
|
||||
},
|
||||
) = (&source, &dest)
|
||||
{
|
||||
let from = src.to_path_buf();
|
||||
let to = dest_parent.join(&name);
|
||||
match rename_noreplace(&from, &to) {
|
||||
Ok(()) => renamed = Some((from, to)),
|
||||
Err(e) if e.raw_os_error() == Some(libc::EXDEV) => {
|
||||
info!(from = %folder.local_path, to = %dest_folder.local_path,
|
||||
"move: mirror folders are on different filesystems");
|
||||
}
|
||||
Err(e) if e.raw_os_error() == Some(libc::EEXIST) => {
|
||||
return Err(CoreError::conflict(format!(
|
||||
"{name} already exists in {}",
|
||||
dest_parent.display()
|
||||
)));
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(CoreError::internal(format!(
|
||||
"could not move {} to {}: {e}",
|
||||
rel,
|
||||
join_rel(dest_rel, &name)
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if renamed.is_none()
|
||||
&& let Place::Mirror { folder, rel } = &source
|
||||
&& let Some(reason) =
|
||||
mirror_subtree_unsynced(Path::new(&folder.local_path), rel, &baselines[&folder.id])
|
||||
{
|
||||
return Err(CoreError::conflict(format!(
|
||||
"{name} is not fully synced yet ({reason}); wait for the sync to finish"
|
||||
)));
|
||||
}
|
||||
|
||||
if let Err(e) = self.move_remote(&src_uid, &dest_uid) {
|
||||
if let Some((from, to)) = &renamed
|
||||
&& let Err(back) = std::fs::rename(to, from)
|
||||
{
|
||||
warn!(from = %to.display(), to = %from.display(), error = %back,
|
||||
"move: could not put the local copy back after the move failed");
|
||||
}
|
||||
return Err(CoreError::from_api(&e, "move"));
|
||||
}
|
||||
|
||||
self.settle_source(
|
||||
&source,
|
||||
&src_uid,
|
||||
renamed.is_some(),
|
||||
&dest,
|
||||
&dest_uid,
|
||||
&name,
|
||||
src,
|
||||
);
|
||||
self.settle_dest(&dest, &dest_uid, &name);
|
||||
drop(guards);
|
||||
if renamed.is_none()
|
||||
&& let Place::Mirror { folder, .. } = &dest
|
||||
{
|
||||
// Nothing local exists yet; the folder's next pass downloads it.
|
||||
self.sync_now(Some(folder.id));
|
||||
}
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
/// Which location `abs` is in. A mount is looked for first: a mirror folder
|
||||
/// cannot live inside a FUSE mount, and a mount is what the kernel sees.
|
||||
fn locate(&self, abs: &Path) -> CoreResult<Place> {
|
||||
if let Some((core, rel)) = self.rooted_at(abs) {
|
||||
if !rel
|
||||
.components()
|
||||
.all(|c| matches!(c, Component::Normal(_) | Component::CurDir))
|
||||
{
|
||||
return Err(CoreError::invalid(format!(
|
||||
"{} escapes its location",
|
||||
abs.display()
|
||||
)));
|
||||
}
|
||||
return Ok(Place::Mounted {
|
||||
core: Box::new(core),
|
||||
rel,
|
||||
});
|
||||
}
|
||||
let folders = self
|
||||
.db
|
||||
.sync_folder_list()
|
||||
.map_err(|e| CoreError::internal(format!("db: {e}")))?;
|
||||
mirror_covering(folders, abs)
|
||||
.map(|(folder, rel)| Place::Mirror { folder, rel })
|
||||
.ok_or_else(|| {
|
||||
CoreError::invalid(format!(
|
||||
"{} is not in a Proton Drive location",
|
||||
abs.display()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// The node being moved, once everything that must hold before it leaves
|
||||
/// its location has been checked.
|
||||
fn source_uid(
|
||||
&self,
|
||||
source: &Place,
|
||||
baselines: &HashMap<i64, HashMap<String, StoredSyncEntry>>,
|
||||
) -> CoreResult<NodeUid> {
|
||||
match source {
|
||||
Place::Mounted { core, rel } => {
|
||||
let (ino, uid) = core.resolve(rel)?;
|
||||
if is_local_uid(&uid) {
|
||||
return Err(CoreError::conflict(
|
||||
"it is not uploaded yet; wait for the upload to finish",
|
||||
));
|
||||
}
|
||||
let old_parent = core.source_parent_uid(ino, rel)?;
|
||||
for authority in [&uid, &old_parent] {
|
||||
core.require_uid_writable(authority)
|
||||
.map_err(|e| core.errno_error(e, "move access"))?;
|
||||
}
|
||||
if self.has_pending_work_under(ino, &uid)? {
|
||||
return Err(CoreError::conflict(
|
||||
"it has changes that are not uploaded yet; wait for the upload to finish",
|
||||
));
|
||||
}
|
||||
Ok(uid)
|
||||
}
|
||||
Place::Mirror { folder, rel } => baselines[&folder.id]
|
||||
.get(rel)
|
||||
.and_then(|entry| entry.remote_uid.as_deref())
|
||||
.and_then(parse_uid)
|
||||
.ok_or_else(|| {
|
||||
CoreError::conflict(
|
||||
"it is not on Proton Drive yet; wait for the sync to finish",
|
||||
)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// The folder the node moves into. Refuses one that already holds `name`
|
||||
/// on this disk; Drive refuses a clash on its side by itself.
|
||||
fn dest_uid(
|
||||
&self,
|
||||
dest: &Place,
|
||||
baselines: &HashMap<i64, HashMap<String, StoredSyncEntry>>,
|
||||
name: &str,
|
||||
) -> CoreResult<NodeUid> {
|
||||
let uid = match dest {
|
||||
Place::Mounted { core, rel } => {
|
||||
let (ino, uid) = core.resolve(rel)?;
|
||||
let is_folder = core
|
||||
.state()
|
||||
.entries
|
||||
.get(&ino)
|
||||
.is_some_and(|entry| matches!(entry.node.kind, NodeKind::Folder));
|
||||
if !is_folder {
|
||||
return Err(CoreError::invalid("the destination is not a folder"));
|
||||
}
|
||||
core.require_uid_writable(&uid)
|
||||
.map_err(|e| core.errno_error(e, "move access"))?;
|
||||
uid
|
||||
}
|
||||
Place::Mirror { folder, rel } => {
|
||||
let local = Path::new(&folder.local_path).join(rel);
|
||||
if !local.is_dir() {
|
||||
return Err(CoreError::invalid("the destination is not a folder"));
|
||||
}
|
||||
if std::fs::symlink_metadata(local.join(name)).is_ok() {
|
||||
return Err(CoreError::conflict(format!(
|
||||
"{name} already exists in the destination"
|
||||
)));
|
||||
}
|
||||
let uid = if rel.is_empty() {
|
||||
Some(folder.remote_uid.as_str())
|
||||
} else {
|
||||
baselines[&folder.id]
|
||||
.get(rel)
|
||||
.and_then(|entry| entry.remote_uid.as_deref())
|
||||
};
|
||||
uid.and_then(parse_uid).ok_or_else(|| {
|
||||
CoreError::conflict(
|
||||
"the destination is not on Proton Drive yet; wait for the sync to finish",
|
||||
)
|
||||
})?
|
||||
}
|
||||
};
|
||||
if is_local_uid(&uid) {
|
||||
return Err(CoreError::conflict(
|
||||
"the destination is not uploaded yet; wait for the upload to finish",
|
||||
));
|
||||
}
|
||||
Ok(uid)
|
||||
}
|
||||
|
||||
/// Whether the queue owes Drive anything for `uid` (inode `ino`) or a node
|
||||
/// under it, or a file under it is open for writing. Moving such a node away
|
||||
/// would strand the pending change against a path that no longer leads to
|
||||
/// it.
|
||||
fn has_pending_work_under(&self, ino: u64, uid: &NodeUid) -> CoreResult<bool> {
|
||||
let ops = self
|
||||
.db
|
||||
.pending_ops()
|
||||
.map_err(|e| CoreError::internal(format!("db: {e}")))?;
|
||||
if ops.iter().any(|op| {
|
||||
under(self, uid, &op.uid)
|
||||
|| op
|
||||
.parent_uid
|
||||
.as_deref()
|
||||
.is_some_and(|p| under(self, uid, p))
|
||||
}) {
|
||||
return Ok(true);
|
||||
}
|
||||
// Open handles are placed in the tree by inode, not by uid in the nodes
|
||||
// table. A file opened just after its create landed holds the
|
||||
// placeholder uid until the tree catches up, and the nodes table no
|
||||
// longer has that uid, so the move went ahead under an open write.
|
||||
let mut writing = false;
|
||||
self.for_each_state(|st| {
|
||||
writing = st
|
||||
.active_writes
|
||||
.keys()
|
||||
.any(|&open| ino_is_under(st, open, ino));
|
||||
});
|
||||
Ok(writing)
|
||||
}
|
||||
|
||||
/// Bring the source location level with a move Drive has made.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn settle_source(
|
||||
&self,
|
||||
source: &Place,
|
||||
uid: &NodeUid,
|
||||
renamed: bool,
|
||||
dest: &Place,
|
||||
dest_uid: &NodeUid,
|
||||
name: &str,
|
||||
src: &Path,
|
||||
) {
|
||||
match source {
|
||||
Place::Mounted { core, rel } => {
|
||||
// Moved everywhere, so no mount keeps serving it under the old
|
||||
// path and its row stays (`docs/BUGS.md` B117). The content
|
||||
// cache is keyed by uid and stays valid.
|
||||
self.relocate_everywhere(uid, dest_uid, name);
|
||||
core.invalidate_parent_listing(rel);
|
||||
}
|
||||
Place::Mirror { folder, rel } if renamed => {
|
||||
let Place::Mirror {
|
||||
folder: dest_folder,
|
||||
rel: dest_rel,
|
||||
} = dest
|
||||
else {
|
||||
unreachable!("a local rename needs a mirror destination");
|
||||
};
|
||||
if let Err(e) = self.db.sync_entries_move(
|
||||
folder.id,
|
||||
rel,
|
||||
dest_folder.id,
|
||||
&join_rel(dest_rel, name),
|
||||
) {
|
||||
// The next passes see a copy with no baseline on one side
|
||||
// and a baseline with no copy on the other. That uploads
|
||||
// the files again and trashes the moved node, but loses
|
||||
// nothing.
|
||||
warn!(error = ?e, "move: could not carry the sync baseline along");
|
||||
}
|
||||
}
|
||||
Place::Mirror { folder, rel } => {
|
||||
// Drive had all of it when the move was checked, so the local
|
||||
// copy is only a stale duplicate now. Something written into it
|
||||
// since would go with it, so look again first; if anything is
|
||||
// new, the copy stays and the next pass uploads it as new.
|
||||
let changed = match self.db.sync_entries(folder.id) {
|
||||
Ok(baseline) => {
|
||||
mirror_subtree_unsynced(Path::new(&folder.local_path), rel, &baseline)
|
||||
}
|
||||
Err(e) => Some(format!("db: {e}")),
|
||||
};
|
||||
if let Err(e) = self.db.sync_entries_remove_subtree(folder.id, rel) {
|
||||
warn!(error = ?e, "move: could not drop the sync baseline");
|
||||
}
|
||||
if let Some(reason) = changed {
|
||||
warn!(path = %src.display(), reason,
|
||||
"move: the local copy changed during the move; keeping it");
|
||||
return;
|
||||
}
|
||||
let removed = if src.is_dir() {
|
||||
std::fs::remove_dir_all(src)
|
||||
} else {
|
||||
std::fs::remove_file(src)
|
||||
};
|
||||
if let Err(e) = removed {
|
||||
warn!(path = %src.display(), error = %e,
|
||||
"move: could not remove the local copy after the move");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bring the destination location level with a move Drive has made.
|
||||
fn settle_dest(&self, dest: &Place, uid: &NodeUid, name: &str) {
|
||||
if let Place::Mounted { .. } = dest {
|
||||
self.invalidate_children_of(uid);
|
||||
// A lookup that missed before the move may be cached as absent.
|
||||
self.for_each_mount(|st, notify| {
|
||||
if let Some(&ino) = st.by_uid.get(uid) {
|
||||
notify.inval_entry(ino, name.to_string());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn folder(id: i64, local_path: &str, mode: &str) -> StoredSyncFolder {
|
||||
StoredSyncFolder {
|
||||
id,
|
||||
local_path: local_path.to_string(),
|
||||
remote_uid: format!("vol~root{id}"),
|
||||
remote_share_id: String::new(),
|
||||
mode: mode.to_string(),
|
||||
pending_mode: None,
|
||||
state: "idle".to_string(),
|
||||
last_sync: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_covering_finds_the_mirror_folder_and_the_path_inside_it() {
|
||||
let folders = vec![folder(1, "/home/u/Docs", "mirror")];
|
||||
let (found, rel) = mirror_covering(folders, Path::new("/home/u/Docs/a/b.txt")).unwrap();
|
||||
assert_eq!(found.id, 1);
|
||||
assert_eq!(rel, "a/b.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_covering_gives_an_empty_path_for_the_folder_itself() {
|
||||
let folders = vec![folder(1, "/home/u/Docs", "mirror")];
|
||||
let (_, rel) = mirror_covering(folders, Path::new("/home/u/Docs")).unwrap();
|
||||
assert_eq!(rel, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_covering_prefers_the_deepest_folder() {
|
||||
let folders = vec![
|
||||
folder(1, "/home/u/Docs", "mirror"),
|
||||
folder(2, "/home/u/Docs/Work", "mirror"),
|
||||
];
|
||||
let (found, rel) = mirror_covering(folders, Path::new("/home/u/Docs/Work/x")).unwrap();
|
||||
assert_eq!(found.id, 2);
|
||||
assert_eq!(rel, "x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_covering_skips_on_demand_folders_and_name_prefixed_siblings() {
|
||||
let folders = || {
|
||||
vec![
|
||||
folder(1, "/home/u/Docs", "mirror"),
|
||||
folder(2, "/home/u/Media", "ondemand"),
|
||||
]
|
||||
};
|
||||
assert!(mirror_covering(folders(), Path::new("/home/u/Media/x")).is_none());
|
||||
assert!(mirror_covering(folders(), Path::new("/home/u/Docs2/x")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_covering_refuses_a_path_that_climbs_out() {
|
||||
let folders = vec![folder(1, "/home/u/Docs", "mirror")];
|
||||
assert!(mirror_covering(folders, Path::new("/home/u/Docs/../etc")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_rel_names_a_child_of_the_folder_root_without_a_slash() {
|
||||
assert_eq!(join_rel("", "a"), "a");
|
||||
assert_eq!(join_rel("x/y", "a"), "x/y/a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_local_rename_never_replaces_what_is_already_there() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfs-relocate-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (a, b, c) = (dir.join("a"), dir.join("b"), dir.join("c"));
|
||||
std::fs::write(&a, b"moved").unwrap();
|
||||
std::fs::write(&b, b"kept").unwrap();
|
||||
|
||||
let err = rename_noreplace(&a, &b).unwrap_err();
|
||||
assert_eq!(err.raw_os_error(), Some(libc::EEXIST));
|
||||
assert_eq!(std::fs::read(&b).unwrap(), b"kept");
|
||||
assert!(a.exists());
|
||||
|
||||
rename_noreplace(&a, &c).unwrap();
|
||||
assert!(!a.exists());
|
||||
assert_eq!(std::fs::read(&c).unwrap(), b"moved");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,826 @@
|
||||
//! Sharing: per-node share membership, public links, the invitations this
|
||||
//! account has received, saved public-link bookmarks, and the reverse view of
|
||||
//! everything this account shares out.
|
||||
//!
|
||||
//! All of it is remote-only state — nothing here touches the inode maps or the
|
||||
//! content cache, so every method is a round-trip wrapped in the crate error
|
||||
//! shape the control socket expects.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use pdfs_core::control::{
|
||||
BookmarkInfo, DirEntry, InvitationInfo, PublicLinkInfo, ShareEntry, ShareEntryKind, SharedItem,
|
||||
};
|
||||
use pdfs_core::{CoreError, CoreResult};
|
||||
use tracing::debug;
|
||||
|
||||
use crate::QUOTA_TTL;
|
||||
use proton_drive_rs::proton_sdk::crypto::VerificationStatus;
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
use proton_drive_rs::{MemberRole, NodeKind};
|
||||
|
||||
use proton_drive_rs::Node;
|
||||
|
||||
use super::{Core, node_size, parse_uid, public_link_info, role_from_str, role_to_str};
|
||||
|
||||
/// A node from a *shared* listing as a [`DirEntry`].
|
||||
///
|
||||
/// Still uid-addressed — that is the only handle a foreign-volume node is
|
||||
/// guaranteed to have — but `path` is filled in when the node is resident under
|
||||
/// the synthetic `Shared with me/` directory, which is what lets a front-end
|
||||
/// offer path-keyed actions on it. The local-state flags (`pinned`, `cached`)
|
||||
/// stay false: they describe content this account owns a local copy policy for,
|
||||
/// which a share on someone else's volume is not.
|
||||
fn shared_entry_of(core: &Core, n: Node) -> DirEntry {
|
||||
let is_dir = n.is_folder();
|
||||
let size = match &n.kind {
|
||||
NodeKind::File {
|
||||
claimed_size,
|
||||
total_size_on_storage,
|
||||
..
|
||||
} => claimed_size.unwrap_or(*total_size_on_storage).max(0) as u64,
|
||||
NodeKind::Folder => 0,
|
||||
};
|
||||
let role = n
|
||||
.membership
|
||||
.as_ref()
|
||||
.and_then(|m| m.role_exact())
|
||||
.map(role_to_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let invitation = n.membership.as_ref();
|
||||
let shared_by = invitation
|
||||
.and_then(|m| m.inviter_email.clone())
|
||||
.unwrap_or_default();
|
||||
let shared_at = invitation.and_then(|m| m.invite_time).unwrap_or(0);
|
||||
// Only a signature that is present and wrong is a warning. An invitation
|
||||
// that could not be checked — unsigned, or no keys for the claimed inviter —
|
||||
// is not evidence of forgery, and flagging every such share would teach the
|
||||
// user to ignore the flag.
|
||||
let shared_by_unverified = invitation
|
||||
.and_then(|m| m.inviter_verification)
|
||||
.is_some_and(|status| status == VerificationStatus::Failed);
|
||||
DirEntry {
|
||||
name: n.name,
|
||||
is_dir,
|
||||
size,
|
||||
modified: n.modification_time,
|
||||
pinned: false,
|
||||
pinned_by_folder: false,
|
||||
cached: false,
|
||||
path: core.rel_path_for_uid(&n.uid).unwrap_or_default(),
|
||||
uid: n.uid.to_string(),
|
||||
role,
|
||||
shared_by,
|
||||
shared_at,
|
||||
shared_by_unverified,
|
||||
trashed_at: 0,
|
||||
trashed_from: None,
|
||||
issue: None,
|
||||
}
|
||||
}
|
||||
|
||||
impl Core {
|
||||
// ---- sharing a node ---------------------------------------------------
|
||||
|
||||
/// Invite Proton and/or external emails to the node at `rel` at `role`.
|
||||
/// Returns `(proton_invited, external_invited)`.
|
||||
pub(crate) fn share_node(
|
||||
&self,
|
||||
rel: &Path,
|
||||
emails: &[String],
|
||||
role: &str,
|
||||
message: Option<&str>,
|
||||
) -> CoreResult<(usize, usize)> {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
self.share_node_for_uid(&uid, emails, role, message)
|
||||
}
|
||||
|
||||
/// [`Core::share_node`] for a node addressed through any daemon location.
|
||||
pub(crate) fn share_node_by_uid(
|
||||
&self,
|
||||
uid: &str,
|
||||
emails: &[String],
|
||||
role: &str,
|
||||
message: Option<&str>,
|
||||
) -> CoreResult<(usize, usize)> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
self.share_node_for_uid(&uid, emails, role, message)
|
||||
}
|
||||
|
||||
fn share_node_for_uid(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
emails: &[String],
|
||||
role: &str,
|
||||
message: Option<&str>,
|
||||
) -> CoreResult<(usize, usize)> {
|
||||
let role = role_from_str(role)?;
|
||||
let invitees: Vec<(String, MemberRole)> =
|
||||
emails.iter().map(|e| (e.clone(), role)).collect();
|
||||
self.rt
|
||||
.block_on(self.client.invite_users(uid, &invitees, message))
|
||||
.map_err(|e| CoreError::from_api(&e, "share"))
|
||||
}
|
||||
|
||||
/// List the members, pending invitations and public link of the node at `rel`.
|
||||
pub(crate) fn list_share(
|
||||
&self,
|
||||
rel: &Path,
|
||||
) -> CoreResult<(Vec<ShareEntry>, Option<PublicLinkInfo>)> {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
self.list_share_for_uid(&uid)
|
||||
}
|
||||
|
||||
/// [`Core::list_share`] for a node addressed through any daemon location.
|
||||
pub(crate) fn list_share_by_uid(
|
||||
&self,
|
||||
uid: &str,
|
||||
) -> CoreResult<(Vec<ShareEntry>, Option<PublicLinkInfo>)> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
self.list_share_for_uid(&uid)
|
||||
}
|
||||
|
||||
fn list_share_for_uid(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
) -> CoreResult<(Vec<ShareEntry>, Option<PublicLinkInfo>)> {
|
||||
let mut entries = Vec::new();
|
||||
for m in self
|
||||
.rt
|
||||
.block_on(self.client.list_share_members(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list members"))?
|
||||
{
|
||||
entries.push(ShareEntry {
|
||||
id: m.membership_id.to_string(),
|
||||
email: m.email,
|
||||
role: role_to_str(m.role).to_string(),
|
||||
kind: ShareEntryKind::Member,
|
||||
});
|
||||
}
|
||||
for inv in self
|
||||
.rt
|
||||
.block_on(self.client.list_share_invitations(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list invitations"))?
|
||||
{
|
||||
entries.push(ShareEntry {
|
||||
id: inv.invitation_id,
|
||||
email: inv.invitee_email,
|
||||
role: role_to_str(inv.role).to_string(),
|
||||
kind: ShareEntryKind::ProtonInvite,
|
||||
});
|
||||
}
|
||||
for ext in self
|
||||
.rt
|
||||
.block_on(self.client.list_external_invitations(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list external invitations"))?
|
||||
{
|
||||
entries.push(ShareEntry {
|
||||
id: ext.invitation_id,
|
||||
email: ext.invitee_email,
|
||||
role: role_to_str(ext.role).to_string(),
|
||||
kind: ShareEntryKind::ExternalInvite,
|
||||
});
|
||||
}
|
||||
|
||||
let link = self
|
||||
.rt
|
||||
.block_on(self.client.get_public_link(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "get public link"))?
|
||||
.map(public_link_info);
|
||||
|
||||
Ok((entries, link))
|
||||
}
|
||||
|
||||
/// Change the role of a member or pending Proton invitation on the node at
|
||||
/// `rel`. External invitations have no role-update endpoint.
|
||||
pub(crate) fn update_share_role(
|
||||
&self,
|
||||
rel: &Path,
|
||||
id: &str,
|
||||
kind: ShareEntryKind,
|
||||
role: &str,
|
||||
) -> CoreResult<()> {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
self.update_share_role_for_uid(&uid, id, kind, role)
|
||||
}
|
||||
|
||||
/// [`Core::update_share_role`] for a node addressed through any daemon
|
||||
/// location.
|
||||
pub(crate) fn update_share_role_by_uid(
|
||||
&self,
|
||||
uid: &str,
|
||||
id: &str,
|
||||
kind: ShareEntryKind,
|
||||
role: &str,
|
||||
) -> CoreResult<()> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
self.update_share_role_for_uid(&uid, id, kind, role)
|
||||
}
|
||||
|
||||
fn update_share_role_for_uid(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
id: &str,
|
||||
kind: ShareEntryKind,
|
||||
role: &str,
|
||||
) -> CoreResult<()> {
|
||||
let role = role_from_str(role)?;
|
||||
match kind {
|
||||
ShareEntryKind::Member => {
|
||||
let member = self
|
||||
.rt
|
||||
.block_on(self.client.list_share_members(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list members"))?
|
||||
.into_iter()
|
||||
.find(|m| m.membership_id.to_string() == id)
|
||||
.ok_or_else(|| CoreError::not_found("member not found"))?;
|
||||
self.rt
|
||||
.block_on(self.client.update_member_role(&member, role))
|
||||
.map_err(|e| CoreError::from_api(&e, "update role"))
|
||||
}
|
||||
ShareEntryKind::ProtonInvite => {
|
||||
let inv = self
|
||||
.rt
|
||||
.block_on(self.client.list_share_invitations(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list invitations"))?
|
||||
.into_iter()
|
||||
.find(|i| i.invitation_id == id)
|
||||
.ok_or_else(|| CoreError::not_found("invitation not found"))?;
|
||||
self.rt
|
||||
.block_on(self.client.update_invitation_role(&inv, role))
|
||||
.map_err(|e| CoreError::from_api(&e, "update role"))
|
||||
}
|
||||
ShareEntryKind::ExternalInvite => Err(CoreError::invalid(
|
||||
"an external invitation's role cannot be changed",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a member, pending Proton invite, or external invite from the node
|
||||
/// at `rel`.
|
||||
pub(crate) fn remove_share_entry(
|
||||
&self,
|
||||
rel: &Path,
|
||||
id: &str,
|
||||
kind: ShareEntryKind,
|
||||
) -> CoreResult<()> {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
self.remove_share_entry_for_uid(&uid, id, kind)
|
||||
}
|
||||
|
||||
/// [`Core::remove_share_entry`] for a node addressed through any daemon
|
||||
/// location.
|
||||
pub(crate) fn remove_share_entry_by_uid(
|
||||
&self,
|
||||
uid: &str,
|
||||
id: &str,
|
||||
kind: ShareEntryKind,
|
||||
) -> CoreResult<()> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
self.remove_share_entry_for_uid(&uid, id, kind)
|
||||
}
|
||||
|
||||
fn remove_share_entry_for_uid(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
id: &str,
|
||||
kind: ShareEntryKind,
|
||||
) -> CoreResult<()> {
|
||||
match kind {
|
||||
ShareEntryKind::Member => {
|
||||
let member = self
|
||||
.rt
|
||||
.block_on(self.client.list_share_members(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list members"))?
|
||||
.into_iter()
|
||||
.find(|m| m.membership_id.to_string() == id)
|
||||
.ok_or_else(|| CoreError::not_found("member not found"))?;
|
||||
self.rt
|
||||
.block_on(self.client.remove_member(&member))
|
||||
.map_err(|e| CoreError::from_api(&e, "remove member"))
|
||||
}
|
||||
ShareEntryKind::ProtonInvite => {
|
||||
let inv = self
|
||||
.rt
|
||||
.block_on(self.client.list_share_invitations(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list invitations"))?
|
||||
.into_iter()
|
||||
.find(|i| i.invitation_id == id)
|
||||
.ok_or_else(|| CoreError::not_found("invitation not found"))?;
|
||||
self.rt
|
||||
.block_on(self.client.delete_invitation(&inv))
|
||||
.map_err(|e| CoreError::from_api(&e, "revoke invitation"))
|
||||
}
|
||||
ShareEntryKind::ExternalInvite => {
|
||||
let ext = self
|
||||
.rt
|
||||
.block_on(self.client.list_external_invitations(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list external invitations"))?
|
||||
.into_iter()
|
||||
.find(|i| i.invitation_id == id)
|
||||
.ok_or_else(|| CoreError::not_found("external invitation not found"))?;
|
||||
self.rt
|
||||
.block_on(self.client.delete_external_invitation(&ext))
|
||||
.map_err(|e| CoreError::from_api(&e, "revoke external invitation"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a public link on the node at `rel`, returning its info (with URL).
|
||||
pub(crate) fn create_public_link(
|
||||
&self,
|
||||
rel: &Path,
|
||||
role: &str,
|
||||
password: Option<&str>,
|
||||
expires: Option<i64>,
|
||||
) -> CoreResult<PublicLinkInfo> {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
self.create_public_link_for_uid(&uid, role, password, expires)
|
||||
}
|
||||
|
||||
/// [`Core::create_public_link`] for a node addressed through any daemon
|
||||
/// location.
|
||||
pub(crate) fn create_public_link_by_uid(
|
||||
&self,
|
||||
uid: &str,
|
||||
role: &str,
|
||||
password: Option<&str>,
|
||||
expires: Option<i64>,
|
||||
) -> CoreResult<PublicLinkInfo> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
self.create_public_link_for_uid(&uid, role, password, expires)
|
||||
}
|
||||
|
||||
fn create_public_link_for_uid(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
role: &str,
|
||||
password: Option<&str>,
|
||||
expires: Option<i64>,
|
||||
) -> CoreResult<PublicLinkInfo> {
|
||||
let role = role_from_str(role)?;
|
||||
let link = self
|
||||
.rt
|
||||
.block_on(self.client.create_public_link(uid, role, password, expires))
|
||||
.map_err(|e| CoreError::from_api(&e, "create public link"))?;
|
||||
Ok(public_link_info(link))
|
||||
}
|
||||
|
||||
/// Remove the public link `id` from the node at `rel`.
|
||||
pub(crate) fn remove_public_link(&self, rel: &Path, id: &str) -> CoreResult<()> {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
self.remove_public_link_for_uid(&uid, id)
|
||||
}
|
||||
|
||||
/// [`Core::remove_public_link`] for a node addressed through any daemon
|
||||
/// location.
|
||||
pub(crate) fn remove_public_link_by_uid(&self, uid: &str, id: &str) -> CoreResult<()> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
self.remove_public_link_for_uid(&uid, id)
|
||||
}
|
||||
|
||||
fn remove_public_link_for_uid(&self, uid: &NodeUid, id: &str) -> CoreResult<()> {
|
||||
let link = self
|
||||
.rt
|
||||
.block_on(self.client.get_public_link(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "get public link"))?
|
||||
.filter(|l| l.public_link_id == id)
|
||||
.ok_or_else(|| CoreError::not_found("public link not found"))?;
|
||||
self.rt
|
||||
.block_on(self.client.remove_public_link(&link))
|
||||
.map_err(|e| CoreError::from_api(&e, "remove public link"))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub(crate) fn list_shared_with_me(&self) -> CoreResult<Vec<DirEntry>> {
|
||||
let uids = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_shared_with_me_node_uids())
|
||||
.map_err(|e| CoreError::from_api(&e, "enumerate shared"))?;
|
||||
if uids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let nodes = self
|
||||
.rt
|
||||
.block_on(self.drive.enumerate_nodes(&uids))
|
||||
.map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?;
|
||||
Ok(nodes
|
||||
.into_iter()
|
||||
.map(|node| shared_entry_of(self, node))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// List the children of a folder shared with me, addressed by uid.
|
||||
///
|
||||
/// A shared subtree has no path in this account's mount — the FUSE tree only
|
||||
/// spans my own volume — so browsing into one is uid-addressed the whole way
|
||||
/// down, each listing handing the front-end the uids it descends with next.
|
||||
pub(crate) fn list_shared_folder(&self, uid: &str) -> CoreResult<Vec<DirEntry>> {
|
||||
let uid =
|
||||
parse_uid(uid).ok_or_else(|| CoreError::invalid(format!("invalid uid: {uid}")))?;
|
||||
let child_uids = self
|
||||
.rt
|
||||
.block_on(self.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.drive.enumerate_nodes(&child_uids))
|
||||
.map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?;
|
||||
Ok(nodes
|
||||
.into_iter()
|
||||
.map(|node| shared_entry_of(self, node))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Download a file shared with me into the content cache, returning its
|
||||
/// on-disk path (served from cache when a fresh blob already exists).
|
||||
///
|
||||
/// The by-uid twin of [`Core::open_file`]: same cache and same tracked
|
||||
/// download, but the node is fetched from the server rather than resolved
|
||||
/// through the inode tree, which does not cover other people's volumes.
|
||||
///
|
||||
/// [`Core::open_file`]: super::Core::open_file
|
||||
pub(crate) fn open_shared_file(&self, uid: &str) -> CoreResult<std::path::PathBuf> {
|
||||
let uid =
|
||||
parse_uid(uid).ok_or_else(|| CoreError::invalid(format!("invalid uid: {uid}")))?;
|
||||
let node = self
|
||||
.rt
|
||||
.block_on(self.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() {
|
||||
return Err(CoreError::invalid("not a regular file"));
|
||||
}
|
||||
let size = node_size(&node);
|
||||
let mtime = node.modification_time;
|
||||
if let Some(p) = self.cache.cached_content_path(&uid, mtime, size) {
|
||||
return Ok(p);
|
||||
}
|
||||
let bytes = self
|
||||
.download_file_tracked(&uid, &node.name, size)
|
||||
.map_err(|e| CoreError::from_api(&e, "download"))?;
|
||||
self.cache
|
||||
.store(&uid, mtime, size, &bytes)
|
||||
.map_err(|e| CoreError::internal(format!("cache store: {e}")))?;
|
||||
Ok(self.cache.content_path(&uid))
|
||||
}
|
||||
|
||||
/// Leave a shared node by its uid.
|
||||
pub(crate) fn leave_shared(&self, uid: &str) -> CoreResult<()> {
|
||||
let uid =
|
||||
parse_uid(uid).ok_or_else(|| CoreError::invalid(format!("invalid uid: {uid}")))?;
|
||||
self.rt
|
||||
.block_on(self.client.leave_shared_node(&uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "leave shared"))
|
||||
}
|
||||
|
||||
// ---- incoming invitations ---------------------------------------------
|
||||
|
||||
/// List invitations addressed to me, pending accept or reject.
|
||||
pub(crate) fn list_invitations(&self) -> CoreResult<Vec<InvitationInfo>> {
|
||||
let invitations = self
|
||||
.rt
|
||||
.block_on(self.client.list_incoming_invitations())
|
||||
.map_err(|e| CoreError::from_api(&e, "list invitations"))?;
|
||||
Ok(invitations
|
||||
.into_iter()
|
||||
.map(|i| InvitationInfo {
|
||||
id: i.invitation_id,
|
||||
inviter_email: i.inviter_email,
|
||||
name: i.node_name,
|
||||
is_dir: i.is_folder,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Accept an invitation addressed to me by its id.
|
||||
pub(crate) fn accept_invitation(&self, id: &str) -> CoreResult<()> {
|
||||
self.rt
|
||||
.block_on(self.client.accept_invitation(id))
|
||||
.map_err(|e| CoreError::from_api(&e, "accept invitation"))
|
||||
}
|
||||
|
||||
/// Reject an invitation addressed to me by its id.
|
||||
pub(crate) fn reject_invitation(&self, id: &str) -> CoreResult<()> {
|
||||
self.rt
|
||||
.block_on(self.client.reject_invitation(id))
|
||||
.map_err(|e| CoreError::from_api(&e, "reject invitation"))
|
||||
}
|
||||
|
||||
// ---- bookmarks --------------------------------------------------------
|
||||
|
||||
/// List public links saved to my account.
|
||||
pub(crate) fn list_bookmarks(&self) -> CoreResult<Vec<BookmarkInfo>> {
|
||||
let bookmarks = self
|
||||
.rt
|
||||
.block_on(self.client.list_bookmarks())
|
||||
.map_err(|e| CoreError::from_api(&e, "list bookmarks"))?;
|
||||
Ok(bookmarks
|
||||
.into_iter()
|
||||
.map(|b| BookmarkInfo {
|
||||
token: b.token,
|
||||
url: b.url,
|
||||
name: b.node_name,
|
||||
is_dir: b.is_folder,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Save a public link (optionally password-protected) as a bookmark.
|
||||
pub(crate) fn create_bookmark(&self, url: &str, password: Option<&str>) -> CoreResult<()> {
|
||||
self.rt
|
||||
.block_on(self.client.create_bookmark(url, password))
|
||||
.map_err(|e| CoreError::from_api(&e, "create bookmark"))
|
||||
}
|
||||
|
||||
/// Remove a saved bookmark by its token.
|
||||
pub(crate) fn delete_bookmark(&self, token: &str) -> CoreResult<()> {
|
||||
self.rt
|
||||
.block_on(self.client.delete_bookmark(token))
|
||||
.map_err(|e| CoreError::from_api(&e, "delete bookmark"))
|
||||
}
|
||||
|
||||
// ---- account ----------------------------------------------------------
|
||||
|
||||
/// Total account storage usage `(max_space, used_space)` in bytes, across all
|
||||
/// Proton products (not Drive-only). A remote round-trip; nothing here is
|
||||
/// cached.
|
||||
pub(crate) fn account_quota(&self) -> CoreResult<(i64, i64)> {
|
||||
let q = self.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
|
||||
/// state (members, pending invitations, public link). One list call enumerates
|
||||
/// the shared uids; the per-node detail is then gathered best-effort — a single
|
||||
/// node racing with an unshare drops from the list rather than failing the whole
|
||||
/// request.
|
||||
///
|
||||
/// 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
|
||||
.block_on(self.client.enumerate_shared_by_me_node_uids())
|
||||
.map_err(|e| CoreError::from_api(&e, "enumerate shared-by-me"))?;
|
||||
if uids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let nodes = self
|
||||
.rt
|
||||
.block_on(self.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 {
|
||||
let uid = n.uid.clone();
|
||||
let members = self
|
||||
.rt
|
||||
.block_on(self.client.list_share_members(&uid))
|
||||
.map(|m| m.len());
|
||||
let proton_invites = self
|
||||
.rt
|
||||
.block_on(self.client.list_share_invitations(&uid))
|
||||
.map(|i| i.len());
|
||||
let external_invites = self
|
||||
.rt
|
||||
.block_on(self.client.list_external_invitations(&uid))
|
||||
.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,
|
||||
invite_count: proton_invites + external_invites,
|
||||
link,
|
||||
});
|
||||
}
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
/// Best-effort mountpoint-relative path for a node already interned in the live
|
||||
/// tree, by walking parent inodes to the root. `None` when the node has never
|
||||
/// been seen through the mount (e.g. shared but not browsed to this session) —
|
||||
/// the caller then leaves the path empty.
|
||||
pub(crate) fn rel_path_for_uid(&self, uid: &NodeUid) -> Option<String> {
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 !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);
|
||||
}
|
||||
}
|
||||
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)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
//! Background conflict sweep.
|
||||
//!
|
||||
//! A `(sync-conflict <ts>)` copy is only ever a fallback: the drain and the sync
|
||||
//! engine cut one when they cannot prove a queued write is safe to apply, so the
|
||||
//! user never loses bytes. Most of those copies turn out to be byte-identical to
|
||||
//! the live file — a spurious conflict from a re-stamped revision (B16/B25) or a
|
||||
//! first-sync of an already-identical file. This sweep reconciles them after the
|
||||
//! fact:
|
||||
//!
|
||||
//! - **Identical** to the live sibling (same size *and* same content SHA-1): the
|
||||
//! copy is redundant, so it is trashed and logged as a [`ActivityKind::Trash`].
|
||||
//! - **Divergent** (different bytes) or **orphaned** (no live sibling): left in
|
||||
//! place and surfaced once as an [`ActivityKind::Conflict`], because only the
|
||||
//! user can decide which version wins.
|
||||
//!
|
||||
//! Identity is proved without downloading anything: the SHA-1 comes from the
|
||||
//! revision's decrypted extended attributes, which enumeration already reads. A
|
||||
//! copy whose sibling has no recorded SHA-1 is treated as divergent — the sweep
|
||||
//! never removes a file it cannot prove is a duplicate.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use pdfs_core::batch;
|
||||
use pdfs_core::config::SweepMode;
|
||||
use pdfs_core::control::ActivityKind;
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use super::{Core, node_content_sha1, node_revision_id, node_size};
|
||||
|
||||
/// Time between sweeps. Conflicts are rare and non-urgent; a slow cadence keeps
|
||||
/// the sweep off the hot path while still cleaning up within a few minutes.
|
||||
const CONFLICT_SWEEP_INTERVAL: Duration = Duration::from_secs(300);
|
||||
|
||||
/// Delay before the first sweep, so it does not compete with the burst of work a
|
||||
/// fresh mount does at startup (hydrate, recover, initial enumeration).
|
||||
const CONFLICT_SWEEP_WARMUP: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Strip a ` (sync-conflict <stamp>)` (optionally `-<n>`) marker from `name`,
|
||||
/// returning the original file name it was a copy of. `None` when `name` is not a
|
||||
/// conflict copy. Inverse of the `conflict_name` / `conflict_path_with_suffix`
|
||||
/// formats, so the two halves of the product agree on what a conflict is.
|
||||
pub(crate) fn conflict_base_name(name: &str) -> Option<String> {
|
||||
const MARKER: &str = " (sync-conflict ";
|
||||
let start = name.find(MARKER)?;
|
||||
let after = start + MARKER.len();
|
||||
let close_rel = name[after..].find(')')?;
|
||||
let close = after + close_rel;
|
||||
let inner = &name[after..close];
|
||||
// The token is `<digits>` or `<digits>-<digits>`; reject anything else so a
|
||||
// file that merely happens to contain the phrase is not mistaken for a copy.
|
||||
let mut parts = inner.splitn(2, '-');
|
||||
let stamp = parts.next().unwrap_or_default();
|
||||
let suffix = parts.next();
|
||||
let is_digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit());
|
||||
if !is_digits(stamp) || suffix.is_some_and(|s| !is_digits(s)) {
|
||||
return None;
|
||||
}
|
||||
let mut base = String::with_capacity(start + name.len() - close);
|
||||
base.push_str(&name[..start]);
|
||||
base.push_str(&name[close + 1..]);
|
||||
Some(base)
|
||||
}
|
||||
|
||||
impl Core {
|
||||
/// Sweep for stale conflict copies forever, on its own thread. See the module
|
||||
/// docs for the identical/divergent policy.
|
||||
pub(crate) fn run_conflict_sweep_loop(&self) {
|
||||
// Both waits are interruptible, so teardown does not have to sit out a
|
||||
// five-minute interval to join this thread (`docs/BUGS.md` B44).
|
||||
if !self.shutdown.sleep(CONFLICT_SWEEP_WARMUP) {
|
||||
return;
|
||||
}
|
||||
loop {
|
||||
self.sweep_conflicts_once();
|
||||
if !self.shutdown.sleep(CONFLICT_SWEEP_INTERVAL) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One sweep pass over every node the daemon currently knows.
|
||||
fn sweep_conflicts_once(&self) {
|
||||
let stored = match self.db.load_all() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
warn!(error = ?e, "conflict sweep: load nodes failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let nodes: Vec<_> = stored
|
||||
.into_iter()
|
||||
.map(|stored| stored.node)
|
||||
.filter(|node| self.is_own_or_virtual(&node.uid))
|
||||
.collect();
|
||||
|
||||
// Index live (non-trashed) files by (parent, name) for sibling lookup.
|
||||
let mut by_parent_name: HashMap<(String, &str), &_> = HashMap::new();
|
||||
for node in &nodes {
|
||||
if node.trashed || node.is_folder() {
|
||||
continue;
|
||||
}
|
||||
let parent = node
|
||||
.parent_uid
|
||||
.as_ref()
|
||||
.map(|u| u.to_string())
|
||||
.unwrap_or_default();
|
||||
by_parent_name.insert((parent, node.name.as_str()), node);
|
||||
}
|
||||
|
||||
let roots = self.search_roots();
|
||||
let online = self.online.load(std::sync::atomic::Ordering::Relaxed);
|
||||
for node in &nodes {
|
||||
if node.trashed || node.is_folder() {
|
||||
continue;
|
||||
}
|
||||
let Some(base) = conflict_base_name(&node.name) else {
|
||||
continue;
|
||||
};
|
||||
let parent = node
|
||||
.parent_uid
|
||||
.as_ref()
|
||||
.map(|u| u.to_string())
|
||||
.unwrap_or_default();
|
||||
let sibling = by_parent_name.get(&(parent, base.as_str())).copied();
|
||||
|
||||
match decide(node, sibling, &base, self.sweep_mode) {
|
||||
SweepAction::Remove => {
|
||||
// Only while online — the remote trash is the point. Offline,
|
||||
// it waits for a later pass.
|
||||
if online {
|
||||
self.remove_conflict_copy(node, &base);
|
||||
}
|
||||
}
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Trash a conflict copy proven identical to its live sibling, and unhook it
|
||||
/// locally — the same sequence the interactive unlink path uses after a trash.
|
||||
///
|
||||
/// `snapshot` is the node as the pass saw it, which by now is stale: the pass
|
||||
/// enumerated every node up front and has been doing network I/O since. Before
|
||||
/// touching anything remote this re-reads the node and refuses to act unless
|
||||
/// it is *still* the same revision, still untrashed, still unwritten and still
|
||||
/// unopened (`docs/BUGS.md` B71). Nothing on this path discards a queued write
|
||||
/// or its staged blob: a write that arrives after that check is left for the
|
||||
/// drain to resolve against the now-trashed node.
|
||||
fn remove_conflict_copy(&self, snapshot: &proton_drive_rs::Node, base: &str) {
|
||||
let uid = &snapshot.uid;
|
||||
let name = snapshot.name.as_str();
|
||||
if !self.duplicate_still_removable(snapshot) {
|
||||
return;
|
||||
}
|
||||
match self
|
||||
.rt
|
||||
.block_on(self.drive.trash_nodes(std::slice::from_ref(uid)))
|
||||
.and_then(batch::into_unit)
|
||||
{
|
||||
Ok(()) => {}
|
||||
Err(e) if super::is_gone(&e) => {}
|
||||
Err(e) => {
|
||||
warn!(%uid, name, error = %e, "conflict sweep: trashing duplicate failed");
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 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);
|
||||
});
|
||||
// Deliberately *not* `discard_queued_ops`: `duplicate_still_removable`
|
||||
// proved the queue empty before the trash above, but that proof is stale
|
||||
// the moment `trash_nodes` goes to the network. A `close(2)` landing in
|
||||
// that window stages bytes, and discarding here would unlink the staged
|
||||
// blob holding the only copy of them. Leaving the op queued is the
|
||||
// non-destructive outcome: the drain re-reads the node, sees it trashed,
|
||||
// and `revision_conflict` routes it to `keep_as_conflict_copy`.
|
||||
self.cache.evict(uid);
|
||||
self.evict_reader(uid);
|
||||
if let Err(e) = self.db.delete_node(uid) {
|
||||
warn!(%uid, error = ?e, "conflict sweep: db delete after trash failed");
|
||||
}
|
||||
self.invalidate_trash();
|
||||
self.conflict_notified.lock().remove(uid);
|
||||
self.log_activity(
|
||||
ActivityKind::Trash,
|
||||
name,
|
||||
format!("auto-removed duplicate of {base}"),
|
||||
true,
|
||||
);
|
||||
info!(%uid, name, base, "conflict sweep: removed identical conflict copy");
|
||||
}
|
||||
|
||||
/// Re-check, immediately before the destructive call, everything the pass
|
||||
/// decided from its stale snapshot. Any doubt means "leave it alone": a
|
||||
/// conflict copy that survives to the next pass costs nothing, a wrongly
|
||||
/// removed one costs the user data.
|
||||
fn duplicate_still_removable(&self, snapshot: &proton_drive_rs::Node) -> bool {
|
||||
let uid = &snapshot.uid;
|
||||
let name = snapshot.name.as_str();
|
||||
|
||||
// A queued op means bytes are still owed an upload; trashing the node
|
||||
// under them turns an ordinary upload into a conflict fork.
|
||||
match self.db.has_any_op(&uid.to_string()) {
|
||||
Ok(false) => {}
|
||||
Ok(true) => {
|
||||
debug!(%uid, name, "conflict sweep: skipping duplicate with queued ops");
|
||||
return false;
|
||||
}
|
||||
// Cannot prove the queue is empty — assume it is not.
|
||||
Err(e) => {
|
||||
warn!(%uid, name, error = ?e, "conflict sweep: op check failed; skipping");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Someone is writing to it, or holds it open. Trashing now would pull the
|
||||
// node out from under a live handle.
|
||||
if self.is_busy(uid) {
|
||||
debug!(%uid, name, "conflict sweep: skipping duplicate that is open or being written");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Finally, the node itself must not have moved on since the snapshot.
|
||||
match self.db.node_by_uid(&uid.to_string()) {
|
||||
Ok(Some(fresh)) => {
|
||||
if fresh.trashed {
|
||||
return false;
|
||||
}
|
||||
if fresh.name != snapshot.name || fresh.parent_uid != snapshot.parent_uid {
|
||||
debug!(%uid, name, "conflict sweep: duplicate was renamed or moved; skipping");
|
||||
return false;
|
||||
}
|
||||
if node_revision_id(&fresh) != node_revision_id(snapshot)
|
||||
|| node_size(&fresh) != node_size(snapshot)
|
||||
|| node_content_sha1(&fresh) != node_content_sha1(snapshot)
|
||||
{
|
||||
debug!(%uid, name, "conflict sweep: duplicate changed since the pass began");
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
// Already gone locally, or unreadable — either way, not ours to remove.
|
||||
Ok(None) => false,
|
||||
Err(e) => {
|
||||
warn!(%uid, name, error = ?e, "conflict sweep: re-read failed; skipping");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a node has a write in flight or a file handle open against it.
|
||||
/// Read-only opens use fh 0 and are not tracked, so this is specifically the
|
||||
/// writer/holder check.
|
||||
/// Asked across **every** mount. The sweep runs on the primary Core but
|
||||
/// trashes nodes anywhere in the tree, and a file open inside a sync folder
|
||||
/// is tracked in that fork's `State` — so consulting `self.state` alone
|
||||
/// answered "idle" for every such file and let the sweep delete one with a
|
||||
/// writer attached (`docs/BUGS.md` B74). Busy anywhere means busy.
|
||||
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 {
|
||||
return;
|
||||
};
|
||||
busy |=
|
||||
st.active_writes.contains_key(&ino) || st.handles.values().any(|&held| held == ino);
|
||||
});
|
||||
busy
|
||||
}
|
||||
|
||||
/// Surface a conflict copy that needs the user's attention, 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// What the sweep concluded about one `(sync-conflict …)` copy.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum SweepAction {
|
||||
/// Provably redundant: trash it, subject to the runtime interlocks in
|
||||
/// [`Core::duplicate_still_removable`].
|
||||
Remove,
|
||||
/// Only the user can resolve this one; surface it with this detail.
|
||||
Flag(String),
|
||||
}
|
||||
|
||||
/// Decide what to do about conflict copy `node` given its live sibling (if any)
|
||||
/// and the configured `mode`.
|
||||
///
|
||||
/// Pure, so the policy that governs deletion is testable without a mount, a
|
||||
/// runtime, or an account — the same split used for `revision_changed` and
|
||||
/// `is_own_self_supersede`. Everything that can fail or race lives in the caller.
|
||||
fn decide(
|
||||
node: &proton_drive_rs::Node,
|
||||
sibling: Option<&proton_drive_rs::Node>,
|
||||
base: &str,
|
||||
mode: SweepMode,
|
||||
) -> SweepAction {
|
||||
match sibling {
|
||||
Some(sib) if is_identical(node, sib) => {
|
||||
if mode.is_enforcing() {
|
||||
SweepAction::Remove
|
||||
} else {
|
||||
// Report-only: say what would happen, remove nothing. The point is
|
||||
// to accumulate field evidence that the identity check is right
|
||||
// before it is trusted with deletion (docs/BUGS.md B71).
|
||||
SweepAction::Flag(format!(
|
||||
"identical to {base}; would be removed (sweep is report-only)"
|
||||
))
|
||||
}
|
||||
}
|
||||
Some(_) => SweepAction::Flag(format!("differs from {base}")),
|
||||
None => SweepAction::Flag(format!("no original {base} to reconcile against")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether two file nodes provably hold the same bytes: equal size and equal,
|
||||
/// present content SHA-1. A missing digest on either side is not proof, so the
|
||||
/// sweep treats it as divergent rather than risk removing a real difference.
|
||||
fn is_identical(a: &proton_drive_rs::Node, b: &proton_drive_rs::Node) -> bool {
|
||||
if node_size(a) != node_size(b) {
|
||||
return false;
|
||||
}
|
||||
match (node_content_sha1(a), node_content_sha1(b)) {
|
||||
(Some(x), Some(y)) => x == y,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use proton_drive_rs::NodeKind;
|
||||
use proton_drive_rs::proton_sdk::ids::{LinkId, VolumeId};
|
||||
|
||||
/// A file node with the two fields the sweep's identity check reads.
|
||||
fn file(name: &str, size: i64, sha1: Option<&str>) -> proton_drive_rs::Node {
|
||||
proton_drive_rs::Node {
|
||||
uid: NodeUid::new(VolumeId::from("v"), LinkId::from(name)),
|
||||
parent_uid: None,
|
||||
kind: NodeKind::File {
|
||||
media_type: "text/plain".into(),
|
||||
total_size_on_storage: size,
|
||||
active_revision_state: None,
|
||||
active_revision_id: Some("rev".into()),
|
||||
claimed_size: Some(size),
|
||||
claimed_modification_time: None,
|
||||
content_sha1: sha1.map(String::from),
|
||||
},
|
||||
name: name.into(),
|
||||
creation_time: 0,
|
||||
modification_time: 0,
|
||||
trashed: false,
|
||||
is_shared: false,
|
||||
is_shared_publicly: false,
|
||||
signature_email: None,
|
||||
membership: None,
|
||||
photo: None,
|
||||
album: None,
|
||||
verification: Default::default(),
|
||||
direct_role: None,
|
||||
share_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The only input shape that may ever produce a removal: same size, same
|
||||
/// present digest, live sibling, enforcing mode.
|
||||
#[test]
|
||||
fn removes_only_a_provably_identical_copy_when_enforcing() {
|
||||
let copy = file("f (sync-conflict 1).txt", 100, Some("aaa"));
|
||||
let live = file("f.txt", 100, Some("aaa"));
|
||||
assert_eq!(
|
||||
decide(©, Some(&live), "f.txt", SweepMode::Enforce),
|
||||
SweepAction::Remove
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_only_never_removes_but_still_says_what_it_would_do() {
|
||||
let copy = file("f (sync-conflict 1).txt", 100, Some("aaa"));
|
||||
let live = file("f.txt", 100, Some("aaa"));
|
||||
// Same inputs as the removal case above; only the mode differs.
|
||||
let SweepAction::Flag(detail) = decide(©, Some(&live), "f.txt", SweepMode::Report)
|
||||
else {
|
||||
panic!("report mode must never remove");
|
||||
};
|
||||
assert!(detail.contains("would be removed"), "{detail}");
|
||||
assert!(detail.contains("report-only"), "{detail}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_differing_size_is_a_real_conflict() {
|
||||
let copy = file("f (sync-conflict 1).txt", 101, Some("aaa"));
|
||||
let live = file("f.txt", 100, Some("aaa"));
|
||||
assert_eq!(
|
||||
decide(©, Some(&live), "f.txt", SweepMode::Enforce),
|
||||
SweepAction::Flag("differs from f.txt".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_digest_is_never_proof_of_identity() {
|
||||
// Equal sizes are not enough: two different 100-byte files are common.
|
||||
// A copy the sweep cannot fingerprint must survive, in either direction.
|
||||
for (a, b) in [(None, Some("aaa")), (Some("aaa"), None), (None, None)] {
|
||||
let copy = file("f (sync-conflict 1).txt", 100, a);
|
||||
let live = file("f.txt", 100, b);
|
||||
assert_eq!(
|
||||
decide(©, Some(&live), "f.txt", SweepMode::Enforce),
|
||||
SweepAction::Flag("differs from f.txt".into()),
|
||||
"{a:?} vs {b:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_differing_digest_at_equal_size_is_a_real_conflict() {
|
||||
let copy = file("f (sync-conflict 1).txt", 100, Some("aaa"));
|
||||
let live = file("f.txt", 100, Some("bbb"));
|
||||
assert_eq!(
|
||||
decide(©, Some(&live), "f.txt", SweepMode::Enforce),
|
||||
SweepAction::Flag("differs from f.txt".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_orphaned_copy_is_surfaced_not_removed() {
|
||||
// No live original left to reconcile against — the copy may be the only
|
||||
// surviving version of the file, so it is never the sweep's to remove.
|
||||
let copy = file("f (sync-conflict 1).txt", 100, Some("aaa"));
|
||||
assert_eq!(
|
||||
decide(©, None, "f.txt", SweepMode::Enforce),
|
||||
SweepAction::Flag("no original f.txt to reconcile against".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_needs_both_size_and_digest_to_match() {
|
||||
let a = file("a", 100, Some("aaa"));
|
||||
assert!(is_identical(&a, &file("b", 100, Some("aaa"))));
|
||||
assert!(!is_identical(&a, &file("b", 101, Some("aaa"))));
|
||||
assert!(!is_identical(&a, &file("b", 100, Some("bbb"))));
|
||||
assert!(!is_identical(&a, &file("b", 100, None)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_conflict_names_and_leaves_others_alone() {
|
||||
assert_eq!(
|
||||
conflict_base_name("test_export (sync-conflict 1784898786).xml").as_deref(),
|
||||
Some("test_export.xml")
|
||||
);
|
||||
assert_eq!(
|
||||
conflict_base_name("README (sync-conflict 42)").as_deref(),
|
||||
Some("README")
|
||||
);
|
||||
// The sync engine's de-duplicated suffix form.
|
||||
assert_eq!(
|
||||
conflict_base_name("notes (sync-conflict 1700-3).txt").as_deref(),
|
||||
Some("notes.txt")
|
||||
);
|
||||
// Not a conflict copy.
|
||||
assert_eq!(conflict_base_name("report.xml"), None);
|
||||
// Looks close but the token is not a stamp.
|
||||
assert_eq!(conflict_base_name("x (sync-conflict abc).txt"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dotted_stem_keeps_its_inner_dots() {
|
||||
assert_eq!(
|
||||
conflict_base_name("archive.tar (sync-conflict 7).gz").as_deref(),
|
||||
Some("archive.tar.gz")
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,325 @@
|
||||
//! Filesystem watcher, polling, and debounce control plane.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// How long the watcher must go quiet before a burst counts as finished.
|
||||
///
|
||||
/// Measured from the *last* event, not the first: applications do not write a
|
||||
/// file once. An editor saves by writing a temp file and renaming it over the
|
||||
/// target; an export or a database dump writes continuously for as long as it
|
||||
/// takes. Settling a fixed interval after the first event walks the tree while
|
||||
/// the file is still growing, and uploads a torn snapshot as a real revision —
|
||||
/// corrected on the next pass, but only after spending the encrypt-and-upload
|
||||
/// on a file that was never in that state.
|
||||
const DEBOUNCE: Duration = Duration::from_secs(2);
|
||||
/// Ceiling on the settle wait, so a directory under sustained change still
|
||||
/// syncs. Without it, copying a large tree in — events arriving forever —
|
||||
/// would postpone the reconcile for as long as the copy ran.
|
||||
const MAX_SETTLE: Duration = Duration::from_secs(30);
|
||||
/// How often to re-walk remotes so changes made on other clients are pulled in.
|
||||
const POLL_INTERVAL: Duration = Duration::from_secs(120);
|
||||
/// How many uploads/downloads/folder-creations a reconcile runs at once. The
|
||||
/// per-item work is a network round-trip, so a folder with thousands of files is
|
||||
/// unusable done one-at-a-time; this bounds concurrency like the bulk-upload
|
||||
/// engine does.
|
||||
pub(super) const SYNC_CONCURRENCY: usize = 8;
|
||||
|
||||
/// A message to the sync engine's control thread.
|
||||
pub(crate) enum SyncMsg {
|
||||
/// Reconcile one folder by id (a local change, or a targeted `SyncNow`).
|
||||
Reconcile(i64),
|
||||
/// Reconcile every mirror folder (periodic poll, or startup).
|
||||
ReconcileAll,
|
||||
/// Re-read the folder set and adjust the filesystem watches (after add/remove).
|
||||
Rewatch,
|
||||
/// The daemon is tearing down: finish the current pass and end the engine
|
||||
/// thread, so the mount can join it (`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>) -> Option<std::thread::JoinHandle<()>> {
|
||||
match std::thread::Builder::new()
|
||||
.name("pdfs-sync".into())
|
||||
.spawn(move || engine_loop(core, rx))
|
||||
{
|
||||
Ok(handle) => Some(handle),
|
||||
Err(e) => {
|
||||
warn!(error = %e, "failed to start sync engine");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The engine control loop. Single-threaded so reconcile passes never overlap.
|
||||
fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
|
||||
// Paths the watcher currently covers, mapped to their folder id, so an event
|
||||
// path can be resolved back to the folder it belongs to.
|
||||
let watched: 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 };
|
||||
// Ignore pure metadata/access noise; content changes are what matter.
|
||||
if matches!(event.kind, notify::EventKind::Access(_)) {
|
||||
return;
|
||||
}
|
||||
let msg = event_target(&watched_events.lock(), &event.paths);
|
||||
let _ = tx_events.send(msg);
|
||||
}) {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "filesystem watcher unavailable; sync is poll-only");
|
||||
// Still run the loop so polling and explicit SyncNow work.
|
||||
poll_only_loop(&core, rx);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Periodic remote poll.
|
||||
{
|
||||
let tx = core.sync_tx.clone();
|
||||
let shutdown = core.shutdown.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("pdfs-sync-poll".into())
|
||||
.spawn(move || {
|
||||
while shutdown.sleep(POLL_INTERVAL) {
|
||||
if tx.send(SyncMsg::ReconcileAll).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
rewatch(&core, &mut watcher, &watched);
|
||||
reconcile_all(&core);
|
||||
|
||||
while let Ok(msg) = rx.recv() {
|
||||
let mut ids: HashSet<i64> = HashSet::new();
|
||||
let mut all = false;
|
||||
let mut do_rewatch = false;
|
||||
if !classify(msg, &mut ids, &mut all, &mut do_rewatch)
|
||||
|| !settle(&rx, &mut ids, &mut all, &mut do_rewatch)
|
||||
{
|
||||
info!("sync engine stopping");
|
||||
return;
|
||||
}
|
||||
|
||||
if do_rewatch {
|
||||
rewatch(&core, &mut watcher, &watched);
|
||||
}
|
||||
if all {
|
||||
reconcile_all(&core);
|
||||
} else {
|
||||
for id in ids {
|
||||
if core.sync_paused() {
|
||||
break;
|
||||
}
|
||||
if let Ok(Some(folder)) = core.db.sync_folder_get(id) {
|
||||
core.reconcile_folder(&folder);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallback loop when no filesystem watcher could be created: poll + SyncNow only.
|
||||
fn poll_only_loop(core: &Core, rx: Receiver<SyncMsg>) {
|
||||
{
|
||||
let tx = core.sync_tx.clone();
|
||||
let shutdown = core.shutdown.clone();
|
||||
std::thread::spawn(move || {
|
||||
while shutdown.sleep(POLL_INTERVAL) {
|
||||
if tx.send(SyncMsg::ReconcileAll).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
reconcile_all(core);
|
||||
while let Ok(msg) = rx.recv() {
|
||||
let mut ids: HashSet<i64> = HashSet::new();
|
||||
let mut all = false;
|
||||
let mut do_rewatch = false;
|
||||
if !classify(msg, &mut ids, &mut all, &mut do_rewatch)
|
||||
|| !settle(&rx, &mut ids, &mut all, &mut do_rewatch)
|
||||
{
|
||||
info!("sync engine stopping");
|
||||
return;
|
||||
}
|
||||
if all {
|
||||
reconcile_all(core);
|
||||
} else {
|
||||
for id in ids {
|
||||
if core.sync_paused() {
|
||||
break;
|
||||
}
|
||||
if let Ok(Some(folder)) = core.db.sync_folder_get(id) {
|
||||
core.reconcile_folder(&folder);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Absorb the rest of an event burst, returning once the watcher has been quiet
|
||||
/// for `quiet` or `cap` has elapsed since the first event — whichever comes
|
||||
/// first. The caller has already classified the event that opened the burst.
|
||||
///
|
||||
/// Split out with its timings as parameters so the settling behaviour can be
|
||||
/// tested in milliseconds rather than in the tens of seconds the real constants
|
||||
/// describe.
|
||||
pub(super) fn settle_with(
|
||||
rx: &Receiver<SyncMsg>,
|
||||
ids: &mut HashSet<i64>,
|
||||
all: &mut bool,
|
||||
do_rewatch: &mut bool,
|
||||
quiet: Duration,
|
||||
cap: Duration,
|
||||
) -> bool {
|
||||
let deadline = Instant::now() + cap;
|
||||
loop {
|
||||
let now = Instant::now();
|
||||
if now >= deadline {
|
||||
break;
|
||||
}
|
||||
// Never wait past the cap, however quiet the watcher goes.
|
||||
let wait = quiet.min(deadline - now);
|
||||
match rx.recv_timeout(wait) {
|
||||
// Another event: the burst is still going, so the quiet window
|
||||
// starts again from here.
|
||||
// A stop lands mid-burst: give up the rest of the burst rather
|
||||
// than settling for it, and let the caller end the loop.
|
||||
Ok(m) => {
|
||||
if !classify(m, ids, all, do_rewatch) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Quiet for a full window: whatever was being written has stopped.
|
||||
Err(RecvTimeoutError::Timeout) => break,
|
||||
// Every sender is gone; the caller's loop is about to end anyway.
|
||||
Err(RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// [`settle_with`] at the real timings.
|
||||
fn settle(
|
||||
rx: &Receiver<SyncMsg>,
|
||||
ids: &mut HashSet<i64>,
|
||||
all: &mut bool,
|
||||
do_rewatch: &mut bool,
|
||||
) -> bool {
|
||||
settle_with(rx, ids, all, do_rewatch, DEBOUNCE, MAX_SETTLE)
|
||||
}
|
||||
|
||||
/// Fold one message into the pending work of the current burst. Returns `false`
|
||||
/// for [`SyncMsg::Stop`], which is not work but an instruction to end the loop.
|
||||
pub(super) fn classify(
|
||||
msg: SyncMsg,
|
||||
ids: &mut HashSet<i64>,
|
||||
all: &mut bool,
|
||||
rewatch: &mut bool,
|
||||
) -> bool {
|
||||
match msg {
|
||||
SyncMsg::Reconcile(id) => {
|
||||
ids.insert(id);
|
||||
}
|
||||
SyncMsg::ReconcileAll => *all = true,
|
||||
SyncMsg::Rewatch => *rewatch = true,
|
||||
SyncMsg::Stop => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Reconcile every mirror folder in turn. 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) => {
|
||||
warn!(error = ?e, "sync: cannot list folders");
|
||||
return;
|
||||
}
|
||||
};
|
||||
for folder in folders {
|
||||
core.reconcile_folder(&folder);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
watched: &Mutex<Vec<(PathBuf, i64)>>,
|
||||
) {
|
||||
let want: Vec<(PathBuf, i64)> = match core.db.sync_folder_list() {
|
||||
Ok(f) => f
|
||||
.into_iter()
|
||||
.filter(|f| f.mode == "mirror")
|
||||
.map(|f| (PathBuf::from(f.local_path), f.id))
|
||||
.collect(),
|
||||
Err(e) => {
|
||||
warn!(error = ?e, "sync: cannot list folders to watch");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut have = watched.lock().clone();
|
||||
// Drop watches no longer wanted.
|
||||
have.retain(|(path, _)| {
|
||||
if want.iter().any(|(p, _)| p == path) {
|
||||
true
|
||||
} else {
|
||||
let _ = watcher.unwatch(path);
|
||||
false
|
||||
}
|
||||
});
|
||||
// Add newly wanted watches.
|
||||
for (path, id) in &want {
|
||||
if have.iter().any(|(p, _)| p == path) {
|
||||
continue;
|
||||
}
|
||||
match watcher.watch(path, RecursiveMode::Recursive) {
|
||||
Ok(()) => have.push((path.clone(), *id)),
|
||||
Err(e) => warn!(path = %path.display(), error = %e, "sync: watch failed"),
|
||||
}
|
||||
}
|
||||
*watched.lock() = have;
|
||||
}
|
||||
|
||||
// ---- reconcile ------------------------------------------------------------
|
||||
Loaded 100 of 263 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user