Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
No files matched your search
@@ -0,0 +1,107 @@
|
|||||||
|
name: AUR
|
||||||
|
|
||||||
|
on:
|
||||||
|
# The -git package computes its pkgver from git at build time, so a new commit
|
||||||
|
# upstream needs no AUR push. Only a changed -git recipe is published.
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'packaging/aur/proton-drive-for-linux-git/**'
|
||||||
|
# Called by release.yml once the GitHub release and its assets exist.
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
secrets:
|
||||||
|
AUR_SSH_PRIVATE_KEY:
|
||||||
|
required: true
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: 'Release to publish, such as 2.1.0. Leave empty to publish only the -git package.'
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: aur
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
name: Publish AUR packages
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
container: archlinux:base-devel
|
||||||
|
env:
|
||||||
|
# makepkg refuses to run as root, so the packaging steps run as `builder`.
|
||||||
|
HOME: /home/builder
|
||||||
|
VERSION: ${{ inputs.version }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Install packaging tools
|
||||||
|
run: pacman -Syu --noconfirm --needed git openssh pacman-contrib
|
||||||
|
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
# The updated recipes are committed back to main, not to the tag.
|
||||||
|
ref: main
|
||||||
|
|
||||||
|
- name: Create build user
|
||||||
|
run: |
|
||||||
|
useradd --create-home builder
|
||||||
|
chown -R builder: "$GITHUB_WORKSPACE"
|
||||||
|
|
||||||
|
- name: Configure AUR access
|
||||||
|
env:
|
||||||
|
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||||
|
run: |
|
||||||
|
install -d -m700 -o builder -g builder "$HOME/.ssh"
|
||||||
|
printf '%s\n' "$AUR_SSH_PRIVATE_KEY" > "$HOME/.ssh/aur"
|
||||||
|
cat > "$HOME/.ssh/config" <<'EOF'
|
||||||
|
Host aur.archlinux.org
|
||||||
|
IdentityFile ~/.ssh/aur
|
||||||
|
User aur
|
||||||
|
EOF
|
||||||
|
# Pin the host key to the fingerprint published on aur.archlinux.org.
|
||||||
|
# The scan sometimes returns nothing from the runner, so retry it.
|
||||||
|
for attempt in 1 2 3 4 5; do
|
||||||
|
ssh-keyscan -T 15 -t ed25519 aur.archlinux.org > "$HOME/.ssh/known_hosts" &&
|
||||||
|
[ -s "$HOME/.ssh/known_hosts" ] && break
|
||||||
|
echo "ssh-keyscan attempt $attempt returned no key; retrying"
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
ssh-keygen -lf "$HOME/.ssh/known_hosts" | grep -q 'SHA256:RFzBCUItH9LZS0cKB5UE6ceAYhBD5C8GeOBip8Z11+4'
|
||||||
|
chown -R builder: "$HOME/.ssh"
|
||||||
|
chmod 600 "$HOME/.ssh/aur"
|
||||||
|
runuser -u builder -- git config --global user.name 'Nils Pukropp'
|
||||||
|
runuser -u builder -- git config --global user.email 'contact@narl.io'
|
||||||
|
|
||||||
|
- name: Update release packages
|
||||||
|
if: env.VERSION != ''
|
||||||
|
run: runuser -u builder -- packaging/aur/update.sh "${VERSION#v}"
|
||||||
|
|
||||||
|
- name: Refresh the -git metadata
|
||||||
|
if: env.VERSION == ''
|
||||||
|
working-directory: packaging/aur/proton-drive-for-linux-git
|
||||||
|
run: runuser -u builder -- sh -c 'makepkg --printsrcinfo > .SRCINFO'
|
||||||
|
|
||||||
|
- name: Publish to the AUR
|
||||||
|
run: |
|
||||||
|
if [ -n "$VERSION" ]; then
|
||||||
|
runuser -u builder -- packaging/aur/push.sh
|
||||||
|
else
|
||||||
|
runuser -u builder -- packaging/aur/push.sh proton-drive-for-linux-git
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Commit the updated recipes to main
|
||||||
|
if: env.VERSION != ''
|
||||||
|
run: |
|
||||||
|
runuser -u builder -- git add packaging/aur
|
||||||
|
if ! runuser -u builder -- git diff --cached --quiet; then
|
||||||
|
runuser -u builder -- git commit -m "chore(aur): update packages to ${VERSION#v}"
|
||||||
|
runuser -u builder -- git push origin HEAD:main
|
||||||
|
fi
|
||||||
@@ -13,7 +13,7 @@ jobs:
|
|||||||
rust:
|
rust:
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Install native dependencies
|
- name: Install native dependencies
|
||||||
run: |
|
run: |
|
||||||
@@ -32,8 +32,14 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
toolchain: '1.96'
|
toolchain: '1.96'
|
||||||
components: rustfmt, clippy
|
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
|
- uses: swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
shared-key: debug
|
||||||
|
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||||
|
|
||||||
- name: Formatting
|
- name: Formatting
|
||||||
run: cargo fmt --all -- --check
|
run: cargo fmt --all -- --check
|
||||||
@@ -57,3 +63,84 @@ jobs:
|
|||||||
|
|
||||||
# Real Proton credentials and /dev/fuse deliberately do not run on pull
|
# Real Proton credentials and /dev/fuse deliberately do not run on pull
|
||||||
# requests. Follow docs/TESTING.md on a dedicated test-account runner.
|
# requests. Follow docs/TESTING.md on a dedicated test-account runner.
|
||||||
|
|
||||||
|
# The seeded simulation runs (docs/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
|
||||||
@@ -10,14 +10,13 @@ permissions:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-release:
|
checks:
|
||||||
name: Build and Release
|
name: Release quality gates
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
environment: 'rpm signing'
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Install system dependencies
|
- name: Install system dependencies
|
||||||
run: |
|
run: |
|
||||||
@@ -29,18 +28,22 @@ jobs:
|
|||||||
libadwaita-1-dev \
|
libadwaita-1-dev \
|
||||||
libwebkitgtk-6.0-dev \
|
libwebkitgtk-6.0-dev \
|
||||||
libsecret-1-dev \
|
libsecret-1-dev \
|
||||||
libdbus-1-dev \
|
libdbus-1-dev
|
||||||
desktop-file-utils \
|
|
||||||
rpm
|
|
||||||
|
|
||||||
- name: Set up Rust
|
- name: Set up Rust
|
||||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||||
with:
|
with:
|
||||||
toolchain: '1.96'
|
toolchain: '1.96'
|
||||||
components: rustfmt, clippy
|
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
|
- name: Cache Cargo target
|
||||||
uses: swatinem/rust-cache@v2
|
uses: swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
shared-key: debug
|
||||||
|
save-if: false
|
||||||
|
|
||||||
- name: Verify tag and manifest versions
|
- name: Verify tag and manifest versions
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
@@ -59,6 +62,43 @@ jobs:
|
|||||||
cargo test --workspace --locked
|
cargo test --workspace --locked
|
||||||
scripts/fuse-acceptance.sh --offline-only
|
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
|
- name: Build release binaries
|
||||||
run: |
|
run: |
|
||||||
cargo build --release --locked
|
cargo build --release --locked
|
||||||
@@ -84,6 +124,7 @@ jobs:
|
|||||||
cp target/release/pdfs-tray dist/binaries/
|
cp target/release/pdfs-tray dist/binaries/
|
||||||
cp target/release/pdfs-app dist/binaries/
|
cp target/release/pdfs-app dist/binaries/
|
||||||
cp target/release/pdfs-prompt dist/binaries/
|
cp target/release/pdfs-prompt dist/binaries/
|
||||||
|
po/build.sh dist/binaries/locale
|
||||||
|
|
||||||
# Create tarball
|
# Create tarball
|
||||||
tar -czf proton-drive-linux-${{ env.VERSION }}-x86_64.tar.gz -C dist/binaries .
|
tar -czf proton-drive-linux-${{ env.VERSION }}-x86_64.tar.gz -C dist/binaries .
|
||||||
@@ -110,6 +151,7 @@ jobs:
|
|||||||
cp packaging/io.narl.proton-drive-linux.svg deb/usr/share/icons/hicolor/scalable/apps/
|
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 packaging/proton-drive.service deb/usr/lib/systemd/user/
|
||||||
cp LICENSE deb/usr/share/licenses/proton-drive-linux/
|
cp LICENSE deb/usr/share/licenses/proton-drive-linux/
|
||||||
|
po/build.sh deb/usr/share/locale
|
||||||
|
|
||||||
# Calculate Installed-Size in KB for the control file
|
# Calculate Installed-Size in KB for the control file
|
||||||
INSTALLED_SIZE=$(du -sk deb | cut -f1)
|
INSTALLED_SIZE=$(du -sk deb | cut -f1)
|
||||||
@@ -124,7 +166,7 @@ jobs:
|
|||||||
Maintainer: Nils Pukropp <contact@narl.io>
|
Maintainer: Nils Pukropp <contact@narl.io>
|
||||||
Depends: fuse3, libgtk-4-1 (>= 4.12.0), libadwaita-1-0 (>= 1.5.0), libsecret-1-0, libwebkitgtk-6.0-4, libimage-exiftool-perl
|
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}
|
Installed-Size: ${INSTALLED_SIZE}
|
||||||
Homepage: https://github.com/narrrl/proton-drive-linux
|
Homepage: https://proton-drive.narl.io
|
||||||
Description: Unofficial Proton Drive client for Linux
|
Description: Unofficial Proton Drive client for Linux
|
||||||
Files-on-demand Proton Drive for the Linux desktop, unaffiliated with Proton AG:
|
Files-on-demand Proton Drive for the Linux desktop, unaffiliated with Proton AG:
|
||||||
- FUSE virtual mount with block-level caching
|
- FUSE virtual mount with block-level caching
|
||||||
@@ -149,6 +191,7 @@ jobs:
|
|||||||
--define "_topdir $PWD/rpmbuild" \
|
--define "_topdir $PWD/rpmbuild" \
|
||||||
--define "_sourcedir $PWD" \
|
--define "_sourcedir $PWD" \
|
||||||
--define "git_dir $PWD" \
|
--define "git_dir $PWD" \
|
||||||
|
--define "prebuilt 1" \
|
||||||
--define "version ${RPM_VERSION}" \
|
--define "version ${RPM_VERSION}" \
|
||||||
--define "_dbpath $PWD/rpmbuild/db" \
|
--define "_dbpath $PWD/rpmbuild/db" \
|
||||||
packaging/proton-drive-linux.spec
|
packaging/proton-drive-linux.spec
|
||||||
@@ -178,7 +221,7 @@ jobs:
|
|||||||
|
|
||||||
|
|
||||||
- name: Upload Workflow Artifacts
|
- name: Upload Workflow Artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: proton-drive-linux-builds
|
name: proton-drive-linux-builds
|
||||||
path: |
|
path: |
|
||||||
@@ -187,9 +230,20 @@ jobs:
|
|||||||
*.rpm
|
*.rpm
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
|
publish:
|
||||||
|
name: Create GitHub Release
|
||||||
|
needs: [checks, build-release]
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Download packages
|
||||||
|
uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
name: proton-drive-linux-builds
|
||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Create GitHub Release
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
uses: softprops/action-gh-release@v3
|
||||||
uses: softprops/action-gh-release@v2
|
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
*.deb
|
*.deb
|
||||||
@@ -199,3 +253,13 @@ jobs:
|
|||||||
prerelease: false
|
prerelease: false
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
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."
|
||||||
@@ -6,9 +6,10 @@ deliberately high — everything else is ordinary Rust review.
|
|||||||
|
|
||||||
## Before you start
|
## Before you start
|
||||||
|
|
||||||
- Read [`AGENTS.md`](AGENTS.md) for repository conventions and
|
- Read [`AGENTS.md`](AGENTS.md) for repository conventions,
|
||||||
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for how the daemon, front ends, and SDK fit
|
[`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md) for building, running and debugging from source,
|
||||||
together.
|
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
|
- Check [`docs/BUGS.md`](docs/BUGS.md) — it is the authoritative issue ledger, and your problem may
|
||||||
already be tracked there with context.
|
already be tracked there with context.
|
||||||
- For anything larger than a fix, open an issue first so the design can be discussed before you
|
- For anything larger than a fix, open an issue first so the design can be discussed before you
|
||||||
@@ -23,8 +24,9 @@ cargo build --workspace
|
|||||||
```
|
```
|
||||||
|
|
||||||
GUI and FUSE targets need the GTK4, libadwaita, WebKitGTK 6, libsecret, D-Bus, and FUSE3
|
GUI and FUSE targets need the GTK4, libadwaita, WebKitGTK 6, libsecret, D-Bus, and FUSE3
|
||||||
development packages listed in the [README](README.md#prerequisites). Minimum supported Rust
|
development packages listed in [`docs/INSTALL.md`](docs/INSTALL.md#build-dependencies). Minimum
|
||||||
version is 1.96.
|
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
|
## Quality gates
|
||||||
|
|
||||||
@@ -37,9 +39,9 @@ cargo test --workspace --locked
|
|||||||
scripts/fuse-acceptance.sh --offline-only
|
scripts/fuse-acceptance.sh --offline-only
|
||||||
```
|
```
|
||||||
|
|
||||||
Changes to the FUSE layer, sync, or the pending-operation queue should also be exercised against a
|
Changes to the FUSE layer, sync, or the queue should also pass the simulation runs and be
|
||||||
real mount before review — see [`docs/TESTING.md`](docs/TESTING.md). Live runs are destructive:
|
exercised against a real mount before review — see [`docs/TESTING.md`](docs/TESTING.md). Live
|
||||||
use a dedicated test account, never one holding data you cannot lose.
|
runs are destructive: use a dedicated test account, never one holding data you cannot lose.
|
||||||
|
|
||||||
## Making changes
|
## Making changes
|
||||||
|
|
||||||
@@ -51,6 +53,13 @@ use a dedicated test account, never one holding data you cannot lose.
|
|||||||
control-socket work stays off GTK's main thread.
|
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
|
- **`staging/` and `recovery/` may hold the only copy of user data.** Nothing may clear or purge
|
||||||
them opportunistically.
|
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 and pull requests
|
||||||
|
|
||||||
|
|||||||
@@ -3,20 +3,20 @@ resolver = "2"
|
|||||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "1.10.0"
|
version = "3.2.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||||
rust-version = "1.96"
|
rust-version = "1.96"
|
||||||
repository = "https://github.com/narrrl/proton-drive-linux"
|
repository = "https://github.com/narrrl/proton-drive-linux"
|
||||||
homepage = "https://github.com/narrrl/proton-drive-linux"
|
homepage = "https://proton-drive.narl.io"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
keywords = ["proton", "drive", "fuse", "sync", "linux"]
|
keywords = ["proton", "drive", "fuse", "sync", "linux"]
|
||||||
categories = ["filesystem", "command-line-utilities"]
|
categories = ["filesystem", "command-line-utilities"]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
proton-sdk = { version = "0.6.3" }
|
proton-sdk = { version = "0.8.1" }
|
||||||
proton-drive-rs = { version = "0.6.3" }
|
proton-drive-rs = { version = "0.8.1" }
|
||||||
|
|
||||||
# Internal crates.
|
# Internal crates.
|
||||||
pdfs-core = { path = "crates/pdfs-core" }
|
pdfs-core = { path = "crates/pdfs-core" }
|
||||||
@@ -25,7 +25,8 @@ pdfs-fuse = { path = "crates/pdfs-fuse" }
|
|||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "io-util", "time", "signal"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "io-util", "time", "signal"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
base64 = "0.22"
|
base64 = "0.23"
|
||||||
|
zeroize = "1"
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
# Non-poisoning mutexes. A handler that panics while holding a lock must not
|
# Non-poisoning mutexes. A handler that panics while holding a lock must not
|
||||||
@@ -35,24 +36,28 @@ anyhow = "1"
|
|||||||
parking_lot = "0.12"
|
parking_lot = "0.12"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
directories = "5"
|
directories = "6"
|
||||||
ignore = "0.4"
|
ignore = "0.4"
|
||||||
sha2 = "0.10"
|
sha2 = "0.11"
|
||||||
# Google Takeout exports are zip sets; the importer reads their central
|
# Google Takeout exports are zip sets; the importer reads their central
|
||||||
# directories and streams entries out without extracting to disk first.
|
# directories and streams entries out without extracting to disk first.
|
||||||
zip = { version = "8", default-features = false, features = ["deflate"] }
|
zip = { version = "8", default-features = false, features = ["deflate"] }
|
||||||
# Proton's photo duplicate detection is keyed on the plaintext SHA-1, so an
|
# Proton's photo duplicate detection is keyed on the plaintext SHA-1, so an
|
||||||
# import has to speak SHA-1 whatever its age.
|
# import has to speak SHA-1 whatever its age.
|
||||||
sha1 = "0.10"
|
sha1 = "0.11"
|
||||||
# Thumbnail generation for photos the server has none for. Decoders only for the
|
# 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
|
# formats a camera or phone actually produces — the rest of the crate's codecs
|
||||||
# would be dead weight in the daemon.
|
# would be dead weight in the daemon.
|
||||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "tiff", "bmp", "rayon"] }
|
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "tiff", "bmp", "rayon"] }
|
||||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||||
keyring = { version = "3", features = ["sync-secret-service", "crypto-rust"] }
|
# 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"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
rpassword = "7"
|
rpassword = "7"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
fuser = "0.17"
|
fuser = "0.18"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
@@ -4,488 +4,174 @@
|
|||||||
|
|
||||||
# Proton Drive for Linux
|
# Proton Drive for Linux
|
||||||
|
|
||||||
**Files-on-demand Proton Drive for the Linux desktop** — a FUSE virtual mount with block-level
|
**Proton Drive on the Linux desktop.** Your files as a folder that downloads on demand, synced
|
||||||
caching, a scriptable CLI, and a native GTK4 app with tray and search launcher.
|
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/actions/workflows/ci.yml)
|
||||||
[](https://github.com/narrrl/proton-drive-linux/releases/latest)
|
[](https://github.com/narrrl/proton-drive-linux/releases/latest)
|
||||||
|
[](https://aur.archlinux.org/packages/proton-drive-for-linux)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://www.rust-lang.org)
|
[](https://www.rust-lang.org)
|
||||||
[](#prerequisites)
|
[](https://buymeacoffee.com/narl)
|
||||||
|
|
||||||
<img src="images/files.png" alt="The GTK4 file browser" width="820">
|
<img src="images/files.png" alt="My files in the Proton Drive app" width="820">
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> This is an **unofficial**, community-built client. It is not affiliated with, endorsed by, or
|
> 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
|
> supported by Proton AG. "Proton" and "Proton Drive" are trademarks of Proton AG, used here only
|
||||||
> to describe what the software talks to. Use a test account first, and read
|
> to describe what the software talks to. Try it with data you have another copy of first, and
|
||||||
> [`docs/RECOVERY.md`](docs/RECOVERY.md) before trusting it with data you cannot lose.
|
> read [what survives a lost machine](docs/RECOVERY.md) before you rely on it.
|
||||||
|
|
||||||
## Install
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Debian / Ubuntu
|
|
||||||
sudo apt install ./proton-drive-linux_*.deb
|
|
||||||
|
|
||||||
# Fedora
|
|
||||||
sudo dnf install ./proton-drive-linux-*.rpm
|
|
||||||
|
|
||||||
# Arch Linux
|
|
||||||
cd packaging && makepkg -fi
|
|
||||||
```
|
|
||||||
|
|
||||||
Packages for each release are attached to the [latest
|
|
||||||
release](https://github.com/narrrl/proton-drive-linux/releases/latest); see
|
|
||||||
[Installation & Packages](#installation--packages) for details and
|
|
||||||
[Building from Source](#building-from-source) to compile it yourself.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pdfs login # sign in (SRP + 2FA, credentials go to the system keyring)
|
|
||||||
pdfs status # mount state, queued writes, cache usage
|
|
||||||
pdfs-app # the desktop app
|
|
||||||
```
|
|
||||||
|
|
||||||
## Contents
|
|
||||||
|
|
||||||
- [Features](#features) · [Quick search](#quick-search-and-media-opening) · [Opening files](#choosing-how-files-open)
|
|
||||||
- [Selective sync](#selective-sync-pdfsignore) · [Diagnostics](#diagnostics--maintenance) · [Scripting](#scripting---json)
|
|
||||||
- [Performance](#performance--caching) · [Filesystem safety](#filesystem-safety) · [Screenshots](#screenshots)
|
|
||||||
- [Prerequisites](#prerequisites) · [Building](#building-from-source) · [Packages](#installation--packages) · [Releases](#automated-releases-cicd)
|
|
||||||
|
|
||||||
**Documentation:** [Architecture](docs/ARCHITECTURE.md) · [Testing](docs/TESTING.md) ·
|
|
||||||
[Recovery](docs/RECOVERY.md) · [Development](docs/DEVELOPMENT.md) ·
|
|
||||||
[Known issues](docs/BUGS.md) · [Changelog](docs/CHANGELOG.md) ·
|
|
||||||
[Contributing](CONTRIBUTING.md) · [Security policy](SECURITY.md)
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Files-on-Demand (FUSE)**: Mount your Proton Drive as a virtual filesystem where files are downloaded only when opened, utilizing smart block-level caching and disk-backed writes.
|
**Files**
|
||||||
- **Command-Line Interface (CLI)**: Manage your drive, authenticate, and monitor sync status directly from the terminal.
|
|
||||||
- **Non-Blocking GTK4 Desktop App**: Browse files, manage pins, configure options, and monitor active transfers through a modern, native GUI with a fully non-blocking asynchronous main loop.
|
|
||||||
- **File Thumbnails**: Browse locally cached image previews in Files, Shared, and Trash, including embedded previews from camera RAW files. Build a whole folder tree from the Files toolbar and resize the grid from the status bar.
|
|
||||||
- **Shared Links & Invites**: Browse files shared with you by other users, and view/manage your own public shared links directly in the GUI.
|
|
||||||
- **Local Backup (Computers)**: Sync and back up local directories (like Downloads, Documents, Pictures, etc.) directly to your Proton Drive account.
|
|
||||||
- **Locations**: One page (and `pdfs locations`) listing every place Proton Drive occupies on this machine — the main mount plus each backed-up folder — with its mode, sync state, and whether it is read-only. Switching a folder between a full local copy and on-demand happens here.
|
|
||||||
- **System Tray Integration**: Background indicator for status monitoring, quick actions, and fast sync controls.
|
|
||||||
- **Unified Search Launcher (HUD)**: A resident Google Drive-style launcher (`pdfs-prompt`) that searches Proton Drive and local files together, ranks the best matches, tolerates abbreviations and typos, and is ideal for a system-wide hotkey. It can also present the same search in your own launcher (`--dmenu`) or, searching live as you type, in `fzf` (`--fzf`).
|
|
||||||
- **Secure Credential Storage**: Integrates with the system Secret Service (GNOME Keyring, KWallet, etc.) with smart in-memory credential caching to avoid UI thread blockages.
|
|
||||||
- **Proton Photos Support**: Access your Proton Photos timeline, view thumbnails, and download backed-up media natively (available in the GUI as a navigation tab and via the CLI).
|
|
||||||
- **File Version History**: Every revision Proton Drive still holds for a file, from the browser's details pane (**Versions**) or `pdfs versions list|restore|save|rm`. Restoring is server-side — no re-upload — and an old version can be written out to a local file without touching the live one.
|
|
||||||
- **Photo Favourites**: Star a photo in the lightbox, filter the gallery to favourites, or use `pdfs favorite <uid>` / `pdfs photos --favorites`.
|
|
||||||
- **Photo Albums**: Browse your albums — including the ones shared with you — from the Albums view of the Photos page, or with `pdfs albums` / `pdfs album <uid>`. Album contents open in the same gallery as the timeline.
|
|
||||||
- **Human Verification (CAPTCHA) Recovery**: Detects sign-in gates (VPN/new IP challenges) and launches an embedded `WebKitWebView` dialog to safely complete the challenge, transparently retrying authentication with the earned token.
|
|
||||||
- **Selective Sync (`.pdfsignore`)**: Keep build trees, dependency directories, and editor leftovers out of synced folders using gitignore-style rules.
|
|
||||||
- **Data-Safe Offline Writes**: Durable scratch/staging files and a transactional pending queue preserve acknowledged writes across network failures and restarts.
|
|
||||||
|
|
||||||
## Quick Search and Media Opening
|
- **Your Drive as a folder.** `~/ProtonDrive` works with every program. Files download when they
|
||||||
|
are opened, a block at a time, so a video starts playing and a large archive can be read
|
||||||
|
without fetching all of it.
|
||||||
|
- **Available offline.** Keep chosen files and folders fully downloaded; everything else is
|
||||||
|
cached within a size limit you set.
|
||||||
|
- **Synced folders.** Back up folders such as `~/Documents` under your computer in Proton Drive,
|
||||||
|
as a full two-way copy or online only. Gitignore-style `.pdfsignore` rules, conflict copies
|
||||||
|
instead of overwrites, pause and bandwidth limits.
|
||||||
|
- **Works at local speed.** Making, renaming and deleting files and folders returns at once, online
|
||||||
|
or offline. Changes are staged on disk and queued before they are acknowledged, so they survive
|
||||||
|
a lost connection, a crash or a reboot and reach Drive when they can.
|
||||||
|
|
||||||
`pdfs-prompt` searches Drive metadata and the local home-directory index in one request. Results are ranked together rather than split by source. Matching is case-insensitive and supports exact names, prefixes, parent paths, multiple terms, punctuation-separated words, ordered abbreviations, common typos, and adjacent transpositions.
|
**Desktop app**
|
||||||
|
|
||||||
The prompt stays resident after its first launch, so invoking the shortcut again reuses and resets the existing window. Drive folders open from the mount. Audio and video results also open through FUSE, allowing media players to seek and request ranges without downloading the entire file first; ordinary Drive files are materialized into the local cache before opening.
|
- **Browse** My files, Shared with me, Shared by me, Computers and Trash, with thumbnails
|
||||||
|
(including camera RAW), drag and drop, details and **version history**.
|
||||||
|
- **Photos.** Timeline with favorites, albums, and Places on a map; RAW+JPEG pairs shown as one
|
||||||
|
photo; duplicate finder; slideshow; **import from Google Photos** (Takeout).
|
||||||
|
- **Sharing.** Invite people as viewer, editor or admin, create public links with password and
|
||||||
|
expiry, accept invitations.
|
||||||
|
- **Sync overview.** What is uploading, what is waiting, what failed and why, and a history of
|
||||||
|
what happened. A change Drive refuses (storage full, access taken away) shows at once, on the
|
||||||
|
file too, and can be exported or discarded.
|
||||||
|
- **Tray icon** with status, pause and quick actions, and a **search launcher** (`pdfs-prompt`)
|
||||||
|
that searches Proton Drive and your home folder together, as its own window, through fuzzel,
|
||||||
|
rofi or wofi, or live in `fzf`.
|
||||||
|
- **Restore a computer.** Continue a backup on a new machine and bring its folders back in one
|
||||||
|
step.
|
||||||
|
- **Sign-in** with two-factor authentication and CAPTCHA; the session is kept in the system
|
||||||
|
keyring.
|
||||||
|
- Available in English, German, Spanish, French, Italian, Dutch, Polish and Brazilian
|
||||||
|
Portuguese.
|
||||||
|
|
||||||
### Using your own launcher (fuzzel, rofi, wofi, …)
|
**Command line**
|
||||||
|
|
||||||
`pdfs-prompt --dmenu` runs the same search through an external dmenu-style
|
- About 55 `pdfs` commands cover files, sync, sharing, Photos, Trash, versions and diagnostics.
|
||||||
launcher instead of the built-in window, so the HUD matches the rest of a
|
Query commands print JSON with `--json`.
|
||||||
tiling-WM desktop:
|
|
||||||
|
|
||||||
```bash
|
## Install
|
||||||
pdfs-prompt --dmenu # fuzzel/rofi/wofi/tofi/bemenu/dmenu, whichever is installed
|
|
||||||
pdfs-prompt --dmenu --menu 'fuzzel --dmenu --width 60'
|
|
||||||
pdfs-prompt --dmenu --query invoice # skip straight to a search
|
|
||||||
```
|
|
||||||
|
|
||||||
A launcher filters a fixed list — it cannot ask for a new one per keystroke — so
|
| Distribution | Package |
|
||||||
searching takes two steps, and the prompt says which step you are on:
|
|
||||||
|
|
||||||
- `Search Drive ›` lists your pinned files. Type anything that isn't one of them
|
|
||||||
and press Enter to search for it.
|
|
||||||
- `Drive: invoice ›` lists the results. Enter opens one; typing something else
|
|
||||||
and pressing Enter searches again. Escape closes.
|
|
||||||
|
|
||||||
`--query` skips the first step. fuzzel and rofi also get file-type icons.
|
|
||||||
|
|
||||||
To make an existing keybinding use it without re-binding, and to pin the
|
|
||||||
launcher command, set them in `config.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"prompt": {
|
|
||||||
"mode": "dmenu",
|
|
||||||
"menu": ["fuzzel", "--dmenu", "--width", "60"],
|
|
||||||
"menu_limit": 50
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`--gtk` overrides `"mode": "dmenu"` for one invocation. A `{prompt}` token
|
|
||||||
anywhere in `menu` is replaced by the prompt text; without one, the launcher's
|
|
||||||
own prompt flag is appended.
|
|
||||||
|
|
||||||
### Live launcher search (fzf)
|
|
||||||
|
|
||||||
The two-step above exists because no dmenu-style launcher can call back for new
|
|
||||||
results per keystroke. `fzf` can, so `pdfs-prompt --fzf` searches as you type,
|
|
||||||
exactly like the built-in window:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pdfs-prompt --fzf # pinned files, then live results as you type
|
|
||||||
pdfs-prompt --fzf --query invoice # start on a search
|
|
||||||
```
|
|
||||||
|
|
||||||
Every keystroke re-queries the daemon (debounced, like the GTK prompt) and fzf's
|
|
||||||
own matcher is switched off, so the ordering you see is the daemon's ranking —
|
|
||||||
abbreviations and typos included, which a launcher filtering a fixed list cannot
|
|
||||||
do. Enter opens; Escape closes.
|
|
||||||
|
|
||||||
fzf is a terminal program, so when it is started without one — from a WM
|
|
||||||
keybinding — it spawns a terminal for itself: foot, ghostty, kitty, alacritty,
|
|
||||||
wezterm or xterm, whichever is installed. Each is launched with the app-id/class
|
|
||||||
`pdfs-prompt`, so one window rule floats it whichever terminal you have. In
|
|
||||||
Hyprland:
|
|
||||||
|
|
||||||
```
|
|
||||||
windowrulev2 = float, class:^(pdfs-prompt)$
|
|
||||||
windowrulev2 = size 900 500, class:^(pdfs-prompt)$
|
|
||||||
windowrulev2 = center, class:^(pdfs-prompt)$
|
|
||||||
bind = SUPER, space, exec, pdfs-prompt --fzf
|
|
||||||
```
|
|
||||||
|
|
||||||
Set it as the default front end, and pick the terminal explicitly, in
|
|
||||||
`config.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"prompt": {
|
|
||||||
"mode": "fzf",
|
|
||||||
"terminal": ["foot", "--app-id=pdfs-prompt", "--"],
|
|
||||||
"menu_limit": 50
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
A `{cmd}` token anywhere in `terminal` is replaced by the command to run;
|
|
||||||
without one it is appended, which is what every detected terminal expects.
|
|
||||||
|
|
||||||
## Choosing How Files Open
|
|
||||||
|
|
||||||
By default every result is handed to `xdg-open`. An `open_with` block in
|
|
||||||
`config.json` overrides that per file type — for instance opening text in
|
|
||||||
Neovim inside Alacritty, while everything else still goes to the desktop:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"open_with": {
|
|
||||||
"terminal": ["alacritty", "-e"],
|
|
||||||
"rules": [
|
|
||||||
{ "match": ["@text"], "command": ["nvim"], "terminal": true },
|
|
||||||
{ "match": ["*.png", "*.jpg"], "command": ["imv"] }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `match` takes file-name globs (`*.md`, `notes-*.txt`) or the classes `@dir`,
|
|
||||||
`@text`, `@document`, `@image`, `@media`, `@any`. First matching rule wins.
|
|
||||||
- `command` is argv, not a shell line. A `{}` token is replaced by the path;
|
|
||||||
without one the path is appended. `$VAR` tokens expand from the environment,
|
|
||||||
so `["$EDITOR"]` follows your editor.
|
|
||||||
- `"terminal": true` wraps the command in `terminal`. When that is unset, the
|
|
||||||
terminal comes from `$PDFS_TERMINAL`, then `$TERMINAL`, then the first known
|
|
||||||
emulator on `PATH`; a bare name like `TERMINAL=alacritty` gains the right
|
|
||||||
"run this" flag automatically.
|
|
||||||
- `"default": [...]` replaces `xdg-open` for everything unmatched.
|
|
||||||
|
|
||||||
The rules apply to the prompt (both front ends) and to the GTK browser, so a
|
|
||||||
file opens the same way wherever it was picked. Patterns are matched against the
|
|
||||||
Drive name — a downloaded file is stored in the cache under its content hash,
|
|
||||||
which says nothing about its type, so matching that would send everything to
|
|
||||||
`xdg-open`.
|
|
||||||
|
|
||||||
## Selective Sync (`.pdfsignore`)
|
|
||||||
|
|
||||||
Two-way synced folders skip paths matched by ignore rules, so syncing a project
|
|
||||||
directory does not upload `node_modules/`, `target/`, or `.git/`.
|
|
||||||
|
|
||||||
Rules come from two places, and both apply:
|
|
||||||
|
|
||||||
1. **Per folder** — a `.pdfsignore` file at the root of the synced folder
|
|
||||||
(`.protonignore` also works). Gitignore syntax, including negation:
|
|
||||||
|
|
||||||
```gitignore
|
|
||||||
# everything build-related
|
|
||||||
build/
|
|
||||||
*.log
|
|
||||||
|
|
||||||
# ...except this one
|
|
||||||
!important.log
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Globally** — an `ignore_patterns` list in `config.json`, applied to every
|
|
||||||
synced folder. When unset, sensible defaults apply: `.git/`, `.hg/`, `.svn/`,
|
|
||||||
`node_modules/`, `target/`, `.venv/`, `__pycache__/`, `*~`, `*.swp`, `*.tmp`,
|
|
||||||
`.DS_Store`, and `Thumbs.db`.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"ignore_patterns": ["node_modules/", "target/", "*.iso"]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Set it to `[]` to opt out of the defaults entirely.
|
|
||||||
|
|
||||||
Rules are re-read at the start of every sync pass, so edits take effect on the
|
|
||||||
next pass without restarting the daemon.
|
|
||||||
|
|
||||||
**Ignoring is never destructive.** If a rule starts matching a file that was
|
|
||||||
already synced, its copy on Drive is left untouched — the file simply stops
|
|
||||||
being tracked. Removing the rule later picks the existing remote file back up
|
|
||||||
rather than re-uploading it.
|
|
||||||
|
|
||||||
## Diagnostics & Maintenance
|
|
||||||
|
|
||||||
When something looks wrong, `pdfs diagnose` checks the installation and prints a
|
|
||||||
report. It runs without a daemon on purpose — the state worth diagnosing is
|
|
||||||
usually the state where the daemon will not start:
|
|
||||||
|
|
||||||
```console
|
|
||||||
$ pdfs diagnose
|
|
||||||
Paths
|
|
||||||
[ok ] state dir: /home/you/.local/state/proton-drive-linux
|
|
||||||
[ok ] database: /home/you/.local/state/proton-drive-linux/cache.db (170.3 MiB)
|
|
||||||
|
|
||||||
Account
|
|
||||||
[ok ] keyring session: you@proton.me
|
|
||||||
|
|
||||||
Daemon
|
|
||||||
[ok ] daemon responding
|
|
||||||
[ok ] mounted at: /home/you/ProtonDrive
|
|
||||||
[ok ] queued writes: none
|
|
||||||
|
|
||||||
No problems found.
|
|
||||||
```
|
|
||||||
|
|
||||||
It exits non-zero if any check fails, so it works in a health-check script.
|
|
||||||
|
|
||||||
For the local metadata database and content cache:
|
|
||||||
|
|
||||||
| Command | What it does |
|
|
||||||
|---|---|
|
|---|---|
|
||||||
| `pdfs cache inspect` | Database size, reclaimable space, per-table row counts, cache usage against budget |
|
| Debian, Ubuntu 24.04 or newer | `.deb` from the [latest release](https://github.com/narrrl/proton-drive-linux/releases/latest) |
|
||||||
| `pdfs cache inspect --deep` | Also runs SQLite's integrity check — reads every page, so it is slow on a large database |
|
| Fedora 44 or newer | `.rpm` from the [latest release](https://github.com/narrrl/proton-drive-linux/releases/latest) |
|
||||||
| `pdfs cache vacuum` | Checkpoints the write-ahead log and compacts the database |
|
| Arch Linux | [`proton-drive-for-linux`](https://aur.archlinux.org/packages/proton-drive-for-linux) (also `-bin` and `-git`) in the AUR |
|
||||||
| `pdfs cache clear` | Deletes cached file content, keeping pinned files |
|
| Anything else | [Build from source](docs/INSTALL.md#build-from-source) |
|
||||||
|
|
||||||
`vacuum` takes a write lock for its duration and needs room for a second copy of
|
```bash
|
||||||
the database while it runs, so it is a deliberate operation rather than
|
sudo apt install ./proton-drive-linux_*.deb # Debian, Ubuntu
|
||||||
something the daemon does on a timer.
|
sudo dnf install ./proton-drive-linux-*.rpm # Fedora
|
||||||
|
paru -S proton-drive-for-linux # Arch
|
||||||
Before a release, run the real-kernel acceptance suite against a dedicated test
|
|
||||||
account and mount. See [`docs/TESTING.md`](docs/TESTING.md); it includes optional
|
|
||||||
two-mount convergence checks and confines destructive operations to a fresh
|
|
||||||
test directory.
|
|
||||||
|
|
||||||
## Scripting (`--json`)
|
|
||||||
|
|
||||||
Query commands accept a global `--json` flag and emit machine-readable output:
|
|
||||||
|
|
||||||
```console
|
|
||||||
$ pdfs --json sync list | jq '.items[] | select(.state != "idle") | .local_path'
|
|
||||||
$ pdfs --json cache inspect | jq '.db_reclaimable_bytes'
|
|
||||||
$ pdfs --json status | jq -r '.mount.mountpoint'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Supported on `status`, `ls`, `pins`, `sync list`, `devices list`, `transfers`,
|
Then open **Proton Drive** from your applications menu and sign in. The app starts the background
|
||||||
`activity`, and `cache inspect`. Commands that perform an action keep their
|
service, and your files appear in `~/ProtonDrive`.
|
||||||
human output — a script that needs to know whether one worked has the exit code.
|
|
||||||
|
|
||||||
Two things worth relying on:
|
Signing in from a terminal instead? `pdfs login` stores the session but does not start the
|
||||||
|
service. Enable it once:
|
||||||
|
|
||||||
- **The payload is unwrapped.** Output is `{"items": […]}`, not the daemon's
|
```bash
|
||||||
internal `{"SyncFolders": {"items": […]}}`, so scripts never name an internal
|
pdfs login
|
||||||
variant.
|
systemctl --user enable --now proton-drive.service
|
||||||
- **Errors still fail.** A daemon-side error prints its JSON body (with a
|
pdfs status
|
||||||
machine-readable `kind`) on stdout *and* exits non-zero, so `set -e` and
|
```
|
||||||
`if pdfs …` behave as expected rather than treating a failed lookup as
|
|
||||||
success.
|
|
||||||
|
|
||||||
## Performance & Caching
|
[docs/INSTALL.md](docs/INSTALL.md) has the requirements, optional extras (tray on GNOME, RAW and
|
||||||
|
video thumbnails), headless use and uninstalling.
|
||||||
|
|
||||||
The client includes several optimizations designed for high efficiency, a low memory footprint, and a responsive user experience:
|
## Documentation
|
||||||
|
|
||||||
- **On-Demand Block Cache**: Files are read in fixed 4 MiB blocks. For unpinned files, the client fetches and caches only the blocks containing the requested byte ranges. This enables fast sequential and sparse reads (e.g. media streaming or metadata scanning) without downloading entire files.
|
| | |
|
||||||
- **Disk-Backed Writes**: Large file writes are staged on disk in temporary scratch files (rather than fully buffered in RAM) and track modified byte intervals. Only the unedited remote segments are fetched at commit time, keeping memory usage minimal.
|
|---|---|
|
||||||
- **Non-Blocking GTK4 Loop**: To prevent UI freezes, all synchronous D-Bus credential checks, control socket requests, and cache usage calculations are offloaded to background worker threads or fetched asynchronously.
|
| [User guide](docs/USER_GUIDE.md) | Everything the app, tray and launcher do |
|
||||||
- **Flicker-Free UI Rendering**: The GTK4 application performs differential rendering of the pins list, only modifying list rows when the list content changes, preserving the user's scroll position and widget focus.
|
| [Command-line reference](docs/CLI.md) | Every `pdfs` command and scripting with `--json` |
|
||||||
- **Durable Staging**: Scratch metadata and staged writes are synced and atomically published before an upload is acknowledged locally. Rapid revisions supersede pending work transactionally.
|
| [Configuration](docs/CONFIGURATION.md) | `config.json`, file locations, environment variables, the service |
|
||||||
|
| [Troubleshooting](docs/TROUBLESHOOTING.md) | Diagnosing problems and fixing common ones |
|
||||||
## Filesystem Safety
|
| [Recovering on a new computer](docs/RECOVERY.md) | Getting your synced folders back after a reinstall or a lost machine |
|
||||||
|
| [Changelog](docs/CHANGELOG.md) | What changed in each release |
|
||||||
Version 1.0 strengthens the boundaries around local-only data and destructive reconciliation:
|
|
||||||
|
|
||||||
- Truncate composes with queued revisions, preserving the authoritative prefix and correct zero-filled growth.
|
|
||||||
- Combined move-and-rename operations are queued durably and tolerate partially completed remote state.
|
|
||||||
- Session tombstones prevent successfully unlinked entries from reappearing through eventually consistent listings.
|
|
||||||
- Names are valid UTF-8 and no longer than Linux's 255-byte component limit.
|
|
||||||
- Incomplete mirror scans are non-destructive, and total-wipe protection covers every non-empty baseline.
|
|
||||||
- Failed conflict preservation, local deletion, or staging publication does not settle a new baseline or discard the only local copy.
|
|
||||||
- State, cache, and config directories must be real, current-user-owned `0700` directories; control sockets are `0600`.
|
|
||||||
|
|
||||||
|
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
|
## Screenshots
|
||||||
|
|
||||||
### GUI Application & Launcher
|
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" width="50%"><img src="images/login.png" alt="Login Screen" width="100%"><br><sub><b>Login Screen</b></sub></td>
|
<td align="center" width="50%"><img src="images/files.png" alt="My files" width="100%"><br><sub><b>My files</b></sub></td>
|
||||||
<td align="center" width="50%"><img src="images/files.png" alt="Files Browser" width="100%"><br><sub><b>Files Browser</b></sub></td>
|
<td align="center" width="50%"><img src="images/photos.png" alt="Photos timeline" width="100%"><br><sub><b>Photos</b></sub></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" width="50%"><img src="images/shared.png" alt="Shared Links" width="100%"><br><sub><b>Shared Links</b></sub></td>
|
<td align="center" width="50%"><img src="images/photos_map.png" alt="Photos places map" width="100%"><br><sub><b>Places</b></sub></td>
|
||||||
<td align="center" width="50%"><img src="images/shared_with_me.png" alt="Shared with me" width="100%"><br><sub><b>Shared with me</b></sub></td>
|
<td align="center" width="50%"><img src="images/computers.png" alt="Computers" width="100%"><br><sub><b>Computers</b></sub></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" width="50%"><img src="images/computers.png" alt="Local Backups (Computers)" width="100%"><br><sub><b>Local Backups (Computers)</b></sub></td>
|
<td align="center" width="50%"><img src="images/sync.png" alt="Sync folders" width="100%"><br><sub><b>Sync</b></sub></td>
|
||||||
<td align="center" width="50%"><img src="images/photos.png" alt="Photos Timeline" width="100%"><br><sub><b>Photos Timeline</b></sub></td>
|
<td align="center" width="50%"><img src="images/conflicts.png" alt="Sync history showing resolved conflicts" width="100%"><br><sub><b>Sync history</b></sub></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" width="50%"><img src="images/prompt.png" alt="Search Launcher Prompt" width="100%"><br><sub><b>Search Launcher Prompt</b></sub></td>
|
<td align="center" width="50%"><img src="images/prompt_gtk.png" alt="Search launcher" width="100%"><br><sub><b>Search launcher</b></sub></td>
|
||||||
<td align="center" width="50%"><img src="images/settings.png" alt="Settings" width="100%"><br><sub><b>Settings</b></sub></td>
|
<td align="center" width="50%"><img src="images/prompt_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>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
---
|
## How it works
|
||||||
|
|
||||||
## Prerequisites
|
A background service (`proton-drive.service`, the `pdfs daemon` command) holds your session,
|
||||||
|
mounts `~/ProtonDrive` through FUSE, runs the upload queue and the sync engine, and follows
|
||||||
|
Proton's event stream for changes made elsewhere. The app, the tray, the launcher and the CLI are
|
||||||
|
clients of that service over a local socket. Everything is end-to-end encrypted with your Proton
|
||||||
|
keys before it leaves the machine, through a Rust implementation of Proton's Drive SDK.
|
||||||
|
|
||||||
To compile the application from source or run the built binaries, ensure you have the following system libraries installed on your distribution:
|
Files you open are decrypted into a local cache so programs can read them. What that means for
|
||||||
|
the security of the machine is spelled out in the
|
||||||
### Ubuntu / Debian (24.04+)
|
[threat model](docs/ARCHITECTURE.md#10-threat-model-what-this-client-writes-to-disk-in-plaintext).
|
||||||
```bash
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y \
|
|
||||||
pkg-config \
|
|
||||||
libfuse3-dev \
|
|
||||||
libgtk-4-dev \
|
|
||||||
libadwaita-1-dev \
|
|
||||||
libwebkitgtk-6.0-dev \
|
|
||||||
libsecret-1-dev \
|
|
||||||
libdbus-1-dev \
|
|
||||||
libimage-exiftool-perl
|
|
||||||
```
|
|
||||||
|
|
||||||
### Arch Linux
|
|
||||||
```bash
|
|
||||||
sudo pacman -S --needed pkgconf fuse3 gtk4 libadwaita libsecret dbus webkitgtk-6.0 perl-image-exiftool
|
|
||||||
```
|
|
||||||
|
|
||||||
### Fedora (44+)
|
|
||||||
```bash
|
|
||||||
sudo dnf install -y \
|
|
||||||
pkgconf-pkg-config fuse3-devel gtk4-devel libadwaita-devel \
|
|
||||||
webkitgtk6.0-devel libsecret-devel dbus-devel glib2-devel \
|
|
||||||
perl-Image-ExifTool cargo rust
|
|
||||||
```
|
|
||||||
|
|
||||||
Runtime extras (pick your desktop):
|
|
||||||
```bash
|
|
||||||
# GNOME — keyring + tray (AppIndicator)
|
|
||||||
sudo dnf install -y gnome-keyring gnome-shell-extension-appindicator xdg-utils
|
|
||||||
|
|
||||||
# KDE Plasma — KWallet (Secret Service); tray works via built-in SNI
|
|
||||||
sudo dnf install -y kwallet xdg-utils
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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/narrrl/proton-drive-linux.git
|
|
||||||
cd proton-drive-linux
|
|
||||||
```
|
|
||||||
2. Build the workspace in release mode:
|
|
||||||
```bash
|
|
||||||
cargo build --release --locked
|
|
||||||
```
|
|
||||||
|
|
||||||
The compiled binaries will be available under `target/release/`:
|
|
||||||
- `pdfs`: The CLI utility.
|
|
||||||
- `pdfs-app`: The GTK4 application.
|
|
||||||
- `pdfs-tray`: The tray status notifier.
|
|
||||||
- `pdfs-prompt`: The launcher prompt for quick HUD search.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Installation & Packages
|
|
||||||
|
|
||||||
### 1. Debian / Ubuntu (.deb)
|
|
||||||
Install the debian package via `dpkg` or `apt`:
|
|
||||||
```bash
|
|
||||||
sudo apt install ./proton-drive-linux_*.deb
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Arch Linux
|
|
||||||
A local `PKGBUILD` is available under the `packaging/` directory. You can build and install it using:
|
|
||||||
```bash
|
|
||||||
cd packaging && makepkg -fi
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Fedora (local RPM)
|
|
||||||
A local `.spec` is available under `packaging/`. From the repository root:
|
|
||||||
```bash
|
|
||||||
sudo dnf install -y rpm-build
|
|
||||||
rpmbuild -bb packaging/proton-drive-linux.spec \
|
|
||||||
--define "git_dir $PWD" \
|
|
||||||
--define "_rpmdir $PWD/packaging/out" \
|
|
||||||
--define "_builddir $PWD/packaging/build" \
|
|
||||||
--define "_sourcedir $PWD" \
|
|
||||||
--define "_specdir $PWD/packaging" \
|
|
||||||
--define "_srcrpmdir $PWD/packaging/out"
|
|
||||||
sudo dnf install packaging/out/x86_64/proton-drive-linux-*.rpm
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Automated Releases (CI/CD)
|
|
||||||
|
|
||||||
This project has a GitHub Actions CI workflow configured under `.github/workflows/release.yml`.
|
|
||||||
|
|
||||||
### How it works:
|
|
||||||
1. **Triggers**:
|
|
||||||
- Pushing a git tag matching `v*` (e.g. `git tag v0.1.0 && git push origin v0.1.0`).
|
|
||||||
- Manual runs via the **Actions** tab in GitHub (**workflow_dispatch**).
|
|
||||||
2. **Quality Gates and Build Process**:
|
|
||||||
- For tagged builds, verifies that the tag, workspace version, and `packaging/PKGBUILD` version agree.
|
|
||||||
- Runs `cargo fmt`, Clippy with warnings denied, the locked workspace test suite, and the account-free FUSE acceptance contract.
|
|
||||||
- Spawns an Ubuntu runner and installs GTK4, Libadwaita, FUSE3, and Secret Service packages.
|
|
||||||
- Sets up the Rust compiler and caches build targets to speed up runs.
|
|
||||||
- Compiles the workspace members in release mode.
|
|
||||||
3. **Artifact Packaging**:
|
|
||||||
- Generates a `.tar.gz` containing the raw binaries (`pdfs`, `pdfs-app`, `pdfs-tray`, `pdfs-prompt`).
|
|
||||||
- Packs them into Debian (`.deb`) and Fedora (`.rpm`) packages.
|
|
||||||
- Includes the systemd user service and tray autostart entry in the Debian package.
|
|
||||||
4. **Publishing**:
|
|
||||||
- Creates a GitHub Release matching the pushed tag and uploads the `.deb`, `.rpm`, and `.tar.gz` packages as release assets.
|
|
||||||
- For manual runs, compiles and exposes the packages as workflow run artifacts for testing.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Bug reports, packaging fixes, and patches are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for
|
Bug reports, translations and patches are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for the
|
||||||
the workflow and the quality gates CI enforces, and [SECURITY.md](SECURITY.md) for reporting a
|
workflow and the checks CI runs, and [SECURITY.md](SECURITY.md) to report a vulnerability
|
||||||
vulnerability privately.
|
privately.
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
Proton Drive Linux is free and built in spare time. If it saves you a few headaches, you can
|
||||||
|
[buy me a coffee](https://buymeacoffee.com/narl).
|
||||||
|
|
||||||
|
<a href="https://buymeacoffee.com/narl"><img src="https://img.shields.io/badge/Buy%20me%20a%20coffee-FFDD00?logo=buymeacoffee&logoColor=black" alt="Buy me a coffee"></a>
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Released under the [MIT License](LICENSE).
|
Released under the [MIT License](LICENSE).
|
||||||
|
|
||||||
|
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
|
Proton Drive is a service of Proton AG. This project is an independent client and carries no
|
||||||
affiliation with or endorsement from Proton AG.
|
affiliation with or endorsement from Proton AG.
|
||||||
@@ -30,5 +30,10 @@ the interesting boundaries are:
|
|||||||
- **Cryptography.** Key derivation, signature handling, and anything that could publish plaintext
|
- **Cryptography.** Key derivation, signature handling, and anything that could publish plaintext
|
||||||
or the wrong key material to Proton.
|
or the wrong key material to Proton.
|
||||||
|
|
||||||
|
What the client deliberately keeps in plaintext on disk, and why, is documented in the
|
||||||
|
[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
|
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.
|
require an attacker who already has your user account on the machine.
|
||||||
@@ -23,3 +23,4 @@ tracing-subscriber.workspace = true
|
|||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
rpassword.workspace = true
|
rpassword.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
zeroize.workspace = true
|
||||||
@@ -23,7 +23,10 @@ sha2.workspace = true
|
|||||||
sha1.workspace = true
|
sha1.workspace = true
|
||||||
zip.workspace = true
|
zip.workspace = true
|
||||||
rusqlite.workspace = true
|
rusqlite.workspace = true
|
||||||
keyring.workspace = true
|
keyring-core.workspace = true
|
||||||
|
dbus-secret-service-keyring-store.workspace = true
|
||||||
parking_lot.workspace = true
|
parking_lot.workspace = true
|
||||||
async-trait.workspace = true
|
async-trait.workspace = true
|
||||||
libc.workspace = true
|
libc.workspace = true
|
||||||
|
zeroize.workspace = true
|
||||||
|
base64.workspace = true
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
AD Andorra
|
||||||
|
AE United Arab Emirates
|
||||||
|
AF Afghanistan
|
||||||
|
AG Antigua and Barbuda
|
||||||
|
AI Anguilla
|
||||||
|
AL Albania
|
||||||
|
AM Armenia
|
||||||
|
AO Angola
|
||||||
|
AQ Antarctica
|
||||||
|
AR Argentina
|
||||||
|
AS American Samoa
|
||||||
|
AT Austria
|
||||||
|
AU Australia
|
||||||
|
AW Aruba
|
||||||
|
AX Åland Islands
|
||||||
|
AZ Azerbaijan
|
||||||
|
BA Bosnia and Herzegovina
|
||||||
|
BB Barbados
|
||||||
|
BD Bangladesh
|
||||||
|
BE Belgium
|
||||||
|
BF Burkina Faso
|
||||||
|
BG Bulgaria
|
||||||
|
BH Bahrain
|
||||||
|
BI Burundi
|
||||||
|
BJ Benin
|
||||||
|
BL Saint Barthélemy
|
||||||
|
BM Bermuda
|
||||||
|
BN Brunei Darussalam
|
||||||
|
BO Bolivia, Plurinational State of
|
||||||
|
BQ Bonaire, Sint Eustatius and Saba
|
||||||
|
BR Brazil
|
||||||
|
BS Bahamas
|
||||||
|
BT Bhutan
|
||||||
|
BV Bouvet Island
|
||||||
|
BW Botswana
|
||||||
|
BY Belarus
|
||||||
|
BZ Belize
|
||||||
|
CA Canada
|
||||||
|
CC Cocos (Keeling) Islands
|
||||||
|
CD Congo, The Democratic Republic of the
|
||||||
|
CF Central African Republic
|
||||||
|
CG Congo
|
||||||
|
CH Switzerland
|
||||||
|
CI Côte d'Ivoire
|
||||||
|
CK Cook Islands
|
||||||
|
CL Chile
|
||||||
|
CM Cameroon
|
||||||
|
CN China
|
||||||
|
CO Colombia
|
||||||
|
CR Costa Rica
|
||||||
|
CU Cuba
|
||||||
|
CV Cabo Verde
|
||||||
|
CW Curaçao
|
||||||
|
CX Christmas Island
|
||||||
|
CY Cyprus
|
||||||
|
CZ Czechia
|
||||||
|
DE Germany
|
||||||
|
DJ Djibouti
|
||||||
|
DK Denmark
|
||||||
|
DM Dominica
|
||||||
|
DO Dominican Republic
|
||||||
|
DZ Algeria
|
||||||
|
EC Ecuador
|
||||||
|
EE Estonia
|
||||||
|
EG Egypt
|
||||||
|
EH Western Sahara
|
||||||
|
ER Eritrea
|
||||||
|
ES Spain
|
||||||
|
ET Ethiopia
|
||||||
|
FI Finland
|
||||||
|
FJ Fiji
|
||||||
|
FK Falkland Islands (Malvinas)
|
||||||
|
FM Micronesia, Federated States of
|
||||||
|
FO Faroe Islands
|
||||||
|
FR France
|
||||||
|
GA Gabon
|
||||||
|
GB United Kingdom
|
||||||
|
GD Grenada
|
||||||
|
GE Georgia
|
||||||
|
GF French Guiana
|
||||||
|
GG Guernsey
|
||||||
|
GH Ghana
|
||||||
|
GI Gibraltar
|
||||||
|
GL Greenland
|
||||||
|
GM Gambia
|
||||||
|
GN Guinea
|
||||||
|
GP Guadeloupe
|
||||||
|
GQ Equatorial Guinea
|
||||||
|
GR Greece
|
||||||
|
GS South Georgia and the South Sandwich Islands
|
||||||
|
GT Guatemala
|
||||||
|
GU Guam
|
||||||
|
GW Guinea-Bissau
|
||||||
|
GY Guyana
|
||||||
|
HK Hong Kong
|
||||||
|
HM Heard Island and McDonald Islands
|
||||||
|
HN Honduras
|
||||||
|
HR Croatia
|
||||||
|
HT Haiti
|
||||||
|
HU Hungary
|
||||||
|
ID Indonesia
|
||||||
|
IE Ireland
|
||||||
|
IL Israel
|
||||||
|
IM Isle of Man
|
||||||
|
IN India
|
||||||
|
IO British Indian Ocean Territory
|
||||||
|
IQ Iraq
|
||||||
|
IR Iran, Islamic Republic of
|
||||||
|
IS Iceland
|
||||||
|
IT Italy
|
||||||
|
JE Jersey
|
||||||
|
JM Jamaica
|
||||||
|
JO Jordan
|
||||||
|
JP Japan
|
||||||
|
KE Kenya
|
||||||
|
KG Kyrgyzstan
|
||||||
|
KH Cambodia
|
||||||
|
KI Kiribati
|
||||||
|
KM Comoros
|
||||||
|
KN Saint Kitts and Nevis
|
||||||
|
KP Korea, Democratic People's Republic of
|
||||||
|
KR Korea, Republic of
|
||||||
|
KW Kuwait
|
||||||
|
KY Cayman Islands
|
||||||
|
KZ Kazakhstan
|
||||||
|
LA Lao People's Democratic Republic
|
||||||
|
LB Lebanon
|
||||||
|
LC Saint Lucia
|
||||||
|
LI Liechtenstein
|
||||||
|
LK Sri Lanka
|
||||||
|
LR Liberia
|
||||||
|
LS Lesotho
|
||||||
|
LT Lithuania
|
||||||
|
LU Luxembourg
|
||||||
|
LV Latvia
|
||||||
|
LY Libya
|
||||||
|
MA Morocco
|
||||||
|
MC Monaco
|
||||||
|
MD Moldova, Republic of
|
||||||
|
ME Montenegro
|
||||||
|
MF Saint Martin (French part)
|
||||||
|
MG Madagascar
|
||||||
|
MH Marshall Islands
|
||||||
|
MK North Macedonia
|
||||||
|
ML Mali
|
||||||
|
MM Myanmar
|
||||||
|
MN Mongolia
|
||||||
|
MO Macao
|
||||||
|
MP Northern Mariana Islands
|
||||||
|
MQ Martinique
|
||||||
|
MR Mauritania
|
||||||
|
MS Montserrat
|
||||||
|
MT Malta
|
||||||
|
MU Mauritius
|
||||||
|
MV Maldives
|
||||||
|
MW Malawi
|
||||||
|
MX Mexico
|
||||||
|
MY Malaysia
|
||||||
|
MZ Mozambique
|
||||||
|
NA Namibia
|
||||||
|
NC New Caledonia
|
||||||
|
NE Niger
|
||||||
|
NF Norfolk Island
|
||||||
|
NG Nigeria
|
||||||
|
NI Nicaragua
|
||||||
|
NL Netherlands
|
||||||
|
NO Norway
|
||||||
|
NP Nepal
|
||||||
|
NR Nauru
|
||||||
|
NU Niue
|
||||||
|
NZ New Zealand
|
||||||
|
OM Oman
|
||||||
|
PA Panama
|
||||||
|
PE Peru
|
||||||
|
PF French Polynesia
|
||||||
|
PG Papua New Guinea
|
||||||
|
PH Philippines
|
||||||
|
PK Pakistan
|
||||||
|
PL Poland
|
||||||
|
PM Saint Pierre and Miquelon
|
||||||
|
PN Pitcairn
|
||||||
|
PR Puerto Rico
|
||||||
|
PS Palestine, State of
|
||||||
|
PT Portugal
|
||||||
|
PW Palau
|
||||||
|
PY Paraguay
|
||||||
|
QA Qatar
|
||||||
|
RE Réunion
|
||||||
|
RO Romania
|
||||||
|
RS Serbia
|
||||||
|
RU Russian Federation
|
||||||
|
RW Rwanda
|
||||||
|
SA Saudi Arabia
|
||||||
|
SB Solomon Islands
|
||||||
|
SC Seychelles
|
||||||
|
SD Sudan
|
||||||
|
SE Sweden
|
||||||
|
SG Singapore
|
||||||
|
SH Saint Helena, Ascension and Tristan da Cunha
|
||||||
|
SI Slovenia
|
||||||
|
SJ Svalbard and Jan Mayen
|
||||||
|
SK Slovakia
|
||||||
|
SL Sierra Leone
|
||||||
|
SM San Marino
|
||||||
|
SN Senegal
|
||||||
|
SO Somalia
|
||||||
|
SR Suriname
|
||||||
|
SS South Sudan
|
||||||
|
ST Sao Tome and Principe
|
||||||
|
SV El Salvador
|
||||||
|
SX Sint Maarten (Dutch part)
|
||||||
|
SY Syrian Arab Republic
|
||||||
|
SZ Eswatini
|
||||||
|
TC Turks and Caicos Islands
|
||||||
|
TD Chad
|
||||||
|
TF French Southern Territories
|
||||||
|
TG Togo
|
||||||
|
TH Thailand
|
||||||
|
TJ Tajikistan
|
||||||
|
TK Tokelau
|
||||||
|
TL Timor-Leste
|
||||||
|
TM Turkmenistan
|
||||||
|
TN Tunisia
|
||||||
|
TO Tonga
|
||||||
|
TR Türkiye
|
||||||
|
TT Trinidad and Tobago
|
||||||
|
TV Tuvalu
|
||||||
|
TW Taiwan, Province of China
|
||||||
|
TZ Tanzania, United Republic of
|
||||||
|
UA Ukraine
|
||||||
|
UG Uganda
|
||||||
|
UM United States Minor Outlying Islands
|
||||||
|
US United States
|
||||||
|
UY Uruguay
|
||||||
|
UZ Uzbekistan
|
||||||
|
VA Holy See (Vatican City State)
|
||||||
|
VC Saint Vincent and the Grenadines
|
||||||
|
VE Venezuela, Bolivarian Republic of
|
||||||
|
VG Virgin Islands, British
|
||||||
|
VI Virgin Islands, U.S.
|
||||||
|
VN Viet Nam
|
||||||
|
VU Vanuatu
|
||||||
|
WF Wallis and Futuna
|
||||||
|
WS Samoa
|
||||||
|
XK Kosovo
|
||||||
|
YE Yemen
|
||||||
|
YT Mayotte
|
||||||
|
ZA South Africa
|
||||||
|
ZM Zambia
|
||||||
|
ZW Zimbabwe
|
||||||
|
@@ -1,22 +1,50 @@
|
|||||||
//! Login, session persistence, and Drive-client construction.
|
//! Login, session persistence, and Drive-client construction.
|
||||||
//!
|
//!
|
||||||
//! A successful login is persisted to the OS keyring as a single JSON blob
|
//! A successful login is persisted to the OS keyring as a single JSON blob
|
||||||
//! holding the resumable session tokens plus the mailbox password (needed to
|
//! holding the resumable session tokens, the account's key salts and — unless
|
||||||
//! rebuild the key chain on resume). The daemon resumes from this blob with no
|
//! the user chose otherwise — the per-key *passphrases* derived from the
|
||||||
//! interactive step; the refresh token auto-renews via the HTTP client's 401
|
//! mailbox password. The password itself is never stored: the passphrases are
|
||||||
//! path, so no fresh 2FA is required until the refresh token itself expires.
|
//! what it exists to produce, and they are enough to rebuild the key chain on
|
||||||
|
//! resume. The daemon resumes from this blob with no interactive step; the
|
||||||
|
//! refresh token auto-renews via the HTTP client's 401 path, so no fresh 2FA is
|
||||||
|
//! required until the refresh token itself expires.
|
||||||
|
//!
|
||||||
|
//! # Two-password accounts
|
||||||
|
//!
|
||||||
|
//! An account with a separate mailbox password exists precisely so that no one
|
||||||
|
//! login holds the means to read the data. Storing that password in the keyring
|
||||||
|
//! would defeat it, so such an account is **locked by default**: its
|
||||||
|
//! passphrases are kept only in the kernel keyring ([`crate::kernelkey`]), for
|
||||||
|
//! the length of the user's session, and `pdfs unlock` (or the app) supplies
|
||||||
|
//! them again after each login. Storing them in the Secret Service as well is
|
||||||
|
//! an explicit opt-in ([`Mailbox::remember`]). Single-password accounts have no
|
||||||
|
//! such separation to protect and are stored, as they always were.
|
||||||
|
|
||||||
use keyring::Entry;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use base64::Engine as _;
|
||||||
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||||
|
use keyring_core::Entry;
|
||||||
use proton_drive_rs::{KeySalt, ProtonDriveClient};
|
use proton_drive_rs::{KeySalt, ProtonDriveClient};
|
||||||
|
use proton_sdk::account::KeyPassphrases;
|
||||||
use proton_sdk::api::{HumanVerification, HumanVerificationCredential};
|
use proton_sdk::api::{HumanVerification, HumanVerificationCredential};
|
||||||
use proton_sdk::cache::EncryptedCacheRepository;
|
use proton_sdk::cache::EncryptedCacheRepository;
|
||||||
use proton_sdk::config::ProtonClientConfiguration;
|
use proton_sdk::config::ProtonClientConfiguration;
|
||||||
|
use proton_sdk::error::ProtonError;
|
||||||
use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
|
use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
|
||||||
use proton_sdk::telemetry::TracingTelemetry;
|
use proton_sdk::telemetry::TracingTelemetry;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
use crate::config::{APP_VERSION, AppDirs, KEYRING_SERVICE, USER_AGENT};
|
use crate::config::{APP_VERSION, AppDirs, KEYRING_SERVICE, USER_AGENT};
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
|
use crate::kernelkey;
|
||||||
|
|
||||||
|
/// How often a wrong mailbox password is asked for again before the login gives up.
|
||||||
|
const MAILBOX_ATTEMPTS: u32 = 3;
|
||||||
|
|
||||||
|
/// Domain-separation label for the key that encrypts the persistent entity cache.
|
||||||
|
const ENTITY_CACHE_PURPOSE: &[u8] = b"pdfs entity cache v1";
|
||||||
|
|
||||||
/// Fixed keyring account name for the single stored session blob.
|
/// Fixed keyring account name for the single stored session blob.
|
||||||
const KEYRING_USER: &str = "session";
|
const KEYRING_USER: &str = "session";
|
||||||
@@ -32,8 +60,11 @@ pub struct StoredSession {
|
|||||||
pub scopes: Vec<String>,
|
pub scopes: Vec<String>,
|
||||||
/// `1` = single password, `2` = dual (Proton wire value).
|
/// `1` = single password, `2` = dual (Proton wire value).
|
||||||
pub password_mode: u8,
|
pub password_mode: u8,
|
||||||
/// Mailbox (data) password — required by `ProtonDriveClient::new` to derive
|
/// The mailbox password of a blob written by 3.0.x and earlier.
|
||||||
/// the key chain. Lives only in the OS keyring, never on disk in cleartext.
|
///
|
||||||
|
/// Read only, to migrate: [`resume_client`] derives the passphrases from it
|
||||||
|
/// once and writes the blob back without it. Never serialized again.
|
||||||
|
#[serde(default, skip_serializing)]
|
||||||
pub mailbox_password: String,
|
pub mailbox_password: String,
|
||||||
/// The account's key salts, captured at login.
|
/// The account's key salts, captured at login.
|
||||||
///
|
///
|
||||||
@@ -49,6 +80,11 @@ pub struct StoredSession {
|
|||||||
/// backfilling) them.
|
/// backfilling) them.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub key_salts: Vec<KeySalt>,
|
pub key_salts: Vec<KeySalt>,
|
||||||
|
/// Per-key passphrases (key id → base64), when the user keeps them in the
|
||||||
|
/// keyring: always for a single-password account, by opt-in for a
|
||||||
|
/// two-password one. `None` means they are not stored here.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub key_passphrases: Option<BTreeMap<String, String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StoredSession {
|
impl StoredSession {
|
||||||
@@ -73,7 +109,10 @@ fn client_config() -> ProtonClientConfiguration {
|
|||||||
let (app_version, user_agent) = match AppDirs::new() {
|
let (app_version, user_agent) = match AppDirs::new() {
|
||||||
Ok(dirs) => {
|
Ok(dirs) => {
|
||||||
let config = dirs.load_config();
|
let config = dirs.load_config();
|
||||||
(config.app_version, config.user_agent)
|
(
|
||||||
|
config.resolved_app_version().to_string(),
|
||||||
|
config.resolved_user_agent().to_string(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Err(_) => (APP_VERSION.to_string(), USER_AGENT.to_string()),
|
Err(_) => (APP_VERSION.to_string(), USER_AGENT.to_string()),
|
||||||
};
|
};
|
||||||
@@ -81,9 +120,34 @@ fn client_config() -> ProtonClientConfiguration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn keyring_entry() -> Result<Entry> {
|
fn keyring_entry() -> Result<Entry> {
|
||||||
|
// Installed lazily rather than once at startup so a session bus that was not
|
||||||
|
// up yet on the first attempt is retried on the next one.
|
||||||
|
if keyring_core::get_default_store().is_none() {
|
||||||
|
keyring_core::set_default_store(dbus_secret_service_keyring_store::Store::new()?);
|
||||||
|
}
|
||||||
Ok(Entry::new(KEYRING_SERVICE, KEYRING_USER)?)
|
Ok(Entry::new(KEYRING_SERVICE, KEYRING_USER)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What the user answered when asked for the mailbox password.
|
||||||
|
pub struct Mailbox {
|
||||||
|
pub password: Zeroizing<String>,
|
||||||
|
/// Keep the derived passphrases in the keyring, so the daemon unlocks by
|
||||||
|
/// itself after every login. Off by default: it trades the protection a
|
||||||
|
/// separate mailbox password gives for convenience.
|
||||||
|
pub remember: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the passphrases that unlock this account's keys currently live.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum KeyState {
|
||||||
|
/// In the keyring, available after every login.
|
||||||
|
Stored,
|
||||||
|
/// In kernel memory for this login session only.
|
||||||
|
Session,
|
||||||
|
/// Nowhere: the mailbox password has to be entered (`pdfs unlock`).
|
||||||
|
Locked,
|
||||||
|
}
|
||||||
|
|
||||||
/// Run an interactive SRP + (optional) 2FA login and persist the session.
|
/// Run an interactive SRP + (optional) 2FA login and persist the session.
|
||||||
///
|
///
|
||||||
/// `get_totp` is only invoked when the account requires a second factor, so
|
/// `get_totp` is only invoked when the account requires a second factor, so
|
||||||
@@ -91,14 +155,19 @@ fn keyring_entry() -> Result<Entry> {
|
|||||||
/// more than once — [`login_interactive`] re-runs the whole login after human
|
/// more than once — [`login_interactive`] re-runs the whole login after human
|
||||||
/// verification, and a 2FA account needs a fresh code on that second attempt.
|
/// verification, and a 2FA account needs a fresh code on that second attempt.
|
||||||
///
|
///
|
||||||
|
/// `get_mailbox` is only invoked for a two-password account. It receives the
|
||||||
|
/// number of earlier wrong answers (so a prompt can say "try again") and is
|
||||||
|
/// asked at most [`MAILBOX_ATTEMPTS`] times; an `Err` from it aborts the login.
|
||||||
|
///
|
||||||
/// A gated login fails with [`Error::HumanVerificationRequired`]; a front-end
|
/// A gated login fails with [`Error::HumanVerificationRequired`]; a front-end
|
||||||
/// that can present the challenge should use [`login_interactive`] instead.
|
/// that can present the challenge should use [`login_interactive`] instead.
|
||||||
pub async fn login(
|
pub async fn login(
|
||||||
username: &str,
|
username: &str,
|
||||||
password: &str,
|
password: &str,
|
||||||
get_totp: impl Fn() -> Result<String>,
|
get_totp: impl Fn() -> Result<String>,
|
||||||
|
get_mailbox: impl Fn(u32) -> Result<Mailbox>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
login_verified(username, password, None, get_totp).await
|
login_verified(username, password, None, get_totp, get_mailbox).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`login`], able to answer a human-verification gate rather than fail on it.
|
/// [`login`], able to answer a human-verification gate rather than fail on it.
|
||||||
@@ -118,17 +187,63 @@ pub async fn login_interactive(
|
|||||||
username: &str,
|
username: &str,
|
||||||
password: &str,
|
password: &str,
|
||||||
get_totp: impl Fn() -> Result<String>,
|
get_totp: impl Fn() -> Result<String>,
|
||||||
|
get_mailbox: impl Fn(u32) -> Result<Mailbox>,
|
||||||
get_hv: impl FnOnce(HumanVerification) -> Result<HumanVerificationCredential>,
|
get_hv: impl FnOnce(HumanVerification) -> Result<HumanVerificationCredential>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
match login_verified(username, password, None, &get_totp).await {
|
match login_verified(username, password, None, &get_totp, &get_mailbox).await {
|
||||||
Err(Error::HumanVerificationRequired(hv)) => {
|
Err(Error::HumanVerificationRequired(hv)) => {
|
||||||
let credential = get_hv(*hv)?;
|
let credential = get_hv(*hv)?;
|
||||||
login_verified(username, password, Some(&credential), &get_totp).await
|
login_verified(
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
Some(&credential),
|
||||||
|
&get_totp,
|
||||||
|
&get_mailbox,
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
other => other,
|
other => other,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Proton's verification page for the user's own browser, for a front-end that
|
||||||
|
/// cannot host it in a webview.
|
||||||
|
///
|
||||||
|
/// Unlike [`HumanVerification::verification_url`] this asks for the full page,
|
||||||
|
/// not the embedded one: a browser tab has no host to post the token to, so the
|
||||||
|
/// page has to tell the user they are done instead. Proton Mail Bridge opens the
|
||||||
|
/// same URL.
|
||||||
|
pub fn browser_verification_url(hv: &HumanVerification) -> String {
|
||||||
|
let token: String = hv
|
||||||
|
.token
|
||||||
|
.bytes()
|
||||||
|
.map(|b| match b {
|
||||||
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||||
|
(b as char).to_string()
|
||||||
|
}
|
||||||
|
_ => format!("%{b:02X}"),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
format!(
|
||||||
|
"https://verify.proton.me/?methods={}&token={token}",
|
||||||
|
hv.methods.join(","),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The credential to retry with once the user has solved the challenge in
|
||||||
|
/// their browser.
|
||||||
|
///
|
||||||
|
/// The browser keeps the page's own token, so the retry carries the challenge
|
||||||
|
/// token instead: solving the page marks that token as verified on Proton's
|
||||||
|
/// side. The type is every offered method, as Proton Mail Bridge sends it, since
|
||||||
|
/// the user may have picked any of them on the page.
|
||||||
|
pub fn browser_verified(hv: &HumanVerification) -> HumanVerificationCredential {
|
||||||
|
HumanVerificationCredential {
|
||||||
|
token: hv.token.clone(),
|
||||||
|
method: hv.methods.join(","),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// [`login`], replaying a human-verification token the user has already earned.
|
/// [`login`], replaying a human-verification token the user has already earned.
|
||||||
///
|
///
|
||||||
/// A gated login fails with [`Error::HumanVerificationRequired`] carrying the
|
/// A gated login fails with [`Error::HumanVerificationRequired`] carrying the
|
||||||
@@ -140,15 +255,18 @@ pub async fn login_verified(
|
|||||||
password: &str,
|
password: &str,
|
||||||
verification: Option<&HumanVerificationCredential>,
|
verification: Option<&HumanVerificationCredential>,
|
||||||
get_totp: impl Fn() -> Result<String>,
|
get_totp: impl Fn() -> Result<String>,
|
||||||
|
get_mailbox: impl Fn(u32) -> Result<Mailbox>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let mut session = ProtonApiSession::begin_verified(
|
let password = Zeroizing::new(password.to_owned());
|
||||||
|
let session = ProtonApiSession::begin_verified(
|
||||||
client_config(),
|
client_config(),
|
||||||
username,
|
username,
|
||||||
password.as_bytes(),
|
password.as_bytes(),
|
||||||
verification,
|
verification,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(classify_verification_gate)?;
|
.map_err(classify_verification_gate);
|
||||||
|
let mut session = session?;
|
||||||
|
|
||||||
if session.is_waiting_for_second_factor() {
|
if session.is_waiting_for_second_factor() {
|
||||||
let code = get_totp()?;
|
let code = get_totp()?;
|
||||||
@@ -158,38 +276,38 @@ pub async fn login_verified(
|
|||||||
// Grab the key salts while this access token still has the `locked` scope:
|
// Grab the key salts while this access token still has the `locked` scope:
|
||||||
// after its first refresh it never will again, and without them no later
|
// after its first refresh it never will again, and without them no later
|
||||||
// resume can unlock the key chain. See `StoredSession::key_salts`.
|
// resume can unlock the key chain. See `StoredSession::key_salts`.
|
||||||
let client = ProtonDriveClient::new(&session, password.as_bytes().to_vec());
|
let key_salts = ProtonDriveClient::new(&session, Vec::new())
|
||||||
let key_salts = client.account().key_salts().await?;
|
.account()
|
||||||
|
.key_salts()
|
||||||
|
.await?;
|
||||||
|
|
||||||
register_refresh_handler(&session, password.to_owned(), key_salts.clone());
|
// A single-password account's mailbox password is the login password. A
|
||||||
|
// two-password one has a second secret the user must type, and it is checked
|
||||||
save(&session, password, key_salts).await?;
|
// against the account now — a typo found at login is a retry, found later it
|
||||||
Ok(())
|
// is a daemon that never mounts.
|
||||||
}
|
let (passphrases, remember) = match session.password_mode() {
|
||||||
|
PasswordMode::Single => (
|
||||||
/// Lift a human-verification gate out of the generic API error into the typed
|
derive_verified(&session, password.as_bytes(), key_salts.clone()).await?,
|
||||||
/// variant a front-end can act on.
|
true,
|
||||||
///
|
),
|
||||||
/// Only a gate that names a solvable method is converted. A `9001` with no
|
PasswordMode::Dual => {
|
||||||
/// `Details`, or one offering only `email`/`sms`, stays a plain API error:
|
let mut attempt = 0;
|
||||||
/// promoting it would send the UI to open a verification page it cannot
|
loop {
|
||||||
/// complete, which reads to the user as a hang rather than a refusal.
|
let mailbox = get_mailbox(attempt)?;
|
||||||
fn classify_verification_gate(e: proton_sdk::error::ProtonError) -> Error {
|
match derive_verified(&session, mailbox.password.as_bytes(), key_salts.clone())
|
||||||
let proton_sdk::error::ProtonError::Api(api) = &e else {
|
.await
|
||||||
return e.into();
|
{
|
||||||
|
Ok(passphrases) => break (passphrases, mailbox.remember),
|
||||||
|
Err(Error::WrongMailboxPassword) if attempt + 1 < MAILBOX_ATTEMPTS => {
|
||||||
|
attempt += 1;
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
match api.human_verification() {
|
|
||||||
Some(hv) if hv.supports_captcha() => Error::HumanVerificationRequired(Box::new(hv)),
|
|
||||||
_ => e.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Persist the session's current tokens, mailbox password and key salts.
|
register_refresh_handler(&session);
|
||||||
pub async fn save(
|
|
||||||
session: &ProtonApiSession,
|
|
||||||
mailbox_password: &str,
|
|
||||||
key_salts: Vec<KeySalt>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let tokens = session.current_tokens().await;
|
let tokens = session.current_tokens().await;
|
||||||
let stored = StoredSession {
|
let stored = StoredSession {
|
||||||
session_id: session.session_id().as_str().to_owned(),
|
session_id: session.session_id().as_str().to_owned(),
|
||||||
@@ -202,10 +320,103 @@ pub async fn save(
|
|||||||
PasswordMode::Single => 1,
|
PasswordMode::Single => 1,
|
||||||
PasswordMode::Dual => 2,
|
PasswordMode::Dual => 2,
|
||||||
},
|
},
|
||||||
mailbox_password: mailbox_password.to_owned(),
|
mailbox_password: String::new(),
|
||||||
key_salts,
|
key_salts,
|
||||||
|
key_passphrases: None,
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&stored)?;
|
keep_passphrases(stored, &passphrases, remember)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive the key passphrases from `mailbox` and prove they unlock the account.
|
||||||
|
///
|
||||||
|
/// [`Error::WrongMailboxPassword`] means the account answered and the password
|
||||||
|
/// is wrong; any other error says nothing about the password (no network, an
|
||||||
|
/// API fault) and must not be reported as a typo.
|
||||||
|
async fn derive_verified(
|
||||||
|
session: &ProtonApiSession,
|
||||||
|
mailbox: &[u8],
|
||||||
|
key_salts: Vec<KeySalt>,
|
||||||
|
) -> Result<KeyPassphrases> {
|
||||||
|
let client = ProtonDriveClient::with_key_salts(session, mailbox.to_vec(), key_salts);
|
||||||
|
client.account().unlock().await.map_err(|e| match e {
|
||||||
|
ProtonError::KeysLocked => Error::WrongMailboxPassword,
|
||||||
|
other => other.into(),
|
||||||
|
})?;
|
||||||
|
Ok(client.account().key_passphrases().await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write `stored` with the passphrases in the place the user chose: the
|
||||||
|
/// keyring (`remember`) or kernel memory only. Whichever is not chosen is
|
||||||
|
/// cleared, so a revoked choice never leaves a copy behind.
|
||||||
|
///
|
||||||
|
/// The kernel entry is written first. If it cannot be (no keyring support), the
|
||||||
|
/// secret is *not* quietly stored somewhere weaker: the blob is written locked
|
||||||
|
/// and the error says so.
|
||||||
|
fn keep_passphrases(
|
||||||
|
mut stored: StoredSession,
|
||||||
|
passphrases: &KeyPassphrases,
|
||||||
|
remember: bool,
|
||||||
|
) -> Result<()> {
|
||||||
|
let outcome = if remember {
|
||||||
|
stored.key_passphrases = Some(encode(passphrases));
|
||||||
|
// Best-effort: a system without a kernel keyring has nothing to clear.
|
||||||
|
let _ = kernelkey::clear();
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
stored.key_passphrases = None;
|
||||||
|
kernelkey::store(&encode_secret(passphrases)).map_err(|e| {
|
||||||
|
Error::Other(format!(
|
||||||
|
"the kernel keyring is unavailable, so the unlock cannot be kept for this session: {e}"
|
||||||
|
))
|
||||||
|
})
|
||||||
|
};
|
||||||
|
write_stored(&stored)?;
|
||||||
|
outcome
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode(passphrases: &KeyPassphrases) -> BTreeMap<String, String> {
|
||||||
|
passphrases
|
||||||
|
.iter()
|
||||||
|
.map(|(id, secret)| (id.to_owned(), BASE64.encode(secret)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode(encoded: &BTreeMap<String, String>) -> Option<KeyPassphrases> {
|
||||||
|
let mut passphrases = KeyPassphrases::new();
|
||||||
|
for (id, secret) in encoded {
|
||||||
|
passphrases.insert(id.clone(), BASE64.decode(secret).ok()?);
|
||||||
|
}
|
||||||
|
(!passphrases.is_empty()).then_some(passphrases)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The kernel-keyring payload: the same map as the blob's, as JSON.
|
||||||
|
fn encode_secret(passphrases: &KeyPassphrases) -> Zeroizing<Vec<u8>> {
|
||||||
|
Zeroizing::new(serde_json::to_vec(&encode(passphrases)).unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_secret(secret: &[u8]) -> Option<KeyPassphrases> {
|
||||||
|
decode(&serde_json::from_slice(secret).ok()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lift a human-verification gate out of the generic API error into the typed
|
||||||
|
/// variant a front-end can act on.
|
||||||
|
///
|
||||||
|
/// Only a gate that names a solvable method is converted. A `9001` with no
|
||||||
|
/// `Details`, or one offering only `email`/`sms`, stays a plain API error:
|
||||||
|
/// promoting it would send the UI to open a verification page it cannot
|
||||||
|
/// complete, which reads to the user as a hang rather than a refusal.
|
||||||
|
fn classify_verification_gate(e: ProtonError) -> Error {
|
||||||
|
let ProtonError::Api(api) = &e else {
|
||||||
|
return e.into();
|
||||||
|
};
|
||||||
|
match api.human_verification() {
|
||||||
|
Some(hv) if hv.supports_captcha() => Error::HumanVerificationRequired(Box::new(hv)),
|
||||||
|
_ => e.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_stored(stored: &StoredSession) -> Result<()> {
|
||||||
|
let json = Zeroizing::new(serde_json::to_string(stored)?);
|
||||||
keyring_entry()?.set_password(&json)?;
|
keyring_entry()?.set_password(&json)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -215,17 +426,98 @@ pub fn load() -> Result<StoredSession> {
|
|||||||
let entry = keyring_entry()?;
|
let entry = keyring_entry()?;
|
||||||
match entry.get_password() {
|
match entry.get_password() {
|
||||||
Ok(json) => Ok(serde_json::from_str(&json)?),
|
Ok(json) => Ok(serde_json::from_str(&json)?),
|
||||||
Err(keyring::Error::NoEntry) => Err(Error::NotLoggedIn),
|
Err(keyring_core::Error::NoEntry) => Err(Error::NotLoggedIn),
|
||||||
Err(e) => Err(e.into()),
|
Err(e) => Err(e.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where the passphrases for the stored session live right now.
|
||||||
|
///
|
||||||
|
/// Reads the kernel keyring and the stored blob only; never the network.
|
||||||
|
pub fn key_state() -> Result<KeyState> {
|
||||||
|
Ok(key_state_of(&load()?))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key_state_of(stored: &StoredSession) -> KeyState {
|
||||||
|
match find_passphrases(stored) {
|
||||||
|
Ok(Some((_, origin))) => origin,
|
||||||
|
// A 3.0.x blob still holds its password; `resume_client` migrates it.
|
||||||
|
_ if !stored.mailbox_password.is_empty() => KeyState::Stored,
|
||||||
|
_ => KeyState::Locked,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the stored session can be resumed without asking the user:
|
||||||
|
/// `Ok` when it can, [`Error::NotLoggedIn`] or [`Error::Locked`] when not.
|
||||||
|
/// What the daemon waits on before it mounts.
|
||||||
|
pub fn ready() -> Result<()> {
|
||||||
|
match key_state_of(&load()?) {
|
||||||
|
KeyState::Locked => Err(Error::Locked),
|
||||||
|
_ => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The passphrases the stored session can use without being asked, and which
|
||||||
|
/// of the two places they came from. Prefers the keyring blob: it is what the
|
||||||
|
/// user opted into. A corrupt entry is treated as absent, so it reads as
|
||||||
|
/// locked instead of wedging the daemon.
|
||||||
|
fn find_passphrases(stored: &StoredSession) -> Result<Option<(KeyPassphrases, KeyState)>> {
|
||||||
|
if let Some(found) = stored.key_passphrases.as_ref().and_then(decode) {
|
||||||
|
return Ok(Some((found, KeyState::Stored)));
|
||||||
|
}
|
||||||
|
match kernelkey::load() {
|
||||||
|
Ok(Some(secret)) => Ok(decode_secret(&secret).map(|p| (p, KeyState::Session))),
|
||||||
|
Ok(None) => Ok(None),
|
||||||
|
// No keyring on this system: nothing was ever kept there.
|
||||||
|
Err(e) => {
|
||||||
|
tracing::debug!(error = %e, "kernel keyring not readable; treating the account as locked");
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supply the mailbox password of a locked account.
|
||||||
|
///
|
||||||
|
/// Verifies it against the account, then keeps the derived passphrases in
|
||||||
|
/// kernel memory for this login session — or, with `remember`, in the keyring.
|
||||||
|
/// A daemon waiting on [`Error::Locked`] picks them up on its next attempt.
|
||||||
|
///
|
||||||
|
/// Fails with [`Error::WrongMailboxPassword`] for a wrong password, which
|
||||||
|
/// changes nothing, and [`Error::ReloginRequired`] for a session that predates
|
||||||
|
/// stored key salts.
|
||||||
|
pub async fn unlock(mailbox: &str, remember: bool) -> Result<()> {
|
||||||
|
let stored = load()?;
|
||||||
|
if stored.key_salts.is_empty() {
|
||||||
|
return Err(Error::ReloginRequired);
|
||||||
|
}
|
||||||
|
let mailbox = Zeroizing::new(mailbox.to_owned());
|
||||||
|
let session = ProtonApiSession::resume(client_config(), stored.to_params())?;
|
||||||
|
register_refresh_handler(&session);
|
||||||
|
let passphrases =
|
||||||
|
derive_verified(&session, mailbox.as_bytes(), stored.key_salts.clone()).await?;
|
||||||
|
keep_passphrases(stored, &passphrases, remember)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop the passphrases held in kernel memory, so the account is locked again
|
||||||
|
/// until [`unlock`]. With `forget`, also those in the keyring blob.
|
||||||
|
pub fn lock(forget: bool) -> Result<()> {
|
||||||
|
kernelkey::clear()?;
|
||||||
|
if forget {
|
||||||
|
let mut stored = load()?;
|
||||||
|
if stored.key_passphrases.take().is_some() {
|
||||||
|
write_stored(&stored)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Forget the persisted session (best-effort; absent entry is not an error).
|
/// Forget the persisted session (best-effort; absent entry is not an error).
|
||||||
pub fn logout() -> Result<()> {
|
pub fn logout() -> Result<()> {
|
||||||
// The persisted entity cache describes the account being logged out of. It
|
// The persisted entity cache describes the account being logged out of. It
|
||||||
// is encrypted under that account's mailbox password, but leaving it behind
|
// is encrypted under a key derived from that account's passphrases, but
|
||||||
// would also mean the next account inherits a store it can only ever read as
|
// leaving it behind would also mean the next account inherits a store it can
|
||||||
// misses. Best-effort: failing to remove a cache must not block the logout.
|
// only ever read as misses. Best-effort: failing to remove a cache must not
|
||||||
|
// block the logout.
|
||||||
if let Ok(dirs) = AppDirs::new() {
|
if let Ok(dirs) = AppDirs::new() {
|
||||||
let path = dirs.state_dir().join("sdk_cache.db");
|
let path = dirs.state_dir().join("sdk_cache.db");
|
||||||
if let Some(cache) = crate::sdkcache::SdkCache::opened()
|
if let Some(cache) = crate::sdkcache::SdkCache::opened()
|
||||||
@@ -237,52 +529,98 @@ pub fn logout() -> Result<()> {
|
|||||||
let _ = std::fs::remove_file(format!("{}{suffix}", path.display()));
|
let _ = std::fs::remove_file(format!("{}{suffix}", path.display()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Err(e) = kernelkey::clear() {
|
||||||
|
tracing::warn!(error = %e, "clearing the kernel-keyring unlock on logout failed");
|
||||||
|
}
|
||||||
match keyring_entry()?.delete_credential() {
|
match keyring_entry()?.delete_credential() {
|
||||||
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(()),
|
||||||
Err(e) => Err(e.into()),
|
Err(e) => Err(e.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resume a persisted session and build an authenticated Drive client.
|
/// Resume a persisted session and build an authenticated Drive client.
|
||||||
///
|
///
|
||||||
/// Returns `Error::NotLoggedIn` when no session has been saved. The caller is
|
/// Returns `Error::NotLoggedIn` when no session has been saved and
|
||||||
/// responsible for persisting rotated tokens over the session's lifetime via
|
/// [`Error::Locked`] when the account is waiting for its mailbox password. Tokens
|
||||||
/// [`persist`] — Proton refresh tokens are single-use, so a refresh that is not
|
/// the session rotates are written back to the keyring as they happen (see
|
||||||
/// written back leaves the keyring holding a stale (now-invalid) refresh token,
|
/// [`register_refresh_handler`]); Proton refresh tokens are single-use, so a
|
||||||
/// and the next resume fails with `InvalidRefreshToken`.
|
/// refresh that is not written back would leave the keyring holding a stale
|
||||||
|
/// refresh token, and the next resume would fail with `InvalidRefreshToken`.
|
||||||
|
///
|
||||||
|
/// Makes no network call for an account whose passphrases are already known, so
|
||||||
|
/// a daemon starting offline can still mount from its cache.
|
||||||
pub async fn resume_client() -> Result<(ProtonDriveClient, ProtonApiSession)> {
|
pub async fn resume_client() -> Result<(ProtonDriveClient, ProtonApiSession)> {
|
||||||
let stored = load()?;
|
let stored = load()?;
|
||||||
let session = ProtonApiSession::resume(client_config(), stored.to_params())?;
|
let session = ProtonApiSession::resume(client_config(), stored.to_params())?;
|
||||||
let password = stored.mailbox_password.clone();
|
register_refresh_handler(&session);
|
||||||
|
|
||||||
// Blobs written before key salts were persisted have none: fetch them once
|
let passphrases = match find_passphrases(&stored)? {
|
||||||
// more and write them back, which only works while the stored access token
|
Some((passphrases, _)) => passphrases,
|
||||||
// still carries the `locked` scope. If it doesn't, the account must be
|
None if !stored.mailbox_password.is_empty() => migrate_legacy(&session, &stored).await?,
|
||||||
// logged into again — no refresh can restore that scope.
|
None => return Err(Error::Locked),
|
||||||
let key_salts = if stored.key_salts.is_empty() {
|
|
||||||
register_refresh_handler(&session, password.clone(), Vec::new());
|
|
||||||
let probe = ProtonDriveClient::new(&session, password.clone().into_bytes());
|
|
||||||
let salts = probe.account().key_salts().await.map_err(|e| match &e {
|
|
||||||
proton_sdk::error::ProtonError::Api(api) if api.is_insufficient_scope() => {
|
|
||||||
Error::ReloginRequired
|
|
||||||
}
|
|
||||||
_ => e.into(),
|
|
||||||
})?;
|
|
||||||
save(&session, &password, salts.clone()).await?;
|
|
||||||
salts
|
|
||||||
} else {
|
|
||||||
stored.key_salts
|
|
||||||
};
|
};
|
||||||
|
|
||||||
register_refresh_handler(&session, password.clone(), key_salts.clone());
|
|
||||||
|
|
||||||
let client = tune(
|
let client = tune(
|
||||||
ProtonDriveClient::with_key_salts(&session, password.clone().into_bytes(), key_salts),
|
ProtonDriveClient::with_key_passphrases(&session, passphrases.clone()),
|
||||||
password.as_bytes(),
|
&passphrases,
|
||||||
);
|
);
|
||||||
Ok((client, session))
|
Ok((client, session))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Turn a blob written by 3.0.x — which holds the mailbox password — into one
|
||||||
|
/// that holds passphrases, so the password stops being stored.
|
||||||
|
///
|
||||||
|
/// The old login saved whatever was typed as the mailbox password, which for a
|
||||||
|
/// two-password account is the *login* password and unlocks nothing. That is
|
||||||
|
/// checked here: a password the account rejects leaves the account locked (and
|
||||||
|
/// the stored copy gone) rather than failing every start forever.
|
||||||
|
async fn migrate_legacy(
|
||||||
|
session: &ProtonApiSession,
|
||||||
|
stored: &StoredSession,
|
||||||
|
) -> Result<KeyPassphrases> {
|
||||||
|
let legacy = Zeroizing::new(stored.mailbox_password.clone());
|
||||||
|
|
||||||
|
// Blobs from before key salts were persisted have none: fetch them once
|
||||||
|
// more, which only works while the stored access token still carries the
|
||||||
|
// `locked` scope. If it doesn't, the account must be logged into again — no
|
||||||
|
// refresh can restore that scope.
|
||||||
|
let key_salts = if stored.key_salts.is_empty() {
|
||||||
|
ProtonDriveClient::new(session, Vec::new())
|
||||||
|
.account()
|
||||||
|
.key_salts()
|
||||||
|
.await
|
||||||
|
.map_err(|e| match &e {
|
||||||
|
ProtonError::Api(api) if api.is_insufficient_scope() => Error::ReloginRequired,
|
||||||
|
_ => e.into(),
|
||||||
|
})?
|
||||||
|
} else {
|
||||||
|
stored.key_salts.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut migrated = stored.clone();
|
||||||
|
migrated.mailbox_password = String::new();
|
||||||
|
migrated.key_salts = key_salts.clone();
|
||||||
|
|
||||||
|
match derive_verified(session, legacy.as_bytes(), key_salts).await {
|
||||||
|
Ok(passphrases) => {
|
||||||
|
migrated.key_passphrases = Some(encode(&passphrases));
|
||||||
|
write_stored(&migrated)?;
|
||||||
|
tracing::info!(
|
||||||
|
"migrated the stored session from a mailbox password to key passphrases"
|
||||||
|
);
|
||||||
|
Ok(passphrases)
|
||||||
|
}
|
||||||
|
Err(Error::WrongMailboxPassword) => {
|
||||||
|
write_stored(&migrated)?;
|
||||||
|
tracing::warn!(
|
||||||
|
"the stored password does not unlock the account's keys; unlock required"
|
||||||
|
);
|
||||||
|
Err(Error::Locked)
|
||||||
|
}
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The client settings every front-end wants, applied wherever a
|
/// The client settings every front-end wants, applied wherever a
|
||||||
/// [`ProtonDriveClient`] is built for real work.
|
/// [`ProtonDriveClient`] is built for real work.
|
||||||
///
|
///
|
||||||
@@ -296,23 +634,24 @@ pub async fn resume_client() -> Result<(ProtonDriveClient, ProtonApiSession)> {
|
|||||||
/// failure point per write instead of three.
|
/// failure point per write instead of three.
|
||||||
/// - **A persistent entity cache** ([`crate::sdkcache`]), so a restart does not
|
/// - **A persistent entity cache** ([`crate::sdkcache`]), so a restart does not
|
||||||
/// re-fetch and re-decrypt the tree the previous run already walked. It is
|
/// re-fetch and re-decrypt the tree the previous run already walked. It is
|
||||||
/// wrapped in the SDK's [`EncryptedCacheRepository`] keyed by the mailbox
|
/// wrapped in the SDK's [`EncryptedCacheRepository`] keyed by a secret derived
|
||||||
/// password, so the decrypted node names it holds are not readable from the
|
/// from the key passphrases, so the decrypted node names it holds are not
|
||||||
/// file alone — and a password change simply reads as a cold cache, since the
|
/// readable from the file alone — and a password change simply reads as a cold
|
||||||
/// SDK treats an undecryptable entry as a miss and clears the store.
|
/// cache, since the SDK treats an undecryptable entry as a miss and clears the
|
||||||
|
/// store.
|
||||||
///
|
///
|
||||||
/// Opening it is best-effort: a store this process cannot open costs cache
|
/// Opening it is best-effort: a store this process cannot open costs cache
|
||||||
/// hits, not the session, so the client falls back to the SDK's in-memory
|
/// hits, not the session, so the client falls back to the SDK's in-memory
|
||||||
/// default.
|
/// default.
|
||||||
fn tune(client: ProtonDriveClient, mailbox_password: &[u8]) -> ProtonDriveClient {
|
fn tune(client: ProtonDriveClient, passphrases: &KeyPassphrases) -> ProtonDriveClient {
|
||||||
let client = client
|
let client = client
|
||||||
.with_telemetry(TracingTelemetry::shared())
|
.with_telemetry(TracingTelemetry::shared())
|
||||||
.with_small_file_upload(true);
|
.with_small_file_upload(true);
|
||||||
match AppDirs::new().and_then(|dirs| crate::sdkcache::SdkCache::shared(&dirs)) {
|
match AppDirs::new().and_then(|dirs| crate::sdkcache::SdkCache::shared(&dirs)) {
|
||||||
Ok(cache) => client.with_entity_repository(EncryptedCacheRepository::shared(
|
Ok(cache) => {
|
||||||
cache,
|
let key = passphrases.derive_secret(ENTITY_CACHE_PURPOSE);
|
||||||
mailbox_password.to_vec(),
|
client.with_entity_repository(EncryptedCacheRepository::shared(cache, key.to_vec()))
|
||||||
)),
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(error = %e, "persistent SDK entity cache unavailable; using memory");
|
tracing::warn!(error = %e, "persistent SDK entity cache unavailable; using memory");
|
||||||
client
|
client
|
||||||
@@ -322,49 +661,129 @@ fn tune(client: ProtonDriveClient, mailbox_password: &[u8]) -> ProtonDriveClient
|
|||||||
|
|
||||||
/// Write the session's current tokens back to the keyring.
|
/// Write the session's current tokens back to the keyring.
|
||||||
///
|
///
|
||||||
/// The session does not carry the mailbox password (it is only needed to rebuild
|
/// Everything else in the stored blob — salts, passphrases — is preserved, so
|
||||||
/// the key chain on resume), so it is re-read from the existing stored blob and
|
/// this never undoes an [`unlock`] or [`lock`] made since the session started.
|
||||||
/// preserved. Call whenever the session may have rotated its tokens through the
|
/// Call whenever the session may have rotated its tokens so a later
|
||||||
/// 401-refresh path so a later [`resume_client`] presents a live refresh token.
|
/// [`resume_client`] presents a live refresh token.
|
||||||
pub async fn persist(session: &ProtonApiSession) -> Result<()> {
|
pub async fn persist(session: &ProtonApiSession) -> Result<()> {
|
||||||
let stored = load()?;
|
let tokens = session.current_tokens().await;
|
||||||
save(session, &stored.mailbox_password, stored.key_salts).await
|
store_tokens(tokens.access_token, tokens.refresh_token)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn register_refresh_handler(
|
fn store_tokens(access_token: String, refresh_token: String) -> Result<()> {
|
||||||
session: &ProtonApiSession,
|
let mut stored = load()?;
|
||||||
mailbox_password: String,
|
stored.access_token = access_token;
|
||||||
key_salts: Vec<KeySalt>,
|
stored.refresh_token = refresh_token;
|
||||||
) {
|
write_stored(&stored)
|
||||||
let session_id = session.session_id().as_str().to_owned();
|
}
|
||||||
let username = session.username().to_owned();
|
|
||||||
let user_id = session.user_id().as_str().to_owned();
|
|
||||||
let scopes = session.scopes().to_vec();
|
|
||||||
let password_mode = match session.password_mode() {
|
|
||||||
PasswordMode::Single => 1,
|
|
||||||
PasswordMode::Dual => 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
|
/// Persist rotated tokens the moment the session obtains them.
|
||||||
|
///
|
||||||
|
/// The handler re-reads the stored blob instead of capturing one, so it cannot
|
||||||
|
/// write back a stale copy over a later unlock or lock — and cannot resurrect a
|
||||||
|
/// session that was logged out while the daemon still ran.
|
||||||
|
fn register_refresh_handler(session: &ProtonApiSession) {
|
||||||
session.http().set_on_tokens_refreshed(move |tokens| {
|
session.http().set_on_tokens_refreshed(move |tokens| {
|
||||||
let stored = StoredSession {
|
match store_tokens(tokens.access_token, tokens.refresh_token) {
|
||||||
session_id: session_id.clone(),
|
Ok(()) => tracing::info!("successfully auto-persisted refreshed tokens in keyring"),
|
||||||
username: username.clone(),
|
Err(Error::NotLoggedIn) => {}
|
||||||
user_id: user_id.clone(),
|
Err(e) => {
|
||||||
access_token: tokens.access_token,
|
tracing::warn!(error = %e, "failed to auto-persist refreshed tokens in keyring")
|
||||||
refresh_token: tokens.refresh_token,
|
|
||||||
scopes: scopes.clone(),
|
|
||||||
password_mode,
|
|
||||||
mailbox_password: mailbox_password.clone(),
|
|
||||||
key_salts: key_salts.clone(),
|
|
||||||
};
|
|
||||||
if let Ok(json) = serde_json::to_string(&stored)
|
|
||||||
&& let Ok(entry) = keyring_entry()
|
|
||||||
{
|
|
||||||
if let Err(e) = entry.set_password(&json) {
|
|
||||||
tracing::warn!(error = %e, "failed to auto-persist refreshed tokens in keyring");
|
|
||||||
} else {
|
|
||||||
tracing::info!("successfully auto-persisted refreshed tokens in keyring");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn passphrases() -> KeyPassphrases {
|
||||||
|
let mut p = KeyPassphrases::new();
|
||||||
|
p.insert("key-a", b"alpha".to_vec());
|
||||||
|
p.insert("key-b", vec![0, 255, 7]);
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
fn blob(extra: &str) -> String {
|
||||||
|
format!(
|
||||||
|
r#"{{"session_id":"s","username":"u","user_id":"i","access_token":"a",
|
||||||
|
"refresh_token":"r","scopes":[],"password_mode":2{extra}}}"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn challenge() -> HumanVerification {
|
||||||
|
serde_json::from_str(
|
||||||
|
r#"{"HumanVerificationToken":"ab+c/d=","HumanVerificationMethods":["captcha","email"]}"#,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_browser_gets_the_full_verification_page() {
|
||||||
|
assert_eq!(
|
||||||
|
browser_verification_url(&challenge()),
|
||||||
|
"https://verify.proton.me/?methods=captcha,email&token=ab%2Bc%2Fd%3D"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The browser keeps the page's token, so the retry replays the challenge
|
||||||
|
/// itself, typed with every method the page offered.
|
||||||
|
#[test]
|
||||||
|
fn a_browser_verification_retries_with_the_challenge_token() {
|
||||||
|
let credential = browser_verified(&challenge());
|
||||||
|
assert_eq!(credential.token, "ab+c/d=");
|
||||||
|
assert_eq!(credential.method, "captcha,email");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn passphrases_survive_the_blob_encoding() {
|
||||||
|
let decoded = decode(&encode(&passphrases())).expect("decodes");
|
||||||
|
assert_eq!(decoded, passphrases());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn passphrases_survive_the_kernel_payload_encoding() {
|
||||||
|
let decoded = decode_secret(&encode_secret(&passphrases())).expect("decodes");
|
||||||
|
assert_eq!(decoded, passphrases());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn corrupt_or_empty_secrets_read_as_absent() {
|
||||||
|
assert!(decode_secret(b"not json").is_none());
|
||||||
|
assert!(decode(&BTreeMap::new()).is_none());
|
||||||
|
assert!(decode(&BTreeMap::from([("k".into(), "!!!".into())])).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_blob_is_read_but_its_password_is_never_written_back() {
|
||||||
|
let stored: StoredSession =
|
||||||
|
serde_json::from_str(&blob(r#","mailbox_password":"hunter2""#)).unwrap();
|
||||||
|
assert_eq!(stored.mailbox_password, "hunter2");
|
||||||
|
assert!(stored.key_passphrases.is_none());
|
||||||
|
|
||||||
|
let written = serde_json::to_string(&stored).unwrap();
|
||||||
|
assert!(!written.contains("hunter2"));
|
||||||
|
assert!(!written.contains("mailbox_password"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn locked_blob_has_no_passphrases_field() {
|
||||||
|
let stored: StoredSession = serde_json::from_str(&blob("")).unwrap();
|
||||||
|
assert!(
|
||||||
|
serde_json::to_string(&stored)
|
||||||
|
.unwrap()
|
||||||
|
.find("key_passphrases")
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stored_passphrases_win_over_the_kernel_keyring() {
|
||||||
|
let mut stored: StoredSession = serde_json::from_str(&blob("")).unwrap();
|
||||||
|
stored.key_passphrases = Some(encode(&passphrases()));
|
||||||
|
let (found, origin) = find_passphrases(&stored).unwrap().unwrap();
|
||||||
|
assert_eq!(origin, KeyState::Stored);
|
||||||
|
assert_eq!(found, passphrases());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -91,7 +91,7 @@ const TOUCH_FLUSH_AT: usize = 256;
|
|||||||
///
|
///
|
||||||
/// Public because it is the *contract* a block has to satisfy, not a detail of
|
/// Public because it is the *contract* a block has to satisfy, not a detail of
|
||||||
/// this cache: the network read path validates against it too, so a block that
|
/// this cache: the network read path validates against it too, so a block that
|
||||||
/// comes back short is refused before it can truncate a file (bugs.md B84).
|
/// comes back short is refused before it can truncate a file (`docs/BUGS.md` B84).
|
||||||
pub fn block_len(size: u64, idx: u64) -> u64 {
|
pub fn block_len(size: u64, idx: u64) -> u64 {
|
||||||
let start = idx.saturating_mul(BLOCK_SIZE);
|
let start = idx.saturating_mul(BLOCK_SIZE);
|
||||||
size.saturating_sub(start).min(BLOCK_SIZE)
|
size.saturating_sub(start).min(BLOCK_SIZE)
|
||||||
@@ -298,6 +298,10 @@ pub struct Pin {
|
|||||||
/// which is why it is defaulted.
|
/// which is why it is defaulted.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub is_dir: Option<bool>,
|
pub is_dir: Option<bool>,
|
||||||
|
/// A pinned file whose content is on disk right now. The daemon fills it
|
||||||
|
/// in when it answers `ListPins`; folders and older daemons say `false`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub cached: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The legacy JSON pin registry, kept only to import a pre-P5 `pins.json` into
|
/// The legacy JSON pin registry, kept only to import a pre-P5 `pins.json` into
|
||||||
@@ -307,8 +311,7 @@ struct PinFile {
|
|||||||
pins: BTreeMap<String, Pin>,
|
pins: BTreeMap<String, Pin>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What a staged file actually contains, written beside it as `<name>.json`
|
/// What a staged file actually contains, written beside it as `<name>.json`.
|
||||||
/// (offline.md Phase 2).
|
|
||||||
///
|
///
|
||||||
/// A staged file is **not** necessarily valid whole-file content, and that is
|
/// A staged file is **not** necessarily valid whole-file content, and that is
|
||||||
/// the whole reason this exists. A partial overwrite commits by filling the
|
/// the whole reason this exists. A partial overwrite commits by filling the
|
||||||
@@ -361,6 +364,14 @@ pub struct StagedWrite {
|
|||||||
pub based_on: Option<Baseline>,
|
pub based_on: Option<Baseline>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl StagedWrite {
|
||||||
|
/// Whether `authored` ranges cover a file of `len` bytes, so the staged
|
||||||
|
/// bytes are its complete new content. An empty file has nothing to cover.
|
||||||
|
pub fn covers(authored: &[(u64, u64)], len: u64) -> bool {
|
||||||
|
len == 0 || authored == [(0, len)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The identity of a remote revision, as far as we can observe it.
|
/// The identity of a remote revision, as far as we can observe it.
|
||||||
///
|
///
|
||||||
/// `revision_id` is the authoritative identity when present: the server assigns a
|
/// `revision_id` is the authoritative identity when present: the server assigns a
|
||||||
@@ -427,9 +438,9 @@ pub struct ContentCache {
|
|||||||
/// Subdirectory for write-handle scratch files (disk-backed write buffers).
|
/// Subdirectory for write-handle scratch files (disk-backed write buffers).
|
||||||
/// Emptied on open so a crashed run leaves no orphans.
|
/// Emptied on open so a crashed run leaves no orphans.
|
||||||
scratch_dir: PathBuf,
|
scratch_dir: PathBuf,
|
||||||
/// Subdirectory holding the bytes of writes that have not been uploaded yet
|
/// Subdirectory holding the bytes of writes that have not been uploaded yet.
|
||||||
/// (offline.md Phase 2/3) — every released write passes through here, since
|
/// Every released write passes through here, since the upload is a queued
|
||||||
/// the upload is a queued op performed later. Unlike `scratch_dir` this is
|
/// op performed later. Unlike `scratch_dir` this is
|
||||||
/// **never** emptied on open: these are the only copy of content the user
|
/// **never** emptied on open: these are the only copy of content the user
|
||||||
/// authored, and the whole point is that they outlive the daemon.
|
/// authored, and the whole point is that they outlive the daemon.
|
||||||
staging_dir: PathBuf,
|
staging_dir: PathBuf,
|
||||||
@@ -464,7 +475,7 @@ pub struct ContentCache {
|
|||||||
thumb_bytes: AtomicU64,
|
thumb_bytes: AtomicU64,
|
||||||
/// Unified metadata DB. Its `cache_entries` table is the LRU index: every
|
/// Unified metadata DB. Its `cache_entries` table is the LRU index: every
|
||||||
/// store/read/evict updates it, and the budget enforcers query it instead of
|
/// store/read/evict updates it, and the budget enforcers query it instead of
|
||||||
/// scanning the cache directories (plan.md P4).
|
/// scanning the cache directories.
|
||||||
db: Arc<Db>,
|
db: Arc<Db>,
|
||||||
/// LRU touches not yet written to `cache_entries`, keyed by cache key.
|
/// LRU touches not yet written to `cache_entries`, keyed by cache key.
|
||||||
///
|
///
|
||||||
@@ -726,6 +737,60 @@ impl ContentCache {
|
|||||||
Some(buf)
|
Some(buf)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Write the whole content of `uid` at `(mtime, size)` to `dst` from the
|
||||||
|
/// cache alone, and report whether that worked.
|
||||||
|
///
|
||||||
|
/// A whole-file blob is cloned. Otherwise every block of the revision has to
|
||||||
|
/// be cached, under its recorded geometry or, failing that, the uniform one
|
||||||
|
/// the read path would use. Anything short of the full content leaves
|
||||||
|
/// nothing at `dst`, so a caller falls back to a download with a clean slate.
|
||||||
|
///
|
||||||
|
/// This is what lets a mirror folder take a file an on-demand mount has
|
||||||
|
/// already read, after a move between them, without fetching it again. The
|
||||||
|
/// trust is the read path's: the same `(mtime, size)` key, and blocks that
|
||||||
|
/// were validated when they were fetched.
|
||||||
|
pub fn copy_cached_to(&self, uid: &NodeUid, mtime: i64, size: u64, dst: &Path) -> bool {
|
||||||
|
if size == 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if let Some(blob) = self.valid_blob(uid, mtime, size) {
|
||||||
|
if Self::clone_or_copy(&blob, dst).is_ok_and(|len| len == size) {
|
||||||
|
self.touch(Self::key(uid));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let _ = std::fs::remove_file(dst);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let geometry = self
|
||||||
|
.block_geometry(uid, mtime, size)
|
||||||
|
.unwrap_or_else(|| BlockGeometry::uniform(size));
|
||||||
|
if geometry.size() != size {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let spans = geometry.spans(0, size);
|
||||||
|
// Checked up front so a file with one block missing costs stats, not a
|
||||||
|
// copy of every block before it.
|
||||||
|
if !spans
|
||||||
|
.iter()
|
||||||
|
.all(|&span| self.has_block(uid, mtime, size, span))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let written = (|| -> Option<()> {
|
||||||
|
use std::io::Write;
|
||||||
|
let mut out = std::fs::File::create(dst).ok()?;
|
||||||
|
for span in spans {
|
||||||
|
out.write_all(&self.cached_block(uid, mtime, size, span)?)
|
||||||
|
.ok()?;
|
||||||
|
}
|
||||||
|
(out.metadata().ok()?.len() == size).then_some(())
|
||||||
|
})();
|
||||||
|
if written.is_none() {
|
||||||
|
let _ = std::fs::remove_file(dst);
|
||||||
|
}
|
||||||
|
written.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
/// Store `bytes` as the cached content for `uid`, tagged with `(mtime,
|
/// Store `bytes` as the cached content for `uid`, tagged with `(mtime,
|
||||||
/// size)`. The blob is written to a temp file then renamed so a concurrent
|
/// size)`. The blob is written to a temp file then renamed so a concurrent
|
||||||
/// reader never sees a partial blob; the meta tag is written last so a
|
/// reader never sees a partial blob; the meta tag is written last so a
|
||||||
@@ -779,6 +844,9 @@ impl ContentCache {
|
|||||||
}
|
}
|
||||||
let bytes = std::fs::metadata(&tmp)?.len();
|
let bytes = std::fs::metadata(&tmp)?.len();
|
||||||
std::fs::rename(&tmp, &blob)?;
|
std::fs::rename(&tmp, &blob)?;
|
||||||
|
// Linked from the blob already cached, the temp name was a second name
|
||||||
|
// of it, and the rename did nothing (`docs/BUGS.md` B187).
|
||||||
|
let _ = std::fs::remove_file(&tmp);
|
||||||
let meta = serde_json::to_vec(&Meta { mtime, size })?;
|
let meta = serde_json::to_vec(&Meta { mtime, size })?;
|
||||||
std::fs::write(self.meta_path(uid), meta)?;
|
std::fs::write(self.meta_path(uid), meta)?;
|
||||||
self.db
|
self.db
|
||||||
@@ -920,7 +988,7 @@ impl ContentCache {
|
|||||||
/// a miss ([`cached_block`](Self::cached_block) checks), so a bad block
|
/// a miss ([`cached_block`](Self::cached_block) checks), so a bad block
|
||||||
/// cannot escape this cache even though this does not check. Validating a
|
/// cannot escape this cache even though this does not check. Validating a
|
||||||
/// block is the *reader's* job, at the point it is fetched — see
|
/// block is the *reader's* job, at the point it is fetched — see
|
||||||
/// `Core::read_block` (bugs.md B84).
|
/// `Core::read_block` (`docs/BUGS.md` B84).
|
||||||
pub fn store_block(
|
pub fn store_block(
|
||||||
&self,
|
&self,
|
||||||
uid: &NodeUid,
|
uid: &NodeUid,
|
||||||
@@ -1422,9 +1490,8 @@ impl ContentCache {
|
|||||||
/// This is what makes a write survive its upload: the caller is releasing a
|
/// This is what makes a write survive its upload: the caller is releasing a
|
||||||
/// write handle and would otherwise delete the file, so until the bytes are
|
/// write handle and would otherwise delete the file, so until the bytes are
|
||||||
/// on the remote, staging holds the only copy. Every dirty handle goes
|
/// on the remote, staging holds the only copy. Every dirty handle goes
|
||||||
/// through here — the upload is a queued op performed later (offline.md
|
/// through here — the upload is a queued op performed later, and a staged
|
||||||
/// Phase 3), and a staged file is also what a human can recover from if the
|
/// file is also what a human can recover from if the queue never drains.
|
||||||
/// queue never drains.
|
|
||||||
///
|
///
|
||||||
/// Falls back to a copy when the rename crosses a filesystem boundary; a
|
/// Falls back to a copy when the rename crosses a filesystem boundary; a
|
||||||
/// failure here means we could not save the bytes at all, so it is reported
|
/// failure here means we could not save the bytes at all, so it is reported
|
||||||
@@ -1854,6 +1921,7 @@ impl ContentCache {
|
|||||||
path: row.path,
|
path: row.path,
|
||||||
recursive: row.recursive,
|
recursive: row.recursive,
|
||||||
is_dir: row.is_dir,
|
is_dir: row.is_dir,
|
||||||
|
cached: false,
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -1997,6 +2065,17 @@ mod tests {
|
|||||||
NodeUid::new(VolumeId::from("vol"), LinkId::from(link))
|
NodeUid::new(VolumeId::from("vol"), LinkId::from(link))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_write_covers_the_file_and_a_gap_does_not() {
|
||||||
|
assert!(
|
||||||
|
StagedWrite::covers(&[], 0),
|
||||||
|
"an empty file has nothing to cover"
|
||||||
|
);
|
||||||
|
assert!(StagedWrite::covers(&[(0, 7)], 7));
|
||||||
|
assert!(!StagedWrite::covers(&[(0, 3), (4, 7)], 7));
|
||||||
|
assert!(!StagedWrite::covers(&[], 7));
|
||||||
|
}
|
||||||
|
|
||||||
/// A unique temp directory removed on drop; avoids a dev-dependency.
|
/// A unique temp directory removed on drop; avoids a dev-dependency.
|
||||||
struct TempDir(PathBuf);
|
struct TempDir(PathBuf);
|
||||||
impl TempDir {
|
impl TempDir {
|
||||||
@@ -2119,6 +2198,24 @@ mod tests {
|
|||||||
assert_eq!(c.read_range(&u, 200, 5, 0, 5).unwrap(), b"newer");
|
assert_eq!(c.read_range(&u, 200, 5, 0, 5).unwrap(), b"newer");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A landed create and the revision after it can adopt the same staged
|
||||||
|
/// blob. The link to the temp name is then a second name of the cached
|
||||||
|
/// blob, and a rename between two names of one file does nothing: the
|
||||||
|
/// temp name stayed until the next start swept it (`docs/BUGS.md` B187).
|
||||||
|
#[test]
|
||||||
|
fn store_file_twice_from_the_same_blob_leaves_no_temp_file() {
|
||||||
|
let (c, d) = cache();
|
||||||
|
let u = uid("a");
|
||||||
|
let src = d.path().join("staged-blob");
|
||||||
|
std::fs::write(&src, b"staged").unwrap();
|
||||||
|
|
||||||
|
c.store_file(&u, 100, 6, &src).unwrap();
|
||||||
|
c.store_file(&u, 100, 6, &src).unwrap();
|
||||||
|
|
||||||
|
assert!(!c.blob_path(&u).with_extension("tmp").exists());
|
||||||
|
assert_eq!(c.read_range(&u, 100, 6, 0, 6).unwrap(), b"staged");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reconcile_rebuilds_index_from_disk() {
|
fn reconcile_rebuilds_index_from_disk() {
|
||||||
let dir = TempDir::new();
|
let dir = TempDir::new();
|
||||||
@@ -2212,42 +2309,26 @@ mod tests {
|
|||||||
/// call also needs.
|
/// call also needs.
|
||||||
#[test]
|
#[test]
|
||||||
fn budget_check_is_free_when_under_budget() {
|
fn budget_check_is_free_when_under_budget() {
|
||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
// Cap far above what we store, so every store stays under budget.
|
// Cap far above what we store, so every store stays under budget.
|
||||||
let (c, _d) = cache_capped(1 << 30);
|
let (c, _d) = cache_capped(1 << 30);
|
||||||
let payload = vec![0u8; 4096];
|
let payload = vec![0u8; 4096];
|
||||||
|
for i in 0..8u64 {
|
||||||
// Cost of a store against a near-empty index...
|
|
||||||
let t = Instant::now();
|
|
||||||
for i in 0..500u64 {
|
|
||||||
c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, i), &payload)
|
c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, i), &payload)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
let empty = t.elapsed() / 500;
|
|
||||||
|
|
||||||
// ...and against one with a few thousand entries in it.
|
// Plant an index row far over the cap behind the running total's back,
|
||||||
for i in 500..2500u64 {
|
// and older than every real block. Any budget check that consults the
|
||||||
c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, i), &payload)
|
// index would see the pool over budget, evict, and reseed the total.
|
||||||
.unwrap();
|
c.db.cache_touch("planted", KIND_BLOCK, 1 << 40, 0).unwrap();
|
||||||
}
|
|
||||||
let t = Instant::now();
|
|
||||||
for i in 2500..3000u64 {
|
|
||||||
c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, i), &payload)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
let full = t.elapsed() / 500;
|
|
||||||
|
|
||||||
println!("B4: store_block under budget — {empty:?} at ~0 entries, {full:?} at 2500");
|
c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, 8), &payload)
|
||||||
// A ratio, not a wall-clock bound: the claim is that the under-budget
|
.unwrap();
|
||||||
// path does not grow with the index, and only a ratio tests that. An
|
|
||||||
// absolute threshold measures the machine's load instead, and this test
|
// Not a timing ratio: that measured the CI runner's load and flaked.
|
||||||
// runs alongside the rest of the suite.
|
assert_eq!(c.block_bytes.load(Ordering::Relaxed), 9 * 4096);
|
||||||
assert!(
|
let entries = c.db.cache_entries_by_kind(KIND_BLOCK).unwrap();
|
||||||
full < empty * 3,
|
assert_eq!(entries.len(), 10, "under-budget store evicted: {entries:?}");
|
||||||
"under-budget store should not scale with the index: \
|
|
||||||
{empty:?} at ~0 entries vs {full:?} at 2500"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The running totals are a fast path, not a second source of truth: after
|
/// The running totals are a fast path, not a second source of truth: after
|
||||||
@@ -2556,6 +2637,46 @@ mod tests {
|
|||||||
assert!(c.cached_block(&u, 100, 5000, blk(5000, 0)).is_none());
|
assert!(c.cached_block(&u, 100, 5000, blk(5000, 0)).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_cached_blob_is_copied_out_whole() {
|
||||||
|
let (c, d) = cache();
|
||||||
|
let u = uid("blob");
|
||||||
|
c.store(&u, 7, 5, b"hello").unwrap();
|
||||||
|
let dst = d.path().join("out");
|
||||||
|
assert!(c.copy_cached_to(&u, 7, 5, &dst));
|
||||||
|
assert_eq!(std::fs::read(&dst).unwrap(), b"hello");
|
||||||
|
// Another revision is not this one.
|
||||||
|
assert!(!c.copy_cached_to(&u, 8, 5, &d.path().join("stale")));
|
||||||
|
assert!(!d.path().join("stale").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cached_blocks_are_copied_out_in_order() {
|
||||||
|
let (c, d) = cache();
|
||||||
|
let u = uid("blocks");
|
||||||
|
let size = BLOCK_SIZE + 3;
|
||||||
|
let first = vec![1_u8; BLOCK_SIZE as usize];
|
||||||
|
c.store_block(&u, 7, size, blk(size, 0), &first).unwrap();
|
||||||
|
c.store_block(&u, 7, size, blk(size, 1), b"end").unwrap();
|
||||||
|
let dst = d.path().join("out");
|
||||||
|
assert!(c.copy_cached_to(&u, 7, size, &dst));
|
||||||
|
let bytes = std::fs::read(&dst).unwrap();
|
||||||
|
assert_eq!(bytes.len() as u64, size);
|
||||||
|
assert_eq!(&bytes[..BLOCK_SIZE as usize], first.as_slice());
|
||||||
|
assert_eq!(&bytes[BLOCK_SIZE as usize..], b"end");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_file_with_a_block_missing_is_not_copied_out() {
|
||||||
|
let (c, d) = cache();
|
||||||
|
let u = uid("partial");
|
||||||
|
let size = BLOCK_SIZE + 3;
|
||||||
|
c.store_block(&u, 7, size, blk(size, 1), b"end").unwrap();
|
||||||
|
let dst = d.path().join("out");
|
||||||
|
assert!(!c.copy_cached_to(&u, 7, size, &dst));
|
||||||
|
assert!(!dst.exists(), "a partial copy must not be left behind");
|
||||||
|
}
|
||||||
|
|
||||||
/// The [`BlockSpan`] the uniform geometry gives block `idx` of a file of
|
/// The [`BlockSpan`] the uniform geometry gives block `idx` of a file of
|
||||||
/// `size` bytes — the shape these tests were written against, and the one
|
/// `size` bytes — the shape these tests were written against, and the one
|
||||||
/// this client's own uploads still have.
|
/// this client's own uploads still have.
|
||||||
@@ -2568,7 +2689,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The geometry the whole read path validates against, here and over the
|
/// The geometry the whole read path validates against, here and over the
|
||||||
/// network (bugs.md B84): full blocks, then whatever is left.
|
/// network (`docs/BUGS.md` B84): full blocks, then whatever is left.
|
||||||
#[test]
|
#[test]
|
||||||
fn block_len_is_full_blocks_then_the_remainder() {
|
fn block_len_is_full_blocks_then_the_remainder() {
|
||||||
let size = BLOCK_SIZE * 2 + 17;
|
let size = BLOCK_SIZE * 2 + 17;
|
||||||
@@ -2648,7 +2769,7 @@ mod tests {
|
|||||||
/// A cached block is addressed by the range it covers, not by its index
|
/// A cached block is addressed by the range it covers, not by its index
|
||||||
/// alone. Re-planning a file onto its real geometry must therefore miss the
|
/// alone. Re-planning a file onto its real geometry must therefore miss the
|
||||||
/// blocks the uniform fallback cached, rather than serve their bytes at the
|
/// blocks the uniform fallback cached, rather than serve their bytes at the
|
||||||
/// wrong offset (bugs.md B85).
|
/// wrong offset (`docs/BUGS.md` B85).
|
||||||
#[test]
|
#[test]
|
||||||
fn a_cached_block_is_not_served_to_a_different_geometry() {
|
fn a_cached_block_is_not_served_to_a_different_geometry() {
|
||||||
let (c, _d) = cache();
|
let (c, _d) = cache();
|
||||||
@@ -2715,7 +2836,7 @@ mod tests {
|
|||||||
|
|
||||||
/// A block of the wrong length cannot escape the cache even though the
|
/// A block of the wrong length cannot escape the cache even though the
|
||||||
/// store does not check: the read validates it and reports a miss, so the
|
/// store does not check: the read validates it and reports a miss, so the
|
||||||
/// next read refetches rather than serving a hole (bugs.md B84).
|
/// next read refetches rather than serving a hole (`docs/BUGS.md` B84).
|
||||||
#[test]
|
#[test]
|
||||||
fn a_block_of_the_wrong_length_reads_as_a_miss() {
|
fn a_block_of_the_wrong_length_reads_as_a_miss() {
|
||||||
let (c, _d) = cache();
|
let (c, _d) = cache();
|
||||||
@@ -2780,7 +2901,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A failed upload must never cost the user their bytes (offline.md Phase 2):
|
/// A failed upload must never cost the user their bytes:
|
||||||
/// the scratch file moves to staging intact, and staging survives a reopen
|
/// the scratch file moves to staging intact, and staging survives a reopen
|
||||||
/// the way scratch deliberately does not.
|
/// the way scratch deliberately does not.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use directories::ProjectDirs;
|
use directories::ProjectDirs;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Deserializer, Serialize};
|
||||||
|
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
use crate::menu::PromptConfig;
|
use crate::menu::PromptConfig;
|
||||||
@@ -12,8 +12,19 @@ use crate::syncignore::DEFAULT_IGNORE_PATTERNS;
|
|||||||
|
|
||||||
/// Proton requires `external-drive-{name}@{semver}-{channel}` (channel ∈
|
/// Proton requires `external-drive-{name}@{semver}-{channel}` (channel ∈
|
||||||
/// stable/beta/alpha); a malformed value trips the 422 anti-abuse path.
|
/// stable/beta/alpha); a malformed value trips the 422 anti-abuse path.
|
||||||
pub const APP_VERSION: &str = "external-drive-linux@0.1.0-alpha";
|
/// Follows the release so Proton can tell releases apart and retire old ones
|
||||||
pub const USER_AGENT: &str = "proton-drive-linux/0.1.0";
|
/// without retiring every one.
|
||||||
|
pub const APP_VERSION: &str = concat!(
|
||||||
|
"external-drive-linux@",
|
||||||
|
env!("CARGO_PKG_VERSION"),
|
||||||
|
"-stable"
|
||||||
|
);
|
||||||
|
pub const USER_AGENT: &str = concat!("proton-drive-linux/", env!("CARGO_PKG_VERSION"));
|
||||||
|
|
||||||
|
/// The fixed identity every release up to 3.1.0 wrote into `config.json`. Read
|
||||||
|
/// back as "unset" so those configs follow the release instead of pinning it.
|
||||||
|
const LEGACY_APP_VERSION: &str = "external-drive-linux@0.1.0-alpha";
|
||||||
|
const LEGACY_USER_AGENT: &str = "proton-drive-linux/0.1.0";
|
||||||
|
|
||||||
/// Keyring service name; one entry per credential kind keyed by username.
|
/// Keyring service name; one entry per credential kind keyed by username.
|
||||||
pub const KEYRING_SERVICE: &str = "proton-drive-linux";
|
pub const KEYRING_SERVICE: &str = "proton-drive-linux";
|
||||||
@@ -76,15 +87,38 @@ fn resolve_sweep_mode(env: Option<&str>, stored: Option<SweepMode>) -> SweepMode
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Configuration structure allowing the user to customize client identification headers.
|
/// Configuration structure allowing the user to customize client identification headers.
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
|
||||||
pub struct AppConfig {
|
pub struct AppConfig {
|
||||||
pub app_version: String,
|
/// Override for [`APP_VERSION`]. `None` means "use the release's own";
|
||||||
pub user_agent: String,
|
/// the old fixed default reads as `None`, so it leaves the file on the
|
||||||
|
/// next save.
|
||||||
|
#[serde(
|
||||||
|
default,
|
||||||
|
deserialize_with = "identity_override",
|
||||||
|
skip_serializing_if = "Option::is_none"
|
||||||
|
)]
|
||||||
|
pub app_version: Option<String>,
|
||||||
|
/// Override for [`USER_AGENT`], handled like `app_version`.
|
||||||
|
#[serde(
|
||||||
|
default,
|
||||||
|
deserialize_with = "identity_override",
|
||||||
|
skip_serializing_if = "Option::is_none"
|
||||||
|
)]
|
||||||
|
pub user_agent: Option<String>,
|
||||||
/// Soft cap on the on-disk content cache, in bytes (`0` = unlimited).
|
/// Soft cap on the on-disk content cache, in bytes (`0` = unlimited).
|
||||||
/// `None` means "use [`DEFAULT_CACHE_BUDGET_BYTES`]"; the Settings page
|
/// `None` means "use [`DEFAULT_CACHE_BUDGET_BYTES`]"; the Settings page
|
||||||
/// writes an explicit value here. Defaulted for configs predating the field.
|
/// writes an explicit value here. Defaulted for configs predating the field.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub cache_budget: Option<u64>,
|
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
|
/// Mountpoint the daemon mounts at. `None` means
|
||||||
/// [`AppDirs::default_mountpoint`]; the Settings page writes an explicit
|
/// [`AppDirs::default_mountpoint`]; the Settings page writes an explicit
|
||||||
/// path here. Defaulted for configs predating the field.
|
/// path here. Defaulted for configs predating the field.
|
||||||
@@ -119,25 +153,116 @@ pub struct AppConfig {
|
|||||||
/// field.
|
/// field.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub prompt: Option<PromptConfig>,
|
pub prompt: Option<PromptConfig>,
|
||||||
|
/// Paint the desktop app in Proton's colours instead of the system theme
|
||||||
|
/// and accent colour. `None` means "follow the system". Defaulted for
|
||||||
|
/// configs predating the field; also read from `proton_accent`, its name
|
||||||
|
/// while it only changed the accent.
|
||||||
|
#[serde(default, alias = "proton_accent")]
|
||||||
|
pub proton_theme: Option<bool>,
|
||||||
|
/// How the desktop app's My Files page lays out and orders a folder.
|
||||||
|
/// Defaulted for configs predating the field.
|
||||||
|
#[serde(default)]
|
||||||
|
pub files_view: FilesView,
|
||||||
|
/// The user hid the tray icon from its own menu. The tray exits at start
|
||||||
|
/// while this is set, and the app does not spawn it; Preferences turns it
|
||||||
|
/// back on. Defaulted for configs predating the field.
|
||||||
|
#[serde(default)]
|
||||||
|
pub tray_hidden: bool,
|
||||||
|
/// Language for the desktop app, tray and search prompt, as a gettext code
|
||||||
|
/// such as `de` or `pt_BR`. `None` means "follow the system locale".
|
||||||
|
/// Defaulted for configs predating the field.
|
||||||
|
#[serde(default)]
|
||||||
|
pub language: Option<String>,
|
||||||
|
/// Draw the Places map from OpenFreeMap's street map instead of the
|
||||||
|
/// built-in outline. Off by default: the tiles come from a third party,
|
||||||
|
/// which then learns which parts of the world are looked at. Defaulted for
|
||||||
|
/// configs predating the field.
|
||||||
|
#[serde(default)]
|
||||||
|
pub online_map: bool,
|
||||||
|
/// Record a new folder, a new file, a rename or a delete through the mount
|
||||||
|
/// locally and send it from the queue, instead of waiting for Drive in the
|
||||||
|
/// syscall. `None` means on. `false` brings back the old path for one
|
||||||
|
/// release (docs/ROADMAP.md). Defaulted for configs predating
|
||||||
|
/// the field.
|
||||||
|
#[serde(default)]
|
||||||
|
pub local_first: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppConfig {
|
/// The My Files page's view choices, remembered between runs.
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct FilesView {
|
||||||
|
/// Columns instead of the icon grid.
|
||||||
|
pub list: bool,
|
||||||
|
pub sort: FileSort,
|
||||||
|
pub descending: bool,
|
||||||
|
/// Folders ahead of files, whatever the sort.
|
||||||
|
pub folders_first: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for FilesView {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
app_version: APP_VERSION.to_string(),
|
list: false,
|
||||||
user_agent: USER_AGENT.to_string(),
|
sort: FileSort::Name,
|
||||||
cache_budget: None,
|
descending: false,
|
||||||
mountpoint: None,
|
folders_first: true,
|
||||||
ignore_patterns: None,
|
|
||||||
device_uid: None,
|
|
||||||
conflict_sweep: None,
|
|
||||||
open_with: None,
|
|
||||||
prompt: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
impl AppConfig {
|
||||||
|
/// The `x-pm-appversion` sent to Proton: the user's override, or
|
||||||
|
/// [`APP_VERSION`].
|
||||||
|
pub fn resolved_app_version(&self) -> &str {
|
||||||
|
self.app_version.as_deref().unwrap_or(APP_VERSION)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `User-Agent` sent to Proton: the user's override, or
|
||||||
|
/// [`USER_AGENT`].
|
||||||
|
pub fn resolved_user_agent(&self) -> &str {
|
||||||
|
self.user_agent.as_deref().unwrap_or(USER_AGENT)
|
||||||
|
}
|
||||||
|
|
||||||
/// The effective cache budget in bytes: the user's explicit choice, or
|
/// The effective cache budget in bytes: the user's explicit choice, or
|
||||||
/// [`DEFAULT_CACHE_BUDGET_BYTES`] when unset.
|
/// [`DEFAULT_CACHE_BUDGET_BYTES`] when unset.
|
||||||
pub fn resolved_cache_budget(&self) -> u64 {
|
pub fn resolved_cache_budget(&self) -> u64 {
|
||||||
@@ -329,7 +454,7 @@ impl AppDirs {
|
|||||||
/// typical umask makes them `0755`. Until this was fixed, the only thing
|
/// typical umask makes them `0755`. Until this was fixed, the only thing
|
||||||
/// keeping another local user out was `~/.cache` and `~/.local/state`
|
/// keeping another local user out was `~/.cache` and `~/.local/state`
|
||||||
/// happening to be `0700`, which is a convention of the user's system and
|
/// happening to be `0700`, which is a convention of the user's system and
|
||||||
/// not something this client established (bugs.md B6).
|
/// not something this client established (`docs/BUGS.md` B6).
|
||||||
///
|
///
|
||||||
/// Applied on every start, not just at creation: a directory that already
|
/// Applied on every start, not just at creation: a directory that already
|
||||||
/// exists with a permissive mode — restored from a backup that flattened
|
/// exists with a permissive mode — restored from a backup that flattened
|
||||||
@@ -380,7 +505,7 @@ fn restrict_dir(dir: &Path) -> Result<()> {
|
|||||||
/// session — enumerate, read, upload, trash, share — without presenting any
|
/// session — enumerate, read, upload, trash, share — without presenting any
|
||||||
/// credential, since the daemon already holds one. It is an authority boundary,
|
/// credential, since the daemon already holds one. It is an authority boundary,
|
||||||
/// not merely private data, and `UnixListener::bind` applies the umask like any
|
/// not merely private data, and `UnixListener::bind` applies the umask like any
|
||||||
/// other file (bugs.md B6).
|
/// other file (`docs/BUGS.md` B6).
|
||||||
///
|
///
|
||||||
/// There is a window between `bind` and this call during which the socket
|
/// There is a window between `bind` and this call during which the socket
|
||||||
/// carries the umask's mode. It is closed in practice by the containing
|
/// carries the umask's mode. It is closed in practice by the containing
|
||||||
@@ -401,7 +526,7 @@ mod tests {
|
|||||||
std::fs::metadata(p).unwrap().permissions().mode() & 0o777
|
std::fs::metadata(p).unwrap().permissions().mode() & 0o777
|
||||||
}
|
}
|
||||||
|
|
||||||
/// bugs.md B6. A permissive mode here is not a cosmetic problem: these
|
/// `docs/BUGS.md` B6. A permissive mode here is not a cosmetic problem: these
|
||||||
/// directories hold decrypted content, a plaintext index of every node name,
|
/// directories hold decrypted content, a plaintext index of every node name,
|
||||||
/// and the control socket that commands the daemon's session.
|
/// and the control socket that commands the daemon's session.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -468,27 +593,115 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_app_config_serialization() {
|
fn test_app_config_serialization() {
|
||||||
let config = AppConfig {
|
let config = AppConfig {
|
||||||
app_version: "external-drive-test-client@1.0.0".to_string(),
|
app_version: Some("external-drive-test-client@1.0.0".to_string()),
|
||||||
user_agent: "test-agent/1.0".to_string(),
|
user_agent: Some("test-agent/1.0".to_string()),
|
||||||
cache_budget: Some(1234),
|
cache_budget: Some(1234),
|
||||||
|
upload_limit: Some(500_000),
|
||||||
|
download_limit: None,
|
||||||
mountpoint: Some("/tmp/x".to_string()),
|
mountpoint: Some("/tmp/x".to_string()),
|
||||||
ignore_patterns: Some(vec!["build/".to_string()]),
|
ignore_patterns: Some(vec!["build/".to_string()]),
|
||||||
device_uid: Some("dev-uid".to_string()),
|
device_uid: Some("dev-uid".to_string()),
|
||||||
conflict_sweep: Some(SweepMode::Enforce),
|
conflict_sweep: Some(SweepMode::Enforce),
|
||||||
open_with: None,
|
open_with: None,
|
||||||
prompt: 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 json = serde_json::to_string(&config).unwrap();
|
||||||
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
|
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
|
||||||
assert_eq!(decoded.app_version, "external-drive-test-client@1.0.0");
|
assert_eq!(
|
||||||
assert_eq!(decoded.user_agent, "test-agent/1.0");
|
decoded.resolved_app_version(),
|
||||||
|
"external-drive-test-client@1.0.0"
|
||||||
|
);
|
||||||
|
assert_eq!(decoded.resolved_user_agent(), "test-agent/1.0");
|
||||||
|
assert_eq!(decoded.proton_theme, Some(true));
|
||||||
|
assert!(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]
|
#[test]
|
||||||
fn test_default_app_config() {
|
fn test_default_app_config() {
|
||||||
let default_config = AppConfig::default();
|
let default_config = AppConfig::default();
|
||||||
assert_eq!(default_config.app_version, APP_VERSION);
|
assert_eq!(default_config.resolved_app_version(), APP_VERSION);
|
||||||
assert_eq!(default_config.user_agent, USER_AGENT);
|
assert_eq!(default_config.resolved_user_agent(), USER_AGENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_client_identity_follows_the_release() {
|
||||||
|
let version = env!("CARGO_PKG_VERSION");
|
||||||
|
assert_eq!(
|
||||||
|
APP_VERSION,
|
||||||
|
format!("external-drive-linux@{version}-stable")
|
||||||
|
);
|
||||||
|
assert_eq!(USER_AGENT, format!("proton-drive-linux/{version}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_old_config_with_the_fixed_identity_follows_the_release() {
|
||||||
|
// Every config written up to 3.1.0 holds these two values, so they must
|
||||||
|
// not pin the identity once Proton retires 0.1.0.
|
||||||
|
let json = format!(
|
||||||
|
r#"{{"app_version":"{LEGACY_APP_VERSION}","user_agent":"{LEGACY_USER_AGENT}","tray_hidden":true}}"#
|
||||||
|
);
|
||||||
|
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(decoded.resolved_app_version(), APP_VERSION);
|
||||||
|
assert_eq!(decoded.resolved_user_agent(), USER_AGENT);
|
||||||
|
assert!(decoded.tray_hidden, "the rest of the config survives");
|
||||||
|
|
||||||
|
let saved = serde_json::to_string(&decoded).unwrap();
|
||||||
|
assert!(!saved.contains("app_version"), "{saved}");
|
||||||
|
assert!(!saved.contains("user_agent"), "{saved}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_hand_set_client_identity_is_kept() {
|
||||||
|
let json = r#"{"app_version":"external-drive-linux@9.9.9-beta","user_agent":"custom/1"}"#;
|
||||||
|
let decoded: AppConfig = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
decoded.resolved_app_version(),
|
||||||
|
"external-drive-linux@9.9.9-beta"
|
||||||
|
);
|
||||||
|
assert_eq!(decoded.resolved_user_agent(), "custom/1");
|
||||||
|
let saved = serde_json::to_string(&decoded).unwrap();
|
||||||
|
assert!(saved.contains("external-drive-linux@9.9.9-beta"), "{saved}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_config_without_a_client_identity_loads() {
|
||||||
|
let decoded: AppConfig = serde_json::from_str("{}").unwrap();
|
||||||
|
assert_eq!(decoded.resolved_app_version(), APP_VERSION);
|
||||||
|
assert_eq!(decoded.resolved_user_agent(), USER_AGENT);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -554,6 +767,11 @@ mod tests {
|
|||||||
let json = r#"{"app_version":"v","user_agent":"u"}"#;
|
let json = r#"{"app_version":"v","user_agent":"u"}"#;
|
||||||
let decoded: AppConfig = serde_json::from_str(json).unwrap();
|
let decoded: AppConfig = serde_json::from_str(json).unwrap();
|
||||||
assert!(decoded.ignore_patterns.is_none());
|
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!(
|
assert_eq!(
|
||||||
decoded.resolved_ignore_patterns().len(),
|
decoded.resolved_ignore_patterns().len(),
|
||||||
DEFAULT_IGNORE_PATTERNS.len(),
|
DEFAULT_IGNORE_PATTERNS.len(),
|
||||||
|
|||||||
@@ -29,6 +29,17 @@ impl Db {
|
|||||||
Ok(())
|
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
|
/// The most recent activity, newest first, capped at `limit` entries. Rows
|
||||||
/// whose stored `kind` no longer parses (written by an older build) are
|
/// whose stored `kind` no longer parses (written by an older build) are
|
||||||
/// skipped rather than failing the whole read.
|
/// skipped rather than failing the whole read.
|
||||||
|
|||||||
@@ -178,10 +178,14 @@ impl Db {
|
|||||||
ratio: r.get(3)?,
|
ratio: r.get(3)?,
|
||||||
thumb_state: r.get(4)?,
|
thumb_state: r.get(4)?,
|
||||||
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
||||||
// Album rows have no favourite state of their own: an album
|
// Album rows have no favorite state of their own: an album
|
||||||
// photo on someone else's volume is not in our timeline, and
|
// photo on someone else's volume is not in our timeline, and
|
||||||
// favouriting one is not supported yet anyway.
|
// favouriting one is not supported yet anyway.
|
||||||
favorite: false,
|
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<_>>()?;
|
.collect::<rusqlite::Result<_>>()?;
|
||||||
@@ -235,10 +239,14 @@ impl Db {
|
|||||||
ratio: r.get(3)?,
|
ratio: r.get(3)?,
|
||||||
thumb_state: r.get(4)?,
|
thumb_state: r.get(4)?,
|
||||||
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
||||||
// Album rows have no favourite state of their own: an album
|
// Album rows have no favorite state of their own: an album
|
||||||
// photo on someone else's volume is not in our timeline, and
|
// photo on someone else's volume is not in our timeline, and
|
||||||
// favouriting one is not supported yet anyway.
|
// favouriting one is not supported yet anyway.
|
||||||
favorite: false,
|
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<_>>()?;
|
.collect::<rusqlite::Result<_>>()?;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
//! Database health reporting and repair (features.md 4.3).
|
//! Database health reporting and repair.
|
||||||
//!
|
//!
|
||||||
//! Everything here exists for the case where something has already gone wrong:
|
//! Everything here exists for the case where something has already gone wrong:
|
||||||
//! a crash mid-write, a full disk, a cache that has grown out of proportion to
|
//! a crash mid-write, a full disk, a cache that has grown out of proportion to
|
||||||
|
|||||||
@@ -5,11 +5,13 @@
|
|||||||
//! is already on disk somewhere, so a change here would silently diverge from
|
//! is already on disk somewhere, so a change here would silently diverge from
|
||||||
//! what an existing install actually has. Add a new one instead.
|
//! what an existing install actually has. Add a new one instead.
|
||||||
|
|
||||||
|
use rusqlite::OptionalExtension;
|
||||||
|
|
||||||
use super::Db;
|
use super::Db;
|
||||||
use crate::Result;
|
use crate::Result;
|
||||||
|
|
||||||
/// Current schema version. Bump on every forward migration added below.
|
/// Current schema version. Bump on every forward migration added below.
|
||||||
pub(super) const SCHEMA_VERSION: i64 = 28;
|
pub(super) const SCHEMA_VERSION: i64 = 42;
|
||||||
|
|
||||||
impl Db {
|
impl Db {
|
||||||
pub(super) fn migrate(&self) -> Result<()> {
|
pub(super) fn migrate(&self) -> Result<()> {
|
||||||
@@ -218,6 +220,226 @@ impl Db {
|
|||||||
tx.execute_batch(MIGRATION_V28)?;
|
tx.execute_batch(MIGRATION_V28)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if current < 29 {
|
||||||
|
// Same guards as V26/V27/V28: an old fixture may not have `trash`,
|
||||||
|
// and a fixture rewound from the current schema already has the
|
||||||
|
// column.
|
||||||
|
let has_column: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('trash') WHERE name = 'parent_uid'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
let has_trash: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'trash'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_trash && !has_column {
|
||||||
|
tx.execute_batch(MIGRATION_V29)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current < 30 {
|
||||||
|
// Same guards as V26-V29: the column may already be there on a
|
||||||
|
// fixture rewound from the current schema, and a very old fixture
|
||||||
|
// may not have `photos` at all.
|
||||||
|
let has_column: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('photos') WHERE name = 'group_key'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
let has_photos: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'photos'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_photos && !has_column {
|
||||||
|
tx.execute_batch(MIGRATION_V30)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current < 31 {
|
||||||
|
// Same guards as V26-V30.
|
||||||
|
let has_column: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('photos') WHERE name = 'resolved_at'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
let has_photos: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'photos'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_photos && !has_column {
|
||||||
|
tx.execute_batch(MIGRATION_V31)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current < 32 {
|
||||||
|
// Data only, so safe to repeat; each table is guarded for a very
|
||||||
|
// old fixture that never had it.
|
||||||
|
for table in ["photos", "album_photos"] {
|
||||||
|
let has_table: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1",
|
||||||
|
[table],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_table {
|
||||||
|
tx.execute(&MIGRATION_V32.replace("{table}", table), [])?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current < 33 {
|
||||||
|
// Same guards as V26-V31.
|
||||||
|
let has_column: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('photos') WHERE name = 'place_id'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
let has_photos: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'photos'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_photos && !has_column {
|
||||||
|
tx.execute_batch(MIGRATION_V33)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current < 34 {
|
||||||
|
// Same guards as V26-V33.
|
||||||
|
let has_column: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('photos') WHERE name = 'similar_hash'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
let has_photos: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'photos'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_photos && !has_column {
|
||||||
|
tx.execute_batch(MIGRATION_V34)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current < 35 {
|
||||||
|
// Same guards as V26-V34.
|
||||||
|
let has_column: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('trash') WHERE name = 'trashed_at'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
let has_trash: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'trash'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_trash && !has_column {
|
||||||
|
tx.execute_batch(MIGRATION_V35)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current < 36 {
|
||||||
|
// Same guards as V26-V35.
|
||||||
|
let has_column: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('nodes') WHERE name = 'lid'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
let has_nodes: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'nodes'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_nodes && !has_column {
|
||||||
|
tx.execute_batch(MIGRATION_V36)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current < 37 {
|
||||||
|
// Same guards as V26-V36.
|
||||||
|
let sql: Option<String> = tx
|
||||||
|
.query_row(
|
||||||
|
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'pending_op'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
if sql.is_some_and(|sql| !sql.contains("AUTOINCREMENT")) {
|
||||||
|
tx.execute_batch(MIGRATION_V37)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current < 38 {
|
||||||
|
// Same guards as V26-V37.
|
||||||
|
let has_column: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('pending_op') WHERE name = 'issue'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
let has_ops: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'pending_op'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_ops && !has_column {
|
||||||
|
tx.execute_batch(MIGRATION_V38)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current < 39 {
|
||||||
|
// Same guards as V26-V38.
|
||||||
|
let has_column: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('pending_op') WHERE name = 'lid'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
let has_ops: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'pending_op'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_ops && !has_column {
|
||||||
|
tx.execute_batch(MIGRATION_V39)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current < 40 {
|
||||||
|
// Same guards as V26-V39.
|
||||||
|
let has_column: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('nodes') WHERE name = 'parent_lid'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
let has_nodes: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'nodes'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_nodes && !has_column {
|
||||||
|
tx.execute_batch(MIGRATION_V40)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current < 41 {
|
||||||
|
// Needs both tables; on a database that never had them there is
|
||||||
|
// nothing to rewrite.
|
||||||
|
let tables: i64 = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master
|
||||||
|
WHERE type = 'table' AND name IN ('nodes', 'pending_op', 'pins')",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)?;
|
||||||
|
if tables == 3 {
|
||||||
|
tx.execute_batch(MIGRATION_V41)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current < 42 {
|
||||||
|
// Same guards as V26-V40.
|
||||||
|
let has_column: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('pending_op') WHERE name = 'sent_sha1'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
let has_ops: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'pending_op'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_ops && !has_column {
|
||||||
|
tx.execute_batch(MIGRATION_V42)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
||||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||||
@@ -355,7 +577,7 @@ CREATE TABLE trash (
|
|||||||
);
|
);
|
||||||
";
|
";
|
||||||
|
|
||||||
/// Schema v8: device sync (devices.md). This machine registers as one Proton Drive
|
/// Schema v8: device sync. This machine registers as one Proton Drive
|
||||||
/// **Device** (a share + root folder on the main volume); `device` is a singleton
|
/// **Device** (a share + root folder on the main volume); `device` is a singleton
|
||||||
/// row cached so we reuse the same device across restarts instead of creating a
|
/// row cached so we reuse the same device across restarts instead of creating a
|
||||||
/// new one each run. `sync_folder` is one row per local folder the user added,
|
/// new one each run. `sync_folder` is one row per local folder the user added,
|
||||||
@@ -416,8 +638,8 @@ ALTER TABLE sync_folder ADD COLUMN pending_mode TEXT;
|
|||||||
";
|
";
|
||||||
|
|
||||||
/// Schema v11: writes no longer upload inside the FUSE handler. `release` stages
|
/// Schema v11: writes no longer upload inside the FUSE handler. `release` stages
|
||||||
/// the bytes and records the intended upload here, and a drain worker performs it
|
/// the bytes and records the intended upload here, and a drain worker performs it.
|
||||||
/// (offline.md Phase 3). The row outlives the process, so a write survives both a
|
/// The row outlives the process, so a write survives both a
|
||||||
/// dead network and a restart.
|
/// dead network and a restart.
|
||||||
///
|
///
|
||||||
/// `blob_path` points into the content cache's `staging/` dir and `meta_json` is
|
/// `blob_path` points into the content cache's `staging/` dir and `meta_json` is
|
||||||
@@ -444,7 +666,7 @@ CREATE INDEX pending_op_uid ON pending_op(uid);
|
|||||||
|
|
||||||
/// Schema v12: `pending_op` also carries mutations that *create* a node, which a
|
/// Schema v12: `pending_op` also carries mutations that *create* a node, which a
|
||||||
/// revision op never had to describe — it always addressed a node the server had
|
/// revision op never had to describe — it always addressed a node the server had
|
||||||
/// already minted a uid for (offline.md Phase 3b).
|
/// already minted a uid for.
|
||||||
///
|
///
|
||||||
/// An offline `create`/`mkdir` cannot get a real uid, so the node is invented
|
/// An offline `create`/`mkdir` cannot get a real uid, so the node is invented
|
||||||
/// locally under a `local~<uuid>` placeholder and the op records where it goes
|
/// locally under a `local~<uuid>` placeholder and the op records where it goes
|
||||||
@@ -632,7 +854,7 @@ 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_album_photos_uid ON album_photos(uid);
|
||||||
";
|
";
|
||||||
|
|
||||||
/// V20: whether a photo is a favourite.
|
/// V20: whether a photo is a favorite.
|
||||||
///
|
///
|
||||||
/// A column rather than a tag table: `Favorite` is the only Proton photo tag a
|
/// A column rather than a tag table: `Favorite` is the only Proton photo tag a
|
||||||
/// user sets by hand (the rest — Video, Screenshot, Selfie, Raw… — are the
|
/// user sets by hand (the rest — Video, Screenshot, Selfie, Raw… — are the
|
||||||
@@ -834,7 +1056,7 @@ ALTER TABLE pending_op ADD COLUMN access_deferred_since INTEGER NOT NULL DEFAULT
|
|||||||
/// as the last sync and left the file the same length — a fixed-size record
|
/// as the last sync and left the file the same length — a fixed-size record
|
||||||
/// rewritten, an image re-exported, a database page updated in place — read as
|
/// rewritten, an image re-exported, a database page updated in place — read as
|
||||||
/// *unchanged* and was never uploaded. The remote copy silently stayed at the
|
/// *unchanged* and was never uploaded. The remote copy silently stayed at the
|
||||||
/// older content until something else about the file moved (bugs.md B25).
|
/// older content until something else about the file moved (`docs/BUGS.md` B25).
|
||||||
///
|
///
|
||||||
/// Nullable rather than a converted `local_mtime * 1_000_000_000`: the
|
/// Nullable rather than a converted `local_mtime * 1_000_000_000`: the
|
||||||
/// nanosecond part of an existing baseline is genuinely unknown, and inventing
|
/// nanosecond part of an existing baseline is genuinely unknown, and inventing
|
||||||
@@ -846,3 +1068,265 @@ ALTER TABLE pending_op ADD COLUMN access_deferred_since INTEGER NOT NULL DEFAULT
|
|||||||
const MIGRATION_V28: &str = "
|
const MIGRATION_V28: &str = "
|
||||||
ALTER TABLE sync_entry ADD COLUMN local_mtime_ns INTEGER;
|
ALTER TABLE sync_entry ADD COLUMN local_mtime_ns INTEGER;
|
||||||
";
|
";
|
||||||
|
|
||||||
|
/// Schema v29: the trash listing remembers each trashed node's parent, so a
|
||||||
|
/// restore can reason about the shape of what it is putting back.
|
||||||
|
///
|
||||||
|
/// Trash is a flat list of uids on the wire, but the user deletes a *tree*: a
|
||||||
|
/// folder and, often, items inside it that were trashed separately. Restoring
|
||||||
|
/// one of those in isolation is what the parent link fixes — a child restored
|
||||||
|
/// under a still-trashed parent lands somewhere invisible, and a folder restored
|
||||||
|
/// without its separately-trashed contents comes back empty.
|
||||||
|
///
|
||||||
|
/// `NULL` means "this row predates the column" (or the node is a volume root);
|
||||||
|
/// the next trash refresh fills it in, and until then the restore behaves as it
|
||||||
|
/// did before.
|
||||||
|
const MIGRATION_V29: &str = "
|
||||||
|
ALTER TABLE trash ADD COLUMN parent_uid TEXT;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_trash_parent ON trash(parent_uid);
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Schema v30: a RAW and the JPEG of the same shot are one photo to the person
|
||||||
|
/// who took it, so the timeline records what ties them together.
|
||||||
|
///
|
||||||
|
/// `content_hash` and `main_uid` come from the server's own photo properties —
|
||||||
|
/// the duplicate-detection hash, and the main photo a related one belongs to.
|
||||||
|
/// `group_key` is what the gallery pages by: every member of one shot carries
|
||||||
|
/// the same key, and the member the grid shows is the one whose `main_uid` is
|
||||||
|
/// its own uid.
|
||||||
|
///
|
||||||
|
/// All three are `NULL` until the next timeline refresh resolves the photo's
|
||||||
|
/// node, and a row without a `group_key` is its own group — which is exactly how
|
||||||
|
/// the gallery behaved before this column existed.
|
||||||
|
const MIGRATION_V30: &str = "
|
||||||
|
ALTER TABLE photos ADD COLUMN content_hash TEXT;
|
||||||
|
ALTER TABLE photos ADD COLUMN main_uid TEXT;
|
||||||
|
ALTER TABLE photos ADD COLUMN group_key TEXT;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_photos_group ON photos(group_key);
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Schema v31: which photos a refresh still has to read from the server.
|
||||||
|
///
|
||||||
|
/// A refresh used to resolve every photo's node to learn its name, media type,
|
||||||
|
/// favorite tag, content hash and photo relation — minutes of round-trips for a
|
||||||
|
/// library of any size, repeated every time the timeline went stale. `resolved_at`
|
||||||
|
/// records that the work was already done, so a refresh only reads the photos it
|
||||||
|
/// has never read and the ones a remote event marked stale (`resolved_at = NULL`).
|
||||||
|
///
|
||||||
|
/// The backfill treats an existing timeline as resolved, so an upgrade does not
|
||||||
|
/// re-read the whole library once. `WHERE name IS NOT NULL` keeps the photos
|
||||||
|
/// whose earlier resolve had failed eligible: a nameless row is one nothing was
|
||||||
|
/// ever learned about.
|
||||||
|
const MIGRATION_V31: &str = "
|
||||||
|
ALTER TABLE photos ADD COLUMN resolved_at INTEGER;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_photos_unresolved ON photos(resolved_at);
|
||||||
|
UPDATE photos SET resolved_at = strftime('%s','now') WHERE name IS NOT NULL;
|
||||||
|
";
|
||||||
|
|
||||||
|
/// V32: give videos written off as un-thumbnailable another chance.
|
||||||
|
///
|
||||||
|
/// Before local video thumbnails, the fallback fed a video's bytes to the image
|
||||||
|
/// decoder, which could only fail, and that failure was recorded as the
|
||||||
|
/// permanent "no thumbnail" verdict. Run once per table in `{table}`.
|
||||||
|
const MIGRATION_V32: &str = "UPDATE {table} SET thumb_state = 0 WHERE kind = 1 AND thumb_state = 2";
|
||||||
|
|
||||||
|
/// Schema v33: where each photo was taken.
|
||||||
|
///
|
||||||
|
/// `latitude` and `longitude` come from the location other clients write into
|
||||||
|
/// a photo's extended attributes; `place_id` is the GeoNames id of the nearest
|
||||||
|
/// town (see `crate::places`), so the Places view is a plain `GROUP BY`.
|
||||||
|
///
|
||||||
|
/// Every photo is marked unresolved, so the next refresh reads the location of
|
||||||
|
/// the whole library once. That is the backfill: the metadata is on the node
|
||||||
|
/// the refresh reads anyway, and nothing has to be downloaded.
|
||||||
|
const MIGRATION_V33: &str = "
|
||||||
|
ALTER TABLE photos ADD COLUMN latitude REAL;
|
||||||
|
ALTER TABLE photos ADD COLUMN longitude REAL;
|
||||||
|
ALTER TABLE photos ADD COLUMN place_id INTEGER;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_photos_place ON photos(place_id);
|
||||||
|
UPDATE photos SET resolved_at = NULL;
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Schema v34: what each photo looks like, for finding similar ones.
|
||||||
|
///
|
||||||
|
/// `similar_hash` is a 64-bit difference hash of the photo's thumbnail (see
|
||||||
|
/// `crate::similar`): resized and re-encoded copies of one picture land a few
|
||||||
|
/// bits apart, where the server's `content_hash` only matches byte-identical
|
||||||
|
/// files. `NULL` until the daemon has hashed the thumbnail, which it does as
|
||||||
|
/// thumbnails arrive and when the similar-photo finder asks for the rest.
|
||||||
|
const MIGRATION_V34: &str = "
|
||||||
|
ALTER TABLE photos ADD COLUMN similar_hash INTEGER;
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Schema v35: when each trashed node went to the trash.
|
||||||
|
///
|
||||||
|
/// The API does not tell this client, so `trashed_at` is when the daemon first
|
||||||
|
/// saw the node in the trash, in epoch seconds. `NULL` when it cannot say: rows
|
||||||
|
/// from before this version, and nodes found by a refresh that did not follow a
|
||||||
|
/// complete one, which may have been in the trash for a long time already.
|
||||||
|
const MIGRATION_V35: &str = "
|
||||||
|
ALTER TABLE trash ADD COLUMN trashed_at INTEGER;
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Schema v36: every node row has a local id (`lid`); see
|
||||||
|
/// docs/ARCHITECTURE.md §2.
|
||||||
|
///
|
||||||
|
/// The `uid` was the row's key, so a node had no identity until Drive minted
|
||||||
|
/// one, and a create that landed moved its row to a new key. The `lid` is given
|
||||||
|
/// when the row is first written and never changes or comes back, which is what
|
||||||
|
/// `AUTOINCREMENT` adds over a plain rowid. It takes the old rowid, so the
|
||||||
|
/// search index, keyed by rowid since v15, stays valid. The `uid` becomes
|
||||||
|
/// nullable for a node that is not on Drive yet.
|
||||||
|
const MIGRATION_V36: &str = "
|
||||||
|
CREATE TABLE nodes_v36 (
|
||||||
|
lid INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
uid TEXT UNIQUE,
|
||||||
|
parent_uid TEXT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
is_dir INTEGER NOT NULL,
|
||||||
|
size INTEGER,
|
||||||
|
mtime INTEGER NOT NULL,
|
||||||
|
revision_hash TEXT,
|
||||||
|
trashed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
node_json TEXT,
|
||||||
|
listed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
path TEXT
|
||||||
|
);
|
||||||
|
INSERT INTO nodes_v36
|
||||||
|
(lid, uid, parent_uid, name, is_dir, size, mtime, revision_hash, trashed, node_json, listed,
|
||||||
|
path)
|
||||||
|
SELECT rowid, uid, parent_uid, name, is_dir, size, mtime, revision_hash, trashed, node_json,
|
||||||
|
listed, path
|
||||||
|
FROM nodes;
|
||||||
|
DROP TABLE nodes;
|
||||||
|
ALTER TABLE nodes_v36 RENAME TO nodes;
|
||||||
|
CREATE INDEX idx_nodes_parent ON nodes(parent_uid);
|
||||||
|
CREATE INDEX idx_nodes_name_nocase ON nodes(name COLLATE NOCASE);
|
||||||
|
CREATE INDEX idx_nodes_parent_live ON nodes(parent_uid) WHERE trashed = 0;
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Schema v37: a `pending_op` id is never given out twice.
|
||||||
|
///
|
||||||
|
/// A plain rowid is the largest one plus one, so an op queued right after the
|
||||||
|
/// newest one was dropped took its id. The drain retires the op it is
|
||||||
|
/// working on by id, and so deleted the new op: a trash queued over a rename
|
||||||
|
/// on the wire was lost when the rename landed. `AUTOINCREMENT` never reuses
|
||||||
|
/// an id. Ids are kept, as is every index.
|
||||||
|
const MIGRATION_V37: &str = "
|
||||||
|
CREATE TABLE pending_op_v37 (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
uid TEXT NOT NULL,
|
||||||
|
blob_path TEXT,
|
||||||
|
meta_json TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_error TEXT,
|
||||||
|
next_attempt_at INTEGER NOT NULL DEFAULT 0,
|
||||||
|
parent_uid TEXT,
|
||||||
|
name TEXT,
|
||||||
|
claimed_at INTEGER NOT NULL DEFAULT 0,
|
||||||
|
access_deferred_since INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
INSERT INTO pending_op_v37
|
||||||
|
(id, kind, uid, blob_path, meta_json, created_at, attempts, last_error, next_attempt_at,
|
||||||
|
parent_uid, name, claimed_at, access_deferred_since)
|
||||||
|
SELECT id, kind, uid, blob_path, meta_json, created_at, attempts, last_error,
|
||||||
|
next_attempt_at, parent_uid, name, claimed_at, access_deferred_since
|
||||||
|
FROM pending_op;
|
||||||
|
DROP TABLE pending_op;
|
||||||
|
ALTER TABLE pending_op_v37 RENAME TO pending_op;
|
||||||
|
CREATE INDEX pending_op_uid ON pending_op(uid);
|
||||||
|
CREATE INDEX pending_op_parent ON pending_op(parent_uid);
|
||||||
|
CREATE INDEX idx_pending_op_claimed
|
||||||
|
ON pending_op(uid) WHERE claimed_at <> 0;
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Schema v38: why Drive refused a queued op, when it did (a sync issue).
|
||||||
|
/// `NULL` for an op that has not been refused, which is every op migrated.
|
||||||
|
const MIGRATION_V38: &str = "
|
||||||
|
ALTER TABLE pending_op ADD COLUMN issue TEXT;
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Schema v39: a queued op names its node and its parent by local id too.
|
||||||
|
///
|
||||||
|
/// A create made inside a folder that had not landed named the folder by its
|
||||||
|
/// placeholder uid, and every landing had to find and rewrite those strings.
|
||||||
|
/// The local id stays the same when the folder lands. An op whose node has no
|
||||||
|
/// row keeps `NULL` and is matched by uid, as before.
|
||||||
|
const MIGRATION_V39: &str = "
|
||||||
|
ALTER TABLE pending_op ADD COLUMN lid INTEGER;
|
||||||
|
ALTER TABLE pending_op ADD COLUMN parent_lid INTEGER;
|
||||||
|
UPDATE pending_op
|
||||||
|
SET lid = (SELECT lid FROM nodes WHERE nodes.uid = pending_op.uid),
|
||||||
|
parent_lid = (SELECT lid FROM nodes WHERE nodes.uid = pending_op.parent_uid);
|
||||||
|
CREATE INDEX pending_op_lid ON pending_op(lid);
|
||||||
|
CREATE INDEX pending_op_parent_lid ON pending_op(parent_lid);
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Schema v40: a node row names its parent by local id too.
|
||||||
|
///
|
||||||
|
/// The local id stays when a folder made here lands and its row takes the uid
|
||||||
|
/// Drive gave it, so a child linked by it needs no rewrite. `NULL` while the
|
||||||
|
/// parent has no row, as `parent_uid` then names nothing either.
|
||||||
|
const MIGRATION_V40: &str = "
|
||||||
|
ALTER TABLE nodes ADD COLUMN parent_lid INTEGER;
|
||||||
|
UPDATE nodes SET parent_lid = (SELECT p.lid FROM nodes p WHERE p.uid = nodes.parent_uid);
|
||||||
|
CREATE INDEX idx_nodes_parent_lid ON nodes(parent_lid);
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Schema v41: a node made on this machine goes by `local~<lid>` until it
|
||||||
|
/// lands, a function of its row, where it used to get a `local~<ms>-<n>`
|
||||||
|
/// placeholder. Every placeholder still stored is rewritten to its row's
|
||||||
|
/// stand-in: the row itself and its `node_json`, its children, the ops on it
|
||||||
|
/// or into it, and its pins. A placeholder op with no row keeps its uid and
|
||||||
|
/// drains as before.
|
||||||
|
const MIGRATION_V41: &str = "
|
||||||
|
CREATE TEMP TABLE local_v41 AS
|
||||||
|
SELECT uid AS old, 'local~' || lid AS new, lid FROM nodes
|
||||||
|
WHERE uid LIKE 'local~%' AND uid <> 'local~' || lid;
|
||||||
|
UPDATE pending_op SET uid = (SELECT new FROM local_v41 WHERE old = pending_op.uid)
|
||||||
|
WHERE uid IN (SELECT old FROM local_v41);
|
||||||
|
UPDATE pending_op SET parent_uid = (SELECT new FROM local_v41 WHERE old = pending_op.parent_uid)
|
||||||
|
WHERE parent_uid IN (SELECT old FROM local_v41);
|
||||||
|
UPDATE pending_op
|
||||||
|
SET meta_json = json_set(meta_json, '$.uid',
|
||||||
|
(SELECT new FROM local_v41 WHERE old = json_extract(pending_op.meta_json, '$.uid')))
|
||||||
|
WHERE json_valid(meta_json)
|
||||||
|
AND json_extract(meta_json, '$.uid') IN (SELECT old FROM local_v41);
|
||||||
|
UPDATE pending_op
|
||||||
|
SET meta_json = json_set(meta_json, '$.original_parent_uid',
|
||||||
|
(SELECT new FROM local_v41
|
||||||
|
WHERE old = json_extract(pending_op.meta_json, '$.original_parent_uid')))
|
||||||
|
WHERE json_valid(meta_json)
|
||||||
|
AND json_extract(meta_json, '$.original_parent_uid') IN (SELECT old FROM local_v41);
|
||||||
|
UPDATE pins SET uid = (SELECT new FROM local_v41 WHERE old = pins.uid)
|
||||||
|
WHERE uid IN (SELECT old FROM local_v41);
|
||||||
|
UPDATE nodes
|
||||||
|
SET node_json = json_set(node_json, '$.parent_uid.link_id',
|
||||||
|
(SELECT CAST(lid AS TEXT) FROM local_v41 WHERE old = nodes.parent_uid))
|
||||||
|
WHERE parent_uid IN (SELECT old FROM local_v41)
|
||||||
|
AND json_valid(node_json)
|
||||||
|
AND json_extract(node_json, '$.parent_uid.volume_id') = 'local';
|
||||||
|
UPDATE nodes SET parent_uid = (SELECT new FROM local_v41 WHERE old = nodes.parent_uid)
|
||||||
|
WHERE parent_uid IN (SELECT old FROM local_v41);
|
||||||
|
UPDATE nodes
|
||||||
|
SET node_json = json_set(node_json, '$.uid.link_id', CAST(lid AS TEXT))
|
||||||
|
WHERE lid IN (SELECT lid FROM local_v41)
|
||||||
|
AND json_valid(node_json)
|
||||||
|
AND json_extract(node_json, '$.uid.volume_id') = 'local';
|
||||||
|
UPDATE nodes SET uid = 'local~' || lid WHERE lid IN (SELECT lid FROM local_v41);
|
||||||
|
DROP TABLE local_v41;
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Schema v42: the SHA-1 of each blob a queued create sent, space-separated.
|
||||||
|
/// A create whose answer was lost may have made its file with any of them,
|
||||||
|
/// and the op that finds that file again may run after a restart. `NULL` for
|
||||||
|
/// an op that has sent nothing, which is every op migrated: what the daemon
|
||||||
|
/// that wrote it sent is not known. Likewise the parent and name each attempt
|
||||||
|
/// was sent under, as a JSON list of pairs: a rename since then moves the op
|
||||||
|
/// but not the file Drive may have made.
|
||||||
|
const MIGRATION_V42: &str = "
|
||||||
|
ALTER TABLE pending_op ADD COLUMN sent_sha1 TEXT;
|
||||||
|
ALTER TABLE pending_op ADD COLUMN sent_to TEXT;
|
||||||
|
";
|
||||||
@@ -56,10 +56,11 @@ pub use local::LocalFileHit;
|
|||||||
pub use maintenance::{DbStats, VacuumOutcome};
|
pub use maintenance::{DbStats, VacuumOutcome};
|
||||||
pub use nodes::{PublishedSharedRoot, SearchHit, StoredNode};
|
pub use nodes::{PublishedSharedRoot, SearchHit, StoredNode};
|
||||||
pub use ops::{
|
pub use ops::{
|
||||||
AttachedBlob, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_UNTIL,
|
AttachedBlob, CreateLanding, CreateRetired, FAILING_ATTEMPTS, LOCAL_VOLUME, OP_CREATE,
|
||||||
PendingCounts, PendingOp, RenameMeta, op_supersedes,
|
OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_EXPIRY_MS, PARK_UNTIL, PendingCounts,
|
||||||
|
PendingOp, RenameMeta, local_lid, local_uid, op_supersedes,
|
||||||
};
|
};
|
||||||
pub use photos::{StoredPhoto, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN, TimelineRow};
|
pub use photos::{StoredPhoto, StoredPlace, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN, TimelineRow};
|
||||||
pub use pins::PinRow;
|
pub use pins::PinRow;
|
||||||
pub use sync::{StoredSyncEntry, StoredSyncFolder};
|
pub use sync::{StoredSyncEntry, StoredSyncFolder};
|
||||||
pub use trash::StoredTrash;
|
pub use trash::StoredTrash;
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ impl Db {
|
|||||||
pending_mode: pending_mode.as_deref().map(MountMode::from),
|
pending_mode: pending_mode.as_deref().map(MountMode::from),
|
||||||
mounted: false,
|
mounted: false,
|
||||||
progress: None,
|
progress: None,
|
||||||
|
paused: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(mounts)
|
Ok(mounts)
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
|
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use rusqlite::types::Value;
|
||||||
use rusqlite::{OptionalExtension, Transaction, params};
|
use rusqlite::{OptionalExtension, Transaction, params};
|
||||||
|
|
||||||
use super::Db;
|
use super::Db;
|
||||||
|
use super::ops::{OP_CREATE, OP_MKDIR, OP_RENAME, local_lid, local_uid, wake_ops_waiting_for_tx};
|
||||||
use crate::{Access, Result};
|
use crate::{Access, Result};
|
||||||
use proton_drive_rs::proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
use proton_drive_rs::proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
||||||
use proton_drive_rs::{Node, NodeKind};
|
use proton_drive_rs::{Node, NodeKind};
|
||||||
@@ -17,6 +19,8 @@ use super::utils::{
|
|||||||
pub struct StoredNode {
|
pub struct StoredNode {
|
||||||
pub node: Node,
|
pub node: Node,
|
||||||
pub listed: bool,
|
pub listed: bool,
|
||||||
|
/// The row's local id ([`Db::lid_of`]).
|
||||||
|
pub lid: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One full-text search match: the stored [`Node`] plus its mountpoint-relative
|
/// One full-text search match: the stored [`Node`] plus its mountpoint-relative
|
||||||
@@ -62,12 +66,13 @@ impl Db {
|
|||||||
/// snapshot while offline, including after a live event expired its TTL.
|
/// snapshot while offline, including after a live event expired its TTL.
|
||||||
pub fn visible_children(&self, parent: &NodeUid) -> Result<Vec<Node>> {
|
pub fn visible_children(&self, parent: &NodeUid) -> Result<Vec<Node>> {
|
||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let (key, parent) = parent_key(&conn, &parent.to_string())?;
|
||||||
"SELECT node_json FROM nodes
|
let mut stmt = conn.prepare(&format!(
|
||||||
WHERE parent_uid = ?1 AND trashed = 0 AND node_json IS NOT NULL
|
"SELECT {NODE_NOW} FROM nodes n LEFT JOIN nodes p ON p.lid = n.parent_lid
|
||||||
ORDER BY name, uid",
|
WHERE n.{key} = ?1 AND n.trashed = 0 AND n.node_json IS NOT NULL
|
||||||
)?;
|
ORDER BY n.name, n.uid"
|
||||||
let rows = stmt.query_map([parent.to_string()], |row| row.get::<_, String>(0))?;
|
))?;
|
||||||
|
let rows = stmt.query_map([parent], |row| row.get::<_, String>(0))?;
|
||||||
let mut nodes = Vec::new();
|
let mut nodes = Vec::new();
|
||||||
for row in rows {
|
for row in rows {
|
||||||
nodes.push(serde_json::from_str(&row?)?);
|
nodes.push(serde_json::from_str(&row?)?);
|
||||||
@@ -264,10 +269,25 @@ impl Db {
|
|||||||
/// resolve its shared-tree access. Removing the op first could resurrect
|
/// resolve its shared-tree access. Removing the op first could resurrect
|
||||||
/// that row after a crash; removing the row first could strand a now
|
/// that row after a crash; removing the row first could strand a now
|
||||||
/// unauthorizable op. One transaction closes both windows.
|
/// unauthorizable op. One transaction closes both windows.
|
||||||
pub fn complete_trash_op(&self, op_id: i64, uid: &NodeUid) -> Result<()> {
|
///
|
||||||
|
/// An op that found the name still held by this trash failed with a
|
||||||
|
/// backoff, and the name is free now. It is made due at once
|
||||||
|
/// ([`Db::wake_ops_waiting_for`]).
|
||||||
|
///
|
||||||
|
/// Returns the staged blob of the create the trash withdrew, if it kept
|
||||||
|
/// one, for the caller to discard.
|
||||||
|
pub fn complete_trash_op(&self, op_id: i64, uid: &NodeUid) -> Result<Option<String>> {
|
||||||
let uid = uid.to_string();
|
let uid = uid.to_string();
|
||||||
let mut conn = self.conn.lock();
|
let mut conn = self.conn.lock();
|
||||||
let tx = conn.transaction()?;
|
let tx = conn.transaction()?;
|
||||||
|
let (name, blob): (Option<String>, Option<String>) = tx
|
||||||
|
.query_row(
|
||||||
|
"SELECT name, blob_path FROM pending_op WHERE id = ?1",
|
||||||
|
params![op_id],
|
||||||
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||||
|
)
|
||||||
|
.optional()?
|
||||||
|
.unwrap_or_default();
|
||||||
let rowid: Option<i64> = tx
|
let rowid: Option<i64> = tx
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT rowid FROM nodes WHERE uid = ?1",
|
"SELECT rowid FROM nodes WHERE uid = ?1",
|
||||||
@@ -280,17 +300,23 @@ impl Db {
|
|||||||
tx.execute("DELETE FROM nodes_fts WHERE rowid = ?1", params![rowid])?;
|
tx.execute("DELETE FROM nodes_fts WHERE rowid = ?1", params![rowid])?;
|
||||||
}
|
}
|
||||||
tx.execute("DELETE FROM pending_op WHERE id = ?1", params![op_id])?;
|
tx.execute("DELETE FROM pending_op WHERE id = ?1", params![op_id])?;
|
||||||
|
if let Some(name) = name {
|
||||||
|
wake_ops_waiting_for_tx(&tx, &name)?;
|
||||||
|
}
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
Ok(())
|
Ok(blob)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a folder node has any non-trashed children in the database.
|
/// Check if a folder node has any non-trashed children in the database.
|
||||||
pub fn has_children(&self, parent_uid: &NodeUid) -> Result<bool> {
|
pub fn has_children(&self, parent_uid: &NodeUid) -> Result<bool> {
|
||||||
let uid_str = parent_uid.to_string();
|
|
||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
|
let (key, parent) = parent_key(&conn, &parent_uid.to_string())?;
|
||||||
let count: i64 = conn.query_row(
|
let count: i64 = conn.query_row(
|
||||||
"SELECT COUNT(*) FROM nodes WHERE parent_uid = ?1 AND trashed = 0",
|
&format!(
|
||||||
params![uid_str],
|
"SELECT COUNT(*) FROM nodes
|
||||||
|
WHERE {key} = ?1 AND trashed = 0 AND node_json IS NOT NULL"
|
||||||
|
),
|
||||||
|
params![parent],
|
||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
)?;
|
)?;
|
||||||
Ok(count > 0)
|
Ok(count > 0)
|
||||||
@@ -305,9 +331,11 @@ impl Db {
|
|||||||
pub fn node_path(&self, uid: &str) -> Result<Option<String>> {
|
pub fn node_path(&self, uid: &str) -> Result<Option<String>> {
|
||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
let exists: Option<i64> = conn
|
let exists: Option<i64> = conn
|
||||||
.query_row("SELECT 1 FROM nodes WHERE uid = ?1", params![uid], |r| {
|
.query_row(
|
||||||
r.get(0)
|
"SELECT 1 FROM nodes WHERE uid = ?1 AND node_json IS NOT NULL",
|
||||||
})
|
params![uid],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
.optional()?;
|
.optional()?;
|
||||||
if exists.is_none() {
|
if exists.is_none() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -325,11 +353,11 @@ impl Db {
|
|||||||
pub fn path_relative_to(&self, ancestor_uid: &str, uid: &str) -> Result<Option<String>> {
|
pub fn path_relative_to(&self, ancestor_uid: &str, uid: &str) -> Result<Option<String>> {
|
||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"WITH RECURSIVE anc(uid, parent_uid, name, depth) AS (
|
"WITH RECURSIVE anc(uid, parent_lid, name, depth) AS (
|
||||||
SELECT uid, parent_uid, name, 0 FROM nodes WHERE uid = ?1
|
SELECT uid, parent_lid, name, 0 FROM nodes WHERE uid = ?1
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT n.uid, n.parent_uid, n.name, anc.depth + 1
|
SELECT n.uid, n.parent_lid, n.name, anc.depth + 1
|
||||||
FROM nodes n JOIN anc ON n.uid = anc.parent_uid
|
FROM nodes n JOIN anc ON n.lid = anc.parent_lid
|
||||||
WHERE anc.depth < 1024
|
WHERE anc.depth < 1024
|
||||||
)
|
)
|
||||||
SELECT uid, name FROM anc ORDER BY depth",
|
SELECT uid, name FROM anc ORDER BY depth",
|
||||||
@@ -355,6 +383,22 @@ impl Db {
|
|||||||
/// than [`TRIGRAM_MIN`] fall back to a `LIKE` scan since trigram indexes
|
/// than [`TRIGRAM_MIN`] fall back to a `LIKE` scan since trigram indexes
|
||||||
/// nothing below 3 chars.
|
/// nothing below 3 chars.
|
||||||
pub fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchHit>> {
|
pub fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchHit>> {
|
||||||
|
self.search_in(query, limit, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`search`](Self::search) restricted to the subtree under the
|
||||||
|
/// mountpoint-relative folder `scope`. `None` or an empty scope searches
|
||||||
|
/// everything.
|
||||||
|
pub fn search_in(
|
||||||
|
&self,
|
||||||
|
query: &str,
|
||||||
|
limit: usize,
|
||||||
|
scope: Option<&str>,
|
||||||
|
) -> Result<Vec<SearchHit>> {
|
||||||
|
let scope = scope.map(|s| s.trim_matches('/')).filter(|s| !s.is_empty());
|
||||||
|
// Rows whose path is not persisted yet pass the SQL filter and are
|
||||||
|
// checked once their path is walked below.
|
||||||
|
let scope_pat = scope.map(|s| format!("{}/%", like_escape(s)));
|
||||||
let query = query.trim();
|
let query = query.trim();
|
||||||
if query.is_empty() {
|
if query.is_empty() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
@@ -362,12 +406,14 @@ impl Db {
|
|||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
let rows: Vec<HitRow> = if query.chars().count() < TRIGRAM_MIN {
|
let rows: Vec<HitRow> = if query.chars().count() < TRIGRAM_MIN {
|
||||||
let pat = format!("%{}%", like_escape(query));
|
let pat = format!("%{}%", like_escape(query));
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(&format!(
|
||||||
"SELECT node_json, uid, path FROM nodes
|
"SELECT {NODE_NOW}, n.uid, n.path
|
||||||
WHERE name LIKE ?1 ESCAPE '\\' AND trashed = 0 AND node_json IS NOT NULL
|
FROM nodes n LEFT JOIN nodes p ON p.lid = n.parent_lid
|
||||||
ORDER BY name LIMIT ?2",
|
WHERE n.name LIKE ?1 ESCAPE '\\' AND n.trashed = 0 AND n.node_json IS NOT NULL
|
||||||
)?;
|
AND (?3 IS NULL OR n.path IS NULL OR n.path LIKE ?3 ESCAPE '\\')
|
||||||
collect_hits(stmt.query_map(params![pat, limit as i64], hit_row)?)?
|
ORDER BY n.name LIMIT ?2"
|
||||||
|
))?;
|
||||||
|
collect_hits(stmt.query_map(params![pat, limit as i64, scope_pat], hit_row)?)?
|
||||||
} else {
|
} else {
|
||||||
// Escape double quotes and quote each term, then combine with AND so
|
// Escape double quotes and quote each term, then combine with AND so
|
||||||
// all terms must match but can appear in any order or position.
|
// all terms must match but can appear in any order or position.
|
||||||
@@ -376,13 +422,15 @@ impl Db {
|
|||||||
.map(|word| format!("\"{}\"", word.replace('"', "\"\"")))
|
.map(|word| format!("\"{}\"", word.replace('"', "\"\"")))
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(" AND ");
|
.join(" AND ");
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(&format!(
|
||||||
"SELECT n.node_json, n.uid, n.path
|
"SELECT {NODE_NOW}, n.uid, n.path
|
||||||
FROM nodes_fts f JOIN nodes n ON n.rowid = f.rowid
|
FROM nodes_fts f JOIN nodes n ON n.rowid = f.rowid
|
||||||
|
LEFT JOIN nodes p ON p.lid = n.parent_lid
|
||||||
WHERE f.name MATCH ?1 AND n.trashed = 0 AND n.node_json IS NOT NULL
|
WHERE f.name MATCH ?1 AND n.trashed = 0 AND n.node_json IS NOT NULL
|
||||||
ORDER BY f.rank LIMIT ?2",
|
AND (?3 IS NULL OR n.path IS NULL OR n.path LIKE ?3 ESCAPE '\\')
|
||||||
)?;
|
ORDER BY f.rank LIMIT ?2"
|
||||||
collect_hits(stmt.query_map(params![phrase, limit as i64], hit_row)?)?
|
))?;
|
||||||
|
collect_hits(stmt.query_map(params![phrase, limit as i64, scope_pat], hit_row)?)?
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut hits = Vec::with_capacity(rows.len());
|
let mut hits = Vec::with_capacity(rows.len());
|
||||||
@@ -392,6 +440,13 @@ impl Db {
|
|||||||
Some(path) => path,
|
Some(path) => path,
|
||||||
None => walk_path_of(&conn, &uid)?,
|
None => walk_path_of(&conn, &uid)?,
|
||||||
};
|
};
|
||||||
|
if let Some(scope) = scope
|
||||||
|
&& !path
|
||||||
|
.strip_prefix(scope)
|
||||||
|
.is_some_and(|rest| rest.starts_with('/'))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
hits.push(SearchHit { node, path });
|
hits.push(SearchHit { node, path });
|
||||||
}
|
}
|
||||||
Ok(hits)
|
Ok(hits)
|
||||||
@@ -411,20 +466,22 @@ impl Db {
|
|||||||
let lane_limit = limit.div_ceil(2);
|
let lane_limit = limit.div_ceil(2);
|
||||||
let mut rows: Vec<HitRow> = if terms.is_empty() {
|
let mut rows: Vec<HitRow> = if terms.is_empty() {
|
||||||
let pat = format!("%{}%", like_escape(query));
|
let pat = format!("%{}%", like_escape(query));
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(&format!(
|
||||||
"SELECT node_json, uid, path FROM nodes
|
"SELECT {NODE_NOW}, n.uid, n.path
|
||||||
WHERE name LIKE ?1 ESCAPE '\\' AND trashed = 0 AND node_json IS NOT NULL
|
FROM nodes n LEFT JOIN nodes p ON p.lid = n.parent_lid
|
||||||
ORDER BY name LIMIT ?2",
|
WHERE n.name LIKE ?1 ESCAPE '\\' AND n.trashed = 0 AND n.node_json IS NOT NULL
|
||||||
)?;
|
ORDER BY n.name LIMIT ?2"
|
||||||
|
))?;
|
||||||
collect_hits(stmt.query_map(params![pat, limit as i64], hit_row)?)?
|
collect_hits(stmt.query_map(params![pat, limit as i64], hit_row)?)?
|
||||||
} else {
|
} else {
|
||||||
let expression = terms.join(" OR ");
|
let expression = terms.join(" OR ");
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(&format!(
|
||||||
"SELECT n.node_json, n.uid, n.path
|
"SELECT {NODE_NOW}, n.uid, n.path
|
||||||
FROM nodes_fts f JOIN nodes n ON n.rowid = f.rowid
|
FROM nodes_fts f JOIN nodes n ON n.rowid = f.rowid
|
||||||
|
LEFT JOIN nodes p ON p.lid = n.parent_lid
|
||||||
WHERE nodes_fts MATCH ?1 AND n.trashed = 0 AND n.node_json IS NOT NULL
|
WHERE nodes_fts MATCH ?1 AND n.trashed = 0 AND n.node_json IS NOT NULL
|
||||||
ORDER BY f.rank LIMIT ?2",
|
ORDER BY f.rank LIMIT ?2"
|
||||||
)?;
|
))?;
|
||||||
collect_hits(stmt.query_map(params![expression, lane_limit as i64], hit_row)?)?
|
collect_hits(stmt.query_map(params![expression, lane_limit as i64], hit_row)?)?
|
||||||
};
|
};
|
||||||
// Trigram rank does not know that a name *starts* with the query, and a
|
// Trigram rank does not know that a name *starts* with the query, and a
|
||||||
@@ -434,12 +491,13 @@ impl Db {
|
|||||||
if !terms.is_empty()
|
if !terms.is_empty()
|
||||||
&& let Some(first) = query.chars().next()
|
&& let Some(first) = query.chars().next()
|
||||||
{
|
{
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(&format!(
|
||||||
"SELECT node_json, uid, path FROM nodes
|
"SELECT {NODE_NOW}, n.uid, n.path
|
||||||
WHERE name COLLATE NOCASE LIKE ?1 ESCAPE '\\'
|
FROM nodes n LEFT JOIN nodes p ON p.lid = n.parent_lid
|
||||||
AND trashed = 0 AND node_json IS NOT NULL
|
WHERE n.name COLLATE NOCASE LIKE ?1 ESCAPE '\\'
|
||||||
ORDER BY name COLLATE NOCASE LIMIT ?2",
|
AND n.trashed = 0 AND n.node_json IS NOT NULL
|
||||||
)?;
|
ORDER BY n.name COLLATE NOCASE LIMIT ?2"
|
||||||
|
))?;
|
||||||
let mut extra = Vec::new();
|
let mut extra = Vec::new();
|
||||||
for pattern in [
|
for pattern in [
|
||||||
format!("{}%", like_escape(query)),
|
format!("{}%", like_escape(query)),
|
||||||
@@ -475,40 +533,50 @@ impl Db {
|
|||||||
/// re-enumerates from the remote on next access.
|
/// re-enumerates from the remote on next access.
|
||||||
pub fn set_listed(&self, uid: &NodeUid, listed: bool) -> Result<()> {
|
pub fn set_listed(&self, uid: &NodeUid, listed: bool) -> Result<()> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.conn.lock();
|
||||||
|
let (key, uid) = row_key(&uid.to_string());
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE nodes SET listed = ?2 WHERE uid = ?1",
|
&format!("UPDATE nodes SET listed = ?2 WHERE {key} = ?1"),
|
||||||
params![uid.to_string(), listed as i64],
|
params![uid, listed as i64],
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load every persisted node for cold-start hydration of the `State` maps.
|
/// Load every persisted node for cold-start hydration of the `State` maps.
|
||||||
|
///
|
||||||
|
/// Each node names its parent as the parent's row has it now, found by
|
||||||
|
/// local id: a folder that landed has its new uid there, while the node
|
||||||
|
/// stored below it may still hold the placeholder.
|
||||||
pub fn load_all(&self) -> Result<Vec<StoredNode>> {
|
pub fn load_all(&self) -> Result<Vec<StoredNode>> {
|
||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
let mut stmt =
|
let mut stmt = conn.prepare(&format!(
|
||||||
conn.prepare("SELECT node_json, listed FROM nodes WHERE node_json IS NOT NULL")?;
|
"SELECT {NODE_NOW}, n.listed, n.lid
|
||||||
|
FROM nodes n LEFT JOIN nodes p ON p.lid = n.parent_lid
|
||||||
|
WHERE n.node_json IS NOT NULL"
|
||||||
|
))?;
|
||||||
let rows = stmt.query_map([], |row| {
|
let rows = stmt.query_map([], |row| {
|
||||||
let json: String = row.get(0)?;
|
let json: String = row.get(0)?;
|
||||||
let listed: i64 = row.get(1)?;
|
let listed: i64 = row.get(1)?;
|
||||||
Ok((json, listed != 0))
|
Ok((json, listed != 0, row.get(2)?))
|
||||||
})?;
|
})?;
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for row in rows {
|
for row in rows {
|
||||||
let (json, listed) = row?;
|
let (json, listed, lid) = row?;
|
||||||
let node: Node = serde_json::from_str(&json)?;
|
let node: Node = serde_json::from_str(&json)?;
|
||||||
out.push(StoredNode { node, listed });
|
out.push(StoredNode { node, listed, lid });
|
||||||
}
|
}
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load one persisted node back by uid. Used to recover the My Files root
|
/// Load one persisted node back by uid. Used to recover the My Files root
|
||||||
/// when the API is unreachable, so the mount can still serve the cached tree
|
/// when the API is unreachable, so the mount can still serve the cached tree.
|
||||||
/// (offline.md Phase 1).
|
|
||||||
pub fn node_by_uid(&self, uid: &str) -> Result<Option<Node>> {
|
pub fn node_by_uid(&self, uid: &str) -> Result<Option<Node>> {
|
||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
let json: Option<String> = conn
|
let json: Option<String> = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT node_json FROM nodes WHERE uid = ?1 AND node_json IS NOT NULL",
|
&format!(
|
||||||
|
"SELECT {NODE_NOW} FROM nodes n LEFT JOIN nodes p ON p.lid = n.parent_lid
|
||||||
|
WHERE n.uid = ?1 AND n.node_json IS NOT NULL"
|
||||||
|
),
|
||||||
params![uid],
|
params![uid],
|
||||||
|r| r.get(0),
|
|r| r.get(0),
|
||||||
)
|
)
|
||||||
@@ -519,31 +587,178 @@ impl Db {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The uid of the node with local id `lid`: `None` when it has no row, or
|
||||||
|
/// is not on Drive yet.
|
||||||
|
pub fn uid_of_lid(&self, lid: i64) -> Result<Option<String>> {
|
||||||
|
let conn = self.read();
|
||||||
|
Ok(conn
|
||||||
|
.query_row("SELECT uid FROM nodes WHERE lid = ?1", params![lid], |r| {
|
||||||
|
r.get(0)
|
||||||
|
})
|
||||||
|
.optional()?
|
||||||
|
.flatten())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The local id of the node stored under `uid`, or `None` when it has no
|
||||||
|
/// row. It stays the same for as long as the row exists, including when a
|
||||||
|
/// create lands and the row takes the uid Drive gave it: a node made on
|
||||||
|
/// this machine is found by its stand-in ([`local_uid`]) before and after.
|
||||||
|
pub fn lid_of(&self, uid: &str) -> Result<Option<i64>> {
|
||||||
|
node_lid(&self.read(), uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The local id of each node, in order, giving a row to each that has none.
|
||||||
|
///
|
||||||
|
/// A new row is only a stub: it has no `node_json`, so no listing or search
|
||||||
|
/// returns it until the node is upserted, which the caller still owes. That
|
||||||
|
/// keeps this cheap enough to call with the inode lock held, which is
|
||||||
|
/// where an inode number is first needed.
|
||||||
|
pub fn lids_for(&self, nodes: &[&Node]) -> Result<Vec<i64>> {
|
||||||
|
let mut conn = self.conn.lock();
|
||||||
|
let tx = conn.transaction()?;
|
||||||
|
let mut lids = Vec::with_capacity(nodes.len());
|
||||||
|
{
|
||||||
|
let mut known = tx.prepare_cached("SELECT lid FROM nodes WHERE uid = ?1")?;
|
||||||
|
let mut stub = tx.prepare_cached(
|
||||||
|
"INSERT INTO nodes (uid, parent_uid, name, is_dir, mtime, trashed, parent_lid)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||||
|
)?;
|
||||||
|
for node in nodes {
|
||||||
|
let uid = node.uid.to_string();
|
||||||
|
let lid = match known.query_row(params![uid], |r| r.get(0)).optional()? {
|
||||||
|
Some(lid) => lid,
|
||||||
|
None => {
|
||||||
|
let parent = node.parent_uid.as_ref().map(ToString::to_string);
|
||||||
|
let parent_lid = match &parent {
|
||||||
|
Some(parent) => node_lid(&tx, parent)?,
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
stub.execute(params![
|
||||||
|
uid,
|
||||||
|
parent,
|
||||||
|
node.name,
|
||||||
|
node.is_folder() as i64,
|
||||||
|
node.modification_time,
|
||||||
|
node.trashed as i64,
|
||||||
|
parent_lid,
|
||||||
|
])?;
|
||||||
|
let lid = tx.last_insert_rowid();
|
||||||
|
adopt_children_tx(&tx, &uid, lid)?;
|
||||||
|
lid
|
||||||
|
}
|
||||||
|
};
|
||||||
|
lids.push(lid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(lids)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Give a node being made on this machine its row under `parent_uid`, and
|
||||||
|
/// return its local id. The row goes by [`local_uid`] of that id until
|
||||||
|
/// the node lands.
|
||||||
|
///
|
||||||
|
/// Like [`Db::lids_for`], only a stub: the caller upserts the node.
|
||||||
|
pub fn add_local_row(
|
||||||
|
&self,
|
||||||
|
parent_uid: &str,
|
||||||
|
name: &str,
|
||||||
|
is_dir: bool,
|
||||||
|
mtime: i64,
|
||||||
|
) -> Result<i64> {
|
||||||
|
let mut conn = self.conn.lock();
|
||||||
|
let tx = conn.transaction()?;
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO nodes (uid, parent_uid, name, is_dir, mtime, trashed, parent_lid)
|
||||||
|
VALUES (NULL, ?1, ?2, ?3, ?4, 0,
|
||||||
|
COALESCE(?5, (SELECT lid FROM nodes WHERE uid = ?1)))",
|
||||||
|
params![
|
||||||
|
parent_uid,
|
||||||
|
name,
|
||||||
|
is_dir as i64,
|
||||||
|
mtime,
|
||||||
|
local_lid(parent_uid)
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
let lid = tx.last_insert_rowid();
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE nodes SET uid = ?2 WHERE lid = ?1",
|
||||||
|
params![lid, local_uid(lid)],
|
||||||
|
)?;
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(lid)
|
||||||
|
}
|
||||||
|
|
||||||
/// Read the persisted incremental-sync cursor (a `DriveEventId`), if any.
|
/// Read the persisted incremental-sync cursor (a `DriveEventId`), if any.
|
||||||
/// The daemon resumes from this on restart instead of reseeding to the
|
/// The daemon resumes from this on restart instead of reseeding to the
|
||||||
/// server head, so changes made while unmounted are still applied (P2).
|
/// server head, so changes made while unmounted are still applied (P2).
|
||||||
pub fn children_if_listed(&self, parent: &NodeUid) -> Result<Option<Vec<Node>>> {
|
pub fn children_if_listed(&self, parent: &NodeUid) -> Result<Option<Vec<Node>>> {
|
||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
|
let (key, folder) = row_key(&parent.to_string());
|
||||||
let listed: Option<i64> = conn
|
let listed: Option<i64> = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT listed FROM nodes WHERE uid = ?1",
|
&format!("SELECT listed FROM nodes WHERE {key} = ?1"),
|
||||||
params![parent.to_string()],
|
params![folder],
|
||||||
|r| r.get(0),
|
|r| r.get(0),
|
||||||
)
|
)
|
||||||
.optional()?;
|
.optional()?;
|
||||||
if listed != Some(1) {
|
if listed != Some(1) {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let mut stmt = conn.prepare(
|
drop(conn);
|
||||||
"SELECT node_json FROM nodes
|
self.known_children(parent).map(Some)
|
||||||
WHERE parent_uid = ?1 AND node_json IS NOT NULL AND trashed = 0",
|
}
|
||||||
)?;
|
|
||||||
let rows = stmt.query_map(params![parent.to_string()], |r| r.get::<_, String>(0))?;
|
/// The children the DB knows under `parent`, whether or not the folder's
|
||||||
|
/// listing is still marked complete. Only for when nothing better can be
|
||||||
|
/// had: a listing dropped as stale may miss or keep a child.
|
||||||
|
pub fn known_children(&self, parent: &NodeUid) -> Result<Vec<Node>> {
|
||||||
|
let conn = self.read();
|
||||||
|
let (key, parent) = parent_key(&conn, &parent.to_string())?;
|
||||||
|
let mut stmt = conn.prepare(&format!(
|
||||||
|
"SELECT {NODE_NOW} FROM nodes n LEFT JOIN nodes p ON p.lid = n.parent_lid
|
||||||
|
WHERE n.{key} = ?1 AND n.node_json IS NOT NULL AND n.trashed = 0"
|
||||||
|
))?;
|
||||||
|
let rows = stmt.query_map(params![parent], |r| r.get::<_, String>(0))?;
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for json in rows {
|
for json in rows {
|
||||||
out.push(serde_json::from_str(&json?)?);
|
out.push(serde_json::from_str(&json?)?);
|
||||||
}
|
}
|
||||||
Ok(Some(out))
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a queued change does to `parent`'s listing, which Drive does not
|
||||||
|
/// show until it lands: the children made or moved in by a queued create,
|
||||||
|
/// mkdir or rename, as the DB holds them, and the nodes a queued rename
|
||||||
|
/// moves elsewhere.
|
||||||
|
///
|
||||||
|
/// A listing read from Drive is laid over with these, or a folder listed
|
||||||
|
/// while a create in it is queued, after a restart say, loses the file
|
||||||
|
/// until the create lands (`docs/BUGS.md` B132).
|
||||||
|
pub fn queued_children(&self, parent: &NodeUid) -> Result<(Vec<Node>, HashSet<String>)> {
|
||||||
|
let conn = self.read();
|
||||||
|
let (key, parent) = parent_key(&conn, &parent.to_string())?;
|
||||||
|
let mut stmt = conn.prepare(&format!(
|
||||||
|
"SELECT {NODE_NOW} FROM nodes n LEFT JOIN nodes p ON p.lid = n.parent_lid
|
||||||
|
WHERE n.{key} = ?1 AND n.node_json IS NOT NULL AND n.trashed = 0
|
||||||
|
AND EXISTS (SELECT 1 FROM pending_op o
|
||||||
|
WHERE (o.lid = n.lid OR o.uid = n.uid) AND o.kind IN (?2, ?3, ?4))"
|
||||||
|
))?;
|
||||||
|
let rows = stmt.query_map(params![parent, OP_CREATE, OP_MKDIR, OP_RENAME], |r| {
|
||||||
|
r.get::<_, String>(0)
|
||||||
|
})?;
|
||||||
|
let mut here = Vec::new();
|
||||||
|
for json in rows {
|
||||||
|
here.push(serde_json::from_str(&json?)?);
|
||||||
|
}
|
||||||
|
let mut stmt = conn.prepare(&format!(
|
||||||
|
"SELECT DISTINCT n.uid FROM pending_op p JOIN nodes n ON n.lid = p.lid OR n.uid = p.uid
|
||||||
|
WHERE p.kind = ?2 AND n.{key} IS NOT ?1"
|
||||||
|
))?;
|
||||||
|
let gone = stmt
|
||||||
|
.query_map(params![parent, OP_RENAME], |r| r.get::<_, String>(0))?
|
||||||
|
.collect::<std::result::Result<HashSet<_>, _>>()?;
|
||||||
|
Ok((here, gone))
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Content-cache LRU index (P4) -------------------------------------
|
// --- Content-cache LRU index (P4) -------------------------------------
|
||||||
@@ -565,9 +780,157 @@ struct PriorRow {
|
|||||||
trashed: bool,
|
trashed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [`Db::lid_of`] on `conn`, which may be a transaction.
|
||||||
|
pub(super) fn node_lid(conn: &rusqlite::Connection, uid: &str) -> Result<Option<i64>> {
|
||||||
|
let row = match local_lid(uid) {
|
||||||
|
Some(lid) => conn.query_row("SELECT lid FROM nodes WHERE lid = ?1", params![lid], |r| {
|
||||||
|
r.get(0)
|
||||||
|
}),
|
||||||
|
None => conn.query_row("SELECT lid FROM nodes WHERE uid = ?1", params![uid], |r| {
|
||||||
|
r.get(0)
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
Ok(row.optional()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The column and value that find `uid`'s own row. A stand-in is found by its
|
||||||
|
/// local id: a create that lands gives the row the uid Drive made before the
|
||||||
|
/// tree has it, and the tree asks by the stand-in until then (`docs/BUGS.md`
|
||||||
|
/// B177).
|
||||||
|
fn row_key(uid: &str) -> (&'static str, Value) {
|
||||||
|
match local_lid(uid) {
|
||||||
|
Some(lid) => ("lid", Value::Integer(lid)),
|
||||||
|
None => ("uid", Value::Text(uid.to_owned())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The column and value a row names `parent` by: its local id when it has a
|
||||||
|
/// row, which the children keep when it lands, or its uid when it has none,
|
||||||
|
/// as a device folder's root does.
|
||||||
|
fn parent_key(conn: &rusqlite::Connection, parent: &str) -> Result<(&'static str, Value)> {
|
||||||
|
Ok(match node_lid(conn, parent)? {
|
||||||
|
Some(lid) => ("parent_lid", Value::Integer(lid)),
|
||||||
|
None => ("parent_uid", Value::Text(parent.to_owned())),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `n.node_json` naming its parent as the parent's row `p`, joined by
|
||||||
|
/// `n.parent_lid`, has it now. A folder that lands takes its real uid in its
|
||||||
|
/// own row only, so a node stored below it still names the stand-in.
|
||||||
|
const NODE_NOW: &str = "CASE WHEN p.uid IS NULL OR p.uid = n.parent_uid THEN n.node_json
|
||||||
|
ELSE json_set(n.node_json, '$.parent_uid', json_object(
|
||||||
|
'volume_id', substr(p.uid, 1, instr(p.uid, '~') - 1),
|
||||||
|
'link_id', substr(p.uid, instr(p.uid, '~') + 1))) END";
|
||||||
|
|
||||||
|
/// Hand a drained placeholder's row to the real uid it landed as.
|
||||||
|
///
|
||||||
|
/// Nothing else is rewritten. The rows below it and the ops made inside it
|
||||||
|
/// name it by its local id, which stays; what reads them for a uid takes the
|
||||||
|
/// one the row has now ([`NODE_NOW`], `PARENT_NOW` in `ops.rs`).
|
||||||
|
///
|
||||||
|
/// Dropping the placeholder row and waiting for the server's copy of the real
|
||||||
|
/// node left a window with no row for either uid. A queued child drained in
|
||||||
|
/// that window found no authority for its parent, re-read it over the network
|
||||||
|
/// and was deferred five seconds behind a folder that had just landed
|
||||||
|
/// (`docs/BUGS.md` B120). The placeholder's own node, readdressed, stands in
|
||||||
|
/// until the server's copy is upserted over it. A row the real uid already
|
||||||
|
/// has is newer than the placeholder, so its node is kept, under the
|
||||||
|
/// placeholder's `lid`: the node was known by that one first.
|
||||||
|
pub(super) fn adopt_placeholder_row_tx(
|
||||||
|
tx: &Transaction<'_>,
|
||||||
|
local: &str,
|
||||||
|
real: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
// A folder pinned before it landed stays pinned: the pin keyed by the
|
||||||
|
// placeholder was dropped with it (`docs/BUGS.md` B148).
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE OR IGNORE pins SET uid = ?2 WHERE uid = ?1",
|
||||||
|
params![local, real],
|
||||||
|
)?;
|
||||||
|
tx.execute("DELETE FROM pins WHERE uid = ?1", params![local])?;
|
||||||
|
let row: Option<(i64, Option<String>)> = tx
|
||||||
|
.query_row(
|
||||||
|
"SELECT lid, node_json FROM nodes WHERE uid = ?1",
|
||||||
|
params![local],
|
||||||
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
// Ops queued before schema 39 have no `parent_lid`, nor do ops made
|
||||||
|
// under a placeholder that never had a row.
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE pending_op SET parent_uid = ?2 WHERE parent_lid IS NULL AND parent_uid = ?1",
|
||||||
|
params![local, real],
|
||||||
|
)?;
|
||||||
|
let Some((lid, json)) = row else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let known: Option<(i64, Option<String>)> = tx
|
||||||
|
.query_row(
|
||||||
|
"SELECT lid, node_json FROM nodes WHERE uid = ?1",
|
||||||
|
params![real],
|
||||||
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
if let Some((known_lid, _)) = known {
|
||||||
|
// Ops queued against the row Drive listed now name the landing one.
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE pending_op SET lid = ?2 WHERE lid = ?1",
|
||||||
|
params![known_lid, lid],
|
||||||
|
)?;
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE pending_op SET parent_lid = ?2 WHERE parent_lid = ?1",
|
||||||
|
params![known_lid, lid],
|
||||||
|
)?;
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE nodes SET parent_lid = ?2 WHERE parent_lid = ?1",
|
||||||
|
params![known_lid, lid],
|
||||||
|
)?;
|
||||||
|
tx.execute("DELETE FROM nodes WHERE lid = ?1", params![known_lid])?;
|
||||||
|
tx.execute("DELETE FROM nodes_fts WHERE rowid = ?1", params![known_lid])?;
|
||||||
|
}
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE nodes SET uid = ?2 WHERE lid = ?1",
|
||||||
|
params![lid, real],
|
||||||
|
)?;
|
||||||
|
let json = known.and_then(|(_, known_json)| known_json).or(json);
|
||||||
|
let (Some(json), Some(real)) = (json, parse_node_uid(real)) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let mut node: Node = serde_json::from_str(&json)?;
|
||||||
|
node.uid = real;
|
||||||
|
upsert_node_tx(tx, &node)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Link the rows that name `uid` as their parent to its new row `lid`: they
|
||||||
|
/// were stored before it, or under a row of the same uid since dropped.
|
||||||
|
fn adopt_children_tx(tx: &Transaction<'_>, uid: &str, lid: i64) -> Result<()> {
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE nodes SET parent_lid = ?2 WHERE parent_uid = ?1 AND parent_lid IS NOT ?2",
|
||||||
|
params![uid, lid],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn upsert_node_tx(tx: &Transaction<'_>, node: &Node) -> Result<()> {
|
fn upsert_node_tx(tx: &Transaction<'_>, node: &Node) -> Result<()> {
|
||||||
let json = serde_json::to_string(node)?;
|
|
||||||
let uid = node.uid.to_string();
|
let uid = node.uid.to_string();
|
||||||
|
// A node written under its stand-in after it landed is its row, which has
|
||||||
|
// the uid Drive gave it by now: written under the stand-in, it would bring
|
||||||
|
// the placeholder row back beside it.
|
||||||
|
if let Some(lid) = local_lid(&uid) {
|
||||||
|
let landed: Option<String> = tx
|
||||||
|
.query_row(
|
||||||
|
"SELECT uid FROM nodes WHERE lid = ?1 AND uid IS NOT ?2",
|
||||||
|
params![lid, uid],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
if let Some(real) = landed.as_deref().and_then(parse_node_uid) {
|
||||||
|
let mut node = node.clone();
|
||||||
|
node.uid = real;
|
||||||
|
return upsert_node_tx(tx, &node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let json = serde_json::to_string(node)?;
|
||||||
let parent_uid = node.parent_uid.as_ref().map(|u| u.to_string());
|
let parent_uid = node.parent_uid.as_ref().map(|u| u.to_string());
|
||||||
|
|
||||||
let prior: Option<PriorRow> = tx
|
let prior: Option<PriorRow> = tx
|
||||||
@@ -589,26 +952,34 @@ fn upsert_node_tx(tx: &Transaction<'_>, node: &Node) -> Result<()> {
|
|||||||
// where resolving it from scratch is a recursive walk to the root. A node
|
// where resolving it from scratch is a recursive walk to the root. A node
|
||||||
// whose parent is not cached (a device folder's root lives in `device`, never
|
// whose parent is not cached (a device folder's root lives in `device`, never
|
||||||
// in `nodes`) starts a path of its own, which is what the walk did too.
|
// in `nodes`) starts a path of its own, which is what the walk did too.
|
||||||
let path = match &parent_uid {
|
let (parent_lid, path) = match &parent_uid {
|
||||||
None => String::new(),
|
None => (None, String::new()),
|
||||||
Some(parent) => {
|
Some(parent) => {
|
||||||
let parent_path: Option<Option<String>> = tx
|
let parent_row: Option<(i64, Option<String>)> = match node_lid(tx, parent)? {
|
||||||
.query_row(
|
Some(lid) => tx
|
||||||
"SELECT path FROM nodes WHERE uid = ?1",
|
.query_row(
|
||||||
params![parent],
|
"SELECT lid, path FROM nodes WHERE lid = ?1",
|
||||||
|row| row.get(0),
|
params![lid],
|
||||||
)
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||||
.optional()?;
|
)
|
||||||
join_path(parent_path.flatten().as_deref().unwrap_or(""), &node.name)
|
.optional()?,
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let (lid, parent_path) = parent_row.unzip();
|
||||||
|
(
|
||||||
|
lid,
|
||||||
|
join_path(parent_path.flatten().as_deref().unwrap_or(""), &node.name),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO nodes
|
"INSERT INTO nodes
|
||||||
(uid, parent_uid, name, is_dir, size, mtime, trashed, node_json, path)
|
(uid, parent_uid, name, is_dir, size, mtime, trashed, node_json, path, parent_lid)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
|
||||||
ON CONFLICT(uid) DO UPDATE SET
|
ON CONFLICT(uid) DO UPDATE SET
|
||||||
parent_uid = excluded.parent_uid,
|
parent_uid = excluded.parent_uid,
|
||||||
|
parent_lid = excluded.parent_lid,
|
||||||
name = excluded.name,
|
name = excluded.name,
|
||||||
is_dir = excluded.is_dir,
|
is_dir = excluded.is_dir,
|
||||||
size = excluded.size,
|
size = excluded.size,
|
||||||
@@ -626,6 +997,7 @@ fn upsert_node_tx(tx: &Transaction<'_>, node: &Node) -> Result<()> {
|
|||||||
node.trashed as i64,
|
node.trashed as i64,
|
||||||
json,
|
json,
|
||||||
path,
|
path,
|
||||||
|
parent_lid,
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
let rowid: i64 = tx.query_row(
|
let rowid: i64 = tx.query_row(
|
||||||
@@ -633,6 +1005,9 @@ fn upsert_node_tx(tx: &Transaction<'_>, node: &Node) -> Result<()> {
|
|||||||
params![uid],
|
params![uid],
|
||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
)?;
|
)?;
|
||||||
|
if prior.is_none() {
|
||||||
|
adopt_children_tx(tx, &uid, rowid)?;
|
||||||
|
}
|
||||||
|
|
||||||
// FTS5 has no UPSERT, so the node's own row is always replaced.
|
// FTS5 has no UPSERT, so the node's own row is always replaced.
|
||||||
let indexable = node_is_indexable_tx(tx, &uid)?;
|
let indexable = node_is_indexable_tx(tx, &uid)?;
|
||||||
@@ -655,7 +1030,7 @@ fn upsert_node_tx(tx: &Transaction<'_>, node: &Node) -> Result<()> {
|
|||||||
|| prior.trashed != node.trashed
|
|| prior.trashed != node.trashed
|
||||||
});
|
});
|
||||||
if node.is_folder() && subtree_moved {
|
if node.is_folder() && subtree_moved {
|
||||||
reindex_subtree_tx(tx, &uid, &path, indexable)?;
|
reindex_subtree_tx(tx, rowid, &path, indexable)?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -676,22 +1051,22 @@ fn upsert_node_tx(tx: &Transaction<'_>, node: &Node) -> Result<()> {
|
|||||||
/// this deep.
|
/// this deep.
|
||||||
fn reindex_subtree_tx(
|
fn reindex_subtree_tx(
|
||||||
tx: &Transaction<'_>,
|
tx: &Transaction<'_>,
|
||||||
folder: &str,
|
folder: i64,
|
||||||
folder_path: &str,
|
folder_path: &str,
|
||||||
folder_indexable: bool,
|
folder_indexable: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let descendants: Vec<(i64, String, bool)> = {
|
let descendants: Vec<(i64, String, bool)> = {
|
||||||
let mut stmt = tx.prepare(
|
let mut stmt = tx.prepare(
|
||||||
"WITH RECURSIVE sub(rowid, uid, name, path, blocked, depth) AS (
|
"WITH RECURSIVE sub(rowid, name, path, blocked, depth) AS (
|
||||||
SELECT n.rowid, n.uid, n.name,
|
SELECT n.rowid, n.name,
|
||||||
CASE WHEN ?2 = '' THEN n.name ELSE ?2 || '/' || n.name END,
|
CASE WHEN ?2 = '' THEN n.name ELSE ?2 || '/' || n.name END,
|
||||||
n.trashed, 0
|
n.trashed, 0
|
||||||
FROM nodes n WHERE n.parent_uid = ?1
|
FROM nodes n WHERE n.parent_lid = ?1
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT n.rowid, n.uid, n.name,
|
SELECT n.rowid, n.name,
|
||||||
sub.path || '/' || n.name,
|
sub.path || '/' || n.name,
|
||||||
MAX(sub.blocked, n.trashed), sub.depth + 1
|
MAX(sub.blocked, n.trashed), sub.depth + 1
|
||||||
FROM nodes n JOIN sub ON n.parent_uid = sub.uid
|
FROM nodes n JOIN sub ON n.parent_lid = sub.rowid
|
||||||
WHERE sub.depth < 256
|
WHERE sub.depth < 256
|
||||||
)
|
)
|
||||||
SELECT rowid, path, blocked FROM sub",
|
SELECT rowid, path, blocked FROM sub",
|
||||||
@@ -716,8 +1091,10 @@ fn reindex_subtree_tx(
|
|||||||
let mut set_path = tx.prepare("UPDATE nodes SET path = ?2 WHERE rowid = ?1")?;
|
let mut set_path = tx.prepare("UPDATE nodes SET path = ?2 WHERE rowid = ?1")?;
|
||||||
let mut unindex = tx.prepare("DELETE FROM nodes_fts WHERE rowid = ?1")?;
|
let mut unindex = tx.prepare("DELETE FROM nodes_fts WHERE rowid = ?1")?;
|
||||||
let mut index = tx.prepare(
|
let mut index = tx.prepare(
|
||||||
|
// Not a stub row (`Db::lids_for`): it is indexed when its node is
|
||||||
|
// written.
|
||||||
"INSERT INTO nodes_fts (rowid, name, path)
|
"INSERT INTO nodes_fts (rowid, name, path)
|
||||||
SELECT rowid, name, ?2 FROM nodes WHERE rowid = ?1",
|
SELECT rowid, name, ?2 FROM nodes WHERE rowid = ?1 AND node_json IS NOT NULL",
|
||||||
)?;
|
)?;
|
||||||
for (rowid, path, blocked) in descendants {
|
for (rowid, path, blocked) in descendants {
|
||||||
set_path.execute(params![rowid, path])?;
|
set_path.execute(params![rowid, path])?;
|
||||||
@@ -730,7 +1107,8 @@ fn reindex_subtree_tx(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn direct_child_uids_tx(tx: &Transaction<'_>, parent: &str) -> Result<Vec<String>> {
|
fn direct_child_uids_tx(tx: &Transaction<'_>, parent: &str) -> Result<Vec<String>> {
|
||||||
let mut stmt = tx.prepare("SELECT uid FROM nodes WHERE parent_uid = ?1")?;
|
let (key, parent) = parent_key(tx, parent)?;
|
||||||
|
let mut stmt = tx.prepare(&format!("SELECT uid FROM nodes WHERE {key} = ?1"))?;
|
||||||
let rows = stmt.query_map([parent], |row| row.get::<_, String>(0))?;
|
let rows = stmt.query_map([parent], |row| row.get::<_, String>(0))?;
|
||||||
let mut uids = Vec::new();
|
let mut uids = Vec::new();
|
||||||
for row in rows {
|
for row in rows {
|
||||||
@@ -755,14 +1133,14 @@ fn direct_child_uids_tx(tx: &Transaction<'_>, parent: &str) -> Result<Vec<String
|
|||||||
/// ends at one whose parent is a row we have already visited.
|
/// ends at one whose parent is a row we have already visited.
|
||||||
fn node_is_indexable_tx(tx: &Transaction<'_>, uid: &str) -> Result<bool> {
|
fn node_is_indexable_tx(tx: &Transaction<'_>, uid: &str) -> Result<bool> {
|
||||||
let indexable: i64 = tx.query_row(
|
let indexable: i64 = tx.query_row(
|
||||||
"WITH RECURSIVE ancestors(uid, parent_uid, trashed, depth, path) AS (
|
"WITH RECURSIVE ancestors(lid, parent_lid, trashed, depth, path) AS (
|
||||||
SELECT uid, parent_uid, trashed, 0, char(31) || uid || char(31)
|
SELECT lid, parent_lid, trashed, 0, ',' || lid || ','
|
||||||
FROM nodes WHERE uid = ?1
|
FROM nodes WHERE uid = ?1
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT n.uid, n.parent_uid, n.trashed, a.depth + 1,
|
SELECT n.lid, n.parent_lid, n.trashed, a.depth + 1,
|
||||||
a.path || n.uid || char(31)
|
a.path || n.lid || ','
|
||||||
FROM ancestors a JOIN nodes n ON n.uid = a.parent_uid
|
FROM ancestors a JOIN nodes n ON n.lid = a.parent_lid
|
||||||
WHERE instr(a.path, char(31) || n.uid || char(31)) = 0
|
WHERE instr(a.path, ',' || n.lid || ',') = 0
|
||||||
)
|
)
|
||||||
SELECT CASE
|
SELECT CASE
|
||||||
WHEN COUNT(*) = 0 THEN 0
|
WHEN COUNT(*) = 0 THEN 0
|
||||||
@@ -770,8 +1148,7 @@ fn node_is_indexable_tx(tx: &Transaction<'_>, uid: &str) -> Result<bool> {
|
|||||||
WHEN EXISTS (
|
WHEN EXISTS (
|
||||||
SELECT 1 FROM ancestors a
|
SELECT 1 FROM ancestors a
|
||||||
WHERE a.depth = (SELECT MAX(depth) FROM ancestors)
|
WHERE a.depth = (SELECT MAX(depth) FROM ancestors)
|
||||||
AND a.parent_uid IS NOT NULL
|
AND EXISTS (SELECT 1 FROM nodes p WHERE p.lid = a.parent_lid)
|
||||||
AND EXISTS (SELECT 1 FROM nodes p WHERE p.uid = a.parent_uid)
|
|
||||||
) THEN 0
|
) THEN 0
|
||||||
ELSE 1
|
ELSE 1
|
||||||
END
|
END
|
||||||
@@ -789,7 +1166,7 @@ fn tombstone_subtree_tx(tx: &Transaction<'_>, root_uid: &str) -> Result<()> {
|
|||||||
SELECT rowid, uid, node_json FROM nodes WHERE uid = ?1
|
SELECT rowid, uid, node_json FROM nodes WHERE uid = ?1
|
||||||
UNION
|
UNION
|
||||||
SELECT n.rowid, n.uid, n.node_json FROM nodes n
|
SELECT n.rowid, n.uid, n.node_json FROM nodes n
|
||||||
JOIN subtree s ON n.parent_uid = s.uid
|
JOIN subtree s ON n.parent_lid = s.rowid
|
||||||
)
|
)
|
||||||
SELECT rowid, uid, node_json FROM subtree",
|
SELECT rowid, uid, node_json FROM subtree",
|
||||||
)?;
|
)?;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
use rusqlite::params;
|
use rusqlite::params;
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
use super::Db;
|
use super::Db;
|
||||||
use crate::Result;
|
use crate::Result;
|
||||||
@@ -34,67 +34,380 @@ pub struct StoredPhoto {
|
|||||||
pub kind: crate::control::PhotoKind,
|
pub kind: crate::control::PhotoKind,
|
||||||
/// Whether the photo carries Proton's `Favorite` tag.
|
/// Whether the photo carries Proton's `Favorite` tag.
|
||||||
pub favorite: bool,
|
pub favorite: bool,
|
||||||
|
/// How many photos this photo's group holds — one shot taken as RAW+JPEG is
|
||||||
|
/// two files and one group. `1` for a photo that stands on its own.
|
||||||
|
pub group_size: usize,
|
||||||
|
/// Whether any member of the group is a camera raw file. The grid badges
|
||||||
|
/// that, because the JPEG it shows is not the whole of what is stored.
|
||||||
|
pub has_raw: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One row of a timeline replacement: `(uid, capture_time, name, media_type,
|
/// A town photos were taken in: its GeoNames id (see [`crate::places`]), how
|
||||||
/// favorite)`. The two trailing options are "what this refresh learned" — a
|
/// many photos it holds, and the newest of them.
|
||||||
/// `None` keeps whatever is already stored rather than clearing it.
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub type TimelineRow = (String, i64, Option<String>, Option<String>, Option<bool>);
|
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 {
|
impl Db {
|
||||||
/// Replace the timeline wholesale. Each item is
|
/// Replace the timeline wholesale. A row whose `resolved_at` is `Some` was
|
||||||
/// `(uid, capture_time, name, media_type, favorite)`; `favorite` is `None`
|
/// read from the server in this pass, so its fields are taken as they are —
|
||||||
/// when the refresh could not resolve that photo's node, in which case the
|
/// a `None` there means the server no longer reports that value. A row whose
|
||||||
/// flag already stored is kept rather than silently cleared.
|
/// `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<()> {
|
pub fn photos_replace(&self, items: &[TimelineRow]) -> Result<()> {
|
||||||
let mut conn = self.conn.lock();
|
let mut conn = self.conn.lock();
|
||||||
let tx = conn.transaction()?;
|
let tx = conn.transaction()?;
|
||||||
// `media_type` is learned-and-kept like the ratio and thumb verdict: the
|
// `name` and `media_type` are learned-and-kept like the ratio and thumb
|
||||||
// timeline DTO carries only the uid and capture time, so the daemon may
|
// verdict: the timeline DTO carries only the uid and capture time, so the
|
||||||
// not know a photo's media type yet when it replaces the timeline. Keep
|
// daemon may not know them when it replaces the timeline — because the
|
||||||
// any previously learned value so the Photos/Videos/Raw split survives a
|
// refresh skipped an already-resolved photo, or because resolving it
|
||||||
// refresh instead of collapsing back to name-extension guesses.
|
// failed. Keep any previously learned value so the tab split and the
|
||||||
let learned: HashMap<String, (Option<f64>, i64, Option<String>, bool)> = {
|
// RAW+JPEG grouping survive a refresh instead of collapsing to nothing:
|
||||||
let mut stmt =
|
// both are derived from the name. The photo relation is kept for the same
|
||||||
tx.prepare("SELECT uid, ratio, thumb_state, media_type, favorite FROM photos")?;
|
// 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| {
|
let rows = stmt.query_map([], |r| {
|
||||||
Ok((
|
Ok((
|
||||||
r.get::<_, String>(0)?,
|
r.get::<_, String>(0)?,
|
||||||
(r.get(1)?, r.get(2)?, r.get(3)?, r.get::<_, i64>(4)? != 0),
|
Learned {
|
||||||
|
name: r.get(1)?,
|
||||||
|
ratio: r.get(2)?,
|
||||||
|
thumb_state: r.get(3)?,
|
||||||
|
media_type: r.get(4)?,
|
||||||
|
favorite: r.get::<_, i64>(5)? != 0,
|
||||||
|
content_hash: r.get(6)?,
|
||||||
|
main_uid: r.get(7)?,
|
||||||
|
resolved_at: r.get(8)?,
|
||||||
|
location: r
|
||||||
|
.get::<_, Option<f64>>(9)?
|
||||||
|
.zip(r.get::<_, Option<f64>>(10)?),
|
||||||
|
similar_hash: r.get(11)?,
|
||||||
|
},
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
rows.collect::<rusqlite::Result<_>>()?
|
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", [])?;
|
tx.execute("DELETE FROM photos", [])?;
|
||||||
{
|
{
|
||||||
let mut stmt = tx.prepare(
|
let mut stmt = tx.prepare(
|
||||||
"INSERT INTO photos
|
"INSERT INTO photos
|
||||||
(uid, capture_time, name, ratio, thumb_state, seq, media_type, kind, favorite)
|
(uid, capture_time, name, ratio, thumb_state, seq, media_type, kind, favorite,
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
content_hash, main_uid, group_key, resolved_at, latitude, longitude,
|
||||||
|
place_id, similar_hash)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15,
|
||||||
|
?16, ?17)",
|
||||||
)?;
|
)?;
|
||||||
for (seq, (uid, capture_time, name, media_type, favorite)) in items.iter().enumerate() {
|
for (seq, ((row, carried, kind), group_key)) in
|
||||||
let (ratio, thumb_state, learned_media, learned_favorite) = learned
|
resolved.iter().zip(group_keys.iter()).enumerate()
|
||||||
.get(uid)
|
{
|
||||||
.cloned()
|
|
||||||
.unwrap_or((None, THUMB_UNKNOWN, None, false));
|
|
||||||
let media_type = media_type.clone().or(learned_media);
|
|
||||||
let favorite = favorite.unwrap_or(learned_favorite);
|
|
||||||
// The tab this photo lands in is derived here, once, so a page or
|
|
||||||
// count query is a plain indexed `WHERE kind = ?` rather than a
|
|
||||||
// reclassification of every row.
|
|
||||||
let kind =
|
|
||||||
crate::control::PhotoKind::classify(name.as_deref(), media_type.as_deref());
|
|
||||||
stmt.execute(params![
|
stmt.execute(params![
|
||||||
uid,
|
row.uid,
|
||||||
capture_time,
|
row.capture_time,
|
||||||
name,
|
carried.name,
|
||||||
ratio,
|
carried.ratio,
|
||||||
thumb_state,
|
carried.thumb_state,
|
||||||
seq as i64,
|
seq as i64,
|
||||||
media_type,
|
carried.media_type,
|
||||||
kind.as_i64(),
|
kind.as_i64(),
|
||||||
favorite as i64,
|
carried.favorite as i64,
|
||||||
|
carried.content_hash,
|
||||||
|
carried.main_uid,
|
||||||
|
group_key,
|
||||||
|
carried.resolved_at,
|
||||||
|
carried.location.map(|(lat, _)| lat),
|
||||||
|
carried.location.map(|(_, lon)| lon),
|
||||||
|
carried
|
||||||
|
.location
|
||||||
|
.and_then(|(lat, lon)| crate::places::nearest(lat, lon))
|
||||||
|
.map(|city| city.id),
|
||||||
|
carried.similar_hash,
|
||||||
])?;
|
])?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,11 +415,93 @@ impl Db {
|
|||||||
Ok(())
|
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,
|
/// One page of the persisted timeline, newest first. `kind`, when set,
|
||||||
/// restricts the page to one tab (Photos / Videos / Raw); `range`, 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
|
/// restricts it to a `[from, to)` capture-time window (epoch seconds) — the
|
||||||
/// date scrubber's jump. `offset` is relative to whatever the filters leave.
|
/// date scrubber's jump. `offset` is relative to whatever the filters leave.
|
||||||
/// `favorites` restricts the page to favourited photos.
|
/// `favorites` restricts the page to favorited photos, and `not_in_album`
|
||||||
|
/// to the shots no album holds any file of.
|
||||||
|
///
|
||||||
|
/// The page holds one row per *group*: the RAW and the JPEG of one shot are
|
||||||
|
/// one tile. The Raw tab is the exception and lists every raw file, because
|
||||||
|
/// that tab exists to answer "which raws do I have".
|
||||||
pub fn photos_page(
|
pub fn photos_page(
|
||||||
&self,
|
&self,
|
||||||
offset: usize,
|
offset: usize,
|
||||||
@@ -114,27 +509,38 @@ impl Db {
|
|||||||
kind: Option<crate::control::PhotoKind>,
|
kind: Option<crate::control::PhotoKind>,
|
||||||
range: Option<(i64, i64)>,
|
range: Option<(i64, i64)>,
|
||||||
favorites: bool,
|
favorites: bool,
|
||||||
|
not_in_album: bool,
|
||||||
) -> Result<Vec<StoredPhoto>> {
|
) -> Result<Vec<StoredPhoto>> {
|
||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
// Built up so any combination of the optional filters is one indexed
|
// Built up so any combination of the optional filters is one indexed
|
||||||
// query rather than a statement per case.
|
// query rather than a statement per case.
|
||||||
let mut sql = String::from(
|
let mut sql = String::from(PHOTO_SELECT);
|
||||||
"SELECT uid, capture_time, name, ratio, thumb_state, kind, favorite FROM photos",
|
|
||||||
);
|
|
||||||
let mut binds: Vec<i64> = Vec::new();
|
let mut binds: Vec<i64> = Vec::new();
|
||||||
let mut conds: Vec<String> = 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 {
|
if let Some(k) = kind {
|
||||||
binds.push(k.as_i64());
|
binds.push(k.as_i64());
|
||||||
conds.push(format!("kind = ?{}", binds.len()));
|
conds.push(format!("p.kind = ?{}", binds.len()));
|
||||||
}
|
}
|
||||||
if let Some((from, to)) = range {
|
if let Some((from, to)) = range {
|
||||||
binds.push(from);
|
binds.push(from);
|
||||||
conds.push(format!("capture_time >= ?{}", binds.len()));
|
conds.push(format!("p.capture_time >= ?{}", binds.len()));
|
||||||
binds.push(to);
|
binds.push(to);
|
||||||
conds.push(format!("capture_time < ?{}", binds.len()));
|
conds.push(format!("p.capture_time < ?{}", binds.len()));
|
||||||
}
|
}
|
||||||
if favorites {
|
if favorites {
|
||||||
conds.push("favorite = 1".to_string());
|
conds.push("p.favorite = 1".to_string());
|
||||||
|
}
|
||||||
|
// By group, not by file: a shot filed as its JPEG is filed, even though
|
||||||
|
// its RAW was never added anywhere.
|
||||||
|
if not_in_album {
|
||||||
|
conds.push(
|
||||||
|
"NOT EXISTS (SELECT 1 FROM album_photos a JOIN photos m ON m.uid = a.uid \
|
||||||
|
WHERE COALESCE(m.group_key, m.uid) = COALESCE(p.group_key, p.uid))"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if !conds.is_empty() {
|
if !conds.is_empty() {
|
||||||
sql.push_str(" WHERE ");
|
sql.push_str(" WHERE ");
|
||||||
@@ -145,47 +551,41 @@ impl Db {
|
|||||||
binds.push(offset as i64);
|
binds.push(offset as i64);
|
||||||
let offset_pos = binds.len();
|
let offset_pos = binds.len();
|
||||||
sql.push_str(&format!(
|
sql.push_str(&format!(
|
||||||
" ORDER BY seq LIMIT ?{limit_pos} OFFSET ?{offset_pos}"
|
" ORDER BY p.seq LIMIT ?{limit_pos} OFFSET ?{offset_pos}"
|
||||||
));
|
));
|
||||||
|
|
||||||
let mut stmt = conn.prepare(&sql)?;
|
let mut stmt = conn.prepare(&sql)?;
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
.query_map(rusqlite::params_from_iter(binds), |r| {
|
.query_map(rusqlite::params_from_iter(binds), stored_photo)?
|
||||||
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,
|
|
||||||
})
|
|
||||||
})?
|
|
||||||
.collect::<rusqlite::Result<_>>()?;
|
.collect::<rusqlite::Result<_>>()?;
|
||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The whole persisted timeline, in stored order. Only for whole-library
|
/// Every photo of one photo's group, in server order — the RAW and the JPEG
|
||||||
/// passes (the photo re-date) — the gallery pages with
|
/// of a shot, or just the photo itself when it stands alone. The lightbox
|
||||||
/// [`Db::photos_page`].
|
/// 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>> {
|
pub fn photos_all(&self) -> Result<Vec<StoredPhoto>> {
|
||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let sql = format!("{PHOTO_SELECT} ORDER BY p.seq");
|
||||||
"SELECT uid, capture_time, name, ratio, thumb_state, kind, favorite \
|
let mut stmt = conn.prepare(&sql)?;
|
||||||
FROM photos ORDER BY seq",
|
|
||||||
)?;
|
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
.query_map([], |r| {
|
.query_map([], stored_photo)?
|
||||||
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,
|
|
||||||
})
|
|
||||||
})?
|
|
||||||
.collect::<rusqlite::Result<_>>()?;
|
.collect::<rusqlite::Result<_>>()?;
|
||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
@@ -200,9 +600,15 @@ impl Db {
|
|||||||
kind: Option<crate::control::PhotoKind>,
|
kind: Option<crate::control::PhotoKind>,
|
||||||
) -> Result<Vec<(i32, i32, usize)>> {
|
) -> Result<Vec<(i32, i32, usize)>> {
|
||||||
let conn = self.read();
|
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 {
|
let (filter, binds): (&str, Vec<i64>) = match kind {
|
||||||
Some(k) => (" WHERE kind = ?1", vec![k.as_i64()]),
|
Some(crate::control::PhotoKind::Raw) => (" WHERE kind = 2", Vec::new()),
|
||||||
None => ("", 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!(
|
let sql = format!(
|
||||||
"SELECT CAST(strftime('%Y', capture_time, 'unixepoch', 'localtime') AS INTEGER) AS y, \
|
"SELECT CAST(strftime('%Y', capture_time, 'unixepoch', 'localtime') AS INTEGER) AS y, \
|
||||||
@@ -223,11 +629,267 @@ impl Db {
|
|||||||
Ok(rows)
|
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)`.
|
/// Per-tab counts for the Photos page subtitle: `(photos, videos, raw)`.
|
||||||
pub fn photos_counts(&self) -> Result<(usize, usize, usize)> {
|
pub fn photos_counts(&self) -> Result<(usize, usize, usize)> {
|
||||||
use crate::control::PhotoKind;
|
use crate::control::PhotoKind;
|
||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare("SELECT kind, COUNT(*) FROM photos GROUP BY kind")?;
|
// Photos and Videos count groups — one shot is one entry, however many
|
||||||
|
// files it was stored as. Raw counts files, because the Raw tab lists
|
||||||
|
// them individually.
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT kind, COUNT(*) FROM photos \
|
||||||
|
WHERE uid = COALESCE(group_key, uid) OR kind = 2 GROUP BY kind",
|
||||||
|
)?;
|
||||||
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?;
|
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?;
|
||||||
let (mut photos, mut videos, mut raw) = (0usize, 0usize, 0usize);
|
let (mut photos, mut videos, mut raw) = (0usize, 0usize, 0usize);
|
||||||
for row in rows {
|
for row in rows {
|
||||||
@@ -250,21 +912,9 @@ impl Db {
|
|||||||
}
|
}
|
||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
let placeholders = vec!["?"; uids.len()].join(",");
|
let placeholders = vec!["?"; uids.len()].join(",");
|
||||||
let mut stmt = conn.prepare(&format!(
|
let sql = format!("{PHOTO_SELECT} WHERE p.uid IN ({placeholders})");
|
||||||
"SELECT uid, capture_time, name, ratio, thumb_state, kind, favorite FROM photos
|
let mut stmt = conn.prepare(&sql)?;
|
||||||
WHERE uid IN ({placeholders})"
|
let rows = stmt.query_map(rusqlite::params_from_iter(uids), stored_photo)?;
|
||||||
))?;
|
|
||||||
let rows = stmt.query_map(rusqlite::params_from_iter(uids), |r| {
|
|
||||||
Ok(StoredPhoto {
|
|
||||||
uid: r.get(0)?,
|
|
||||||
capture_time: r.get(1)?,
|
|
||||||
name: r.get(2)?,
|
|
||||||
ratio: r.get(3)?,
|
|
||||||
thumb_state: r.get(4)?,
|
|
||||||
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
|
||||||
favorite: r.get::<_, i64>(6)? != 0,
|
|
||||||
})
|
|
||||||
})?;
|
|
||||||
let mut photos = Vec::new();
|
let mut photos = Vec::new();
|
||||||
for row in rows {
|
for row in rows {
|
||||||
photos.push(row?);
|
photos.push(row?);
|
||||||
@@ -272,7 +922,7 @@ impl Db {
|
|||||||
Ok(photos)
|
Ok(photos)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record a photo's favourite flag locally, after the server accepted the
|
/// Record a photo's favorite flag locally, after the server accepted the
|
||||||
/// change. A uid the timeline does not hold is a no-op — an album photo on
|
/// 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.
|
/// someone else's volume is never in our own `photos` table.
|
||||||
pub fn photos_set_favorite(&self, uid: &str, favorite: bool) -> Result<()> {
|
pub fn photos_set_favorite(&self, uid: &str, favorite: bool) -> Result<()> {
|
||||||
|
|||||||
@@ -64,11 +64,11 @@ impl Db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Whether `uid` is pinned — directly, or because a strict ancestor folder
|
/// Whether `uid` is pinned — directly, or because a strict ancestor folder
|
||||||
/// is pinned recursively. The ancestor check walks `parent_uid` to the root
|
/// is pinned recursively. The ancestor check walks `parent_lid` to the root
|
||||||
/// via a CTE; a direct pin is honoured even when the node has no `nodes` row
|
/// 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).
|
/// yet (e.g. a CLI that never hydrates the node cache).
|
||||||
///
|
///
|
||||||
/// The walk is depth-capped like [`super::utils::path_of`]: a `parent_uid`
|
/// The walk is depth-capped like [`super::utils::path_of`]: a parent
|
||||||
/// cycle is corrupt data the API can hand us, and `UNION ALL` over one never
|
/// 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
|
/// terminates — while holding the daemon's only SQLite connection, on a path
|
||||||
/// that runs per cached read.
|
/// that runs per cached read.
|
||||||
@@ -85,11 +85,11 @@ impl Db {
|
|||||||
let anc: Option<i64> = conn
|
let anc: Option<i64> = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
&format!(
|
&format!(
|
||||||
"WITH RECURSIVE anc(uid, parent_uid, depth) AS (
|
"WITH RECURSIVE anc(uid, parent_lid, depth) AS (
|
||||||
SELECT uid, parent_uid, 0 FROM nodes WHERE uid = ?1
|
SELECT uid, parent_lid, 0 FROM nodes WHERE uid = ?1
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT n.uid, n.parent_uid, anc.depth + 1
|
SELECT n.uid, n.parent_lid, anc.depth + 1
|
||||||
FROM nodes n JOIN anc ON n.uid = anc.parent_uid
|
FROM nodes n JOIN anc ON n.lid = anc.parent_lid
|
||||||
WHERE anc.depth < {MAX_PATH_DEPTH}
|
WHERE anc.depth < {MAX_PATH_DEPTH}
|
||||||
)
|
)
|
||||||
SELECT 1 FROM anc a JOIN pins p ON p.uid = a.uid
|
SELECT 1 FROM anc a JOIN pins p ON p.uid = a.uid
|
||||||
@@ -109,10 +109,11 @@ impl Db {
|
|||||||
pub fn pinned_uids(&self) -> Result<Vec<String>> {
|
pub fn pinned_uids(&self) -> Result<Vec<String>> {
|
||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"WITH RECURSIVE sub(uid) AS (
|
"WITH RECURSIVE sub(uid, lid) AS (
|
||||||
SELECT uid FROM pins WHERE recursive = 1
|
SELECT p.uid, n.lid FROM pins p LEFT JOIN nodes n ON n.uid = p.uid
|
||||||
|
WHERE p.recursive = 1
|
||||||
UNION
|
UNION
|
||||||
SELECT n.uid FROM nodes n JOIN sub ON n.parent_uid = sub.uid
|
SELECT n.uid, n.lid FROM nodes n JOIN sub ON n.parent_lid = sub.lid
|
||||||
)
|
)
|
||||||
SELECT uid FROM sub
|
SELECT uid FROM sub
|
||||||
UNION
|
UNION
|
||||||
@@ -126,15 +127,16 @@ impl Db {
|
|||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every descendant uid of `folder` (all depths), via a `parent_uid` CTE.
|
/// Every descendant uid of `folder` (all depths), via a `parent_lid` CTE.
|
||||||
/// Used to evict a recursively-pinned subtree's cached blobs on unpin.
|
/// Used to evict a recursively-pinned subtree's cached blobs on unpin.
|
||||||
pub fn descendants(&self, folder: &str) -> Result<Vec<String>> {
|
pub fn descendants(&self, folder: &str) -> Result<Vec<String>> {
|
||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"WITH RECURSIVE sub(uid) AS (
|
"WITH RECURSIVE sub(uid, lid) AS (
|
||||||
SELECT uid FROM nodes WHERE parent_uid = ?1
|
SELECT c.uid, c.lid FROM nodes c JOIN nodes f ON c.parent_lid = f.lid
|
||||||
|
WHERE f.uid = ?1
|
||||||
UNION
|
UNION
|
||||||
SELECT n.uid FROM nodes n JOIN sub ON n.parent_uid = sub.uid
|
SELECT n.uid, n.lid FROM nodes n JOIN sub ON n.parent_lid = sub.lid
|
||||||
)
|
)
|
||||||
SELECT uid FROM sub",
|
SELECT uid FROM sub",
|
||||||
)?;
|
)?;
|
||||||
|
|||||||
@@ -95,11 +95,27 @@ impl Db {
|
|||||||
/// Resolve a persisted node's effective access through its nearest recorded
|
/// Resolve a persisted node's effective access through its nearest recorded
|
||||||
/// share root. `None` means the node itself is not persisted, which callers
|
/// share root. `None` means the node itself is not persisted, which callers
|
||||||
/// must not treat as owned: stale handles need to fail closed.
|
/// 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>> {
|
pub fn effective_node_access(&self, uid: &NodeUid) -> Result<Option<Access>> {
|
||||||
let conn = self.read();
|
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(
|
let exists: bool = conn.query_row(
|
||||||
"SELECT EXISTS(SELECT 1 FROM nodes WHERE uid = ?1)",
|
"SELECT EXISTS(SELECT 1 FROM nodes WHERE uid = ?1 AND node_json IS NOT NULL)",
|
||||||
[uid.to_string()],
|
[&key],
|
||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
)?;
|
)?;
|
||||||
if !exists {
|
if !exists {
|
||||||
@@ -107,25 +123,26 @@ impl Db {
|
|||||||
}
|
}
|
||||||
let value = conn
|
let value = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"WITH RECURSIVE ancestors(uid, parent_uid, depth, path) AS (
|
"WITH RECURSIVE ancestors(uid, parent_uid, parent_lid, depth, path) AS (
|
||||||
SELECT uid, parent_uid, 0, char(31) || uid || char(31)
|
SELECT uid, parent_uid, parent_lid, 0, char(31) || uid || char(31)
|
||||||
FROM nodes WHERE uid = ?1
|
FROM nodes WHERE uid = ?1
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT a.parent_uid, n.parent_uid, a.depth + 1,
|
SELECT COALESCE(n.uid, a.parent_uid), n.parent_uid, n.parent_lid,
|
||||||
a.path || a.parent_uid || char(31)
|
a.depth + 1,
|
||||||
|
a.path || COALESCE(n.uid, a.parent_uid) || char(31)
|
||||||
FROM ancestors a
|
FROM ancestors a
|
||||||
LEFT JOIN nodes n ON n.uid = a.parent_uid
|
LEFT JOIN nodes n ON n.lid = a.parent_lid
|
||||||
WHERE a.parent_uid IS NOT NULL
|
WHERE a.parent_uid IS NOT NULL
|
||||||
AND instr(
|
AND instr(
|
||||||
a.path,
|
a.path,
|
||||||
char(31) || a.parent_uid || char(31)
|
char(31) || COALESCE(n.uid, a.parent_uid) || char(31)
|
||||||
) = 0
|
) = 0
|
||||||
)
|
)
|
||||||
SELECT sa.access
|
SELECT sa.access
|
||||||
FROM ancestors a JOIN share_access sa ON sa.root_uid = a.uid
|
FROM ancestors a JOIN share_access sa ON sa.root_uid = a.uid
|
||||||
ORDER BY a.depth ASC
|
ORDER BY a.depth ASC
|
||||||
LIMIT 1",
|
LIMIT 1",
|
||||||
[uid.to_string()],
|
[&key],
|
||||||
|row| row.get::<_, String>(0),
|
|row| row.get::<_, String>(0),
|
||||||
)
|
)
|
||||||
.optional()?;
|
.optional()?;
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ pub struct StoredSyncFolder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// One per-file sync baseline row: what a path looked like on both sides at the
|
/// One per-file sync baseline row: what a path looked like on both sides at the
|
||||||
/// last successful sync, so the next reconcile can tell which side changed
|
/// last successful sync, so the next reconcile can tell which side changed.
|
||||||
/// (devices.md Phase 2). `remote_rev`/`remote_hash` hold the remote signature —
|
/// `remote_rev`/`remote_hash` hold the remote signature —
|
||||||
/// its modification time and size as strings — since no cheap content hash is
|
/// its modification time and size as strings — since no cheap content hash is
|
||||||
/// exposed; change detection is `(mtime, size)` on each side.
|
/// exposed; change detection is `(mtime, size)` on each side.
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
@@ -39,7 +39,7 @@ pub struct StoredSyncEntry {
|
|||||||
///
|
///
|
||||||
/// `local_mtime` alone cannot tell an edit that landed inside the same
|
/// `local_mtime` alone cannot tell an edit that landed inside the same
|
||||||
/// second as the last sync — and left the file the same length — from no
|
/// second as the last sync — and left the file the same length — from no
|
||||||
/// edit at all, so those never uploaded (bugs.md B25). `None` on a row
|
/// edit at all, so those never uploaded (`docs/BUGS.md` B25). `None` on a row
|
||||||
/// written before schema 28, which the comparison reads as "compare whole
|
/// written before schema 28, which the comparison reads as "compare whole
|
||||||
/// seconds", so upgrading does not make an entire mirror look changed.
|
/// seconds", so upgrading does not make an entire mirror look changed.
|
||||||
pub local_mtime_ns: Option<i64>,
|
pub local_mtime_ns: Option<i64>,
|
||||||
@@ -139,6 +139,17 @@ impl Db {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Re-point a synced folder at the share that now holds its remote folder,
|
||||||
|
/// after the folder moved to another device without changing its uid.
|
||||||
|
pub fn sync_folder_set_share(&self, id: i64, share_id: &str) -> Result<()> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE sync_folder SET remote_share_id = ?2 WHERE id = ?1",
|
||||||
|
params![id, share_id],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Queue (or, with `None`, withdraw) a mode the folder should move to once it
|
/// Queue (or, with `None`, withdraw) a mode the folder should move to once it
|
||||||
/// is safe to switch.
|
/// is safe to switch.
|
||||||
pub fn sync_folder_set_pending_mode(&self, id: i64, mode: Option<&str>) -> Result<()> {
|
pub fn sync_folder_set_pending_mode(&self, id: i64, mode: Option<&str>) -> Result<()> {
|
||||||
@@ -260,10 +271,92 @@ impl Db {
|
|||||||
Ok(())
|
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:
|
/// Drop the entire baseline for a folder. Used when flipping ondemand→mirror:
|
||||||
/// the local tree was evicted, so the old baseline is stale and would make the
|
/// the local tree was evicted, so the old baseline is stale and would make the
|
||||||
/// next reconcile mistake "locally deleted" for "must re-download". Clearing it
|
/// next reconcile mistake "locally deleted" for "must re-download". Clearing it
|
||||||
/// leaves an empty baseline + full remote = pure download (devices.md P3).
|
/// leaves an empty baseline + full remote = pure download.
|
||||||
pub fn sync_entries_clear(&self, folder_id: i64) -> Result<()> {
|
pub fn sync_entries_clear(&self, folder_id: i64) -> Result<()> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.conn.lock();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
//! Trashed nodes, persisted so the trash view opens without a round-trip.
|
//! Trashed nodes, persisted so the trash view opens without a round-trip.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use rusqlite::params;
|
use rusqlite::params;
|
||||||
|
|
||||||
use super::Db;
|
use super::Db;
|
||||||
@@ -11,6 +13,13 @@ pub struct StoredTrash {
|
|||||||
pub is_dir: bool,
|
pub is_dir: bool,
|
||||||
pub size: i64,
|
pub size: i64,
|
||||||
pub mtime: 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 {
|
impl Db {
|
||||||
@@ -20,7 +29,8 @@ impl Db {
|
|||||||
tx.execute("DELETE FROM trash", [])?;
|
tx.execute("DELETE FROM trash", [])?;
|
||||||
{
|
{
|
||||||
let mut stmt = tx.prepare(
|
let mut stmt = tx.prepare(
|
||||||
"INSERT INTO trash (uid, name, is_dir, size, mtime) VALUES (?1, ?2, ?3, ?4, ?5)",
|
"INSERT INTO trash (uid, name, is_dir, size, mtime, parent_uid, trashed_at) \
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||||
)?;
|
)?;
|
||||||
for item in items {
|
for item in items {
|
||||||
stmt.execute(params![
|
stmt.execute(params![
|
||||||
@@ -28,7 +38,9 @@ impl Db {
|
|||||||
item.name,
|
item.name,
|
||||||
item.is_dir as i64,
|
item.is_dir as i64,
|
||||||
item.size,
|
item.size,
|
||||||
item.mtime
|
item.mtime,
|
||||||
|
item.parent_uid,
|
||||||
|
item.trashed_at
|
||||||
])?;
|
])?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -41,7 +53,7 @@ impl Db {
|
|||||||
pub fn trash_list(&self) -> Result<Vec<StoredTrash>> {
|
pub fn trash_list(&self) -> Result<Vec<StoredTrash>> {
|
||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT uid, name, is_dir, size, mtime FROM trash
|
"SELECT uid, name, is_dir, size, mtime, parent_uid, trashed_at FROM trash
|
||||||
ORDER BY is_dir DESC, name COLLATE NOCASE",
|
ORDER BY is_dir DESC, name COLLATE NOCASE",
|
||||||
)?;
|
)?;
|
||||||
let rows = stmt.query_map([], |r| {
|
let rows = stmt.query_map([], |r| {
|
||||||
@@ -51,6 +63,8 @@ impl Db {
|
|||||||
is_dir: r.get::<_, i64>(2)? != 0,
|
is_dir: r.get::<_, i64>(2)? != 0,
|
||||||
size: r.get(3)?,
|
size: r.get(3)?,
|
||||||
mtime: r.get(4)?,
|
mtime: r.get(4)?,
|
||||||
|
parent_uid: r.get(5)?,
|
||||||
|
trashed_at: r.get(6)?,
|
||||||
})
|
})
|
||||||
})?;
|
})?;
|
||||||
let mut items = Vec::new();
|
let mut items = Vec::new();
|
||||||
@@ -60,5 +74,33 @@ impl Db {
|
|||||||
Ok(items)
|
Ok(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- device sync (devices.md) -----------------------------------------
|
/// Every trashed node paired with the folder it was trashed from, for the
|
||||||
|
/// restore's tree walk. Kept separate from [`Db::trash_list`] because the
|
||||||
|
/// restore wants only the links, not the names, sizes or ordering.
|
||||||
|
pub fn trash_parents(&self) -> Result<Vec<(String, Option<String>)>> {
|
||||||
|
let conn = self.read();
|
||||||
|
let mut stmt = conn.prepare("SELECT uid, parent_uid FROM trash")?;
|
||||||
|
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?;
|
||||||
|
let mut pairs = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
pairs.push(row?);
|
||||||
|
}
|
||||||
|
Ok(pairs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When each node now in the trash was first seen there, keyed by uid, so
|
||||||
|
/// a refresh can carry the times over into the listing it writes.
|
||||||
|
pub fn trash_seen(&self) -> Result<HashMap<String, Option<i64>>> {
|
||||||
|
let conn = self.read();
|
||||||
|
let mut stmt = conn.prepare("SELECT uid, trashed_at FROM trash")?;
|
||||||
|
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?;
|
||||||
|
let mut seen = HashMap::new();
|
||||||
|
for row in rows {
|
||||||
|
let (uid, at) = row?;
|
||||||
|
seen.insert(uid, at);
|
||||||
|
}
|
||||||
|
Ok(seen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- device sync -----------------------------------------
|
||||||
}
|
}
|
||||||
@@ -72,18 +72,18 @@ pub(super) fn path_of(conn: &Connection, uid: &str) -> Result<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve a path by walking `parent_uid` to the root via a recursive CTE.
|
/// Resolve a path by walking `parent_lid` to the root via a recursive CTE.
|
||||||
///
|
///
|
||||||
/// A cycle in `parent_uid` is corrupt data the API can still hand us, and
|
/// A cycle in the parents is corrupt data the API can still hand us, and
|
||||||
/// `UNION ALL` over one never terminates — the walk is therefore depth-capped
|
/// `UNION ALL` over one never terminates — the walk is therefore depth-capped
|
||||||
/// and returns the truncated path rather than hanging the caller.
|
/// and returns the truncated path rather than hanging the caller.
|
||||||
pub(super) fn walk_path_of(conn: &Connection, uid: &str) -> Result<String> {
|
pub(super) fn walk_path_of(conn: &Connection, uid: &str) -> Result<String> {
|
||||||
let mut stmt = conn.prepare(&format!(
|
let mut stmt = conn.prepare(&format!(
|
||||||
"WITH RECURSIVE anc(uid, parent_uid, name, depth) AS (
|
"WITH RECURSIVE anc(parent_lid, parent_uid, name, depth) AS (
|
||||||
SELECT uid, parent_uid, name, 0 FROM nodes WHERE uid = ?1
|
SELECT parent_lid, parent_uid, name, 0 FROM nodes WHERE uid = ?1
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT n.uid, n.parent_uid, n.name, anc.depth + 1
|
SELECT n.parent_lid, n.parent_uid, n.name, anc.depth + 1
|
||||||
FROM nodes n JOIN anc ON n.uid = anc.parent_uid
|
FROM nodes n JOIN anc ON n.lid = anc.parent_lid
|
||||||
WHERE anc.depth < {MAX_PATH_DEPTH}
|
WHERE anc.depth < {MAX_PATH_DEPTH}
|
||||||
)
|
)
|
||||||
SELECT name, parent_uid FROM anc ORDER BY depth DESC"
|
SELECT name, parent_uid FROM anc ORDER BY depth DESC"
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ pub enum Error {
|
|||||||
Proton(#[from] proton_sdk::ProtonError),
|
Proton(#[from] proton_sdk::ProtonError),
|
||||||
|
|
||||||
#[error("keyring: {0}")]
|
#[error("keyring: {0}")]
|
||||||
Keyring(#[from] keyring::Error),
|
Keyring(#[from] keyring_core::Error),
|
||||||
|
|
||||||
#[error("serialization: {0}")]
|
#[error("serialization: {0}")]
|
||||||
Serde(#[from] serde_json::Error),
|
Serde(#[from] serde_json::Error),
|
||||||
@@ -32,6 +32,16 @@ pub enum Error {
|
|||||||
#[error("saved session can no longer unlock your keys — run `pdfs login` again")]
|
#[error("saved session can no longer unlock your keys — run `pdfs login` again")]
|
||||||
ReloginRequired,
|
ReloginRequired,
|
||||||
|
|
||||||
|
/// The account has a separate mailbox password that has not been entered
|
||||||
|
/// since login, so its keys cannot be unlocked. The fix is
|
||||||
|
/// [`auth::unlock`](crate::auth::unlock), not a new login.
|
||||||
|
#[error("account is locked — enter the mailbox password with `pdfs unlock`")]
|
||||||
|
Locked,
|
||||||
|
|
||||||
|
/// The mailbox password was checked against the account and is wrong.
|
||||||
|
#[error("that mailbox password does not unlock your account")]
|
||||||
|
WrongMailboxPassword,
|
||||||
|
|
||||||
/// The login was gated behind human verification. Not a failure the user can
|
/// The login was gated behind human verification. Not a failure the user can
|
||||||
/// fix by retrying: the challenge has to be solved and the login restarted
|
/// fix by retrying: the challenge has to be solved and the login restarted
|
||||||
/// with the resulting token (see [`auth::login_verified`](crate::auth::login_verified)).
|
/// with the resulting token (see [`auth::login_verified`](crate::auth::login_verified)).
|
||||||
@@ -66,6 +76,20 @@ impl Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Error {
|
||||||
|
/// Whether a control-socket round-trip gave up waiting for the reply: the
|
||||||
|
/// daemon is running but stuck, as opposed to not running at all.
|
||||||
|
pub fn is_timeout(&self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
Error::Io(e) if matches!(
|
||||||
|
e.kind(),
|
||||||
|
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub type Result<T> = std::result::Result<T, Error>;
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
/// A failure on its way to a front-end: prose for the user, plus the
|
/// A failure on its way to a front-end: prose for the user, plus the
|
||||||
@@ -185,9 +209,11 @@ impl From<serde_json::Error> for CoreError {
|
|||||||
impl From<Error> for CoreError {
|
impl From<Error> for CoreError {
|
||||||
fn from(e: Error) -> Self {
|
fn from(e: Error) -> Self {
|
||||||
let kind = match &e {
|
let kind = match &e {
|
||||||
Error::NotLoggedIn | Error::ReloginRequired | Error::HumanVerificationRequired(_) => {
|
Error::NotLoggedIn
|
||||||
ErrorKind::Denied
|
| Error::ReloginRequired
|
||||||
}
|
| Error::Locked
|
||||||
|
| Error::WrongMailboxPassword
|
||||||
|
| Error::HumanVerificationRequired(_) => ErrorKind::Denied,
|
||||||
Error::Proton(proton_sdk::ProtonError::Api(api)) => api_kind(api.code),
|
Error::Proton(proton_sdk::ProtonError::Api(api)) => api_kind(api.code),
|
||||||
// A `ProtonError` that never reached the API is a transport failure.
|
// A `ProtonError` that never reached the API is a transport failure.
|
||||||
Error::Proton(_) => ErrorKind::Offline,
|
Error::Proton(_) => ErrorKind::Offline,
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
//! 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.
|
||||||
|
//!
|
||||||
|
//! 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;
|
||||||
|
|
||||||
|
/// Key description. Namespaced so it cannot collide with another program's key.
|
||||||
|
#[cfg(not(test))]
|
||||||
|
const DESCRIPTION: &[u8] = b"pdfs:key-passphrases\0";
|
||||||
|
/// Tests must never touch a real unlock.
|
||||||
|
#[cfg(test)]
|
||||||
|
const DESCRIPTION: &[u8] = b"pdfs:key-passphrases: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() -> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store `secret`, replacing any earlier one.
|
||||||
|
pub fn store(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(),
|
||||||
|
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 _ = clear();
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the stored secret, or `None` when there is none.
|
||||||
|
pub fn load() -> io::Result<Option<Zeroizing<Vec<u8>>>> {
|
||||||
|
let Some(id) = find()? 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() -> io::Result<()> {
|
||||||
|
let Some(id) = find()? 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 store(b"probe").is_err() {
|
||||||
|
eprintln!("kernel keyring unavailable; skipping");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let saved = load().unwrap().expect("stored secret is readable");
|
||||||
|
assert_eq!(saved.as_slice(), b"probe");
|
||||||
|
|
||||||
|
store(b"replaced").unwrap();
|
||||||
|
assert_eq!(load().unwrap().unwrap().as_slice(), b"replaced");
|
||||||
|
|
||||||
|
clear().unwrap();
|
||||||
|
assert!(load().unwrap().is_none());
|
||||||
|
clear().unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,17 +10,21 @@ pub mod config;
|
|||||||
pub mod control;
|
pub mod control;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod kernelkey;
|
||||||
pub mod localindex;
|
pub mod localindex;
|
||||||
pub mod menu;
|
pub mod menu;
|
||||||
pub mod mounts;
|
pub mod mounts;
|
||||||
pub mod opener;
|
pub mod opener;
|
||||||
|
pub mod places;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
pub mod sdkcache;
|
pub mod sdkcache;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
pub mod service;
|
pub mod service;
|
||||||
pub mod shell;
|
pub mod shell;
|
||||||
|
pub mod similar;
|
||||||
pub mod syncignore;
|
pub mod syncignore;
|
||||||
pub mod takeout;
|
pub mod takeout;
|
||||||
|
pub mod tray;
|
||||||
|
|
||||||
pub use access::{Access, access_for, perm_bits};
|
pub use access::{Access, access_for, perm_bits};
|
||||||
pub use error::{CoreError, CoreResult, Error, Result};
|
pub use error::{CoreError, CoreResult, Error, Result};
|
||||||
|
|||||||
@@ -199,6 +199,9 @@ fn to_entry(entry: &ignore::DirEntry) -> Option<LocalEntry> {
|
|||||||
/// The paths a scan should never descend into: the Drive mountpoint (walking it
|
/// 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
|
/// would fault every remote node in through FUSE) plus our own state and cache
|
||||||
/// dirs, which hold blobs no user searches for by name.
|
/// 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> {
|
pub fn default_excludes(mountpoint: &Path, state_dir: &Path, cache_dir: &Path) -> Vec<PathBuf> {
|
||||||
vec![
|
vec![
|
||||||
mountpoint.to_path_buf(),
|
mountpoint.to_path_buf(),
|
||||||
@@ -207,6 +210,66 @@ pub fn default_excludes(mountpoint: &Path, state_dir: &Path, cache_dir: &Path) -
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every mountpoint strictly below `root`, read from `/proc/self/mounts`.
|
||||||
|
///
|
||||||
|
/// [`scan`] already asks the walker to stay on one filesystem, but that check is
|
||||||
|
/// a *stat of the mountpoint* (`ignore`'s `is_same_file_system`), performed after
|
||||||
|
/// the directory is queued — so the walker has to touch a foreign mount to learn
|
||||||
|
/// it should skip it. On a dead network FUSE mount (an sshfs whose server is
|
||||||
|
/// gone, an unresponsive rclone) that stat blocks in `request_wait_answer` with
|
||||||
|
/// nobody left to answer it, and blocks *uninterruptibly*: the walker thread then
|
||||||
|
/// ignores SIGKILL, so the daemon can never exit, systemd's stop times out, and
|
||||||
|
/// the half-dead process keeps the cache.db lock that the next start needs
|
||||||
|
/// (docs/BUGS.md B90).
|
||||||
|
///
|
||||||
|
/// Excluding the mountpoints by path avoids that stat entirely. It does not
|
||||||
|
/// change what ends up indexed: a nested mount is a different device, so
|
||||||
|
/// `same_file_system(true)` was already going to skip it.
|
||||||
|
pub fn nested_mount_points(root: &Path) -> Vec<PathBuf> {
|
||||||
|
let Ok(mounts) = std::fs::read_to_string("/proc/self/mounts") else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
parse_nested_mount_points(&mounts, root)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The mountpoint-field parse behind [`nested_mount_points`], split out so it can
|
||||||
|
/// be tested without a real `/proc`.
|
||||||
|
fn parse_nested_mount_points(mounts: &str, root: &Path) -> Vec<PathBuf> {
|
||||||
|
mounts
|
||||||
|
.lines()
|
||||||
|
.filter_map(|line| line.split_whitespace().nth(1))
|
||||||
|
.map(|field| PathBuf::from(unescape_mount_field(field)))
|
||||||
|
// Strictly below: `root` itself being a mountpoint must not exclude the
|
||||||
|
// whole scan.
|
||||||
|
.filter(|path| path.starts_with(root) && path != root)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Undo the octal escaping `/proc/self/mounts` applies to space, tab, newline and
|
||||||
|
/// backslash in path fields. An un-unescaped `\040` would simply fail to match a
|
||||||
|
/// real path, silently putting a mount with a space in its name back in the walk.
|
||||||
|
fn unescape_mount_field(field: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(field.len());
|
||||||
|
let mut rest = field;
|
||||||
|
while let Some(at) = rest.find('\\') {
|
||||||
|
out.push_str(&rest[..at]);
|
||||||
|
let escape = rest[at + 1..].get(..3);
|
||||||
|
match escape.and_then(|digits| u8::from_str_radix(digits, 8).ok()) {
|
||||||
|
Some(byte) => {
|
||||||
|
out.push(byte as char);
|
||||||
|
rest = &rest[at + 4..];
|
||||||
|
}
|
||||||
|
// Not an octal escape after all — keep the backslash verbatim.
|
||||||
|
None => {
|
||||||
|
out.push('\\');
|
||||||
|
rest = &rest[at + 1..];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push_str(rest);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -268,4 +331,42 @@ mod tests {
|
|||||||
|
|
||||||
std::fs::remove_dir_all(&tmp).unwrap();
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -110,6 +110,9 @@ pub struct MountSpec {
|
|||||||
/// Live device sync progress, absent when no pass is running.
|
/// Live device sync progress, absent when no pass is running.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub progress: Option<SyncProgress>,
|
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)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
//! Offline reverse geocoding: the town a photo was taken in, from its GPS
|
||||||
|
//! position, without asking any service.
|
||||||
|
//!
|
||||||
|
//! The towns are GeoNames' `cities15000` dump (every place with more than
|
||||||
|
//! 15,000 people), trimmed to id, name, position and country in
|
||||||
|
//! `data/cities15000.tsv` by `scripts/geonames-cities.py`. The script drops the
|
||||||
|
//! parts of a city, such as Kreuzberg or "Paris 15 Vaugirard", so that a photo
|
||||||
|
//! lands in the town people would name. GeoNames publishes the dump under
|
||||||
|
//! CC BY 4.0, which is why the About dialog credits it.
|
||||||
|
//!
|
||||||
|
//! `data/countries.tsv` maps each country code to its ISO 3166-1 English name,
|
||||||
|
//! as the `iso-codes` project spells it, so a front end can translate it
|
||||||
|
//! through that project's `iso_3166-1` gettext domain.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
/// How far from the nearest town a photo may be and still count as taken
|
||||||
|
/// there, in km. Farther out (at sea, in the wilderness) it has no place.
|
||||||
|
pub const MAX_DISTANCE_KM: f64 = 50.0;
|
||||||
|
|
||||||
|
/// Mean Earth radius in km, for the haversine distance.
|
||||||
|
const EARTH_RADIUS_KM: f64 = 6371.0;
|
||||||
|
|
||||||
|
const CITIES: &str = include_str!("../data/cities15000.tsv");
|
||||||
|
const COUNTRIES: &str = include_str!("../data/countries.tsv");
|
||||||
|
|
||||||
|
/// One town of the table.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct City {
|
||||||
|
/// The GeoNames id, stable across dumps.
|
||||||
|
pub id: u32,
|
||||||
|
pub name: &'static str,
|
||||||
|
pub latitude: f64,
|
||||||
|
pub longitude: f64,
|
||||||
|
/// ISO 3166-1 alpha-2 country code.
|
||||||
|
pub country: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The towns, and an index of them by whole-degree cell.
|
||||||
|
struct Table {
|
||||||
|
cities: Vec<City>,
|
||||||
|
cells: HashMap<(i32, i32), Vec<usize>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn table() -> &'static Table {
|
||||||
|
static TABLE: OnceLock<Table> = OnceLock::new();
|
||||||
|
TABLE.get_or_init(|| {
|
||||||
|
let cities: Vec<City> = CITIES.lines().filter_map(parse_city).collect();
|
||||||
|
let mut cells: HashMap<(i32, i32), Vec<usize>> = HashMap::new();
|
||||||
|
for (index, city) in cities.iter().enumerate() {
|
||||||
|
cells
|
||||||
|
.entry(cell(city.latitude, city.longitude))
|
||||||
|
.or_default()
|
||||||
|
.push(index);
|
||||||
|
}
|
||||||
|
Table { cities, cells }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_city(line: &'static str) -> Option<City> {
|
||||||
|
let mut fields = line.split('\t');
|
||||||
|
Some(City {
|
||||||
|
id: fields.next()?.parse().ok()?,
|
||||||
|
name: fields.next()?,
|
||||||
|
latitude: fields.next()?.parse().ok()?,
|
||||||
|
longitude: fields.next()?.parse().ok()?,
|
||||||
|
country: fields.next()?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cell(latitude: f64, longitude: f64) -> (i32, i32) {
|
||||||
|
(latitude.floor() as i32, longitude.floor() as i32)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Great-circle distance between two positions, in km.
|
||||||
|
pub fn distance_km(a: (f64, f64), b: (f64, f64)) -> f64 {
|
||||||
|
let (lat1, lat2) = (a.0.to_radians(), b.0.to_radians());
|
||||||
|
let dlat = lat2 - lat1;
|
||||||
|
let dlon = (b.1 - a.1).to_radians();
|
||||||
|
let h = (dlat / 2.0).sin().powi(2) + lat1.cos() * lat2.cos() * (dlon / 2.0).sin().powi(2);
|
||||||
|
2.0 * EARTH_RADIUS_KM * h.sqrt().min(1.0).asin()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The town nearest to a position, if one lies within [`MAX_DISTANCE_KM`].
|
||||||
|
pub fn nearest(latitude: f64, longitude: f64) -> Option<&'static City> {
|
||||||
|
if !latitude.is_finite() || !longitude.is_finite() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let table = table();
|
||||||
|
// A degree of latitude is about 111 km everywhere; a degree of longitude
|
||||||
|
// shrinks towards the poles, so more cells are needed to cover the radius.
|
||||||
|
let lat_span = (MAX_DISTANCE_KM / 111.0).ceil() as i32;
|
||||||
|
let lon_span = {
|
||||||
|
let scale = latitude.to_radians().cos().abs().max(0.01);
|
||||||
|
((MAX_DISTANCE_KM / (111.0 * scale)).ceil() as i32).min(180)
|
||||||
|
};
|
||||||
|
let (row, column) = cell(latitude, longitude);
|
||||||
|
let mut best: Option<(f64, &City)> = None;
|
||||||
|
for dlat in -lat_span..=lat_span {
|
||||||
|
for dlon in -lon_span..=lon_span {
|
||||||
|
// Longitude wraps at the antimeridian: -181 is 179.
|
||||||
|
let lon_cell = (column + dlon + 180).rem_euclid(360) - 180;
|
||||||
|
let Some(indices) = table.cells.get(&(row + dlat, lon_cell)) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for &index in indices {
|
||||||
|
let city = &table.cities[index];
|
||||||
|
let d = distance_km((latitude, longitude), (city.latitude, city.longitude));
|
||||||
|
if d <= MAX_DISTANCE_KM && best.is_none_or(|(bd, _)| d < bd) {
|
||||||
|
best = Some((d, city));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best.map(|(_, city)| city)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The town with this GeoNames id.
|
||||||
|
pub fn city(id: u32) -> Option<&'static City> {
|
||||||
|
let cities = &table().cities;
|
||||||
|
// The table is sorted by id.
|
||||||
|
cities
|
||||||
|
.binary_search_by_key(&id, |city| city.id)
|
||||||
|
.ok()
|
||||||
|
.map(|index| &cities[index])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The English ISO 3166-1 name of a country code, such as "Germany" for "DE".
|
||||||
|
pub fn country_name(code: &str) -> Option<&'static str> {
|
||||||
|
COUNTRIES.lines().find_map(|line| {
|
||||||
|
let (c, name) = line.split_once('\t')?;
|
||||||
|
(c == code).then_some(name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_position_in_a_town_resolves_to_that_town() {
|
||||||
|
let berlin = nearest(52.52, 13.405).expect("Berlin is a town");
|
||||||
|
assert_eq!(berlin.name, "Berlin");
|
||||||
|
assert_eq!(berlin.country, "DE");
|
||||||
|
assert_eq!(city(berlin.id), Some(berlin));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_open_sea_has_no_town() {
|
||||||
|
assert_eq!(nearest(0.0, -30.0), None);
|
||||||
|
assert_eq!(nearest(f64::NAN, 0.0), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_town_across_the_antimeridian_is_still_found() {
|
||||||
|
// Just east of 180°, the nearest towns in Fiji are west of it.
|
||||||
|
let town = nearest(-16.8, 179.99);
|
||||||
|
assert!(town.is_none_or(|t| t.country == "FJ"));
|
||||||
|
let suva = nearest(-18.14, 178.44).expect("Suva is a town");
|
||||||
|
assert_eq!(suva.country, "FJ");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_town_has_a_named_country() {
|
||||||
|
for city in &table().cities {
|
||||||
|
assert!(
|
||||||
|
country_name(city.country).is_some(),
|
||||||
|
"{} has no country name for {}",
|
||||||
|
city.name,
|
||||||
|
city.country
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(country_name("DE"), Some("Germany"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn distances_are_great_circle_km() {
|
||||||
|
let d = distance_km((52.52, 13.405), (48.137, 11.575));
|
||||||
|
assert!(
|
||||||
|
(d - 504.0).abs() < 5.0,
|
||||||
|
"Berlin to Munich is about 504 km, got {d}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
//! The machine profile that travels with a device (features.md 5.3).
|
//! The machine profile that travels with a device.
|
||||||
//!
|
//!
|
||||||
//! Sync folder mappings, pins, and the settings that shape them live in
|
//! Sync folder mappings, pins, and the settings that shape them live in
|
||||||
//! `cache.db` and `config.json` — both on the local disk. When that disk dies,
|
//! `cache.db` and `config.json` — both on the local disk. When that disk dies,
|
||||||
|
|||||||
@@ -55,9 +55,10 @@ fn systemctl_query(args: &[&str]) -> bool {
|
|||||||
/// keyring checks). `--now` is a no-op on an active unit, so the daemon would
|
/// 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.
|
/// keep sleeping and the mount would not appear until the next poll fired.
|
||||||
/// Restarting makes the new session take effect immediately, and starts the
|
/// Restarting makes the new session take effect immediately, and starts the
|
||||||
/// unit when it is not running at all.
|
/// unit when it is not running at all. `reenable` for the reason given on
|
||||||
|
/// [`enable`].
|
||||||
pub fn enable_start() -> bool {
|
pub fn enable_start() -> bool {
|
||||||
let enabled = systemctl(&["enable"]);
|
let enabled = systemctl(&["reenable"]);
|
||||||
systemctl(&["restart"]) && enabled
|
systemctl(&["restart"]) && enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,8 +98,14 @@ pub fn is_enabled() -> bool {
|
|||||||
/// Enable the service so it auto-starts on future logins, without necessarily
|
/// 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
|
/// starting it now beyond what `enable` does. Used by the Settings "Start on
|
||||||
/// login" switch; pairs with [`disable`](disable).
|
/// 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 {
|
pub fn enable() -> bool {
|
||||||
systemctl(&["enable"])
|
systemctl(&["reenable"])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Disable auto-start without stopping the running mount. Used by the Settings
|
/// Disable auto-start without stopping the running mount. Used by the Settings
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
//! Telling photos apart by what they show rather than by their bytes.
|
||||||
|
//!
|
||||||
|
//! The server's content hash only matches byte-identical files. A photo that
|
||||||
|
//! was resized, re-encoded or sent through a messenger is another file, so the
|
||||||
|
//! similar-photo finder hashes each thumbnail instead: a 64-bit difference
|
||||||
|
//! hash ("dHash"), which compares neighbouring brightness in a 9×8 grid. Two
|
||||||
|
//! copies of one picture land a few bits apart, and different pictures about
|
||||||
|
//! half of the bits apart.
|
||||||
|
|
||||||
|
/// Hashes this many bits apart or fewer show the same picture.
|
||||||
|
pub const SIMILAR_BITS: u32 = 6;
|
||||||
|
|
||||||
|
/// Stored for a photo whose thumbnail was looked at and gave no usable hash
|
||||||
|
/// (it couldn't be decoded, or is one flat colour), so it isn't looked at
|
||||||
|
/// again. A real hash is never 0: a picture that would hash to 0 has no
|
||||||
|
/// brightness changes, which [`difference_hash`] turns down as flat.
|
||||||
|
pub const NO_HASH: u64 = 0;
|
||||||
|
|
||||||
|
/// Samples of the grid that differ by less than this in brightness, all of
|
||||||
|
/// them, make a flat picture — a black frame, a blank page — whose hash is
|
||||||
|
/// noise and would match every other flat picture.
|
||||||
|
const FLAT: u8 = 8;
|
||||||
|
|
||||||
|
/// The difference hash of an 8-bit greyscale image `width` px wide, rows
|
||||||
|
/// stored one after another in `luma`. `None` for an empty or flat image.
|
||||||
|
pub fn difference_hash(width: usize, height: usize, luma: &[u8]) -> Option<u64> {
|
||||||
|
if width == 0 || height == 0 || luma.len() < width * height {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Box-average down to 9×8, so every source pixel counts once and the hash
|
||||||
|
// doesn't depend on which pixels a nearest-neighbour pick happens to hit.
|
||||||
|
let mut grid = [[0u8; 9]; 8];
|
||||||
|
for (gy, row) in grid.iter_mut().enumerate() {
|
||||||
|
let (y0, y1) = span(gy, 8, height);
|
||||||
|
for (gx, cell) in row.iter_mut().enumerate() {
|
||||||
|
let (x0, x1) = span(gx, 9, width);
|
||||||
|
let mut sum = 0u64;
|
||||||
|
for y in y0..y1 {
|
||||||
|
sum += luma[y * width + x0..y * width + x1]
|
||||||
|
.iter()
|
||||||
|
.map(|&v| u64::from(v))
|
||||||
|
.sum::<u64>();
|
||||||
|
}
|
||||||
|
*cell = (sum / ((y1 - y0) * (x1 - x0)) as u64) as u8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (min, max) = grid
|
||||||
|
.iter()
|
||||||
|
.flatten()
|
||||||
|
.fold((u8::MAX, 0), |(min, max), &v| (min.min(v), max.max(v)));
|
||||||
|
if max - min < FLAT {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut hash = 0u64;
|
||||||
|
for row in &grid {
|
||||||
|
for pair in row.windows(2) {
|
||||||
|
hash = hash << 1 | u64::from(pair[0] < pair[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(hash != NO_HASH).then_some(hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The source pixels `from..to` that cell `i` of `cells` covers along an edge
|
||||||
|
/// of `len` px; never empty, even for an edge shorter than the grid.
|
||||||
|
fn span(i: usize, cells: usize, len: usize) -> (usize, usize) {
|
||||||
|
let from = (i * len / cells).min(len - 1);
|
||||||
|
let to = ((i + 1) * len / cells).max(from + 1);
|
||||||
|
(from, to)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether two hashes show the same picture.
|
||||||
|
pub fn alike(a: u64, b: u64) -> bool {
|
||||||
|
(a ^ b).count_ones() <= SIMILAR_BITS
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Group `hashes` into sets of alike ones, by index, each set in index order
|
||||||
|
/// and the sets by their first index. A hash alike to none other is in no
|
||||||
|
/// set. Alike is taken as catching: a burst of shots each a little different
|
||||||
|
/// from the last is one set.
|
||||||
|
pub fn sets(hashes: &[u64]) -> Vec<Vec<usize>> {
|
||||||
|
// Two hashes at most SIMILAR_BITS apart agree on at least one of their
|
||||||
|
// eight bytes, as long as SIMILAR_BITS < 8. So only hashes sharing some
|
||||||
|
// byte are compared, not every pair of the library.
|
||||||
|
const _: () = assert!(SIMILAR_BITS < 8);
|
||||||
|
let mut parent: Vec<usize> = (0..hashes.len()).collect();
|
||||||
|
fn root(parent: &mut [usize], mut i: usize) -> usize {
|
||||||
|
while parent[i] != i {
|
||||||
|
parent[i] = parent[parent[i]];
|
||||||
|
i = parent[i];
|
||||||
|
}
|
||||||
|
i
|
||||||
|
}
|
||||||
|
for byte in 0..8 {
|
||||||
|
let mut buckets: Vec<Vec<usize>> = vec![Vec::new(); 256];
|
||||||
|
for (i, &hash) in hashes.iter().enumerate() {
|
||||||
|
buckets[(hash >> (byte * 8)) as u8 as usize].push(i);
|
||||||
|
}
|
||||||
|
for bucket in &buckets {
|
||||||
|
for (n, &a) in bucket.iter().enumerate() {
|
||||||
|
for &b in &bucket[n + 1..] {
|
||||||
|
if alike(hashes[a], hashes[b]) {
|
||||||
|
let (ra, rb) = (root(&mut parent, a), root(&mut parent, b));
|
||||||
|
if ra != rb {
|
||||||
|
parent[ra.max(rb)] = ra.min(rb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut by_root: std::collections::BTreeMap<usize, Vec<usize>> = Default::default();
|
||||||
|
for i in 0..hashes.len() {
|
||||||
|
let r = root(&mut parent, i);
|
||||||
|
by_root.entry(r).or_default().push(i);
|
||||||
|
}
|
||||||
|
by_root.into_values().filter(|set| set.len() > 1).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// A 90×80 picture: a bright disc on a gradient.
|
||||||
|
fn picture(width: usize, height: usize) -> Vec<u8> {
|
||||||
|
let mut luma = Vec::with_capacity(width * height);
|
||||||
|
for y in 0..height {
|
||||||
|
for x in 0..width {
|
||||||
|
let (fx, fy) = (x as f64 / width as f64, y as f64 / height as f64);
|
||||||
|
let disc = ((fx - 0.3).powi(2) + (fy - 0.6).powi(2)).sqrt() < 0.2;
|
||||||
|
let v = if disc { 240.0 } else { 40.0 + 120.0 * fx * fy };
|
||||||
|
luma.push(v as u8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
luma
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_resized_copy_hashes_alike() {
|
||||||
|
let big = difference_hash(900, 800, &picture(900, 800)).unwrap();
|
||||||
|
let small = difference_hash(91, 79, &picture(91, 79)).unwrap();
|
||||||
|
assert!(
|
||||||
|
alike(big, small),
|
||||||
|
"{} bits apart",
|
||||||
|
(big ^ small).count_ones()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_different_picture_hashes_apart() {
|
||||||
|
let one = difference_hash(90, 80, &picture(90, 80)).unwrap();
|
||||||
|
let mirrored: Vec<u8> = picture(90, 80)
|
||||||
|
.chunks(90)
|
||||||
|
.flat_map(|row| row.iter().rev().copied().collect::<Vec<_>>())
|
||||||
|
.collect();
|
||||||
|
let other = difference_hash(90, 80, &mirrored).unwrap();
|
||||||
|
assert!(!alike(one, other));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_flat_picture_has_no_hash() {
|
||||||
|
assert_eq!(difference_hash(64, 64, &[17; 64 * 64]), None);
|
||||||
|
assert_eq!(difference_hash(0, 0, &[]), None);
|
||||||
|
assert_eq!(difference_hash(4, 4, &[0; 3]), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_picture_smaller_than_the_grid_still_hashes() {
|
||||||
|
let luma: Vec<u8> = (0..4 * 3).map(|i| (i * 20) as u8).collect();
|
||||||
|
assert!(difference_hash(4, 3, &luma).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn alike_hashes_form_sets_and_catch_on() {
|
||||||
|
let a = 0x0123_4567_89ab_cdef_u64;
|
||||||
|
let hashes = [
|
||||||
|
a,
|
||||||
|
!a,
|
||||||
|
a ^ 0b111,
|
||||||
|
a ^ 0b111 ^ (0b1111 << 40),
|
||||||
|
0x5555_0000_ffff_aaaa,
|
||||||
|
];
|
||||||
|
assert_eq!(sets(&hashes), vec![vec![0, 2, 3]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hashes_apart_in_most_bytes_are_still_found() {
|
||||||
|
// One bit apart in each of six bytes: six bits in all, and only the
|
||||||
|
// top two bytes agree.
|
||||||
|
let a = 1u64 << 63;
|
||||||
|
let b = (0..6).fold(a, |h, byte| h | 1 << (byte * 8));
|
||||||
|
assert_eq!(sets(&[a, b]), vec![vec![0, 1]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
//! Selective-sync ignore rules for mirror folders (features.md 1.1).
|
//! Selective-sync ignore rules for mirror folders.
|
||||||
//!
|
//!
|
||||||
//! A mirror folder otherwise uploads every path beneath its root, which for a
|
//! A mirror folder otherwise uploads every path beneath its root, which for a
|
||||||
//! project directory means `node_modules/`, `.git/`, and `target/` — quota
|
//! project directory means `node_modules/`, `.git/`, and `target/` — quota
|
||||||
@@ -45,18 +45,30 @@ pub const DEFAULT_IGNORE_PATTERNS: &[&str] = &[
|
|||||||
"Thumbs.db",
|
"Thumbs.db",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Suffixes of files that are, by convention, incomplete or throwaway while they
|
/// Suffixes that mean a file is incomplete *by construction*: only the program
|
||||||
/// wear the name: a download in flight, an editor's swap or backup, an atomic
|
/// writing it gives it this name, only while the write is in flight, and the
|
||||||
/// write's scratch copy. On an on-demand mount these should never seal a remote
|
/// finished article always arrives under a different one. On an on-demand mount
|
||||||
/// revision — the app renames the finished file to its real name, and only that
|
/// these should never seal a remote revision — the app renames the finished file
|
||||||
/// belongs on Drive. See [`is_transient_name`] and docs/BUGS.md B70.
|
/// 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] = &[
|
const TRANSIENT_SUFFIXES: &[&str] = &[
|
||||||
".crdownload", // Chromium/Brave/Edge
|
".crdownload", // Chromium/Brave/Edge
|
||||||
".part", // Firefox, wget, aria2c
|
".part", // Firefox, wget, aria2c
|
||||||
".partial", // some downloaders / older IE
|
".partial", // some downloaders / older IE
|
||||||
".download", // Safari / macOS
|
".download", // Safari / macOS
|
||||||
".tmp", // generic scratch
|
|
||||||
".temp", // generic scratch
|
|
||||||
".swp", // vim swap
|
".swp", // vim swap
|
||||||
".swx", // vim swap (secondary)
|
".swx", // vim swap (secondary)
|
||||||
];
|
];
|
||||||
@@ -84,6 +96,26 @@ pub fn is_transient_name(name: &str) -> bool {
|
|||||||
false
|
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.
|
/// Filenames accepted as a folder's ignore file, in precedence order.
|
||||||
const IGNORE_FILE_NAMES: &[&str] = &[".pdfsignore", ".protonignore"];
|
const IGNORE_FILE_NAMES: &[&str] = &[".pdfsignore", ".protonignore"];
|
||||||
|
|
||||||
@@ -319,8 +351,6 @@ mod tests {
|
|||||||
"movie.mkv.part",
|
"movie.mkv.part",
|
||||||
"big.iso.partial",
|
"big.iso.partial",
|
||||||
"photo.jpg.download",
|
"photo.jpg.download",
|
||||||
"export.xml.tmp",
|
|
||||||
"notes.TEMP",
|
|
||||||
".notes.txt.swp",
|
".notes.txt.swp",
|
||||||
"doc.txt~",
|
"doc.txt~",
|
||||||
".~lock.report.odt#",
|
".~lock.report.odt#",
|
||||||
@@ -331,6 +361,25 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_by_rename_temporaries_are_scratch() {
|
||||||
|
for name in [
|
||||||
|
".goutputstream-4XK2B2",
|
||||||
|
".org.chromium.Chromium.aBc123",
|
||||||
|
"~$report.docx",
|
||||||
|
".~lock.budget.ods#",
|
||||||
|
".#notes.org",
|
||||||
|
"#notes.org#",
|
||||||
|
"cache.tmp",
|
||||||
|
"page.html.crdownload",
|
||||||
|
] {
|
||||||
|
assert!(is_scratch_name(name), "{name}");
|
||||||
|
}
|
||||||
|
for name in ["report.docx", "notes.org", "#1 hits.txt", ".bashrc"] {
|
||||||
|
assert!(!is_scratch_name(name), "{name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn finished_names_are_not_transient() {
|
fn finished_names_are_not_transient() {
|
||||||
for name in [
|
for name in [
|
||||||
@@ -340,6 +389,10 @@ mod tests {
|
|||||||
"archive.partly.zip", // ".partly" is not ".part"
|
"archive.partly.zip", // ".partly" is not ".part"
|
||||||
"template.dotx",
|
"template.dotx",
|
||||||
"readme",
|
"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");
|
assert!(!is_transient_name(name), "{name} should not be transient");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -812,7 +812,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sidecar_supplies_capture_time_and_favourite() {
|
fn sidecar_supplies_capture_time_and_favorite() {
|
||||||
let mut sidecars = HashMap::new();
|
let mut sidecars = HashMap::new();
|
||||||
sidecar(
|
sidecar(
|
||||||
&mut sidecars,
|
&mut sidecars,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ zip.workspace = true
|
|||||||
sha1.workspace = true
|
sha1.workspace = true
|
||||||
proton-drive-rs.workspace = true
|
proton-drive-rs.workspace = true
|
||||||
futures.workspace = true
|
futures.workspace = true
|
||||||
|
async-trait.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
fuser.workspace = true
|
fuser.workspace = true
|
||||||
@@ -24,3 +25,6 @@ base64.workspace = true
|
|||||||
image.workspace = true
|
image.workspace = true
|
||||||
notify = "8"
|
notify = "8"
|
||||||
parking_lot.workspace = true
|
parking_lot.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tracing-subscriber.workspace = true
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
|
|
||||||
use pdfs_core::control::{AlbumInfo, PhotoItem};
|
use pdfs_core::control::{AlbumInfo, PhotoItem, Topic};
|
||||||
use pdfs_core::db::StoredAlbum;
|
use pdfs_core::db::StoredAlbum;
|
||||||
use pdfs_core::{CoreError, CoreResult};
|
use pdfs_core::{CoreError, CoreResult};
|
||||||
|
|
||||||
@@ -137,10 +137,12 @@ impl Core {
|
|||||||
}
|
}
|
||||||
let core = self.clone();
|
let core = self.clone();
|
||||||
self.rt.spawn(async move {
|
self.rt.spawn(async move {
|
||||||
if let Err(e) = core.refresh_albums().await {
|
let refreshed = core.refresh_albums().await;
|
||||||
warn!(error = %e, "background album refresh failed");
|
|
||||||
}
|
|
||||||
core.albums_refreshing.store(false, Ordering::SeqCst);
|
core.albums_refreshing.store(false, Ordering::SeqCst);
|
||||||
|
match refreshed {
|
||||||
|
Ok(_) => core.events.publish(&[Topic::Photos]),
|
||||||
|
Err(e) => warn!(error = %e, "background album refresh failed"),
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,6 +177,40 @@ impl Core {
|
|||||||
Ok(page.into_iter().map(|p| self.photo_item(p)).collect())
|
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.
|
/// Re-enumerate one album's photos and replace its persisted contents.
|
||||||
pub(crate) async fn refresh_album(&self, album: &NodeUid) -> CoreResult<()> {
|
pub(crate) async fn refresh_album(&self, album: &NodeUid) -> CoreResult<()> {
|
||||||
let photos = self.photos();
|
let photos = self.photos();
|
||||||
@@ -221,6 +257,84 @@ impl Core {
|
|||||||
Ok(())
|
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
|
/// Drop the album listing's and every album's freshness stamp, so the next
|
||||||
/// request re-enumerates. Paired with [`Core::invalidate_photos`] — a change
|
/// request re-enumerates. Paired with [`Core::invalidate_photos`] — a change
|
||||||
/// to the timeline can just as easily be a change to an album.
|
/// to the timeline can just as easily be a change to an album.
|
||||||
@@ -230,6 +344,20 @@ impl Core {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Photos an album change applied to, and the ones it failed for with why.
|
||||||
|
pub(crate) type AlbumOutcome = (Vec<String>, Vec<(String, String)>);
|
||||||
|
|
||||||
|
fn album_outcome(outcomes: Vec<pdfs_core::batch::Outcome>) -> AlbumOutcome {
|
||||||
|
let (done, failed) = pdfs_core::batch::split(outcomes);
|
||||||
|
(
|
||||||
|
done.iter().map(|uid| uid.to_string()).collect(),
|
||||||
|
failed
|
||||||
|
.into_iter()
|
||||||
|
.map(|(uid, error)| (uid.to_string(), error.to_string()))
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Project a persisted album into the wire item a front-end paints.
|
/// Project a persisted album into the wire item a front-end paints.
|
||||||
fn album_info(album: StoredAlbum) -> AlbumInfo {
|
fn album_info(album: StoredAlbum) -> AlbumInfo {
|
||||||
AlbumInfo {
|
AlbumInfo {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use crate::shutdown::Shutdown;
|
|||||||
///
|
///
|
||||||
/// Returns whether this mount actually held the node, so the caller can evict
|
/// Returns whether this mount actually held the node, so the caller can evict
|
||||||
/// its content blob once if *any* mount did.
|
/// its content blob once if *any* mount did.
|
||||||
fn forget_and_notify(st: &mut State, notify: &mut NotifyBatch, uid: &NodeUid) -> bool {
|
pub(crate) fn forget_and_notify(st: &mut State, notify: &mut NotifyBatch, uid: &NodeUid) -> bool {
|
||||||
// Capture the inode before `forget` clears the uid mapping.
|
// Capture the inode before `forget` clears the uid mapping.
|
||||||
let child = st.by_uid.get(uid).copied();
|
let child = st.by_uid.get(uid).copied();
|
||||||
let Some((parent, name)) = st.forget(uid) else {
|
let Some((parent, name)) = st.forget(uid) else {
|
||||||
@@ -97,15 +97,13 @@ fn event_serializes_shared_publication(event: &DriveEvent, own_volume: &VolumeId
|
|||||||
/// entry forces the next `lookup`/`readdir` to re-enumerate from the remote, so
|
/// entry forces the next `lookup`/`readdir` to re-enumerate from the remote, so
|
||||||
/// most events only need to invalidate listings rather than re-fetch eagerly.
|
/// most events only need to invalidate listings rather than re-fetch eagerly.
|
||||||
///
|
///
|
||||||
/// Applied to **every** mounted inode space, not just the primary one. This task
|
/// Applied to the tree every mount shares, and told to **every** mounted
|
||||||
/// is per-daemon while node state is per-mount, and a `DriveEvent` names a uid,
|
/// session. A `DriveEvent` names a uid, not a mount, so a file trashed from
|
||||||
/// not a mount — so a file trashed from another device has to be withdrawn from
|
/// another device has to be withdrawn from whichever of our sessions is showing
|
||||||
/// whichever of our sessions is showing it, which for anything under a sync
|
/// it — which for anything under a sync folder is a [`Core::fork_state`] fork.
|
||||||
/// folder is a [`Core::fork_state`] fork rather than `core.state`. Reaching for
|
/// Telling the primary session alone left forks serving a deleted file, the
|
||||||
/// the primary state alone left forks serving a deleted file indefinitely, the
|
/// read-side twin of `docs/BUGS.md` B74. [`Core::for_each_mount`] sends what
|
||||||
/// read-side twin of `docs/BUGS.md` B74. Inode numbers are per-mount, so each
|
/// the kernel needs to hear to every session.
|
||||||
/// mount must be notified through its **own** channel; that pairing is what
|
|
||||||
/// [`Core::for_each_mount`] exists to preserve.
|
|
||||||
fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdfs_core::Result<()> {
|
fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdfs_core::Result<()> {
|
||||||
let foreign_delete = is_foreign_node_delete(event, &core.primary_root_uid.volume_id);
|
let foreign_delete = is_foreign_node_delete(event, &core.primary_root_uid.volume_id);
|
||||||
let serializes_shared_publication =
|
let serializes_shared_publication =
|
||||||
@@ -145,7 +143,7 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdf
|
|||||||
// create, and re-fetching would replace the size and mtime of the
|
// create, and re-fetching would replace the size and mtime of the
|
||||||
// write we just accepted with the stale revision's — making a file
|
// write we just accepted with the stale revision's — making a file
|
||||||
// that was copied in seconds ago read as empty until its upload
|
// that was copied in seconds ago read as empty until its upload
|
||||||
// lands (offline.md Phase 3).
|
// lands.
|
||||||
//
|
//
|
||||||
// A write that has already drained is the same story one step later:
|
// A write that has already drained is the same story one step later:
|
||||||
// the drain brought the tree level with the revision it sealed, so
|
// the drain brought the tree level with the revision it sealed, so
|
||||||
@@ -233,6 +231,48 @@ fn acknowledge_applied_event(
|
|||||||
Ok(id)
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How many applied node-update ids [`RecentEvents`] remembers. Far more than a
|
||||||
|
/// replay can reach back: one comes from a batch whose last event was not its
|
||||||
|
/// newest, so it repeats at most what that batch held.
|
||||||
|
const RECENT_EVENTS: usize = 4096;
|
||||||
|
|
||||||
|
/// The node updates applied lately, by event id, so one that comes round again
|
||||||
|
/// is skipped rather than applied twice.
|
||||||
|
///
|
||||||
|
/// The feed can deliver the same event twice in a batch, and a batch whose
|
||||||
|
/// events come out of order leaves the cursor short of its newest event, so the
|
||||||
|
/// next poll replays what lay past it. An update applied twice is not harmless:
|
||||||
|
/// the first copy of our own change claims the echo [`Core::note_self_change`]
|
||||||
|
/// recorded, and the second, unclaimed, reads as someone else's edit and evicts
|
||||||
|
/// the bytes a partial write is about to gap-fill from (docs/BUGS.md B123).
|
||||||
|
///
|
||||||
|
/// Only node updates: a delete is idempotent, and a continuity loss names the
|
||||||
|
/// cursor it lost, which is an id already applied.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct RecentEvents {
|
||||||
|
ids: HashSet<DriveEventId>,
|
||||||
|
order: VecDeque<DriveEventId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RecentEvents {
|
||||||
|
/// Record `event` as applied, reporting whether it was already.
|
||||||
|
fn repeat(&mut self, event: &DriveEvent) -> bool {
|
||||||
|
let DriveEvent::NodeUpdated { id, .. } = event else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if !self.ids.insert(id.clone()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
self.order.push_back(id.clone());
|
||||||
|
if self.order.len() > RECENT_EVENTS
|
||||||
|
&& let Some(old) = self.order.pop_front()
|
||||||
|
{
|
||||||
|
self.ids.remove(&old);
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The folders one batch of events changed, collected so the listings are
|
/// The folders one batch of events changed, collected so the listings are
|
||||||
/// dropped once at the end of the batch rather than once per event.
|
/// dropped once at the end of the batch rather than once per event.
|
||||||
///
|
///
|
||||||
@@ -320,10 +360,10 @@ fn flush_dirty_parents(core: &Core, dirty: &DirtyParents) {
|
|||||||
/// only on fatal error.
|
/// only on fatal error.
|
||||||
///
|
///
|
||||||
/// Takes the whole `Core` rather than the primary mount's pieces: an event has
|
/// Takes the whole `Core` rather than the primary mount's pieces: an event has
|
||||||
/// to be applied to every mounted inode space, and the registry that enumerates
|
/// to reach every mounted session, and the registry that enumerates them lives
|
||||||
/// them lives on the `Core` (see [`apply_event`]).
|
/// on the `Core` (see [`apply_event`]).
|
||||||
pub(super) async fn run_event_sync(
|
pub(super) async fn run_event_sync(
|
||||||
client: ProtonDriveClient,
|
client: Arc<dyn DriveApi>,
|
||||||
scope: DriveEventScopeId,
|
scope: DriveEventScopeId,
|
||||||
core: Core,
|
core: Core,
|
||||||
) {
|
) {
|
||||||
@@ -335,7 +375,7 @@ pub(super) async fn run_event_sync(
|
|||||||
// server head; persist it so the next restart resumes instead of
|
// server head; persist it so the next restart resumes instead of
|
||||||
// reseeding (which would skip everything that changed offline).
|
// reseeding (which would skip everything that changed offline).
|
||||||
// Seeding needs the network, and this task also runs on mounts that
|
// Seeding needs the network, and this task also runs on mounts that
|
||||||
// started offline (offline.md Phase 1) — so retry rather than giving up,
|
// started offline — so retry rather than giving up,
|
||||||
// which used to disable live sync for the life of the daemon.
|
// which used to disable live sync for the life of the daemon.
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
// Nothing says what changed before this cursor, so a persisted SDK
|
// Nothing says what changed before this cursor, so a persisted SDK
|
||||||
@@ -377,10 +417,12 @@ pub(super) async fn run_event_sync(
|
|||||||
};
|
};
|
||||||
info!(?cursor, "event sync started");
|
info!(?cursor, "event sync started");
|
||||||
|
|
||||||
|
let mut recent = RecentEvents::default();
|
||||||
loop {
|
loop {
|
||||||
tokio::time::sleep(POLL_INTERVAL).await;
|
core.next_poll(POLL_INTERVAL).await;
|
||||||
let events = match client.enumerate_events(&scope, cursor.as_ref()).await {
|
let events = match link::bounded(client.enumerate_events(&scope, cursor.as_ref())).await {
|
||||||
Ok(events) => events,
|
Ok(events) => events,
|
||||||
|
Err(e) if core.lost_link(&e, "poll events") => continue,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!(error = %e, "event poll failed; retrying after interval");
|
warn!(error = %e, "event poll failed; retrying after interval");
|
||||||
continue;
|
continue;
|
||||||
@@ -396,6 +438,11 @@ pub(super) async fn run_event_sync(
|
|||||||
// did apply.
|
// did apply.
|
||||||
let mut dirty = DirtyParents::default();
|
let mut dirty = DirtyParents::default();
|
||||||
for event in &events {
|
for event in &events {
|
||||||
|
if recent.repeat(event) {
|
||||||
|
debug!(id = %event.id().as_str(), "skipping an event already applied");
|
||||||
|
cursor = Some(event.id().clone());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// Converge the SDK's own caches (folder keys, entity cache) on the
|
// Converge the SDK's own caches (folder keys, entity cache) on the
|
||||||
// server before applying the event to our tree. Without this, a node
|
// server before applying the event to our tree. Without this, a node
|
||||||
// re-keyed/moved by another client keeps a stale key in the SDK for
|
// re-keyed/moved by another client keeps a stale key in the SDK for
|
||||||
@@ -422,6 +469,171 @@ pub(super) async fn run_event_sync(
|
|||||||
cursor = Some(applied);
|
cursor = Some(applied);
|
||||||
}
|
}
|
||||||
flush_dirty_parents(&core, &dirty);
|
flush_dirty_parents(&core, &dirty);
|
||||||
|
// Another device changed the tree: whatever a front-end lists may have
|
||||||
|
// moved, including what is shared and what is in the trash.
|
||||||
|
core.events
|
||||||
|
.publish(&[Topic::Files, Topic::Trash, Topic::Shares, Topic::Conflicts]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The state key holding the photos volume's event cursor. Separate from the
|
||||||
|
/// Drive cursor because the two volumes have their own event streams, and one
|
||||||
|
/// cursor cannot address both.
|
||||||
|
const PHOTOS_EVENT_CURSOR: &str = "photos_event_cursor";
|
||||||
|
|
||||||
|
/// Poll the photos volume's event stream, so a photo deleted or added on another
|
||||||
|
/// device shows up in the gallery within one poll instead of on the timeline's
|
||||||
|
/// freshness window.
|
||||||
|
///
|
||||||
|
/// Deliberately much smaller than [`run_event_sync`]: the photos volume is not
|
||||||
|
/// part of the FUSE mount, so there is no inode space to converge and nothing to
|
||||||
|
/// invalidate in the kernel. A trash or a delete removes the rows behind the
|
||||||
|
/// gallery; anything else only drops the timeline's freshness stamp, and the
|
||||||
|
/// next read re-enumerates. Getting that wrong costs a stale tile, not a wrong
|
||||||
|
/// file.
|
||||||
|
pub(super) async fn run_photos_event_sync(core: Core) {
|
||||||
|
let photos = core.photos();
|
||||||
|
// The photos volume only exists once the user has used Photos at all, and
|
||||||
|
// the daemon may well start before the network does.
|
||||||
|
let scope = loop {
|
||||||
|
match photos.get_photos_root().await {
|
||||||
|
Ok(Some(root)) => break root.tree_event_scope_id(),
|
||||||
|
Ok(None) => {
|
||||||
|
debug!("no photos volume; photo event sync not started");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
warn!(%error, "reading the photos root failed; retrying");
|
||||||
|
tokio::time::sleep(ONLINE_PROBE_MAX).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut cursor: Option<DriveEventId> = core
|
||||||
|
.db
|
||||||
|
.state_str(PHOTOS_EVENT_CURSOR)
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.map(DriveEventId::from);
|
||||||
|
info!(?cursor, "photo event sync started");
|
||||||
|
|
||||||
|
loop {
|
||||||
|
core.next_poll(POLL_INTERVAL).await;
|
||||||
|
let events = match link::bounded(photos.enumerate_events(&scope, cursor.as_ref())).await {
|
||||||
|
Ok(events) => events,
|
||||||
|
Err(error) if core.lost_link(&error, "poll photo events") => continue,
|
||||||
|
Err(error) => {
|
||||||
|
warn!(%error, "photo event poll failed; retrying after interval");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let Some(head) = events.last().map(|event| event.id().clone()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
apply_photo_events(&core, &events);
|
||||||
|
core.events.publish(&[Topic::Photos]);
|
||||||
|
// One cursor write per batch, after the batch is applied: a crash in
|
||||||
|
// between replays events whose effects are idempotent.
|
||||||
|
if let Err(error) = core.db.set_state_str(PHOTOS_EVENT_CURSOR, head.as_str()) {
|
||||||
|
warn!(%error, "persisting the photo event cursor failed");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
cursor = Some(head);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What one batch of photos-volume events asks the gallery's tables to do.
|
||||||
|
#[derive(Debug, Default, PartialEq)]
|
||||||
|
pub(super) struct PhotoEventPlan {
|
||||||
|
/// Photos that left the volume, by delete or by trash.
|
||||||
|
pub(super) gone: Vec<String>,
|
||||||
|
/// Photos whose node changed, so their stored metadata is no longer trusted.
|
||||||
|
pub(super) touched: Vec<String>,
|
||||||
|
/// Whether the whole timeline has to be read again.
|
||||||
|
pub(super) full: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PhotoEventPlan {
|
||||||
|
fn is_empty(&self) -> bool {
|
||||||
|
self.gone.is_empty() && self.touched.is_empty() && !self.full
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read one batch of photos-volume events. Pure, so the classification is
|
||||||
|
/// testable without a volume behind it.
|
||||||
|
///
|
||||||
|
/// `is_echo` claims an event as this daemon's own write. The feed replays our
|
||||||
|
/// writes back at us and a `NodeUpdated` carries no revision id, so without this
|
||||||
|
/// every favorite the user toggles here would mark its own photo stale and buy
|
||||||
|
/// a resolve of a node we just wrote.
|
||||||
|
fn classify_photo_events(
|
||||||
|
events: &[DriveEvent],
|
||||||
|
mut is_echo: impl FnMut(&NodeUid) -> bool,
|
||||||
|
) -> PhotoEventPlan {
|
||||||
|
let mut plan = PhotoEventPlan::default();
|
||||||
|
for event in events {
|
||||||
|
match event {
|
||||||
|
DriveEvent::NodeDeleted { node_uid, .. } => plan.gone.push(node_uid.to_string()),
|
||||||
|
DriveEvent::NodeUpdated {
|
||||||
|
node_uid,
|
||||||
|
is_trashed,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
if *is_trashed {
|
||||||
|
plan.gone.push(node_uid.to_string());
|
||||||
|
} else if !is_echo(node_uid) {
|
||||||
|
// An untrash, a rename, a favorite toggled on the phone: the
|
||||||
|
// event says which node changed but never what about it, so
|
||||||
|
// the uid is marked stale and the next refresh reads it.
|
||||||
|
plan.touched.push(node_uid.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The daemon cannot know what it missed here, so everything it
|
||||||
|
// thinks it knows about every photo is dropped.
|
||||||
|
DriveEvent::ContinuityLost { .. } | DriveEvent::ScopeAccessLost { .. } => {
|
||||||
|
plan.full = true;
|
||||||
|
}
|
||||||
|
DriveEvent::CursorAdvanced { .. } | DriveEvent::SharedWithMeUpdated { .. } => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
plan
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply one batch of photos-volume events to the gallery's tables.
|
||||||
|
fn apply_photo_events(core: &Core, events: &[DriveEvent]) {
|
||||||
|
let plan = classify_photo_events(events, |uid| core.take_self_change(uid));
|
||||||
|
let PhotoEventPlan {
|
||||||
|
gone,
|
||||||
|
touched,
|
||||||
|
full,
|
||||||
|
} = &plan;
|
||||||
|
if !gone.is_empty() {
|
||||||
|
match core.db.photos_delete(gone) {
|
||||||
|
Ok(removed) if removed > 0 => {
|
||||||
|
debug!(removed, "photos removed by a remote event");
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(error) => warn!(%error, "removing remotely deleted photos failed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A stale mark is what makes the next refresh cheap: it reads these photos'
|
||||||
|
// nodes and carries every other photo's metadata as it stands.
|
||||||
|
if !touched.is_empty() {
|
||||||
|
match core.db.photos_mark_unresolved(touched) {
|
||||||
|
Ok(marked) if marked > 0 => debug!(marked, "photos changed by a remote event"),
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(error) => warn!(%error, "marking remotely changed photos failed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if *full {
|
||||||
|
match core.db.photos_mark_all_unresolved() {
|
||||||
|
Ok(marked) => debug!(marked, "the photo event stream lost continuity"),
|
||||||
|
Err(error) => warn!(%error, "marking the whole timeline unresolved failed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !plan.is_empty() {
|
||||||
|
core.invalidate_photos();
|
||||||
|
core.invalidate_albums();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,7 +660,7 @@ pub(super) fn run_local_index(
|
|||||||
scan_local_once(&db, &indexing, &transfers, &mountpoint);
|
scan_local_once(&db, &indexing, &transfers, &mountpoint);
|
||||||
}
|
}
|
||||||
// Interruptible, so teardown joins this thread instead of leaving it to
|
// Interruptible, so teardown joins this thread instead of leaving it to
|
||||||
// keep rewriting the index for a mount that is gone (bugs.md B44).
|
// keep rewriting the index for a mount that is gone (`docs/BUGS.md` B44).
|
||||||
if !shutdown.sleep(LOCAL_INDEX_CHECK) {
|
if !shutdown.sleep(LOCAL_INDEX_CHECK) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -487,13 +699,18 @@ fn scan_local_once(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let excludes = localindex::default_excludes(mountpoint, &dirs.state_dir(), &dirs.cache_dir());
|
let mut excludes =
|
||||||
|
localindex::default_excludes(mountpoint, &dirs.state_dir(), &dirs.cache_dir());
|
||||||
|
// Resolved per scan rather than once at startup: mounts come and go, and a
|
||||||
|
// mount that appeared since the daemon started is exactly the one most
|
||||||
|
// likely to be a half-alive network filesystem (see `nested_mount_points`).
|
||||||
|
excludes.extend(localindex::nested_mount_points(&home));
|
||||||
indexing.store(true, Ordering::Relaxed);
|
indexing.store(true, Ordering::Relaxed);
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
|
|
||||||
// The walk has no idea how many files it will find, so the job counts what it
|
// The walk has no idea how many files it will find, so the job counts what it
|
||||||
// has seen and stays indeterminate.
|
// has seen and stays indeterminate.
|
||||||
let job = transfers.begin_job("Indexing this computer");
|
let job = transfers.begin_background_job("Indexing this computer");
|
||||||
job.detail("Scanning your files");
|
job.detail("Scanning your files");
|
||||||
let walked = localindex::scan(&[home], &excludes, |batch| {
|
let walked = localindex::scan(&[home], &excludes, |batch| {
|
||||||
if let Err(e) = db.local_upsert_batch(generation, &batch) {
|
if let Err(e) = db.local_upsert_batch(generation, &batch) {
|
||||||
@@ -546,6 +763,43 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_node_update_seen_again_is_skipped_but_other_events_are_not() {
|
||||||
|
let update = |id: &str| DriveEvent::NodeUpdated {
|
||||||
|
id: DriveEventId::from(id),
|
||||||
|
node_uid: uid("file"),
|
||||||
|
parent_node_uid: None,
|
||||||
|
is_trashed: false,
|
||||||
|
is_shared: false,
|
||||||
|
};
|
||||||
|
let mut recent = RecentEvents::default();
|
||||||
|
assert!(!recent.repeat(&update("e1")));
|
||||||
|
assert!(!recent.repeat(&update("e2")));
|
||||||
|
assert!(recent.repeat(&update("e1")), "a duplicate or replay");
|
||||||
|
let lost = DriveEvent::ContinuityLost {
|
||||||
|
id: DriveEventId::from("e2"),
|
||||||
|
};
|
||||||
|
assert!(!recent.repeat(&lost), "names the cursor, which was applied");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recent_events_forget_the_oldest_past_their_bound() {
|
||||||
|
let update = |n: usize| DriveEvent::NodeUpdated {
|
||||||
|
id: DriveEventId::from(format!("e{n}")),
|
||||||
|
node_uid: uid("file"),
|
||||||
|
parent_node_uid: None,
|
||||||
|
is_trashed: false,
|
||||||
|
is_shared: false,
|
||||||
|
};
|
||||||
|
let mut recent = RecentEvents::default();
|
||||||
|
for n in 0..=RECENT_EVENTS {
|
||||||
|
assert!(!recent.repeat(&update(n)));
|
||||||
|
}
|
||||||
|
assert_eq!(recent.ids.len(), RECENT_EVENTS);
|
||||||
|
assert!(!recent.repeat(&update(0)), "the oldest was forgotten");
|
||||||
|
assert!(recent.repeat(&update(RECENT_EVENTS)));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn foreign_delete_invalidates_an_earlier_refresh_generation() {
|
fn foreign_delete_invalidates_an_earlier_refresh_generation() {
|
||||||
let own_volume = VolumeId::from("own");
|
let own_volume = VolumeId::from("own");
|
||||||
@@ -789,4 +1043,96 @@ mod tests {
|
|||||||
assert!(acknowledge_applied_event(&db, &event, applied).is_err());
|
assert!(acknowledge_applied_event(&db, &event, applied).is_err());
|
||||||
assert!(db.get_event_cursor().unwrap().is_none());
|
assert!(db.get_event_cursor().unwrap().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A photo trashed or deleted elsewhere leaves the gallery; one that only
|
||||||
|
/// changed is marked stale, so the next refresh reads that node and no other.
|
||||||
|
#[test]
|
||||||
|
fn a_photo_event_names_what_it_changed() {
|
||||||
|
let plan = classify_photo_events(
|
||||||
|
&[
|
||||||
|
DriveEvent::NodeDeleted {
|
||||||
|
id: DriveEventId::from("e1"),
|
||||||
|
node_uid: uid("deleted"),
|
||||||
|
parent_node_uid: None,
|
||||||
|
},
|
||||||
|
DriveEvent::NodeUpdated {
|
||||||
|
id: DriveEventId::from("e2"),
|
||||||
|
node_uid: uid("trashed"),
|
||||||
|
parent_node_uid: None,
|
||||||
|
is_trashed: true,
|
||||||
|
is_shared: false,
|
||||||
|
},
|
||||||
|
DriveEvent::NodeUpdated {
|
||||||
|
id: DriveEventId::from("e3"),
|
||||||
|
node_uid: uid("favorited"),
|
||||||
|
parent_node_uid: None,
|
||||||
|
is_trashed: false,
|
||||||
|
is_shared: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|_| false,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
plan,
|
||||||
|
PhotoEventPlan {
|
||||||
|
gone: vec![uid("deleted").to_string(), uid("trashed").to_string()],
|
||||||
|
touched: vec![uid("favorited").to_string()],
|
||||||
|
full: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The feed replays this daemon's own writes. Applying one would cost a
|
||||||
|
/// resolve of a node whose changed field the daemon just wrote itself.
|
||||||
|
#[test]
|
||||||
|
fn our_own_photo_write_is_not_a_remote_change() {
|
||||||
|
let plan = classify_photo_events(
|
||||||
|
&[DriveEvent::NodeUpdated {
|
||||||
|
id: DriveEventId::from("e1"),
|
||||||
|
node_uid: uid("ours"),
|
||||||
|
parent_node_uid: None,
|
||||||
|
is_trashed: false,
|
||||||
|
is_shared: false,
|
||||||
|
}],
|
||||||
|
|claimed| *claimed == uid("ours"),
|
||||||
|
);
|
||||||
|
assert_eq!(plan, PhotoEventPlan::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A gap in the stream means the daemon cannot know what it missed, so it
|
||||||
|
/// keeps nothing.
|
||||||
|
#[test]
|
||||||
|
fn a_lost_photo_event_stream_asks_for_everything() {
|
||||||
|
for event in [
|
||||||
|
DriveEvent::ContinuityLost {
|
||||||
|
id: DriveEventId::from("e1"),
|
||||||
|
},
|
||||||
|
DriveEvent::ScopeAccessLost {
|
||||||
|
id: DriveEventId::from("e1"),
|
||||||
|
},
|
||||||
|
] {
|
||||||
|
let plan = classify_photo_events(std::slice::from_ref(&event), |_| false);
|
||||||
|
assert!(plan.full, "{event:?}");
|
||||||
|
assert!(plan.gone.is_empty());
|
||||||
|
assert!(plan.touched.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bookkeeping events change nothing about the gallery, and must not buy a
|
||||||
|
/// refresh of it.
|
||||||
|
#[test]
|
||||||
|
fn photo_bookkeeping_events_ask_for_nothing() {
|
||||||
|
let plan = classify_photo_events(
|
||||||
|
&[
|
||||||
|
DriveEvent::CursorAdvanced {
|
||||||
|
id: DriveEventId::from("e1"),
|
||||||
|
},
|
||||||
|
DriveEvent::SharedWithMeUpdated {
|
||||||
|
id: DriveEventId::from("e2"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|_| false,
|
||||||
|
);
|
||||||
|
assert_eq!(plan, PhotoEventPlan::default());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,18 +17,38 @@ use std::time::{Duration, Instant};
|
|||||||
|
|
||||||
use pdfs_core::config::AppDirs;
|
use pdfs_core::config::AppDirs;
|
||||||
use pdfs_core::control::{
|
use pdfs_core::control::{
|
||||||
ActivityKind, PhotoMonth, RefreshScope, Request as CtlRequest, Response as CtlResponse,
|
ActivityKind, Event, HEARTBEAT, PhotoMonth, RefreshScope, Request as CtlRequest,
|
||||||
TransferDirection,
|
Response as CtlResponse, Topic, TransferDirection, TrashFailure,
|
||||||
};
|
};
|
||||||
use pdfs_core::{CoreError, CoreResult};
|
use pdfs_core::{CoreError, CoreResult};
|
||||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||||
use tracing::{info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
use super::transfers::CountingReader;
|
use super::transfers::CountingReader;
|
||||||
use super::{Core, count_noun, human_bytes, human_duration, parse_uid};
|
use super::{Core, count_noun, human_bytes, human_duration, parse_uid};
|
||||||
|
|
||||||
const MAX_CONTROL_REQUEST_BYTES: u64 = 1024 * 1024;
|
const MAX_CONTROL_REQUEST_BYTES: u64 = 1024 * 1024;
|
||||||
const CONTROL_READ_TIMEOUT: Duration = Duration::from_secs(10);
|
const CONTROL_READ_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
/// Answer a [`CtlRequest::StopSharing`]: log it, and turn a partial removal
|
||||||
|
/// into an error that names what is still shared.
|
||||||
|
fn stop_sharing_reply(
|
||||||
|
core: &Core,
|
||||||
|
target: &str,
|
||||||
|
result: CoreResult<super::sharing::StopSharingOutcome>,
|
||||||
|
) -> CtlResponse {
|
||||||
|
match result.map(|outcome| outcome.into_result()) {
|
||||||
|
Ok(Ok(message)) => {
|
||||||
|
core.log_activity(ActivityKind::Unshare, target, "stopped sharing", true);
|
||||||
|
CtlResponse::Ok { message }
|
||||||
|
}
|
||||||
|
Ok(Err(left)) => {
|
||||||
|
core.log_activity(ActivityKind::Unshare, target, &left, false);
|
||||||
|
CtlResponse::error(CoreError::remote(left))
|
||||||
|
}
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A client that connects, sends a request and then never reads its reply used
|
/// A client that connects, sends a request and then never reads its reply used
|
||||||
/// to park a handler thread forever in `write_all`; 64 of those exhaust
|
/// to park a handler thread forever in `write_all`; 64 of those exhaust
|
||||||
/// [`MAX_CONTROL_HANDLERS`] permanently. Generous enough for the largest
|
/// [`MAX_CONTROL_HANDLERS`] permanently. Generous enough for the largest
|
||||||
@@ -46,6 +66,32 @@ const MAX_ROW_LIMIT: usize = 1000;
|
|||||||
fn clamp_limit(limit: usize) -> usize {
|
fn clamp_limit(limit: usize) -> usize {
|
||||||
limit.min(MAX_ROW_LIMIT)
|
limit.min(MAX_ROW_LIMIT)
|
||||||
}
|
}
|
||||||
|
/// Answer an album add or remove: parse the uids, run `change`, and report
|
||||||
|
/// per photo.
|
||||||
|
fn album_change(
|
||||||
|
uid: &str,
|
||||||
|
photos: &[String],
|
||||||
|
change: impl FnOnce(&NodeUid, &[NodeUid]) -> CoreResult<super::albums::AlbumOutcome>,
|
||||||
|
) -> CtlResponse {
|
||||||
|
let Some(album) = parse_uid(uid) else {
|
||||||
|
return CtlResponse::error(CoreError::invalid(format!("bad album uid: {uid}")));
|
||||||
|
};
|
||||||
|
let photos = match Core::parse_uids(photos) {
|
||||||
|
Ok(photos) => photos,
|
||||||
|
Err(e) => return CtlResponse::error(e),
|
||||||
|
};
|
||||||
|
match change(&album, &photos) {
|
||||||
|
Ok((changed, failed)) => CtlResponse::AlbumChanged {
|
||||||
|
changed,
|
||||||
|
failed: failed
|
||||||
|
.into_iter()
|
||||||
|
.map(|(uid, message)| TrashFailure { uid, message })
|
||||||
|
.collect(),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static ACTIVE_CONTROL_HANDLERS: AtomicUsize = AtomicUsize::new(0);
|
static ACTIVE_CONTROL_HANDLERS: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
|
||||||
struct ControlHandlerPermit;
|
struct ControlHandlerPermit;
|
||||||
@@ -67,6 +113,16 @@ impl Drop for ControlHandlerPermit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handler threads in use, for the diagnostics. At the limit the daemon refuses
|
||||||
|
/// connections, which a front-end cannot tell apart from a dead daemon.
|
||||||
|
pub(crate) fn active_handlers() -> usize {
|
||||||
|
ACTIVE_CONTROL_HANDLERS.load(Ordering::Acquire)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn handler_limit() -> usize {
|
||||||
|
MAX_CONTROL_HANDLERS
|
||||||
|
}
|
||||||
|
|
||||||
fn read_request_line(reader: &mut impl BufRead) -> std::io::Result<Option<String>> {
|
fn read_request_line(reader: &mut impl BufRead) -> std::io::Result<Option<String>> {
|
||||||
let mut bytes = Vec::new();
|
let mut bytes = Vec::new();
|
||||||
loop {
|
loop {
|
||||||
@@ -107,8 +163,8 @@ fn read_request_line(reader: &mut impl BufRead) -> std::io::Result<Option<String
|
|||||||
/// Turn a CLI-supplied path into the mount that owns it and the path relative
|
/// Turn a CLI-supplied path into the mount that owns it and the path relative
|
||||||
/// to *that* mount's root.
|
/// to *that* mount's root.
|
||||||
///
|
///
|
||||||
/// [`rel_to_mount`] is the older, primary-only reading, and it is what every
|
/// [`rel_to_mount`] is the older, primary-only reading, and it is what most
|
||||||
/// mutating request still uses. It rejects anything outside the primary
|
/// requests still use. It rejects anything outside the primary
|
||||||
/// mountpoint, which leaves a secondary on-demand mount — a synced folder
|
/// mountpoint, which leaves a secondary on-demand mount — a synced folder
|
||||||
/// switched out of mirror mode — with no way to be named at all: `pdfs ls` and
|
/// switched out of mirror mode — with no way to be named at all: `pdfs ls` and
|
||||||
/// `pdfs refresh` both answered "is not under the mountpoint", so a folder
|
/// `pdfs refresh` both answered "is not under the mountpoint", so a folder
|
||||||
@@ -129,6 +185,62 @@ fn route_to_mount(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Cor
|
|||||||
rel_to_mount(mountpoint, path).map(|rel| (core.clone(), rel))
|
rel_to_mount(mountpoint, path).map(|rel| (core.clone(), rel))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [`route_to_mount`] for a request that changes what it names: trash, rename,
|
||||||
|
/// create a folder, resolve a conflict. Each has to run against the on-demand
|
||||||
|
/// mount serving the path, and `pdfs rm ~/Downloads/x` answered "is not under
|
||||||
|
/// the mountpoint" while `pdfs move` took the same path. The conflict listing
|
||||||
|
/// names a copy in a synced folder by its absolute local path (`docs/BUGS.md`
|
||||||
|
/// B99), so resolving one needs this too. A mirror folder has no mount to run
|
||||||
|
/// against: a file there is an ordinary local file.
|
||||||
|
fn route_change(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Core, PathBuf)> {
|
||||||
|
let routed = route_to_mount(core, mountpoint, path).map_err(|e| {
|
||||||
|
let p = Path::new(path);
|
||||||
|
let mirrored = core
|
||||||
|
.db
|
||||||
|
.sync_folder_list()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.iter()
|
||||||
|
.any(|folder| folder.mode == "mirror" && p.starts_with(&folder.local_path));
|
||||||
|
if mirrored {
|
||||||
|
CoreError::invalid(format!(
|
||||||
|
"{path} is in a mirrored folder; change the local file there instead"
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
e
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
if routed
|
||||||
|
.1
|
||||||
|
.components()
|
||||||
|
.all(|component| matches!(component, Component::Normal(_) | Component::CurDir))
|
||||||
|
{
|
||||||
|
Ok(routed)
|
||||||
|
} else {
|
||||||
|
Err(CoreError::invalid(format!("{path} escapes the mountpoint")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`route_change`] for a request that trashes or renames the node it names.
|
||||||
|
/// A mount's root is My files or a whole synced folder. An on-demand folder's
|
||||||
|
/// root has a parent on Drive, so nothing further down would stop `pdfs rm
|
||||||
|
/// ~/Downloads` from trashing the folder out from under its own mount.
|
||||||
|
fn route_node(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Core, PathBuf)> {
|
||||||
|
let routed = route_change(core, mountpoint, path)?;
|
||||||
|
if below_root(&routed.1) {
|
||||||
|
Ok(routed)
|
||||||
|
} else {
|
||||||
|
Err(CoreError::invalid(format!(
|
||||||
|
"{path} is the root of a synced location and cannot be changed"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a mount-relative path names something below the mount's root.
|
||||||
|
fn below_root(rel: &Path) -> bool {
|
||||||
|
rel.components()
|
||||||
|
.any(|component| matches!(component, Component::Normal(_)))
|
||||||
|
}
|
||||||
|
|
||||||
/// Turn a CLI-supplied path into a mountpoint-relative path. An absolute path
|
/// Turn a CLI-supplied path into a mountpoint-relative path. An absolute path
|
||||||
/// must live under `mountpoint`; a relative path is taken as already relative to
|
/// must live under `mountpoint`; a relative path is taken as already relative to
|
||||||
/// the mount root.
|
/// the mount root.
|
||||||
@@ -154,6 +266,203 @@ fn rel_to_mount(mountpoint: &Path, path: &str) -> CoreResult<PathBuf> {
|
|||||||
|
|
||||||
/// Handle one control-socket connection: read a single JSON request line,
|
/// Handle one control-socket connection: read a single JSON request line,
|
||||||
/// dispatch it against `core`, and write back a JSON response line.
|
/// dispatch it against `core`, and write back a JSON response line.
|
||||||
|
/// Refresh scopes being served right now, one entry per running refresh.
|
||||||
|
///
|
||||||
|
/// A `Vec` because there are three scopes and at most `MAX_CONTROL_HANDLERS`
|
||||||
|
/// handlers, so a set would buy nothing.
|
||||||
|
static REFRESHING: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
|
||||||
|
|
||||||
|
/// What makes two refreshes the same work: the scope, and for a directory the
|
||||||
|
/// folder, since refreshing one folder says nothing about another. A full
|
||||||
|
/// photos refresh is not the same work as an ordinary one — it forgets what the
|
||||||
|
/// daemon knows first — so it keys apart.
|
||||||
|
fn scope_key(scope: &RefreshScope) -> String {
|
||||||
|
match scope {
|
||||||
|
RefreshScope::Dir { path } => format!("dir:{path}"),
|
||||||
|
RefreshScope::Trash => "trash".to_string(),
|
||||||
|
RefreshScope::Photos { full: true } => "photos:full".to_string(),
|
||||||
|
RefreshScope::Photos { full: false } => "photos".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Holds one refresh scope for as long as it is being served. `acquire`
|
||||||
|
/// returns `None` when that scope is already in flight.
|
||||||
|
struct RefreshGuard(String);
|
||||||
|
|
||||||
|
impl RefreshGuard {
|
||||||
|
fn acquire(scope: &RefreshScope) -> Option<Self> {
|
||||||
|
let key = scope_key(scope);
|
||||||
|
let mut running = REFRESHING.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
if running.contains(&key) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
running.push(key.clone());
|
||||||
|
Some(Self(key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for RefreshGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let mut running = REFRESHING.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
if let Some(at) = running.iter().position(|key| *key == self.0) {
|
||||||
|
running.swap_remove(at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The reply to [`CtlRequest::Status`], which subscriptions are also sent
|
||||||
|
/// whenever it changes.
|
||||||
|
pub(crate) fn status_response(core: &Core, username: &str, mountpoint: &Path) -> CtlResponse {
|
||||||
|
let pins = core.cache.list_pins();
|
||||||
|
let queued = core.db.pending_op_counts().unwrap_or_default();
|
||||||
|
// Read after the queue: the drain counts an upload as landing before it
|
||||||
|
// retires the op, so one of the two always has it (B119).
|
||||||
|
let landing = core.landing_uploads.load(Ordering::SeqCst);
|
||||||
|
let staging = core.cache.staging_usage();
|
||||||
|
CtlResponse::Status {
|
||||||
|
parked_uploads: queued.parked.max(0) as u64,
|
||||||
|
failing_ops: queued.failing.max(0) as u64,
|
||||||
|
failing_error: queued.last_error.clone(),
|
||||||
|
staged_bytes: staging.bytes,
|
||||||
|
staged_oldest_secs: staging.oldest_secs,
|
||||||
|
paused: core.sync_pause().is_some(),
|
||||||
|
paused_until: core.sync_pause().flatten(),
|
||||||
|
username: username.to_string(),
|
||||||
|
mountpoint: mountpoint.display().to_string(),
|
||||||
|
pinned: pins.len(),
|
||||||
|
used: core.cache.usage(),
|
||||||
|
budget: core.cache.budget(),
|
||||||
|
pins,
|
||||||
|
online: core.online.load(Ordering::Relaxed),
|
||||||
|
pending_uploads: queued.uploads.max(0) as u64 + landing,
|
||||||
|
pending_changes: queued.changes.max(0) as u64,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a front-end shows that `request` changes when it succeeds. Requests
|
||||||
|
/// from the CLI publish the same way as the app's, so an open window follows
|
||||||
|
/// a change made in a terminal.
|
||||||
|
fn touched_topics(request: &CtlRequest) -> &'static [Topic] {
|
||||||
|
use CtlRequest as R;
|
||||||
|
match request {
|
||||||
|
R::Pin { .. } | R::Unpin { .. } => &[Topic::Files],
|
||||||
|
R::CreateAlbum { .. }
|
||||||
|
| R::RenameAlbum { .. }
|
||||||
|
| R::DeleteAlbum { .. }
|
||||||
|
| R::AddToAlbum { .. }
|
||||||
|
| R::RemoveFromAlbum { .. }
|
||||||
|
| R::SetPhotoFavorite { .. }
|
||||||
|
| R::UploadPhoto { .. } => &[Topic::Photos],
|
||||||
|
R::TrashNodes { .. } => &[Topic::Files, Topic::Photos, Topic::Trash, Topic::Conflicts],
|
||||||
|
R::Rename { .. } | R::Move { .. } | R::CreateFolder { .. } | R::UploadPaths { .. } => {
|
||||||
|
&[Topic::Files, Topic::Conflicts]
|
||||||
|
}
|
||||||
|
R::Delete { .. } => &[Topic::Files, Topic::Trash, Topic::Conflicts],
|
||||||
|
R::Restore { .. } | R::DeleteForever { .. } | R::EmptyTrash => {
|
||||||
|
&[Topic::Files, Topic::Photos, Topic::Trash, Topic::Conflicts]
|
||||||
|
}
|
||||||
|
R::Refresh { scope } => match scope {
|
||||||
|
RefreshScope::Dir { .. } => &[Topic::Files],
|
||||||
|
RefreshScope::Trash => &[Topic::Trash],
|
||||||
|
// A photos refresh answers once it has started; the timeline
|
||||||
|
// publishes again when it lands.
|
||||||
|
RefreshScope::Photos { .. } => &[],
|
||||||
|
},
|
||||||
|
R::RenameDevice { .. } | R::DeleteDevice { .. } => &[Topic::Devices],
|
||||||
|
R::AdoptDevice { .. } | R::MigrateDevice { .. } => &[Topic::Devices, Topic::Locations],
|
||||||
|
R::AddSyncFolder { .. }
|
||||||
|
| R::RemoveSyncFolder { .. }
|
||||||
|
| R::SetSyncFolderMode { .. }
|
||||||
|
| R::SetSyncFolderPaused { .. }
|
||||||
|
| R::RestoreSyncFolders { .. }
|
||||||
|
| R::RestoreDeviceFolders { .. } => &[Topic::Locations, Topic::Devices],
|
||||||
|
R::RetryPendingOp { .. } => &[Topic::Queue],
|
||||||
|
R::DiscardPendingOp { .. } => &[Topic::Queue, Topic::Files],
|
||||||
|
R::ResolveConflict { .. } => &[Topic::Files, Topic::Trash, Topic::Conflicts],
|
||||||
|
R::ShareNode { .. }
|
||||||
|
| R::ShareNodeByUid { .. }
|
||||||
|
| R::UpdateShareRole { .. }
|
||||||
|
| R::UpdateShareRoleByUid { .. }
|
||||||
|
| R::RemoveShareEntry { .. }
|
||||||
|
| R::RemoveShareEntryByUid { .. }
|
||||||
|
| R::CreatePublicLink { .. }
|
||||||
|
| R::CreatePublicLinkByUid { .. }
|
||||||
|
| R::RemovePublicLink { .. }
|
||||||
|
| R::RemovePublicLinkByUid { .. }
|
||||||
|
| R::StopSharing { .. }
|
||||||
|
| R::StopSharingByUid { .. } => &[Topic::Shares, Topic::Files],
|
||||||
|
R::LeaveShared { .. }
|
||||||
|
| R::AcceptInvitation { .. }
|
||||||
|
| R::RejectInvitation { .. }
|
||||||
|
| R::CreateBookmark { .. }
|
||||||
|
| R::DeleteBookmark { .. } => &[Topic::Shares],
|
||||||
|
R::RestoreRevision { .. }
|
||||||
|
| R::RestoreRevisionByUid { .. }
|
||||||
|
| R::SaveRevisionAs { .. }
|
||||||
|
| R::SaveRevisionAsByUid { .. } => &[Topic::Files],
|
||||||
|
_ => &[],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long a subscription's writes may block. A client that stops reading
|
||||||
|
/// is dropped after this rather than holding its handler forever.
|
||||||
|
const SUBSCRIPTION_WRITE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
|
/// Serve a [`CtlRequest::Subscribe`]: send the current status and transfers
|
||||||
|
/// and say every topic changed, so the client catches up, then send whatever
|
||||||
|
/// the [`EventHub`](super::events::EventHub) collects, with a heartbeat when
|
||||||
|
/// nothing does. Ends when the client goes away or the daemon stops.
|
||||||
|
fn serve_subscription(core: &Core, username: &str, mountpoint: &Path, mut stream: UnixStream) {
|
||||||
|
let Some(mailbox) = core.events.subscribe() else {
|
||||||
|
warn!("control: refusing a subscription at the limit");
|
||||||
|
if let Ok(mut out) = serde_json::to_vec(&CtlResponse::error(CoreError::remote(
|
||||||
|
"too many subscriptions",
|
||||||
|
))) {
|
||||||
|
out.push(b'\n');
|
||||||
|
let _ = stream.write_all(&out);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
struct Unsubscribe<'a>(&'a Core, std::sync::Arc<super::events::Mailbox>);
|
||||||
|
impl Drop for Unsubscribe<'_> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.0.events.unsubscribe(&self.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _unsubscribe = Unsubscribe(core, mailbox.clone());
|
||||||
|
if let Err(e) = stream.set_write_timeout(Some(SUBSCRIPTION_WRITE_TIMEOUT)) {
|
||||||
|
warn!(error = %e, "control: could not set subscription timeout");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let heartbeat = super::events::event_line(&Event::Heartbeat);
|
||||||
|
let mut lines: Vec<_> = [
|
||||||
|
super::events::event_line(&Event::Status(Box::new(status_response(
|
||||||
|
core, username, mountpoint,
|
||||||
|
)))),
|
||||||
|
super::events::transfers_line(core.transfers.snapshot(), core.jobs_snapshot()),
|
||||||
|
super::events::event_line(&Event::Changed {
|
||||||
|
topics: Topic::ALL.to_vec(),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.collect();
|
||||||
|
loop {
|
||||||
|
for line in &lines {
|
||||||
|
if stream.write_all(line.as_bytes()).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if core.shutdown.is_stopping() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lines = mailbox.take(HEARTBEAT);
|
||||||
|
if lines.is_empty() {
|
||||||
|
lines.extend(heartbeat.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: UnixStream) {
|
fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: UnixStream) {
|
||||||
if let Err(e) = stream.set_read_timeout(Some(CONTROL_READ_TIMEOUT)) {
|
if let Err(e) = stream.set_read_timeout(Some(CONTROL_READ_TIMEOUT)) {
|
||||||
warn!(error = %e, "control: could not set request timeout");
|
warn!(error = %e, "control: could not set request timeout");
|
||||||
@@ -178,27 +487,21 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let response = match serde_json::from_str::<CtlRequest>(line.trim()) {
|
let request = serde_json::from_str::<CtlRequest>(line.trim());
|
||||||
Ok(CtlRequest::Status) => {
|
// A subscription lasts as long as its client, so it is not a request in
|
||||||
let pins = core.cache.list_pins();
|
// flight and stays out of the diagnostics' list of them.
|
||||||
let queued = core.db.pending_op_counts().unwrap_or_default();
|
if let Ok(CtlRequest::Subscribe) = request {
|
||||||
let staging = core.cache.staging_usage();
|
serve_subscription(core, username, mountpoint, stream);
|
||||||
CtlResponse::Status {
|
return;
|
||||||
parked_uploads: queued.parked.max(0) as u64,
|
}
|
||||||
failing_ops: queued.failing.max(0) as u64,
|
let touched = request.as_ref().map_or(&[][..], touched_topics);
|
||||||
failing_error: queued.last_error.clone(),
|
// Registered before the request is even parsed, so a handler that wedges
|
||||||
staged_bytes: staging.bytes,
|
// inside the daemon shows up by name in `pdfs diagnostics`.
|
||||||
staged_oldest_secs: staging.oldest_secs,
|
let _inflight = super::diagnostics::InflightGuard::new(super::diagnostics::request_kind(&line));
|
||||||
username: username.to_string(),
|
let response = match request {
|
||||||
mountpoint: mountpoint.display().to_string(),
|
Ok(CtlRequest::Status) => status_response(core, username, mountpoint),
|
||||||
pinned: pins.len(),
|
Ok(CtlRequest::Subscribe) => {
|
||||||
used: core.cache.usage(),
|
CtlResponse::error(CoreError::invalid("a subscription is served on its own"))
|
||||||
budget: core.cache.budget(),
|
|
||||||
pins,
|
|
||||||
online: core.online.load(Ordering::Relaxed),
|
|
||||||
pending_uploads: queued.uploads.max(0) as u64,
|
|
||||||
pending_changes: queued.changes.max(0) as u64,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(CtlRequest::Pin { path }) => match rel_to_mount(mountpoint, &path) {
|
Ok(CtlRequest::Pin { path }) => match rel_to_mount(mountpoint, &path) {
|
||||||
Ok(rel) => match core.pin(&rel) {
|
Ok(rel) => match core.pin(&rel) {
|
||||||
@@ -225,7 +528,7 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
},
|
||||||
Ok(CtlRequest::ListPins) => CtlResponse::Pins {
|
Ok(CtlRequest::ListPins) => CtlResponse::Pins {
|
||||||
pins: core.cache.list_pins(),
|
pins: core.list_pins(),
|
||||||
},
|
},
|
||||||
Ok(CtlRequest::ListDir { path }) => match route_to_mount(core, mountpoint, &path) {
|
Ok(CtlRequest::ListDir { path }) => match route_to_mount(core, mountpoint, &path) {
|
||||||
Ok((core, rel)) => match core.list_dir(&rel) {
|
Ok((core, rel)) => match core.list_dir(&rel) {
|
||||||
@@ -234,36 +537,84 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
},
|
},
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
},
|
||||||
Ok(CtlRequest::Refresh { scope }) => {
|
Ok(CtlRequest::Refresh { scope }) => match RefreshGuard::acquire(&scope) {
|
||||||
let result = match &scope {
|
// The same scope is already being refreshed by another handler.
|
||||||
RefreshScope::Dir { path } => route_to_mount(core, mountpoint, path)
|
// Running it again cannot make the answer newer, and the duplicate
|
||||||
.and_then(|(core, rel)| core.refresh_dir(&rel)),
|
// would spend its whole run contending for the state and database
|
||||||
RefreshScope::Trash => {
|
// locks the first one holds — which is how five stacked photo
|
||||||
core.invalidate_trash();
|
// refreshes sat in flight for twenty-three minutes at mount time.
|
||||||
Ok(())
|
None => {
|
||||||
|
debug!(scope = %scope_key(&scope), "a refresh of this scope is already running");
|
||||||
|
CtlResponse::Ok {
|
||||||
|
message: "refreshing".to_string(),
|
||||||
}
|
}
|
||||||
RefreshScope::Photos => {
|
|
||||||
core.invalidate_photos();
|
|
||||||
// Whatever changed the timeline can just as easily have
|
|
||||||
// changed an album; one scope covers the whole photos view.
|
|
||||||
core.invalidate_albums();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
};
|
|
||||||
match result {
|
|
||||||
Ok(()) => CtlResponse::Ok {
|
|
||||||
message: "refreshed".to_string(),
|
|
||||||
},
|
|
||||||
Err(e) => CtlResponse::error(e),
|
|
||||||
}
|
}
|
||||||
}
|
Some(_refreshing) => {
|
||||||
|
let result = match &scope {
|
||||||
|
RefreshScope::Dir { path } => route_to_mount(core, mountpoint, path)
|
||||||
|
.and_then(|(core, rel)| core.refresh_dir(&rel)),
|
||||||
|
RefreshScope::Trash => {
|
||||||
|
core.invalidate_trash();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
RefreshScope::Photos { full } if !full && core.timeline_refreshing() => {
|
||||||
|
// A refresh is already running and will publish exactly the
|
||||||
|
// timeline this request asks for. Doing the work again
|
||||||
|
// cannot make it newer, and the invalidation plus the
|
||||||
|
// album read contend with the running refresh for the
|
||||||
|
// database lock — which is how a handful of stacked photo
|
||||||
|
// refreshes at mount time left five control requests
|
||||||
|
// in flight for twenty-three minutes.
|
||||||
|
debug!("a photos refresh is already running; joining it");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
RefreshScope::Photos { full } => {
|
||||||
|
core.invalidate_photos();
|
||||||
|
// A full refresh is "forget what you know", not a second code
|
||||||
|
// path: the ordinary refresh then finds every photo stale.
|
||||||
|
if let Some(Err(e)) = full.then(|| core.db.photos_mark_all_unresolved()) {
|
||||||
|
warn!(error = %e, "marking the whole timeline unresolved failed");
|
||||||
|
}
|
||||||
|
// Whatever changed the timeline can just as easily have
|
||||||
|
// changed an album; one scope covers the whole photos view.
|
||||||
|
core.invalidate_albums();
|
||||||
|
// The refresh is started here, not awaited. A library of
|
||||||
|
// twelve thousand photos is re-read in chunks of
|
||||||
|
// `TIMELINE_ENRICH_CHUNK` nodes, which takes minutes —
|
||||||
|
// longer than the client's read bound, so awaiting it
|
||||||
|
// returned `Resource temporarily unavailable` to a caller
|
||||||
|
// whose refresh was in fact running fine (B92). The front
|
||||||
|
// end follows it with `PhotosRefreshStatus`.
|
||||||
|
core.spawn_timeline_refresh();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match result {
|
||||||
|
Ok(()) => CtlResponse::Ok {
|
||||||
|
message: match scope {
|
||||||
|
RefreshScope::Photos { .. } => "refreshing".to_string(),
|
||||||
|
_ => "refreshed".to_string(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
Ok(CtlRequest::PhotosTimeline {
|
Ok(CtlRequest::PhotosTimeline {
|
||||||
offset,
|
offset,
|
||||||
limit,
|
limit,
|
||||||
kind,
|
kind,
|
||||||
range,
|
range,
|
||||||
favorites,
|
favorites,
|
||||||
}) => match core.photos_timeline(offset, clamp_limit(limit), kind, range, favorites) {
|
not_in_album,
|
||||||
|
}) => match core.photos_timeline(
|
||||||
|
offset,
|
||||||
|
clamp_limit(limit),
|
||||||
|
kind,
|
||||||
|
range,
|
||||||
|
favorites,
|
||||||
|
not_in_album,
|
||||||
|
) {
|
||||||
Ok(Some(items)) => CtlResponse::Photos {
|
Ok(Some(items)) => CtlResponse::Photos {
|
||||||
available: true,
|
available: true,
|
||||||
items,
|
items,
|
||||||
@@ -276,13 +627,64 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
},
|
},
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
},
|
||||||
|
Ok(CtlRequest::PhotoDuplicates) => match core.photo_duplicates() {
|
||||||
|
Ok(sets) => CtlResponse::PhotoDuplicates { sets },
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::PhotoSimilar) => match core.photo_similar() {
|
||||||
|
Ok((sets, hashing, uncompared)) => CtlResponse::PhotoSimilar {
|
||||||
|
sets,
|
||||||
|
hashing,
|
||||||
|
uncompared,
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::PhotoPlaces) => match core.photo_places() {
|
||||||
|
Ok(items) => CtlResponse::Places {
|
||||||
|
items,
|
||||||
|
mapping: core.timeline_progress(),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::PlacePhotos { id, offset, limit }) => {
|
||||||
|
match core.place_photos(id, offset, clamp_limit(limit)) {
|
||||||
|
Ok(items) => CtlResponse::Photos {
|
||||||
|
available: true,
|
||||||
|
items,
|
||||||
|
counts: None,
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(CtlRequest::PlaceLocations { id }) => match core.place_locations(id) {
|
||||||
|
Ok(items) => CtlResponse::PhotoLocations { items },
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::PhotosOnThisDay { limit }) => {
|
||||||
|
match core.photos_on_this_day(clamp_limit(limit)) {
|
||||||
|
Ok(items) => CtlResponse::Photos {
|
||||||
|
available: true,
|
||||||
|
items,
|
||||||
|
counts: None,
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(CtlRequest::PhotoGroup { uid }) => match core.photo_group(&uid) {
|
||||||
|
Ok(items) => CtlResponse::Photos {
|
||||||
|
available: true,
|
||||||
|
items,
|
||||||
|
counts: None,
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
Ok(CtlRequest::SetPhotoFavorite { uid, favorite }) => match parse_uid(&uid) {
|
Ok(CtlRequest::SetPhotoFavorite { uid, favorite }) => match parse_uid(&uid) {
|
||||||
Some(node_uid) => match core.set_photo_favorite(&node_uid, favorite) {
|
Some(node_uid) => match core.set_photo_favorite(&node_uid, favorite) {
|
||||||
Ok(()) => CtlResponse::Ok {
|
Ok(()) => CtlResponse::Ok {
|
||||||
message: if favorite {
|
message: if favorite {
|
||||||
"added to favourites".to_string()
|
"added to favorites".to_string()
|
||||||
} else {
|
} else {
|
||||||
"removed from favourites".to_string()
|
"removed from favorites".to_string()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
@@ -314,6 +716,38 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
},
|
},
|
||||||
None => CtlResponse::error(CoreError::invalid(format!("bad album uid: {uid}"))),
|
None => CtlResponse::error(CoreError::invalid(format!("bad album uid: {uid}"))),
|
||||||
},
|
},
|
||||||
|
Ok(CtlRequest::CreateAlbum { name }) => match core.create_album(&name) {
|
||||||
|
Ok(uid) => CtlResponse::AlbumCreated { uid },
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::RenameAlbum { uid, name }) => match parse_uid(&uid) {
|
||||||
|
Some(album) => match core.rename_album(&album, &name) {
|
||||||
|
Ok(()) => CtlResponse::Ok {
|
||||||
|
message: format!("renamed album to {name}"),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
None => CtlResponse::error(CoreError::invalid(format!("bad album uid: {uid}"))),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::DeleteAlbum { uid, delete_photos }) => match parse_uid(&uid) {
|
||||||
|
Some(album) => match core.delete_album(&album, delete_photos) {
|
||||||
|
Ok(()) => CtlResponse::Ok {
|
||||||
|
message: "album deleted".to_string(),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
None => CtlResponse::error(CoreError::invalid(format!("bad album uid: {uid}"))),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::AddToAlbum { uid, photos }) => {
|
||||||
|
album_change(&uid, &photos, |album, photos| {
|
||||||
|
core.add_to_album(album, photos)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Ok(CtlRequest::RemoveFromAlbum { uid, photos }) => {
|
||||||
|
album_change(&uid, &photos, |album, photos| {
|
||||||
|
core.remove_from_album(album, photos)
|
||||||
|
})
|
||||||
|
}
|
||||||
Ok(CtlRequest::PhotoMonths { kind }) => match core.db.photos_months(kind) {
|
Ok(CtlRequest::PhotoMonths { kind }) => match core.db.photos_months(kind) {
|
||||||
Ok(months) => CtlResponse::PhotoMonths {
|
Ok(months) => CtlResponse::PhotoMonths {
|
||||||
months: months
|
months: months
|
||||||
@@ -329,6 +763,19 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
items: core.photo_thumbs(&parsed),
|
items: core.photo_thumbs(&parsed),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(CtlRequest::TrashNodes { uids, files_only }) => match Core::parse_uids(&uids) {
|
||||||
|
Ok(parsed) => match core.trash_photos(&parsed, files_only) {
|
||||||
|
Ok((trashed, failed)) => CtlResponse::Trashed {
|
||||||
|
trashed,
|
||||||
|
failed: failed
|
||||||
|
.into_iter()
|
||||||
|
.map(|(uid, message)| TrashFailure { uid, message })
|
||||||
|
.collect(),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
Ok(CtlRequest::FileThumbs { items, generation }) => {
|
Ok(CtlRequest::FileThumbs { items, generation }) => {
|
||||||
match core.file_thumbs(&items, generation) {
|
match core.file_thumbs(&items, generation) {
|
||||||
Some(items) => CtlResponse::Thumbs { items },
|
Some(items) => CtlResponse::Thumbs { items },
|
||||||
@@ -357,6 +804,9 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
Ok(CtlRequest::ThumbnailBuildStatus) => CtlResponse::ThumbnailBuild {
|
Ok(CtlRequest::ThumbnailBuildStatus) => CtlResponse::ThumbnailBuild {
|
||||||
status: core.thumbnail_build_status(),
|
status: core.thumbnail_build_status(),
|
||||||
},
|
},
|
||||||
|
Ok(CtlRequest::PhotosRefreshStatus) => CtlResponse::PhotosRefresh {
|
||||||
|
running: core.timeline_refreshing(),
|
||||||
|
},
|
||||||
Ok(CtlRequest::OpenPhoto { uid }) => match parse_uid(&uid) {
|
Ok(CtlRequest::OpenPhoto { uid }) => match parse_uid(&uid) {
|
||||||
Some(u) => match core.open_photo(&u) {
|
Some(u) => match core.open_photo(&u) {
|
||||||
Ok(p) => CtlResponse::FilePath {
|
Ok(p) => CtlResponse::FilePath {
|
||||||
@@ -508,7 +958,11 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(CtlRequest::Search { query, limit }) => match core.search(&query, clamp_limit(limit)) {
|
Ok(CtlRequest::Search {
|
||||||
|
query,
|
||||||
|
limit,
|
||||||
|
scope,
|
||||||
|
}) => match core.search(&query, clamp_limit(limit), scope.as_deref()) {
|
||||||
Ok(hits) => CtlResponse::SearchResults { hits },
|
Ok(hits) => CtlResponse::SearchResults { hits },
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
},
|
||||||
@@ -533,8 +987,8 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
},
|
},
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
},
|
||||||
Ok(CtlRequest::Rename { path, new_name }) => match rel_to_mount(mountpoint, &path) {
|
Ok(CtlRequest::Rename { path, new_name }) => match route_node(core, mountpoint, &path) {
|
||||||
Ok(rel) => match core.rename(&rel, &new_name) {
|
Ok((core, rel)) => match core.rename(&rel, &new_name) {
|
||||||
Ok(name) => {
|
Ok(name) => {
|
||||||
core.log_activity(ActivityKind::Rename, &name, format!("was {path}"), true);
|
core.log_activity(ActivityKind::Rename, &name, format!("was {path}"), true);
|
||||||
CtlResponse::Ok {
|
CtlResponse::Ok {
|
||||||
@@ -548,6 +1002,24 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
},
|
},
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
},
|
||||||
|
// Two absolute paths may name two different locations, which only
|
||||||
|
// `move_between` can resolve; a relative pair keeps the old meaning.
|
||||||
|
Ok(CtlRequest::Move { path, new_parent })
|
||||||
|
if Path::new(&path).is_absolute() && Path::new(&new_parent).is_absolute() =>
|
||||||
|
{
|
||||||
|
match core.move_between(Path::new(&path), Path::new(&new_parent)) {
|
||||||
|
Ok(name) => {
|
||||||
|
core.log_activity(ActivityKind::Move, &name, format!("to {new_parent}"), true);
|
||||||
|
CtlResponse::Ok {
|
||||||
|
message: format!("moved {name}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
core.log_activity(ActivityKind::Move, &path, &e, false);
|
||||||
|
CtlResponse::error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(CtlRequest::Move { path, new_parent }) => {
|
Ok(CtlRequest::Move { path, new_parent }) => {
|
||||||
match (
|
match (
|
||||||
rel_to_mount(mountpoint, &path),
|
rel_to_mount(mountpoint, &path),
|
||||||
@@ -573,8 +1045,8 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
(Err(e), _) | (_, Err(e)) => CtlResponse::error(e),
|
(Err(e), _) | (_, Err(e)) => CtlResponse::error(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(CtlRequest::Delete { path }) => match rel_to_mount(mountpoint, &path) {
|
Ok(CtlRequest::Delete { path }) => match route_node(core, mountpoint, &path) {
|
||||||
Ok(rel) => match core.delete(&rel) {
|
Ok((core, rel)) => match core.delete(&rel) {
|
||||||
Ok(name) => {
|
Ok(name) => {
|
||||||
core.log_activity(ActivityKind::Trash, &name, "", true);
|
core.log_activity(ActivityKind::Trash, &name, "", true);
|
||||||
CtlResponse::Ok {
|
CtlResponse::Ok {
|
||||||
@@ -588,21 +1060,23 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
},
|
},
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
},
|
||||||
Ok(CtlRequest::CreateFolder { parent, name }) => match rel_to_mount(mountpoint, &parent) {
|
Ok(CtlRequest::CreateFolder { parent, name }) => {
|
||||||
Ok(parent_rel) => match core.create_folder(&parent_rel, &name) {
|
match route_change(core, mountpoint, &parent) {
|
||||||
Ok(name) => {
|
Ok((core, parent_rel)) => match core.create_folder(&parent_rel, &name) {
|
||||||
core.log_activity(ActivityKind::CreateFolder, &name, "", true);
|
Ok(name) => {
|
||||||
CtlResponse::Ok {
|
core.log_activity(ActivityKind::CreateFolder, &name, "", true);
|
||||||
message: format!("created folder {name}"),
|
CtlResponse::Ok {
|
||||||
|
message: format!("created folder {name}"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
Err(e) => {
|
||||||
Err(e) => {
|
core.log_activity(ActivityKind::CreateFolder, &name, &e, false);
|
||||||
core.log_activity(ActivityKind::CreateFolder, &name, &e, false);
|
CtlResponse::error(e)
|
||||||
CtlResponse::error(e)
|
}
|
||||||
}
|
},
|
||||||
},
|
Err(e) => CtlResponse::error(e),
|
||||||
Err(e) => CtlResponse::error(e),
|
}
|
||||||
},
|
}
|
||||||
Ok(CtlRequest::UploadPaths { parent, sources }) => {
|
Ok(CtlRequest::UploadPaths { parent, sources }) => {
|
||||||
match rel_to_mount(mountpoint, &parent) {
|
match rel_to_mount(mountpoint, &parent) {
|
||||||
// Ack immediately and upload on a background thread: a directory tree
|
// Ack immediately and upload on a background thread: a directory tree
|
||||||
@@ -657,6 +1131,9 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(CtlRequest::Diagnostics) => CtlResponse::Diagnostics {
|
||||||
|
diagnostics: core.diagnostics(),
|
||||||
|
},
|
||||||
Ok(CtlRequest::ListTrash) => match core.list_trash() {
|
Ok(CtlRequest::ListTrash) => match core.list_trash() {
|
||||||
Ok(entries) => CtlResponse::Entries { entries },
|
Ok(entries) => CtlResponse::Entries { entries },
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
@@ -686,9 +1163,14 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
},
|
},
|
||||||
Ok(CtlRequest::EmptyTrash) => match core.empty_trash() {
|
Ok(CtlRequest::EmptyTrash) => match core.empty_trash() {
|
||||||
Ok(n) => {
|
Ok(n) => {
|
||||||
core.log_activity(ActivityKind::EmptyTrash, format!("{n} item(s)"), "", true);
|
let items = if n == 1 {
|
||||||
|
"1 item".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{n} items")
|
||||||
|
};
|
||||||
|
core.log_activity(ActivityKind::EmptyTrash, items.clone(), "", true);
|
||||||
CtlResponse::Ok {
|
CtlResponse::Ok {
|
||||||
message: format!("emptied trash ({n} item(s))"),
|
message: format!("emptied trash ({items})"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
@@ -768,6 +1250,29 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
))),
|
))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(CtlRequest::SetBandwidthLimits { upload, download }) => {
|
||||||
|
core.transfers.set_limits(upload, download);
|
||||||
|
// Persisted like the cache budget: best-effort, the live caps are
|
||||||
|
// already applied.
|
||||||
|
match AppDirs::new().map(|dirs| {
|
||||||
|
let mut cfg = dirs.load_config();
|
||||||
|
cfg.upload_limit = Some(upload);
|
||||||
|
cfg.download_limit = Some(download);
|
||||||
|
dirs.save_config(&cfg)
|
||||||
|
}) {
|
||||||
|
Ok(Ok(())) => CtlResponse::Ok {
|
||||||
|
message: format!(
|
||||||
|
"bandwidth limits set to {upload} B/s up, {download} B/s down"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
Ok(Err(e)) => CtlResponse::error(CoreError::internal(format!(
|
||||||
|
"limits applied but config write failed: {e}"
|
||||||
|
))),
|
||||||
|
Err(e) => CtlResponse::error(CoreError::internal(format!(
|
||||||
|
"limits applied but config unavailable: {e}"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(CtlRequest::ListDevices) => match core.list_devices() {
|
Ok(CtlRequest::ListDevices) => match core.list_devices() {
|
||||||
Ok(items) => CtlResponse::Devices { items },
|
Ok(items) => CtlResponse::Devices { items },
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
@@ -792,6 +1297,10 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
Ok(message) => CtlResponse::Ok { message },
|
Ok(message) => CtlResponse::Ok { message },
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
},
|
||||||
|
Ok(CtlRequest::MigrateDevice { uid }) => match core.migrate_device(&uid) {
|
||||||
|
Ok(message) => CtlResponse::Ok { message },
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
Ok(CtlRequest::AddSyncFolder { local_path }) => {
|
Ok(CtlRequest::AddSyncFolder { local_path }) => {
|
||||||
// Registering the device and uploading a folder tree far outlasts the
|
// Registering the device and uploading a folder tree far outlasts the
|
||||||
// socket read timeout, so ack immediately and work on a background
|
// socket read timeout, so ack immediately and work on a background
|
||||||
@@ -851,6 +1360,17 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(CtlRequest::SetSyncFolderPaused { id, paused }) => {
|
||||||
|
match core.set_sync_folder_paused(id, paused) {
|
||||||
|
Ok(()) => CtlResponse::Ok {
|
||||||
|
message: match paused {
|
||||||
|
true => format!("folder {id} paused"),
|
||||||
|
false => format!("folder {id} resumed"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(CtlRequest::SyncNow { id }) => {
|
Ok(CtlRequest::SyncNow { id }) => {
|
||||||
core.sync_now(id);
|
core.sync_now(id);
|
||||||
CtlResponse::Ok {
|
CtlResponse::Ok {
|
||||||
@@ -860,22 +1380,91 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(CtlRequest::ListRestorableFolders) => match core.list_restorable_folders() {
|
Ok(CtlRequest::SetSyncPaused { paused, until }) => {
|
||||||
|
match core.set_sync_paused(paused, until) {
|
||||||
|
Ok(()) => CtlResponse::Ok {
|
||||||
|
message: match (paused, until) {
|
||||||
|
(false, _) => "sync resumed".to_string(),
|
||||||
|
(true, None) => "sync paused until resumed".to_string(),
|
||||||
|
(true, Some(_)) => "sync paused".to_string(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(CtlRequest::ListConflicts) => match core.list_conflicts() {
|
||||||
|
Ok(items) => CtlResponse::Conflicts { items },
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::ResolveConflict { path, keep }) => {
|
||||||
|
match route_change(core, mountpoint, &path) {
|
||||||
|
Ok((core, rel)) => match core.resolve_conflict(&rel, &keep) {
|
||||||
|
Ok(message) => CtlResponse::Ok { message },
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(CtlRequest::ListPendingOps) => match core.pending_op_infos() {
|
||||||
|
Ok(items) => CtlResponse::PendingOps { items },
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::ExportPendingOp { id, dest }) => {
|
||||||
|
match core.export_pending_op(id, Path::new(&dest)) {
|
||||||
|
Ok(written) => CtlResponse::Ok {
|
||||||
|
message: format!("wrote {} to {dest}", human_bytes(written)),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(CtlRequest::DiscardPendingOp { id }) => match core.discard_pending_op(id) {
|
||||||
|
Ok(path) => CtlResponse::Ok {
|
||||||
|
message: format!("discarded the queued change to {path}"),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::RetryPendingOp { id }) => match core.retry_pending_ops(id) {
|
||||||
|
Ok(0) if id.is_some() => CtlResponse::Ok {
|
||||||
|
message: "nothing to retry: the operation is already due or waiting for a rename"
|
||||||
|
.to_string(),
|
||||||
|
},
|
||||||
|
Ok(moved) => CtlResponse::Ok {
|
||||||
|
message: format!("retrying {moved} queued operation(s) now"),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::ListRestorableFolders) => match core.list_restorable_folders(None) {
|
||||||
Ok(items) => CtlResponse::RestorableFolders { items },
|
Ok(items) => CtlResponse::RestorableFolders { items },
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
},
|
||||||
Ok(CtlRequest::RestoreSyncFolders { items }) => {
|
Ok(CtlRequest::ListDeviceRestorableFolders { device }) => {
|
||||||
|
match core.list_restorable_folders(Some(&device)) {
|
||||||
|
Ok(items) => CtlResponse::RestorableFolders { items },
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(
|
||||||
|
request @ (CtlRequest::RestoreSyncFolders { .. }
|
||||||
|
| CtlRequest::RestoreDeviceFolders { .. }),
|
||||||
|
) => {
|
||||||
|
let (device, items) = match request {
|
||||||
|
CtlRequest::RestoreDeviceFolders { device, items } => (Some(device), items),
|
||||||
|
CtlRequest::RestoreSyncFolders { items } => (None, items),
|
||||||
|
_ => unreachable!("matched above"),
|
||||||
|
};
|
||||||
// Like AddSyncFolder: each restored folder downloads a whole tree,
|
// Like AddSyncFolder: each restored folder downloads a whole tree,
|
||||||
// which outlasts the socket timeout. Ack, then work.
|
// which outlasts the socket timeout. Ack, then work.
|
||||||
let core = core.clone();
|
let core = core.clone();
|
||||||
std::thread::spawn(move || match core.restore_sync_folders(&items) {
|
std::thread::spawn(move || {
|
||||||
Ok(message) => {
|
match core.restore_sync_folders(&items, device.as_deref()) {
|
||||||
core.touch_profile();
|
Ok(message) => {
|
||||||
core.log_activity(ActivityKind::Download, &message, "", true);
|
core.touch_profile();
|
||||||
}
|
core.log_activity(ActivityKind::Download, &message, "", true);
|
||||||
Err(e) => {
|
}
|
||||||
warn!(error = %e, "restore failed");
|
Err(e) => {
|
||||||
core.log_activity(ActivityKind::Download, "restore folders", &e, false);
|
warn!(error = %e, "restore failed");
|
||||||
|
core.log_activity(ActivityKind::Download, "restore folders", &e, false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
CtlResponse::Ok {
|
CtlResponse::Ok {
|
||||||
@@ -1046,6 +1635,13 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(CtlRequest::StopSharing { path }) => match rel_to_mount(mountpoint, &path) {
|
||||||
|
Ok(rel) => stop_sharing_reply(core, &path, core.stop_sharing(&rel)),
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::StopSharingByUid { uid }) => {
|
||||||
|
stop_sharing_reply(core, &uid, core.stop_sharing_by_uid(&uid))
|
||||||
|
}
|
||||||
Ok(CtlRequest::ListRevisions { path }) => match rel_to_mount(mountpoint, &path) {
|
Ok(CtlRequest::ListRevisions { path }) => match rel_to_mount(mountpoint, &path) {
|
||||||
Ok(rel) => match core.list_revisions(&rel) {
|
Ok(rel) => match core.list_revisions(&rel) {
|
||||||
Ok(items) => CtlResponse::Revisions { items },
|
Ok(items) => CtlResponse::Revisions { items },
|
||||||
@@ -1192,6 +1788,9 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
// The request did not parse: the caller sent something malformed.
|
// The request did not parse: the caller sent something malformed.
|
||||||
Err(e) => CtlResponse::error(CoreError::invalid(format!("bad request: {e}"))),
|
Err(e) => CtlResponse::error(CoreError::invalid(format!("bad request: {e}"))),
|
||||||
};
|
};
|
||||||
|
if !touched.is_empty() && !matches!(response, CtlResponse::Error { .. }) {
|
||||||
|
core.events.publish(touched);
|
||||||
|
}
|
||||||
let mut out = match serde_json::to_vec(&response) {
|
let mut out = match serde_json::to_vec(&response) {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -1221,7 +1820,7 @@ pub(crate) fn run_control_socket(
|
|||||||
info!("control socket listening");
|
info!("control socket listening");
|
||||||
for conn in listener.incoming() {
|
for conn in listener.incoming() {
|
||||||
// Checked on every accept, including the deliberate poke teardown sends
|
// Checked on every accept, including the deliberate poke teardown sends
|
||||||
// to wake this thread out of a blocking accept (bugs.md B44).
|
// to wake this thread out of a blocking accept (`docs/BUGS.md` B44).
|
||||||
if core.shutdown.is_stopping() {
|
if core.shutdown.is_stopping() {
|
||||||
info!("control socket closing");
|
info!("control socket closing");
|
||||||
return;
|
return;
|
||||||
@@ -1313,4 +1912,41 @@ mod request_limit_tests {
|
|||||||
assert!(rel_to_mount(mountpoint, "Photos/../../escape").is_err());
|
assert!(rel_to_mount(mountpoint, "Photos/../../escape").is_err());
|
||||||
assert!(rel_to_mount(mountpoint, "/mnt/elsewhere").is_err());
|
assert!(rel_to_mount(mountpoint, "/mnt/elsewhere").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `pdfs rm` and `pdfs rename` reach on-demand mounts, whose root is a whole
|
||||||
|
/// synced folder with a parent on Drive. Only a path below a root may get
|
||||||
|
/// through to the trash or rename call.
|
||||||
|
#[test]
|
||||||
|
fn only_a_path_below_a_mount_root_names_a_node() {
|
||||||
|
assert!(below_root(Path::new("Photos")));
|
||||||
|
assert!(below_root(Path::new("./Photos/2026")));
|
||||||
|
assert!(!below_root(Path::new("")));
|
||||||
|
assert!(!below_root(Path::new(".")));
|
||||||
|
assert!(!below_root(Path::new("./.")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Two refreshes of the same scope are one piece of work; two refreshes of
|
||||||
|
/// different scopes are not.
|
||||||
|
#[test]
|
||||||
|
fn a_scope_refreshes_once_at_a_time() {
|
||||||
|
let photos = RefreshScope::Photos { full: false };
|
||||||
|
let held = RefreshGuard::acquire(&photos).expect("first refresh runs");
|
||||||
|
assert!(RefreshGuard::acquire(&photos).is_none());
|
||||||
|
// A full refresh forgets what the daemon knows, so it is other work.
|
||||||
|
assert!(RefreshGuard::acquire(&RefreshScope::Photos { full: true }).is_some());
|
||||||
|
assert!(
|
||||||
|
RefreshGuard::acquire(&RefreshScope::Dir {
|
||||||
|
path: "a".to_string()
|
||||||
|
})
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
|
||||||
|
drop(held);
|
||||||
|
assert!(RefreshGuard::acquire(&photos).is_some());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
//! This machine as a Proton Drive Device, and the local folders it keeps in
|
//! This machine as a Proton Drive Device, and the local folders it keeps in
|
||||||
//! step with the remote (devices.md).
|
//! step with the remote.
|
||||||
//!
|
//!
|
||||||
//! A sync folder runs in one of two modes. `mirror` keeps a real local tree that
|
//! A sync folder runs in one of two modes. `mirror` keeps a real local tree that
|
||||||
//! the engine in [`super::sync`] reconciles both ways; `ondemand` evicts that
|
//! the engine in [`super::sync`] reconciles both ways; `ondemand` evicts that
|
||||||
@@ -19,6 +19,7 @@ use pdfs_core::control::{
|
|||||||
};
|
};
|
||||||
use pdfs_core::db::{StoredDevice, StoredSyncFolder};
|
use pdfs_core::db::{StoredDevice, StoredSyncFolder};
|
||||||
use pdfs_core::mounts::MountMode;
|
use pdfs_core::mounts::MountMode;
|
||||||
|
use pdfs_core::profile::PROFILE_DIR_NAME;
|
||||||
use pdfs_core::{CoreError, CoreResult};
|
use pdfs_core::{CoreError, CoreResult};
|
||||||
use proton_drive_rs::proton_sdk::ids::{DeviceUid, NodeUid};
|
use proton_drive_rs::proton_sdk::ids::{DeviceUid, NodeUid};
|
||||||
use proton_drive_rs::{DeviceType, Node};
|
use proton_drive_rs::{DeviceType, Node};
|
||||||
@@ -27,7 +28,7 @@ use tracing::{info, warn};
|
|||||||
|
|
||||||
use super::sync::{self, base_name};
|
use super::sync::{self, base_name};
|
||||||
use super::{
|
use super::{
|
||||||
Core, SecondaryInsertRejection, SecondaryMount, State, SwitchBlocked, clear_stale_mount,
|
Core, SecondaryInsertRejection, SecondaryMount, SwitchBlocked, clear_stale_mount,
|
||||||
device_type_str, dir_is_empty, evict_dir_contents, fuse_connection_id, is_stale_mount,
|
device_type_str, dir_is_empty, evict_dir_contents, fuse_connection_id, is_stale_mount,
|
||||||
now_secs, parse_uid, spawn_session, sync_folder_info, this_hostname,
|
now_secs, parse_uid, spawn_session, sync_folder_info, this_hostname,
|
||||||
};
|
};
|
||||||
@@ -114,6 +115,22 @@ pub(crate) fn set_adopted_device_uid(uid: Option<&str>) -> CoreResult<()> {
|
|||||||
.map_err(|e| CoreError::internal(format!("write config: {e}")))
|
.map_err(|e| CoreError::internal(format!("write config: {e}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The name a folder moved in from `device` takes when `name` is already
|
||||||
|
/// taken under this device's root, or `None` when it can keep its own.
|
||||||
|
fn migrated_name(name: &str, device: &str, taken: &[String]) -> Option<String> {
|
||||||
|
let free = |candidate: &str| !taken.iter().any(|t| t == candidate);
|
||||||
|
if free(name) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let base = format!("{name} ({device})");
|
||||||
|
if free(&base) {
|
||||||
|
return Some(base);
|
||||||
|
}
|
||||||
|
(2..)
|
||||||
|
.map(|n| format!("{base} {n}"))
|
||||||
|
.find(|candidate| free(candidate))
|
||||||
|
}
|
||||||
|
|
||||||
impl Core {
|
impl Core {
|
||||||
// ---- devices ----------------------------------------------------------
|
// ---- devices ----------------------------------------------------------
|
||||||
|
|
||||||
@@ -128,6 +145,7 @@ impl Core {
|
|||||||
location.mounted = self.states.is_mounted_at(Path::new(&location.local_path));
|
location.mounted = self.states.is_mounted_at(Path::new(&location.local_path));
|
||||||
if let MountKind::Device { sync_folder_id } = &location.kind {
|
if let MountKind::Device { sync_folder_id } = &location.kind {
|
||||||
location.progress = progress.get(sync_folder_id).cloned();
|
location.progress = progress.get(sync_folder_id).cloned();
|
||||||
|
location.paused = self.sync_folder_paused(*sync_folder_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(locations)
|
Ok(locations)
|
||||||
@@ -183,8 +201,8 @@ impl Core {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Adopt an existing device as *this* machine's, pinning it in `config.json`
|
/// Adopt an existing device as *this* machine's, pinning it in `config.json`
|
||||||
/// so hostname changes and reinstalls stop registering duplicates
|
/// so hostname changes and reinstalls stop registering duplicates.
|
||||||
/// (features.md 5.1). `None` clears the pin and returns to hostname matching.
|
/// `None` clears the pin and returns to hostname matching.
|
||||||
///
|
///
|
||||||
/// Refuses a uid that is not in the account: the whole point of the pin is
|
/// Refuses a uid that is not in the account: the whole point of the pin is
|
||||||
/// that it is authoritative, so accepting a bad one would defer the failure
|
/// that it is authoritative, so accepting a bad one would defer the failure
|
||||||
@@ -226,7 +244,141 @@ impl Core {
|
|||||||
Ok(format!("adopted device {name}"))
|
Ok(format!("adopted device {name}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- device folder sync (devices.md, Phase 1) -------------------------
|
/// The device a restore reads from: this machine's own for `None`, else
|
||||||
|
/// the registered device with that uid.
|
||||||
|
///
|
||||||
|
/// Another device is looked up without touching the cached device row —
|
||||||
|
/// restoring its folders here must not make this machine back up as it.
|
||||||
|
/// Its folders are then attached with that device's share id, so they sync
|
||||||
|
/// against the other computer's tree.
|
||||||
|
pub(crate) fn restore_source(&self, device: Option<&str>) -> CoreResult<StoredDevice> {
|
||||||
|
let Some(uid) = device else {
|
||||||
|
return self.ensure_device();
|
||||||
|
};
|
||||||
|
let remote = self
|
||||||
|
.rt
|
||||||
|
.block_on(self.client.enumerate_devices())
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "enumerate devices"))?;
|
||||||
|
let d = remote
|
||||||
|
.into_iter()
|
||||||
|
.find(|d| d.uid.to_string() == uid)
|
||||||
|
.ok_or_else(|| CoreError::not_found(format!("no device {uid} in this account")))?;
|
||||||
|
Ok(StoredDevice {
|
||||||
|
uid: d.uid.to_string(),
|
||||||
|
share_id: d.share_id.to_string(),
|
||||||
|
root_uid: d.root_folder_uid.to_string(),
|
||||||
|
name: d
|
||||||
|
.name
|
||||||
|
.as_deref()
|
||||||
|
.ok()
|
||||||
|
.unwrap_or("(unnamed device)")
|
||||||
|
.to_string(),
|
||||||
|
created: d.creation_time,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move every folder another device backs up into this machine's own
|
||||||
|
/// device, so this machine carries on backing them up as itself. The other
|
||||||
|
/// device stays registered, without those folders.
|
||||||
|
///
|
||||||
|
/// Device roots all live on the main volume, so each folder is one
|
||||||
|
/// server-side move: nothing is downloaded or uploaded, and no quota is
|
||||||
|
/// used twice. A folder whose name is taken here keeps its content under
|
||||||
|
/// "<name> (<device>)". A failed folder does not stop the others, and a
|
||||||
|
/// second run picks up whatever is still left on the other device.
|
||||||
|
///
|
||||||
|
/// Nothing is synced to this disk here. The moved folders are now this
|
||||||
|
/// device's, so the ordinary restore offers them. Only folders that were
|
||||||
|
/// already synced here from the other device are touched: their uid
|
||||||
|
/// survives the move but their share does not.
|
||||||
|
pub(crate) fn migrate_device(&self, uid: &str) -> CoreResult<String> {
|
||||||
|
let source = self.restore_source(Some(uid))?;
|
||||||
|
let target = self.ensure_device()?;
|
||||||
|
if source.uid == target.uid {
|
||||||
|
return Err(CoreError::invalid(format!(
|
||||||
|
"{} is this computer's own device",
|
||||||
|
source.name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let bad_root = |d: &StoredDevice| {
|
||||||
|
parse_uid(&d.root_uid)
|
||||||
|
.ok_or_else(|| CoreError::internal(format!("bad device root uid: {}", d.root_uid)))
|
||||||
|
};
|
||||||
|
let (source_root, target_root) = (bad_root(&source)?, bad_root(&target)?);
|
||||||
|
if source_root.volume_id != target_root.volume_id {
|
||||||
|
return Err(CoreError::invalid(
|
||||||
|
"the two devices are on different Proton Drive volumes; copy it instead",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut taken: Vec<String> = self
|
||||||
|
.device_root_nodes(&target_root)?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|n| !n.trashed)
|
||||||
|
.map(|n| n.name)
|
||||||
|
.collect();
|
||||||
|
let synced = self
|
||||||
|
.db
|
||||||
|
.sync_folder_list()
|
||||||
|
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
||||||
|
|
||||||
|
let mut moved = 0usize;
|
||||||
|
let mut renamed: Vec<String> = Vec::new();
|
||||||
|
let mut left: Vec<String> = Vec::new();
|
||||||
|
let mut failed: Vec<String> = Vec::new();
|
||||||
|
for node in self.device_root_nodes(&source_root)? {
|
||||||
|
if node.trashed || node.name == PROFILE_DIR_NAME {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// A device root only ever holds folders (B68); anything else was
|
||||||
|
// put there by hand and is left for the user to deal with.
|
||||||
|
if !node.is_folder() {
|
||||||
|
left.push(node.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let new_name = migrated_name(&node.name, &source.name, &taken);
|
||||||
|
if let Err(e) = self.move_rename_remote(&node.uid, &target_root, new_name.as_deref()) {
|
||||||
|
warn!(name = %node.name, error = %e, "migrate: could not move folder");
|
||||||
|
failed.push(format!("{}: {e}", node.name));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let landed = new_name.clone().unwrap_or_else(|| node.name.clone());
|
||||||
|
if new_name.is_some() {
|
||||||
|
renamed.push(format!("{} -> {landed}", node.name));
|
||||||
|
}
|
||||||
|
taken.push(landed);
|
||||||
|
moved += 1;
|
||||||
|
|
||||||
|
let uid = node.uid.to_string();
|
||||||
|
for folder in synced.iter().filter(|f| f.remote_uid == uid) {
|
||||||
|
if let Err(e) = self.db.sync_folder_set_share(folder.id, &target.share_id) {
|
||||||
|
warn!(id = folder.id, error = ?e, "migrate: could not re-point sync folder");
|
||||||
|
}
|
||||||
|
self.sync_now(Some(folder.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.invalidate_children_of(&source_root);
|
||||||
|
self.invalidate_children_of(&target_root);
|
||||||
|
|
||||||
|
let mut message = format!("moved {moved} folder(s) from {}", source.name);
|
||||||
|
if !renamed.is_empty() {
|
||||||
|
message.push_str(&format!("; renamed: {}", renamed.join(", ")));
|
||||||
|
}
|
||||||
|
if !left.is_empty() {
|
||||||
|
message.push_str(&format!("; left behind: {}", left.join(", ")));
|
||||||
|
}
|
||||||
|
if !failed.is_empty() {
|
||||||
|
message.push_str(&format!("; failed: {}", failed.join(", ")));
|
||||||
|
}
|
||||||
|
info!(from = %source.name, to = %target.name, moved, failed = failed.len(),
|
||||||
|
"device migrated");
|
||||||
|
if moved == 0 && !failed.is_empty() {
|
||||||
|
return Err(CoreError::conflict(message));
|
||||||
|
}
|
||||||
|
Ok(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- device folder sync -------------------------
|
||||||
|
|
||||||
/// Auto-register (or recover) this machine as a Proton Drive Device, caching
|
/// Auto-register (or recover) this machine as a Proton Drive Device, caching
|
||||||
/// it so restarts reuse the same device. Recovery matches an existing remote
|
/// it so restarts reuse the same device. Recovery matches an existing remote
|
||||||
@@ -236,8 +388,7 @@ impl Core {
|
|||||||
/// Resolution order: the *adopted* uid pinned in `config.json`, then the
|
/// Resolution order: the *adopted* uid pinned in `config.json`, then the
|
||||||
/// cached DB row (validated remotely), then a hostname match, then create.
|
/// cached DB row (validated remotely), then a hostname match, then create.
|
||||||
/// The pin exists because the hostname heuristic silently registers a second
|
/// The pin exists because the hostname heuristic silently registers a second
|
||||||
/// device after a rename or reinstall, orphaning the first one's folders
|
/// device after a rename or reinstall, orphaning the first one's folders.
|
||||||
/// (features.md 5.1).
|
|
||||||
pub(crate) fn ensure_device(&self) -> CoreResult<StoredDevice> {
|
pub(crate) fn ensure_device(&self) -> CoreResult<StoredDevice> {
|
||||||
let name = this_hostname();
|
let name = this_hostname();
|
||||||
// Enumerate the remote devices once: used to validate the pin, validate
|
// Enumerate the remote devices once: used to validate the pin, validate
|
||||||
@@ -327,23 +478,27 @@ impl Core {
|
|||||||
root_uid: &NodeUid,
|
root_uid: &NodeUid,
|
||||||
name: &str,
|
name: &str,
|
||||||
) -> CoreResult<Option<NodeUid>> {
|
) -> CoreResult<Option<NodeUid>> {
|
||||||
let uids = self
|
Ok(self
|
||||||
.rt
|
.device_root_nodes(root_uid)?
|
||||||
.block_on(self.client.enumerate_folder_children_node_uids(root_uid))
|
|
||||||
.map_err(|e| CoreError::from_api(&e, "list device root"))?;
|
|
||||||
if uids.is_empty() {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
let nodes = self
|
|
||||||
.rt
|
|
||||||
.block_on(self.client.enumerate_nodes(&uids))
|
|
||||||
.map_err(|e| CoreError::from_api(&e, "resolve device root children"))?;
|
|
||||||
Ok(nodes
|
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|n| n.is_folder() && !n.trashed && n.name == name)
|
.find(|n| n.is_folder() && !n.trashed && n.name == name)
|
||||||
.map(|n| n.uid))
|
.map(|n| n.uid))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Everything directly under a device root, trashed nodes included.
|
||||||
|
fn device_root_nodes(&self, root_uid: &NodeUid) -> CoreResult<Vec<Node>> {
|
||||||
|
let uids = self
|
||||||
|
.rt
|
||||||
|
.block_on(self.drive.enumerate_folder_children_node_uids(root_uid))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "list device root"))?;
|
||||||
|
if uids.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
self.rt
|
||||||
|
.block_on(self.drive.enumerate_nodes(&uids))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "resolve device root children"))
|
||||||
|
}
|
||||||
|
|
||||||
/// Add a local folder to this device's sync set: register the device if
|
/// Add a local folder to this device's sync set: register the device if
|
||||||
/// needed, create a matching folder under the device root, upload the local
|
/// needed, create a matching folder under the device root, upload the local
|
||||||
/// tree into it once, and record the mapping. Phase 1 is a one-shot upload —
|
/// tree into it once, and record the mapping. Phase 1 is a one-shot upload —
|
||||||
@@ -412,10 +567,7 @@ impl Core {
|
|||||||
}
|
}
|
||||||
None => self
|
None => self
|
||||||
.rt
|
.rt
|
||||||
.block_on(
|
.block_on(self.drive.create_folder(&root_uid, &name, Some(now_secs())))
|
||||||
self.client
|
|
||||||
.create_folder(&root_uid, &name, Some(now_secs())),
|
|
||||||
)
|
|
||||||
.map_err(|e| CoreError::from_api(&e, &format!("create device folder {name}")))?,
|
.map_err(|e| CoreError::from_api(&e, &format!("create device folder {name}")))?,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -448,7 +600,8 @@ impl Core {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|f| {
|
.map(|f| {
|
||||||
let live = progress.get(&f.id).cloned();
|
let live = progress.get(&f.id).cloned();
|
||||||
sync_folder_info(f, live)
|
let paused = self.sync_folder_paused(f.id);
|
||||||
|
sync_folder_info(f, live, paused)
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
@@ -504,12 +657,16 @@ impl Core {
|
|||||||
} else {
|
} else {
|
||||||
p.total.max(p.done) as u64
|
p.total.max(p.done) as u64
|
||||||
},
|
},
|
||||||
|
// Most passes find nothing; only one that queues work is
|
||||||
|
// the drive syncing.
|
||||||
|
background: true,
|
||||||
},
|
},
|
||||||
SyncPhase::Applying => JobItem {
|
SyncPhase::Applying => JobItem {
|
||||||
title: format!("Syncing {folder}"),
|
title: format!("Syncing {folder}"),
|
||||||
detail: p.current.clone(),
|
detail: p.current.clone(),
|
||||||
done: p.done as u64,
|
done: p.done as u64,
|
||||||
total: p.total.max(p.done) as u64,
|
total: p.total.max(p.done) as u64,
|
||||||
|
background: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -561,13 +718,14 @@ impl Core {
|
|||||||
return Err(CoreError::invalid(format!("no synced folder with id {id}")));
|
return Err(CoreError::invalid(format!("no synced folder with id {id}")));
|
||||||
}
|
}
|
||||||
drop(_guard);
|
drop(_guard);
|
||||||
|
self.clear_sync_folder_paused(id);
|
||||||
// Stop watching the folder we just dropped.
|
// Stop watching the folder we just dropped.
|
||||||
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
|
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
|
||||||
if delete_remote
|
if delete_remote
|
||||||
&& let Some(uid) = parse_uid(&folder.remote_uid)
|
&& let Some(uid) = parse_uid(&folder.remote_uid)
|
||||||
&& let Err(e) = self
|
&& let Err(e) = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.trash_nodes(&[uid]))
|
.block_on(self.drive.trash_nodes(&[uid]))
|
||||||
.and_then(batch::into_unit)
|
.and_then(batch::into_unit)
|
||||||
{
|
{
|
||||||
warn!(id, error = %e, "delete remote device folder failed");
|
warn!(id, error = %e, "delete remote device folder failed");
|
||||||
@@ -583,23 +741,16 @@ impl Core {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A sibling Core that shares this one's client/rt/cache/db (and transfer,
|
/// A sibling Core for a secondary FUSE session rooted at an `ondemand` sync
|
||||||
/// activity, mount registries) but gets a **fresh, empty `State`** — its own
|
/// folder. It shares everything with this one,
|
||||||
/// inode space starting at [`ROOT_INO`]. Used to root a secondary FUSE session
|
/// the tree included, except what belongs to a session: its root and its
|
||||||
/// at an `ondemand` sync folder without colliding with the main mount's inodes
|
/// kernel channel, both filled in when the session is built.
|
||||||
/// (devices.md Phase 3).
|
|
||||||
pub(crate) fn fork_state(&self) -> Core {
|
pub(crate) fn fork_state(&self) -> Core {
|
||||||
let mut fork = self.clone();
|
let mut fork = self.clone();
|
||||||
fork.primary = false;
|
fork.primary = false;
|
||||||
let share_access = self.state().share_access.clone();
|
fork.root = Arc::new(OnceLock::new());
|
||||||
fork.state = Arc::new(Mutex::new(State::new(self.db.clone(), share_access, 2)));
|
|
||||||
// A fresh inode space needs a fresh notification channel: this fork's
|
|
||||||
// session is the only one that knows these inodes, so it must be the one
|
|
||||||
// notified about them. Filled in by `spawn_ondemand_mount`.
|
|
||||||
fork.notifier = Arc::new(OnceLock::new());
|
fork.notifier = Arc::new(OnceLock::new());
|
||||||
fork.session_live = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
fork.session_live = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||||
// Size upgrades are keyed by inode, which is per-fork too.
|
|
||||||
fork.size_upgrades = Arc::new(Mutex::new(HashMap::new()));
|
|
||||||
fork
|
fork
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -625,6 +776,13 @@ impl Core {
|
|||||||
.ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?;
|
.ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?;
|
||||||
let current_mode = ApplicableMountMode::parse(&folder.mode)
|
let current_mode = ApplicableMountMode::parse(&folder.mode)
|
||||||
.map_err(|error| CoreError::invalid(format!("unsupported current mode: {error}")))?;
|
.map_err(|error| CoreError::invalid(format!("unsupported current mode: {error}")))?;
|
||||||
|
// A switch waits on the folder's reconcile, which a pause skips; refuse
|
||||||
|
// it up front rather than queue it indefinitely.
|
||||||
|
if current_mode != target_mode && self.sync_folder_paused(id) {
|
||||||
|
return Err(CoreError::invalid(
|
||||||
|
"this folder is paused; resume it before changing its mode",
|
||||||
|
));
|
||||||
|
}
|
||||||
if current_mode == target_mode {
|
if current_mode == target_mode {
|
||||||
let mode_str = target_mode.as_str();
|
let mode_str = target_mode.as_str();
|
||||||
let lock = self.sync_lock(id);
|
let lock = self.sync_lock(id);
|
||||||
@@ -810,12 +968,29 @@ impl Core {
|
|||||||
if folder.state != "idle" {
|
if folder.state != "idle" {
|
||||||
return Err(SwitchBlocked::NotNow);
|
return Err(SwitchBlocked::NotNow);
|
||||||
}
|
}
|
||||||
|
// `idle` only says the last pass applied everything it tried.
|
||||||
|
// It never tries an ignored path, a symlink or a folder it
|
||||||
|
// cannot read, it defers a file open for writing, and a file can
|
||||||
|
// change after it ends. The eviction below deletes all of those
|
||||||
|
// for good, so it waits until nothing here is missing from Drive
|
||||||
|
// (B198).
|
||||||
|
let baseline = self
|
||||||
|
.db
|
||||||
|
.sync_entries(id)
|
||||||
|
.map_err(|e| SwitchBlocked::Failed(format!("db: {e:?}")))?;
|
||||||
|
if let Some(reason) =
|
||||||
|
sync::mirror_tree_unsynced(&local, &baseline, &self.ignore_rules(&local))
|
||||||
|
{
|
||||||
|
return Err(SwitchBlocked::Failed(format!(
|
||||||
|
"going online-only would delete what is only on this computer: {reason}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
let root_uid = parse_uid(&folder.remote_uid).ok_or_else(|| {
|
let root_uid = parse_uid(&folder.remote_uid).ok_or_else(|| {
|
||||||
SwitchBlocked::Failed(format!("bad remote uid: {}", folder.remote_uid))
|
SwitchBlocked::Failed(format!("bad remote uid: {}", folder.remote_uid))
|
||||||
})?;
|
})?;
|
||||||
let root = self
|
let root = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.enumerate_nodes(std::slice::from_ref(&root_uid)))
|
.block_on(self.drive.enumerate_nodes(std::slice::from_ref(&root_uid)))
|
||||||
.map_err(|e| SwitchBlocked::Failed(format!("fetch remote root: {e}")))?
|
.map_err(|e| SwitchBlocked::Failed(format!("fetch remote root: {e}")))?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.next()
|
.next()
|
||||||
@@ -894,8 +1069,8 @@ impl Core {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn a secondary FUSE session rooted at `root` over `local` on a forked
|
/// Spawn a secondary FUSE session rooted at `root` over `local` from a
|
||||||
/// inode space. Clears any stale kernel mount first (a crashed run can leave
|
/// [`Core::fork_state`] sibling. Clears any stale kernel mount first (a crashed run can leave
|
||||||
/// one, which would fail the fresh mount with EBUSY).
|
/// one, which would fail the fresh mount with EBUSY).
|
||||||
pub(crate) fn spawn_ondemand_mount(
|
pub(crate) fn spawn_ondemand_mount(
|
||||||
&self,
|
&self,
|
||||||
@@ -953,8 +1128,7 @@ impl Core {
|
|||||||
/// restart (their local dirs are empty on disk — the files live in the cloud).
|
/// restart (their local dirs are empty on disk — the files live in the cloud).
|
||||||
/// Best-effort per folder: a missing local path or a failed remote fetch marks
|
/// Best-effort per folder: a missing local path or a failed remote fetch marks
|
||||||
/// the folder `error` and moves on rather than aborting the rest. Runs on its
|
/// the folder `error` and moves on rather than aborting the rest. Runs on its
|
||||||
/// own thread from `mount` so the network fetches never block startup
|
/// own thread from `mount` so the network fetches never block startup.
|
||||||
/// (devices.md Phase 4).
|
|
||||||
pub(crate) fn restore_ondemand_mounts(&self) {
|
pub(crate) fn restore_ondemand_mounts(&self) {
|
||||||
let folders = match self.db.sync_folder_list() {
|
let folders = match self.db.sync_folder_list() {
|
||||||
Ok(f) => f,
|
Ok(f) => f,
|
||||||
@@ -974,7 +1148,7 @@ impl Core {
|
|||||||
};
|
};
|
||||||
let root = match self
|
let root = match self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.enumerate_nodes(std::slice::from_ref(&root_uid)))
|
.block_on(self.drive.enumerate_nodes(std::slice::from_ref(&root_uid)))
|
||||||
{
|
{
|
||||||
Ok(v) => match v.into_iter().next() {
|
Ok(v) => match v.into_iter().next() {
|
||||||
Some(n) => n,
|
Some(n) => n,
|
||||||
@@ -1076,10 +1250,38 @@ mod tests {
|
|||||||
use pdfs_core::db::StoredSyncFolder;
|
use pdfs_core::db::StoredSyncFolder;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
ApplicableMountMode, commit_after_teardown, pending_intent_matches,
|
ApplicableMountMode, commit_after_teardown, migrated_name, pending_intent_matches,
|
||||||
restore_snapshot_matches, select_mode_transition,
|
restore_snapshot_matches, select_mode_transition,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migrated_folder_keeps_its_name_when_it_is_free() {
|
||||||
|
let taken = vec!["Pictures".to_string()];
|
||||||
|
assert_eq!(migrated_name("Documents", "DESKTOP-1", &taken), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migrated_folder_is_named_after_its_device_on_a_clash() {
|
||||||
|
let taken = vec!["Documents".to_string()];
|
||||||
|
assert_eq!(
|
||||||
|
migrated_name("Documents", "DESKTOP-1", &taken).as_deref(),
|
||||||
|
Some("Documents (DESKTOP-1)")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migrated_folder_is_numbered_when_the_device_name_is_taken_too() {
|
||||||
|
let taken = vec![
|
||||||
|
"Documents".to_string(),
|
||||||
|
"Documents (DESKTOP-1)".to_string(),
|
||||||
|
"Documents (DESKTOP-1) 2".to_string(),
|
||||||
|
];
|
||||||
|
assert_eq!(
|
||||||
|
migrated_name("Documents", "DESKTOP-1", &taken).as_deref(),
|
||||||
|
Some("Documents (DESKTOP-1) 3")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unknown_mode_cannot_select_a_destructive_transition() {
|
fn unknown_mode_cannot_select_a_destructive_transition() {
|
||||||
let mut unmounts = 0;
|
let mut unmounts = 0;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,473 @@
|
|||||||
|
//! The calls the sync core makes to Drive, behind a trait.
|
||||||
|
//!
|
||||||
|
//! `Core` used to hold a concrete [`ProtonDriveClient`], so no test could make
|
||||||
|
//! Drive answer late, answer stale, or not answer at all, and the interleavings
|
||||||
|
//! behind most recent bugs could only be found on a real account over a slow
|
||||||
|
//! link. [`DriveApi`] is the part of the
|
||||||
|
//! client the mount, the drain, the event feed and the mirror engine use; the
|
||||||
|
//! real client implements it by forwarding, and the simulation tests implement
|
||||||
|
//! it with an in-memory Drive that injects Drive's known faults.
|
||||||
|
//!
|
||||||
|
//! Sharing, Photos, devices, invitations and public links stay on the concrete
|
||||||
|
//! client (`Core::client`) until a test needs them faked.
|
||||||
|
//!
|
||||||
|
//! The method names match the client's own, so a call site reads the same
|
||||||
|
//! whichever it goes through. The client's generic methods (a reader or writer
|
||||||
|
//! of any type) cannot be part of an object-safe trait, so the trait takes them
|
||||||
|
//! as trait objects and `impl dyn DriveApi` puts the generic signatures back.
|
||||||
|
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use futures::StreamExt as _;
|
||||||
|
use futures::stream::BoxStream;
|
||||||
|
use proton_drive_rs::proton_sdk::account::Quota;
|
||||||
|
use proton_drive_rs::proton_sdk::error::Result;
|
||||||
|
use proton_drive_rs::proton_sdk::ids::{DriveEventId, NodeUid};
|
||||||
|
use proton_drive_rs::{
|
||||||
|
DriveEvent, DriveEventScopeId, Node, NodeMoveItem, ProtonDriveClient, RevisionReader, Thumbnail,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// One outcome per node of a batch request; see [`ProtonDriveClient::trash_nodes`].
|
||||||
|
pub(crate) type NodeOutcomes = Vec<(NodeUid, Result<()>)>;
|
||||||
|
|
||||||
|
/// The outcomes of a streaming batch request, one item per node as it lands.
|
||||||
|
pub(crate) type OutcomeStream<'a> = BoxStream<'a, Result<(NodeUid, Result<()>)>>;
|
||||||
|
|
||||||
|
/// Drive as the sync core sees it. See the module documentation.
|
||||||
|
#[async_trait]
|
||||||
|
pub(crate) trait DriveApi: Send + Sync {
|
||||||
|
async fn get_my_files_folder(&self) -> Result<Node>;
|
||||||
|
|
||||||
|
async fn get_node(&self, uid: &NodeUid) -> Result<Option<Node>>;
|
||||||
|
|
||||||
|
async fn enumerate_nodes(&self, uids: &[NodeUid]) -> Result<Vec<Node>>;
|
||||||
|
|
||||||
|
/// As [`DriveApi::enumerate_nodes`], but files carry no claimed size or
|
||||||
|
/// modification time; see [`ProtonDriveClient::enumerate_nodes_light`].
|
||||||
|
async fn enumerate_nodes_light(&self, uids: &[NodeUid]) -> Result<Vec<Node>>;
|
||||||
|
|
||||||
|
async fn enumerate_folder_children_node_uids(
|
||||||
|
&self,
|
||||||
|
folder_uid: &NodeUid,
|
||||||
|
) -> Result<Vec<NodeUid>>;
|
||||||
|
|
||||||
|
async fn enumerate_events(
|
||||||
|
&self,
|
||||||
|
scope: &DriveEventScopeId,
|
||||||
|
cursor: Option<&DriveEventId>,
|
||||||
|
) -> Result<Vec<DriveEvent>>;
|
||||||
|
|
||||||
|
async fn invalidate_caches_for_event(&self, event: &DriveEvent) -> Result<()>;
|
||||||
|
|
||||||
|
async fn create_folder(
|
||||||
|
&self,
|
||||||
|
parent_uid: &NodeUid,
|
||||||
|
name: &str,
|
||||||
|
last_modification_time: Option<i64>,
|
||||||
|
) -> Result<NodeUid>;
|
||||||
|
|
||||||
|
async fn upload_file(
|
||||||
|
&self,
|
||||||
|
parent_uid: &NodeUid,
|
||||||
|
name: &str,
|
||||||
|
media_type: &str,
|
||||||
|
contents: &[u8],
|
||||||
|
) -> Result<NodeUid>;
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
async fn upload_file_from_dyn(
|
||||||
|
&self,
|
||||||
|
parent_uid: &NodeUid,
|
||||||
|
name: &str,
|
||||||
|
media_type: &str,
|
||||||
|
reader: &mut (dyn Read + Send),
|
||||||
|
intended_size: i64,
|
||||||
|
thumbnails: Vec<Thumbnail>,
|
||||||
|
last_modification_time: Option<i64>,
|
||||||
|
aead: bool,
|
||||||
|
) -> Result<NodeUid>;
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
async fn upload_file_replacing_draft_from_dyn(
|
||||||
|
&self,
|
||||||
|
parent_uid: &NodeUid,
|
||||||
|
name: &str,
|
||||||
|
media_type: &str,
|
||||||
|
reader: &mut (dyn Read + Send),
|
||||||
|
intended_size: i64,
|
||||||
|
thumbnails: Vec<Thumbnail>,
|
||||||
|
last_modification_time: Option<i64>,
|
||||||
|
aead: bool,
|
||||||
|
) -> Result<NodeUid>;
|
||||||
|
|
||||||
|
async fn upload_new_revision_from_dyn(
|
||||||
|
&self,
|
||||||
|
file_uid: &NodeUid,
|
||||||
|
reader: &mut (dyn Read + Send),
|
||||||
|
intended_size: i64,
|
||||||
|
thumbnails: Vec<Thumbnail>,
|
||||||
|
last_modification_time: Option<i64>,
|
||||||
|
) -> Result<()>;
|
||||||
|
|
||||||
|
async fn open_revision(&self, uid: &NodeUid) -> Result<Arc<dyn RevisionRead>>;
|
||||||
|
|
||||||
|
async fn download_file_to_dyn(
|
||||||
|
&self,
|
||||||
|
uid: &NodeUid,
|
||||||
|
output: &mut (dyn Write + Send),
|
||||||
|
) -> Result<()>;
|
||||||
|
|
||||||
|
async fn download_revision_to_dyn(
|
||||||
|
&self,
|
||||||
|
file_uid: &NodeUid,
|
||||||
|
revision_id: &str,
|
||||||
|
writer: &mut (dyn Write + Send),
|
||||||
|
) -> Result<()>;
|
||||||
|
|
||||||
|
async fn rename_node(
|
||||||
|
&self,
|
||||||
|
uid: &NodeUid,
|
||||||
|
new_name: &str,
|
||||||
|
new_media_type: Option<&str>,
|
||||||
|
) -> Result<()>;
|
||||||
|
|
||||||
|
async fn move_node(&self, uid: &NodeUid, new_parent: &NodeUid) -> Result<()>;
|
||||||
|
|
||||||
|
fn move_nodes_streaming<'a>(
|
||||||
|
&'a self,
|
||||||
|
items: Vec<NodeMoveItem>,
|
||||||
|
new_parent: NodeUid,
|
||||||
|
) -> OutcomeStream<'a>;
|
||||||
|
|
||||||
|
async fn trash_nodes(&self, uids: &[NodeUid]) -> Result<NodeOutcomes>;
|
||||||
|
|
||||||
|
async fn restore_nodes(&self, uids: &[NodeUid]) -> Result<NodeOutcomes>;
|
||||||
|
|
||||||
|
fn restore_nodes_streaming<'a>(&'a self, uids: &[NodeUid]) -> OutcomeStream<'a>;
|
||||||
|
|
||||||
|
fn delete_nodes_streaming<'a>(&'a self, uids: &[NodeUid]) -> OutcomeStream<'a>;
|
||||||
|
|
||||||
|
async fn enumerate_trash_node_uids(&self) -> Result<Vec<NodeUid>>;
|
||||||
|
|
||||||
|
/// The account's storage, across every Proton product.
|
||||||
|
async fn quota(&self) -> Result<Quota>;
|
||||||
|
|
||||||
|
/// Called by the drain once it has let go of the blob of an upload to
|
||||||
|
/// `uid` that landed, or retired the create that made `uid`, before the
|
||||||
|
/// tree has the node, so a simulation can hold it there. Blocks the drain
|
||||||
|
/// worker it is called on.
|
||||||
|
#[cfg(test)]
|
||||||
|
fn landing(&self, _uid: &NodeUid) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The generic signatures of the client, over the object-safe ones above.
|
||||||
|
impl dyn DriveApi {
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(crate) async fn upload_file_from<R: Read + Send>(
|
||||||
|
&self,
|
||||||
|
parent_uid: &NodeUid,
|
||||||
|
name: &str,
|
||||||
|
media_type: &str,
|
||||||
|
mut reader: R,
|
||||||
|
intended_size: i64,
|
||||||
|
thumbnails: Vec<Thumbnail>,
|
||||||
|
last_modification_time: Option<i64>,
|
||||||
|
aead: bool,
|
||||||
|
) -> Result<NodeUid> {
|
||||||
|
self.upload_file_from_dyn(
|
||||||
|
parent_uid,
|
||||||
|
name,
|
||||||
|
media_type,
|
||||||
|
&mut reader,
|
||||||
|
intended_size,
|
||||||
|
thumbnails,
|
||||||
|
last_modification_time,
|
||||||
|
aead,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(crate) async fn upload_file_replacing_draft_from<R: Read + Send>(
|
||||||
|
&self,
|
||||||
|
parent_uid: &NodeUid,
|
||||||
|
name: &str,
|
||||||
|
media_type: &str,
|
||||||
|
mut reader: R,
|
||||||
|
intended_size: i64,
|
||||||
|
thumbnails: Vec<Thumbnail>,
|
||||||
|
last_modification_time: Option<i64>,
|
||||||
|
aead: bool,
|
||||||
|
) -> Result<NodeUid> {
|
||||||
|
self.upload_file_replacing_draft_from_dyn(
|
||||||
|
parent_uid,
|
||||||
|
name,
|
||||||
|
media_type,
|
||||||
|
&mut reader,
|
||||||
|
intended_size,
|
||||||
|
thumbnails,
|
||||||
|
last_modification_time,
|
||||||
|
aead,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn upload_new_revision_from<R: Read + Send>(
|
||||||
|
&self,
|
||||||
|
file_uid: &NodeUid,
|
||||||
|
mut reader: R,
|
||||||
|
intended_size: i64,
|
||||||
|
thumbnails: Vec<Thumbnail>,
|
||||||
|
last_modification_time: Option<i64>,
|
||||||
|
) -> Result<()> {
|
||||||
|
self.upload_new_revision_from_dyn(
|
||||||
|
file_uid,
|
||||||
|
&mut reader,
|
||||||
|
intended_size,
|
||||||
|
thumbnails,
|
||||||
|
last_modification_time,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn download_file_to<W: Write + Send>(
|
||||||
|
&self,
|
||||||
|
uid: &NodeUid,
|
||||||
|
output: &mut W,
|
||||||
|
) -> Result<()> {
|
||||||
|
self.download_file_to_dyn(uid, output).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn download_revision_to<W: Write + Send>(
|
||||||
|
&self,
|
||||||
|
file_uid: &NodeUid,
|
||||||
|
revision_id: &str,
|
||||||
|
writer: &mut W,
|
||||||
|
) -> Result<()> {
|
||||||
|
self.download_revision_to_dyn(file_uid, revision_id, writer)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An open revision that block reads are served from; see [`RevisionReader`].
|
||||||
|
#[async_trait]
|
||||||
|
pub(crate) trait RevisionRead: Send + Sync {
|
||||||
|
/// Plaintext size of the revision.
|
||||||
|
fn size(&self) -> u64;
|
||||||
|
|
||||||
|
/// Plaintext size of each block, in block order.
|
||||||
|
fn block_sizes(&self) -> &[u64];
|
||||||
|
|
||||||
|
async fn read_at(&self, offset: u64, length: u64) -> Result<Vec<u8>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl RevisionRead for RevisionReader {
|
||||||
|
fn size(&self) -> u64 {
|
||||||
|
RevisionReader::size(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn block_sizes(&self) -> &[u64] {
|
||||||
|
RevisionReader::block_sizes(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_at(&self, offset: u64, length: u64) -> Result<Vec<u8>> {
|
||||||
|
RevisionReader::read_at(self, offset, length).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl DriveApi for ProtonDriveClient {
|
||||||
|
async fn get_my_files_folder(&self) -> Result<Node> {
|
||||||
|
ProtonDriveClient::get_my_files_folder(self).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_node(&self, uid: &NodeUid) -> Result<Option<Node>> {
|
||||||
|
ProtonDriveClient::get_node(self, uid).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn enumerate_nodes(&self, uids: &[NodeUid]) -> Result<Vec<Node>> {
|
||||||
|
ProtonDriveClient::enumerate_nodes(self, uids).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn enumerate_nodes_light(&self, uids: &[NodeUid]) -> Result<Vec<Node>> {
|
||||||
|
ProtonDriveClient::enumerate_nodes_light(self, uids).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn enumerate_folder_children_node_uids(
|
||||||
|
&self,
|
||||||
|
folder_uid: &NodeUid,
|
||||||
|
) -> Result<Vec<NodeUid>> {
|
||||||
|
ProtonDriveClient::enumerate_folder_children_node_uids(self, folder_uid).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn enumerate_events(
|
||||||
|
&self,
|
||||||
|
scope: &DriveEventScopeId,
|
||||||
|
cursor: Option<&DriveEventId>,
|
||||||
|
) -> Result<Vec<DriveEvent>> {
|
||||||
|
ProtonDriveClient::enumerate_events(self, scope, cursor).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invalidate_caches_for_event(&self, event: &DriveEvent) -> Result<()> {
|
||||||
|
ProtonDriveClient::invalidate_caches_for_event(self, event).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_folder(
|
||||||
|
&self,
|
||||||
|
parent_uid: &NodeUid,
|
||||||
|
name: &str,
|
||||||
|
last_modification_time: Option<i64>,
|
||||||
|
) -> Result<NodeUid> {
|
||||||
|
ProtonDriveClient::create_folder(self, parent_uid, name, last_modification_time).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upload_file(
|
||||||
|
&self,
|
||||||
|
parent_uid: &NodeUid,
|
||||||
|
name: &str,
|
||||||
|
media_type: &str,
|
||||||
|
contents: &[u8],
|
||||||
|
) -> Result<NodeUid> {
|
||||||
|
ProtonDriveClient::upload_file(self, parent_uid, name, media_type, contents).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upload_file_from_dyn(
|
||||||
|
&self,
|
||||||
|
parent_uid: &NodeUid,
|
||||||
|
name: &str,
|
||||||
|
media_type: &str,
|
||||||
|
reader: &mut (dyn Read + Send),
|
||||||
|
intended_size: i64,
|
||||||
|
thumbnails: Vec<Thumbnail>,
|
||||||
|
last_modification_time: Option<i64>,
|
||||||
|
aead: bool,
|
||||||
|
) -> Result<NodeUid> {
|
||||||
|
ProtonDriveClient::upload_file_from(
|
||||||
|
self,
|
||||||
|
parent_uid,
|
||||||
|
name,
|
||||||
|
media_type,
|
||||||
|
reader,
|
||||||
|
intended_size,
|
||||||
|
thumbnails,
|
||||||
|
last_modification_time,
|
||||||
|
aead,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upload_file_replacing_draft_from_dyn(
|
||||||
|
&self,
|
||||||
|
parent_uid: &NodeUid,
|
||||||
|
name: &str,
|
||||||
|
media_type: &str,
|
||||||
|
reader: &mut (dyn Read + Send),
|
||||||
|
intended_size: i64,
|
||||||
|
thumbnails: Vec<Thumbnail>,
|
||||||
|
last_modification_time: Option<i64>,
|
||||||
|
aead: bool,
|
||||||
|
) -> Result<NodeUid> {
|
||||||
|
ProtonDriveClient::upload_file_replacing_draft_from(
|
||||||
|
self,
|
||||||
|
parent_uid,
|
||||||
|
name,
|
||||||
|
media_type,
|
||||||
|
reader,
|
||||||
|
intended_size,
|
||||||
|
thumbnails,
|
||||||
|
last_modification_time,
|
||||||
|
aead,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upload_new_revision_from_dyn(
|
||||||
|
&self,
|
||||||
|
file_uid: &NodeUid,
|
||||||
|
reader: &mut (dyn Read + Send),
|
||||||
|
intended_size: i64,
|
||||||
|
thumbnails: Vec<Thumbnail>,
|
||||||
|
last_modification_time: Option<i64>,
|
||||||
|
) -> Result<()> {
|
||||||
|
ProtonDriveClient::upload_new_revision_from(
|
||||||
|
self,
|
||||||
|
file_uid,
|
||||||
|
reader,
|
||||||
|
intended_size,
|
||||||
|
thumbnails,
|
||||||
|
last_modification_time,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn open_revision(&self, uid: &NodeUid) -> Result<Arc<dyn RevisionRead>> {
|
||||||
|
let reader = ProtonDriveClient::open_revision(self, uid).await?;
|
||||||
|
Ok(Arc::new(reader))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn download_file_to_dyn(
|
||||||
|
&self,
|
||||||
|
uid: &NodeUid,
|
||||||
|
output: &mut (dyn Write + Send),
|
||||||
|
) -> Result<()> {
|
||||||
|
ProtonDriveClient::download_file_to(self, uid, &mut &mut *output).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn download_revision_to_dyn(
|
||||||
|
&self,
|
||||||
|
file_uid: &NodeUid,
|
||||||
|
revision_id: &str,
|
||||||
|
writer: &mut (dyn Write + Send),
|
||||||
|
) -> Result<()> {
|
||||||
|
ProtonDriveClient::download_revision_to(self, file_uid, revision_id, &mut &mut *writer)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn rename_node(
|
||||||
|
&self,
|
||||||
|
uid: &NodeUid,
|
||||||
|
new_name: &str,
|
||||||
|
new_media_type: Option<&str>,
|
||||||
|
) -> Result<()> {
|
||||||
|
ProtonDriveClient::rename_node(self, uid, new_name, new_media_type).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn move_node(&self, uid: &NodeUid, new_parent: &NodeUid) -> Result<()> {
|
||||||
|
ProtonDriveClient::move_node(self, uid, new_parent).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_nodes_streaming<'a>(
|
||||||
|
&'a self,
|
||||||
|
items: Vec<NodeMoveItem>,
|
||||||
|
new_parent: NodeUid,
|
||||||
|
) -> OutcomeStream<'a> {
|
||||||
|
ProtonDriveClient::move_nodes_streaming(self, items, new_parent).boxed()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn trash_nodes(&self, uids: &[NodeUid]) -> Result<NodeOutcomes> {
|
||||||
|
ProtonDriveClient::trash_nodes(self, uids).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn restore_nodes(&self, uids: &[NodeUid]) -> Result<NodeOutcomes> {
|
||||||
|
ProtonDriveClient::restore_nodes(self, uids).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restore_nodes_streaming<'a>(&'a self, uids: &[NodeUid]) -> OutcomeStream<'a> {
|
||||||
|
ProtonDriveClient::restore_nodes_streaming(self, uids).boxed()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_nodes_streaming<'a>(&'a self, uids: &[NodeUid]) -> OutcomeStream<'a> {
|
||||||
|
ProtonDriveClient::delete_nodes_streaming(self, uids).boxed()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn enumerate_trash_node_uids(&self) -> Result<Vec<NodeUid>> {
|
||||||
|
ProtonDriveClient::enumerate_trash_node_uids(self).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn quota(&self) -> Result<Quota> {
|
||||||
|
ProtonDriveClient::quota(self).await
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
//! 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,
|
||||||
|
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}"
|
||||||
|
);
|
||||||
|
(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,
|
||||||
|
pending_uploads: 1,
|
||||||
|
pending_changes: 0,
|
||||||
|
};
|
||||||
|
assert_eq!(status_keys(&status(5)), status_keys(&status(6)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
//! 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::Ordering;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use parking_lot::{Condvar, Mutex};
|
||||||
|
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>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Report a failed remote call. Returns whether it failed for want of a
|
||||||
|
/// network, in which case the mount is now offline and the caller should
|
||||||
|
/// take its queued path instead of failing.
|
||||||
|
pub(crate) fn lost_link(&self, e: &ProtonError, what: &str) -> bool {
|
||||||
|
if !is_network_error(e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.mark_offline(what, e);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
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) => {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -193,7 +193,7 @@ pub(super) fn clear_stale_mount(mountpoint: &Path) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `sync_state` key holding the uid of the My Files root, so a later run can
|
/// `sync_state` key holding the uid of the My Files root, so a later run can
|
||||||
/// recover it from `nodes` without the API (offline.md Phase 1).
|
/// recover it from `nodes` without the API.
|
||||||
const ROOT_UID_KEY: &str = "root_uid";
|
const ROOT_UID_KEY: &str = "root_uid";
|
||||||
|
|
||||||
/// The My Files root, and whether we got it from the API (`true`) or from the
|
/// The My Files root, and whether we got it from the API (`true`) or from the
|
||||||
@@ -207,11 +207,11 @@ const ROOT_UID_KEY: &str = "root_uid";
|
|||||||
/// means an empty tree, and mounting that would show the user an empty Drive
|
/// means an empty tree, and mounting that would show the user an empty Drive
|
||||||
/// rather than an honest error.
|
/// rather than an honest error.
|
||||||
fn fetch_or_recall_root(
|
fn fetch_or_recall_root(
|
||||||
client: &ProtonDriveClient,
|
drive: &dyn DriveApi,
|
||||||
rt: &tokio::runtime::Handle,
|
rt: &tokio::runtime::Handle,
|
||||||
db: &Db,
|
db: &Db,
|
||||||
) -> std::io::Result<(Node, bool)> {
|
) -> std::io::Result<(Node, bool)> {
|
||||||
let err = match rt.block_on(client.get_my_files_folder()) {
|
let err = match rt.block_on(drive.get_my_files_folder()) {
|
||||||
Ok(root) => {
|
Ok(root) => {
|
||||||
if let Err(e) = db.set_state_str(ROOT_UID_KEY, &root.uid.to_string()) {
|
if let Err(e) = db.set_state_str(ROOT_UID_KEY, &root.uid.to_string()) {
|
||||||
warn!(error = %e, "persist root uid failed");
|
warn!(error = %e, "persist root uid failed");
|
||||||
@@ -241,6 +241,12 @@ pub struct MountOptions {
|
|||||||
pub username: String,
|
pub username: String,
|
||||||
/// What the background conflict sweep may do. See `docs/BUGS.md` B71.
|
/// What the background conflict sweep may do. See `docs/BUGS.md` B71.
|
||||||
pub sweep_mode: SweepMode,
|
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.
|
/// Spawn one FUSE session rooted at an arbitrary remote node.
|
||||||
@@ -273,13 +279,14 @@ pub(super) fn spawn_session(
|
|||||||
info!(mountpoint = %mountpoint.display(), "mounting Proton Drive location");
|
info!(mountpoint = %mountpoint.display(), "mounting Proton Drive location");
|
||||||
let fs = ProtonFs::new(core.clone(), root);
|
let fs = ProtonFs::new(core.clone(), root);
|
||||||
if core.primary {
|
if core.primary {
|
||||||
// Only My Files rehydrates the daemon-wide node cache. A secondary
|
// Only My Files rehydrates the daemon-wide node cache, once. A
|
||||||
// inode space is rooted at one device subtree and must not import every
|
// secondary mount shares the tree it fills.
|
||||||
// unrelated persisted node.
|
|
||||||
core.hydrate();
|
core.hydrate();
|
||||||
}
|
}
|
||||||
let session = Session::new(fs, mountpoint, &config)?.spawn()?;
|
let session = Session::new(fs, mountpoint, &config)?.spawn()?;
|
||||||
let _ = core.notifier.set(session.notifier());
|
let _ = core
|
||||||
|
.notifier
|
||||||
|
.set(KernelChannel::new(session.notifier(), core.root_ino()));
|
||||||
core.session_live.store(true, Ordering::Release);
|
core.session_live.store(true, Ordering::Release);
|
||||||
Ok(session)
|
Ok(session)
|
||||||
}
|
}
|
||||||
@@ -336,12 +343,57 @@ pub fn mount(
|
|||||||
control_socket: &Path,
|
control_socket: &Path,
|
||||||
db: Arc<Db>,
|
db: Arc<Db>,
|
||||||
options: MountOptions,
|
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> {
|
) -> std::io::Result<MountOutcome> {
|
||||||
let MountOptions {
|
let MountOptions {
|
||||||
username,
|
username,
|
||||||
sweep_mode,
|
sweep_mode,
|
||||||
|
upload_limit,
|
||||||
|
download_limit,
|
||||||
|
local_first,
|
||||||
} = options;
|
} = options;
|
||||||
let (root, online) = fetch_or_recall_root(&client, &rt, &db)?;
|
// Start the uptime clock here rather than at the first `pdfs diagnostics`,
|
||||||
|
// so the age it reports is the daemon's own.
|
||||||
|
super::diagnostics::start_clock();
|
||||||
|
let (root, online) = fetch_or_recall_root(drive.as_ref(), &rt, &db)?;
|
||||||
let scope = root.tree_event_scope_id();
|
let scope = root.tree_event_scope_id();
|
||||||
db.mount_upsert_my_files(&mountpoint.to_string_lossy(), &root.uid.to_string(), None)
|
db.mount_upsert_my_files(&mountpoint.to_string_lossy(), &root.uid.to_string(), None)
|
||||||
.map_err(|error| std::io::Error::other(format!("project My Files mount: {error}")))?;
|
.map_err(|error| std::io::Error::other(format!("project My Files mount: {error}")))?;
|
||||||
@@ -349,18 +401,21 @@ pub fn mount(
|
|||||||
.all_share_access()
|
.all_share_access()
|
||||||
.map_err(|error| std::io::Error::other(format!("load shared access: {error}")))?;
|
.map_err(|error| std::io::Error::other(format!("load shared access: {error}")))?;
|
||||||
|
|
||||||
// The folder-sync engine (devices.md Phase 2) runs on its own thread and is
|
// The folder-sync engine runs on its own thread and is
|
||||||
// nudged over this channel; the sender lives in Core so control-socket
|
// nudged over this channel; the sender lives in Core so control-socket
|
||||||
// handlers can trigger reconciles.
|
// handlers can trigger reconciles.
|
||||||
let (sync_tx, sync_rx) = std::sync::mpsc::channel::<sync::SyncMsg>();
|
let (sync_tx, sync_rx) = std::sync::mpsc::channel::<sync::SyncMsg>();
|
||||||
|
|
||||||
|
let paused_until = pause::load_paused_until(&db);
|
||||||
let core = Core {
|
let core = Core {
|
||||||
client: client.clone(),
|
drive,
|
||||||
|
client,
|
||||||
rt: rt.clone(),
|
rt: rt.clone(),
|
||||||
maintenance: Arc::new(Mutex::new(Default::default())),
|
maintenance: Arc::new(Mutex::new(Default::default())),
|
||||||
primary_root_uid: root.uid.clone(),
|
primary_root_uid: root.uid.clone(),
|
||||||
primary: true,
|
primary: true,
|
||||||
state: Arc::new(Mutex::new(State::new(db.clone(), share_access, 2))),
|
state: Arc::new(Mutex::new(State::new(db.clone(), share_access))),
|
||||||
|
root: Arc::new(OnceLock::new()),
|
||||||
cache: Arc::new(cache),
|
cache: Arc::new(cache),
|
||||||
readers: Arc::new(Mutex::new(HashMap::new())),
|
readers: Arc::new(Mutex::new(HashMap::new())),
|
||||||
block_ring: Arc::new(Mutex::new(BlockRing::default())),
|
block_ring: Arc::new(Mutex::new(BlockRing::default())),
|
||||||
@@ -374,19 +429,28 @@ pub fn mount(
|
|||||||
shared_generation: Arc::new(AtomicU64::new(0)),
|
shared_generation: Arc::new(AtomicU64::new(0)),
|
||||||
shared_refresh_deadlines: Arc::new(Mutex::new(SharedRefreshDeadlines::default())),
|
shared_refresh_deadlines: Arc::new(Mutex::new(SharedRefreshDeadlines::default())),
|
||||||
online: Arc::new(AtomicBool::new(online)),
|
online: Arc::new(AtomicBool::new(online)),
|
||||||
|
link: Arc::new(Default::default()),
|
||||||
|
sync_paused_until: Arc::new(AtomicI64::new(paused_until)),
|
||||||
pending: Arc::new(Mutex::new(HashMap::new())),
|
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||||
hidden: Arc::new(Mutex::new(HashSet::new())),
|
hidden: Arc::new(Mutex::new(HashSet::new())),
|
||||||
|
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())),
|
drain_wake: Arc::new((Mutex::new(false), Condvar::new())),
|
||||||
shutdown: Arc::new(crate::shutdown::Shutdown::default()),
|
shutdown: Arc::new(crate::shutdown::Shutdown::default()),
|
||||||
upload_times: Arc::new(Mutex::new(HashMap::new())),
|
upload_times: Arc::new(Mutex::new(HashMap::new())),
|
||||||
upload_cancel: 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_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)),
|
albums_refreshing: Arc::new(AtomicBool::new(false)),
|
||||||
trash_refreshing: Arc::new(AtomicBool::new(false)),
|
trash_refreshing: Arc::new(AtomicBool::new(false)),
|
||||||
trash_progress: Arc::new(tokio::sync::Notify::new()),
|
trash_progress: Arc::new(tokio::sync::Notify::new()),
|
||||||
conflict_notified: Arc::new(Mutex::new(HashSet::new())),
|
conflict_notified: Arc::new(Mutex::new(HashSet::new())),
|
||||||
sweep_mode,
|
sweep_mode,
|
||||||
own_sealed_revs: Arc::new(Mutex::new(HashMap::new())),
|
local_first,
|
||||||
self_changes: Arc::new(Mutex::new(HashMap::new())),
|
self_changes: Arc::new(Mutex::new(HashMap::new())),
|
||||||
thumb_gen: Arc::new(Mutex::new(HashSet::new())),
|
thumb_gen: Arc::new(Mutex::new(HashSet::new())),
|
||||||
thumb_gen_budget: Arc::new(tokio::sync::Semaphore::new(
|
thumb_gen_budget: Arc::new(tokio::sync::Semaphore::new(
|
||||||
@@ -399,21 +463,25 @@ pub fn mount(
|
|||||||
thumbnail_build_cancel: Arc::new(tokio::sync::Notify::new()),
|
thumbnail_build_cancel: Arc::new(tokio::sync::Notify::new()),
|
||||||
thumbnail_misses: Arc::new(Mutex::new(Default::default())),
|
thumbnail_misses: Arc::new(Mutex::new(Default::default())),
|
||||||
quota: Arc::new(Mutex::new(None)),
|
quota: Arc::new(Mutex::new(None)),
|
||||||
|
quota_refreshing: Arc::new(AtomicBool::new(false)),
|
||||||
size_upgrades: Arc::new(Mutex::new(HashMap::new())),
|
size_upgrades: Arc::new(Mutex::new(HashMap::new())),
|
||||||
size_waiters: Arc::new(Default::default()),
|
size_waiters: Arc::new(Default::default()),
|
||||||
notifier: Arc::new(OnceLock::new()),
|
notifier: Arc::new(OnceLock::new()),
|
||||||
session_live: Arc::new(AtomicBool::new(false)),
|
session_live: Arc::new(AtomicBool::new(false)),
|
||||||
transfers: TransferRegistry::new(),
|
transfers: TransferRegistry::new(),
|
||||||
|
events: Arc::new(Default::default()),
|
||||||
indexing: Arc::new(AtomicBool::new(false)),
|
indexing: Arc::new(AtomicBool::new(false)),
|
||||||
sync_progress: Arc::new(Mutex::new(HashMap::new())),
|
sync_progress: Arc::new(Mutex::new(HashMap::new())),
|
||||||
sync_tx,
|
sync_tx,
|
||||||
mounts: Arc::new(Mutex::new(SecondaryMountRegistry::default())),
|
mounts: Arc::new(Mutex::new(SecondaryMountRegistry::default())),
|
||||||
sync_locks: Arc::new(Mutex::new(HashMap::new())),
|
sync_locks: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
sync_unscanned: Arc::new(Mutex::new(HashMap::new())),
|
||||||
states: Arc::new(StateRegistry::default()),
|
states: Arc::new(StateRegistry::default()),
|
||||||
};
|
};
|
||||||
// Before anything can queue work against it: the drain thread below reaches
|
// Before anything can queue work against it: the drain thread below reaches
|
||||||
// every mount's inode space through this registry, not through `core.state`.
|
// every mount's session through this registry.
|
||||||
core.register_state(mountpoint);
|
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
|
// 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.
|
// an upload, and reads must be served from their staged blobs until they land.
|
||||||
@@ -434,7 +502,7 @@ pub fn mount(
|
|||||||
// Every long-lived thread started from here on is kept in `workers` and
|
// Every long-lived thread started from here on is kept in `workers` and
|
||||||
// joined at the end of this function. Leaving them running turned an
|
// joined at the end of this function. Leaving them running turned an
|
||||||
// in-process remount into a second generation of drain workers, sync engines
|
// in-process remount into a second generation of drain workers, sync engines
|
||||||
// and sweeps all operating on a mount that no longer exists (bugs.md B44).
|
// and sweeps all operating on a mount that no longer exists (`docs/BUGS.md` B44).
|
||||||
let mut workers: Vec<std::thread::JoinHandle<()>> = Vec::new();
|
let mut workers: Vec<std::thread::JoinHandle<()>> = Vec::new();
|
||||||
for worker in 0..DRAIN_WORKERS {
|
for worker in 0..DRAIN_WORKERS {
|
||||||
let core = core.clone();
|
let core = core.clone();
|
||||||
@@ -448,6 +516,10 @@ pub fn mount(
|
|||||||
// Start the folder-sync engine. It watches every mirror folder, polls the
|
// 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.
|
// remotes, and reconciles on its own thread — never in front of a FUSE call.
|
||||||
workers.extend(sync::spawn(core.clone(), sync_rx));
|
workers.extend(sync::spawn(core.clone(), sync_rx));
|
||||||
|
// A timed pause persisted by an earlier run still has to end on time.
|
||||||
|
if pause::paused_at(paused_until, now_secs()) && paused_until != pause::PAUSED_INDEFINITELY {
|
||||||
|
core.arm_pause_timer(paused_until);
|
||||||
|
}
|
||||||
|
|
||||||
// Reconcile leftover `(sync-conflict …)` copies: drop the ones that turned
|
// Reconcile leftover `(sync-conflict …)` copies: drop the ones that turned
|
||||||
// out identical to the live file, surface the ones that genuinely diverge.
|
// out identical to the live file, surface the ones that genuinely diverge.
|
||||||
@@ -466,9 +538,10 @@ pub fn mount(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mounted from the cache: watch for the network coming back so the mount can
|
// Watch the link for the life of the mount: a mount from the cache waits for
|
||||||
// stop being read-only-ish without the user restarting the daemon.
|
// the network to come back, and an online one can lose it at any moment and
|
||||||
if !online {
|
// must notice it coming back just the same.
|
||||||
|
{
|
||||||
let core = core.clone();
|
let core = core.clone();
|
||||||
workers.push(
|
workers.push(
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
@@ -479,7 +552,7 @@ pub fn mount(
|
|||||||
|
|
||||||
// Keep the launcher prompt's "This computer" index fresh. Its own thread:
|
// 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.
|
// 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 db = core.db.clone();
|
||||||
let indexing = core.indexing.clone();
|
let indexing = core.indexing.clone();
|
||||||
let transfers = core.transfers.clone();
|
let transfers = core.transfers.clone();
|
||||||
@@ -551,6 +624,42 @@ pub fn mount(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Publishes what changes without one place to publish it from (status,
|
||||||
|
// transfers, sync progress) to the front-ends subscribed on that socket.
|
||||||
|
{
|
||||||
|
let sampler_core = core.clone();
|
||||||
|
let username = username.clone();
|
||||||
|
let mountpoint = mountpoint.to_path_buf();
|
||||||
|
match std::thread::Builder::new()
|
||||||
|
.name("pdfs-events".into())
|
||||||
|
.spawn(move || events::run_event_sampler(sampler_core, username, mountpoint))
|
||||||
|
{
|
||||||
|
Ok(handle) => workers.push(handle),
|
||||||
|
// Not fatal: front-ends still get every published change, only
|
||||||
|
// not the sampled ones.
|
||||||
|
Err(error) => warn!(error = %error, "could not start the event sampler"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The supervisor watches the pool and answers the systemd watchdog. Started
|
||||||
|
// after the listener, because its liveness probe is a round trip over that
|
||||||
|
// socket.
|
||||||
|
{
|
||||||
|
let supervisor_core = core.clone();
|
||||||
|
let socket = control_socket.to_path_buf();
|
||||||
|
match std::thread::Builder::new()
|
||||||
|
.name("pdfs-supervisor".into())
|
||||||
|
.spawn(move || supervisor::run(supervisor_core, socket))
|
||||||
|
{
|
||||||
|
Ok(handle) => workers.push(handle),
|
||||||
|
// Not fatal: the mount works without a supervisor, it just goes back
|
||||||
|
// to being undiagnosable if it wedges.
|
||||||
|
Err(error) => warn!(error = %error, "could not start the daemon supervisor"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The mount is live and the control socket answers: this is the moment the
|
||||||
|
// unit is actually usable, which is what `Type=notify` reports.
|
||||||
|
systemd::ready();
|
||||||
|
|
||||||
// Re-establish on-demand mounts only after the primary session is live.
|
// 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
|
// Keep the worker so shutdown can close publication, wait for any in-flight
|
||||||
// fetch to observe that closure, and then drain a stable registry.
|
// fetch to observe that closure, and then drain a stable registry.
|
||||||
@@ -567,8 +676,13 @@ pub fn mount(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
rt.spawn(run_event_sync(client, scope, core.clone()));
|
rt.spawn(run_event_sync(core.drive.clone(), scope, core.clone()));
|
||||||
if online {
|
// 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 repair_core = core.clone();
|
||||||
let root_uid = core.primary_root_uid.clone();
|
let root_uid = core.primary_root_uid.clone();
|
||||||
if let Err(error) = std::thread::Builder::new()
|
if let Err(error) = std::thread::Builder::new()
|
||||||
@@ -583,30 +697,11 @@ pub fn mount(
|
|||||||
// are delivered onto the async runtime; bridge them onto a sync channel so
|
// 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
|
// 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.
|
// channel of 1 is enough — we only need to know that *a* stop arrived.
|
||||||
let (sig_tx, sig_rx) = std::sync::mpsc::sync_channel::<()>(1);
|
let sig_rx = match host {
|
||||||
rt.spawn(async move {
|
Host::Daemon => signal_stop(&rt),
|
||||||
let mut sigterm =
|
#[cfg(test)]
|
||||||
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
|
Host::Simulation(rx) => rx,
|
||||||
Ok(s) => s,
|
};
|
||||||
Err(e) => {
|
|
||||||
warn!(error = %e, "install SIGTERM handler failed");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let mut sigint =
|
|
||||||
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt()) {
|
|
||||||
Ok(s) => s,
|
|
||||||
Err(e) => {
|
|
||||||
warn!(error = %e, "install SIGINT handler failed");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
tokio::select! {
|
|
||||||
_ = sigterm.recv() => info!("received SIGTERM"),
|
|
||||||
_ = sigint.recv() => info!("received SIGINT"),
|
|
||||||
}
|
|
||||||
let _ = sig_tx.try_send(());
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wait for whichever happens first: a stop signal (→ we unmount ourselves
|
// Wait for whichever happens first: a stop signal (→ we unmount ourselves
|
||||||
// via the lazy MNT_DETACH path, clean even mid-download), or the kernel
|
// via the lazy MNT_DETACH path, clean even mid-download), or the kernel
|
||||||
@@ -644,7 +739,7 @@ pub fn mount(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Unmount every on-demand sync folder too, or the kernel mounts linger as
|
// Unmount every on-demand sync folder too, or the kernel mounts linger as
|
||||||
// stale and the next start fails with EBUSY (devices.md Phase 3).
|
// stale and the next start fails with EBUSY.
|
||||||
if let Some(worker) = restore_worker
|
if let Some(worker) = restore_worker
|
||||||
&& worker.join().is_err()
|
&& worker.join().is_err()
|
||||||
{
|
{
|
||||||
@@ -656,6 +751,7 @@ pub fn mount(
|
|||||||
warn!(id, error = %e, "unmount on-demand folder failed");
|
warn!(id, error = %e, "unmount on-demand folder failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
core.queue_unreleased_writes();
|
||||||
|
|
||||||
// Last, because the workers may legitimately be finishing an upload, and
|
// Last, because the workers may legitimately be finishing an upload, and
|
||||||
// nothing they can still do is harmful once the mounts are down.
|
// nothing they can still do is harmful once the mounts are down.
|
||||||
@@ -663,20 +759,59 @@ pub fn mount(
|
|||||||
Ok(outcome)
|
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.
|
/// 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
|
/// The mount used to return with its drain workers, sync engine, sweep, online
|
||||||
/// probe, indexer and control listener all still running on `Core` clones. The
|
/// probe, indexer and control listener all still running on `Core` clones. The
|
||||||
/// process usually exited straight afterwards and hid it; an in-process remount
|
/// process usually exited straight afterwards and hid it; an in-process remount
|
||||||
/// did not, and started a second full set on top of the first (bugs.md B44).
|
/// did not, and started a second full set on top of the first (`docs/BUGS.md` B44).
|
||||||
///
|
///
|
||||||
/// Two of the waits need waking rather than merely flagging: the drain workers
|
/// Two of the waits need waking rather than merely flagging: the drain workers
|
||||||
/// block on their own condvar (`wake_drain`), and the control listener blocks
|
/// block on their own condvar (`wake_drain`), and the control listener blocks
|
||||||
/// inside `accept`, which only returns when a connection arrives — so it gets
|
/// 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
|
/// 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
|
/// expected and correct: a worker part-way through an upload finishes it rather
|
||||||
/// than abandoning the user's bytes mid-flight.
|
/// than abandoning the user's bytes mid-flight. Up to [`WORKER_JOIN_DEADLINE`]:
|
||||||
|
/// past it, systemd kills the daemon anyway, and a queued op is sent again.
|
||||||
fn stop_workers(core: &Core, control_socket: &Path, workers: Vec<std::thread::JoinHandle<()>>) {
|
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.shutdown.stop();
|
||||||
core.wake_drain();
|
core.wake_drain();
|
||||||
let _ = core.sync_tx.send(sync::SyncMsg::Stop);
|
let _ = core.sync_tx.send(sync::SyncMsg::Stop);
|
||||||
@@ -686,19 +821,71 @@ fn stop_workers(core: &Core, control_socket: &Path, workers: Vec<std::thread::Jo
|
|||||||
// unlink cannot put it back to sleep.
|
// unlink cannot put it back to sleep.
|
||||||
let _ = std::os::unix::net::UnixStream::connect(control_socket);
|
let _ = std::os::unix::net::UnixStream::connect(control_socket);
|
||||||
let _ = std::fs::remove_file(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 {
|
for worker in workers {
|
||||||
if worker.join().is_err() {
|
while !worker.is_finished() && Instant::now() < deadline {
|
||||||
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
if !worker.is_finished() {
|
||||||
|
running.push(worker.thread().name().unwrap_or("unnamed").to_string());
|
||||||
|
} else if worker.join().is_err() {
|
||||||
warn!("a background worker panicked before shutdown");
|
warn!("a background worker panicked before shutdown");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
debug!("background workers stopped");
|
running
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use super::{SecondaryInsertRejection, SecondaryMountRegistry, teardown_session};
|
use super::{SecondaryInsertRejection, SecondaryMountRegistry, join_until, teardown_session};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_stop_names_the_workers_still_running_at_its_deadline() {
|
||||||
|
let (release, wait) = std::sync::mpsc::channel::<()>();
|
||||||
|
let done = std::thread::Builder::new()
|
||||||
|
.name("done".into())
|
||||||
|
.spawn(|| {})
|
||||||
|
.unwrap();
|
||||||
|
let stuck = std::thread::Builder::new()
|
||||||
|
.name("stuck".into())
|
||||||
|
.spawn(move || {
|
||||||
|
let _ = wait.recv();
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let running = join_until(
|
||||||
|
vec![done, stuck],
|
||||||
|
Instant::now() + Duration::from_millis(100),
|
||||||
|
);
|
||||||
|
assert_eq!(running, ["stuck"]);
|
||||||
|
drop(release);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn session_is_unroutable_before_teardown_starts() {
|
fn session_is_unroutable_before_teardown_starts() {
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
//! Reading and writing this machine's `profile.json` in its device root, and
|
//! Reading and writing this machine's `profile.json` in its device root, and
|
||||||
//! the restore that rebuilds a machine from one (features.md 5.2 and 5.3).
|
//! the restore that rebuilds a machine from one.
|
||||||
//!
|
//!
|
||||||
//! The document itself is defined in [`pdfs_core::profile`]; this is the half
|
//! The document itself is defined in [`pdfs_core::profile`]; this is the half
|
||||||
//! that talks to Drive. Saving is best-effort and never blocks a user action:
|
//! that talks to Drive. Saving is best-effort and never blocks a user action:
|
||||||
@@ -101,7 +101,7 @@ impl Core {
|
|||||||
match existing {
|
match existing {
|
||||||
Some(uid) => self
|
Some(uid) => self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.upload_new_revision_from(
|
.block_on(self.drive.upload_new_revision_from(
|
||||||
&uid,
|
&uid,
|
||||||
Cursor::new(bytes),
|
Cursor::new(bytes),
|
||||||
len,
|
len,
|
||||||
@@ -112,7 +112,7 @@ impl Core {
|
|||||||
.map_err(|e| CoreError::from_api(&e, "upload profile revision"))?,
|
.map_err(|e| CoreError::from_api(&e, "upload profile revision"))?,
|
||||||
None => {
|
None => {
|
||||||
self.rt
|
self.rt
|
||||||
.block_on(self.client.upload_file_replacing_draft_from(
|
.block_on(self.drive.upload_file_replacing_draft_from(
|
||||||
&dir_uid,
|
&dir_uid,
|
||||||
PROFILE_FILE_NAME,
|
PROFILE_FILE_NAME,
|
||||||
"application/json",
|
"application/json",
|
||||||
@@ -182,14 +182,14 @@ impl Core {
|
|||||||
) -> CoreResult<Option<NodeUid>> {
|
) -> CoreResult<Option<NodeUid>> {
|
||||||
let uids = self
|
let uids = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.enumerate_folder_children_node_uids(parent_uid))
|
.block_on(self.drive.enumerate_folder_children_node_uids(parent_uid))
|
||||||
.map_err(|e| CoreError::from_api(&e, "list device root"))?;
|
.map_err(|e| CoreError::from_api(&e, "list device root"))?;
|
||||||
if uids.is_empty() {
|
if uids.is_empty() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let nodes = self
|
let nodes = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.enumerate_nodes(&uids))
|
.block_on(self.drive.enumerate_nodes(&uids))
|
||||||
.map_err(|e| CoreError::from_api(&e, "resolve device root children"))?;
|
.map_err(|e| CoreError::from_api(&e, "resolve device root children"))?;
|
||||||
Ok(nodes
|
Ok(nodes
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -210,7 +210,7 @@ impl Core {
|
|||||||
let uid = self
|
let uid = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(
|
.block_on(
|
||||||
self.client
|
self.drive
|
||||||
.create_folder(root_uid, PROFILE_DIR_NAME, Some(now_secs())),
|
.create_folder(root_uid, PROFILE_DIR_NAME, Some(now_secs())),
|
||||||
)
|
)
|
||||||
.map_err(|e| CoreError::from_api(&e, "create profile folder"))?;
|
.map_err(|e| CoreError::from_api(&e, "create profile folder"))?;
|
||||||
@@ -241,22 +241,26 @@ impl Core {
|
|||||||
};
|
};
|
||||||
let mut buf: Vec<u8> = Vec::new();
|
let mut buf: Vec<u8> = Vec::new();
|
||||||
self.rt
|
self.rt
|
||||||
.block_on(self.client.download_file_to(&uid, &mut buf))
|
.block_on(self.drive.download_file_to(&uid, &mut buf))
|
||||||
.map_err(|e| CoreError::from_api(&e, "download profile"))?;
|
.map_err(|e| CoreError::from_api(&e, "download profile"))?;
|
||||||
Profile::parse(&buf).map(Some).map_err(CoreError::invalid)
|
Profile::parse(&buf).map(Some).map_err(CoreError::invalid)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- restore (features.md 5.2) ----------------------------------------
|
// ---- restore ----------------------------------------
|
||||||
|
|
||||||
/// The folders under this machine's device that could be synced here, each
|
/// The folders under a device that could be synced here, each carrying a
|
||||||
/// carrying a proposed local path.
|
/// 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
|
/// The proposal comes from the profile when one exists and its path is
|
||||||
/// usable on this machine, and from `~/<name>` otherwise — a restored
|
/// usable on this machine, and from `~/<name>` otherwise — a restored
|
||||||
/// `/home/other-user/Documents` would be wrong on a machine where that user
|
/// `/home/other-user/Documents` would be wrong on a machine where that user
|
||||||
/// does not exist, and silently creating it would be worse.
|
/// does not exist, and silently creating it would be worse.
|
||||||
pub(crate) fn list_restorable_folders(&self) -> CoreResult<Vec<RestorableFolder>> {
|
pub(crate) fn list_restorable_folders(
|
||||||
let device = self.ensure_device()?;
|
&self,
|
||||||
|
device: Option<&str>,
|
||||||
|
) -> CoreResult<Vec<RestorableFolder>> {
|
||||||
|
let device = self.restore_source(device)?;
|
||||||
let root_uid = parse_uid(&device.root_uid).ok_or_else(|| {
|
let root_uid = parse_uid(&device.root_uid).ok_or_else(|| {
|
||||||
CoreError::internal(format!("bad device root uid: {}", device.root_uid))
|
CoreError::internal(format!("bad device root uid: {}", device.root_uid))
|
||||||
})?;
|
})?;
|
||||||
@@ -273,13 +277,13 @@ impl Core {
|
|||||||
|
|
||||||
let uids = self
|
let uids = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.enumerate_folder_children_node_uids(&root_uid))
|
.block_on(self.drive.enumerate_folder_children_node_uids(&root_uid))
|
||||||
.map_err(|e| CoreError::from_api(&e, "list device root"))?;
|
.map_err(|e| CoreError::from_api(&e, "list device root"))?;
|
||||||
let nodes = if uids.is_empty() {
|
let nodes = if uids.is_empty() {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
} else {
|
} else {
|
||||||
self.rt
|
self.rt
|
||||||
.block_on(self.client.enumerate_nodes(&uids))
|
.block_on(self.drive.enumerate_nodes(&uids))
|
||||||
.map_err(|e| CoreError::from_api(&e, "resolve device root children"))?
|
.map_err(|e| CoreError::from_api(&e, "resolve device root children"))?
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -334,11 +338,12 @@ impl Core {
|
|||||||
pub(crate) fn restore_sync_folders(
|
pub(crate) fn restore_sync_folders(
|
||||||
&self,
|
&self,
|
||||||
items: &[pdfs_core::control::RestoreItem],
|
items: &[pdfs_core::control::RestoreItem],
|
||||||
|
device: Option<&str>,
|
||||||
) -> CoreResult<String> {
|
) -> CoreResult<String> {
|
||||||
if items.is_empty() {
|
if items.is_empty() {
|
||||||
return Err(CoreError::invalid("nothing to restore"));
|
return Err(CoreError::invalid("nothing to restore"));
|
||||||
}
|
}
|
||||||
let device = self.ensure_device()?;
|
let device = self.restore_source(device)?;
|
||||||
let existing = self
|
let existing = self
|
||||||
.db
|
.db
|
||||||
.sync_folder_list()
|
.sync_folder_list()
|
||||||
@@ -499,6 +504,19 @@ mod tests {
|
|||||||
assert_before(add, "PROFILE_DIR_NAME", "ensure_device()");
|
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 {
|
fn function_source<'a>(source: &'a str, signature: &str) -> &'a str {
|
||||||
let start = source.find(signature).expect("function signature exists");
|
let start = source.find(signature).expect("function signature exists");
|
||||||
let body = &source[start..];
|
let body = &source[start..];
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,21 @@ pub(super) const REPAIR_MAX: u64 = 512 * 1024 * 1024;
|
|||||||
/// demand read in the SDK's flat block semaphore.
|
/// demand read in the SDK's flat block semaphore.
|
||||||
pub(super) const PREFETCH_BUDGET: usize = 8;
|
pub(super) const PREFETCH_BUDGET: usize = 8;
|
||||||
|
|
||||||
|
/// How long one read may wait on the network before it fails with `EIO`.
|
||||||
|
///
|
||||||
|
/// The SDK bounds each request, not a read: a block that times out is retried,
|
||||||
|
/// and a read can need several blocks, a block table and a repair. On a link
|
||||||
|
/// that stalled without dropping, one `read` held a FUSE worker for 34 minutes
|
||||||
|
/// and the application behind it with it (`docs/BUGS.md` B107). An error the
|
||||||
|
/// caller can retry beats a process stuck in `D` state.
|
||||||
|
pub(super) const READ_FETCH_TIMEOUT: Duration = Duration::from_secs(120);
|
||||||
|
|
||||||
|
/// How long a read of blocks nothing has cached waits for a lost link to come
|
||||||
|
/// back before failing. Long enough to ride out a Wi-Fi blip, short enough
|
||||||
|
/// that a program reading while the laptop is really offline gets its `EIO`
|
||||||
|
/// before the user gives up on it.
|
||||||
|
const READ_OFFLINE_WAIT: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
/// Cap on tracked sequential streams. Only reads in progress matter, so the map
|
/// Cap on tracked sequential streams. Only reads in progress matter, so the map
|
||||||
/// is cleared wholesale rather than aged — losing the ramp costs one read.
|
/// is cleared wholesale rather than aged — losing the ramp costs one read.
|
||||||
const MAX_PREFETCH_STREAMS: usize = 256;
|
const MAX_PREFETCH_STREAMS: usize = 256;
|
||||||
@@ -55,7 +70,7 @@ const MAX_OPEN_READERS: usize = 64;
|
|||||||
/// `(mtime, size)` — the same validity pair the content cache uses (a new
|
/// `(mtime, size)` — the same validity pair the content cache uses (a new
|
||||||
/// revision bumps mtime). On a mismatch the reader is dropped and reopened.
|
/// revision bumps mtime). On a mismatch the reader is dropped and reopened.
|
||||||
pub(super) struct CachedReader {
|
pub(super) struct CachedReader {
|
||||||
reader: Arc<RevisionReader>,
|
reader: Arc<dyn RevisionRead>,
|
||||||
mtime: i64,
|
mtime: i64,
|
||||||
size: u64,
|
size: u64,
|
||||||
/// For LRU eviction.
|
/// For LRU eviction.
|
||||||
@@ -86,7 +101,7 @@ pub(super) struct PendingOpen {
|
|||||||
/// must not join this open — it would get a reader for the wrong bytes.
|
/// must not join this open — it would get a reader for the wrong bytes.
|
||||||
mtime: i64,
|
mtime: i64,
|
||||||
size: u64,
|
size: u64,
|
||||||
rx: tokio::sync::watch::Receiver<Option<Result<Arc<RevisionReader>, Errno>>>,
|
rx: tokio::sync::watch::Receiver<Option<Result<Arc<dyn RevisionRead>, Errno>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Distinguishes `PendingOpen`s. Only uniqueness matters, so a plain counter
|
/// Distinguishes `PendingOpen`s. Only uniqueness matters, so a plain counter
|
||||||
@@ -298,16 +313,16 @@ impl Core {
|
|||||||
uid: &NodeUid,
|
uid: &NodeUid,
|
||||||
mtime: i64,
|
mtime: i64,
|
||||||
fsize: u64,
|
fsize: u64,
|
||||||
) -> Result<Arc<RevisionReader>, Errno> {
|
) -> Result<Arc<dyn RevisionRead>, Errno> {
|
||||||
loop {
|
loop {
|
||||||
// Decide under the lock, act outside it — network I/O must never run
|
// Decide under the lock, act outside it — network I/O must never run
|
||||||
// under a std `Mutex`, and an await must never hold one.
|
// under a std `Mutex`, and an await must never hold one.
|
||||||
enum Act {
|
enum Act {
|
||||||
Hit(Arc<RevisionReader>),
|
Hit(Arc<dyn RevisionRead>),
|
||||||
Join(tokio::sync::watch::Receiver<Option<Result<Arc<RevisionReader>, Errno>>>),
|
Join(tokio::sync::watch::Receiver<Option<Result<Arc<dyn RevisionRead>, Errno>>>),
|
||||||
Lead(
|
Lead(
|
||||||
u64,
|
u64,
|
||||||
tokio::sync::watch::Sender<Option<Result<Arc<RevisionReader>, Errno>>>,
|
tokio::sync::watch::Sender<Option<Result<Arc<dyn RevisionRead>, Errno>>>,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
let act = {
|
let act = {
|
||||||
@@ -361,9 +376,10 @@ impl Core {
|
|||||||
}
|
}
|
||||||
Act::Lead(id, tx) => {
|
Act::Lead(id, tx) => {
|
||||||
debug!(%uid, mtime, fsize, "opening revision");
|
debug!(%uid, mtime, fsize, "opening revision");
|
||||||
let result = match self.client.open_revision(uid).await {
|
let result = match self.drive.open_revision(uid).await {
|
||||||
Ok(reader) => Ok(Arc::new(reader)),
|
Ok(reader) => Ok(reader),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
self.lost_link(&e, "open a revision");
|
||||||
warn!(%uid, error = %e, "open_revision failed");
|
warn!(%uid, error = %e, "open_revision failed");
|
||||||
Err(Errno::EIO)
|
Err(Errno::EIO)
|
||||||
}
|
}
|
||||||
@@ -448,7 +464,7 @@ impl Core {
|
|||||||
/// it, and the [`BLOCK_SIZE`] assumption when it has not — which is every
|
/// it, and the [`BLOCK_SIZE`] assumption when it has not — which is every
|
||||||
/// first read, because learning it means opening a `RevisionReader`, and
|
/// first read, because learning it means opening a `RevisionReader`, and
|
||||||
/// opening one per read is precisely the per-file key derivation the block
|
/// opening one per read is precisely the per-file key derivation the block
|
||||||
/// cache exists to avoid (bugs.md B12, B85).
|
/// cache exists to avoid (`docs/BUGS.md` B12, B85).
|
||||||
///
|
///
|
||||||
/// Being wrong here is not a correctness problem: `read_at` plans over the
|
/// Being wrong here is not a correctness problem: `read_at` plans over the
|
||||||
/// real sizes and clamps to what was asked for either way. It costs a
|
/// real sizes and clamps to what was asked for either way. It costs a
|
||||||
@@ -477,15 +493,20 @@ impl Core {
|
|||||||
) -> Result<Vec<u8>, Errno> {
|
) -> Result<Vec<u8>, Errno> {
|
||||||
// A queued write has not reached the remote yet, so the remote's current
|
// A queued write has not reached the remote yet, so the remote's current
|
||||||
// revision is stale and the staged blob is the truth. Serve from it until
|
// revision is stale and the staged blob is the truth. Serve from it until
|
||||||
// the drain worker lands the upload (offline.md Phase 3).
|
// the drain worker lands the upload.
|
||||||
if let Some(pending) = self.pending.lock().get(uid).cloned() {
|
if let Some(pending) = self.pending.lock().get(uid).cloned() {
|
||||||
return self.read_pending(&pending, offset, len);
|
return self.read_pending(&pending, offset, len);
|
||||||
}
|
}
|
||||||
// A node created offline and never written has no blob and no remote: it
|
// A node created offline and never written has no blob and no remote: it
|
||||||
// is an empty file, and asking the API about a `local~` uid would only
|
// is an empty file, and asking the API about a `local~` uid would only
|
||||||
// earn a 404 (offline.md Phase 3b).
|
// earn a 404. Unless its create landed since the
|
||||||
|
// read looked: the landing moved the queued write to the uid Drive
|
||||||
|
// gave it, and the row says which (`docs/BUGS.md` B179).
|
||||||
if is_local_uid(uid) {
|
if is_local_uid(uid) {
|
||||||
return Ok(Vec::new());
|
return match self.landed_stand_in_uid(uid) {
|
||||||
|
Some(real) => self.read_range(&real, mtime, fsize, offset, len, cache_blocks),
|
||||||
|
None => Ok(Vec::new()),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
self.read_range_remote(uid, mtime, fsize, offset, len, cache_blocks)
|
self.read_range_remote(uid, mtime, fsize, offset, len, cache_blocks)
|
||||||
}
|
}
|
||||||
@@ -552,7 +573,7 @@ impl Core {
|
|||||||
/// leader's claim is gone by then, so this cannot recurse.
|
/// leader's claim is gone by then, so this cannot recurse.
|
||||||
async fn fetch_block(
|
async fn fetch_block(
|
||||||
&self,
|
&self,
|
||||||
reader: &Arc<RevisionReader>,
|
reader: &Arc<dyn RevisionRead>,
|
||||||
uid: &NodeUid,
|
uid: &NodeUid,
|
||||||
mtime: i64,
|
mtime: i64,
|
||||||
fsize: u64,
|
fsize: u64,
|
||||||
@@ -615,10 +636,10 @@ impl Core {
|
|||||||
///
|
///
|
||||||
/// A block that comes back shorter than the file's size says it must be is
|
/// A block that comes back shorter than the file's size says it must be is
|
||||||
/// never served, cached or promoted: it is repaired from the whole-file
|
/// never served, cached or promoted: it is repaired from the whole-file
|
||||||
/// download, or the read fails. See [`Core::repair_block`] (bugs.md B84).
|
/// download, or the read fails. See [`Core::repair_block`] (`docs/BUGS.md` B84).
|
||||||
async fn read_block(
|
async fn read_block(
|
||||||
&self,
|
&self,
|
||||||
reader: &Arc<RevisionReader>,
|
reader: &Arc<dyn RevisionRead>,
|
||||||
uid: &NodeUid,
|
uid: &NodeUid,
|
||||||
mtime: i64,
|
mtime: i64,
|
||||||
fsize: u64,
|
fsize: u64,
|
||||||
@@ -630,7 +651,7 @@ impl Core {
|
|||||||
// file's boundaries are wrong, and the failure is not confined to short
|
// file's boundaries are wrong, and the failure is not confined to short
|
||||||
// reads: sizes that *overstate* a block shift every block after it and
|
// reads: sizes that *overstate* a block shift every block after it and
|
||||||
// return full-length reads of the wrong bytes. So a disagreement of any
|
// return full-length reads of the wrong bytes. So a disagreement of any
|
||||||
// kind takes the file off the range path entirely (bugs.md B84).
|
// kind takes the file off the range path entirely (`docs/BUGS.md` B84).
|
||||||
let bytes = if reader.size() != fsize {
|
let bytes = if reader.size() != fsize {
|
||||||
warn!(
|
warn!(
|
||||||
%uid, bidx = span.idx, fsize,
|
%uid, bidx = span.idx, fsize,
|
||||||
@@ -643,12 +664,13 @@ impl Core {
|
|||||||
// Learned here rather than on a path of its own: this is where a
|
// Learned here rather than on a path of its own: this is where a
|
||||||
// reader is open anyway, and recording the real geometry is what
|
// reader is open anyway, and recording the real geometry is what
|
||||||
// lets the *next* read plan its blocks on the revision's own
|
// lets the *next* read plan its blocks on the revision's own
|
||||||
// boundaries instead of the 4 MiB fallback (bugs.md B85).
|
// boundaries instead of the 4 MiB fallback (`docs/BUGS.md` B85).
|
||||||
if cache_blocks {
|
if cache_blocks {
|
||||||
self.cache
|
self.cache
|
||||||
.store_block_geometry(uid, mtime, fsize, reader.block_sizes());
|
.store_block_geometry(uid, mtime, fsize, reader.block_sizes());
|
||||||
}
|
}
|
||||||
let bytes = reader.read_at(span.start, span.len).await.map_err(|e| {
|
let bytes = reader.read_at(span.start, span.len).await.map_err(|e| {
|
||||||
|
self.lost_link(&e, "read a block");
|
||||||
warn!(%uid, start = span.start, len = span.len, error = %e, "block read failed");
|
warn!(%uid, start = span.start, len = span.len, error = %e, "block read failed");
|
||||||
Errno::EIO
|
Errno::EIO
|
||||||
})?;
|
})?;
|
||||||
@@ -684,7 +706,7 @@ impl Core {
|
|||||||
/// The range path derives every block boundary from the revision's recorded
|
/// The range path derives every block boundary from the revision's recorded
|
||||||
/// block sizes. When those understate the file, reads come back short with
|
/// block sizes. When those understate the file, reads come back short with
|
||||||
/// no error anywhere, and the caller hands userspace a truncated file that
|
/// no error anywhere, and the caller hands userspace a truncated file that
|
||||||
/// looks like a clean EOF (bugs.md B84). `download_file_to` does not do that
|
/// looks like a clean EOF (`docs/BUGS.md` B84). `download_file_to` does not do that
|
||||||
/// arithmetic — it streams the block list — so it returns the whole file
|
/// arithmetic — it streams the block list — so it returns the whole file
|
||||||
/// where the range path cannot, which is why pinning was the workaround.
|
/// where the range path cannot, which is why pinning was the workaround.
|
||||||
/// This does the same thing on the user's behalf, once, and the resulting
|
/// This does the same thing on the user's behalf, once, and the resulting
|
||||||
@@ -761,7 +783,7 @@ impl Core {
|
|||||||
error!(%uid, %error, "creating a scratch file for a repair download failed");
|
error!(%uid, %error, "creating a scratch file for a repair download failed");
|
||||||
Errno::EIO
|
Errno::EIO
|
||||||
})?;
|
})?;
|
||||||
let outcome = self.client.download_file_to(uid, &mut file).await;
|
let outcome = self.drive.download_file_to(uid, &mut file).await;
|
||||||
let stored = (|| {
|
let stored = (|| {
|
||||||
outcome.map_err(|error| {
|
outcome.map_err(|error| {
|
||||||
warn!(%uid, %error, "repair download failed");
|
warn!(%uid, %error, "repair download failed");
|
||||||
@@ -896,7 +918,7 @@ impl Core {
|
|||||||
let end = offset.saturating_add(len).min(fsize);
|
let end = offset.saturating_add(len).min(fsize);
|
||||||
let mut out = Vec::with_capacity((end - offset) as usize);
|
let mut out = Vec::with_capacity((end - offset) as usize);
|
||||||
// On the revision's own boundaries where they are known, and on the
|
// On the revision's own boundaries where they are known, and on the
|
||||||
// 4 MiB fallback where they are not (bugs.md B85).
|
// 4 MiB fallback where they are not (`docs/BUGS.md` B85).
|
||||||
let wanted = self.block_geometry(uid, mtime, fsize).spans(offset, end);
|
let wanted = self.block_geometry(uid, mtime, fsize).spans(offset, end);
|
||||||
let (Some(&head), Some(&tail)) = (wanted.first(), wanted.last()) else {
|
let (Some(&head), Some(&tail)) = (wanted.first(), wanted.last()) else {
|
||||||
return Ok(out);
|
return Ok(out);
|
||||||
@@ -939,28 +961,67 @@ impl Core {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !misses.is_empty() {
|
if !misses.is_empty() {
|
||||||
let fetched = self.rt.block_on(async {
|
// One deadline for the whole read, including any wait for a lost
|
||||||
// Resolve the file's keys and block table once, then read every
|
// link. A fetch that failed because the link went down is tried
|
||||||
// missing block through the shared reader. Previously each block
|
// once more when it is back, so a blip is a slow read, not an `EIO`.
|
||||||
// called `download_range`, which redid that resolution per block.
|
let deadline = Instant::now() + READ_FETCH_TIMEOUT;
|
||||||
let reader = self.revision_reader(uid, mtime, fsize).await?;
|
let mut retried = false;
|
||||||
|
let fetched = loop {
|
||||||
|
if !self.is_online() {
|
||||||
|
let wait =
|
||||||
|
READ_OFFLINE_WAIT.min(deadline.saturating_duration_since(Instant::now()));
|
||||||
|
if !self.wait_online(wait) {
|
||||||
|
debug!(%uid, offset, len, "read of uncached blocks while offline; failing it");
|
||||||
|
return Err(Errno::EIO);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let fetch = async {
|
||||||
|
// Resolve the file's keys and block table once, then read every
|
||||||
|
// missing block through the shared reader. Previously each block
|
||||||
|
// called `download_range`, which redid that resolution per block.
|
||||||
|
let reader = self.revision_reader(uid, mtime, fsize).await?;
|
||||||
|
|
||||||
let mut set = tokio::task::JoinSet::new();
|
let mut set = tokio::task::JoinSet::new();
|
||||||
for &span in &misses {
|
for &span in &misses {
|
||||||
let (core, reader, uid) = (self.clone(), reader.clone(), uid.clone());
|
let (core, reader, uid) = (self.clone(), reader.clone(), uid.clone());
|
||||||
set.spawn(async move {
|
set.spawn(async move {
|
||||||
core.fetch_block(&reader, &uid, mtime, fsize, span, cache_blocks)
|
core.fetch_block(&reader, &uid, mtime, fsize, span, cache_blocks)
|
||||||
.await
|
.await
|
||||||
.map(|bytes| (span.idx, bytes))
|
.map(|bytes| (span.idx, bytes))
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
let mut out = Vec::with_capacity(misses.len());
|
||||||
|
while let Some(joined) = set.join_next().await {
|
||||||
|
// A join error means the task panicked; surface it as EIO.
|
||||||
|
out.push(joined.map_err(|_| Errno::EIO)??);
|
||||||
|
}
|
||||||
|
Ok::<_, Errno>(out)
|
||||||
|
};
|
||||||
|
// Dropping the fetch on the deadline aborts the block tasks with
|
||||||
|
// it; blocks that already landed stay cached for the retry. The
|
||||||
|
// timer is built inside the runtime: a FUSE worker has no reactor
|
||||||
|
// of its own, and building it out here panicked every remote read.
|
||||||
|
let budget = deadline.saturating_duration_since(Instant::now());
|
||||||
|
match self
|
||||||
|
.rt
|
||||||
|
.block_on(async { tokio::time::timeout(budget, fetch).await })
|
||||||
|
{
|
||||||
|
Ok(Ok(fetched)) => break fetched,
|
||||||
|
// The fetch saw the link go down; the top of the loop waits
|
||||||
|
// for it within what is left of the deadline.
|
||||||
|
Ok(Err(_)) if !retried && !self.is_online() => retried = true,
|
||||||
|
Ok(Err(e)) => return Err(e),
|
||||||
|
Err(_) => {
|
||||||
|
warn!(
|
||||||
|
%uid, offset, len, secs = READ_FETCH_TIMEOUT.as_secs(),
|
||||||
|
"read got no answer from the network in time; failing it"
|
||||||
|
);
|
||||||
|
self.evict_reader(uid);
|
||||||
|
self.mark_offline("read", &crate::link::no_answer(READ_FETCH_TIMEOUT));
|
||||||
|
return Err(Errno::EIO);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let mut out = Vec::with_capacity(misses.len());
|
};
|
||||||
while let Some(joined) = set.join_next().await {
|
|
||||||
// A join error means the task panicked; surface it as EIO.
|
|
||||||
out.push(joined.map_err(|_| Errno::EIO)??);
|
|
||||||
}
|
|
||||||
Ok::<_, Errno>(out)
|
|
||||||
})?;
|
|
||||||
for (bidx, bytes) in fetched {
|
for (bidx, bytes) in fetched {
|
||||||
blocks[(bidx - first) as usize] = Some(bytes);
|
blocks[(bidx - first) as usize] = Some(bytes);
|
||||||
}
|
}
|
||||||
@@ -977,7 +1038,7 @@ impl Core {
|
|||||||
// — the cache validates it on read, the network path in `read_block`
|
// — the cache validates it on read, the network path in `read_block`
|
||||||
// — so this refuses rather than quietly contributing fewer bytes
|
// — so this refuses rather than quietly contributing fewer bytes
|
||||||
// than the range covers, which is what turned a bad block into a
|
// than the range covers, which is what turned a bad block into a
|
||||||
// truncated file (bugs.md B84).
|
// truncated file (`docs/BUGS.md` B84).
|
||||||
if block.len() as u64 != span.len {
|
if block.len() as u64 != span.len {
|
||||||
error!(
|
error!(
|
||||||
%uid, bidx = span.idx, len = block.len(), expected = span.len, fsize,
|
%uid, bidx = span.idx, len = block.len(), expected = span.len, fsize,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
//!
|
//!
|
||||||
//! Proton has no API for editing a sealed capture time, so the repair is a
|
//! 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
|
//! rewrite: download the photo, upload it again with the capture time its file
|
||||||
//! name implies, put the new copy back into the albums and favourites the old
|
//! 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
|
//! 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.
|
//! delete so a bad run is recoverable from Proton's own trash for 30 days.
|
||||||
//!
|
//!
|
||||||
@@ -213,7 +213,7 @@ impl Core {
|
|||||||
drop(guard);
|
drop(guard);
|
||||||
let new_uid = uploaded.map_err(|e| CoreError::from_api(&e, "re-upload photo"))?;
|
let new_uid = uploaded.map_err(|e| CoreError::from_api(&e, "re-upload photo"))?;
|
||||||
|
|
||||||
// Favourite and albums before the trash: a failure here leaves both
|
// Favorite and albums before the trash: a failure here leaves both
|
||||||
// copies on the account, which is recoverable by hand. Losing the
|
// copies on the account, which is recoverable by hand. Losing the
|
||||||
// original first would not be.
|
// original first would not be.
|
||||||
if candidate.favorite {
|
if candidate.favorite {
|
||||||
@@ -222,8 +222,13 @@ impl Core {
|
|||||||
tags_to_add: vec![PhotoTag::Favorite],
|
tags_to_add: vec![PhotoTag::Favorite],
|
||||||
tags_to_remove: Vec::new(),
|
tags_to_remove: Vec::new(),
|
||||||
};
|
};
|
||||||
if let Err(e) = self.rt.block_on(photos.update_photos(&[update])) {
|
match self.rt.block_on(photos.update_photos(&[update])) {
|
||||||
warn!(name = candidate.name, error = %e, "cannot re-favourite re-dated photo");
|
// 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 {
|
for album in &candidate.albums {
|
||||||
@@ -243,10 +248,7 @@ impl Core {
|
|||||||
|
|
||||||
let outcomes = self
|
let outcomes = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(
|
.block_on(self.drive.trash_nodes(std::slice::from_ref(&candidate.uid)))
|
||||||
self.client
|
|
||||||
.trash_nodes(std::slice::from_ref(&candidate.uid)),
|
|
||||||
)
|
|
||||||
.map_err(|e| CoreError::from_api(&e, "trash the mis-dated original"))?;
|
.map_err(|e| CoreError::from_api(&e, "trash the mis-dated original"))?;
|
||||||
if let Some((_, Err(e))) = outcomes.into_iter().next() {
|
if let Some((_, Err(e))) = outcomes.into_iter().next() {
|
||||||
return Err(CoreError::from_api(&e, "trash the mis-dated original"));
|
return Err(CoreError::from_api(&e, "trash the mis-dated original"));
|
||||||
|
|||||||
@@ -0,0 +1,611 @@
|
|||||||
|
//! Moving a file or folder between two locations without moving its content.
|
||||||
|
//!
|
||||||
|
//! `~/ProtonDrive` and every on-demand synced folder are separate FUSE
|
||||||
|
//! sessions, and a mirror folder is a plain local directory. The kernel refuses
|
||||||
|
//! `rename(2)` between two mounts with `EXDEV` before the daemon hears of it, so
|
||||||
|
//! `mv` falls back to copy and delete: every file is downloaded, uploaded again,
|
||||||
|
//! and the original is trashed.
|
||||||
|
//!
|
||||||
|
//! On Proton Drive the same move is one metadata change, because My files and
|
||||||
|
//! this computer's device folders all live on the main volume. So a move
|
||||||
|
//! request that names two absolute paths is resolved here, against every
|
||||||
|
//! location this daemon knows, and done as a server-side move. The local side
|
||||||
|
//! is then brought level: a mount forgets the node and re-lists, and a mirror
|
||||||
|
//! folder either renames its copy along or drops it once Drive has it.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Component, Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
use pdfs_core::db::{StoredSyncEntry, StoredSyncFolder};
|
||||||
|
use pdfs_core::{CoreError, CoreResult};
|
||||||
|
use proton_drive_rs::NodeKind;
|
||||||
|
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use super::sync::mirror_subtree_unsynced;
|
||||||
|
use super::{Core, is_local_uid, parse_uid};
|
||||||
|
|
||||||
|
/// How long a move waits for a mirror folder's sync pass to finish before it
|
||||||
|
/// reports the folder busy. A pass can run for hours on a first sync, so the
|
||||||
|
/// move does not wait it out.
|
||||||
|
const MIRROR_LOCK_WAIT: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
|
/// Where one side of a move lives.
|
||||||
|
enum Place {
|
||||||
|
/// My files or an on-demand folder: a live FUSE mount. `core` is rooted at
|
||||||
|
/// that mount and `rel` is relative to its mountpoint.
|
||||||
|
Mounted { core: Box<Core>, rel: PathBuf },
|
||||||
|
/// A mirror folder: a local directory kept in step by the sync engine.
|
||||||
|
/// `rel` is `/`-separated, relative to the folder, as the baseline keys it.
|
||||||
|
Mirror {
|
||||||
|
folder: StoredSyncFolder,
|
||||||
|
rel: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Place {
|
||||||
|
fn mirror_id(&self) -> Option<i64> {
|
||||||
|
match self {
|
||||||
|
Place::Mirror { folder, .. } => Some(folder.id),
|
||||||
|
Place::Mounted { .. } => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_root(&self) -> bool {
|
||||||
|
match self {
|
||||||
|
Place::Mounted { rel, .. } => rel.as_os_str().is_empty(),
|
||||||
|
Place::Mirror { rel, .. } => rel.is_empty(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The mirror folder that holds `abs`, and `abs` relative to it. The deepest
|
||||||
|
/// folder wins, although folders are not expected to nest.
|
||||||
|
fn mirror_covering(
|
||||||
|
folders: Vec<StoredSyncFolder>,
|
||||||
|
abs: &Path,
|
||||||
|
) -> Option<(StoredSyncFolder, String)> {
|
||||||
|
let folder = folders
|
||||||
|
.into_iter()
|
||||||
|
.filter(|f| f.mode == "mirror" && abs.starts_with(&f.local_path))
|
||||||
|
.max_by_key(|f| Path::new(&f.local_path).components().count())?;
|
||||||
|
let rel = abs.strip_prefix(&folder.local_path).ok()?;
|
||||||
|
let parts = rel
|
||||||
|
.components()
|
||||||
|
.map(|c| match c {
|
||||||
|
Component::Normal(part) => part.to_str(),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect::<Option<Vec<_>>>()?;
|
||||||
|
Some((folder, parts.join("/")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `name` inside the folder at `parent`, in baseline form.
|
||||||
|
fn join_rel(parent: &str, name: &str) -> String {
|
||||||
|
if parent.is_empty() {
|
||||||
|
name.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{parent}/{name}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rename `from` to `to`, failing with `EEXIST` rather than replacing
|
||||||
|
/// whatever appeared at `to` since it was checked. A filesystem that cannot
|
||||||
|
/// promise that gets a plain rename after one more look.
|
||||||
|
fn rename_noreplace(from: &Path, to: &Path) -> std::io::Result<()> {
|
||||||
|
use std::ffi::CString;
|
||||||
|
use std::os::unix::ffi::OsStrExt;
|
||||||
|
|
||||||
|
let cstr = |p: &Path| {
|
||||||
|
CString::new(p.as_os_str().as_bytes())
|
||||||
|
.map_err(|_| std::io::Error::from_raw_os_error(libc::EINVAL))
|
||||||
|
};
|
||||||
|
let (c_from, c_to) = (cstr(from)?, cstr(to)?);
|
||||||
|
// SAFETY: both are valid NUL-terminated paths that outlive the call.
|
||||||
|
let rc = unsafe {
|
||||||
|
libc::syscall(
|
||||||
|
libc::SYS_renameat2,
|
||||||
|
libc::AT_FDCWD,
|
||||||
|
c_from.as_ptr(),
|
||||||
|
libc::AT_FDCWD,
|
||||||
|
c_to.as_ptr(),
|
||||||
|
libc::RENAME_NOREPLACE,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if rc == 0 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let err = std::io::Error::last_os_error();
|
||||||
|
match err.raw_os_error() {
|
||||||
|
Some(libc::EINVAL | libc::ENOSYS) => {
|
||||||
|
if std::fs::symlink_metadata(to).is_ok() {
|
||||||
|
return Err(std::io::Error::from_raw_os_error(libc::EEXIST));
|
||||||
|
}
|
||||||
|
std::fs::rename(from, to)
|
||||||
|
}
|
||||||
|
_ => Err(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `uid` is `root` or lies under it, as far as the nodes table knows.
|
||||||
|
fn under(core: &Core, root: &NodeUid, uid: &str) -> bool {
|
||||||
|
let root = root.to_string();
|
||||||
|
uid == root || matches!(core.db.path_relative_to(&root, uid), Ok(Some(_)))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Core {
|
||||||
|
/// Move the file or folder at `src` into the folder at `dest_parent`. Both
|
||||||
|
/// are absolute local paths and may be in different locations: My files,
|
||||||
|
/// an on-demand folder, or a mirror folder. Returns the moved name.
|
||||||
|
///
|
||||||
|
/// Within one mount this is [`Core::move_to`]. Across locations it
|
||||||
|
/// is a server-side move followed by local bookkeeping; see the module
|
||||||
|
/// documentation. Anything the move could lose is refused up front.
|
||||||
|
pub(crate) fn move_between(&self, src: &Path, dest_parent: &Path) -> CoreResult<String> {
|
||||||
|
let source = self.locate(src)?;
|
||||||
|
let dest = self.locate(dest_parent)?;
|
||||||
|
if let (
|
||||||
|
Place::Mounted { core, rel },
|
||||||
|
Place::Mounted {
|
||||||
|
core: dest_core,
|
||||||
|
rel: dest_rel,
|
||||||
|
},
|
||||||
|
) = (&source, &dest)
|
||||||
|
&& Arc::ptr_eq(&core.root, &dest_core.root)
|
||||||
|
{
|
||||||
|
return core.move_to(rel, dest_rel);
|
||||||
|
}
|
||||||
|
if source.is_root() {
|
||||||
|
return Err(CoreError::invalid(format!(
|
||||||
|
"{} is the root of a synced location and cannot be moved",
|
||||||
|
src.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if dest_parent.starts_with(src) {
|
||||||
|
return Err(CoreError::invalid("cannot move a folder into itself"));
|
||||||
|
}
|
||||||
|
let name = src
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.ok_or_else(|| CoreError::invalid(format!("{} has no usable name", src.display())))?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Hold every mirror folder involved, lowest id first, so no sync pass
|
||||||
|
// sees the move half done and two moves cannot deadlock.
|
||||||
|
let mut ids: Vec<i64> = [source.mirror_id(), dest.mirror_id()]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.collect();
|
||||||
|
ids.sort_unstable();
|
||||||
|
ids.dedup();
|
||||||
|
let locks: Vec<Arc<Mutex<()>>> = ids.iter().map(|&id| self.sync_lock(id)).collect();
|
||||||
|
let mut guards = Vec::with_capacity(locks.len());
|
||||||
|
for lock in &locks {
|
||||||
|
guards.push(lock.try_lock_for(MIRROR_LOCK_WAIT).ok_or_else(|| {
|
||||||
|
CoreError::conflict("a synced folder is busy syncing; try again when it is done")
|
||||||
|
})?);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut baselines: HashMap<i64, HashMap<String, StoredSyncEntry>> = HashMap::new();
|
||||||
|
for &id in &ids {
|
||||||
|
let baseline = self
|
||||||
|
.db
|
||||||
|
.sync_entries(id)
|
||||||
|
.map_err(|e| CoreError::internal(format!("db: {e}")))?;
|
||||||
|
baselines.insert(id, baseline);
|
||||||
|
}
|
||||||
|
|
||||||
|
let src_uid = self.source_uid(&source, &baselines)?;
|
||||||
|
let dest_uid = self.dest_uid(&dest, &baselines, &name)?;
|
||||||
|
if src_uid.volume_id != dest_uid.volume_id {
|
||||||
|
return Err(CoreError::invalid(
|
||||||
|
"the two locations are on different Proton Drive volumes; copy it instead",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if under(self, &src_uid, &dest_uid.to_string()) {
|
||||||
|
return Err(CoreError::invalid("cannot move a folder into itself"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirror to mirror renames the local copy along, so it needs nothing
|
||||||
|
// proven about it. Another filesystem makes that impossible, and the
|
||||||
|
// pair then drops the source copy like any other move out of a mirror.
|
||||||
|
let mut renamed = None;
|
||||||
|
if let (
|
||||||
|
Place::Mirror { folder, rel },
|
||||||
|
Place::Mirror {
|
||||||
|
folder: dest_folder,
|
||||||
|
rel: dest_rel,
|
||||||
|
},
|
||||||
|
) = (&source, &dest)
|
||||||
|
{
|
||||||
|
let from = src.to_path_buf();
|
||||||
|
let to = dest_parent.join(&name);
|
||||||
|
match rename_noreplace(&from, &to) {
|
||||||
|
Ok(()) => renamed = Some((from, to)),
|
||||||
|
Err(e) if e.raw_os_error() == Some(libc::EXDEV) => {
|
||||||
|
info!(from = %folder.local_path, to = %dest_folder.local_path,
|
||||||
|
"move: mirror folders are on different filesystems");
|
||||||
|
}
|
||||||
|
Err(e) if e.raw_os_error() == Some(libc::EEXIST) => {
|
||||||
|
return Err(CoreError::conflict(format!(
|
||||||
|
"{name} already exists in {}",
|
||||||
|
dest_parent.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
return Err(CoreError::internal(format!(
|
||||||
|
"could not move {} to {}: {e}",
|
||||||
|
rel,
|
||||||
|
join_rel(dest_rel, &name)
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if renamed.is_none()
|
||||||
|
&& let Place::Mirror { folder, rel } = &source
|
||||||
|
&& let Some(reason) =
|
||||||
|
mirror_subtree_unsynced(Path::new(&folder.local_path), rel, &baselines[&folder.id])
|
||||||
|
{
|
||||||
|
return Err(CoreError::conflict(format!(
|
||||||
|
"{name} is not fully synced yet ({reason}); wait for the sync to finish"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = self.move_remote(&src_uid, &dest_uid) {
|
||||||
|
if let Some((from, to)) = &renamed
|
||||||
|
&& let Err(back) = std::fs::rename(to, from)
|
||||||
|
{
|
||||||
|
warn!(from = %to.display(), to = %from.display(), error = %back,
|
||||||
|
"move: could not put the local copy back after the move failed");
|
||||||
|
}
|
||||||
|
return Err(CoreError::from_api(&e, "move"));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.settle_source(
|
||||||
|
&source,
|
||||||
|
&src_uid,
|
||||||
|
renamed.is_some(),
|
||||||
|
&dest,
|
||||||
|
&dest_uid,
|
||||||
|
&name,
|
||||||
|
src,
|
||||||
|
);
|
||||||
|
self.settle_dest(&dest, &dest_uid, &name);
|
||||||
|
drop(guards);
|
||||||
|
if renamed.is_none()
|
||||||
|
&& let Place::Mirror { folder, .. } = &dest
|
||||||
|
{
|
||||||
|
// Nothing local exists yet; the folder's next pass downloads it.
|
||||||
|
self.sync_now(Some(folder.id));
|
||||||
|
}
|
||||||
|
Ok(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which location `abs` is in. A mount is looked for first: a mirror folder
|
||||||
|
/// cannot live inside a FUSE mount, and a mount is what the kernel sees.
|
||||||
|
fn locate(&self, abs: &Path) -> CoreResult<Place> {
|
||||||
|
if let Some((core, rel)) = self.rooted_at(abs) {
|
||||||
|
if !rel
|
||||||
|
.components()
|
||||||
|
.all(|c| matches!(c, Component::Normal(_) | Component::CurDir))
|
||||||
|
{
|
||||||
|
return Err(CoreError::invalid(format!(
|
||||||
|
"{} escapes its location",
|
||||||
|
abs.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
return Ok(Place::Mounted {
|
||||||
|
core: Box::new(core),
|
||||||
|
rel,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let folders = self
|
||||||
|
.db
|
||||||
|
.sync_folder_list()
|
||||||
|
.map_err(|e| CoreError::internal(format!("db: {e}")))?;
|
||||||
|
mirror_covering(folders, abs)
|
||||||
|
.map(|(folder, rel)| Place::Mirror { folder, rel })
|
||||||
|
.ok_or_else(|| {
|
||||||
|
CoreError::invalid(format!(
|
||||||
|
"{} is not in a Proton Drive location",
|
||||||
|
abs.display()
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The node being moved, once everything that must hold before it leaves
|
||||||
|
/// its location has been checked.
|
||||||
|
fn source_uid(
|
||||||
|
&self,
|
||||||
|
source: &Place,
|
||||||
|
baselines: &HashMap<i64, HashMap<String, StoredSyncEntry>>,
|
||||||
|
) -> CoreResult<NodeUid> {
|
||||||
|
match source {
|
||||||
|
Place::Mounted { core, rel } => {
|
||||||
|
let (ino, uid) = core.resolve(rel)?;
|
||||||
|
if is_local_uid(&uid) {
|
||||||
|
return Err(CoreError::conflict(
|
||||||
|
"it is not uploaded yet; wait for the upload to finish",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let old_parent = core.source_parent_uid(ino, rel)?;
|
||||||
|
for authority in [&uid, &old_parent] {
|
||||||
|
core.require_uid_writable(authority)
|
||||||
|
.map_err(|e| core.errno_error(e, "move access"))?;
|
||||||
|
}
|
||||||
|
if self.has_pending_work_under(&uid)? {
|
||||||
|
return Err(CoreError::conflict(
|
||||||
|
"it has changes that are not uploaded yet; wait for the upload to finish",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(uid)
|
||||||
|
}
|
||||||
|
Place::Mirror { folder, rel } => baselines[&folder.id]
|
||||||
|
.get(rel)
|
||||||
|
.and_then(|entry| entry.remote_uid.as_deref())
|
||||||
|
.and_then(parse_uid)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
CoreError::conflict(
|
||||||
|
"it is not on Proton Drive yet; wait for the sync to finish",
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The folder the node moves into. Refuses one that already holds `name`
|
||||||
|
/// on this disk; Drive refuses a clash on its side by itself.
|
||||||
|
fn dest_uid(
|
||||||
|
&self,
|
||||||
|
dest: &Place,
|
||||||
|
baselines: &HashMap<i64, HashMap<String, StoredSyncEntry>>,
|
||||||
|
name: &str,
|
||||||
|
) -> CoreResult<NodeUid> {
|
||||||
|
let uid = match dest {
|
||||||
|
Place::Mounted { core, rel } => {
|
||||||
|
let (ino, uid) = core.resolve(rel)?;
|
||||||
|
let is_folder = core
|
||||||
|
.state()
|
||||||
|
.entries
|
||||||
|
.get(&ino)
|
||||||
|
.is_some_and(|entry| matches!(entry.node.kind, NodeKind::Folder));
|
||||||
|
if !is_folder {
|
||||||
|
return Err(CoreError::invalid("the destination is not a folder"));
|
||||||
|
}
|
||||||
|
core.require_uid_writable(&uid)
|
||||||
|
.map_err(|e| core.errno_error(e, "move access"))?;
|
||||||
|
uid
|
||||||
|
}
|
||||||
|
Place::Mirror { folder, rel } => {
|
||||||
|
let local = Path::new(&folder.local_path).join(rel);
|
||||||
|
if !local.is_dir() {
|
||||||
|
return Err(CoreError::invalid("the destination is not a folder"));
|
||||||
|
}
|
||||||
|
if std::fs::symlink_metadata(local.join(name)).is_ok() {
|
||||||
|
return Err(CoreError::conflict(format!(
|
||||||
|
"{name} already exists in the destination"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let uid = if rel.is_empty() {
|
||||||
|
Some(folder.remote_uid.as_str())
|
||||||
|
} else {
|
||||||
|
baselines[&folder.id]
|
||||||
|
.get(rel)
|
||||||
|
.and_then(|entry| entry.remote_uid.as_deref())
|
||||||
|
};
|
||||||
|
uid.and_then(parse_uid).ok_or_else(|| {
|
||||||
|
CoreError::conflict(
|
||||||
|
"the destination is not on Proton Drive yet; wait for the sync to finish",
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if is_local_uid(&uid) {
|
||||||
|
return Err(CoreError::conflict(
|
||||||
|
"the destination is not uploaded yet; wait for the upload to finish",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the queue owes Drive anything for `uid` or a node under it, or a
|
||||||
|
/// file under it is open for writing. Moving such a node away would strand
|
||||||
|
/// the pending change against a path that no longer leads to it.
|
||||||
|
fn has_pending_work_under(&self, uid: &NodeUid) -> CoreResult<bool> {
|
||||||
|
let ops = self
|
||||||
|
.db
|
||||||
|
.pending_ops()
|
||||||
|
.map_err(|e| CoreError::internal(format!("db: {e}")))?;
|
||||||
|
if ops.iter().any(|op| {
|
||||||
|
under(self, uid, &op.uid)
|
||||||
|
|| op
|
||||||
|
.parent_uid
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|p| under(self, uid, p))
|
||||||
|
}) {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
// Collected first so no State lock is held across the nodes-table reads.
|
||||||
|
let mut writing = Vec::new();
|
||||||
|
self.for_each_state(|st| {
|
||||||
|
writing.extend(st.active_writes.values().map(|w| w.uid.to_string()));
|
||||||
|
});
|
||||||
|
Ok(writing.iter().any(|w| under(self, uid, w)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bring the source location level with a move Drive has made.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn settle_source(
|
||||||
|
&self,
|
||||||
|
source: &Place,
|
||||||
|
uid: &NodeUid,
|
||||||
|
renamed: bool,
|
||||||
|
dest: &Place,
|
||||||
|
dest_uid: &NodeUid,
|
||||||
|
name: &str,
|
||||||
|
src: &Path,
|
||||||
|
) {
|
||||||
|
match source {
|
||||||
|
Place::Mounted { core, rel } => {
|
||||||
|
// Moved everywhere, so no mount keeps serving it under the old
|
||||||
|
// path and its row stays (`docs/BUGS.md` B117). The content
|
||||||
|
// cache is keyed by uid and stays valid.
|
||||||
|
self.relocate_everywhere(uid, dest_uid, name);
|
||||||
|
core.invalidate_parent_listing(rel);
|
||||||
|
}
|
||||||
|
Place::Mirror { folder, rel } if renamed => {
|
||||||
|
let Place::Mirror {
|
||||||
|
folder: dest_folder,
|
||||||
|
rel: dest_rel,
|
||||||
|
} = dest
|
||||||
|
else {
|
||||||
|
unreachable!("a local rename needs a mirror destination");
|
||||||
|
};
|
||||||
|
if let Err(e) = self.db.sync_entries_move(
|
||||||
|
folder.id,
|
||||||
|
rel,
|
||||||
|
dest_folder.id,
|
||||||
|
&join_rel(dest_rel, name),
|
||||||
|
) {
|
||||||
|
// The next passes see a copy with no baseline on one side
|
||||||
|
// and a baseline with no copy on the other. That uploads
|
||||||
|
// the files again and trashes the moved node, but loses
|
||||||
|
// nothing.
|
||||||
|
warn!(error = ?e, "move: could not carry the sync baseline along");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Place::Mirror { folder, rel } => {
|
||||||
|
// Drive had all of it when the move was checked, so the local
|
||||||
|
// copy is only a stale duplicate now. Something written into it
|
||||||
|
// since would go with it, so look again first; if anything is
|
||||||
|
// new, the copy stays and the next pass uploads it as new.
|
||||||
|
let changed = match self.db.sync_entries(folder.id) {
|
||||||
|
Ok(baseline) => {
|
||||||
|
mirror_subtree_unsynced(Path::new(&folder.local_path), rel, &baseline)
|
||||||
|
}
|
||||||
|
Err(e) => Some(format!("db: {e}")),
|
||||||
|
};
|
||||||
|
if let Err(e) = self.db.sync_entries_remove_subtree(folder.id, rel) {
|
||||||
|
warn!(error = ?e, "move: could not drop the sync baseline");
|
||||||
|
}
|
||||||
|
if let Some(reason) = changed {
|
||||||
|
warn!(path = %src.display(), reason,
|
||||||
|
"move: the local copy changed during the move; keeping it");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let removed = if src.is_dir() {
|
||||||
|
std::fs::remove_dir_all(src)
|
||||||
|
} else {
|
||||||
|
std::fs::remove_file(src)
|
||||||
|
};
|
||||||
|
if let Err(e) = removed {
|
||||||
|
warn!(path = %src.display(), error = %e,
|
||||||
|
"move: could not remove the local copy after the move");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bring the destination location level with a move Drive has made.
|
||||||
|
fn settle_dest(&self, dest: &Place, uid: &NodeUid, name: &str) {
|
||||||
|
if let Place::Mounted { .. } = dest {
|
||||||
|
self.invalidate_children_of(uid);
|
||||||
|
// A lookup that missed before the move may be cached as absent.
|
||||||
|
self.for_each_mount(|st, notify| {
|
||||||
|
if let Some(&ino) = st.by_uid.get(uid) {
|
||||||
|
notify.inval_entry(ino, name.to_string());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn folder(id: i64, local_path: &str, mode: &str) -> StoredSyncFolder {
|
||||||
|
StoredSyncFolder {
|
||||||
|
id,
|
||||||
|
local_path: local_path.to_string(),
|
||||||
|
remote_uid: format!("vol~root{id}"),
|
||||||
|
remote_share_id: String::new(),
|
||||||
|
mode: mode.to_string(),
|
||||||
|
pending_mode: None,
|
||||||
|
state: "idle".to_string(),
|
||||||
|
last_sync: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mirror_covering_finds_the_mirror_folder_and_the_path_inside_it() {
|
||||||
|
let folders = vec![folder(1, "/home/u/Docs", "mirror")];
|
||||||
|
let (found, rel) = mirror_covering(folders, Path::new("/home/u/Docs/a/b.txt")).unwrap();
|
||||||
|
assert_eq!(found.id, 1);
|
||||||
|
assert_eq!(rel, "a/b.txt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mirror_covering_gives_an_empty_path_for_the_folder_itself() {
|
||||||
|
let folders = vec![folder(1, "/home/u/Docs", "mirror")];
|
||||||
|
let (_, rel) = mirror_covering(folders, Path::new("/home/u/Docs")).unwrap();
|
||||||
|
assert_eq!(rel, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mirror_covering_prefers_the_deepest_folder() {
|
||||||
|
let folders = vec![
|
||||||
|
folder(1, "/home/u/Docs", "mirror"),
|
||||||
|
folder(2, "/home/u/Docs/Work", "mirror"),
|
||||||
|
];
|
||||||
|
let (found, rel) = mirror_covering(folders, Path::new("/home/u/Docs/Work/x")).unwrap();
|
||||||
|
assert_eq!(found.id, 2);
|
||||||
|
assert_eq!(rel, "x");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mirror_covering_skips_on_demand_folders_and_name_prefixed_siblings() {
|
||||||
|
let folders = || {
|
||||||
|
vec![
|
||||||
|
folder(1, "/home/u/Docs", "mirror"),
|
||||||
|
folder(2, "/home/u/Media", "ondemand"),
|
||||||
|
]
|
||||||
|
};
|
||||||
|
assert!(mirror_covering(folders(), Path::new("/home/u/Media/x")).is_none());
|
||||||
|
assert!(mirror_covering(folders(), Path::new("/home/u/Docs2/x")).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mirror_covering_refuses_a_path_that_climbs_out() {
|
||||||
|
let folders = vec![folder(1, "/home/u/Docs", "mirror")];
|
||||||
|
assert!(mirror_covering(folders, Path::new("/home/u/Docs/../etc")).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn join_rel_names_a_child_of_the_folder_root_without_a_slash() {
|
||||||
|
assert_eq!(join_rel("", "a"), "a");
|
||||||
|
assert_eq!(join_rel("x/y", "a"), "x/y/a");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_local_rename_never_replaces_what_is_already_there() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("pdfs-relocate-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let (a, b, c) = (dir.join("a"), dir.join("b"), dir.join("c"));
|
||||||
|
std::fs::write(&a, b"moved").unwrap();
|
||||||
|
std::fs::write(&b, b"kept").unwrap();
|
||||||
|
|
||||||
|
let err = rename_noreplace(&a, &b).unwrap_err();
|
||||||
|
assert_eq!(err.raw_os_error(), Some(libc::EEXIST));
|
||||||
|
assert_eq!(std::fs::read(&b).unwrap(), b"kept");
|
||||||
|
assert!(a.exists());
|
||||||
|
|
||||||
|
rename_noreplace(&a, &c).unwrap();
|
||||||
|
assert!(!a.exists());
|
||||||
|
assert_eq!(std::fs::read(&c).unwrap(), b"moved");
|
||||||
|
std::fs::remove_dir_all(&dir).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -232,7 +232,7 @@ impl Core {
|
|||||||
let mut out = std::io::BufWriter::new(file);
|
let mut out = std::io::BufWriter::new(file);
|
||||||
let written = match self
|
let written = match self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.download_revision_to(uid, revision_id, &mut out))
|
.block_on(self.drive.download_revision_to(uid, revision_id, &mut out))
|
||||||
.map_err(|e| CoreError::from_api(&e, "download revision"))
|
.map_err(|e| CoreError::from_api(&e, "download revision"))
|
||||||
.and_then(|()| {
|
.and_then(|()| {
|
||||||
use std::io::Write as _;
|
use std::io::Write as _;
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ use proton_drive_rs::{MemberRole, NodeKind};
|
|||||||
|
|
||||||
use proton_drive_rs::Node;
|
use proton_drive_rs::Node;
|
||||||
|
|
||||||
use super::{Core, ROOT_INO, node_size, parse_uid, public_link_info, role_from_str, role_to_str};
|
use super::{Core, node_size, parse_uid, public_link_info, role_from_str, role_to_str};
|
||||||
|
|
||||||
/// A node from a *shared* listing as a [`DirEntry`].
|
/// A node from a *shared* listing as a [`DirEntry`].
|
||||||
///
|
///
|
||||||
@@ -73,6 +73,9 @@ fn shared_entry_of(core: &Core, n: Node) -> DirEntry {
|
|||||||
shared_by,
|
shared_by,
|
||||||
shared_at,
|
shared_at,
|
||||||
shared_by_unverified,
|
shared_by_unverified,
|
||||||
|
trashed_at: 0,
|
||||||
|
trashed_from: None,
|
||||||
|
issue: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,6 +391,66 @@ impl Core {
|
|||||||
.map_err(|e| CoreError::from_api(&e, "remove public 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 ---------------------------------------------------
|
// ---- shared with me ---------------------------------------------------
|
||||||
|
|
||||||
/// List nodes shared with me that I have accepted.
|
/// List nodes shared with me that I have accepted.
|
||||||
@@ -401,7 +464,7 @@ impl Core {
|
|||||||
}
|
}
|
||||||
let nodes = self
|
let nodes = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.enumerate_nodes(&uids))
|
.block_on(self.drive.enumerate_nodes(&uids))
|
||||||
.map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?;
|
.map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?;
|
||||||
Ok(nodes
|
Ok(nodes
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -419,14 +482,14 @@ impl Core {
|
|||||||
parse_uid(uid).ok_or_else(|| CoreError::invalid(format!("invalid uid: {uid}")))?;
|
parse_uid(uid).ok_or_else(|| CoreError::invalid(format!("invalid uid: {uid}")))?;
|
||||||
let child_uids = self
|
let child_uids = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.enumerate_folder_children_node_uids(&uid))
|
.block_on(self.drive.enumerate_folder_children_node_uids(&uid))
|
||||||
.map_err(|e| CoreError::from_api(&e, "enumerate shared children"))?;
|
.map_err(|e| CoreError::from_api(&e, "enumerate shared children"))?;
|
||||||
if child_uids.is_empty() {
|
if child_uids.is_empty() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
let nodes = self
|
let nodes = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.enumerate_nodes(&child_uids))
|
.block_on(self.drive.enumerate_nodes(&child_uids))
|
||||||
.map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?;
|
.map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?;
|
||||||
Ok(nodes
|
Ok(nodes
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -447,7 +510,7 @@ impl Core {
|
|||||||
parse_uid(uid).ok_or_else(|| CoreError::invalid(format!("invalid uid: {uid}")))?;
|
parse_uid(uid).ok_or_else(|| CoreError::invalid(format!("invalid uid: {uid}")))?;
|
||||||
let node = self
|
let node = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.get_node(&uid))
|
.block_on(self.drive.get_node(&uid))
|
||||||
.map_err(|e| CoreError::from_api(&e, "get shared node"))?
|
.map_err(|e| CoreError::from_api(&e, "get shared node"))?
|
||||||
.ok_or_else(|| CoreError::not_found("shared node not found"))?;
|
.ok_or_else(|| CoreError::not_found("shared node not found"))?;
|
||||||
if !node.is_file() {
|
if !node.is_file() {
|
||||||
@@ -548,10 +611,10 @@ impl Core {
|
|||||||
/// Proton products (not Drive-only). A remote round-trip; nothing here is
|
/// Proton products (not Drive-only). A remote round-trip; nothing here is
|
||||||
/// cached.
|
/// cached.
|
||||||
pub(crate) fn account_quota(&self) -> CoreResult<(i64, i64)> {
|
pub(crate) fn account_quota(&self) -> CoreResult<(i64, i64)> {
|
||||||
let q = self
|
let q = self.block_on_bounded(self.drive.quota()).map_err(|e| {
|
||||||
.rt
|
self.lost_link(&e, "account quota");
|
||||||
.block_on(self.client.quota())
|
CoreError::from_api(&e, "account quota")
|
||||||
.map_err(|e| CoreError::from_api(&e, "account quota"))?;
|
})?;
|
||||||
*self.quota.lock() = Some((std::time::Instant::now(), q.max_space, q.used_space));
|
*self.quota.lock() = Some((std::time::Instant::now(), q.max_space, q.used_space));
|
||||||
Ok((q.max_space, q.used_space))
|
Ok((q.max_space, q.used_space))
|
||||||
}
|
}
|
||||||
@@ -570,11 +633,22 @@ impl Core {
|
|||||||
{
|
{
|
||||||
return Some((max_space, used_space));
|
return Some((max_space, used_space));
|
||||||
}
|
}
|
||||||
match self.account_quota() {
|
let last = || self.quota.lock().map(|(_, max, used)| (max, used));
|
||||||
|
if !self.is_online()
|
||||||
|
|| self
|
||||||
|
.quota_refreshing
|
||||||
|
.swap(true, std::sync::atomic::Ordering::AcqRel)
|
||||||
|
{
|
||||||
|
return last();
|
||||||
|
}
|
||||||
|
let refreshed = self.account_quota();
|
||||||
|
self.quota_refreshing
|
||||||
|
.store(false, std::sync::atomic::Ordering::Release);
|
||||||
|
match refreshed {
|
||||||
Ok(q) => Some(q),
|
Ok(q) => Some(q),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
debug!(%error, "account quota refresh failed; serving the last known figures");
|
debug!(%error, "account quota refresh failed; serving the last known figures");
|
||||||
self.quota.lock().map(|(_, max, used)| (max, used))
|
last()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -586,6 +660,12 @@ impl Core {
|
|||||||
/// the shared uids; the per-node detail is then gathered best-effort — a single
|
/// the shared uids; the per-node detail is then gathered best-effort — a single
|
||||||
/// node racing with an unshare drops from the list rather than failing the whole
|
/// node racing with an unshare drops from the list rather than failing the whole
|
||||||
/// request.
|
/// request.
|
||||||
|
///
|
||||||
|
/// A share with no member, no invitation and no link is left out: it is
|
||||||
|
/// shared with no one. Stop Sharing leaves exactly that behind, since the SDK
|
||||||
|
/// cannot delete a share, and Drive keeps listing it until its backend gets
|
||||||
|
/// round to removing it, minutes later. A share whose details could not all
|
||||||
|
/// be read stays listed, so a failed lookup never hides a live share.
|
||||||
pub(crate) fn list_shared_by_me(&self) -> CoreResult<Vec<SharedItem>> {
|
pub(crate) fn list_shared_by_me(&self) -> CoreResult<Vec<SharedItem>> {
|
||||||
let uids = self
|
let uids = self
|
||||||
.rt
|
.rt
|
||||||
@@ -596,7 +676,7 @@ impl Core {
|
|||||||
}
|
}
|
||||||
let nodes = self
|
let nodes = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.enumerate_nodes(&uids))
|
.block_on(self.drive.enumerate_nodes(&uids))
|
||||||
.map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?;
|
.map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?;
|
||||||
let mut items = Vec::with_capacity(nodes.len());
|
let mut items = Vec::with_capacity(nodes.len());
|
||||||
for n in nodes {
|
for n in nodes {
|
||||||
@@ -604,24 +684,26 @@ impl Core {
|
|||||||
let members = self
|
let members = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.list_share_members(&uid))
|
.block_on(self.client.list_share_members(&uid))
|
||||||
.map(|m| m.len())
|
.map(|m| m.len());
|
||||||
.unwrap_or(0);
|
|
||||||
let proton_invites = self
|
let proton_invites = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.list_share_invitations(&uid))
|
.block_on(self.client.list_share_invitations(&uid))
|
||||||
.map(|i| i.len())
|
.map(|i| i.len());
|
||||||
.unwrap_or(0);
|
|
||||||
let external_invites = self
|
let external_invites = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.list_external_invitations(&uid))
|
.block_on(self.client.list_external_invitations(&uid))
|
||||||
.map(|i| i.len())
|
.map(|i| i.len());
|
||||||
.unwrap_or(0);
|
let link = self.rt.block_on(self.client.get_public_link(&uid));
|
||||||
let link = self
|
if let (Ok(0), Ok(0), Ok(0), Ok(None)) =
|
||||||
.rt
|
(&members, &proton_invites, &external_invites, &link)
|
||||||
.block_on(self.client.get_public_link(&uid))
|
{
|
||||||
.ok()
|
debug!(%uid, "shared by me: skipping an emptied share");
|
||||||
.flatten()
|
continue;
|
||||||
.map(public_link_info);
|
}
|
||||||
|
let members = members.unwrap_or(0);
|
||||||
|
let proton_invites = proton_invites.unwrap_or(0);
|
||||||
|
let external_invites = external_invites.unwrap_or(0);
|
||||||
|
let link = link.ok().flatten().map(public_link_info);
|
||||||
items.push(SharedItem {
|
items.push(SharedItem {
|
||||||
uid: uid.to_string(),
|
uid: uid.to_string(),
|
||||||
is_dir: n.is_folder(),
|
is_dir: n.is_folder(),
|
||||||
@@ -641,33 +723,103 @@ impl Core {
|
|||||||
/// been seen through the mount (e.g. shared but not browsed to this session) —
|
/// been seen through the mount (e.g. shared but not browsed to this session) —
|
||||||
/// the caller then leaves the path empty.
|
/// the caller then leaves the path empty.
|
||||||
pub(crate) fn rel_path_for_uid(&self, uid: &NodeUid) -> Option<String> {
|
pub(crate) fn rel_path_for_uid(&self, uid: &NodeUid) -> Option<String> {
|
||||||
// Searched across every mount: a node browsed through a sync folder is
|
// Relative to whichever root the node sits under: a node browsed
|
||||||
// interned in that fork's inode space, so the primary state alone had no
|
// through a sync folder is under that fork's root, not My Files
|
||||||
// entry for it and the caller silently showed an empty path
|
// (`docs/BUGS.md` B74).
|
||||||
// (`docs/BUGS.md` B74). The path is relative to whichever mount holds
|
walk_to_root(&self.state(), uid)
|
||||||
// it, which is the one the user reached it through.
|
|
||||||
let mut path = None;
|
|
||||||
self.for_each_state(|st| {
|
|
||||||
if path.is_some() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
path = walk_to_root(st, uid);
|
|
||||||
});
|
|
||||||
path
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Walk one inode space from `uid` up to its root, building the path. `None` if
|
/// What [`Core::stop_sharing`] managed to remove.
|
||||||
/// that mount has never interned the node, or if the chain breaks partway —
|
#[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.
|
/// a broken chain is a missing path, never a partial one.
|
||||||
fn walk_to_root(st: &super::State, uid: &NodeUid) -> Option<String> {
|
fn walk_to_root(st: &super::State, uid: &NodeUid) -> Option<String> {
|
||||||
let mut ino = *st.by_uid.get(uid)?;
|
let mut ino = *st.by_uid.get(uid)?;
|
||||||
let mut parts = Vec::new();
|
let mut parts = Vec::new();
|
||||||
while ino != ROOT_INO {
|
while !st.roots.contains(&ino) {
|
||||||
let entry = st.entries.get(&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());
|
parts.push(entry.node.name.clone());
|
||||||
ino = entry.parent;
|
ino = entry.parent;
|
||||||
}
|
}
|
||||||
parts.reverse();
|
parts.reverse();
|
||||||
Some(parts.join("/"))
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
//! invisible; in an in-process remount — which is how the daemon recovers from a
|
//! invisible; in an in-process remount — which is how the daemon recovers from a
|
||||||
//! mount going away underneath it — it leaks a whole generation of threads that
|
//! mount going away underneath it — it leaks a whole generation of threads that
|
||||||
//! keep writing to the database and calling the API on behalf of a mount that no
|
//! keep writing to the database and calling the API on behalf of a mount that no
|
||||||
//! longer exists (bugs.md B44).
|
//! longer exists (`docs/BUGS.md` B44).
|
||||||
//!
|
//!
|
||||||
//! The primitive is deliberately small: a flag plus a condvar. Every loop that
|
//! The primitive is deliberately small: a flag plus a condvar. Every loop that
|
||||||
//! would otherwise call `thread::sleep` calls [`Shutdown::sleep`] instead, which
|
//! would otherwise call `thread::sleep` calls [`Shutdown::sleep`] instead, which
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -128,7 +128,62 @@ pub(crate) struct WriteHandle {
|
|||||||
pub(crate) open_count: usize,
|
pub(crate) open_count: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A released write whose upload has not happened yet (offline.md Phase 3).
|
impl WriteHandle {
|
||||||
|
/// A handle on a file `create` just made: an empty base, so everything
|
||||||
|
/// written through it is authored.
|
||||||
|
///
|
||||||
|
/// The base is `node` as it actually exists — the empty file the server just
|
||||||
|
/// minted, or a queued create's placeholder — never the local clock or a
|
||||||
|
/// blank. `queue_revision` turns it into `StagedWrite::based_on` and the
|
||||||
|
/// drain compares the remote against that. A `now_secs()` mtime differs from
|
||||||
|
/// the server's stamp by the create round-trip. A missing revision id drops
|
||||||
|
/// the check to `(mtime, size)`, and moving or renaming the file advances
|
||||||
|
/// its mtime on the server. Either way the first write to a brand-new file
|
||||||
|
/// lands as a conflict copy of its own empty create (`docs/BUGS.md` B113).
|
||||||
|
pub(crate) fn created(ino: u64, node: &Node, file: File, path: PathBuf) -> Self {
|
||||||
|
Self {
|
||||||
|
ino,
|
||||||
|
uid: node.uid.clone(),
|
||||||
|
file: Arc::new(file),
|
||||||
|
path,
|
||||||
|
written: Intervals::default(),
|
||||||
|
len: 0,
|
||||||
|
base_size: 0,
|
||||||
|
base_mtime: node.modification_time,
|
||||||
|
base_revision_id: crate::node_revision_id(node),
|
||||||
|
dirty: false,
|
||||||
|
open_count: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write over `node` from now on: a revision this daemon has just put on the
|
||||||
|
/// server under the handle, by sealing an upload or by landing the queued
|
||||||
|
/// create the handle was opened on. The base the handle opened over is gone,
|
||||||
|
/// and keeping it would make the drain read our own revision as another
|
||||||
|
/// device's edit.
|
||||||
|
pub(crate) fn rebase_onto(&mut self, node: &Node) {
|
||||||
|
self.base_mtime = node.modification_time;
|
||||||
|
self.base_size = crate::node_size(node);
|
||||||
|
self.base_revision_id = crate::node_revision_id(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Account for `[offset, end)` having been written through this handle.
|
||||||
|
///
|
||||||
|
/// A write past the end leaves a hole that reads as zeros, exactly like a
|
||||||
|
/// grow through `setattr`, so the hole is authored too. Left unauthored, a
|
||||||
|
/// hole below the base size was refilled from the base at commit: a file
|
||||||
|
/// shrunk and then written past its new end got its old bytes back.
|
||||||
|
pub(crate) fn record_write(&mut self, offset: u64, end: u64) {
|
||||||
|
if offset > self.len {
|
||||||
|
self.written.add(self.len, offset);
|
||||||
|
}
|
||||||
|
self.written.add(offset, end);
|
||||||
|
self.len = self.len.max(end);
|
||||||
|
self.dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A released write whose upload has not happened yet.
|
||||||
///
|
///
|
||||||
/// The bytes live in the content cache's staging dir and the intent lives in the
|
/// The bytes live in the content cache's staging dir and the intent lives in the
|
||||||
/// `pending_op` table; this pairs them in memory so a read can be served without
|
/// `pending_op` table; this pairs them in memory so a read can be served without
|
||||||
@@ -146,8 +201,29 @@ pub(crate) struct PendingRevision {
|
|||||||
/// time so this module stays free of `fuser`.
|
/// time so this module stays free of `fuser`.
|
||||||
pub(crate) type DirListing = Vec<(u64, bool, String)>;
|
pub(crate) type DirListing = Vec<(u64, bool, String)>;
|
||||||
|
|
||||||
|
/// Where [`State::next_ino`] starts: far above any local id a database hands
|
||||||
|
/// out, so a fallback inode never names a node that has a row.
|
||||||
|
pub(crate) const FALLBACK_INO: u64 = 1 << 48;
|
||||||
|
|
||||||
|
/// The inode of the node with local id `lid` ([`Db::lids_for`]). One above the
|
||||||
|
/// lid, so no node takes [`crate::ROOT_INO`], which each session uses for its
|
||||||
|
/// own root. The same in every mount and after a restart.
|
||||||
|
pub(crate) fn lid_ino(lid: i64) -> u64 {
|
||||||
|
lid.unsigned_abs() + crate::ROOT_INO
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The local id of the node at inode `ino`, the inverse of [`lid_ino`]:
|
||||||
|
/// `None` for the root and a fallback inode.
|
||||||
|
pub(crate) fn ino_lid(ino: u64) -> Option<i64> {
|
||||||
|
(ino > crate::ROOT_INO && ino < FALLBACK_INO).then(|| (ino - crate::ROOT_INO) as i64)
|
||||||
|
}
|
||||||
|
|
||||||
/// Mutable inode bookkeeping, guarded by a mutex because fuser drives the
|
/// Mutable inode bookkeeping, guarded by a mutex because fuser drives the
|
||||||
/// `Filesystem` trait through `&self`.
|
/// `Filesystem` trait through `&self`.
|
||||||
|
///
|
||||||
|
/// One tree for every mount of the daemon. Each FUSE session sees it from its
|
||||||
|
/// own root ([`State::roots`]) and calls that root inode 1; `ProtonFs`
|
||||||
|
/// translates at the kernel boundary.
|
||||||
pub(crate) struct State {
|
pub(crate) struct State {
|
||||||
/// inode -> node metadata.
|
/// inode -> node metadata.
|
||||||
pub(crate) entries: HashMap<u64, Entry>,
|
pub(crate) entries: HashMap<u64, Entry>,
|
||||||
@@ -156,6 +232,12 @@ pub(crate) struct State {
|
|||||||
/// Cached directory listings: parent inode -> child inodes. Presence of a
|
/// Cached directory listings: parent inode -> child inodes. Presence of a
|
||||||
/// key means the directory has been enumerated.
|
/// key means the directory has been enumerated.
|
||||||
pub(crate) children: HashMap<u64, Vec<u64>>,
|
pub(crate) children: HashMap<u64, Vec<u64>>,
|
||||||
|
/// The inodes some session has been rooted at ([`State::install_root`]).
|
||||||
|
/// A root is its own parent. Kept after its session unmounts: the node is
|
||||||
|
/// still one of ours.
|
||||||
|
pub(crate) roots: HashSet<u64>,
|
||||||
|
/// The next inode for a node the database could not give a local id, from
|
||||||
|
/// a range lids never reach ([`FALLBACK_INO`]).
|
||||||
pub(crate) next_ino: u64,
|
pub(crate) next_ino: u64,
|
||||||
/// Shared write state keyed by inode. Concurrent writers share the scratch file.
|
/// Shared write state keyed by inode. Concurrent writers share the scratch file.
|
||||||
pub(crate) active_writes: HashMap<u64, WriteHandle>,
|
pub(crate) active_writes: HashMap<u64, WriteHandle>,
|
||||||
@@ -171,13 +253,13 @@ pub(crate) struct State {
|
|||||||
/// two pages shifted every later entry, and the caller silently skipped or
|
/// two pages shifted every later entry, and the caller silently skipped or
|
||||||
/// repeated whatever crossed the page boundary. Each entry is
|
/// repeated whatever crossed the page boundary. Each entry is
|
||||||
/// `(ino, is_dir, name)` — the kernel type is mapped at reply time so this
|
/// `(ino, is_dir, name)` — the kernel type is mapped at reply time so this
|
||||||
/// module stays free of `fuser` (bugs.md B43).
|
/// module stays free of `fuser` (`docs/BUGS.md` B43).
|
||||||
pub(crate) dir_snapshots: HashMap<u64, Arc<DirListing>>,
|
pub(crate) dir_snapshots: HashMap<u64, Arc<DirListing>>,
|
||||||
/// Resident inode attributes whose effective access changed during an
|
/// Resident inode attributes whose effective access changed during an
|
||||||
/// intern/root refresh. Core drains this set and notifies the matching
|
/// intern/root refresh. Core drains this set and notifies the matching
|
||||||
/// kernel only after releasing the State lock.
|
/// kernel only after releasing the State lock.
|
||||||
pub(crate) access_changes: HashSet<u64>,
|
pub(crate) access_changes: HashSet<u64>,
|
||||||
/// Persisted shared-root authority, loaded once per inode space and updated
|
/// Persisted shared-root authority, loaded once and updated
|
||||||
/// on every explicit role/tombstone change. Descendant inheritance reads
|
/// on every explicit role/tombstone change. Descendant inheritance reads
|
||||||
/// this map instead of issuing one SQLite query per interned inode.
|
/// this map instead of issuing one SQLite query per interned inode.
|
||||||
pub(crate) share_access: HashMap<NodeUid, Access>,
|
pub(crate) share_access: HashMap<NodeUid, Access>,
|
||||||
@@ -245,15 +327,6 @@ pub(crate) struct StateGuard<'a> {
|
|||||||
db: &'a Arc<Db>,
|
db: &'a Arc<Db>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Take a mount's inode lock through a [`StateGuard`], for the loops that walk
|
|
||||||
/// every live mount rather than this `Core`'s own.
|
|
||||||
pub(crate) fn lock_state<'a>(
|
|
||||||
state: &'a parking_lot::Mutex<State>,
|
|
||||||
db: &'a Arc<Db>,
|
|
||||||
) -> StateGuard<'a> {
|
|
||||||
StateGuard::new(state.lock(), db)
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> StateGuard<'a> {
|
impl<'a> StateGuard<'a> {
|
||||||
pub(crate) fn new(guard: parking_lot::MutexGuard<'a, State>, db: &'a Arc<Db>) -> Self {
|
pub(crate) fn new(guard: parking_lot::MutexGuard<'a, State>, db: &'a Arc<Db>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -294,9 +367,6 @@ impl Drop for StateGuard<'_> {
|
|||||||
impl State {
|
impl State {
|
||||||
/// A fresh, empty inode space over `db`.
|
/// A fresh, empty inode space over `db`.
|
||||||
///
|
///
|
||||||
/// `next_ino` differs by caller: a real mount hands out [`crate::ROOT_INO`]
|
|
||||||
/// to its root and starts allocating above it, while a bare state under test
|
|
||||||
/// has no root and starts at 1.
|
|
||||||
/// Apply the write-throughs this state owes SQLite, here and now.
|
/// Apply the write-throughs this state owes SQLite, here and now.
|
||||||
///
|
///
|
||||||
/// Production code never calls this: [`StateGuard`] releases the inode lock
|
/// Production code never calls this: [`StateGuard`] releases the inode lock
|
||||||
@@ -323,12 +393,15 @@ impl State {
|
|||||||
self.db.clone()
|
self.db.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn new(db: Arc<Db>, share_access: HashMap<NodeUid, Access>, next_ino: u64) -> Self {
|
/// A fresh, empty tree over `db`. A node's inode comes from its local id
|
||||||
|
/// ([`lid_ino`]).
|
||||||
|
pub(crate) fn new(db: Arc<Db>, share_access: HashMap<NodeUid, Access>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
entries: HashMap::new(),
|
entries: HashMap::new(),
|
||||||
by_uid: HashMap::new(),
|
by_uid: HashMap::new(),
|
||||||
children: HashMap::new(),
|
children: HashMap::new(),
|
||||||
next_ino,
|
roots: HashSet::new(),
|
||||||
|
next_ino: FALLBACK_INO,
|
||||||
active_writes: HashMap::new(),
|
active_writes: HashMap::new(),
|
||||||
handles: HashMap::new(),
|
handles: HashMap::new(),
|
||||||
next_fh: 1,
|
next_fh: 1,
|
||||||
@@ -339,18 +412,55 @@ impl State {
|
|||||||
outbox: Vec::new(),
|
outbox: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/// Whether `uid` names an owned node currently reachable from this mount's
|
/// Make `root` the root of a session and return its inode, which comes
|
||||||
/// root. Resident entries can outlive their dentries for open-handle and
|
/// from its local id like any other node's.
|
||||||
/// access-revocation semantics, so `by_uid` membership alone is not proof
|
///
|
||||||
/// that a control-socket operation may address the node.
|
/// The root is always owned. It must never be classified as a shared root,
|
||||||
pub(crate) fn owns_visible_uid(&self, uid: &NodeUid) -> bool {
|
/// even when the API returns membership data (which happens when the owner
|
||||||
let Some(root) = self.entries.get(&crate::ROOT_INO) else {
|
/// shares the folder with someone else), so a stale share row a downgrade
|
||||||
return false;
|
/// may have left for it is dropped. A node already in the tree keeps its
|
||||||
};
|
/// inode and its lookups, and stops naming a parent: a session's `..` at
|
||||||
if uid.volume_id != root.uid.volume_id {
|
/// its root is the root.
|
||||||
return false;
|
pub(crate) fn install_root(&mut self, root: Node) -> u64 {
|
||||||
|
if let Err(e) = self.db.upsert_node(&root) {
|
||||||
|
warn!(uid = %root.uid, error = %e, "db upsert root failed");
|
||||||
}
|
}
|
||||||
|
self.share_access.remove(&root.uid);
|
||||||
|
let _ = self.db.delete_share_access(&root.uid);
|
||||||
|
let ino = match self.by_uid.get(&root.uid) {
|
||||||
|
Some(&ino) => ino,
|
||||||
|
None => self.new_inos(&[&root])[&root.uid],
|
||||||
|
};
|
||||||
|
if let Some(old) = self.entries.get(&ino).map(|e| e.parent)
|
||||||
|
&& old != ino
|
||||||
|
&& let Some(kids) = self.children.get_mut(&old)
|
||||||
|
{
|
||||||
|
kids.retain(|&k| k != ino);
|
||||||
|
}
|
||||||
|
self.by_uid.insert(root.uid.clone(), ino);
|
||||||
|
let lookup_count = self.entries.get(&ino).map_or(1, |e| e.lookup_count.max(1));
|
||||||
|
self.entries.insert(
|
||||||
|
ino,
|
||||||
|
Entry {
|
||||||
|
uid: root.uid.clone(),
|
||||||
|
parent: ino,
|
||||||
|
node: root,
|
||||||
|
access: Access::Owner,
|
||||||
|
lookup_count,
|
||||||
|
open_count: 0,
|
||||||
|
unlinked: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
self.roots.insert(ino);
|
||||||
|
ino
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `uid` names an owned node currently reachable from one of
|
||||||
|
/// `roots`, the roots of the sessions that are mounted. Resident entries
|
||||||
|
/// can outlive their dentries for open-handle and access-revocation
|
||||||
|
/// semantics, so `by_uid` membership alone is not proof that a
|
||||||
|
/// control-socket operation may address the node.
|
||||||
|
pub(crate) fn owns_visible_uid(&self, uid: &NodeUid, roots: &[u64]) -> bool {
|
||||||
let Some(&target) = self.by_uid.get(uid) else {
|
let Some(&target) = self.by_uid.get(uid) else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
@@ -363,7 +473,9 @@ impl State {
|
|||||||
let Some(entry) = self.entries.get(¤t) else {
|
let Some(entry) = self.entries.get(¤t) else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
if entry.uid.volume_id != root.uid.volume_id
|
// The node's root scopes it to its own volume, excluding foreign
|
||||||
|
// shared-with-me residents from uid-addressed sharing.
|
||||||
|
if entry.uid.volume_id != uid.volume_id
|
||||||
|| entry.unlinked
|
|| entry.unlinked
|
||||||
|| entry.node.trashed
|
|| entry.node.trashed
|
||||||
|| entry.uid != entry.node.uid
|
|| entry.uid != entry.node.uid
|
||||||
@@ -371,7 +483,7 @@ impl State {
|
|||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if current == crate::ROOT_INO {
|
if roots.contains(¤t) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,15 +525,17 @@ impl State {
|
|||||||
self.access_changes.drain().collect()
|
self.access_changes.drain().collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether `uid` lives on the volume this mount is rooted in.
|
/// Whether `uid` lives on a volume some mount is rooted in.
|
||||||
///
|
///
|
||||||
/// Used to decide how a node with no resolvable parent is classified:
|
/// Used to decide how a node with no resolvable parent is classified:
|
||||||
/// our own volume fails open (it is owned content), a foreign volume
|
/// our own volume fails open (it is owned content), a foreign volume
|
||||||
/// fails closed (it can only be there because of a share).
|
/// fails closed (it can only be there because of a share).
|
||||||
pub(crate) fn is_own_volume(&self, uid: &NodeUid) -> bool {
|
pub(crate) fn is_own_volume(&self, uid: &NodeUid) -> bool {
|
||||||
self.entries
|
self.roots.iter().any(|ino| {
|
||||||
.get(&crate::ROOT_INO)
|
self.entries
|
||||||
.is_some_and(|root| root.uid.volume_id == uid.volume_id)
|
.get(ino)
|
||||||
|
.is_some_and(|root| root.uid.volume_id == uid.volume_id)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stored_share_access(&self, uid: &NodeUid) -> Option<Access> {
|
fn stored_share_access(&self, uid: &NodeUid) -> Option<Access> {
|
||||||
@@ -770,11 +884,59 @@ impl State {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn intern(&mut self, parent: u64, node: Node) -> u64 {
|
/// Write `node` through as it is, in order with the tree's other changes.
|
||||||
|
pub(crate) fn write_through(&mut self, node: Node) {
|
||||||
|
self.outbox.push(DbWrite::Upsert(vec![node]));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn intern(&mut self, parent: u64, mut node: Node) -> u64 {
|
||||||
|
self.keep_revision_mtime(&mut node);
|
||||||
self.outbox.push(DbWrite::Upsert(vec![node.clone()]));
|
self.outbox.push(DbWrite::Upsert(vec![node.clone()]));
|
||||||
self.intern_mem(parent, node)
|
self.intern_mem(parent, node)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Keep what is already known of a file whose content has not changed.
|
||||||
|
///
|
||||||
|
/// Drive stamps a link's `ModifyTime` on a rename or a move as well, while
|
||||||
|
/// POSIX leaves a file's mtime alone there. The content cache is keyed by
|
||||||
|
/// it, too: taking the new stamp made the cached bytes unreachable, so the
|
||||||
|
/// next partial write could not fill its gaps (docs/BUGS.md B123). The
|
||||||
|
/// active revision id says whether the content is still the same, and so
|
||||||
|
/// whether the size and digest a listing left out are still the ones known.
|
||||||
|
fn keep_revision_mtime(&self, node: &mut Node) {
|
||||||
|
let Some(entry) = self
|
||||||
|
.by_uid
|
||||||
|
.get(&node.uid)
|
||||||
|
.and_then(|ino| self.entries.get(ino))
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let (
|
||||||
|
NodeKind::File {
|
||||||
|
active_revision_id: Some(now),
|
||||||
|
claimed_size,
|
||||||
|
content_sha1,
|
||||||
|
..
|
||||||
|
},
|
||||||
|
NodeKind::File {
|
||||||
|
active_revision_id: Some(was),
|
||||||
|
claimed_size: size_was,
|
||||||
|
content_sha1: sha1_was,
|
||||||
|
..
|
||||||
|
},
|
||||||
|
) = (&mut node.kind, &entry.node.kind)
|
||||||
|
&& now == was
|
||||||
|
{
|
||||||
|
node.modification_time = entry.node.modification_time;
|
||||||
|
if claimed_size.is_none() {
|
||||||
|
*claimed_size = *size_was;
|
||||||
|
}
|
||||||
|
if content_sha1.is_none() {
|
||||||
|
content_sha1.clone_from(sha1_was);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Allocate (or reuse) a stable inode for a node, updating the hot-cache maps
|
/// Allocate (or reuse) a stable inode for a node, updating the hot-cache maps
|
||||||
/// only. Every caller owes the DB a write-through — see the callers below;
|
/// only. Every caller owes the DB a write-through — see the callers below;
|
||||||
/// the split exists so a batch can pay for one transaction instead of `n`.
|
/// the split exists so a batch can pay for one transaction instead of `n`.
|
||||||
@@ -784,11 +946,24 @@ impl State {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn intern_mem_with_access(&mut self, parent: u64, node: Node, access: Access) -> u64 {
|
fn intern_mem_with_access(&mut self, parent: u64, node: Node, access: Access) -> u64 {
|
||||||
|
self.intern_mem_at(parent, node, access, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`State::intern_mem_with_access`], with the inode a new entry takes when
|
||||||
|
/// the caller already has it ([`State::new_inos`]).
|
||||||
|
fn intern_mem_at(
|
||||||
|
&mut self,
|
||||||
|
parent: u64,
|
||||||
|
mut node: Node,
|
||||||
|
access: Access,
|
||||||
|
ino: Option<u64>,
|
||||||
|
) -> u64 {
|
||||||
if let Some(&ino) = self.by_uid.get(&node.uid) {
|
if let Some(&ino) = self.by_uid.get(&node.uid) {
|
||||||
let changed = self
|
let changed = self
|
||||||
.entries
|
.entries
|
||||||
.get(&ino)
|
.get(&ino)
|
||||||
.is_some_and(|entry| entry.access != access);
|
.is_some_and(|entry| entry.access != access);
|
||||||
|
self.keep_open_write_size(ino, &mut node);
|
||||||
if let Some(e) = self.entries.get_mut(&ino) {
|
if let Some(e) = self.entries.get_mut(&ino) {
|
||||||
e.node = node;
|
e.node = node;
|
||||||
e.parent = parent;
|
e.parent = parent;
|
||||||
@@ -801,8 +976,13 @@ impl State {
|
|||||||
}
|
}
|
||||||
return ino;
|
return ino;
|
||||||
}
|
}
|
||||||
let ino = self.next_ino;
|
let ino = match ino {
|
||||||
self.next_ino += 1;
|
Some(ino) => ino,
|
||||||
|
None => self
|
||||||
|
.new_inos(&[&node])
|
||||||
|
.remove(&node.uid)
|
||||||
|
.unwrap_or_default(),
|
||||||
|
};
|
||||||
self.by_uid.insert(node.uid.clone(), ino);
|
self.by_uid.insert(node.uid.clone(), ino);
|
||||||
self.entries.insert(
|
self.entries.insert(
|
||||||
ino,
|
ino,
|
||||||
@@ -819,6 +999,41 @@ impl State {
|
|||||||
ino
|
ino
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The inodes for nodes that have no entry yet, from their local ids, in
|
||||||
|
/// one transaction. Should the database fail, they come from
|
||||||
|
/// [`State::next_ino`] instead, so the mount keeps working, with inodes
|
||||||
|
/// that last only this run. So do the inodes of virtual folders, which are
|
||||||
|
/// never stored.
|
||||||
|
fn new_inos(&mut self, nodes: &[&Node]) -> HashMap<NodeUid, u64> {
|
||||||
|
let (virtual_nodes, stored): (Vec<&Node>, Vec<&Node>) = nodes
|
||||||
|
.iter()
|
||||||
|
.partition(|node| crate::r#virtual::is_virtual_uid(&node.uid));
|
||||||
|
let mut inos = match self.db.lids_for(&stored) {
|
||||||
|
Ok(lids) => stored
|
||||||
|
.iter()
|
||||||
|
.zip(lids)
|
||||||
|
.map(|(node, lid)| (node.uid.clone(), lid_ino(lid)))
|
||||||
|
.collect(),
|
||||||
|
Err(error) => {
|
||||||
|
warn!(%error, "no local ids for new inodes; numbering them for this run");
|
||||||
|
self.run_inos(&stored)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
inos.extend(self.run_inos(&virtual_nodes));
|
||||||
|
inos
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_inos(&mut self, nodes: &[&Node]) -> HashMap<NodeUid, u64> {
|
||||||
|
nodes
|
||||||
|
.iter()
|
||||||
|
.map(|node| {
|
||||||
|
let ino = self.next_ino;
|
||||||
|
self.next_ino += 1;
|
||||||
|
(node.uid.clone(), ino)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Decrement lookup count for an inode and prune if lookup_count == 0 && open_count == 0 && unlinked.
|
/// Decrement lookup count for an inode and prune if lookup_count == 0 && open_count == 0 && unlinked.
|
||||||
pub(crate) fn forget_lookup(&mut self, ino: u64, nlookup: u64) -> Option<(u64, String)> {
|
pub(crate) fn forget_lookup(&mut self, ino: u64, nlookup: u64) -> Option<(u64, String)> {
|
||||||
if let Some(entry) = self.entries.get_mut(&ino) {
|
if let Some(entry) = self.entries.get_mut(&ino) {
|
||||||
@@ -833,6 +1048,23 @@ impl State {
|
|||||||
|
|
||||||
/// Allocate (or reuse) a stable inode for a node that came *from* the
|
/// Allocate (or reuse) a stable inode for a node that came *from* the
|
||||||
/// database, which is why nothing is written back.
|
/// database, which is why nothing is written back.
|
||||||
|
/// Carry the size and mtime of a file open for writing over onto `node`,
|
||||||
|
/// which is about to replace its entry.
|
||||||
|
///
|
||||||
|
/// The open handle is ahead of anything Drive can report until release
|
||||||
|
/// queues it. Taking the remote's size shrank a SQLite database to its empty
|
||||||
|
/// create while SQLite held it open, and the kernel then capped every read
|
||||||
|
/// at that size (docs/BUGS.md B109).
|
||||||
|
pub(crate) fn keep_open_write_size(&self, ino: u64, node: &mut Node) {
|
||||||
|
let (Some(aw), Some(entry)) = (self.active_writes.get(&ino), self.entries.get(&ino)) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let NodeKind::File { claimed_size, .. } = &mut node.kind {
|
||||||
|
*claimed_size = Some(aw.len as i64);
|
||||||
|
node.modification_time = entry.node.modification_time;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn intern_from_db(&mut self, parent: u64, node: Node) -> u64 {
|
pub(crate) fn intern_from_db(&mut self, parent: u64, node: Node) -> u64 {
|
||||||
self.intern_mem(parent, node)
|
self.intern_mem(parent, node)
|
||||||
}
|
}
|
||||||
@@ -842,11 +1074,27 @@ impl State {
|
|||||||
///
|
///
|
||||||
/// This is what keeps `ls` on a large folder quick: one commit for the
|
/// This is what keeps `ls` on a large folder quick: one commit for the
|
||||||
/// listing, rather than one autocommit — and one fsync — per child.
|
/// listing, rather than one autocommit — and one fsync — per child.
|
||||||
pub(crate) fn intern_batch(&mut self, parent: u64, nodes: Vec<Node>) -> Vec<u64> {
|
pub(crate) fn intern_batch(&mut self, parent: u64, mut nodes: Vec<Node>) -> Vec<u64> {
|
||||||
|
for node in &mut nodes {
|
||||||
|
self.keep_revision_mtime(node);
|
||||||
|
}
|
||||||
self.outbox.push(DbWrite::Upsert(nodes.clone()));
|
self.outbox.push(DbWrite::Upsert(nodes.clone()));
|
||||||
|
let fresh: Vec<&Node> = nodes
|
||||||
|
.iter()
|
||||||
|
.filter(|node| !self.by_uid.contains_key(&node.uid))
|
||||||
|
.collect();
|
||||||
|
let mut inos = if fresh.is_empty() {
|
||||||
|
HashMap::new()
|
||||||
|
} else {
|
||||||
|
self.new_inos(&fresh)
|
||||||
|
};
|
||||||
nodes
|
nodes
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|node| self.intern_mem(parent, node))
|
.map(|node| {
|
||||||
|
let access = self.access_for_node(parent, &node);
|
||||||
|
let ino = inos.remove(&node.uid);
|
||||||
|
self.intern_mem_at(parent, node, access, ino)
|
||||||
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -950,7 +1198,7 @@ impl State {
|
|||||||
/// re-enumerate, which is the cheaper way to stay honest about what the
|
/// re-enumerate, which is the cheaper way to stay honest about what the
|
||||||
/// server did. A queued rename cannot: re-enumerating needs the network, and
|
/// server did. A queued rename cannot: re-enumerating needs the network, and
|
||||||
/// the server has not been told yet in any case — so this *is* the tree's
|
/// the server has not been told yet in any case — so this *is* the tree's
|
||||||
/// new truth until the op drains (offline.md Phase 3b).
|
/// new truth until the op drains.
|
||||||
pub(crate) fn rename_in_place(
|
pub(crate) fn rename_in_place(
|
||||||
&mut self,
|
&mut self,
|
||||||
ino: u64,
|
ino: u64,
|
||||||
@@ -1069,7 +1317,7 @@ impl State {
|
|||||||
/// created offline, nothing at all), so this row is the only record that the
|
/// created offline, nothing at all), so this row is the only record that the
|
||||||
/// file is as long as the caller was told it is. Without it a restart serves
|
/// file is as long as the caller was told it is. Without it a restart serves
|
||||||
/// the stale size and the file reads as truncated — or empty — while its
|
/// the stale size and the file reads as truncated — or empty — while its
|
||||||
/// bytes sit safely in staging (offline.md Phase 3).
|
/// bytes sit safely in staging.
|
||||||
/// Returns the node to persist rather than queueing it in the outbox, and is
|
/// Returns the node to persist rather than queueing it in the outbox, and is
|
||||||
/// the one mutation here whose write-through the caller must do itself:
|
/// the one mutation here whose write-through the caller must do itself:
|
||||||
/// failure has to be reported, not logged. Dropping the row silently means
|
/// failure has to be reported, not logged. Dropping the row silently means
|
||||||
@@ -1088,6 +1336,13 @@ mod tests {
|
|||||||
use proton_drive_rs::ShareMembership;
|
use proton_drive_rs::ShareMembership;
|
||||||
use proton_drive_rs::proton_sdk::ids::{LinkId, ShareId, ShareMembershipId, VolumeId};
|
use proton_drive_rs::proton_sdk::ids::{LinkId, ShareId, ShareMembershipId, VolumeId};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_inode_gives_back_the_lid_it_was_made_from() {
|
||||||
|
assert_eq!(ino_lid(lid_ino(7)), Some(7));
|
||||||
|
assert_eq!(ino_lid(crate::ROOT_INO), None);
|
||||||
|
assert_eq!(ino_lid(FALLBACK_INO + 3), None);
|
||||||
|
}
|
||||||
|
|
||||||
fn uid(link: &str) -> NodeUid {
|
fn uid(link: &str) -> NodeUid {
|
||||||
NodeUid::new(VolumeId::from("vol"), LinkId::from(link))
|
NodeUid::new(VolumeId::from("vol"), LinkId::from(link))
|
||||||
}
|
}
|
||||||
@@ -1164,7 +1419,7 @@ mod tests {
|
|||||||
let dir = TempDir::new();
|
let dir = TempDir::new();
|
||||||
let db = Db::open(&dir.0.join("cache.db")).unwrap();
|
let db = Db::open(&dir.0.join("cache.db")).unwrap();
|
||||||
let share_access = db.all_share_access().unwrap();
|
let share_access = db.all_share_access().unwrap();
|
||||||
let st = State::new(Arc::new(db), share_access, 1);
|
let st = State::new(Arc::new(db), share_access);
|
||||||
(st, dir)
|
(st, dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1182,6 +1437,36 @@ mod tests {
|
|||||||
(st, dir, src, dst)
|
(st, dir, src, dst)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A node's inode comes from its row's local id, so it is the same in
|
||||||
|
/// every mount, after the hot cache drops it, and after a restart.
|
||||||
|
#[test]
|
||||||
|
fn a_node_keeps_its_inode_across_mounts_evictions_and_restarts() {
|
||||||
|
let (mut st, _dir) = state();
|
||||||
|
let inos = st.intern_batch(
|
||||||
|
0,
|
||||||
|
vec![
|
||||||
|
node("a", "root", "a.txt", false),
|
||||||
|
node("b", "root", "b.txt", false),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
st.flush_outbox();
|
||||||
|
let db = st.db.clone();
|
||||||
|
assert_ne!(inos[0], inos[1]);
|
||||||
|
assert!(!inos.contains(&crate::ROOT_INO));
|
||||||
|
|
||||||
|
let mut other = State::new(db.clone(), HashMap::new());
|
||||||
|
assert_eq!(other.intern(0, node("b", "root", "b.txt", false)), inos[1]);
|
||||||
|
|
||||||
|
st.forget_mem(&uid("a"));
|
||||||
|
assert_eq!(st.intern(0, node("a", "root", "a.txt", false)), inos[0]);
|
||||||
|
|
||||||
|
let mut restarted = State::new(db, HashMap::new());
|
||||||
|
assert_eq!(
|
||||||
|
restarted.intern_batch(0, vec![node("a", "root", "a.txt", false)]),
|
||||||
|
[inos[0]]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Audit A5. Moving a file out of a folder leaves that folder's DB row
|
/// Audit A5. Moving a file out of a folder leaves that folder's DB row
|
||||||
/// claiming a complete listing, so once the hot cache drops it, remote
|
/// claiming a complete listing, so once the hot cache drops it, remote
|
||||||
/// changes under it stop being seen. `relocate` has to clear both sides.
|
/// changes under it stop being seen. `relocate` has to clear both sides.
|
||||||
@@ -1247,7 +1532,7 @@ mod tests {
|
|||||||
let root = uid("root").to_string();
|
let root = uid("root").to_string();
|
||||||
let state = parking_lot::Mutex::new(st);
|
let state = parking_lot::Mutex::new(st);
|
||||||
{
|
{
|
||||||
let mut guard = lock_state(&state, &db);
|
let mut guard = StateGuard::new(state.lock(), &db);
|
||||||
guard.intern(0, node("root", "none", "My Files", true));
|
guard.intern(0, node("root", "none", "My Files", true));
|
||||||
assert!(
|
assert!(
|
||||||
db.node_by_uid(&root).unwrap().is_none(),
|
db.node_by_uid(&root).unwrap().is_none(),
|
||||||
@@ -1490,6 +1775,7 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
st.next_ino = crate::ROOT_INO + nodes.len() as u64;
|
st.next_ino = crate::ROOT_INO + nodes.len() as u64;
|
||||||
|
st.roots.insert(crate::ROOT_INO);
|
||||||
// Reverse order so entries are inserted before their parents, matching
|
// Reverse order so entries are inserted before their parents, matching
|
||||||
// the hydration path's non-topological materialization.
|
// the hydration path's non-topological materialization.
|
||||||
for (i, node) in nodes.iter().enumerate().rev() {
|
for (i, node) in nodes.iter().enumerate().rev() {
|
||||||
@@ -1863,4 +2149,137 @@ mod tests {
|
|||||||
let shared = st.intern(owned_root, shared);
|
let shared = st.intern(owned_root, shared);
|
||||||
assert_eq!(st.entries[&shared].access, Access::Viewer);
|
assert_eq!(st.entries[&shared].access, Access::Viewer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn at_revision(mut node: Node, revision: &str, mtime: i64) -> Node {
|
||||||
|
node.modification_time = mtime;
|
||||||
|
if let NodeKind::File {
|
||||||
|
active_revision_id, ..
|
||||||
|
} = &mut node.kind
|
||||||
|
{
|
||||||
|
*active_revision_id = Some(revision.into());
|
||||||
|
}
|
||||||
|
node
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_renamed_file_keeps_its_mtime_until_its_content_changes() {
|
||||||
|
let (mut st, _dir) = state();
|
||||||
|
let root = st.intern(0, node("root", "none", "My Files", true));
|
||||||
|
let mut file = node("file", "root", "a.txt", false);
|
||||||
|
if let NodeKind::File {
|
||||||
|
claimed_size,
|
||||||
|
total_size_on_storage,
|
||||||
|
..
|
||||||
|
} = &mut file.kind
|
||||||
|
{
|
||||||
|
*claimed_size = Some(7);
|
||||||
|
*total_size_on_storage = 519;
|
||||||
|
}
|
||||||
|
let ino = st.intern(root, at_revision(file.clone(), "r1", 100));
|
||||||
|
// Drive stamps the link on a rename; the content is still r1.
|
||||||
|
let mut renamed = at_revision(file.clone(), "r1", 160);
|
||||||
|
renamed.name = "b.txt".into();
|
||||||
|
st.intern_batch(root, vec![renamed]);
|
||||||
|
assert_eq!(st.entries[&ino].node.modification_time, 100);
|
||||||
|
assert_eq!(st.entries[&ino].node.name, "b.txt");
|
||||||
|
// A listing that leaves the size out still describes revision r1.
|
||||||
|
let mut bare = at_revision(file.clone(), "r1", 170);
|
||||||
|
assert_eq!(crate::node_size(&st.entries[&ino].node), 7);
|
||||||
|
if let NodeKind::File { claimed_size, .. } = &mut bare.kind {
|
||||||
|
*claimed_size = None;
|
||||||
|
}
|
||||||
|
st.intern(root, bare);
|
||||||
|
assert_eq!(st.entries[&ino].node.modification_time, 100);
|
||||||
|
assert_eq!(crate::node_size(&st.entries[&ino].node), 7);
|
||||||
|
st.intern(root, at_revision(file, "r2", 200));
|
||||||
|
assert_eq!(st.entries[&ino].node.modification_time, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle(len: u64, base_size: u64) -> WriteHandle {
|
||||||
|
let path = std::env::temp_dir().join(format!("pdfs-handle-test-{}", std::process::id()));
|
||||||
|
let file = File::create(&path).unwrap();
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
WriteHandle {
|
||||||
|
ino: 2,
|
||||||
|
uid: uid("file"),
|
||||||
|
file: Arc::new(file),
|
||||||
|
path,
|
||||||
|
written: Intervals::default(),
|
||||||
|
len,
|
||||||
|
base_size,
|
||||||
|
base_mtime: 0,
|
||||||
|
base_revision_id: None,
|
||||||
|
dirty: false,
|
||||||
|
open_count: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A file created offline is written while its queued create lands. The
|
||||||
|
/// placeholder had no revision, so the handle takes the empty file the
|
||||||
|
/// create made as its base (docs/BUGS.md B113).
|
||||||
|
#[test]
|
||||||
|
fn a_handle_on_a_placeholder_is_based_on_the_create_that_landed() {
|
||||||
|
let mut h = handle(0, 0);
|
||||||
|
assert_eq!(h.base_revision_id, None);
|
||||||
|
let mut landed = node("real", "root", "f.mkv", false);
|
||||||
|
landed.modification_time = 160;
|
||||||
|
if let NodeKind::File {
|
||||||
|
active_revision_id, ..
|
||||||
|
} = &mut landed.kind
|
||||||
|
{
|
||||||
|
*active_revision_id = Some("empty".into());
|
||||||
|
}
|
||||||
|
h.rebase_onto(&landed);
|
||||||
|
assert_eq!(h.base_mtime, 160);
|
||||||
|
assert_eq!(h.base_size, 0);
|
||||||
|
assert_eq!(h.base_revision_id.as_deref(), Some("empty"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_listing_does_not_shrink_a_file_open_for_writing() {
|
||||||
|
let (mut st, _dir) = state();
|
||||||
|
let ino = st.intern(0, node("file", "root", "workload.db", false));
|
||||||
|
let mut h = handle(16384, 0);
|
||||||
|
h.ino = ino;
|
||||||
|
st.active_writes.insert(ino, h);
|
||||||
|
st.set_size(ino, 16384);
|
||||||
|
st.touch_mtime(ino, 500);
|
||||||
|
|
||||||
|
// The remote still holds the empty file the create sealed.
|
||||||
|
st.intern(0, node("file", "root", "workload.db", false));
|
||||||
|
assert_eq!(crate::node_size(&st.entries[&ino].node), 16384);
|
||||||
|
assert_eq!(st.entries[&ino].node.modification_time, 500);
|
||||||
|
|
||||||
|
// Once the handle is gone the remote is authoritative again.
|
||||||
|
st.active_writes.remove(&ino);
|
||||||
|
st.intern(0, node("file", "root", "workload.db", false));
|
||||||
|
assert_eq!(crate::node_size(&st.entries[&ino].node), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_write_past_the_end_authors_the_hole_it_leaves() {
|
||||||
|
// Opened over a 100-byte base, shrunk to 10, then written at 50: bytes
|
||||||
|
// 10..50 are zeros now, not the base's bytes 10..50.
|
||||||
|
let mut h = handle(100, 100);
|
||||||
|
h.written.clip(10);
|
||||||
|
h.len = 10;
|
||||||
|
h.record_write(50, 60);
|
||||||
|
assert_eq!(h.len, 60);
|
||||||
|
assert_eq!(
|
||||||
|
h.written.segments(0, 60),
|
||||||
|
vec![(0, 10, false), (10, 60, true)],
|
||||||
|
"only the head still comes from the base"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_write_inside_the_file_leaves_the_rest_to_the_base() {
|
||||||
|
let mut h = handle(100, 100);
|
||||||
|
h.record_write(40, 50);
|
||||||
|
assert_eq!(h.len, 100);
|
||||||
|
assert_eq!(
|
||||||
|
h.written.segments(0, 100),
|
||||||
|
vec![(0, 40, false), (40, 50, true), (50, 100, false)]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,7 +68,7 @@ impl Core {
|
|||||||
/// docs for the identical/divergent policy.
|
/// docs for the identical/divergent policy.
|
||||||
pub(crate) fn run_conflict_sweep_loop(&self) {
|
pub(crate) fn run_conflict_sweep_loop(&self) {
|
||||||
// Both waits are interruptible, so teardown does not have to sit out a
|
// Both waits are interruptible, so teardown does not have to sit out a
|
||||||
// five-minute interval to join this thread (bugs.md B44).
|
// five-minute interval to join this thread (`docs/BUGS.md` B44).
|
||||||
if !self.shutdown.sleep(CONFLICT_SWEEP_WARMUP) {
|
if !self.shutdown.sleep(CONFLICT_SWEEP_WARMUP) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -109,6 +109,7 @@ impl Core {
|
|||||||
by_parent_name.insert((parent, node.name.as_str()), node);
|
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);
|
let online = self.online.load(std::sync::atomic::Ordering::Relaxed);
|
||||||
for node in &nodes {
|
for node in &nodes {
|
||||||
if node.trashed || node.is_folder() {
|
if node.trashed || node.is_folder() {
|
||||||
@@ -132,7 +133,16 @@ impl Core {
|
|||||||
self.remove_conflict_copy(node, &base);
|
self.remove_conflict_copy(node, &base);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SweepAction::Flag(detail) => self.flag_conflict(&node.uid, &node.name, detail),
|
// Only flag what the conflict listing can show. Anything else
|
||||||
|
// would sit in the feed as "resolved" with no way to act on
|
||||||
|
// it (`docs/BUGS.md` B99).
|
||||||
|
SweepAction::Flag(detail) => {
|
||||||
|
if self.conflict_path(&roots, &node.uid).is_some() {
|
||||||
|
self.flag_conflict(&node.uid, &node.name, detail);
|
||||||
|
} else {
|
||||||
|
debug!(uid = %node.uid, name = %node.name, "conflict sweep: copy is outside every location; not flagged");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -155,7 +165,7 @@ impl Core {
|
|||||||
}
|
}
|
||||||
match self
|
match self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.trash_nodes(std::slice::from_ref(uid)))
|
.block_on(self.drive.trash_nodes(std::slice::from_ref(uid)))
|
||||||
.and_then(batch::into_unit)
|
.and_then(batch::into_unit)
|
||||||
{
|
{
|
||||||
Ok(()) => {}
|
Ok(()) => {}
|
||||||
@@ -165,10 +175,9 @@ impl Core {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Every mount, not just the primary: a conflict copy inside a sync
|
// Leaving a conflict copy hooked up in the tree keeps a node we have
|
||||||
// folder lives in that fork's inode space, and leaving it hooked up
|
// just trashed remotely visible and readable in whichever mount shows
|
||||||
// there keeps a node we have just trashed remotely visible and
|
// it (`docs/BUGS.md` B74).
|
||||||
// readable there (`docs/BUGS.md` B74).
|
|
||||||
self.for_each_state(|st| {
|
self.for_each_state(|st| {
|
||||||
st.forget_or_unlink(uid);
|
st.forget_or_unlink(uid);
|
||||||
});
|
});
|
||||||
@@ -261,7 +270,7 @@ impl Core {
|
|||||||
/// is tracked in that fork's `State` — so consulting `self.state` alone
|
/// 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
|
/// answered "idle" for every such file and let the sweep delete one with a
|
||||||
/// writer attached (`docs/BUGS.md` B74). Busy anywhere means busy.
|
/// writer attached (`docs/BUGS.md` B74). Busy anywhere means busy.
|
||||||
fn is_busy(&self, uid: &NodeUid) -> bool {
|
pub(crate) fn is_busy(&self, uid: &NodeUid) -> bool {
|
||||||
let mut busy = false;
|
let mut busy = false;
|
||||||
self.for_each_state(|st| {
|
self.for_each_state(|st| {
|
||||||
let Some(&ino) = st.by_uid.get(uid) else {
|
let Some(&ino) = st.by_uid.get(uid) else {
|
||||||
@@ -273,12 +282,21 @@ impl Core {
|
|||||||
busy
|
busy
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Surface a conflict copy that needs the user's attention, at most once per
|
/// Surface a conflict copy that needs the user's attention, once. The
|
||||||
/// daemon run so the activity feed is not spammed each pass.
|
/// 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) {
|
fn flag_conflict(&self, uid: &NodeUid, name: &str, detail: String) {
|
||||||
if !self.conflict_notified.lock().insert(uid.clone()) {
|
if !self.conflict_notified.lock().insert(uid.clone()) {
|
||||||
return;
|
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");
|
debug!(%uid, name, detail, "conflict sweep: flagged divergent conflict copy");
|
||||||
self.log_activity(ActivityKind::Conflict, name, detail, false);
|
self.log_activity(ActivityKind::Conflict, name, detail, false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ pub(crate) enum SyncMsg {
|
|||||||
/// Re-read the folder set and adjust the filesystem watches (after add/remove).
|
/// Re-read the folder set and adjust the filesystem watches (after add/remove).
|
||||||
Rewatch,
|
Rewatch,
|
||||||
/// The daemon is tearing down: finish the current pass and end the engine
|
/// The daemon is tearing down: finish the current pass and end the engine
|
||||||
/// thread, so the mount can join it (bugs.md B44).
|
/// thread, so the mount can join it (`docs/BUGS.md` B44).
|
||||||
Stop,
|
Stop,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,9 +57,10 @@ pub(crate) fn spawn(core: Core, rx: Receiver<SyncMsg>) -> Option<std::thread::Jo
|
|||||||
fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
|
fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
|
||||||
// Paths the watcher currently covers, mapped to their folder id, so an event
|
// Paths the watcher currently covers, mapped to their folder id, so an event
|
||||||
// path can be resolved back to the folder it belongs to.
|
// path can be resolved back to the folder it belongs to.
|
||||||
let watched: Mutex<Vec<(PathBuf, i64)>> = Mutex::new(Vec::new());
|
let watched: Arc<Mutex<Vec<(PathBuf, i64)>>> = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
|
||||||
let tx_events = core.sync_tx.clone();
|
let tx_events = core.sync_tx.clone();
|
||||||
|
let watched_events = watched.clone();
|
||||||
let mut watcher =
|
let mut watcher =
|
||||||
match notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
|
match notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
|
||||||
let Ok(event) = res else { return };
|
let Ok(event) = res else { return };
|
||||||
@@ -67,7 +68,8 @@ fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
|
|||||||
if matches!(event.kind, notify::EventKind::Access(_)) {
|
if matches!(event.kind, notify::EventKind::Access(_)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let _ = tx_events.send(SyncMsg::ReconcileAll);
|
let msg = event_target(&watched_events.lock(), &event.paths);
|
||||||
|
let _ = tx_events.send(msg);
|
||||||
}) {
|
}) {
|
||||||
Ok(w) => w,
|
Ok(w) => w,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -115,6 +117,9 @@ fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
|
|||||||
reconcile_all(&core);
|
reconcile_all(&core);
|
||||||
} else {
|
} else {
|
||||||
for id in ids {
|
for id in ids {
|
||||||
|
if core.sync_paused() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
if let Ok(Some(folder)) = core.db.sync_folder_get(id) {
|
if let Ok(Some(folder)) = core.db.sync_folder_get(id) {
|
||||||
core.reconcile_folder(&folder);
|
core.reconcile_folder(&folder);
|
||||||
}
|
}
|
||||||
@@ -151,6 +156,9 @@ fn poll_only_loop(core: &Core, rx: Receiver<SyncMsg>) {
|
|||||||
reconcile_all(core);
|
reconcile_all(core);
|
||||||
} else {
|
} else {
|
||||||
for id in ids {
|
for id in ids {
|
||||||
|
if core.sync_paused() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
if let Ok(Some(folder)) = core.db.sync_folder_get(id) {
|
if let Ok(Some(folder)) = core.db.sync_folder_get(id) {
|
||||||
core.reconcile_folder(&folder);
|
core.reconcile_folder(&folder);
|
||||||
}
|
}
|
||||||
@@ -230,8 +238,14 @@ pub(super) fn classify(
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reconcile every mirror folder in turn.
|
/// Reconcile every mirror folder in turn. Skipped while sync is paused: the
|
||||||
|
/// resume sends a fresh [`SyncMsg::ReconcileAll`], which walks everything the
|
||||||
|
/// watcher reported meanwhile.
|
||||||
fn reconcile_all(core: &Core) {
|
fn reconcile_all(core: &Core) {
|
||||||
|
if core.sync_paused() {
|
||||||
|
debug!("sync: paused, skipping reconcile");
|
||||||
|
return;
|
||||||
|
}
|
||||||
let folders = match core.db.sync_folder_list() {
|
let folders = match core.db.sync_folder_list() {
|
||||||
Ok(f) => f,
|
Ok(f) => f,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -244,8 +258,31 @@ fn reconcile_all(core: &Core) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The reconcile a watcher event asks for: the one mirror folder all its paths
|
||||||
|
/// lie in, so an edit in one folder does not walk every other one too. An event
|
||||||
|
/// that spans folders, or that no watched folder claims (a watch list a moment
|
||||||
|
/// out of date), falls back to every folder.
|
||||||
|
pub(super) fn event_target(watched: &[(PathBuf, i64)], paths: &[PathBuf]) -> SyncMsg {
|
||||||
|
let mut ids = paths.iter().map(|path| {
|
||||||
|
watched
|
||||||
|
.iter()
|
||||||
|
.filter(|(root, _)| path.starts_with(root))
|
||||||
|
// The deepest root, should one mirror folder sit inside another.
|
||||||
|
.max_by_key(|(root, _)| root.components().count())
|
||||||
|
.map(|(_, id)| *id)
|
||||||
|
});
|
||||||
|
match ids.next() {
|
||||||
|
Some(Some(first)) if ids.all(|id| id == Some(first)) => SyncMsg::Reconcile(first),
|
||||||
|
_ => SyncMsg::ReconcileAll,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Bring the filesystem watches in line with the current mirror-folder set:
|
/// Bring the filesystem watches in line with the current mirror-folder set:
|
||||||
/// watch newly-added folders, drop removed ones.
|
/// watch newly-added folders, drop removed ones.
|
||||||
|
///
|
||||||
|
/// The watch calls run on a copy, outside the lock: the watcher's event
|
||||||
|
/// callback takes the same lock, and a backend that hands a watch request to
|
||||||
|
/// its event thread and waits for the answer would deadlock against it.
|
||||||
fn rewatch(
|
fn rewatch(
|
||||||
core: &Core,
|
core: &Core,
|
||||||
watcher: &mut notify::RecommendedWatcher,
|
watcher: &mut notify::RecommendedWatcher,
|
||||||
@@ -262,7 +299,7 @@ fn rewatch(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let mut have = watched.lock();
|
let mut have = watched.lock().clone();
|
||||||
// Drop watches no longer wanted.
|
// Drop watches no longer wanted.
|
||||||
have.retain(|(path, _)| {
|
have.retain(|(path, _)| {
|
||||||
if want.iter().any(|(p, _)| p == path) {
|
if want.iter().any(|(p, _)| p == path) {
|
||||||
@@ -282,6 +319,7 @@ fn rewatch(
|
|||||||
Err(e) => warn!(path = %path.display(), error = %e, "sync: watch failed"),
|
Err(e) => warn!(path = %path.display(), error = %e, "sync: watch failed"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
*watched.lock() = have;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- reconcile ------------------------------------------------------------
|
// ---- reconcile ------------------------------------------------------------
|
||||||
@@ -20,7 +20,7 @@ impl LocalSig {
|
|||||||
/// Nanoseconds decide it when both sides have them; otherwise the comparison
|
/// Nanoseconds decide it when both sides have them; otherwise the comparison
|
||||||
/// falls back to whole seconds, which is what a baseline row written before
|
/// falls back to whole seconds, which is what a baseline row written before
|
||||||
/// schema 28 — or a filesystem that reports no sub-second time — can support
|
/// schema 28 — or a filesystem that reports no sub-second time — can support
|
||||||
/// (bugs.md B25).
|
/// (`docs/BUGS.md` B25).
|
||||||
pub(super) fn same_content(&self, other: &Self) -> bool {
|
pub(super) fn same_content(&self, other: &Self) -> bool {
|
||||||
if self.size != other.size {
|
if self.size != other.size {
|
||||||
return false;
|
return false;
|
||||||
@@ -98,6 +98,11 @@ pub(super) struct Outcome {
|
|||||||
pub(super) errors: usize,
|
pub(super) errors: usize,
|
||||||
/// Files skipped because another process held them open for writing.
|
/// Files skipped because another process held them open for writing.
|
||||||
pub(super) deferred: usize,
|
pub(super) deferred: usize,
|
||||||
|
/// Paths the local scan could not read, left out of the pass. Not part of
|
||||||
|
/// [`is_empty`](Self::is_empty) or the summary: an unreadable folder stays
|
||||||
|
/// unreadable from one poll to the next, so the pass reports it once, when
|
||||||
|
/// the set changes, rather than on every poll.
|
||||||
|
pub(super) unscanned: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Outcome {
|
impl Outcome {
|
||||||
@@ -108,6 +113,7 @@ impl Outcome {
|
|||||||
Applied::Uploaded => self.uploaded += 1,
|
Applied::Uploaded => self.uploaded += 1,
|
||||||
Applied::Downloaded => self.downloaded += 1,
|
Applied::Downloaded => self.downloaded += 1,
|
||||||
Applied::Conflict => self.conflicts += 1,
|
Applied::Conflict => self.conflicts += 1,
|
||||||
|
Applied::Vanished => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +166,7 @@ pub(super) enum Pending {
|
|||||||
/// was there. A download can run for minutes, so the apply step revalidates
|
/// was there. A download can run for minutes, so the apply step revalidates
|
||||||
/// against it immediately before it renames over the destination: a writer
|
/// against it immediately before it renames over the destination: a writer
|
||||||
/// that finished an edit in the meantime must not be overwritten without a
|
/// that finished an edit in the meantime must not be overwritten without a
|
||||||
/// conflict copy (bugs.md B39).
|
/// conflict copy (`docs/BUGS.md` B39).
|
||||||
Download {
|
Download {
|
||||||
rel: String,
|
rel: String,
|
||||||
uid: NodeUid,
|
uid: NodeUid,
|
||||||
@@ -226,6 +232,10 @@ pub(super) enum PassAbort {
|
|||||||
Interrupted,
|
Interrupted,
|
||||||
/// The pass could not establish its diff (a walk or the baseline load failed).
|
/// The pass could not establish its diff (a walk or the baseline load failed).
|
||||||
Failed(String),
|
Failed(String),
|
||||||
|
/// The daemon is stopping. What the pass sent before the stop has landed or
|
||||||
|
/// will be cancelled with the runtime; nothing more is sent, and the next
|
||||||
|
/// start's pass picks up from the baseline.
|
||||||
|
Stopped,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<String> for PassAbort {
|
impl From<String> for PassAbort {
|
||||||
|
|||||||
@@ -90,6 +90,96 @@ pub(super) fn filter_baseline<'a, B>(
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The paths a local scan left out because it could not read them: a folder
|
||||||
|
/// it has no permission for, an entry that vanished between the listing and
|
||||||
|
/// its `stat`, a name that is not UTF-8.
|
||||||
|
///
|
||||||
|
/// A left-out path is not a deleted one. A pass drops everything an entry
|
||||||
|
/// covers from classification, exactly as it drops ignored paths, so a
|
||||||
|
/// baseline row under an unreadable folder is neither read as a local deletion
|
||||||
|
/// nor answered with a download into a folder the pass cannot write. Failing
|
||||||
|
/// the whole pass instead is what B55 did, and it left one root-owned folder
|
||||||
|
/// stopping every other file in the tree from syncing (B196).
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(super) struct Unscanned {
|
||||||
|
covered: HashSet<String>,
|
||||||
|
/// The subset worth telling the user about, with why: an entry that only
|
||||||
|
/// vanished mid-scan is not, since the next pass sees it settled.
|
||||||
|
unreadable: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Unscanned {
|
||||||
|
/// Leave out `rel`, which vanished while the scan looked at it.
|
||||||
|
pub(super) fn vanished(&mut self, rel: String) {
|
||||||
|
self.covered.insert(rel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Leave out `rel`, which cannot be read for `reason`.
|
||||||
|
pub(super) fn unreadable(&mut self, rel: String, reason: &str) {
|
||||||
|
self.unreadable.push(format!("{rel} ({reason})"));
|
||||||
|
self.covered.insert(rel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `rel` is a left-out path or lies under one.
|
||||||
|
pub(super) fn covers(&self, rel: &str) -> bool {
|
||||||
|
if self.covered.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut at = rel;
|
||||||
|
loop {
|
||||||
|
if self.covered.contains(at) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
match at.rfind('/') {
|
||||||
|
Some(i) => at = &at[..i],
|
||||||
|
None => return false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The unreadable paths with their reasons, sorted, for the activity feed.
|
||||||
|
pub(super) fn report(&self) -> Vec<String> {
|
||||||
|
let mut lines = self.unreadable.clone();
|
||||||
|
lines.sort_unstable();
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The baseline paths a pass can see: [`filter_baseline`], minus everything
|
||||||
|
/// under a path the scan could not read. This is the set [`guard_local_wipe`]
|
||||||
|
/// checks and the set a push pass reads deletions from, since a path outside
|
||||||
|
/// it is absent from the local walk by rule or by failure, never by loss.
|
||||||
|
pub(super) fn tracked_baseline<'a, B>(
|
||||||
|
baseline: &'a HashMap<String, B>,
|
||||||
|
rules: &IgnoreRules,
|
||||||
|
unscanned: &Unscanned,
|
||||||
|
) -> HashMap<String, &'a B> {
|
||||||
|
let mut tracked = filter_baseline(baseline, rules);
|
||||||
|
tracked.retain(|rel, _| !unscanned.covers(rel));
|
||||||
|
tracked
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The baseline paths a push pass has to trash on Drive: tracked, and gone
|
||||||
|
/// from the local walk. Shallowest first, so a trashed folder takes its
|
||||||
|
/// children with it.
|
||||||
|
///
|
||||||
|
/// Built from [`tracked_baseline`] rather than from the raw baseline. Reading
|
||||||
|
/// it raw trashed every synced path that later became ignored, because the walk
|
||||||
|
/// skips those, and the switch to on-demand that the push pass runs for then
|
||||||
|
/// deleted the local copy too (B197).
|
||||||
|
pub(super) fn local_deletions<B, L>(
|
||||||
|
tracked: &HashMap<String, &B>,
|
||||||
|
local: &HashMap<String, L>,
|
||||||
|
) -> Vec<String> {
|
||||||
|
let mut missing: Vec<String> = tracked
|
||||||
|
.keys()
|
||||||
|
.filter(|rel| !local.contains_key(*rel))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
missing.sort_by_key(|p| p.matches('/').count());
|
||||||
|
missing
|
||||||
|
}
|
||||||
|
|
||||||
/// Refuse to run a pass whose local side has vanished in its entirety.
|
/// Refuse to run a pass whose local side has vanished in its entirety.
|
||||||
///
|
///
|
||||||
/// When every baseline path is absent locally, the likely cause is an unavailable
|
/// When every baseline path is absent locally, the likely cause is an unavailable
|
||||||
@@ -218,7 +308,7 @@ pub(super) fn plan_file(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let local_changed = local.is_some_and(|item| {
|
let local_changed = local.is_some_and(|item| {
|
||||||
// Nanoseconds where both sides have them (bugs.md B25): whole seconds
|
// Nanoseconds where both sides have them (`docs/BUGS.md` B25): whole seconds
|
||||||
// could not see an edit made in the same second as the last sync that
|
// could not see an edit made in the same second as the last sync that
|
||||||
// left the file the same length, so it was never uploaded.
|
// left the file the same length, so it was never uploaded.
|
||||||
baseline.is_none_or(|base| !LocalSig::from(item).same_content(&LocalSig::from(base)))
|
baseline.is_none_or(|base| !LocalSig::from(item).same_content(&LocalSig::from(base)))
|
||||||
@@ -291,7 +381,7 @@ mod file_plan_tests {
|
|||||||
|
|
||||||
/// Whole seconds cannot see an edit made inside the same second that leaves the
|
/// Whole seconds cannot see an edit made inside the same second that leaves the
|
||||||
/// file the same length, so a mirror silently stopped uploading it. With a
|
/// file the same length, so a mirror silently stopped uploading it. With a
|
||||||
/// nanosecond time on both sides it is an ordinary local change (bugs.md B25).
|
/// nanosecond time on both sides it is an ordinary local change (`docs/BUGS.md` B25).
|
||||||
#[test]
|
#[test]
|
||||||
fn a_same_second_same_size_edit_is_still_a_local_change() {
|
fn a_same_second_same_size_edit_is_still_a_local_change() {
|
||||||
let mut base = baseline(10, 20, 30, 40);
|
let mut base = baseline(10, 20, 30, 40);
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
//! The `sd_notify` half of the service contract, without linking libsystemd.
|
||||||
|
//!
|
||||||
|
//! The protocol is one datagram of `KEY=VALUE` lines to the socket in
|
||||||
|
//! `$NOTIFY_SOCKET`. That is little enough to write directly, and writing it
|
||||||
|
//! directly keeps the daemon buildable on a machine without systemd headers —
|
||||||
|
//! everything here degrades to "no socket, do nothing" off systemd.
|
||||||
|
|
||||||
|
use std::os::unix::net::UnixDatagram;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
|
/// Send one notification. `false` when there is no manager to send it to, or the
|
||||||
|
/// send failed — a caller has nothing useful to do about either.
|
||||||
|
pub(crate) fn notify(message: &str) -> bool {
|
||||||
|
let Ok(socket_path) = std::env::var("NOTIFY_SOCKET") else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if socket_path.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Ok(socket) = UnixDatagram::unbound() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if let Some(name) = socket_path.strip_prefix('@') {
|
||||||
|
// Abstract namespace: the address is a NUL byte followed by the name,
|
||||||
|
// which `std`'s path-based API cannot express.
|
||||||
|
send_abstract(&socket, name, message)
|
||||||
|
} else {
|
||||||
|
socket.send_to(message.as_bytes(), &socket_path).is_ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `sendto` on an abstract-namespace address, which needs a hand-built
|
||||||
|
/// `sockaddr_un` because the first byte of the name is NUL.
|
||||||
|
fn send_abstract(socket: &UnixDatagram, name: &str, message: &str) -> bool {
|
||||||
|
use std::os::fd::AsRawFd;
|
||||||
|
|
||||||
|
let mut addr: libc::sockaddr_un = unsafe { std::mem::zeroed() };
|
||||||
|
addr.sun_family = libc::AF_UNIX as libc::sa_family_t;
|
||||||
|
let bytes = name.as_bytes();
|
||||||
|
// One byte for the leading NUL, and the path must still be addressable.
|
||||||
|
if bytes.len() + 1 > addr.sun_path.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (slot, byte) in addr.sun_path.iter_mut().skip(1).zip(bytes) {
|
||||||
|
*slot = *byte as libc::c_char;
|
||||||
|
}
|
||||||
|
let len = std::mem::size_of::<libc::sa_family_t>() + 1 + bytes.len();
|
||||||
|
let sent = unsafe {
|
||||||
|
libc::sendto(
|
||||||
|
socket.as_raw_fd(),
|
||||||
|
message.as_ptr().cast(),
|
||||||
|
message.len(),
|
||||||
|
0,
|
||||||
|
std::ptr::from_ref(&addr).cast(),
|
||||||
|
len as libc::socklen_t,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
sent >= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tell the manager the daemon is up and serving. Called once the mount is live,
|
||||||
|
/// so `systemctl --user start` returns when the mount actually works rather than
|
||||||
|
/// when the process exists.
|
||||||
|
pub(crate) fn ready() {
|
||||||
|
if notify("READY=1\n") {
|
||||||
|
debug!("notified systemd: ready");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tell the manager the daemon is still healthy.
|
||||||
|
pub(crate) fn watchdog_ping() {
|
||||||
|
let _ = notify("WATCHDOG=1\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sleep for `duration`, pinging the watchdog on the way.
|
||||||
|
///
|
||||||
|
/// For the stretches where the daemon is alive but has no mount, and so no
|
||||||
|
/// supervisor: waiting for a login, or backing off after a failed mount. The
|
||||||
|
/// manager arms the watchdog when the process starts, so without these pings a
|
||||||
|
/// daemon that is merely retrying is aborted after `WatchdogSec` and leaves a
|
||||||
|
/// coredump instead of the error it was logging. Never use this while mounted:
|
||||||
|
/// there the ping must prove the control socket answers.
|
||||||
|
pub fn idle_sleep(duration: Duration) {
|
||||||
|
const CHUNK: Duration = Duration::from_secs(10);
|
||||||
|
let mut left = duration;
|
||||||
|
watchdog_ping();
|
||||||
|
while !left.is_zero() {
|
||||||
|
let step = left.min(CHUNK);
|
||||||
|
std::thread::sleep(step);
|
||||||
|
left -= step;
|
||||||
|
watchdog_ping();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tell the manager the daemon is stopping, so a slow teardown is not mistaken
|
||||||
|
/// for a hang by the watchdog it is racing.
|
||||||
|
pub(crate) fn stopping() {
|
||||||
|
let _ = notify("STOPPING=1\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How often the manager expects a ping, from `$WATCHDOG_USEC`, or `None` when
|
||||||
|
/// the watchdog is off or belongs to another process.
|
||||||
|
///
|
||||||
|
/// Reports *half* the configured interval, the interval systemd documents for
|
||||||
|
/// pinging: a ping period equal to the deadline would restart the daemon on the
|
||||||
|
/// first late scheduling.
|
||||||
|
pub(crate) fn watchdog_period() -> Option<Duration> {
|
||||||
|
// `WATCHDOG_PID` is set when the manager wants a specific process to answer;
|
||||||
|
// in that case a ping from anyone else is not the one it is waiting for.
|
||||||
|
if let Ok(pid) = std::env::var("WATCHDOG_PID")
|
||||||
|
&& pid.parse::<u32>().ok() != Some(std::process::id())
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let usec: u64 = std::env::var("WATCHDOG_USEC").ok()?.parse().ok()?;
|
||||||
|
(usec > 0).then(|| Duration::from_micros(usec / 2))
|
||||||
|
}
|
||||||
@@ -357,9 +357,9 @@ impl Core {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Favourites are a separate call: the upload seal takes classification
|
// Favorites are a separate call: the upload seal takes classification
|
||||||
// tags, but `Favorite` is not one of them — it goes through the photos
|
// tags, but `Favorite` is not one of them — it goes through the photos
|
||||||
// volume's dedicated favourite endpoint.
|
// volume's dedicated favorite endpoint.
|
||||||
if !favorites.is_empty() {
|
if !favorites.is_empty() {
|
||||||
let updates: Vec<PhotoTagsUpdate> = favorites
|
let updates: Vec<PhotoTagsUpdate> = favorites
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -370,7 +370,7 @@ impl Core {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
if let Err(e) = self.rt.block_on(photos.update_photos(&updates)) {
|
if let Err(e) = self.rt.block_on(photos.update_photos(&updates)) {
|
||||||
warn!(error = %e, "marking imported favourites failed");
|
warn!(error = %e, "marking imported favorites failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -530,7 +530,7 @@ fn open_archive(
|
|||||||
|
|
||||||
/// The lowercase-hex SHA-1 of everything `reader` yields — the digest Proton's
|
/// The lowercase-hex SHA-1 of everything `reader` yields — the digest Proton's
|
||||||
/// photo duplicate detection is keyed on.
|
/// photo duplicate detection is keyed on.
|
||||||
fn sha1_of(reader: &mut (dyn Read + Send)) -> CoreResult<String> {
|
pub(crate) fn sha1_of(reader: &mut (dyn Read + Send)) -> CoreResult<String> {
|
||||||
let mut hasher = Sha1::new();
|
let mut hasher = Sha1::new();
|
||||||
let mut buffer = vec![0u8; 256 * 1024];
|
let mut buffer = vec![0u8; 256 * 1024];
|
||||||
loop {
|
loop {
|
||||||
|
|||||||
@@ -13,6 +13,10 @@
|
|||||||
//! always reflects exactly what is in flight — even if the work fails partway
|
//! always reflects exactly what is in flight — even if the work fails partway
|
||||||
//! and unwinds.
|
//! and unwinds.
|
||||||
//!
|
//!
|
||||||
|
//! The same per-block tick enforces the user's bandwidth limits: every byte a
|
||||||
|
//! transfer moves is charged to its direction's [`RateLimit`], and the thread
|
||||||
|
//! moving it sleeps off whatever the charge puts it over the cap.
|
||||||
|
//!
|
||||||
//! [`Request::GetQueueStatus`]: pdfs_core::control::Request::GetQueueStatus
|
//! [`Request::GetQueueStatus`]: pdfs_core::control::Request::GetQueueStatus
|
||||||
|
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
@@ -20,7 +24,7 @@ use std::collections::HashMap;
|
|||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
use std::time::Instant;
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use pdfs_core::control::{JobItem, TransferDirection, TransferItem};
|
use pdfs_core::control::{JobItem, TransferDirection, TransferItem};
|
||||||
|
|
||||||
@@ -42,6 +46,8 @@ struct JobEntry {
|
|||||||
detail: Mutex<String>,
|
detail: Mutex<String>,
|
||||||
done: AtomicU64,
|
done: AtomicU64,
|
||||||
total: AtomicU64,
|
total: AtomicU64,
|
||||||
|
/// Housekeeping that moves nothing; see [`JobItem::background`].
|
||||||
|
background: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The set of transfers and jobs currently in flight. Cloned `Arc`-style across
|
/// The set of transfers and jobs currently in flight. Cloned `Arc`-style across
|
||||||
@@ -52,6 +58,59 @@ pub struct TransferRegistry {
|
|||||||
inner: Mutex<HashMap<u64, Arc<Entry>>>,
|
inner: Mutex<HashMap<u64, Arc<Entry>>>,
|
||||||
jobs: Mutex<HashMap<u64, Arc<JobEntry>>>,
|
jobs: Mutex<HashMap<u64, Arc<JobEntry>>>,
|
||||||
next: AtomicU64,
|
next: AtomicU64,
|
||||||
|
upload_limit: RateLimit,
|
||||||
|
download_limit: RateLimit,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How far ahead of its cap a direction may run before it is made to wait, so a
|
||||||
|
/// small file still goes out at full speed.
|
||||||
|
const RATE_BURST: Duration = Duration::from_secs(1);
|
||||||
|
|
||||||
|
/// A bytes-per-second cap shared by every transfer in one direction; `0` is no
|
||||||
|
/// cap.
|
||||||
|
///
|
||||||
|
/// A virtual clock rather than a refilled bucket: each charge pushes
|
||||||
|
/// `next_free` forward by the time its bytes take at the cap, so concurrent
|
||||||
|
/// transfers split the cap between them instead of each getting all of it.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct RateLimit {
|
||||||
|
rate: AtomicU64,
|
||||||
|
next_free: Mutex<Option<Instant>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RateLimit {
|
||||||
|
fn set(&self, rate: u64) {
|
||||||
|
self.rate.store(rate, Ordering::Relaxed);
|
||||||
|
// A new cap starts from now, not from the debt the old one ran up.
|
||||||
|
*self.next_free.lock() = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Charge `n` bytes moved at `now`, returning how long the mover must wait
|
||||||
|
/// to stay under the cap.
|
||||||
|
fn charge(&self, n: u64, now: Instant) -> Duration {
|
||||||
|
let rate = self.rate.load(Ordering::Relaxed);
|
||||||
|
if rate == 0 || n == 0 {
|
||||||
|
return Duration::ZERO;
|
||||||
|
}
|
||||||
|
let mut next_free = self.next_free.lock();
|
||||||
|
let start = next_free.filter(|t| *t > now).unwrap_or(now);
|
||||||
|
let done = start + Duration::from_secs_f64(n as f64 / rate as f64);
|
||||||
|
*next_free = Some(done);
|
||||||
|
done.saturating_duration_since(now + RATE_BURST)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sleep off a rate-limit wait. The bulk uploader reads on tokio workers, so
|
||||||
|
/// the sleep is announced to the runtime there rather than silently holding a
|
||||||
|
/// worker other tasks — FUSE reads among them — may need.
|
||||||
|
fn throttle(wait: Duration) {
|
||||||
|
use tokio::runtime::{Handle, RuntimeFlavor};
|
||||||
|
match Handle::try_current() {
|
||||||
|
Ok(handle) if handle.runtime_flavor() == RuntimeFlavor::MultiThread => {
|
||||||
|
tokio::task::block_in_place(|| std::thread::sleep(wait))
|
||||||
|
}
|
||||||
|
_ => std::thread::sleep(wait),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TransferRegistry {
|
impl TransferRegistry {
|
||||||
@@ -59,6 +118,20 @@ impl TransferRegistry {
|
|||||||
Arc::new(Self::default())
|
Arc::new(Self::default())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cap uploads and downloads at these bytes per second; `0` lifts a cap.
|
||||||
|
/// Applies to transfers already running from their next block.
|
||||||
|
pub fn set_limits(&self, upload: u64, download: u64) {
|
||||||
|
self.upload_limit.set(upload);
|
||||||
|
self.download_limit.set(download);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn limit(&self, direction: TransferDirection) -> &RateLimit {
|
||||||
|
match direction {
|
||||||
|
TransferDirection::Upload => &self.upload_limit,
|
||||||
|
TransferDirection::Download => &self.download_limit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Register a new transfer of `total` bytes (`0` = unknown), returning a
|
/// Register a new transfer of `total` bytes (`0` = unknown), returning a
|
||||||
/// guard that tracks progress and deregisters the transfer when dropped.
|
/// guard that tracks progress and deregisters the transfer when dropped.
|
||||||
pub fn begin(
|
pub fn begin(
|
||||||
@@ -89,12 +162,25 @@ impl TransferRegistry {
|
|||||||
/// that carries its progress and deregisters the job when dropped. The job
|
/// that carries its progress and deregisters the job when dropped. The job
|
||||||
/// starts indeterminate; give it a `total` once one is known.
|
/// starts indeterminate; give it a `total` once one is known.
|
||||||
pub fn begin_job(self: &Arc<Self>, title: impl Into<String>) -> JobGuard {
|
pub fn begin_job(self: &Arc<Self>, title: impl Into<String>) -> JobGuard {
|
||||||
|
self.register_job(title.into(), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`TransferRegistry::begin_job`] for housekeeping the user did not ask
|
||||||
|
/// for and that changes nothing in their files, such as an index rebuild or
|
||||||
|
/// a cache check. It is reported, but front ends don't call the drive busy
|
||||||
|
/// over it.
|
||||||
|
pub fn begin_background_job(self: &Arc<Self>, title: impl Into<String>) -> JobGuard {
|
||||||
|
self.register_job(title.into(), true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_job(self: &Arc<Self>, title: String, background: bool) -> JobGuard {
|
||||||
let id = self.next.fetch_add(1, Ordering::Relaxed);
|
let id = self.next.fetch_add(1, Ordering::Relaxed);
|
||||||
let entry = Arc::new(JobEntry {
|
let entry = Arc::new(JobEntry {
|
||||||
title: title.into(),
|
title,
|
||||||
detail: Mutex::new(String::new()),
|
detail: Mutex::new(String::new()),
|
||||||
done: AtomicU64::new(0),
|
done: AtomicU64::new(0),
|
||||||
total: AtomicU64::new(0),
|
total: AtomicU64::new(0),
|
||||||
|
background,
|
||||||
});
|
});
|
||||||
self.jobs.lock().insert(id, entry.clone());
|
self.jobs.lock().insert(id, entry.clone());
|
||||||
JobGuard {
|
JobGuard {
|
||||||
@@ -120,6 +206,7 @@ impl TransferRegistry {
|
|||||||
detail: e.detail.lock().clone(),
|
detail: e.detail.lock().clone(),
|
||||||
done: e.done.load(Ordering::Relaxed),
|
done: e.done.load(Ordering::Relaxed),
|
||||||
total: e.total.load(Ordering::Relaxed),
|
total: e.total.load(Ordering::Relaxed),
|
||||||
|
background: e.background,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
@@ -169,9 +256,17 @@ pub struct TransferGuard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl TransferGuard {
|
impl TransferGuard {
|
||||||
/// Record `n` more bytes moved.
|
/// Record `n` more bytes moved, then wait if they put the direction over
|
||||||
|
/// its cap.
|
||||||
pub fn add(&self, n: u64) {
|
pub fn add(&self, n: u64) {
|
||||||
self.entry.done.fetch_add(n, Ordering::Relaxed);
|
self.entry.done.fetch_add(n, Ordering::Relaxed);
|
||||||
|
let wait = self
|
||||||
|
.reg
|
||||||
|
.limit(self.entry.direction)
|
||||||
|
.charge(n, Instant::now());
|
||||||
|
if !wait.is_zero() {
|
||||||
|
throttle(wait);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,14 +405,61 @@ impl<R: Read> Read for CountingReader<'_, R> {
|
|||||||
&& cancel.load(Ordering::Relaxed)
|
&& cancel.load(Ordering::Relaxed)
|
||||||
{
|
{
|
||||||
// Not `Ok(0)`: a short read is how this reader says "end of file",
|
// Not `Ok(0)`: a short read is how this reader says "end of file",
|
||||||
// and the SDK would seal a truncated revision from it.
|
// and the SDK would seal a truncated revision from it. Not
|
||||||
return Err(std::io::Error::new(
|
// `Interrupted` either: every reader loop, the SDK's included,
|
||||||
std::io::ErrorKind::Interrupted,
|
// retries that kind, and this flag never clears, so the drain
|
||||||
"upload superseded by a newer write",
|
// thread spun forever with the transfer stuck at 0 bytes (B98).
|
||||||
));
|
return Err(std::io::Error::other("upload superseded by a newer write"));
|
||||||
}
|
}
|
||||||
let n = self.inner.read(buf)?;
|
let n = self.inner.read(buf)?;
|
||||||
self.guard.add(n as u64);
|
self.guard.add(n as u64);
|
||||||
Ok(n)
|
Ok(n)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_uncapped_direction_never_waits() {
|
||||||
|
let limit = RateLimit::default();
|
||||||
|
assert_eq!(limit.charge(u64::MAX / 2, Instant::now()), Duration::ZERO);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_capped_direction_waits_once_past_its_burst() {
|
||||||
|
let limit = RateLimit::default();
|
||||||
|
limit.set(1000);
|
||||||
|
let now = Instant::now();
|
||||||
|
// One second of bytes is the burst: no wait yet.
|
||||||
|
assert_eq!(limit.charge(1000, now), Duration::ZERO);
|
||||||
|
// Two more seconds' worth at the same instant: wait out all but the burst.
|
||||||
|
assert_eq!(limit.charge(2000, now), Duration::from_secs(2));
|
||||||
|
// Lifting the cap drops the debt with it.
|
||||||
|
limit.set(0);
|
||||||
|
assert_eq!(limit.charge(5000, now), Duration::ZERO);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn concurrent_transfers_share_one_cap() {
|
||||||
|
let limit = RateLimit::default();
|
||||||
|
limit.set(1000);
|
||||||
|
let now = Instant::now();
|
||||||
|
assert_eq!(limit.charge(1500, now), Duration::from_millis(500));
|
||||||
|
assert_eq!(limit.charge(1500, now), Duration::from_secs(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_cancelled_upload_fails_its_read_instead_of_asking_for_a_retry() {
|
||||||
|
let reg = TransferRegistry::new();
|
||||||
|
let guard = reg.begin("f", "", TransferDirection::Upload, 4);
|
||||||
|
let cancel = Arc::new(AtomicBool::new(true));
|
||||||
|
let mut reader = CountingReader::new(&b"data"[..], &guard).with_cancel(cancel);
|
||||||
|
// `read_exact` retries `Interrupted` the way the SDK's block loop does,
|
||||||
|
// so this hangs rather than fails if the cancel asks for a retry.
|
||||||
|
let err = reader.read_exact(&mut [0; 4]).unwrap_err();
|
||||||
|
assert_ne!(err.kind(), std::io::ErrorKind::Interrupted);
|
||||||
|
assert_eq!(guard.entry.done.load(Ordering::Relaxed), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,15 +69,18 @@ async fn run_uploads(
|
|||||||
format!("destination access denied: errno {}", error.code()),
|
format!("destination access denied: errno {}", error.code()),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
let thumbnails = core
|
||||||
|
.upload_thumbnails_async(t.path.clone(), t.name.clone())
|
||||||
|
.await;
|
||||||
match core
|
match core
|
||||||
.client
|
.drive
|
||||||
.upload_file_from(
|
.upload_file_from(
|
||||||
&t.parent_uid,
|
&t.parent_uid,
|
||||||
&t.name,
|
&t.name,
|
||||||
media_type_for(&t.name),
|
media_type_for(&t.name),
|
||||||
reader,
|
reader,
|
||||||
t.size as i64,
|
t.size as i64,
|
||||||
Vec::new(),
|
thumbnails,
|
||||||
mtime,
|
mtime,
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
@@ -235,14 +238,11 @@ impl Core {
|
|||||||
.map_err(|error| self.errno_error(error, "upload destination"))?;
|
.map_err(|error| self.errno_error(error, "upload destination"))?;
|
||||||
let new_uid = self
|
let new_uid = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(
|
.block_on(self.drive.create_folder(parent_uid, name, Some(now_secs())))
|
||||||
self.client
|
|
||||||
.create_folder(parent_uid, name, Some(now_secs())),
|
|
||||||
)
|
|
||||||
.map_err(|e| CoreError::from_api(&e, &format!("create folder {name}")))?;
|
.map_err(|e| CoreError::from_api(&e, &format!("create folder {name}")))?;
|
||||||
let node = self
|
let node = self
|
||||||
.fetch_node(&new_uid)
|
.fetch_minted_node(&new_uid, name)
|
||||||
.map_err(|e| self.errno_error(e, "fetch node"))?;
|
.ok_or_else(|| minted_but_unlisted(name))?;
|
||||||
let mut st = self.state();
|
let mut st = self.state();
|
||||||
let ino = st.intern(pino, node);
|
let ino = st.intern(pino, node);
|
||||||
if let Some(kids) = st.children.get_mut(&pino)
|
if let Some(kids) = st.children.get_mut(&pino)
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
|
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
use parking_lot::{Condvar, Mutex};
|
use parking_lot::{Condvar, Mutex};
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
@@ -51,8 +53,25 @@ pub(crate) const FUSE_WORKERS: usize = 11;
|
|||||||
/// exactly the blocking this split exists to prevent.
|
/// exactly the blocking this split exists to prevent.
|
||||||
const META_WORKERS: usize = 3;
|
const META_WORKERS: usize = 3;
|
||||||
|
|
||||||
|
/// Queue depth that gets a line in the log, and the step at which it repeats.
|
||||||
|
/// Every job holds a `Reply` and a few fields, so depth is cheap — but a lane
|
||||||
|
/// this far behind is worth knowing about before the user reports a slow mount.
|
||||||
|
const QUEUE_WARN_STEP: usize = 256;
|
||||||
|
|
||||||
|
/// Whether a queue reaching `depth` is one of the depths worth logging.
|
||||||
|
fn crosses_warn_step(depth: usize) -> bool {
|
||||||
|
depth >= QUEUE_WARN_STEP && depth.is_multiple_of(QUEUE_WARN_STEP)
|
||||||
|
}
|
||||||
|
|
||||||
type Job = Box<dyn FnOnce() + Send + 'static>;
|
type Job = Box<dyn FnOnce() + Send + 'static>;
|
||||||
|
|
||||||
|
/// A queued job and the name its caller gave it, so a job that never finishes
|
||||||
|
/// can be named in the diagnostics instead of showing up as "a worker is busy".
|
||||||
|
struct Queued {
|
||||||
|
label: &'static str,
|
||||||
|
job: Job,
|
||||||
|
}
|
||||||
|
|
||||||
/// Which lane a job belongs in.
|
/// Which lane a job belongs in.
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
pub(crate) enum Lane {
|
pub(crate) enum Lane {
|
||||||
@@ -65,12 +84,63 @@ pub(crate) enum Lane {
|
|||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct Queues {
|
struct Queues {
|
||||||
meta: VecDeque<Job>,
|
meta: VecDeque<Queued>,
|
||||||
transfer: VecDeque<Job>,
|
transfer: VecDeque<Queued>,
|
||||||
/// Set when the pool is dropped, to wake and retire every worker.
|
/// Set when the pool is dropped, to wake and retire every worker.
|
||||||
closed: bool,
|
closed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What one worker thread is doing, published for [`Workers::snapshot`].
|
||||||
|
///
|
||||||
|
/// Every field is an atomic (or a lock the worker only ever holds for the length
|
||||||
|
/// of a pointer write) because the whole point of the snapshot is to answer
|
||||||
|
/// *while the daemon is wedged*: a diagnostic that waits on the same lock the
|
||||||
|
/// stuck job holds reports nothing at exactly the moment it is needed.
|
||||||
|
struct Slot {
|
||||||
|
name: String,
|
||||||
|
/// True between picking a job up and finishing it.
|
||||||
|
busy: AtomicBool,
|
||||||
|
/// When the current state (busy or idle) began, as milliseconds since the
|
||||||
|
/// pool started. Monotonic, so an age is always a real duration.
|
||||||
|
since_ms: AtomicU64,
|
||||||
|
/// The label of the job in hand. Behind a mutex because a `&'static str` is
|
||||||
|
/// two words and there is no atomic for that; the worker holds it only to
|
||||||
|
/// swap the pointer, and the snapshot never blocks on it (see
|
||||||
|
/// [`Workers::snapshot`]).
|
||||||
|
label: Mutex<&'static str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Slot {
|
||||||
|
fn new(name: String, now_ms: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
name,
|
||||||
|
busy: AtomicBool::new(false),
|
||||||
|
since_ms: AtomicU64::new(now_ms),
|
||||||
|
label: Mutex::new(""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One worker's state at the moment [`Workers::snapshot`] read it.
|
||||||
|
pub(crate) struct WorkerSnapshot {
|
||||||
|
pub(crate) name: String,
|
||||||
|
pub(crate) busy: bool,
|
||||||
|
/// How long the worker has been in that state.
|
||||||
|
pub(crate) age_ms: u64,
|
||||||
|
/// The job in hand, or `""` when idle. `"?"` when the label could not be
|
||||||
|
/// read without blocking.
|
||||||
|
pub(crate) label: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pool's state at the moment [`Workers::snapshot`] read it.
|
||||||
|
pub(crate) struct PoolSnapshot {
|
||||||
|
pub(crate) workers: Vec<WorkerSnapshot>,
|
||||||
|
pub(crate) meta_queued: usize,
|
||||||
|
pub(crate) transfer_queued: usize,
|
||||||
|
pub(crate) meta_completed: u64,
|
||||||
|
pub(crate) transfer_completed: u64,
|
||||||
|
}
|
||||||
|
|
||||||
/// The queues and the two wait sets over them.
|
/// The queues and the two wait sets over them.
|
||||||
///
|
///
|
||||||
/// Two condvars, not one, because the workers are not interchangeable: a
|
/// Two condvars, not one, because the workers are not interchangeable: a
|
||||||
@@ -86,6 +156,22 @@ struct Pool {
|
|||||||
meta_cv: Condvar,
|
meta_cv: Condvar,
|
||||||
/// Waited on by the general workers.
|
/// Waited on by the general workers.
|
||||||
general_cv: Condvar,
|
general_cv: Condvar,
|
||||||
|
/// Queue depths, mirrored outside the lock so a snapshot can read them
|
||||||
|
/// without waiting on whatever is holding it.
|
||||||
|
meta_queued: AtomicUsize,
|
||||||
|
transfer_queued: AtomicUsize,
|
||||||
|
/// Jobs finished per lane. A depth that stays high while this stays still is
|
||||||
|
/// a stalled lane rather than a busy one.
|
||||||
|
meta_completed: AtomicU64,
|
||||||
|
transfer_completed: AtomicU64,
|
||||||
|
slots: Vec<Slot>,
|
||||||
|
started: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Pool {
|
||||||
|
fn now_ms(&self) -> u64 {
|
||||||
|
self.started.elapsed().as_millis() as u64
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bounded thread pool behind the network-touching FUSE handlers.
|
/// Bounded thread pool behind the network-touching FUSE handlers.
|
||||||
@@ -94,25 +180,46 @@ struct Pool {
|
|||||||
/// on-demand sync folder), so the bound is per daemon rather than per mount.
|
/// on-demand sync folder), so the bound is per daemon rather than per mount.
|
||||||
pub(crate) struct Workers {
|
pub(crate) struct Workers {
|
||||||
inner: Arc<Pool>,
|
inner: Arc<Pool>,
|
||||||
|
/// Kept so teardown can *join* the workers, not merely flag them. A worker
|
||||||
|
/// still inside a job when the process drops its tokio runtime panics with
|
||||||
|
/// "A Tokio 1.x context was found, but it is being shutdown" (seen
|
||||||
|
/// 2026-09-18 09:58:35), which is a teardown ordering bug rather than a real
|
||||||
|
/// failure.
|
||||||
|
handles: Mutex<Vec<std::thread::JoinHandle<()>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Workers {
|
impl Workers {
|
||||||
pub(crate) fn new(n: usize) -> std::io::Result<Self> {
|
pub(crate) fn new(n: usize) -> std::io::Result<Self> {
|
||||||
|
// Never leave the pool without a general worker, however small `n` is.
|
||||||
|
let meta_workers = META_WORKERS.min(n.saturating_sub(1));
|
||||||
|
let names: Vec<String> = (0..n)
|
||||||
|
.map(|i| {
|
||||||
|
format!(
|
||||||
|
"pdfs-fuse-{}{i}",
|
||||||
|
if i < meta_workers { "meta-" } else { "" }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
let inner = Arc::new(Pool {
|
let inner = Arc::new(Pool {
|
||||||
queues: Mutex::new(Queues::default()),
|
queues: Mutex::new(Queues::default()),
|
||||||
meta_cv: Condvar::new(),
|
meta_cv: Condvar::new(),
|
||||||
general_cv: Condvar::new(),
|
general_cv: Condvar::new(),
|
||||||
|
meta_queued: AtomicUsize::new(0),
|
||||||
|
transfer_queued: AtomicUsize::new(0),
|
||||||
|
meta_completed: AtomicU64::new(0),
|
||||||
|
transfer_completed: AtomicU64::new(0),
|
||||||
|
slots: names
|
||||||
|
.iter()
|
||||||
|
.map(|name| Slot::new(name.clone(), 0))
|
||||||
|
.collect(),
|
||||||
|
started: Instant::now(),
|
||||||
});
|
});
|
||||||
// Never leave the pool without a general worker, however small `n` is.
|
let mut handles = Vec::with_capacity(n);
|
||||||
let meta_workers = META_WORKERS.min(n.saturating_sub(1));
|
for (i, name) in names.iter().enumerate() {
|
||||||
for i in 0..n {
|
|
||||||
let inner = inner.clone();
|
let inner = inner.clone();
|
||||||
let meta_only = i < meta_workers;
|
let meta_only = i < meta_workers;
|
||||||
std::thread::Builder::new()
|
let handle = std::thread::Builder::new()
|
||||||
.name(format!(
|
.name(name.clone())
|
||||||
"pdfs-fuse-{}{i}",
|
|
||||||
if meta_only { "meta-" } else { "" }
|
|
||||||
))
|
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
let cv = if meta_only {
|
let cv = if meta_only {
|
||||||
&inner.meta_cv
|
&inner.meta_cv
|
||||||
@@ -121,17 +228,20 @@ impl Workers {
|
|||||||
};
|
};
|
||||||
loop {
|
loop {
|
||||||
let mut q = inner.queues.lock();
|
let mut q = inner.queues.lock();
|
||||||
let job = loop {
|
let picked = loop {
|
||||||
// A general worker takes transfers first: metadata
|
// A general worker takes transfers first: metadata
|
||||||
// already has threads of its own, so draining the
|
// already has threads of its own, so draining the
|
||||||
// bulk queue is the useful thing for it to do.
|
// bulk queue is the useful thing for it to do.
|
||||||
let picked = if meta_only {
|
let taken = if meta_only {
|
||||||
q.meta.pop_front()
|
q.meta.pop_front().map(|job| (Lane::Meta, job))
|
||||||
} else {
|
} else {
|
||||||
q.transfer.pop_front().or_else(|| q.meta.pop_front())
|
q.transfer
|
||||||
|
.pop_front()
|
||||||
|
.map(|job| (Lane::Transfer, job))
|
||||||
|
.or_else(|| q.meta.pop_front().map(|job| (Lane::Meta, job)))
|
||||||
};
|
};
|
||||||
if let Some(job) = picked {
|
if let Some(taken) = taken {
|
||||||
break Some(job);
|
break Some(taken);
|
||||||
}
|
}
|
||||||
if q.closed {
|
if q.closed {
|
||||||
break None;
|
break None;
|
||||||
@@ -139,7 +249,15 @@ impl Workers {
|
|||||||
cv.wait(&mut q);
|
cv.wait(&mut q);
|
||||||
};
|
};
|
||||||
drop(q);
|
drop(q);
|
||||||
let Some(job) = job else { break };
|
let Some((lane, queued)) = picked else { break };
|
||||||
|
match lane {
|
||||||
|
Lane::Meta => inner.meta_queued.fetch_sub(1, Ordering::Relaxed),
|
||||||
|
Lane::Transfer => inner.transfer_queued.fetch_sub(1, Ordering::Relaxed),
|
||||||
|
};
|
||||||
|
let slot = &inner.slots[i];
|
||||||
|
*slot.label.lock() = queued.label;
|
||||||
|
slot.since_ms.store(inner.now_ms(), Ordering::Relaxed);
|
||||||
|
slot.busy.store(true, Ordering::Relaxed);
|
||||||
// A panicking handler must not cost the pool a worker
|
// A panicking handler must not cost the pool a worker
|
||||||
// for the rest of the run. The dropped `Reply` answers
|
// for the rest of the run. The dropped `Reply` answers
|
||||||
// EIO on its own, so the caller of the failed op is
|
// EIO on its own, so the caller of the failed op is
|
||||||
@@ -164,11 +282,70 @@ impl Workers {
|
|||||||
// poisoning at all. Anything new reached from a job owes
|
// poisoning at all. Anything new reached from a job owes
|
||||||
// the same check — an SDK-side `.lock().unwrap()`
|
// the same check — an SDK-side `.lock().unwrap()`
|
||||||
// silently invalidates this comment.
|
// silently invalidates this comment.
|
||||||
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(job));
|
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(queued.job));
|
||||||
|
slot.busy.store(false, Ordering::Relaxed);
|
||||||
|
slot.since_ms.store(inner.now_ms(), Ordering::Relaxed);
|
||||||
|
*slot.label.lock() = "";
|
||||||
|
match lane {
|
||||||
|
Lane::Meta => inner.meta_completed.fetch_add(1, Ordering::Relaxed),
|
||||||
|
Lane::Transfer => {
|
||||||
|
inner.transfer_completed.fetch_add(1, Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
|
handles.push(handle);
|
||||||
}
|
}
|
||||||
Ok(Self { inner })
|
Ok(Self {
|
||||||
|
inner,
|
||||||
|
handles: Mutex::new(handles),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop accepting work, wake every worker, and wait up to `deadline` for the
|
||||||
|
/// jobs in flight to finish.
|
||||||
|
///
|
||||||
|
/// Bounded because a join is only worth what it costs: a worker wedged on a
|
||||||
|
/// network call would otherwise hold the whole teardown, and the user asked
|
||||||
|
/// for the daemon to stop. What is stuck gets named in the log, and the
|
||||||
|
/// process continues shutting down around it.
|
||||||
|
pub(crate) fn stop_and_join(&self, deadline: std::time::Duration) {
|
||||||
|
self.close();
|
||||||
|
let handles: Vec<_> = std::mem::take(&mut *self.handles.lock());
|
||||||
|
if handles.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
let joiner = std::thread::Builder::new()
|
||||||
|
.name("pdfs-fuse-join".into())
|
||||||
|
.spawn(move || {
|
||||||
|
for handle in handles {
|
||||||
|
let _ = handle.join();
|
||||||
|
}
|
||||||
|
let _ = tx.send(());
|
||||||
|
});
|
||||||
|
if joiner.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if rx.recv_timeout(deadline).is_err() {
|
||||||
|
let busy: Vec<String> = self
|
||||||
|
.snapshot()
|
||||||
|
.workers
|
||||||
|
.into_iter()
|
||||||
|
.filter(|worker| worker.busy)
|
||||||
|
.map(|worker| format!("{}={}", worker.name, worker.label))
|
||||||
|
.collect();
|
||||||
|
warn!(
|
||||||
|
stuck = busy.join(","),
|
||||||
|
"fuse workers did not finish in time; shutting down around them"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close(&self) {
|
||||||
|
self.inner.queues.lock().closed = true;
|
||||||
|
self.inner.meta_cv.notify_all();
|
||||||
|
self.inner.general_cv.notify_all();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Queue `job` in `lane`.
|
/// Queue `job` in `lane`.
|
||||||
@@ -179,7 +356,15 @@ impl Workers {
|
|||||||
/// a pending job holds a `Reply` and a few fields, while the 4 MiB block
|
/// a pending job holds a `Reply` and a few fields, while the 4 MiB block
|
||||||
/// buffer is allocated inside the job once it runs, and the SDK's in-flight
|
/// buffer is allocated inside the job once it runs, and the SDK's in-flight
|
||||||
/// semaphore is what bounds how many of those exist at once.
|
/// semaphore is what bounds how many of those exist at once.
|
||||||
pub(crate) fn run(&self, lane: Lane, job: impl FnOnce() + Send + 'static) {
|
/// The queue is *not* bounded, and the depth warning above is deliberately
|
||||||
|
/// all this does about it: the only caller able to feel backpressure here is
|
||||||
|
/// fuser's dispatch loop, and blocking that is the stall the whole module
|
||||||
|
/// exists to avoid. A depth that keeps climbing is a symptom to read in the
|
||||||
|
/// log, not something to fix by stopping the mount.
|
||||||
|
///
|
||||||
|
/// `label` names the work for the diagnostics (`pdfs diagnostics`) and for
|
||||||
|
/// the stall warnings: it is what a hung daemon reports instead of "busy".
|
||||||
|
pub(crate) fn run(&self, lane: Lane, label: &'static str, job: impl FnOnce() + Send + 'static) {
|
||||||
let mut q = self.inner.queues.lock();
|
let mut q = self.inner.queues.lock();
|
||||||
if q.closed {
|
if q.closed {
|
||||||
// Pre-pool behaviour: a shut-down pool degrades to a slow mount
|
// Pre-pool behaviour: a shut-down pool degrades to a slow mount
|
||||||
@@ -189,11 +374,31 @@ impl Workers {
|
|||||||
job();
|
job();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
match lane {
|
let queued = Queued {
|
||||||
Lane::Meta => q.meta.push_back(Box::new(job)),
|
label,
|
||||||
Lane::Transfer => q.transfer.push_back(Box::new(job)),
|
job: Box::new(job),
|
||||||
}
|
};
|
||||||
|
let depth = match lane {
|
||||||
|
Lane::Meta => {
|
||||||
|
q.meta.push_back(queued);
|
||||||
|
self.inner.meta_queued.fetch_add(1, Ordering::Relaxed) + 1
|
||||||
|
}
|
||||||
|
Lane::Transfer => {
|
||||||
|
q.transfer.push_back(queued);
|
||||||
|
self.inner.transfer_queued.fetch_add(1, Ordering::Relaxed) + 1
|
||||||
|
}
|
||||||
|
};
|
||||||
drop(q);
|
drop(q);
|
||||||
|
if crosses_warn_step(depth) {
|
||||||
|
warn!(
|
||||||
|
lane = if matches!(lane, Lane::Meta) {
|
||||||
|
"meta"
|
||||||
|
} else {
|
||||||
|
"transfer"
|
||||||
|
},
|
||||||
|
depth, label, "fuse worker queue is deep"
|
||||||
|
);
|
||||||
|
}
|
||||||
match lane {
|
match lane {
|
||||||
// Either class can serve metadata, and only one of them needs to:
|
// Either class can serve metadata, and only one of them needs to:
|
||||||
// whichever wakes first takes it, and the other finds the queue
|
// whichever wakes first takes it, and the other finds the queue
|
||||||
@@ -209,13 +414,50 @@ impl Workers {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What every worker is doing right now, without taking the queue lock.
|
||||||
|
///
|
||||||
|
/// A diagnostic is worth having exactly when the daemon is stuck, so this
|
||||||
|
/// reads atomics only, and `try_lock`s each label rather than waiting for
|
||||||
|
/// it: a label is held for one pointer write, so failing to get it means a
|
||||||
|
/// racing worker, not a wedged one, and `"?"` is a better answer than a
|
||||||
|
/// diagnostics request that hangs too.
|
||||||
|
pub(crate) fn snapshot(&self) -> PoolSnapshot {
|
||||||
|
let now = self.inner.now_ms();
|
||||||
|
let workers = self
|
||||||
|
.inner
|
||||||
|
.slots
|
||||||
|
.iter()
|
||||||
|
.map(|slot| {
|
||||||
|
let label = slot
|
||||||
|
.label
|
||||||
|
.try_lock()
|
||||||
|
.map_or_else(|| "?".to_string(), |label| (*label).to_string());
|
||||||
|
WorkerSnapshot {
|
||||||
|
name: slot.name.clone(),
|
||||||
|
busy: slot.busy.load(Ordering::Relaxed),
|
||||||
|
age_ms: now.saturating_sub(slot.since_ms.load(Ordering::Relaxed)),
|
||||||
|
label,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
PoolSnapshot {
|
||||||
|
workers,
|
||||||
|
meta_queued: self.inner.meta_queued.load(Ordering::Relaxed),
|
||||||
|
transfer_queued: self.inner.transfer_queued.load(Ordering::Relaxed),
|
||||||
|
meta_completed: self.inner.meta_completed.load(Ordering::Relaxed),
|
||||||
|
transfer_completed: self.inner.transfer_completed.load(Ordering::Relaxed),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for Workers {
|
impl Drop for Workers {
|
||||||
|
/// A backstop for the paths that never call
|
||||||
|
/// [`stop_and_join`](Workers::stop_and_join) — tests, and a mount that fails
|
||||||
|
/// before teardown is wired up. Flag and wake only: a `Drop` that blocks on
|
||||||
|
/// a network call is worse than a worker outliving its pool by a moment.
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
self.inner.queues.lock().closed = true;
|
self.close();
|
||||||
self.inner.meta_cv.notify_all();
|
|
||||||
self.inner.general_cv.notify_all();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,6 +467,64 @@ mod tests {
|
|||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// The depth warning fires on the step, and only on the step: a lane that
|
||||||
|
/// oscillates around the threshold must not log on every single job.
|
||||||
|
#[test]
|
||||||
|
fn queue_depth_warns_on_each_step() {
|
||||||
|
assert!(!crosses_warn_step(1));
|
||||||
|
assert!(!crosses_warn_step(QUEUE_WARN_STEP - 1));
|
||||||
|
assert!(crosses_warn_step(QUEUE_WARN_STEP));
|
||||||
|
assert!(!crosses_warn_step(QUEUE_WARN_STEP + 1));
|
||||||
|
assert!(crosses_warn_step(QUEUE_WARN_STEP * 3));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Teardown must return even while a job is running, and must not leave the
|
||||||
|
/// pool accepting work.
|
||||||
|
#[test]
|
||||||
|
fn stopping_is_bounded_and_closes_the_pool() {
|
||||||
|
let pool = Workers::new(2).unwrap();
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
pool.run(Lane::Meta, "test", move || tx.send(()).unwrap());
|
||||||
|
rx.recv_timeout(Duration::from_secs(5)).expect("it runs");
|
||||||
|
|
||||||
|
pool.stop_and_join(Duration::from_secs(5));
|
||||||
|
|
||||||
|
// A job queued after the stop runs inline rather than disappearing.
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
pool.run(Lane::Meta, "test", move || tx.send(()).unwrap());
|
||||||
|
rx.recv_timeout(Duration::from_secs(5))
|
||||||
|
.expect("a closed pool serves inline");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A diagnostic is only useful if it names the work. A running job must show
|
||||||
|
/// up under its label, and the queue behind it must show up as depth.
|
||||||
|
#[test]
|
||||||
|
fn a_snapshot_names_the_running_job_and_the_queue_behind_it() {
|
||||||
|
let pool = Workers::new(2).unwrap();
|
||||||
|
let (release_tx, release_rx) = mpsc::channel::<()>();
|
||||||
|
let release_rx = Arc::new(Mutex::new(release_rx));
|
||||||
|
let (started_tx, started_rx) = mpsc::channel();
|
||||||
|
{
|
||||||
|
let release_rx = release_rx.clone();
|
||||||
|
pool.run(Lane::Transfer, "read", move || {
|
||||||
|
started_tx.send(()).unwrap();
|
||||||
|
let _ = release_rx.lock().recv();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
started_rx
|
||||||
|
.recv_timeout(Duration::from_secs(5))
|
||||||
|
.expect("the job starts");
|
||||||
|
|
||||||
|
let snapshot = pool.snapshot();
|
||||||
|
assert!(
|
||||||
|
snapshot.workers.iter().any(|w| w.busy && w.label == "read"),
|
||||||
|
"the running job is named"
|
||||||
|
);
|
||||||
|
assert_eq!(snapshot.transfer_completed, 0);
|
||||||
|
|
||||||
|
let _ = release_tx.send(());
|
||||||
|
}
|
||||||
|
|
||||||
/// Audit A6. Transfers filling every general worker must not delay a
|
/// Audit A6. Transfers filling every general worker must not delay a
|
||||||
/// metadata job: that is what the reserved threads are for.
|
/// metadata job: that is what the reserved threads are for.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -240,7 +540,7 @@ mod tests {
|
|||||||
for _ in 0..general {
|
for _ in 0..general {
|
||||||
let release_rx = release_rx.clone();
|
let release_rx = release_rx.clone();
|
||||||
let started_tx = started_tx.clone();
|
let started_tx = started_tx.clone();
|
||||||
pool.run(Lane::Transfer, move || {
|
pool.run(Lane::Transfer, "test", move || {
|
||||||
started_tx.send(()).unwrap();
|
started_tx.send(()).unwrap();
|
||||||
let _ = release_rx.lock().recv();
|
let _ = release_rx.lock().recv();
|
||||||
});
|
});
|
||||||
@@ -255,13 +555,13 @@ mod tests {
|
|||||||
// in the way that used to starve metadata.
|
// in the way that used to starve metadata.
|
||||||
for _ in 0..16 {
|
for _ in 0..16 {
|
||||||
let release_rx = release_rx.clone();
|
let release_rx = release_rx.clone();
|
||||||
pool.run(Lane::Transfer, move || {
|
pool.run(Lane::Transfer, "test", move || {
|
||||||
let _ = release_rx.lock().recv();
|
let _ = release_rx.lock().recv();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let (meta_tx, meta_rx) = mpsc::channel();
|
let (meta_tx, meta_rx) = mpsc::channel();
|
||||||
pool.run(Lane::Meta, move || meta_tx.send(()).unwrap());
|
pool.run(Lane::Meta, "test", move || meta_tx.send(()).unwrap());
|
||||||
meta_rx
|
meta_rx
|
||||||
.recv_timeout(Duration::from_secs(5))
|
.recv_timeout(Duration::from_secs(5))
|
||||||
.expect("a metadata job runs while every transfer thread is stuck");
|
.expect("a metadata job runs while every transfer thread is stuck");
|
||||||
@@ -279,7 +579,7 @@ mod tests {
|
|||||||
// must have been served by the general worker.
|
// must have been served by the general worker.
|
||||||
let pool = Workers::new(1).unwrap();
|
let pool = Workers::new(1).unwrap();
|
||||||
let (tx, rx) = mpsc::channel();
|
let (tx, rx) = mpsc::channel();
|
||||||
pool.run(Lane::Meta, move || tx.send(()).unwrap());
|
pool.run(Lane::Meta, "test", move || tx.send(()).unwrap());
|
||||||
rx.recv_timeout(Duration::from_secs(5))
|
rx.recv_timeout(Duration::from_secs(5))
|
||||||
.expect("a general worker takes metadata when no transfer is waiting");
|
.expect("a general worker takes metadata when no transfer is waiting");
|
||||||
}
|
}
|
||||||
@@ -288,9 +588,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn a_panicking_job_does_not_retire_its_worker() {
|
fn a_panicking_job_does_not_retire_its_worker() {
|
||||||
let pool = Workers::new(1).unwrap();
|
let pool = Workers::new(1).unwrap();
|
||||||
pool.run(Lane::Meta, || panic!("boom"));
|
pool.run(Lane::Meta, "test", || panic!("boom"));
|
||||||
let (tx, rx) = mpsc::channel();
|
let (tx, rx) = mpsc::channel();
|
||||||
pool.run(Lane::Meta, move || tx.send(()).unwrap());
|
pool.run(Lane::Meta, "test", move || tx.send(()).unwrap());
|
||||||
rx.recv_timeout(Duration::from_secs(5))
|
rx.recv_timeout(Duration::from_secs(5))
|
||||||
.expect("the pool still serves work after a panic");
|
.expect("the pool still serves work after a panic");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,9 +29,15 @@ anyhow.workspace = true
|
|||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
ksni = "0.2"
|
ksni = { version = "0.3", features = ["blocking"] }
|
||||||
gtk4 = { version = "0.9", features = ["v4_12"] }
|
gtk4 = { version = "0.11", features = ["v4_12"] }
|
||||||
adw = { package = "libadwaita", version = "0.7", features = ["v1_5"] }
|
adw = { package = "libadwaita", version = "0.9", features = ["v1_5"] }
|
||||||
async-channel = "2"
|
async-channel = "2"
|
||||||
exif = { version = "0.6", package = "kamadak-exif" }
|
exif = { version = "0.6", package = "kamadak-exif" }
|
||||||
webkit6 = "0.4"
|
webkit6 = "0.6"
|
||||||
|
# Town and street names on the Places map, with font fallback for every script.
|
||||||
|
pangocairo = "0.22"
|
||||||
|
# Map tiles for the opt-in online map. The same build proton-sdk already pulls in.
|
||||||
|
reqwest = { version = "0.13", default-features = false, features = ["rustls", "webpki-roots"] }
|
||||||
|
# The system libintl (glibc) rather than a bundled gettext build.
|
||||||
|
gettext-rs = { version = "0.7", features = ["gettext-system"] }
|
||||||
@@ -9,6 +9,8 @@ fn main() {
|
|||||||
&format!("{}/pdfs.gresource", out_dir),
|
&format!("{}/pdfs.gresource", out_dir),
|
||||||
"--sourcedir",
|
"--sourcedir",
|
||||||
"resources/icons",
|
"resources/icons",
|
||||||
|
"--sourcedir",
|
||||||
|
"resources",
|
||||||
"resources/pdfs.gresource.xml",
|
"resources/pdfs.gresource.xml",
|
||||||
])
|
])
|
||||||
.status()
|
.status()
|
||||||
@@ -22,10 +24,9 @@ fn main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
println!("cargo:rerun-if-changed=resources/pdfs.gresource.xml");
|
println!("cargo:rerun-if-changed=resources/pdfs.gresource.xml");
|
||||||
println!(
|
println!("cargo:rerun-if-changed=resources/icons");
|
||||||
"cargo:rerun-if-changed=resources/icons/scalable/actions/pdfs-build-thumbnails-symbolic.svg"
|
println!("cargo:rerun-if-changed=resources/style.css");
|
||||||
);
|
println!("cargo:rerun-if-changed=resources/proton-theme.css");
|
||||||
println!(
|
println!("cargo:rerun-if-changed=resources/prompt.css");
|
||||||
"cargo:rerun-if-changed=resources/icons/scalable/actions/pdfs-folder-upload-symbolic.svg"
|
println!("cargo:rerun-if-changed=../../packaging/io.narl.proton-drive-linux.svg");
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
47.98,8.00 48.94,9.45 48.94,11.26
|
||||||
|
47.98,8.00 46.42,6.50 44.94,4.91 43.99,4.95 43.58,4.85 43.13,4.64 42.86,4.32 42.02,4.14 41.88,3.98
|
||||||
|
34.25,31.21 34.53,31.53 34.48,31.58
|
||||||
|
35.55,32.40 35.19,32.53 35.07,32.46 34.96,32.16 34.95,31.84 35.20,31.75 34.95,31.60 34.87,31.40 34.91,31.35 35.10,31.37 35.45,31.48
|
||||||
|
35.11,33.08 35.41,33.08 35.49,33.12 35.53,33.25 35.60,33.24
|
||||||
|
35.79,32.73 35.89,32.94 35.79,33.29 35.84,33.42
|
||||||
|
34.00,35.07 33.87,35.09 33.48,35.00 33.38,35.16 33.19,35.17 32.92,35.09 32.71,35.17
|
||||||
|
77.80,35.50 77.89,35.45 78.04,35.48 78.01,35.25 78.33,34.61 78.67,34.52 78.97,34.30 78.97,34.23 78.73,34.01 78.80,33.50 78.92,33.39
|
||||||
|
77.05,35.11 77.00,34.99 76.59,34.74 76.04,34.67 75.71,34.50 75.32,34.58
|
||||||
|
91.63,27.76 91.98,27.73 92.10,27.81 92.55,27.88 92.69,27.99 92.64,28.06 92.70,28.15 93.17,28.36 93.36,28.65 93.90,28.74 93.97,28.86 94.27,28.97 94.29,29.14 94.62,29.31 94.77,29.18 95.39,29.04 95.52,29.21 95.71,29.24 95.77,29.34 96.13,29.38 96.23,29.25 96.36,29.25 96.18,29.12 96.20,28.96 96.44,29.05 96.58,28.76 96.28,28.41 96.39,28.37 96.60,28.46 96.78,28.37 97.08,28.37 97.32,28.22
|
||||||
|
128.37,38.62 128.17,38.36 128.04,38.31 127.09,38.28 126.67,37.92 126.63,37.78
|
||||||
|
-8.68,27.66 -8.82,27.66 -8.75,27.19 -8.79,27.12 -9.41,27.09 -9.82,26.85 -10.03,26.91 -10.25,26.86 -10.76,27.02 -11.39,26.88 -11.32,26.74 -11.34,26.63 -11.51,26.47 -11.72,26.10 -12.06,25.99 -12.43,24.83 -12.99,24.47 -13.31,23.98 -13.89,23.69 -14.10,23.10 -14.22,22.31 -14.63,21.86 -14.61,21.75 -14.75,21.50 -14.97,21.44 -16.04,21.50 -17.00,21.42
|
||||||
|
35.92,4.62 35.76,4.81 35.80,5.16 35.75,5.34 35.47,5.42 35.33,5.36 35.27,5.49
|
||||||
|
-124.76,48.49 -123.86,48.26 -123.39,48.25 -123.14,48.40 -123.24,48.64 -123.01,48.79 -123.31,48.99
|
||||||
|
91.63,27.76 91.64,27.92 91.27,28.08 91.02,27.97 90.33,28.12
|
||||||
|
23.61,51.52 23.61,51.61 23.71,51.64 23.98,51.59 24.36,51.87 25.79,51.92 27.14,51.75 27.30,51.60 27.69,51.57 27.70,51.48 27.86,51.59 28.18,51.61 28.60,51.54 28.73,51.43 29.10,51.63 29.35,51.38 30.16,51.48 30.33,51.33 30.54,51.27 30.63,51.36 30.53,51.60 30.76,51.90 31.08,52.08 31.76,52.10
|
||||||
|
35.84,33.42 35.60,33.24
|
||||||
|
35.84,33.42 36.03,33.59 35.94,33.67 35.99,33.75 36.09,33.83 36.37,33.84 36.28,33.93 36.58,34.22 36.50,34.43 36.33,34.50 36.43,34.61 36.38,34.66 35.98,34.63
|
||||||
|
34.68,-12.01 34.76,-12.03 34.68,-12.12 34.68,-12.01
|
||||||
|
80.21,42.19 80.26,42.24 80.16,42.63 80.20,42.73 80.54,42.87 80.38,42.97 80.39,43.04 80.62,43.13 80.75,43.10 80.79,43.16 80.67,43.31 80.70,43.43 80.65,43.56 80.36,44.10 80.36,44.55 80.38,44.66 80.48,44.71 80.26,44.81 80.00,44.80 79.87,44.88 80.06,45.01 80.85,45.13 81.69,45.35 81.79,45.23 81.94,45.16 82.27,45.22 82.52,45.13 82.62,45.29 82.61,45.42 82.33,45.52 82.32,45.59 82.97,46.97 83.03,47.19 84.02,46.97 84.67,46.97 84.79,46.83 85.23,47.04 85.48,47.06 85.66,47.25 85.53,47.92 85.75,48.39 86.55,48.53 86.72,48.70 86.73,48.94 86.81,49.05 87.05,49.11 87.32,49.09
|
||||||
|
23.48,53.94 24.19,53.95 24.32,53.89 24.77,53.97 24.87,54.15 25.05,54.13 25.46,54.29 25.51,54.16 25.75,54.16 25.75,54.26 25.55,54.33 25.72,54.56 25.72,54.72 25.86,54.92 26.18,55.00 26.29,55.14 26.60,55.13 26.78,55.27 26.46,55.34 26.59,55.67
|
||||||
|
-53.37,-33.74 -53.53,-33.66 -53.53,-33.17 -53.13,-32.74 -53.16,-32.68 -53.60,-32.40 -53.76,-32.06 -54.22,-31.86 -54.59,-31.49 -54.90,-31.39 -55.04,-31.28 -55.17,-31.28 -55.56,-30.88 -55.63,-30.86 -55.81,-31.03
|
||||||
|
116.68,49.82 116.03,48.78 115.82,48.58 115.79,48.25 115.53,48.13 115.62,47.87 115.90,47.69 116.23,47.86 116.76,47.87 117.07,47.81 117.35,47.65 117.77,47.99 118.15,48.03 118.50,47.98 118.76,47.76 119.08,47.65 119.16,47.53 119.71,47.15 119.90,46.86 119.87,46.67 119.71,46.61 119.33,46.61 118.84,46.76 118.72,46.69 118.31,46.72 118.07,46.67 117.74,46.52 117.44,46.59 117.33,46.36 116.86,46.39 116.56,46.29 116.21,45.89 116.20,45.74 115.68,45.46 114.56,45.39 114.42,45.20 114.08,44.97 113.59,44.75 112.71,44.88 112.41,45.06 111.90,45.06 111.62,44.83 111.40,44.37 111.60,44.11 111.84,43.93 111.93,43.71 111.55,43.50 111.01,43.34 110.40,42.77 109.34,42.44 108.17,42.45 106.77,42.29 105.20,41.74 104.98,41.60 104.50,41.66 104.50,41.88 103.71,41.75 103.07,42.01 102.16,42.16 101.97,42.22 101.66,42.50 101.50,42.54 99.98,42.68 99.47,42.57 97.21,42.79 96.39,42.72 96.30,42.93 95.86,43.28 95.53,43.95 95.33,44.04 95.35,44.28 95.05,44.26 94.71,44.35 94.20,44.65 93.96,44.67 93.52,44.94 92.79,45.04 91.58,45.08 91.05,45.22 90.88,45.20 90.66,45.53 90.71,45.73 91.00,46.04 90.91,46.27 91.03,46.57 90.87,46.95 90.72,47.00 90.50,47.29 90.33,47.66 90.10,47.75 90.03,47.88 89.78,47.83 89.48,48.03 89.05,48.00 88.58,48.22 88.52,48.38 87.98,48.56 88.06,48.71 87.74,48.88 87.87,49.00 87.81,49.16
|
||||||
|
13.81,48.77 13.55,48.96 13.40,48.98 12.92,49.33 12.68,49.41 12.39,49.74 12.51,49.90 12.21,50.10 12.09,50.30 12.28,50.18 12.55,50.39 12.94,50.41 13.02,50.49 13.44,50.60 13.56,50.70 14.37,50.90 14.26,51.00 14.32,51.04 14.55,50.99 14.66,50.83 14.81,50.86
|
||||||
|
27.35,57.53 26.97,57.61 26.46,57.54 25.99,57.84 25.28,58.05 25.26,58.00 25.11,58.06 24.32,57.87
|
||||||
|
27.35,57.53 27.51,57.51 27.54,57.43 27.83,57.29 27.64,56.85 27.85,56.85 28.10,56.55 28.20,56.26 28.15,56.14
|
||||||
|
21.05,56.07 21.65,56.31 22.08,56.41 22.88,56.40 23.04,56.32 23.20,56.37 24.12,56.26 24.47,56.28 24.84,56.41 25.07,56.20 25.66,56.10 26.28,55.75 26.59,55.67
|
||||||
|
20.62,69.04 20.12,69.02 20.35,68.85 20.24,68.67 19.97,68.54 20.24,68.48 19.97,68.36 18.30,68.56 18.16,68.53 18.18,68.20 17.92,67.96 17.32,68.10 16.78,67.90 16.57,67.62 16.19,67.51 16.13,67.43 16.43,67.16 16.40,67.05 15.42,66.49 15.48,66.31 15.04,66.17 14.54,66.13 14.63,65.79 14.48,65.30 13.65,64.58 14.08,64.46 14.14,64.17 13.96,64.01 13.20,64.08 12.79,64.00 12.18,63.60 12.21,63.49 12.00,63.29 12.22,63.00 12.11,62.92 12.11,62.59 12.30,62.29 12.16,61.72 12.88,61.35 12.71,61.06 12.29,61.00 12.59,60.45 12.49,60.11 12.17,59.91 11.93,59.86 11.83,59.70 11.68,59.59 11.80,59.29 11.64,58.93 11.47,58.91 11.34,59.09 11.24,59.10 11.14,59.09 11.06,58.96 10.91,58.95
|
||||||
|
20.62,69.04 21.07,69.04 21.13,69.08 21.07,69.21 21.59,69.27 22.30,68.86 22.41,68.72 23.32,68.65 23.71,68.71 23.85,68.81 24.94,68.59 25.09,68.64 25.25,68.82 25.58,68.89 25.75,68.99 25.77,69.28 26.07,69.69 26.53,69.92 27.13,69.91 27.75,70.06 27.89,70.06 28.41,69.82 29.14,69.67 29.33,69.47 28.85,69.18 28.83,69.12 28.97,69.02
|
||||||
|
104.43,10.41 104.56,10.52 104.85,10.53 105.05,10.70 105.05,10.91 105.31,10.85 105.41,10.95 105.76,10.99 105.85,10.86 106.16,10.79 106.16,11.04 105.86,11.29 105.85,11.64 106.01,11.76 106.40,11.69 106.41,11.95 106.70,11.98 106.76,12.05 106.93,12.08 107.21,12.30 107.33,12.32 107.39,12.26 107.51,12.36 107.56,12.54 107.48,13.03 107.61,13.44 107.33,14.13 107.36,14.37 107.49,14.55 107.52,14.71
|
||||||
|
-69.51,-17.50 -69.31,-17.94 -69.09,-18.05 -69.15,-18.14 -68.97,-18.97 -68.46,-19.43 -68.70,-19.72 -68.56,-19.97 -68.60,-20.04 -68.76,-20.09 -68.69,-20.31 -68.76,-20.42 -68.48,-20.63 -68.56,-20.72 -68.56,-20.90 -68.44,-20.95 -68.20,-21.30 -68.19,-21.62 -68.08,-21.98 -67.99,-22.06 -67.88,-22.49 -67.88,-22.82 -67.58,-22.89 -67.19,-22.82
|
||||||
|
-69.51,-17.51 -69.85,-17.70 -69.80,-17.99 -69.93,-18.21 -70.18,-18.33 -70.42,-18.35
|
||||||
|
6.34,49.45 6.49,49.80 6.20,49.92 6.11,50.03 6.12,50.12
|
||||||
|
2.52,51.10 2.60,50.88 2.76,50.75 2.84,50.71 3.11,50.78 3.23,50.66 3.27,50.53 3.60,50.48 3.69,50.31 3.95,50.34 4.17,50.25 4.15,49.97 4.55,49.96 4.82,50.15 4.86,50.14 4.79,49.96 4.87,49.79 5.28,49.68 5.51,49.51 5.79,49.54
|
||||||
|
6.12,50.12 5.98,50.17 5.74,49.92 5.72,49.81 5.88,49.64 5.79,49.54
|
||||||
|
46.43,41.89 45.95,42.04 45.64,42.21 45.71,42.50 45.34,42.53 45.16,42.68 44.87,42.76 44.77,42.62 44.64,42.73 44.51,42.75 43.83,42.57 43.74,42.62 43.78,42.75 43.09,42.99 42.99,43.09 42.76,43.17 42.57,43.16 42.42,43.22 41.58,43.22 40.65,43.53 40.15,43.57 39.98,43.42
|
||||||
|
20.96,40.85 20.93,40.90 20.71,40.93 20.49,41.27 20.45,41.52 20.52,41.57 20.57,41.87
|
||||||
|
20.57,41.87 20.58,42.01 20.49,42.22 20.24,42.34 20.06,42.55
|
||||||
|
41.51,41.52 41.82,41.43 41.93,41.50 42.47,41.44 42.61,41.58 42.75,41.58 43.15,41.31 43.21,41.20 43.40,41.18 43.44,41.11
|
||||||
|
101.14,21.57 101.20,21.52 101.25,21.20 101.54,21.23 101.70,21.15 101.80,21.21 101.72,21.31 101.74,21.83 101.60,21.99 101.52,22.25 101.74,22.50 101.84,22.39 102.02,22.44 102.13,22.38
|
||||||
|
73.63,39.45 73.82,39.49 73.91,39.58 73.84,39.80 73.99,40.04 74.41,40.14 74.68,40.31 74.83,40.33 74.84,40.48 75.11,40.45 75.56,40.63 75.68,40.31 75.87,40.30 76.26,40.43 76.32,40.35 76.48,40.45 76.66,40.78 76.91,41.02 77.58,40.99 78.12,41.08 78.44,41.42 79.29,41.78 79.77,41.90 79.84,42.00 80.22,42.03 80.21,42.19
|
||||||
|
43.44,41.11 44.23,41.21 44.84,41.21 44.81,41.26 45.00,41.29
|
||||||
|
80.21,42.19 79.92,42.41 79.43,42.48 79.13,42.78 76.99,42.97 75.84,42.94 75.64,42.81 75.37,42.84 74.82,42.98 74.21,43.24 73.95,43.20 73.56,43.00 73.42,42.59 73.49,42.41 72.28,42.76 71.76,42.82 71.26,42.73 70.89,42.34 70.95,42.25
|
||||||
|
-56.48,1.94 -56.84,1.88 -57.12,2.01 -57.32,1.96 -57.55,1.73 -57.98,1.65 -58.03,1.52 -58.34,1.59 -58.40,1.48 -58.51,1.44 -58.51,1.28 -58.68,1.28 -58.82,1.20 -59.23,1.38 -59.54,1.70 -59.67,1.75 -59.67,1.84 -59.76,1.90 -59.76,2.27 -59.89,2.36 -59.99,2.69 -59.97,2.99 -59.83,3.35 -59.85,3.59 -59.68,3.70 -59.55,3.93 -59.74,4.23 -59.70,4.38 -59.83,4.48 -60.15,4.53 -60.03,4.74 -59.99,5.08 -60.14,5.24 -60.74,5.20
|
||||||
|
8.39,55.10 8.59,54.92 9.25,54.81 9.74,54.83
|
||||||
|
9.52,30.23 9.90,30.39 10.22,30.78 10.26,30.94 10.11,31.46 10.27,31.68 10.48,31.74 10.61,31.93 10.83,32.08 11.51,32.41 11.53,32.52 11.45,32.64 11.50,33.18
|
||||||
|
45.00,41.29 45.19,41.15 45.07,41.08 45.42,40.99 45.59,40.85 45.38,40.67 45.45,40.53 45.57,40.42 45.96,40.23 45.89,40.02 45.58,39.98 46.20,39.59 46.48,39.56 46.48,39.48 46.37,39.40 46.58,39.22 46.40,39.19 46.49,39.07 46.49,38.91
|
||||||
|
44.77,39.70 45.03,39.77 45.17,39.57 45.25,39.60 45.46,39.49 45.78,39.55 45.77,39.38 45.98,39.24 45.95,39.18 46.11,38.88
|
||||||
|
20.24,46.11 20.51,46.17 20.61,46.13 20.76,46.25 21.12,46.28 21.26,46.41 21.32,46.61 21.50,46.70 21.66,47.04 22.00,47.40 22.00,47.51 22.29,47.73 22.61,47.77 22.88,47.95
|
||||||
|
70.96,40.24 71.30,40.29 71.63,40.22 71.69,40.15 71.96,40.26 72.13,40.44 72.36,40.40 72.40,40.58 72.60,40.53 73.14,40.81 72.66,40.87 72.36,41.04 72.19,41.03 72.16,41.17 71.88,41.20 71.86,41.31 71.66,41.54 71.60,41.50 71.59,41.33 71.42,41.34 71.39,41.12 70.86,41.22 70.69,41.45 70.47,41.41 70.20,41.51 70.18,41.57 70.63,41.88 70.80,41.92 70.86,42.03 71.23,42.16 71.04,42.28 70.95,42.25
|
||||||
|
18.83,49.51 18.94,49.50 18.97,49.40 19.15,49.40 19.44,49.60 19.63,49.41 19.77,49.37 19.76,49.20 20.06,49.18 20.16,49.32 20.36,49.39 20.62,49.39 20.87,49.31 21.08,49.42 21.64,49.41 21.89,49.34 22.02,49.21 22.54,49.07
|
||||||
|
-7.22,55.09 -7.38,55.03 -7.55,54.77 -7.91,54.70 -7.75,54.59 -8.12,54.48 -8.12,54.41 -7.85,54.22 -7.61,54.14 -7.32,54.13 -7.16,54.24 -7.20,54.30 -7.01,54.41 -6.80,54.21 -6.65,54.16 -6.65,54.06 -6.22,54.09
|
||||||
|
18.83,49.51 18.81,49.61 18.59,49.76 18.56,49.88 18.03,50.04 17.87,49.97 17.63,50.12 17.59,50.16 17.74,50.23 17.70,50.31 17.42,50.25 16.88,50.43 16.99,50.24 16.64,50.10 16.21,50.42 16.42,50.57 16.28,50.66 16.01,50.61 15.73,50.74 15.35,50.81 15.26,50.96 14.99,51.01 14.98,50.89 14.81,50.86
|
||||||
|
70.96,40.24 70.95,40.19 70.64,40.08 70.62,40.00 70.52,39.95 70.45,40.05 69.97,40.20 69.53,40.10 69.48,39.92 69.31,39.97 69.28,39.92 69.23,39.76 69.30,39.52 70.50,39.59 70.80,39.39 71.00,39.41 71.07,39.49 71.47,39.60 71.50,39.48 71.73,39.42 71.73,39.31 71.78,39.28 72.04,39.35 72.23,39.21 72.36,39.34 72.56,39.38 73.11,39.36 73.63,39.45
|
||||||
|
20.00,39.71 20.25,39.68 20.31,39.80 20.38,39.80 20.31,39.98 20.66,40.12 20.81,40.45 20.95,40.49 21.03,40.62 20.96,40.85
|
||||||
|
33.20,-14.01 33.15,-13.94 32.99,-14.02 32.87,-13.82 32.77,-13.76 32.80,-13.69 32.67,-13.59 32.85,-13.46 32.97,-13.22 33.00,-12.90 32.95,-12.80 33.02,-12.63 33.40,-12.49 33.51,-12.35 33.34,-12.31 33.25,-12.11 33.30,-11.69 33.23,-11.42 33.38,-11.16 33.26,-10.89 33.46,-10.78 33.66,-10.55 33.53,-10.23 33.31,-10.04 33.35,-9.86 33.20,-9.63 33.00,-9.62 32.92,-9.41
|
||||||
|
22.13,48.41 22.14,48.57 22.30,48.69 22.54,49.07
|
||||||
|
-11.51,6.91 -11.27,7.23 -10.65,7.76 -10.57,8.07 -10.36,8.19 -10.28,8.49
|
||||||
|
24.15,8.67 24.22,8.61 24.21,8.37 24.29,8.29 24.85,8.14 25.20,7.81 25.25,7.72 25.18,7.56 25.28,7.43 25.89,7.06 26.36,6.64 26.31,6.46 26.51,6.07 27.14,5.72 27.33,5.19
|
||||||
|
42.92,11.00 43.25,11.50
|
||||||
|
42.92,11.00 42.56,11.08 41.96,10.94 41.80,10.98 41.79,11.69 42.38,12.47
|
||||||
|
29.40,-4.45 29.32,-4.90 29.61,-5.72 29.48,-6.03 29.54,-6.31 29.71,-6.62 30.21,-7.04 30.75,-8.19
|
||||||
|
42.38,12.47 42.48,12.51 42.70,12.38 42.87,12.62 43.12,12.71
|
||||||
|
13.81,48.77 14.05,48.60 14.69,48.60 14.79,48.75 14.92,48.77 14.97,48.98 15.07,49.00 15.83,48.86 16.06,48.75 16.37,48.74 16.54,48.80 16.88,48.70 16.95,48.60
|
||||||
|
42.36,37.11 42.77,37.37 42.94,37.32 43.09,37.37 43.68,37.23 44.11,37.30 44.19,37.25 44.20,37.05 44.28,36.98 44.61,37.18 44.77,37.14
|
||||||
|
16.09,46.86 16.25,46.97 16.45,47.01 16.49,47.12 16.42,47.22 16.44,47.40 16.62,47.45 16.68,47.54 16.64,47.61 16.42,47.67 16.59,47.75 16.79,47.68 17.07,47.71 17.03,47.84 17.15,48.01
|
||||||
|
7.02,45.93 7.13,45.88 7.54,45.98 7.79,45.92 7.99,46.02 8.13,46.16 8.10,46.27 8.42,46.45 8.46,46.25 8.64,46.11 8.82,46.08 8.78,46.00 8.95,45.83 9.02,45.85 9.00,46.01 9.25,46.29 9.26,46.48 9.43,46.48 9.53,46.31 9.94,46.36 10.04,46.24 10.13,46.24 10.04,46.48 10.09,46.60 10.43,46.55 10.40,46.67 10.45,46.86
|
||||||
|
10.45,46.86 10.35,46.98 10.13,46.85 9.88,46.94 9.85,47.01 9.58,47.06
|
||||||
|
44.77,37.14 44.79,37.29 44.57,37.44 44.55,37.66 44.59,37.71 44.21,37.91 44.45,38.33 44.30,38.39 44.27,38.84 44.17,38.93 44.18,39.14 44.08,39.22 44.02,39.38 44.39,39.42 44.46,39.67 44.59,39.77 44.82,39.65
|
||||||
|
23.48,53.94 23.45,54.14 23.37,54.20 22.89,54.39 22.77,54.36
|
||||||
|
30.75,-8.19 30.89,-8.47 31.03,-8.60 31.35,-8.61 31.53,-8.71 31.67,-8.91 31.92,-8.94 31.94,-9.05 32.43,-9.16 32.92,-9.41
|
||||||
|
7.20,53.28 7.19,53.00 7.03,52.65 6.71,52.62 6.75,52.46 6.97,52.44 7.04,52.38 6.98,52.21 6.72,52.08 6.80,51.97 6.52,51.85 6.36,51.82 6.17,51.88 5.95,51.80 6.20,51.45 6.08,51.22 6.13,51.15 5.86,51.03 6.05,50.90 5.99,50.75
|
||||||
|
9.58,47.06 9.61,47.11 9.53,47.27
|
||||||
|
-8.49,7.56 -8.43,7.60 -8.23,7.56 -8.01,8.08 -8.05,8.17 -8.26,8.25 -8.24,8.46 -8.05,8.50 -7.82,8.47 -7.74,8.38 -7.68,8.41 -7.72,8.64 -7.95,8.79 -7.94,8.98 -7.78,9.08 -7.92,9.19 -7.90,9.42 -8.03,9.40 -8.14,9.50 -8.16,9.97 -7.99,10.16
|
||||||
|
7.49,43.77 7.48,43.86 7.68,44.08 7.64,44.16 7.32,44.14 6.90,44.34 6.84,44.51 7.03,44.72 6.99,44.83 6.74,44.92 6.63,45.07 6.69,45.14 6.84,45.14 7.08,45.24 7.15,45.38 6.79,45.74 6.80,45.81 7.02,45.93
|
||||||
|
28.97,69.02 28.41,68.90 28.77,68.84 28.48,68.54 28.56,68.35 28.69,68.19 29.34,68.06 29.99,67.67 29.94,67.55 29.24,67.10 29.07,66.89 29.90,66.09 30.09,65.79 30.10,65.68 29.72,65.62 29.82,65.57 29.73,65.47 29.71,65.34 29.61,65.25 29.81,65.20 29.83,65.15 29.62,65.04 29.60,64.97 29.78,64.80 30.11,64.73 30.12,64.64 29.99,64.52 30.11,64.37 30.49,64.24 30.53,64.08 30.21,63.80 29.99,63.74 31.18,63.21 31.53,62.89 31.19,62.48 29.25,61.29 28.41,60.90 27.73,60.46
|
||||||
|
30.87,69.78 30.92,69.61 30.86,69.54 30.62,69.53 30.18,69.64 30.19,69.54 30.09,69.43 29.39,69.30 29.21,69.10 28.97,69.02
|
||||||
|
55.19,22.70 55.20,23.03 55.53,23.82 55.47,23.94 55.99,24.06 55.93,24.22 55.76,24.24 55.80,24.87 55.92,24.97 56.00,24.95 55.97,24.86 56.06,24.74 56.39,24.98
|
||||||
|
48.57,41.84 48.39,41.60 48.06,41.46 47.79,41.20 47.32,41.28 47.14,41.52 46.75,41.81 46.57,41.80 46.54,41.87 46.43,41.89
|
||||||
|
-54.62,2.33 -54.72,2.44 -54.88,2.45 -54.98,2.60 -55.39,2.44 -55.73,2.41 -55.96,2.52 -56.14,2.26 -55.92,2.04 -55.93,1.89 -56.02,1.84 -56.48,1.94
|
||||||
|
46.43,41.89 46.20,41.74 46.18,41.66 46.31,41.51 46.67,41.29 46.53,41.09 46.43,41.08 46.17,41.20 45.92,41.19 45.73,41.26 45.72,41.34 45.28,41.45 45.00,41.29
|
||||||
|
-11.39,12.40 -11.45,12.56 -11.39,12.94 -11.63,13.37 -11.76,13.39 -11.83,13.32 -12.05,13.63 -11.96,13.88 -12.02,13.97 -12.02,14.21 -12.23,14.46 -12.19,14.65 -12.28,14.81
|
||||||
|
-54.62,2.33 -54.13,2.12 -53.77,2.35 -53.51,2.25 -53.33,2.34 -53.23,2.20 -52.90,2.21 -52.58,2.53 -52.33,3.18 -51.99,3.70 -51.65,4.06
|
||||||
|
2.71,6.37 2.77,6.71 2.72,6.98 2.79,7.48 2.69,7.87 2.77,9.05 3.04,9.08 3.15,9.32 3.14,9.45 3.33,9.67 3.35,9.81 3.56,9.91 3.65,10.16 3.58,10.29 3.65,10.41 3.77,10.42 3.83,10.61 3.72,11.08 3.49,11.40 3.60,11.70
|
||||||
|
13.07,-4.63 12.80,-4.43 12.38,-4.62 12.31,-4.77 12.20,-4.78 12.02,-5.00
|
||||||
|
16.52,46.50 16.32,46.53 16.24,46.48 16.23,46.37 16.07,46.37 15.64,46.20 15.59,46.14 15.67,46.05 15.65,45.86 15.28,45.73 15.35,45.66 15.28,45.58 15.34,45.47 14.79,45.48 14.57,45.66 14.37,45.48 13.99,45.51 13.88,45.43 13.58,45.52
|
||||||
|
18.91,45.93 18.67,45.91 18.53,45.80 18.36,45.75 17.81,45.79 17.31,46.00 16.87,46.34 16.52,46.50
|
||||||
|
20.24,46.11 19.61,46.17 19.28,46.00 19.09,46.02 18.91,45.93
|
||||||
|
16.52,46.50 16.28,46.86 16.09,46.86
|
||||||
|
51.27,24.61 50.97,24.57 50.80,24.79
|
||||||
|
25.26,-17.79 25.24,-17.97 25.94,-18.94 25.95,-19.08 26.17,-19.54 26.68,-19.89 27.18,-20.10 27.26,-20.23 27.28,-20.48 27.68,-20.50 27.67,-21.06 28.01,-21.55 29.03,-21.80 29.04,-22.02 29.36,-22.19
|
||||||
|
77.80,35.50 77.45,35.48 76.77,35.66
|
||||||
|
28.01,41.97 27.53,41.92 27.24,42.09 27.01,42.06 26.58,41.95 26.51,41.83 26.36,41.80 26.32,41.72
|
||||||
|
102.93,11.71 102.74,12.09 102.76,12.43 102.50,12.67 102.46,13.02 102.33,13.29 102.34,13.56 102.55,13.59 102.54,13.66 102.91,14.14 103.20,14.33 103.60,14.42 103.98,14.36 104.78,14.43 104.97,14.37 105.00,14.25 105.07,14.23 105.18,14.35
|
||||||
|
88.89,27.32 88.74,27.18 88.86,26.96 89.15,26.82 89.33,26.85 89.55,26.80 89.61,26.72 90.12,26.75 90.35,26.89 90.74,26.77 91.29,26.79 91.43,26.87 91.67,26.80 92.05,26.87 92.07,26.98 91.99,27.10 92.08,27.29 91.99,27.45 91.74,27.44 91.59,27.56 91.63,27.76
|
||||||
|
104.07,1.28 104.06,1.40 103.82,1.46 103.70,1.44 103.60,1.21
|
||||||
|
100.12,6.44 100.18,6.67 100.26,6.68 100.35,6.55 100.75,6.46 100.87,6.25 101.05,6.24 101.08,5.96 100.98,5.77 101.11,5.64 101.26,5.79 101.56,5.91 101.68,5.78 101.87,5.83 102.10,6.24
|
||||||
|
5.99,50.75 6.24,50.60 6.17,50.55 6.20,50.50 6.34,50.45 6.36,50.32 6.18,50.23 6.12,50.12
|
||||||
|
-71.78,19.72 -71.71,19.49 -71.75,19.29 -71.65,19.16 -71.81,18.99 -71.74,18.73 -71.87,18.61 -72.00,18.60 -71.76,18.34 -71.77,18.04
|
||||||
|
14.06,13.08 13.61,13.70
|
||||||
|
46.53,29.10 46.91,29.54 47.15,30.00 47.33,30.08 47.64,30.10 47.98,29.98
|
||||||
|
88.11,27.87 88.14,27.95 88.58,28.09 88.80,28.01 88.85,27.87 88.75,27.52 88.89,27.32
|
||||||
|
-90.10,13.74 -90.05,13.90 -89.55,14.24 -89.57,14.39 -89.36,14.42
|
||||||
|
124.04,-9.34 124.09,-9.42 124.28,-9.43 124.41,-9.31 124.44,-9.19
|
||||||
|
124.92,-8.94 124.94,-9.05 125.10,-9.00 125.15,-9.04 125.10,-9.19 124.96,-9.21 125.07,-9.51
|
||||||
|
115.14,4.90 115.27,4.63 115.29,4.35 115.11,4.39 115.03,4.90
|
||||||
|
109.63,2.03 109.54,1.90 109.64,1.78 109.65,1.61 110.51,0.86 111.10,1.05 111.81,1.01 111.92,1.11 112.08,1.14 112.19,1.44 112.48,1.56 112.94,1.57 113.01,1.43 113.62,1.24 113.90,1.43 114.39,1.50 114.51,1.45 114.70,1.85 114.83,1.98 114.76,2.16 114.79,2.25 115.18,2.52 115.08,2.63 115.12,2.89 115.25,3.03 115.45,3.03 115.57,3.45 115.57,3.94 115.68,4.19 115.84,4.33 116.02,4.29 116.24,4.36 116.41,4.31 116.51,4.37 117.10,4.34 117.54,4.17 117.88,4.19
|
||||||
|
10.45,46.86 10.99,46.78 11.24,46.98 11.78,46.99 12.17,47.08 12.15,46.94 12.39,46.70 13.70,46.52
|
||||||
|
13.70,46.52 13.38,46.26 13.63,46.18 13.48,46.01 13.61,45.96 13.58,45.81 13.87,45.61 13.72,45.59
|
||||||
|
29.01,-2.72 29.10,-2.60 29.30,-2.67 29.39,-2.81 29.70,-2.79 29.87,-2.72 29.93,-2.34 30.12,-2.42 30.41,-2.31 30.55,-2.40
|
||||||
|
92.57,21.98 92.63,21.31 92.57,21.26 92.33,21.44 92.18,21.29 92.32,20.79
|
||||||
|
1.43,42.60 1.29,42.71 0.70,42.85 0.63,42.69 -0.04,42.69 -0.30,42.83 -0.59,42.80 -0.76,42.94 -1.18,43.02 -1.30,43.10 -1.46,43.05 -1.41,43.24 -1.71,43.31 -1.79,43.41
|
||||||
|
1.43,42.60 1.50,42.64 1.71,42.60 1.71,42.50
|
||||||
|
1.71,42.50 2.03,42.35 2.20,42.42 2.65,42.34 2.89,42.46 3.21,42.43
|
||||||
|
1.43,42.60 1.45,42.44 1.71,42.50
|
||||||
|
74.89,37.23 75.12,37.39 74.89,37.60 74.94,37.77 74.78,38.19 74.81,38.46 74.28,38.66 74.13,38.66 74.03,38.54 73.80,38.61 73.70,38.85 73.81,38.97 73.61,39.23 73.63,39.45
|
||||||
|
74.89,37.23 74.73,37.29 74.37,37.16 74.54,37.02
|
||||||
|
20.96,40.85 21.40,40.91 21.58,40.87 21.99,41.13 22.60,41.14 22.72,41.18 22.78,41.33 22.92,41.34
|
||||||
|
21.56,42.25 21.39,42.22 21.29,42.10 21.06,42.17 20.78,42.07 20.72,41.87 20.57,41.87
|
||||||
|
22.34,42.31 22.24,42.36 21.56,42.25
|
||||||
|
22.34,42.31 22.52,42.44 22.44,42.63 22.47,42.84 22.71,42.88 22.98,43.19 22.55,43.45 22.37,43.78 22.42,44.01 22.60,44.08 22.63,44.19 22.71,44.24
|
||||||
|
22.92,41.34 23.00,41.74 22.84,41.99 22.58,42.10 22.34,42.31
|
||||||
|
18.44,42.56 18.52,42.43
|
||||||
|
18.44,42.56 17.80,42.90 17.67,42.90
|
||||||
|
78.92,33.39 79.12,33.23
|
||||||
|
20.34,42.83 20.47,42.86 20.48,42.95 20.62,43.03 20.62,43.20 20.80,43.26 20.84,43.17 21.06,43.09 21.40,42.83 21.39,42.75 21.75,42.67 21.61,42.39 21.52,42.33 21.56,42.25
|
||||||
|
20.34,42.83 20.05,42.76 20.06,42.55
|
||||||
|
19.19,43.53 19.22,43.45 19.61,43.17 20.27,42.94 20.34,42.83
|
||||||
|
-89.36,14.42 -89.12,14.37 -88.71,14.03 -88.51,13.98 -88.48,13.85 -88.15,13.99 -87.99,13.88 -87.80,13.89 -87.72,13.81 -87.78,13.52 -87.74,13.45 -87.81,13.40
|
||||||
|
-87.34,12.98 -87.01,13.01 -86.92,13.22 -86.73,13.28 -86.76,13.75 -86.33,13.77 -86.04,14.05 -85.98,13.97 -85.73,13.86 -85.68,13.98 -85.18,14.34 -85.16,14.53 -85.04,14.61 -84.99,14.75 -84.86,14.81 -84.65,14.66 -84.45,14.64 -84.24,14.75 -84.15,14.72 -83.64,14.88 -83.54,14.98 -83.16,14.99
|
||||||
|
-75.28,-0.11 -75.78,0.09 -76.07,0.35 -76.27,0.44 -76.39,0.40 -76.43,0.26 -76.49,0.24 -76.83,0.25 -77.40,0.39 -77.47,0.64 -77.65,0.72 -77.70,0.84 -77.83,0.83 -78.18,0.97 -78.86,1.46
|
||||||
|
-57.60,3.37 -57.65,3.52 -57.83,3.68 -58.03,4.00 -58.05,4.17 -57.85,4.67 -57.92,4.82 -57.88,4.88 -57.71,4.99 -57.33,5.02 -57.21,5.20 -57.32,5.34 -57.19,5.55
|
||||||
|
-54.08,3.33 -53.99,3.59 -54.35,4.05 -54.48,4.84 -54.45,5.01 -54.16,5.36
|
||||||
|
-58.41,-33.94 -58.48,-33.65 -58.38,-33.14 -58.24,-33.11 -58.13,-32.98 -58.20,-32.47
|
||||||
|
-57.61,-30.19 -57.87,-30.59 -57.81,-30.86 -57.90,-30.98 -57.89,-31.20 -58.05,-31.49 -57.99,-31.58 -58.01,-31.68 -58.19,-31.92 -58.12,-32.25 -58.20,-32.47
|
||||||
|
-54.62,-25.58 -54.44,-25.62 -54.15,-25.52 -53.89,-25.67 -53.67,-26.29 -53.75,-26.75 -53.72,-26.88 -53.84,-27.12 -54.16,-27.25 -54.33,-27.42 -54.83,-27.55 -54.91,-27.71 -55.10,-27.87 -55.73,-28.20 -55.69,-28.38 -55.89,-28.37 -56.94,-29.59 -57.22,-29.78 -57.41,-30.03 -57.61,-30.19
|
||||||
|
20.62,69.04 20.90,68.98 20.92,68.91 22.00,68.52 22.85,68.37 23.10,68.26 23.18,68.14 23.32,68.13 23.64,67.95 23.50,67.88 23.54,67.61 23.45,67.46 23.73,67.42 23.77,67.33 23.63,67.23 23.64,67.13 23.99,66.81 23.67,66.38 23.72,66.22 23.91,66.15 24.16,65.81 24.19,65.61 24.12,65.51
|
||||||
|
4.23,51.39 4.17,51.31 3.90,51.21 3.58,51.29 3.43,51.25 3.35,51.38
|
||||||
|
5.99,50.75 5.75,50.76 5.64,50.84 5.75,50.95 5.80,51.15 5.48,51.29 5.21,51.28 5.03,51.47 4.85,51.40 4.76,51.49 4.63,51.42 4.50,51.47 4.38,51.43 4.37,51.36 4.23,51.39
|
||||||
|
6.34,49.45 6.53,49.39 6.74,49.16 6.89,49.21 7.04,49.11 7.45,49.15 7.61,49.06 8.13,48.97 8.14,48.89 7.84,48.64 7.62,48.16 7.53,47.67 7.62,47.59
|
||||||
|
9.52,47.52 9.18,47.67 8.88,47.66 8.57,47.78 8.40,47.69 8.57,47.65 8.45,47.60 7.62,47.59
|
||||||
|
9.53,47.27 9.63,47.47 9.52,47.52
|
||||||
|
9.58,47.06 9.49,47.06 9.53,47.27
|
||||||
|
7.02,45.93 6.77,46.17 6.82,46.28 6.76,46.42 6.43,46.43 6.23,46.33 6.27,46.25 6.20,46.19 6.01,46.14 5.97,46.21 6.10,46.28 6.12,46.38 6.07,46.46 6.16,46.61 6.41,46.76 6.46,46.95 6.67,47.03 7.00,47.32 6.90,47.39 6.97,47.45 7.14,47.49 7.34,47.43 7.62,47.59
|
||||||
|
-7.41,37.18 -7.50,37.59 -7.44,37.73 -7.19,38.01 -7.02,38.04 -6.96,38.19 -7.11,38.18 -7.34,38.46 -7.28,38.71 -7.05,38.91 -7.00,39.06 -7.17,39.14 -7.34,39.47 -7.54,39.66 -7.12,39.68 -6.98,39.80 -6.90,40.02 -7.03,40.17 -6.81,40.34 -6.84,40.78 -6.93,41.01 -6.21,41.53 -6.31,41.64 -6.54,41.67 -6.62,41.94 -7.15,41.98 -7.21,41.90 -7.40,41.83 -7.92,41.88 -8.15,41.81 -8.22,41.90 -8.13,42.02 -8.27,42.14 -8.78,41.94
|
||||||
|
13.81,48.77 13.79,48.59 13.72,48.54 13.49,48.58 13.37,48.36 12.76,48.11 12.95,47.89 12.90,47.72 13.05,47.66 13.01,47.48 12.81,47.54 12.77,47.64 12.69,47.67 12.48,47.64 12.21,47.72 12.19,47.62 11.72,47.58 11.04,47.39 10.87,47.52 10.44,47.55 10.37,47.37 10.18,47.28 10.20,47.36 10.07,47.39 9.97,47.51 9.75,47.58 9.52,47.52
|
||||||
|
16.09,46.86 15.98,46.80 15.96,46.68 15.77,46.71 15.44,46.63 14.89,46.61 14.55,46.40 13.70,46.52
|
||||||
|
18.44,42.56 18.55,42.64 18.47,42.78 18.46,43.00 18.62,43.03 18.67,43.23 18.85,43.35 19.03,43.29 18.95,43.53 19.19,43.53
|
||||||
|
19.19,43.53 19.30,43.59 19.45,43.56 19.50,43.64 19.25,43.97 19.58,44.01 19.12,44.36 19.35,44.88 18.84,44.88 18.66,45.08 18.00,45.14 17.81,45.08 17.65,45.16 17.50,45.12 17.13,45.17 16.92,45.28 16.79,45.20 16.53,45.22 16.29,45.01 16.03,45.19 15.79,45.18 15.74,44.77 16.10,44.52 16.30,44.12 17.08,43.52 17.27,43.45 17.29,43.31 17.65,43.01 17.59,42.94
|
||||||
|
19.01,44.87 19.10,44.97 19.06,45.14 19.14,45.20 19.40,45.21 19.00,45.40 19.06,45.52 18.92,45.60 18.95,45.66 18.84,45.84 18.91,45.93
|
||||||
|
16.95,48.60 17.14,48.84 17.76,48.89 18.09,49.07 18.16,49.26 18.60,49.49 18.83,49.51
|
||||||
|
17.15,48.01 16.87,48.39 16.95,48.60
|
||||||
|
17.15,48.01 17.32,47.99 17.76,47.77 18.72,47.79 18.79,48.00 19.47,48.11 19.63,48.22 19.95,48.15 20.33,48.30 20.49,48.53 21.45,48.55 21.72,48.35 22.13,48.41
|
||||||
|
14.81,50.86 15.02,51.25 14.91,51.46 14.72,51.52 14.74,51.63 14.60,51.83 14.75,52.08 14.55,52.36 14.62,52.53 14.13,52.88 14.41,53.22 14.21,53.95
|
||||||
|
22.88,47.95 22.77,48.11 22.58,48.13 22.35,48.26 22.25,48.41 22.13,48.41
|
||||||
|
28.59,43.74 28.22,43.77 28.05,43.82 27.88,43.99 27.74,43.96 27.43,44.02 27.09,44.17 26.22,44.01 25.82,43.77 25.50,43.67 23.22,43.87 22.92,43.83 22.87,43.95 23.03,44.08 22.71,44.24
|
||||||
|
20.24,46.11 20.71,45.74 20.77,45.75 20.79,45.47 21.49,45.15 21.35,45.01 21.53,44.90 21.36,44.83
|
||||||
|
22.92,41.34 23.64,41.39 24.01,41.46 24.06,41.53 24.49,41.56 24.77,41.36 24.99,41.36 25.25,41.24 25.92,41.31 26.14,41.39 26.07,41.67 26.11,41.73 26.41,41.70 26.58,41.60 26.62,41.40 26.33,41.24 26.33,40.95 26.04,40.73
|
||||||
|
20.06,42.55 19.79,42.48 19.73,42.63 19.65,42.63 19.28,42.17 19.36,42.07 19.34,41.87
|
||||||
|
26.62,48.26 26.79,48.26 26.98,48.16 27.61,47.34 28.07,46.98 28.24,46.64 28.24,46.45 28.10,45.97 28.16,45.65 28.07,45.60 28.21,45.45
|
||||||
|
22.88,47.95 23.20,48.08 23.41,47.99 24.18,47.91 24.58,47.93 24.89,47.72 24.98,47.72 25.46,47.91 26.16,47.99 26.31,48.20 26.62,48.26
|
||||||
|
22.54,49.07 22.81,49.02 22.85,49.06 22.71,49.17 22.73,49.30 22.65,49.54 22.71,49.61 23.71,50.38 23.97,50.41 24.09,50.53 23.98,50.79 24.10,50.87 23.66,51.31 23.61,51.52
|
||||||
|
20.90,55.29 21.39,55.28 22.07,55.06 22.57,55.06 22.82,54.87 22.68,54.56 22.77,54.36
|
||||||
|
31.76,52.10 31.58,52.31 31.62,52.55 31.52,52.70 31.56,52.76 31.26,53.02 31.42,53.20 31.67,53.20 31.85,53.11 32.14,53.09 32.70,53.34 32.71,53.42 32.47,53.55 32.45,53.69 32.20,53.78 31.75,53.81 31.83,54.03 31.40,54.20 31.18,54.45 31.07,54.49 31.15,54.63 30.80,54.78 30.83,54.91 30.98,55.05 30.81,55.31 30.90,55.40 30.91,55.57 30.23,55.85 29.94,55.85 29.48,55.68 29.35,55.78 29.38,55.94 29.03,56.02 28.79,55.94 28.56,56.09 28.28,56.06 28.15,56.14
|
||||||
|
31.76,52.10 32.12,52.05 32.28,52.11 32.44,52.31 32.81,52.25 33.15,52.34 33.74,52.34 33.92,52.25 34.11,51.98 34.40,51.78 34.38,51.72 34.12,51.68 34.27,51.34 34.21,51.26 34.71,51.17 35.06,51.20 35.16,51.06 35.31,51.04 35.44,50.73 35.41,50.54 35.59,50.37 35.67,50.35 35.89,50.44 36.12,50.41 36.31,50.28 36.50,50.28 36.62,50.21 36.76,50.29 37.42,50.41 37.70,50.11 38.05,49.92 38.26,50.05 38.92,49.82 39.17,49.86 39.30,49.74 39.46,49.73 39.78,49.57 40.08,49.58 40.11,49.25 39.89,49.06 39.69,49.01 39.75,48.91 40.00,48.82 39.79,48.81 39.64,48.59 39.84,48.54 39.89,48.36 39.85,48.30 39.96,48.27 39.78,47.89 39.66,47.84 38.90,47.86 38.64,47.67 38.37,47.61 38.29,47.56 38.20,47.32 38.28,47.28 38.20,47.18 38.21,47.09
|
||||||
|
43.44,41.11 43.63,40.93 43.72,40.72 43.57,40.48 43.71,40.17 43.67,40.13 44.01,40.01 44.29,40.04 44.78,39.68
|
||||||
|
87.82,49.16 88.12,49.26 88.19,49.45 88.83,49.45 88.90,49.54 89.01,49.47 89.11,49.50 89.24,49.63 89.40,49.61 89.65,49.72 89.64,49.90 89.98,49.98 90.05,50.09 90.66,50.22 91.02,50.42 91.42,50.47 91.80,50.69 92.19,50.70 92.35,50.86 92.63,50.69 92.78,50.78 92.94,50.78 93.10,50.60 94.25,50.56 94.35,50.22 94.61,50.02 94.93,50.04 95.11,49.94 95.52,49.91 95.79,50.01 95.94,49.96 96.07,50.00 96.32,49.90 96.99,49.88 97.21,49.73 97.36,49.74 97.59,49.91 98.00,50.01 98.25,50.30 98.28,50.53 98.03,50.64 97.84,51.05 98.04,51.45 98.22,51.51 98.35,51.72 98.64,51.80 98.89,52.12 99.72,51.87 99.92,51.76 100.47,51.73 101.38,51.45 102.11,51.35 102.22,50.83 102.32,50.72 102.29,50.59 102.68,50.39 103.16,50.29 103.30,50.20 103.63,50.14 104.08,50.15 104.47,50.31 105.38,50.47 106.22,50.30 106.71,50.31 107.23,49.99 107.92,49.95 107.97,49.65 108.61,49.32 109.24,49.33 110.71,49.14 111.34,49.36 112.08,49.42 112.49,49.53 112.81,49.52 113.06,49.62 113.16,49.80 113.57,50.01 114.30,50.27 114.74,50.23 115.43,49.90 115.72,49.88 116.22,50.01 116.55,49.92 116.68,49.82
|
||||||
|
116.68,49.82 117.25,49.62 117.87,49.51 118.45,49.84 119.26,50.07 119.35,50.28 119.16,50.41 119.45,50.70 120.07,51.60 120.51,51.85 120.75,52.10 120.67,52.30 120.70,52.49 120.66,52.57 120.17,52.60 120.07,52.63 120.04,52.72 120.70,53.17 120.99,53.28 122.34,53.49 123.61,53.55 124.81,53.13 125.08,53.20 125.65,53.04 125.73,52.89 125.87,52.87 126.05,52.74 126.02,52.61 126.34,52.36 126.51,51.93 126.70,51.70 126.71,51.57 126.81,51.51 126.92,51.10 127.31,50.71 127.34,50.35 127.59,50.21 127.49,49.98 127.55,49.80 127.71,49.67 128.00,49.57 128.70,49.60 128.82,49.46 129.07,49.37 129.50,49.39 130.20,48.89 130.55,48.86 130.62,48.77 130.55,48.60 130.80,48.34 130.71,48.13 130.73,48.02 130.92,47.84 130.96,47.71 132.48,47.71 132.71,47.95 133.14,48.11 133.47,48.10 133.84,48.27 134.29,48.37 134.67,48.25 134.67,48.15 134.57,48.02 134.75,47.72 134.54,47.49 134.29,47.41 134.17,47.30 134.20,47.13 133.87,46.50 133.86,46.25 133.70,46.14 133.65,45.96 133.51,45.88 133.44,45.60 133.19,45.49 133.11,45.13 132.94,45.03 131.98,45.24 131.85,45.33 131.45,44.98 130.98,44.84 131.26,44.07 131.17,43.70 131.18,43.51 131.26,43.38 131.07,42.90 130.58,42.81 130.42,42.73 130.58,42.62 130.53,42.54
|
||||||
|
130.53,42.54 130.25,42.74 130.24,42.89 129.90,43.00 129.70,42.45 129.52,42.38 129.31,42.41 129.20,42.22 128.92,42.04 128.05,41.99 128.03,41.90 128.29,41.61 128.15,41.39 128.01,41.45 127.69,41.44 127.18,41.53 126.95,41.77 126.72,41.72 126.60,41.64 126.49,41.36 125.99,40.90 125.73,40.87 125.42,40.66 124.89,40.46 124.39,40.10 124.36,40.00
|
||||||
|
130.53,42.54 130.69,42.30
|
||||||
|
70.96,40.24 70.65,40.20 70.37,40.38 70.38,40.45 70.75,40.72 70.40,41.04 70.29,40.89 69.71,40.66 69.41,40.80 69.36,40.77 69.31,40.63 69.21,40.57 69.30,40.33 69.22,40.29 69.27,40.20 68.95,40.22 68.63,40.17 68.97,40.09 68.80,40.05 68.87,39.91 68.64,39.84 68.59,39.63 68.46,39.54 67.72,39.62 67.43,39.47 67.36,39.22 67.65,39.13 67.69,38.99 68.04,38.98 68.13,38.93 68.05,38.67 68.09,38.47 68.35,38.21 68.29,38.03 67.81,37.49 67.76,37.17
|
||||||
|
35.89,35.92 36.15,35.83 36.20,35.94 36.35,36.00 36.38,36.17 36.64,36.23 36.54,36.46 36.66,36.80 36.94,36.76 37.07,36.65 37.44,36.64 38.19,36.90 38.44,36.86 38.77,36.69 39.36,36.68 40.02,36.83 40.71,37.10 41.52,37.09 42.06,37.21 42.20,37.30 42.31,37.23 42.36,37.11
|
||||||
|
35.45,31.48 35.40,31.23 35.44,31.13 35.14,30.42 35.14,30.14 34.97,29.56
|
||||||
|
35.55,32.40 35.56,31.77 35.45,31.48
|
||||||
|
35.61,32.68 35.55,32.40
|
||||||
|
88.11,27.87 87.14,27.84 86.61,28.10 86.52,27.96 86.41,27.93 86.14,28.11 86.08,28.08 86.06,27.93 85.99,27.91 85.68,28.28 85.12,28.32 85.09,28.37 85.16,28.59 84.80,28.56 84.23,28.91 84.10,29.22 83.94,29.28 83.58,29.18 83.16,29.61 82.85,29.68 82.22,30.06 82.04,30.33 81.64,30.39 81.42,30.34 81.18,30.04 81.11,30.04 81.01,30.16
|
||||||
|
97.32,28.22 97.34,27.94 96.88,27.59 96.90,27.44 97.10,27.12 96.95,27.13 96.73,27.33 96.19,27.26 95.46,26.76 95.13,26.60 95.05,26.35 95.13,26.04 95.02,25.91 94.95,25.70 94.58,25.32 94.55,25.22 94.71,25.05 94.29,24.32 94.13,23.88 93.68,24.01 93.49,23.97 93.33,24.06 93.41,23.68 93.39,23.34 93.35,23.08 93.31,23.03 93.16,23.03 93.08,22.72 93.15,22.23 93.04,22.18 92.96,22.00 92.85,22.01 92.69,22.13 92.57,21.98
|
||||||
|
100.12,20.32 99.89,20.42 99.72,20.33 99.46,20.36 99.49,20.15 99.34,20.08 99.07,20.10 98.92,19.77 98.37,19.69 98.02,19.75 97.82,19.46 97.80,19.13 97.71,18.93 97.75,18.59 97.52,18.50 97.37,18.52 97.45,18.36 97.63,18.29 97.74,17.94 97.71,17.80 98.44,16.98 98.48,16.73 98.66,16.33 98.84,16.42 98.89,16.35 98.82,16.18 98.59,16.05 98.56,15.37 98.19,15.20 98.20,14.98 98.25,14.81 98.57,14.36 98.93,14.05 99.14,13.72 99.18,13.23 99.12,13.03 99.22,12.74 99.41,12.55 99.61,11.78 99.19,11.11 98.76,10.66 98.70,10.19
|
||||||
|
103.09,18.14 103.09,18.14
|
||||||
|
103.09,18.14 103.26,18.28 103.29,18.41 103.95,18.32 104.43,17.70 104.74,17.46 104.82,17.30 104.74,16.88 104.82,16.47 105.05,16.16 105.41,15.99 105.40,15.83 105.64,15.66 105.49,15.26 105.55,14.93 105.50,14.59 105.42,14.47 105.18,14.35
|
||||||
|
100.12,20.32 100.14,20.25 100.32,20.39 100.52,20.18 100.54,20.09 100.40,19.76 100.51,19.55 100.74,19.51 100.97,19.61 101.21,19.55 101.20,19.33 101.29,18.98 101.05,18.44 101.14,18.14 100.91,17.58 100.96,17.54 101.17,17.50 101.69,17.89 101.77,18.03 101.95,18.08 102.10,18.21 102.55,17.97 102.66,17.82 102.81,17.95 103.05,18.03 103.09,18.14
|
||||||
|
101.14,21.57 101.08,21.47 100.70,21.25 100.54,20.99 100.52,20.92 100.62,20.86 100.25,20.73 100.12,20.32
|
||||||
|
97.32,28.22 97.54,28.51 97.60,28.52 98.06,28.19 98.30,27.55 98.45,27.66 98.65,27.57 98.74,26.79 98.69,26.19 98.56,26.07 98.66,25.86 98.47,25.79 98.33,25.59 98.14,25.57 98.01,25.29 97.82,25.25 97.71,25.03 97.74,24.87 97.58,24.77 97.53,24.49 97.67,24.38 97.71,24.23 97.56,23.91 97.69,23.90 98.21,24.11 98.58,24.07 98.84,24.12 98.68,23.91 98.83,23.62 98.80,23.52 98.88,23.38 98.86,23.19 99.42,23.07 99.51,22.96 99.39,22.83 99.34,22.50 99.21,22.28 99.19,22.13 99.92,22.03 99.94,21.76 100.10,21.66 100.15,21.48 100.60,21.47 101.08,21.76 101.13,21.74 101.14,21.57
|
||||||
|
102.13,22.38 102.58,21.90 102.66,21.68 102.74,21.68 102.82,21.81 102.95,21.68 102.85,21.27 103.10,20.89 103.64,20.70 104.10,20.95 104.58,20.65 104.37,20.44 104.62,20.37 104.70,20.21 104.85,20.20 104.93,20.02 104.59,19.62 104.03,19.68 104.06,19.48 103.89,19.30 104.72,18.80 105.15,18.65 105.11,18.41 105.33,18.19 105.46,18.15 105.69,17.74 106.50,16.95 106.55,16.65 106.66,16.49 106.74,16.45 106.85,16.52 106.93,16.35 107.40,16.04 107.39,15.95 107.17,15.80 107.65,15.26 107.48,14.98 107.52,14.71
|
||||||
|
27.40,5.11 27.07,5.20 26.82,5.06 25.53,5.31 25.40,5.26 25.25,5.02 25.07,4.97 24.77,4.93 24.32,4.99 23.42,4.66 23.12,4.74 22.86,4.72 22.71,4.59 22.42,4.13 21.69,4.28 21.54,4.24 20.56,4.46 20.23,4.83 19.81,5.09 19.50,5.13 19.07,4.89 18.83,4.52 18.59,4.35 18.61,3.48
|
||||||
|
30.55,-2.40 30.42,-2.64 30.47,-2.69 30.43,-2.87 30.78,-2.98 30.79,-3.27 30.63,-3.35 30.63,-3.42 30.43,-3.59 30.15,-4.09 29.95,-4.31 29.72,-4.46 29.40,-4.45
|
||||||
|
29.01,-2.72 29.02,-2.80 29.22,-3.05 29.21,-3.83 29.40,-4.45
|
||||||
|
29.58,-1.39 29.35,-1.52 29.20,-1.72 29.13,-1.86 29.13,-2.20 28.88,-2.40 28.89,-2.64 29.01,-2.72
|
||||||
|
35.27,5.49 34.98,5.86 34.71,6.66 34.06,7.23 33.90,7.51 33.67,7.67 33.23,7.76 33.00,7.90 33.28,8.44 33.95,8.44 34.07,8.55 34.08,9.46
|
||||||
|
22.86,10.92 22.49,11.00 22.04,10.82 21.77,10.64 21.71,10.57 21.68,10.29 21.53,10.21 21.40,10.00 21.26,9.97 20.77,9.41 20.34,9.13 20.07,9.13 19.67,9.02 19.15,9.02 18.96,8.94 18.89,8.84 19.11,8.66 18.56,8.05 17.65,7.98 16.78,7.55 16.55,7.87 16.40,7.77 16.38,7.68 15.96,7.51 15.48,7.52
|
||||||
|
13.29,2.16 13.22,2.26 11.56,2.30 11.35,2.30 11.33,2.17
|
||||||
|
11.13,-3.92 11.23,-3.69 11.50,-3.52 11.69,-3.68 11.88,-3.67 11.83,-3.53 11.93,-3.32 11.72,-3.18 11.76,-2.98 11.64,-2.86 11.54,-2.84 11.61,-2.34 11.73,-2.39 11.95,-2.34 12.06,-2.41 12.45,-2.33 12.43,-1.93 12.59,-1.83 12.79,-1.93 12.99,-2.31 13.46,-2.40 13.73,-2.14 13.88,-2.33 13.89,-2.47 13.99,-2.49 14.20,-2.35 14.16,-2.22 14.25,-2.00 14.38,-1.89 14.47,-0.57 14.21,-0.43 14.07,-0.27 13.86,-0.20 13.95,0.35 14.09,0.54 14.32,0.62 14.43,0.90 14.18,1.37 13.85,1.42 13.22,1.25 13.25,1.37 13.17,1.79 13.29,2.16
|
||||||
|
15.48,7.52 15.55,7.79 15.44,7.85 15.12,8.56 14.33,9.20 13.98,9.69 14.24,9.98 14.84,9.94 15.65,10.01 15.28,10.36 15.13,10.65 15.03,11.11 15.12,11.54 15.08,11.85 14.88,12.27 14.85,12.50 14.76,12.66 14.54,12.82 14.46,13.02 14.06,13.08
|
||||||
|
14.06,13.08 14.20,12.38 14.52,12.30 14.62,12.15 14.58,11.53 14.20,11.27 13.98,11.21 13.70,10.87 13.54,10.61 13.41,10.17 13.27,10.04 13.20,9.56 12.93,9.43 12.78,8.82 12.58,8.62 12.40,8.60 12.23,8.28 12.02,7.59 11.77,7.27 11.86,7.12 11.58,6.89 11.48,6.60 11.24,6.45 11.11,6.46 11.01,6.74 10.61,7.06 10.48,6.89 10.21,6.89 10.14,7.00 9.78,6.76 9.66,6.53 9.00,5.92 8.80,5.20 8.56,4.76
|
||||||
|
3.60,11.70 3.66,11.76 3.61,11.89 3.65,12.53 3.95,12.78 4.04,12.93 4.15,13.46 4.66,13.73 5.24,13.76 5.49,13.87 6.30,13.66 6.59,13.41 6.87,13.04 7.01,13.00 7.83,13.34 8.10,13.29 8.75,12.91 9.62,12.81 10.05,13.21 10.48,13.33 11.41,13.35 11.99,13.19 12.12,13.09 12.46,13.09 12.65,13.33 12.87,13.45 13.32,13.67 13.61,13.70
|
||||||
|
1.19,6.09 0.98,6.32 0.74,6.45 0.53,6.85 0.63,7.35 0.51,7.44 0.50,7.55 0.61,7.73 0.58,8.15 0.69,8.35 0.37,8.76 0.49,8.85 0.47,9.12 0.53,9.40 0.41,9.49 0.26,9.43 0.23,9.46 0.34,9.60 0.26,9.64 0.32,9.69 0.38,10.29 -0.09,10.67 0.01,11.02 -0.07,11.12
|
||||||
|
-2.70,9.48 -2.67,9.28 -2.75,9.05 -2.60,8.80 -2.51,8.21 -2.61,8.15 -2.61,8.05 -2.79,7.93 -2.99,7.20 -3.24,6.81 -3.24,6.65 -3.00,5.71 -2.96,5.64 -2.79,5.60 -2.76,5.36 -2.82,5.15 -3.11,5.09
|
||||||
|
-5.52,10.43 -5.46,10.77 -5.49,11.04 -5.35,11.13 -5.25,11.38 -5.29,11.83 -5.11,11.97 -4.70,12.08 -4.43,12.34 -4.42,12.58 -4.48,12.67 -4.23,12.79 -4.33,13.12 -4.15,13.31 -3.95,13.40 -3.53,13.18 -3.30,13.28 -3.25,13.66 -2.95,13.65 -2.87,13.95 -2.59,14.23 -2.46,14.27 -2.11,14.17 -1.97,14.46 -1.70,14.51 -1.05,14.82 -0.76,15.05 -0.54,15.08 -0.41,15.01 -0.24,15.06 0.22,14.91
|
||||||
|
-2.70,9.48 -2.82,9.43 -2.99,9.69 -3.22,9.90 -3.79,9.92 -4.27,9.74 -4.33,9.65 -4.63,9.71 -4.97,9.93 -5.10,10.24 -5.38,10.31 -5.52,10.43
|
||||||
|
-8.49,7.56 -8.30,6.98 -8.33,6.80 -8.60,6.51 -8.29,6.32 -7.89,6.23 -7.80,5.98 -7.45,5.84 -7.40,5.55 -7.43,5.32 -7.57,5.08 -7.59,4.82 -7.54,4.35
|
||||||
|
-8.49,7.56 -8.66,7.69 -8.89,7.26 -9.12,7.22 -9.26,7.38 -9.46,7.42 -9.37,7.70 -9.52,8.35 -9.68,8.48 -9.74,8.45 -9.78,8.54 -10.06,8.43 -10.15,8.52 -10.28,8.49
|
||||||
|
-5.52,10.43 -5.84,10.39 -6.03,10.19 -6.20,10.23 -6.26,10.72 -6.43,10.67 -6.42,10.56 -6.65,10.66 -6.69,10.35 -6.95,10.34 -7.02,10.14 -7.36,10.26 -7.50,10.44 -7.66,10.43 -7.81,10.24 -7.99,10.16
|
||||||
|
-15.04,10.94 -14.68,11.51 -14.27,11.66 -13.73,11.74 -13.74,12.01 -13.95,12.18 -13.71,12.31 -13.67,12.48 -13.73,12.67
|
||||||
|
-11.39,12.40 -11.50,12.20 -11.31,12.02 -10.93,12.21 -10.71,11.90 -10.47,12.14 -10.27,12.21 -9.75,12.03 -9.66,12.14 -9.36,12.26 -9.40,12.46 -9.22,12.48 -9.04,12.40 -8.82,11.92 -8.82,11.67 -8.40,11.37 -8.67,11.01 -8.47,11.05 -8.34,10.99 -8.27,10.49 -8.01,10.32 -7.99,10.16
|
||||||
|
107.52,14.71 107.41,14.56 107.29,14.59 106.94,14.33 106.78,14.34 106.50,14.58 106.35,14.45 106.23,14.48 106.17,14.37 105.98,14.34 106.12,14.05 106.07,13.92 105.90,13.92 105.74,14.08 105.53,14.16 105.35,14.11 105.18,14.35
|
||||||
|
114.06,4.60 114.22,4.48 114.32,4.26 114.42,4.26 114.57,4.05 114.65,4.04 114.78,4.17 114.84,4.39 114.75,4.72 115.03,4.90
|
||||||
|
-88.23,15.73 -88.96,15.15 -89.14,15.07 -89.22,14.87 -89.17,14.61 -89.36,14.42
|
||||||
|
-77.37,8.66 -77.48,8.50 -77.20,7.97 -77.35,7.84 -77.35,7.71 -77.54,7.57 -77.59,7.54 -77.76,7.70 -77.74,7.54 -77.90,7.23
|
||||||
|
-60.74,5.20 -61.39,5.94 -61.13,6.21 -61.20,6.59 -61.15,6.69 -60.87,6.79 -60.72,6.77 -60.35,7.00 -60.35,7.15 -60.58,7.16 -60.63,7.21 -60.61,7.32 -60.72,7.54 -60.51,7.81 -60.38,7.83 -60.03,8.05 -59.96,8.19 -59.85,8.25 -59.83,8.31 -60.02,8.55
|
||||||
|
-60.74,5.20 -60.67,5.16 -60.60,4.95 -61.00,4.54 -61.28,4.52 -61.55,4.29 -61.82,4.20 -62.15,4.10 -62.41,4.16 -62.71,4.02 -62.76,3.67 -62.86,3.59 -62.97,3.59 -63.34,3.94 -63.53,3.89 -64.02,3.93 -64.19,4.13 -64.58,4.14 -64.79,4.28 -64.82,4.23 -64.67,4.01 -64.22,3.59 -64.22,3.20 -64.01,2.67 -64.05,2.50 -63.39,2.41 -63.43,2.16 -64.01,1.93 -64.11,1.62 -64.21,1.53 -64.30,1.46 -64.49,1.45 -64.73,1.25 -65.10,1.11 -65.47,0.69 -65.56,0.69 -65.52,0.84 -65.68,0.98 -66.06,0.79 -66.35,0.77 -66.88,1.22
|
||||||
|
52.49,41.78 53.06,42.15 54.12,42.34 54.85,41.97 55.43,41.30 55.98,41.32
|
||||||
|
55.98,41.32 57.02,41.26 57.12,41.35 57.02,41.45 56.96,41.86 57.29,42.12 57.81,42.19 58.03,42.49 58.28,42.43 58.40,42.29 58.47,42.30 58.15,42.63 58.26,42.69 58.48,42.66 58.59,42.78 58.88,42.56 59.16,42.51 59.35,42.32 59.86,42.30 59.99,42.21 59.94,41.97 60.20,41.80 60.08,41.76 60.14,41.59 60.09,41.40 60.45,41.22 60.87,41.25 61.24,41.19 61.50,41.28 61.90,41.09 62.10,40.68 62.38,40.33 62.48,39.98 63.51,39.38 63.76,39.16 64.16,38.95 64.31,38.98 65.61,38.24 65.97,38.24 66.57,38.01 66.63,37.93 66.53,37.79 66.52,37.35
|
||||||
|
49.23,46.34 48.54,46.61 48.50,46.70 48.56,46.76 48.88,46.71 48.96,46.77 48.55,47.32 48.17,47.71 47.48,47.80 47.29,47.74 47.09,47.95 47.12,48.13 47.06,48.23 46.66,48.41 46.61,48.57 46.70,48.81 47.03,49.15 46.80,49.37 46.89,49.70 46.99,49.85 47.25,50.00 47.33,50.27 47.50,50.40 47.71,50.38 48.33,49.86 48.43,49.83 48.76,49.93 48.84,50.01 48.63,50.61 48.81,50.60 49.32,50.85 49.50,51.08 49.82,51.13 50.31,51.32 50.79,51.73 51.16,51.65 51.34,51.48 51.61,51.48 52.22,51.71 52.57,51.48 53.34,51.48 54.14,51.04 54.42,50.78 54.47,50.58 54.56,50.54 54.65,50.66 54.55,50.95 54.64,51.01 55.36,50.67 55.69,50.58 56.05,50.71 56.14,50.84 56.49,51.02 56.62,50.98 57.01,51.07 57.44,50.89 57.65,50.93 57.84,51.09 58.36,51.06 58.88,50.69 59.45,50.62 59.52,50.58 59.52,50.49 59.81,50.58 59.96,50.80 60.06,50.85 60.42,50.68 60.94,50.70 61.39,50.86 61.59,51.23 61.55,51.32 61.36,51.44 60.46,51.65 60.39,51.77 60.03,51.93 60.43,52.13 60.67,52.15 60.99,52.34 60.77,52.68 61.05,52.97 61.97,52.94 62.08,53.01 62.01,53.11 61.66,53.23 61.20,53.29 61.16,53.34 61.23,53.45 61.53,53.52 61.47,53.58 61.25,53.55 60.98,53.62 61.11,53.75 61.14,53.96 61.23,54.02 61.33,54.05 61.93,53.95 63.85,54.24 64.46,54.38 64.93,54.40 65.09,54.34 65.24,54.52 65.48,54.62 65.71,54.62 65.91,54.69 66.22,54.67 68.16,54.98 68.24,55.05 68.21,55.16 68.52,55.20 68.98,55.39 69.49,55.36 70.18,55.16 70.49,55.28 70.74,55.31 70.91,55.13 71.19,54.60 71.15,54.36 71.05,54.26 71.09,54.21 71.34,54.16 72.00,54.21 72.19,54.33 72.39,54.12 72.45,53.94 72.59,54.00 72.56,54.09 72.62,54.13 72.91,54.11 73.23,53.96 73.62,54.07 73.71,54.04 73.68,53.93 73.40,53.81 73.31,53.71 73.29,53.60 73.41,53.45 73.86,53.62 74.21,53.58 74.35,53.49 74.45,53.65 74.83,53.83 75.22,53.89 75.38,53.97 75.44,54.09 76.84,54.44 76.65,54.15 76.42,54.15 76.48,54.02 77.86,53.27 79.47,51.49 79.99,50.77 80.07,50.76 80.09,50.84 80.22,50.91 80.42,50.95 80.45,51.18 80.74,51.29 80.88,51.28 80.97,51.19 81.13,51.19 81.07,50.97 81.39,50.96 81.47,50.74 82.49,50.73 82.76,50.89 83.36,50.99 83.95,50.77 84.18,50.52 84.32,50.24 84.99,50.06 85.00,49.89 85.23,49.62 86.18,49.50 86.68,49.78 86.73,49.70 86.63,49.56 86.81,49.49 87.00,49.29 87.23,49.22 87.32,49.09
|
||||||
|
34.20,31.32 34.90,29.48
|
||||||
|
33.98,4.22 34.38,4.62
|
||||||
|
41.88,3.98 41.14,3.96 40.77,4.27 39.84,3.85 39.54,3.47 39.23,3.48 38.09,3.65 36.91,4.41 36.08,4.45 35.92,4.62
|
||||||
|
23.38,-17.64 21.42,-18.00 21.11,-17.96 20.75,-18.02 20.39,-17.89 18.96,-17.80 18.72,-17.70 18.40,-17.40 14.02,-17.41 13.48,-17.04 13.10,-16.97 12.55,-17.21 12.01,-17.17 11.74,-17.25
|
||||||
|
25.26,-17.79 25.64,-17.82 26.00,-17.97 26.14,-17.91 26.33,-17.93 26.78,-18.04 27.02,-17.96 27.93,-16.90 28.76,-16.53 28.83,-16.42 28.91,-15.99 29.49,-15.70 30.40,-15.64
|
||||||
|
23.38,-17.64 24.27,-17.48 24.73,-17.52 25.00,-17.57 25.26,-17.79
|
||||||
|
31.29,-22.40 32.43,-21.30 32.35,-21.14 32.48,-20.95 32.49,-20.66 32.67,-20.52 32.99,-19.98 33.01,-19.87 32.78,-19.39 32.85,-19.10 32.70,-18.94 32.72,-18.83 32.88,-18.73 32.99,-18.36 32.97,-17.25 32.88,-16.88 32.95,-16.71 32.24,-16.45 31.94,-16.43 31.69,-16.21 31.49,-16.18 31.24,-16.02 30.44,-16.00 30.40,-15.64
|
||||||
|
12.28,-6.00 12.42,-6.05 13.18,-5.86 16.43,-5.90 16.70,-6.16 16.74,-6.62 16.92,-6.93 16.98,-7.26 17.58,-8.10 18.01,-8.11 18.56,-7.94 18.94,-8.00 19.34,-7.97 19.53,-7.14 19.66,-7.04 19.88,-6.99 20.59,-6.92 20.54,-7.18 20.61,-7.28 21.78,-7.31 21.84,-7.42 21.80,-8.11 21.90,-8.34 21.91,-8.69 21.81,-9.47 22.20,-10.04 22.27,-10.26 22.31,-10.69 22.18,-10.89 22.23,-11.12 22.31,-11.20 22.56,-11.06 23.08,-11.09 23.46,-10.97 23.83,-11.01 23.97,-10.87
|
||||||
|
-16.56,13.59 -15.51,13.59 -15.43,13.73 -15.11,13.81 -14.94,13.79 -14.41,13.50 -13.98,13.54 -13.83,13.41 -13.85,13.34 -14.25,13.24 -15.15,13.56 -15.29,13.40 -15.81,13.33 -15.83,13.16 -16.65,13.15 -16.76,13.06
|
||||||
|
0.22,14.91 0.16,14.50 0.38,14.25 0.35,14.14 0.43,13.97 0.62,13.70 0.90,13.61 1.20,13.36 0.99,13.36 0.99,13.04 1.56,12.64 1.79,12.61 1.96,12.71 2.10,12.70 2.23,12.47 2.07,12.38 2.07,12.31 2.39,11.90
|
||||||
|
18.61,3.48 18.47,3.62 18.16,3.50 17.49,3.69 16.61,3.51 16.48,3.17 16.47,2.83 16.18,2.27
|
||||||
|
16.18,2.26 16.08,2.11 16.14,1.72 16.06,1.68 15.74,1.92 15.34,1.94 15.16,2.04 14.90,2.01 14.88,2.08 14.76,2.08 14.58,2.20 14.48,2.15 13.29,2.16
|
||||||
|
15.48,7.52 15.21,7.21 14.74,6.28 14.51,6.16 14.43,6.04 14.62,5.87 14.56,5.28 14.64,5.18 14.73,4.60 15.06,4.28 15.14,4.04 15.03,4.02 15.13,3.83 15.77,3.13 15.90,3.10 16.06,2.91 16.18,2.26
|
||||||
|
33.98,4.22 33.49,3.76 33.15,3.77 33.00,3.88 32.34,3.71 32.14,3.52 31.94,3.61 31.80,3.80 31.55,3.68 31.15,3.79 30.93,3.63 30.84,3.49
|
||||||
|
13.07,-4.63 12.83,-4.74 12.45,-5.07 12.52,-5.15 12.50,-5.70 12.21,-5.76
|
||||||
|
31.95,-25.96 31.99,-24.46 31.80,-23.89 31.55,-23.48 31.29,-22.40
|
||||||
|
33.20,-14.01 30.23,-14.99 30.40,-15.64
|
||||||
|
34.96,-11.58 34.62,-11.62 34.36,-12.16 34.56,-13.36 34.66,-13.49 34.91,-13.55 35.25,-13.90 35.85,-14.67 35.89,-14.89 35.81,-15.27 35.82,-15.68 35.76,-16.06 35.36,-16.16 35.17,-16.56 35.28,-16.81 35.27,-17.12 35.06,-17.08 35.08,-16.83 34.93,-16.76 34.42,-16.25 34.40,-16.08 34.25,-15.89 34.54,-15.30 34.51,-14.60 34.33,-14.41 33.64,-14.57 33.20,-14.01
|
||||||
|
29.58,-1.39 29.56,-0.98 29.72,0.10 29.93,0.50 29.94,0.82 30.18,0.97 30.32,1.19 30.48,1.24 31.25,2.04 31.27,2.15 31.18,2.27 30.96,2.40 30.73,2.46 30.85,2.89 30.75,3.04 30.91,3.41 30.84,3.49
|
||||||
|
36.52,14.26 36.43,15.13 36.52,15.25 36.91,16.30 36.89,16.62 36.98,16.80 37.01,17.06 37.41,17.06 37.55,17.32 37.78,17.46 38.25,17.58 38.61,18.01
|
||||||
|
42.38,12.47 42.13,12.77 41.95,12.88 41.77,13.18 41.36,13.50 40.82,14.11 40.14,14.46 39.90,14.44 39.53,14.54 39.20,14.48 39.02,14.63 38.81,14.48 38.43,14.43 38.22,14.65 37.88,14.85 37.57,14.15 37.26,14.45 37.02,14.27 36.81,14.32 36.52,14.26
|
||||||
|
27.35,57.53 27.40,57.67 27.54,57.80 27.78,57.87 27.67,57.93 27.50,58.22 27.53,58.44 27.43,58.79 27.76,59.05 27.90,59.28 28.15,59.37 28.01,59.48
|
||||||
|
74.31,32.81 74.66,32.76 74.69,32.49 74.99,32.46 75.30,32.32
|
||||||
|
-84.35,10.98 -84.70,11.05 -84.91,10.95 -85.58,11.19 -85.74,11.06
|
||||||
|
-82.88,8.07 -83.03,8.34 -82.86,8.45 -82.86,8.64 -82.92,8.74 -82.73,8.92 -82.94,9.06 -82.94,9.45 -82.80,9.59 -82.64,9.51 -82.56,9.58
|
||||||
|
-66.88,1.22 -67.21,2.39 -67.62,2.79 -67.86,2.79 -67.83,2.89 -67.31,3.42 -67.66,3.86 -67.86,4.51 -67.82,5.27 -67.64,5.56 -67.58,5.83 -67.47,5.93 -67.48,6.18 -67.86,6.29 -68.47,6.16 -68.94,6.20 -69.27,6.10 -69.43,6.12 -70.13,6.95 -70.74,7.09 -71.13,6.99 -72.01,7.03 -72.21,7.37 -72.39,7.42 -72.47,7.52 -72.45,7.97 -72.36,8.09 -72.39,8.29 -72.67,8.63 -72.80,9.11 -72.96,9.14 -73.06,9.26 -73.37,9.19 -73.01,9.79 -72.87,10.49 -72.69,10.84 -72.45,11.11 -72.25,11.20 -71.96,11.67 -71.32,11.86
|
||||||
|
-68.44,-52.36 -68.46,-52.29 -69.96,-52.01 -71.97,-51.96 -71.95,-51.88 -72.41,-51.54 -72.30,-51.30 -72.38,-51.10 -72.28,-50.91 -72.34,-50.68 -72.51,-50.61 -73.15,-50.74 -73.31,-50.36 -73.50,-50.13 -73.48,-49.77
|
||||||
|
-68.63,-52.65 -68.65,-54.92 -68.06,-54.88 -67.35,-54.93 -66.48,-55.11
|
||||||
|
22.77,54.36 19.60,54.46
|
||||||
|
48.44,28.54 47.67,28.53 47.43,28.99 46.53,29.10
|
||||||
|
44.77,37.14 44.88,36.80 45.02,36.70 45.05,36.47 45.24,36.36 45.36,36.02 45.56,35.98 45.78,35.82 46.27,35.77 46.00,35.61 45.98,35.48 46.11,35.32 46.13,35.13 45.92,35.03 45.68,34.80 45.64,34.57 45.50,34.58 45.44,34.42 45.54,34.22 45.40,33.97 45.74,33.60 45.88,33.61 45.87,33.49 46.02,33.42 46.15,33.23 46.08,33.09 46.11,32.96 46.38,32.93 47.12,32.47 47.37,32.42 47.51,32.15 47.83,31.79 47.68,31.40 47.68,31.00 48.01,30.99 48.01,30.47 48.33,30.29 48.43,30.04 48.55,29.96
|
||||||
|
61.26,35.62 61.25,35.87 61.15,35.98 61.21,36.19 61.12,36.64 60.34,36.64 60.06,36.96 59.45,37.25 59.30,37.51 58.82,37.68 58.26,37.67 57.98,37.83 57.35,37.97 57.31,38.13 57.19,38.22 56.44,38.25 56.32,38.19 56.27,38.08 55.58,38.10 55.38,38.05 54.90,37.78 54.70,37.47 54.19,37.33 53.91,37.34
|
||||||
|
140.97,-2.60 140.97,-6.35 140.86,-6.74 140.98,-6.91 140.98,-9.12
|
||||||
|
9.52,30.23 9.31,30.12 9.64,29.64 9.81,29.18 9.92,27.79 9.75,27.33 9.89,26.85 9.88,26.63 9.86,26.55 9.49,26.33 9.45,26.07 10.00,25.33 10.03,25.05 10.26,24.59 10.40,24.49 10.69,24.55 11.51,24.31 11.97,23.52
|
||||||
|
-13.73,12.67 -15.20,12.68 -15.57,12.49 -16.71,12.35
|
||||||
|
-11.39,12.40 -12.40,12.34 -12.93,12.53 -13.06,12.49 -13.08,12.63 -13.73,12.67
|
||||||
|
0.90,10.99 0.49,10.95 -0.07,11.12
|
||||||
|
-139.06,60.00 -139.19,60.08 -139.08,60.34 -139.68,60.33 -139.97,60.18 -140.45,60.30 -140.53,60.22 -141.00,60.30 -141.00,69.65
|
||||||
|
-130.62,54.71 -130.21,55.03 -130.02,55.28 -130.12,55.69 -130.03,55.89 -130.06,56.07 -130.41,56.12 -130.48,56.23 -130.74,56.34 -131.58,56.60 -131.82,56.59 -131.87,56.79 -132.10,56.86 -132.03,57.03 -132.34,57.08 -132.23,57.20 -133.42,58.34 -133.40,58.41 -133.82,58.71 -134.30,58.90 -134.44,59.09 -134.94,59.29 -135.07,59.44 -135.05,59.58 -135.48,59.79 -136.32,59.60 -136.25,59.53 -136.35,59.46 -136.47,59.46 -136.47,59.28 -136.58,59.15 -136.81,59.15 -137.44,58.90 -137.52,58.92 -137.48,58.99 -137.59,59.23 -138.63,59.78 -138.71,59.90 -139.06,60.00
|
||||||
|
-123.31,48.99 -122.69,48.99
|
||||||
|
-88.89,15.89 -89.24,15.89 -89.16,17.81
|
||||||
|
-75.28,-0.11 -75.48,-0.16 -75.63,-0.12 -75.26,-0.59 -75.25,-0.95 -75.41,-0.92 -75.57,-1.53 -76.09,-2.13 -76.68,-2.56 -77.86,-2.98 -78.18,-3.35 -78.16,-3.47 -78.23,-3.49 -78.35,-3.40 -78.42,-3.78 -78.68,-4.33 -78.69,-4.56 -78.91,-4.71 -78.92,-4.86 -79.03,-4.97 -79.33,-4.93 -79.50,-4.67 -79.52,-4.54 -79.64,-4.45 -79.80,-4.48 -80.14,-4.30 -80.38,-4.46 -80.48,-4.43 -80.35,-4.21 -80.49,-4.17 -80.49,-4.01 -80.30,-4.01 -80.18,-3.88 -80.32,-3.39
|
||||||
|
-69.97,-4.24 -70.34,-3.81 -70.53,-3.87 -70.74,-3.78 -70.07,-2.75 -70.10,-2.66 -70.97,-2.21 -71.40,-2.33 -71.75,-2.15 -71.98,-2.33 -72.22,-2.40 -72.40,-2.43 -72.63,-2.35 -72.94,-2.39 -73.15,-2.28 -73.13,-2.08 -73.20,-1.83 -73.50,-1.69 -73.52,-1.45 -73.66,-1.25 -74.25,-0.97 -74.42,-0.58 -74.80,-0.20 -74.95,-0.19 -75.14,-0.05 -75.28,-0.11
|
||||||
|
-66.88,1.22 -67.08,1.19 -67.12,1.70 -67.40,2.12 -67.56,2.07 -67.82,1.79 -67.94,1.75 -68.19,1.99 -68.26,1.85 -68.18,1.72 -69.32,1.72 -69.54,1.77 -69.85,1.71 -69.85,1.06 -69.31,1.05 -69.16,0.86 -69.15,0.66 -69.28,0.63 -69.47,0.73 -70.05,0.58 -70.07,-0.14 -69.92,-0.32 -69.63,-0.51 -69.61,-0.76 -69.45,-1.00 -69.40,-1.19 -69.97,-4.24
|
||||||
|
-69.58,-10.95 -68.69,-12.50 -68.76,-12.69 -68.98,-12.88 -68.98,-13.50 -69.07,-13.68 -68.87,-14.17 -69.00,-14.27 -69.01,-14.38 -69.36,-14.80 -69.37,-14.96 -69.17,-15.24 -69.42,-15.64 -69.22,-16.15 -68.84,-16.34 -69.03,-16.48 -69.02,-16.64 -69.20,-16.77 -69.44,-17.09 -69.62,-17.20 -69.51,-17.50
|
||||||
|
-69.58,-10.95 -68.85,-11.01 -68.73,-11.12 -68.62,-11.11 -68.31,-10.98 -68.07,-10.70 -67.72,-10.68 -67.42,-10.39 -67.11,-10.27 -66.58,-9.90 -65.92,-9.79 -65.56,-9.80 -65.40,-9.71 -65.31,-9.87 -65.30,-10.15 -65.45,-10.51 -65.32,-11.02 -65.39,-11.25 -65.18,-11.65 -65.19,-11.75 -65.09,-11.74 -64.99,-11.98 -64.51,-12.25 -64.42,-12.44 -63.94,-12.53 -63.69,-12.48 -63.35,-12.68 -63.07,-12.67 -63.02,-12.81 -62.77,-13.00 -62.12,-13.16 -61.79,-13.53 -61.08,-13.49 -60.51,-13.79 -60.40,-14.02 -60.47,-14.18 -60.30,-14.62 -60.27,-15.09 -60.58,-15.10 -60.24,-15.48 -60.18,-16.27 -58.54,-16.33 -58.35,-16.28 -58.35,-16.49 -58.48,-16.70 -58.40,-17.23 -57.99,-17.51 -57.83,-17.51 -57.59,-18.12 -57.50,-18.21 -57.57,-18.28 -57.78,-18.91 -57.73,-18.92 -57.72,-19.04 -57.80,-19.08 -58.13,-19.74 -57.86,-19.98 -58.16,-20.16
|
||||||
|
-69.97,-4.24 -70.05,-4.33 -70.24,-4.30 -70.40,-4.15 -70.80,-4.17 -70.97,-4.35 -71.84,-4.50 -72.35,-4.79 -72.70,-5.07 -72.89,-5.12 -72.98,-5.63 -73.24,-6.10 -73.14,-6.47 -73.76,-6.91 -73.80,-7.08 -73.72,-7.31 -73.96,-7.38 -74.00,-7.56 -73.72,-7.78 -73.78,-7.94 -73.64,-8.07 -73.55,-8.35 -73.36,-8.48 -73.30,-8.65 -72.97,-8.99 -72.97,-9.12 -73.21,-9.41 -72.81,-9.41 -72.38,-9.51 -72.17,-9.84 -72.14,-10.01 -71.24,-9.97 -70.54,-9.44 -70.64,-9.82 -70.64,-11.01 -70.53,-10.95 -70.29,-11.06 -69.96,-10.93 -69.58,-10.95
|
||||||
|
-62.65,-22.23 -62.28,-21.07 -62.28,-20.56 -61.92,-20.06 -61.76,-19.65 -60.01,-19.30 -59.09,-19.29 -58.18,-19.82 -58.16,-20.16
|
||||||
|
-17.05,20.81 -16.96,21.33 -13.02,21.33 -13.15,22.82 -13.03,23.00 -12.62,23.27 -12.02,23.47 -12.02,26.00 -8.68,26.00 -8.68,27.29
|
||||||
|
-8.68,27.29 -4.82,25.00
|
||||||
|
-8.68,27.66 -8.66,28.72 -7.69,29.35 -7.49,29.39 -7.16,29.61 -6.64,29.57 -6.52,29.66 -6.48,29.82 -6.00,29.83 -5.45,29.96 -5.18,30.17 -4.97,30.47 -4.32,30.70 -3.99,30.91 -3.67,30.96 -3.62,31.07 -3.83,31.20 -3.79,31.36 -3.83,31.66 -3.02,31.83 -2.86,32.07 -2.45,32.13 -1.28,32.09
|
||||||
|
-8.68,27.29 -8.68,27.66
|
||||||
|
-12.28,14.81 -12.86,15.24 -12.93,15.45 -13.21,15.62 -13.41,16.06 -13.51,16.14 -13.87,16.15 -14.09,16.42 -14.30,16.58 -14.53,16.66 -14.99,16.68 -15.77,16.49 -16.24,16.53 -16.44,16.20 -16.54,15.84
|
||||||
|
-4.82,25.00 -6.59,24.99 -5.63,16.57 -5.36,16.28 -5.51,15.50 -9.18,15.50 -9.34,15.53 -9.35,15.68 -9.43,15.62 -9.45,15.46 -9.94,15.37 -10.70,15.42 -10.95,15.15 -11.50,15.64 -11.76,15.43 -11.94,14.89 -12.10,14.75 -12.28,14.81
|
||||||
|
4.23,19.14 3.40,18.99 3.26,19.01 3.11,19.15 3.26,19.41 3.20,19.79 3.13,19.85 2.41,20.06 2.22,20.25 1.75,20.33 1.61,20.56 1.17,20.82 1.15,21.10 -4.82,25.00
|
||||||
|
4.23,19.14 5.84,19.48 7.48,20.87 11.97,23.52
|
||||||
|
9.52,30.23 9.04,32.07 8.33,32.54 8.11,33.06 7.73,33.27 7.50,33.83 7.51,34.08 7.75,34.25 7.84,34.41 8.12,34.56 8.25,34.73 8.28,34.98 8.39,35.20 8.25,35.80 8.35,36.37 8.21,36.52 8.44,36.76 8.60,36.83 8.58,36.94
|
||||||
|
23.98,19.50 15.98,23.45 14.98,23.00
|
||||||
|
24.98,22.00 24.98,20.00 23.98,20.00 23.98,19.50
|
||||||
|
25.15,31.65 24.85,31.33 24.96,30.68 24.70,30.20 24.98,29.18 24.98,22.00
|
||||||
|
22.86,10.92 22.92,11.34 22.59,11.58 22.58,11.99 22.47,12.07 22.35,12.66 22.23,12.71 21.93,12.68 21.83,12.79 21.99,13.11 22.23,13.33 22.11,13.80 22.17,13.91 22.54,14.16 22.38,14.55 22.67,14.72 22.76,15.00 22.93,15.16 22.93,15.53 23.11,15.70 23.97,15.72 23.98,19.50
|
||||||
|
24.98,22.00 31.21,21.99 31.40,22.20 31.49,22.15 31.43,22.00 36.87,22.00
|
||||||
|
41.88,3.98 41.34,3.20 40.96,2.81 40.98,-0.87 41.52,-1.57 41.53,-1.70
|
||||||
|
42.92,11.00 42.66,10.60 42.84,10.20 43.07,9.93 43.18,9.88 43.48,9.38 43.62,9.34 43.98,9.01 46.98,8.00 47.98,8.00
|
||||||
|
30.75,-8.19 28.90,-8.49 28.93,-8.59 28.87,-8.79 28.40,-9.22 28.60,-9.68 28.65,-10.55 28.36,-11.48 28.38,-11.57 28.48,-11.81 29.06,-12.35 29.49,-12.42 29.51,-12.23 29.80,-12.16 29.78,-13.44 29.65,-13.41 29.63,-13.30 29.55,-13.25 29.20,-13.40 29.01,-13.37 28.73,-12.93 28.55,-12.84 28.41,-12.52 27.57,-12.23 27.42,-11.94 27.24,-11.78 27.16,-11.58 27.05,-11.62 26.95,-11.90 26.82,-11.97 26.03,-11.89 25.51,-11.75 25.35,-11.62 25.29,-11.21 24.52,-11.44 24.38,-11.42 24.34,-11.37 24.40,-11.26 24.37,-11.13 24.14,-11.03 24.08,-10.89 23.97,-10.87
|
||||||
|
23.38,-17.64 22.15,-16.60 22.04,-16.26 21.98,-15.96 21.98,-13.00 23.96,-12.99 23.88,-12.80 23.99,-12.42 23.97,-11.64 24.05,-11.41 23.97,-10.87
|
||||||
|
33.90,-1.00 33.94,0.17 34.16,0.61 34.41,0.87 34.48,1.04 34.80,1.24 34.78,1.38 34.97,1.64 34.98,1.77 34.91,2.48 34.74,2.82 34.59,2.92 34.52,3.12 34.45,3.16 34.40,3.41 34.44,3.65 34.17,3.81 34.19,3.87 33.98,4.22
|
||||||
|
33.90,-1.00 37.64,-3.05 37.69,-3.25 37.62,-3.51 39.22,-4.69
|
||||||
|
11.33,2.17 9.98,2.17 9.80,2.30
|
||||||
|
11.33,2.17 11.34,1.00 10.03,1.00 9.91,0.96 9.76,1.07 9.59,1.03
|
||||||
|
25.26,-17.79 24.91,-17.82 24.53,-18.05 24.36,-17.98 24.24,-18.02 23.60,-18.46 23.30,-18.03 23.22,-18.00 20.97,-18.32 20.98,-21.96 19.98,-22.00 19.98,-24.75
|
||||||
|
19.98,-24.78 19.98,-28.45 19.54,-28.57 19.31,-28.73 19.25,-28.90 19.16,-28.94 18.84,-28.87 18.10,-28.87 17.45,-28.70 17.35,-28.50 17.36,-28.27 17.06,-28.03 16.88,-28.13 16.76,-28.45 16.45,-28.62
|
||||||
|
19.98,-24.75 19.98,-24.78
|
||||||
|
0.90,10.99 0.79,10.71 0.76,10.39 1.33,10.00 1.39,9.36 1.60,9.05 1.62,7.00 1.53,6.99 1.60,6.61 1.78,6.29 1.62,6.22
|
||||||
|
42.80,16.37 43.17,16.69 43.18,16.81 43.12,16.94 43.16,17.21 43.24,17.27 43.19,17.36 43.42,17.52 43.92,17.32 44.16,17.40 45.15,17.43 45.54,17.30 46.31,17.23 46.73,17.27 46.98,16.95 47.14,16.95 47.44,17.11 47.58,17.45 48.17,18.16 49.04,18.58 51.98,19.00
|
||||||
|
51.98,19.00 52.73,17.30 52.80,17.27 53.09,16.65
|
||||||
|
55.19,22.70 55.64,22.00 54.98,20.00 51.98,19.00
|
||||||
|
55.19,22.70 55.10,22.62 52.56,22.93 51.59,24.08 51.57,24.29
|
||||||
|
42.36,37.11 41.79,36.60 41.42,36.51 41.30,36.38 41.25,36.07 41.35,35.64 41.22,35.29 41.19,34.77 40.99,34.43 40.69,34.33 38.77,33.37
|
||||||
|
35.79,32.73 36.37,32.39 36.82,32.32 38.77,33.37
|
||||||
|
35.61,32.68 35.79,32.73
|
||||||
|
39.15,32.12 39.29,32.24 39.25,32.35 39.04,32.31 38.98,32.47 39.06,32.49 38.77,33.37
|
||||||
|
34.95,29.35 36.07,29.20 36.48,29.50 36.76,29.87 37.47,30.00 37.65,30.33 37.98,30.50 36.96,31.49 38.96,31.99 39.15,32.12
|
||||||
|
46.53,29.10 46.36,29.06 44.69,29.20 42.07,31.08 40.37,31.94 39.15,32.12
|
||||||
|
61.59,25.20 61.66,25.75 61.75,25.84 61.84,26.23 62.13,26.37 62.24,26.36 62.31,26.49 62.44,26.56 63.16,26.65 63.30,27.15 63.17,27.25 62.76,27.25 62.81,27.50 62.76,28.24 62.56,28.24 62.35,28.41 61.89,28.55 61.62,28.79 61.32,29.37 60.84,29.86
|
||||||
|
60.84,29.86 61.81,30.91 61.76,31.29 61.66,31.38 60.82,31.50 60.83,32.25 60.56,33.06 60.56,33.14 60.92,33.51 60.51,33.64 60.49,34.09 60.64,34.31 60.89,34.32 60.73,34.52 60.95,34.65 61.08,34.86 61.15,35.09 61.10,35.27 61.19,35.31 61.28,35.51 61.26,35.62
|
||||||
|
73.73,36.89 74.04,36.83 74.54,37.02
|
||||||
|
60.84,29.86 62.48,29.41 63.57,29.50 64.10,29.39 64.39,29.54 65.10,29.56 66.18,29.84 66.31,29.97 66.24,30.11 66.35,30.80 66.40,30.91 66.60,31.02 66.83,31.26 66.92,31.31 67.45,31.23 67.74,31.34 67.58,31.51 68.02,31.68 68.16,31.80 68.44,31.75 68.60,31.80 68.87,31.63 69.08,31.74 69.28,31.94 69.24,32.43 69.41,32.68 69.50,33.02 69.92,33.11 70.26,33.29 70.28,33.37 70.13,33.62 69.87,33.90 69.89,34.01 69.99,34.05 70.33,33.96 70.65,33.95 71.05,34.05 71.10,34.37 70.97,34.53 71.62,35.18 71.55,35.29 71.60,35.41 71.57,35.55 71.40,35.88 71.19,36.04 71.23,36.12 71.62,36.44 71.77,36.43 72.25,36.73 72.62,36.83 73.73,36.89
|
||||||
|
70.95,42.25 70.72,42.17 70.58,42.04 70.42,42.08 70.10,41.82 69.15,41.43 68.99,41.21 68.58,40.88 68.57,40.62 68.42,40.62 68.05,40.81 68.11,41.03 67.94,41.20 66.71,41.18 66.50,41.99 66.01,42.00 66.10,42.99 65.80,42.88 65.50,43.31 64.91,43.71 64.44,43.55 63.21,43.63 61.99,43.49 61.16,44.17 61.01,44.39 58.56,45.56 55.98,44.99 55.98,41.32
|
||||||
|
13.61,13.70 13.45,14.38 14.37,15.75 15.47,16.91 15.74,19.90 15.96,20.35 15.59,20.73 15.54,20.87 15.61,20.95 15.18,21.52 15.17,21.92 14.98,23.00
|
||||||
|
11.97,23.52 13.48,23.18 14.22,22.62 14.98,23.00
|
||||||
|
0.22,14.91 0.29,14.98 0.95,14.98 1.30,15.27 3.00,15.34 3.06,15.43 3.50,15.36 3.52,15.48 3.84,15.70 4.12,16.36 4.23,17.00 4.23,19.14
|
||||||
|
-0.07,11.12 -0.30,11.17 -0.63,10.93 -0.70,10.99 -1.04,11.01 -2.83,11.00 -2.91,10.59 -2.79,10.43 -2.78,9.75 -2.70,9.48
|
||||||
|
-13.29,9.05 -13.03,9.10 -12.96,9.26 -12.76,9.37 -12.50,9.86 -12.43,9.90 -12.28,9.93 -12.14,9.88 -11.92,9.92 -11.91,9.99 -11.27,10.00 -10.69,9.31 -10.75,9.10 -10.62,9.06 -10.61,8.87 -10.50,8.69 -10.71,8.34 -10.56,8.32 -10.39,8.48 -10.28,8.49
|
||||||
|
5.79,49.54 6.01,49.45 6.18,49.50 6.34,49.45
|
||||||
|
56.08,26.06 56.17,26.05 56.14,25.69 56.30,25.65
|
||||||
|
30.51,-1.07 30.36,-1.07 30.10,-1.37 29.93,-1.47 29.83,-1.34 29.58,-1.39
|
||||||
|
30.51,-1.07 30.51,-1.21 30.81,-1.56 30.88,-2.14 30.83,-2.34 30.55,-2.40
|
||||||
|
33.90,-1.00 30.81,-0.99 30.51,-1.07
|
||||||
|
34.59,-11.97 34.54,-12.02 34.62,-12.07 34.59,-11.97
|
||||||
|
87.32,49.09 87.82,49.16
|
||||||
|
45.50,40.66 45.48,40.61 45.55,40.62 45.50,40.66
|
||||||
|
44.99,41.09 45.00,41.02 44.99,41.09
|
||||||
|
12.44,43.98 12.40,43.93 12.49,43.90 12.50,43.99 12.44,43.98
|
||||||
|
70.57,41.02 70.65,40.94 70.57,41.02
|
||||||
|
-117.13,32.53 -114.72,32.72 -114.84,32.51 -111.04,31.32 -108.21,31.33 -108.21,31.78 -106.45,31.77 -106.15,31.45 -104.92,30.58 -104.68,30.13 -104.62,29.85 -104.40,29.57 -103.26,29.00 -103.09,29.04 -102.89,29.22 -102.73,29.64 -102.34,29.86 -101.99,29.80 -101.55,29.81 -101.38,29.74 -100.75,29.18 -100.33,28.50 -100.30,28.33 -99.75,27.73 -99.51,27.55 -99.46,27.06 -99.23,26.76 -99.11,26.45 -98.28,26.11 -97.80,26.04 -97.38,25.87 -97.15,25.96
|
||||||
|
-92.24,14.55 -92.16,14.69 -92.16,14.96 -92.07,15.07 -92.20,15.28 -91.74,16.07 -90.45,16.07 -90.42,16.39 -90.63,16.51 -90.71,16.71 -91.41,17.26 -90.99,17.25 -90.99,17.82 -89.16,17.81 -89.13,17.97 -89.05,18.00 -88.90,17.91 -88.81,17.97 -88.46,18.48 -88.04,18.42 -88.04,18.20 -87.83,18.18
|
||||||
|
-54.62,-25.58 -54.61,-25.43 -54.44,-25.12 -54.28,-24.31 -54.32,-24.13 -54.24,-24.05 -54.63,-23.81 -55.19,-24.02 -55.42,-23.95 -55.65,-22.89 -55.62,-22.67 -55.85,-22.31 -56.25,-22.26 -56.45,-22.08 -56.63,-22.23 -56.94,-22.27 -57.57,-22.18 -57.72,-22.10 -57.96,-22.11 -57.95,-21.49 -57.83,-21.00 -58.16,-20.16
|
||||||
|
-62.65,-22.23 -62.21,-22.61 -61.80,-23.18 -61.21,-23.56 -61.03,-23.76 -60.51,-23.96 -59.89,-24.09 -59.19,-24.56 -58.37,-24.96 -57.96,-25.05 -57.64,-25.33 -57.57,-25.53 -57.75,-25.70 -57.89,-26.01 -58.11,-26.18 -58.20,-26.38 -58.19,-26.63 -58.32,-26.86 -58.62,-27.13 -58.60,-27.31 -58.17,-27.27 -57.11,-27.47 -56.60,-27.47 -56.44,-27.55 -56.16,-27.32 -55.71,-27.41 -55.43,-27.01 -55.14,-26.93 -54.83,-26.65 -54.68,-26.31 -54.62,-25.58
|
||||||
|
-62.65,-22.23 -62.84,-22.00 -63.92,-22.03 -64.27,-22.60 -64.33,-22.83 -64.61,-22.23 -64.99,-22.11 -65.77,-22.10 -66.22,-21.80 -66.37,-22.11 -66.71,-22.22 -66.80,-22.41 -67.03,-22.55 -67.19,-22.82
|
||||||
|
26.62,48.26 26.85,48.39 27.23,48.37 27.55,48.48 27.82,48.42 28.09,48.26 28.29,48.24 28.46,48.09 28.53,48.15 28.77,48.12 28.92,47.95 29.13,47.96 29.21,47.78 29.13,47.49 29.54,47.27 29.51,47.13 29.57,46.96 29.88,46.83 29.94,46.72 29.92,46.54 30.13,46.42 29.84,46.35 29.71,46.45 29.61,46.40 29.30,46.47 29.20,46.38 29.19,46.52 28.96,46.46 28.93,46.36 29.01,46.18 28.95,46.05 28.74,45.94 28.73,45.85 28.49,45.67 28.50,45.52 28.21,45.45
|
||||||
|
67.76,37.17 67.96,36.97 68.07,36.95 68.91,37.33 69.30,37.12 69.41,37.21 69.40,37.40 69.49,37.55 69.82,37.61 70.04,37.55 70.19,37.58 70.25,37.66 70.21,37.92 70.62,38.33 70.88,38.46 71.26,38.31 71.33,38.17 71.28,37.92 71.58,37.91 71.43,37.13 71.53,36.85 71.67,36.70 71.80,36.69 72.36,36.98 72.66,37.03 72.90,37.27 73.38,37.46 73.72,37.42 73.65,37.24 74.17,37.33 74.35,37.42 74.66,37.39 74.89,37.23
|
||||||
|
66.52,37.35 66.83,37.37 67.07,37.33 67.20,37.24 67.52,37.27 67.76,37.17
|
||||||
|
61.26,35.62 61.34,35.63 61.62,35.43 61.98,35.44 62.31,35.17 62.46,35.25 62.69,35.26 63.06,35.45 63.17,35.68 63.13,35.85 64.01,36.01 64.51,36.34 64.82,37.13 65.09,37.24 65.55,37.25 65.64,37.47 65.77,37.57 66.11,37.41 66.52,37.35
|
||||||
|
92.57,21.98 92.49,22.69 92.36,22.93 92.25,23.68 91.93,23.69 91.94,23.50 91.75,23.29 91.75,23.05 91.62,22.98 91.51,23.03 91.44,23.20 91.37,23.20 91.37,23.07 91.32,23.10 91.16,23.66 91.23,23.92 91.37,24.09 91.88,24.20 91.95,24.36 92.10,24.41 92.23,24.88 92.44,24.85 92.47,24.94 92.05,25.17 90.44,25.16 89.83,25.29 89.82,25.94 89.67,26.21 89.59,26.19 89.55,26.01 89.37,26.01 89.19,26.11 89.02,26.41 88.92,26.38 88.97,26.25 88.83,26.25 88.68,26.29 88.62,26.43 88.42,26.57 88.35,26.50 88.44,26.37 88.15,26.09 88.08,25.89 88.50,25.54 88.77,25.49 88.95,25.26 88.82,25.18 88.46,25.19 88.31,24.88 88.15,24.91 88.02,24.63 88.15,24.49 88.29,24.48 88.72,24.27 88.70,24.00 88.57,23.67 88.74,23.44 88.72,23.25 88.93,23.19 88.85,23.04 89.05,22.09
|
||||||
|
2.39,11.90 2.37,12.22 2.81,12.38 3.60,11.70
|
||||||
|
18.61,3.48 18.62,3.30 18.49,2.92 18.21,2.41 18.07,2.01 18.06,1.53 17.90,1.12 17.89,0.23 17.72,-0.28 17.75,-0.55 17.28,-1.00 16.88,-1.23 16.54,-1.84 16.22,-2.18 16.22,-3.03 16.15,-3.46 15.99,-3.77 15.87,-3.93 15.60,-4.03 15.27,-4.31 14.71,-4.88 14.41,-4.83 14.37,-4.59 14.45,-4.45 14.36,-4.30 13.94,-4.48 13.72,-4.45 13.66,-4.72 13.41,-4.84 13.14,-4.60 13.07,-4.63
|
||||||
|
28.98,-30.00 29.10,-29.92 29.14,-29.70 29.29,-29.57 29.39,-29.27 29.30,-29.09 28.95,-28.88 28.63,-28.58 27.74,-28.94 27.29,-29.52 27.06,-29.63 27.39,-30.32 27.75,-30.60 28.06,-30.63 28.39,-30.15 28.65,-30.13 28.98,-30.00
|
||||||
|
29.36,-22.19 29.66,-22.15 30.19,-22.29 30.92,-22.29 31.29,-22.40
|
||||||
|
19.98,-24.78 20.43,-25.15 20.79,-25.92 20.82,-26.16 20.63,-26.44 20.69,-26.82 21.69,-26.84 21.79,-26.71 22.09,-26.58 22.22,-26.39 22.60,-26.13 22.88,-25.46 23.06,-25.31 23.39,-25.29 23.89,-25.60 24.19,-25.63 24.33,-25.74 24.75,-25.82 25.44,-25.71 25.58,-25.61 25.91,-24.75 26.40,-24.61 26.84,-24.24 26.99,-23.70 27.09,-23.58 27.76,-23.20 28.21,-22.69 28.38,-22.59 28.84,-22.48 29.13,-22.21 29.36,-22.19
|
||||||
|
34.96,-11.58 35.56,-11.60 35.79,-11.45 35.91,-11.45 36.31,-11.71 36.67,-11.68 36.98,-11.57 37.37,-11.71 37.72,-11.58 37.92,-11.29 38.18,-11.28 38.49,-11.41 38.79,-11.23 39.32,-11.12 39.99,-10.82 40.46,-10.46
|
||||||
|
-84.35,10.98 -84.17,10.78 -83.92,10.74 -83.71,10.79 -83.64,10.92
|
||||||
|
28.15,56.14 27.90,56.08 27.64,55.91 27.58,55.80 27.05,55.83 26.59,55.67
|
||||||
|
28.21,45.45 28.45,45.29 28.76,45.23 28.78,45.31 29.40,45.42 29.71,45.26
|
||||||
|
21.36,44.83 21.91,44.67 22.09,44.54 22.50,44.71 22.64,44.65 22.73,44.57 22.55,44.54 22.49,44.44 22.71,44.24
|
||||||
|
56.23,25.30 56.24,25.21 56.29,25.28 56.23,25.30
|
||||||
|
32.11,-26.84 32.89,-26.85
|
||||||
|
71.74,39.98 71.67,39.95 71.75,39.91 71.79,40.00 71.74,39.98
|
||||||
|
-122.69,48.99 -95.16,48.99 -95.16,49.37 -94.85,49.30 -94.80,49.00 -94.62,48.74 -94.06,48.66 -93.71,48.53 -93.38,48.62 -93.00,48.61 -92.50,48.44 -92.41,48.28 -92.17,48.34 -92.01,48.30 -91.52,48.06 -90.92,48.21 -90.74,48.10 -90.09,48.12 -89.90,48.00 -89.46,48.00 -88.38,48.30 -84.88,46.90 -84.78,46.64 -84.56,46.46 -84.15,46.54 -84.09,46.23 -83.98,46.08 -83.62,46.12 -83.47,45.99 -83.59,45.82 -82.55,45.35 -82.14,43.57 -82.41,43.07 -82.55,42.62 -83.07,42.30 -83.15,42.14 -83.14,41.98 -83.03,41.83 -82.69,41.68 -82.44,41.67 -81.28,42.21 -80.25,42.37 -79.04,42.80 -78.92,42.91 -79.03,43.02 -79.06,43.28 -79.17,43.47 -78.85,43.58 -76.82,43.63 -76.46,44.06 -76.15,44.30 -75.00,44.97 -71.52,45.01 -71.33,45.29 -71.13,45.26 -71.00,45.34 -70.87,45.27 -70.80,45.40 -70.69,45.43 -70.70,45.55 -70.42,45.74 -70.30,45.91 -70.25,46.25 -70.07,46.44 -70.01,46.71 -69.24,47.46 -69.05,47.43 -69.05,47.27 -68.94,47.21 -68.24,47.35 -67.81,47.08 -67.80,45.73 -67.43,45.60 -67.49,45.47 -67.43,45.38 -67.47,45.28 -67.37,45.17 -67.12,45.17
|
||||||
|
34.96,-11.58 34.89,-11.39 34.77,-11.34 34.61,-11.08 34.66,-10.71 34.58,-10.53 34.52,-10.03 34.32,-9.73 34.09,-9.54 34.00,-9.50 33.95,-9.66 33.89,-9.67
|
||||||
|
107.97,21.51 107.76,21.66 107.35,21.61 107.27,21.71 107.06,21.79 106.97,21.92 106.66,21.98 106.65,22.24 106.55,22.34 106.55,22.50 106.78,22.78 106.54,22.91 106.28,22.86 106.15,22.97 105.84,22.92 105.55,23.07 105.28,23.35 104.86,23.14 104.80,22.91 104.69,22.82 104.37,22.70 104.14,22.80 104.05,22.75 103.94,22.54 103.62,22.78 103.49,22.59 103.33,22.77 102.98,22.45 102.83,22.59 102.47,22.75 102.13,22.38
|
||||||
|
7.44,43.75 7.38,43.73
|
||||||
|
12.43,41.91 12.43,41.91
|
||||||
|
113.55,22.22 113.48,22.20
|
||||||
|
114.02,22.51 114.12,22.57 114.27,22.54
|
||||||
|
70.61,39.79 70.48,39.88 70.70,39.85 70.61,39.79
|
||||||
|
71.22,39.91 71.01,39.90 71.04,39.99 70.97,40.04 70.98,40.13 71.23,40.05 71.18,39.98 71.22,39.91
|
||||||
|
145.94,43.10 145.78,43.48 145.69,43.55 145.54,43.51 145.24,43.78 145.27,43.91 145.62,44.29 145.66,44.45 145.58,44.79
|
||||||
|
-1.28,32.09 -1.23,32.11 -1.24,32.34 -1.07,32.47 -1.45,32.78 -1.68,33.32 -1.63,33.57 -1.71,33.78 -1.71,34.18 -1.79,34.37 -1.73,34.47 -1.85,34.61 -1.80,34.75 -2.13,34.97 -2.22,35.10
|
||||||
|
29.00,9.61 29.47,9.77 29.60,9.92 29.61,10.07 30.00,10.28 30.76,9.73 31.22,9.80 31.79,10.38 31.93,10.66 32.42,11.09 32.34,11.31 32.34,11.72 32.07,12.01 32.74,12.01 32.72,12.22 33.20,12.22 33.07,11.59 33.17,10.85 33.13,10.76 33.38,10.65 33.91,10.18 33.96,9.86 33.87,9.51 34.08,9.46
|
||||||
|
24.15,8.67 24.21,8.77 24.53,8.89 24.67,9.39 24.78,9.53 24.79,9.81 24.96,9.99 25.02,10.24 25.10,10.31 25.86,10.41 25.92,10.17 26.55,9.53 26.66,9.48 27.07,9.61 27.88,9.60
|
||||||
|
27.88,9.60 28.05,9.33 28.84,9.33 28.84,9.46 29.00,9.61
|
||||||
|
-56.48,1.94 -56.70,2.04 -57.20,2.85 -57.30,3.38 -57.60,3.37
|
||||||
|
-54.62,2.33 -54.40,2.46 -54.20,2.82 -54.20,3.14 -54.08,3.33
|
||||||
|
33.89,-9.67 33.70,-9.60 33.42,-9.61 33.33,-9.52 32.92,-9.41
|
||||||
|
33.59,46.10 33.66,46.22 34.22,46.10 34.45,45.97 34.69,45.98 34.79,45.89 34.80,45.79 35.00,45.73
|
||||||
|
77.05,35.11 76.77,35.66
|
||||||
|
77.05,35.11 77.80,35.50
|
||||||
|
79.12,33.23 79.38,33.15 79.33,32.98 79.50,32.73 79.38,32.57 79.22,32.50
|
||||||
|
79.22,32.50 78.92,32.36 78.70,32.60 78.41,32.56 78.49,32.23
|
||||||
|
78.49,32.23 78.73,31.98 78.72,31.88
|
||||||
|
78.72,31.88 78.69,31.74 78.80,31.62 78.73,31.47 78.74,31.32 78.84,31.30
|
||||||
|
78.84,31.30 78.97,31.33 79.01,31.41 79.11,31.40 79.37,31.08
|
||||||
|
79.37,31.08 79.57,30.95 79.79,30.97 79.91,30.90
|
||||||
|
79.91,30.90 80.19,30.76 80.19,30.57
|
||||||
|
80.19,30.57 80.68,30.41 80.99,30.24 81.01,30.16
|
||||||
|
90.33,28.12 90.35,28.24 90.22,28.28 89.98,28.31 89.80,28.24
|
||||||
|
89.80,28.24 89.48,28.06 89.12,27.62
|
||||||
|
89.12,27.62 89.02,27.51
|
||||||
|
89.02,27.51 88.89,27.32
|
||||||
|
75.30,32.32 75.32,32.22 75.25,32.14 74.74,31.95 74.56,31.82 74.51,31.71 74.59,31.47 74.52,31.19 74.63,31.03 74.34,30.89 73.90,30.44 73.93,30.22 73.81,30.09 73.38,29.93 73.23,29.55 72.90,29.03 72.34,28.75 72.13,28.35 71.95,28.18 71.87,27.96 70.80,27.71 70.69,27.77 70.63,27.94 70.40,28.03 70.14,27.85 69.90,27.47 69.54,27.12 69.47,26.80 69.51,26.74 70.15,26.51 70.10,25.91 70.26,25.71 70.65,25.67 70.65,25.42 71.05,24.69 70.97,24.57 71.04,24.40 70.72,24.24 70.58,24.28 70.55,24.42 70.49,24.41 70.10,24.29 70.02,24.19 69.81,24.17 69.56,24.27 68.83,24.26 68.78,24.31 68.73,24.27 68.72,23.96 68.38,23.95 68.17,23.86
|
||||||
|
36.69,45.62 36.69,45.43 36.59,45.30 36.64,45.26 36.51,45.20 36.61,44.95
|
||||||
|
-55.81,-31.03 -56.00,-31.08 -56.00,-30.85
|
||||||
|
-56.00,-30.85 -56.18,-30.63 -56.83,-30.11 -57.12,-30.14 -57.21,-30.28 -57.55,-30.26 -57.61,-30.19
|
||||||
|
-73.48,-49.77 -73.58,-49.58 -73.46,-49.31 -73.14,-49.30
|
||||||
|
-73.14,-49.30 -73.15,-49.19 -73.03,-49.01 -72.65,-48.84 -72.58,-48.48 -72.35,-48.37 -72.29,-48.23 -72.33,-48.11 -72.51,-47.97 -72.52,-47.88 -72.35,-47.49 -72.04,-47.24 -71.90,-47.20 -71.96,-47.02 -71.94,-46.83 -71.70,-46.65 -71.76,-46.32 -71.88,-46.16 -71.63,-45.95 -71.75,-45.84 -71.75,-45.58 -71.51,-45.51 -71.35,-45.33 -71.35,-45.23 -71.60,-44.98 -72.04,-44.90 -72.06,-44.77 -71.26,-44.76 -71.16,-44.56 -71.21,-44.44 -71.82,-44.38 -71.81,-44.11 -71.68,-43.93 -71.79,-43.75 -71.74,-43.70 -71.75,-43.59 -71.90,-43.44 -71.90,-43.35 -71.75,-43.24 -71.78,-43.17 -72.10,-43.07 -72.14,-42.58 -72.05,-42.47 -72.11,-42.25 -72.03,-42.15 -71.86,-42.15 -71.75,-42.05 -71.91,-41.65 -71.87,-40.89 -71.94,-40.79 -71.80,-40.44 -71.70,-40.34 -71.82,-40.18 -71.66,-40.02 -71.64,-39.89 -71.72,-39.64 -71.54,-39.60 -71.42,-39.29 -71.40,-38.94 -70.95,-38.74 -70.85,-38.54 -70.97,-38.45 -71.03,-38.04 -71.17,-37.76 -71.13,-37.45 -71.20,-37.30 -71.12,-37.11 -71.19,-36.84 -71.06,-36.52 -70.75,-36.39 -70.72,-36.28 -70.40,-36.06 -70.42,-35.52 -70.56,-35.25 -70.39,-35.15 -70.29,-34.73 -70.05,-34.30 -69.85,-34.22 -69.89,-33.73 -69.80,-33.40 -69.82,-33.28 -70.02,-33.27 -70.08,-33.20 -70.09,-33.03 -70.02,-32.88 -70.12,-32.81 -70.17,-32.47 -70.32,-32.27 -70.36,-32.08 -70.25,-31.96 -70.45,-31.84 -70.59,-31.57 -70.52,-31.15 -70.39,-31.12 -70.31,-31.02 -70.35,-30.90 -70.15,-30.36 -69.96,-30.36 -69.84,-30.18 -69.96,-30.08 -69.93,-29.77 -70.03,-29.32 -69.83,-29.10 -69.66,-28.41 -69.17,-27.92 -68.85,-27.15 -68.71,-27.10 -68.59,-27.14 -68.32,-26.97 -68.32,-26.88 -68.59,-26.47 -68.58,-26.35 -68.41,-26.15 -68.59,-25.42 -68.50,-25.16 -68.38,-25.09 -68.56,-24.84 -68.56,-24.75 -68.25,-24.39 -67.36,-24.03 -67.01,-23.00 -67.19,-22.82
|
||||||
|
75.32,34.58 74.30,34.77 73.96,34.65 73.81,34.42 73.81,34.33 73.97,34.24 73.92,34.04 74.25,33.99 74.22,33.89 74.00,33.79 73.98,33.72 74.15,33.51 73.99,33.22 74.30,32.99 74.31,32.81
|
||||||
|
23.61,51.52 23.54,51.71 23.63,51.81 23.65,52.04 23.18,52.29 23.41,52.52 23.84,52.66 23.92,52.77 23.86,53.11 23.48,53.94
|
||||||
|
34.08,9.46 34.16,9.85 34.31,10.19 34.28,10.53 34.34,10.66 34.57,10.88 34.77,10.75 34.93,10.86 34.96,11.28 35.11,11.82 35.67,12.62 36.13,12.76 36.16,13.09 36.39,13.63 36.52,14.26
|
||||||
|
2.39,11.90 2.29,11.69 1.98,11.42 1.43,11.45 1.32,11.30 1.15,11.25 1.08,11.08 0.99,11.08 0.90,10.99
|
||||||
|
31.95,-25.96 31.87,-25.98 31.42,-25.75 31.21,-25.84 30.80,-26.41 30.79,-26.76 30.88,-26.79 31.06,-27.11 31.47,-27.30 31.96,-27.31 31.99,-26.82 32.11,-26.84
|
||||||
|
32.11,-26.84 32.06,-26.02 31.95,-25.96
|
||||||
|
76.77,35.66 76.56,35.77 76.55,35.89 76.15,35.83 76.07,35.98 75.91,36.05 75.97,36.38
|
||||||
|
27.33,5.19 27.79,4.64 28.19,4.35 28.43,4.32 28.73,4.50 29.22,4.39 29.47,4.61 29.68,4.59 30.19,3.98 30.51,3.84 30.59,3.62 30.76,3.62 30.84,3.49
|
||||||
|
22.86,10.92 23.31,10.39 23.65,9.82 23.62,9.34 23.47,9.11 23.55,8.94 23.54,8.82 23.68,8.73 24.15,8.67
|
||||||
|
46.11,38.88 46.49,38.91
|
||||||
|
44.78,39.68 45.14,39.25 45.48,39.01 46.11,38.88
|
||||||
|
46.49,38.91 47.48,39.50 47.77,39.65 48.00,39.68 48.32,39.40 48.10,39.24 48.29,39.02 48.05,38.93 48.00,38.85 48.59,38.41 48.87,38.44
|
||||||
|
81.01,30.16 80.85,30.14 80.40,29.73 80.25,29.42 80.23,29.19 80.08,28.99 80.07,28.83 80.42,28.61 80.59,28.65 81.85,27.87 81.99,27.91 82.45,27.67 82.68,27.67 82.73,27.52 83.29,27.37 83.45,27.47 83.83,27.38 84.09,27.49 84.61,27.30 84.69,27.04 85.13,26.86 85.24,26.75 85.57,26.84 85.70,26.78 85.79,26.60 86.01,26.65 86.70,26.44 87.02,26.56 87.09,26.43 87.29,26.36 87.41,26.42 87.85,26.44 88.00,26.38 88.16,26.72 87.98,27.13 88.15,27.75 88.11,27.87
|
||||||
|
75.97,36.38 75.84,36.65 75.67,36.74 75.42,36.74 75.35,36.91 74.54,37.02
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- A copy is on this computer: a check in an outlined circle. Adwaita 50
|
||||||
|
no longer ships emblem-ok-symbolic. -->
|
||||||
|
<path fill="#222222" fill-rule="evenodd" d="M 8,1 A 7,7 0 1 0 8,15 A 7,7 0 1 0 8,1 Z M 8,2.5 A 5.5,5.5 0 1 1 8,13.5 A 5.5,5.5 0 1 1 8,2.5 Z"/>
|
||||||
|
<path fill="#222222" d="M 4.1,8.6 L 5.2,7.5 L 7,9.3 L 10.8,5.4 L 11.9,6.5 L 7,11.5 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 450 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- Show or hide the details pane: a window with its right-hand pane
|
||||||
|
filled in. Some themes draw sidebar-show-right-symbolic as a solid block. -->
|
||||||
|
<path fill="#222222" fill-rule="evenodd" d="M 3,2 H 13 A 2,2 0 0 1 15,4 V 12 A 2,2 0 0 1 13,14 H 3 A 2,2 0 0 1 1,12 V 4 A 2,2 0 0 1 3,2 Z M 3,3.5 A 0.5,0.5 0 0 0 2.5,4 V 12 A 0.5,0.5 0 0 0 3,12.5 H 13 A 0.5,0.5 0 0 0 13.5,12 V 4 A 0.5,0.5 0 0 0 13,3.5 Z"/>
|
||||||
|
<path fill="#222222" d="M 9.5,5 H 12.5 V 11 H 9.5 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 564 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- "Filters": a funnel, wide at the top and narrowing to a short spout.
|
||||||
|
No stock icon theme ships one under a standard name. -->
|
||||||
|
<path fill="#222222" d="M 1.5,2 H 14.5 L 9.5,8.5 V 13.5 L 6.5,15 V 8.5 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 309 B |
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- A share link: two open chain ends and the bar that joins them, laid
|
||||||
|
on the diagonal. Written out in plain coordinates: GTK's symbolic icon
|
||||||
|
renderer drops a transformed group. -->
|
||||||
|
<path fill="#222222" d="M 4.82,6.23 L 3.05,8 A 3.5,3.5 0 0 0 8,12.95 L 9.77,11.18 L 8.53,9.94 L 6.76,11.71 A 1.75,1.75 0 0 1 4.29,9.24 L 6.06,7.47 Z"/>
|
||||||
|
<path fill="#222222" d="M 6.23,4.82 L 8,3.05 A 3.5,3.5 0 0 1 12.95,8 L 11.18,9.77 L 9.94,8.53 L 11.71,6.76 A 1.75,1.75 0 0 0 9.24,4.29 L 7.47,6.06 Z"/>
|
||||||
|
<path fill="#222222" d="M 5.26,9.5 L 9.5,5.26 L 10.74,6.5 L 6.5,10.74 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 676 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- Show on map: a folded map in three panels. No stock theme has a
|
||||||
|
map-symbolic. -->
|
||||||
|
<path fill="#222222" d="M 1,3.2 L 5,1.5 V 12.8 L 1,14.5 Z M 11,3.2 L 15,1.5 V 12.8 L 11,14.5 Z"/>
|
||||||
|
<path fill="#222222" opacity="0.55" d="M 6,1.5 L 10,3.2 V 14.5 L 6,12.8 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 366 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- "Available offline": a filled disc with a down arrow knocked out of it
|
||||||
|
(one path, fill-rule evenodd, so symbolic recolouring keeps working).
|
||||||
|
Replaces the star, which every desktop uses for "favourite". -->
|
||||||
|
<path fill="#222222" fill-rule="evenodd" d="M 8,1 A 7,7 0 1 0 8,15 A 7,7 0 1 0 8,1 Z M 7,4 H 9 V 8.6 L 10.8,6.8 L 12.2,8.2 L 8,12.4 L 3.8,8.2 L 5.2,6.8 L 7,8.6 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 485 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- "Online only": a cloud outline. Lighter than the stock weather icon, so a
|
||||||
|
folder full of cloud-only files does not shout. -->
|
||||||
|
<path fill="#222222" fill-rule="evenodd" d="M 8.5,3 C 6.6,3 5,4.3 4.6,6.1 C 2.6,6.3 1,8 1,10 C 1,12.2 2.8,14 5,14 H 12 C 13.7,14 15,12.7 15,11 C 15,9.5 13.9,8.2 12.4,8 C 12.1,5.2 10.5,3 8.5,3 Z M 8.5,4.5 C 9.9,4.5 11,5.9 11,8 V 9.4 H 12 C 12.8,9.4 13.5,10.1 13.5,11 C 13.5,11.8 12.8,12.5 12,12.5 H 5 C 3.6,12.5 2.5,11.4 2.5,10 C 2.5,8.6 3.6,7.5 5,7.5 H 5.9 L 6,6.7 C 6.2,5.4 7.2,4.5 8.5,4.5 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 629 B |