mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 00:47:00 +01:00
Compare commits
102
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07d4ee42d9 | ||
|
|
0ac5e2e4fe | ||
|
|
7eb6aa0640 | ||
|
|
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 |
No files matched your search
@@ -67,7 +67,13 @@ jobs:
|
|||||||
User aur
|
User aur
|
||||||
EOF
|
EOF
|
||||||
# Pin the host key to the fingerprint published on aur.archlinux.org.
|
# Pin the host key to the fingerprint published on aur.archlinux.org.
|
||||||
ssh-keyscan -t ed25519 aur.archlinux.org > "$HOME/.ssh/known_hosts"
|
# 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'
|
ssh-keygen -lf "$HOME/.ssh/known_hosts" | grep -q 'SHA256:RFzBCUItH9LZS0cKB5UE6ceAYhBD5C8GeOBip8Z11+4'
|
||||||
chown -R builder: "$HOME/.ssh"
|
chown -R builder: "$HOME/.ssh"
|
||||||
chmod 600 "$HOME/.ssh/aur"
|
chmod 600 "$HOME/.ssh/aur"
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ jobs:
|
|||||||
# Real Proton credentials and /dev/fuse deliberately do not run on pull
|
# Real Proton credentials and /dev/fuse deliberately do not run on pull
|
||||||
# requests. Follow docs/TESTING.md on a dedicated test-account runner.
|
# requests. Follow docs/TESTING.md on a dedicated test-account runner.
|
||||||
|
|
||||||
# The seeded simulation runs (docs/MILESTONE-3.0.0.md §8.2): real FUSE
|
# The seeded simulation runs (docs/TESTING.md): real FUSE
|
||||||
# daemons against an in-memory Drive, so they need /dev/fuse but no account.
|
# daemons against an in-memory Drive, so they need /dev/fuse but no account.
|
||||||
simulation:
|
simulation:
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
@@ -101,6 +101,16 @@ jobs:
|
|||||||
- name: Simulation runs
|
- name: Simulation runs
|
||||||
run: 'cargo test -p pdfs-fuse --lib --locked sim:: -- --ignored --test-threads=1'
|
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
|
# Keeps the release-profile dependencies cached on main, where the release
|
||||||
# workflow can restore them; a tag cannot reuse the caches of earlier tags.
|
# workflow can restore them; a tag cannot reuse the caches of earlier tags.
|
||||||
release-cache:
|
release-cache:
|
||||||
|
|||||||
@@ -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."
|
||||||
+3
-3
@@ -39,9 +39,9 @@ cargo test --workspace --locked
|
|||||||
scripts/fuse-acceptance.sh --offline-only
|
scripts/fuse-acceptance.sh --offline-only
|
||||||
```
|
```
|
||||||
|
|
||||||
Changes to the FUSE layer, sync, or the pending-operation queue should also be exercised against a
|
Changes to the FUSE layer, sync, or the queue should also pass the simulation runs and be
|
||||||
real mount before review — see [`docs/TESTING.md`](docs/TESTING.md). Live runs are destructive:
|
exercised against a real mount before review — see [`docs/TESTING.md`](docs/TESTING.md). Live
|
||||||
use a dedicated test account, never one holding data you cannot lose.
|
runs are destructive: use a dedicated test account, never one holding data you cannot lose.
|
||||||
|
|
||||||
## Making changes
|
## Making changes
|
||||||
|
|
||||||
|
|||||||
Generated
+28
-24
@@ -166,7 +166,7 @@ version = "1.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -177,7 +177,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anstyle",
|
"anstyle",
|
||||||
"once_cell_polyfill",
|
"once_cell_polyfill",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1254,7 +1254,7 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"option-ext",
|
"option-ext",
|
||||||
"redox_users",
|
"redox_users",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1433,7 +1433,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1896,7 +1896,7 @@ dependencies = [
|
|||||||
"gobject-sys",
|
"gobject-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"system-deps 9.0.0",
|
"system-deps 9.0.0",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3100,7 +3100,7 @@ version = "0.50.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3474,7 +3474,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-cli"
|
name = "pdfs-cli"
|
||||||
version = "2.10.0"
|
version = "3.1.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -3485,13 +3485,15 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-core"
|
name = "pdfs-core"
|
||||||
version = "2.10.0"
|
version = "3.1.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
"base64 0.23.1",
|
||||||
"dbus-secret-service-keyring-store",
|
"dbus-secret-service-keyring-store",
|
||||||
"directories",
|
"directories",
|
||||||
"ignore",
|
"ignore",
|
||||||
@@ -3508,12 +3510,13 @@ dependencies = [
|
|||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"zeroize",
|
||||||
"zip",
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-fuse"
|
name = "pdfs-fuse"
|
||||||
version = "2.10.0"
|
version = "3.1.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -3535,7 +3538,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-gui"
|
name = "pdfs-gui"
|
||||||
version = "2.10.0"
|
version = "3.1.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -3572,9 +3575,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pgp"
|
name = "pgp"
|
||||||
version = "0.20.0"
|
version = "0.21.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1cfa4743b28656065ff4c0ba09e46b357a65e8c00fc2341e89084b82f87cbdf1"
|
checksum = "ae70f4d9325a391db30d115d6191a7bb67cec856aa0bba131290f0c4e09a532a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aead 0.5.2",
|
"aead 0.5.2",
|
||||||
"aes 0.8.4",
|
"aes 0.8.4",
|
||||||
@@ -3775,9 +3778,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-drive-rs"
|
name = "proton-drive-rs"
|
||||||
version = "0.7.2"
|
version = "0.8.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d6ce113d234474821d34787436ee80e1458e3d8a8f97212219615ed315eb70be"
|
checksum = "014165ed0f2d3847a4b8956ca94a82ba16c82f2d5c7fa139c3541c15150dba21"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -3802,9 +3805,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-sdk"
|
name = "proton-sdk"
|
||||||
version = "0.7.2"
|
version = "0.8.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2a33c67df9694f90ce60e5b34984275956244138511b4640ca0d100e1dac8b3d"
|
checksum = "9e16bf5592b7947708dd754defeaee7a58e9fa35988a6ea345b5b9a93765fd81"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm 0.11.1",
|
"aes-gcm 0.11.1",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -3831,6 +3834,7 @@ dependencies = [
|
|||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3922,7 +3926,7 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
"socket2",
|
"socket2",
|
||||||
"tracing",
|
"tracing",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4370,7 +4374,7 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4427,7 +4431,7 @@ dependencies = [
|
|||||||
"security-framework",
|
"security-framework",
|
||||||
"security-framework-sys",
|
"security-framework-sys",
|
||||||
"webpki-root-certs",
|
"webpki-root-certs",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4760,7 +4764,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4928,10 +4932,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"getrandom 0.4.3",
|
"getrandom 0.3.4",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5262,7 +5266,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"memoffset",
|
"memoffset",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5586,7 +5590,7 @@ version = "0.1.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
+4
-3
@@ -3,7 +3,7 @@ resolver = "2"
|
|||||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "2.10.0"
|
version = "3.1.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||||
@@ -15,8 +15,8 @@ keywords = ["proton", "drive", "fuse", "sync", "linux"]
|
|||||||
categories = ["filesystem", "command-line-utilities"]
|
categories = ["filesystem", "command-line-utilities"]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
proton-sdk = { version = "0.7.2" }
|
proton-sdk = { version = "0.8.1" }
|
||||||
proton-drive-rs = { version = "0.7.2" }
|
proton-drive-rs = { version = "0.8.1" }
|
||||||
|
|
||||||
# Internal crates.
|
# Internal crates.
|
||||||
pdfs-core = { path = "crates/pdfs-core" }
|
pdfs-core = { path = "crates/pdfs-core" }
|
||||||
@@ -26,6 +26,7 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs",
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
base64 = "0.23"
|
base64 = "0.23"
|
||||||
|
zeroize = "1"
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
# Non-poisoning mutexes. A handler that panics while holding a lock must not
|
# Non-poisoning mutexes. A handler that panics while holding a lock must not
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ scriptable CLI.
|
|||||||
[](https://aur.archlinux.org/packages/proton-drive-for-linux)
|
[](https://aur.archlinux.org/packages/proton-drive-for-linux)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://www.rust-lang.org)
|
[](https://www.rust-lang.org)
|
||||||
|
[](https://buymeacoffee.com/narl)
|
||||||
|
|
||||||
<img src="images/files.png" alt="My files in the Proton Drive app" width="820">
|
<img src="images/files.png" alt="My files in the Proton Drive app" width="820">
|
||||||
|
|
||||||
@@ -38,8 +39,9 @@ scriptable CLI.
|
|||||||
- **Synced folders.** Back up folders such as `~/Documents` under your computer in Proton Drive,
|
- **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
|
as a full two-way copy or online only. Gitignore-style `.pdfsignore` rules, conflict copies
|
||||||
instead of overwrites, pause and bandwidth limits.
|
instead of overwrites, pause and bandwidth limits.
|
||||||
- **Safe writes.** Changes are staged on disk and queued before they are acknowledged, so they
|
- **Works at local speed.** Making, renaming and deleting files and folders returns at once, online
|
||||||
survive a lost connection, a crash or a reboot and upload when they can.
|
or offline. Changes are staged on disk and queued before they are acknowledged, so they survive
|
||||||
|
a lost connection, a crash or a reboot and reach Drive when they can.
|
||||||
|
|
||||||
**Desktop app**
|
**Desktop app**
|
||||||
|
|
||||||
@@ -50,7 +52,8 @@ scriptable CLI.
|
|||||||
- **Sharing.** Invite people as viewer, editor or admin, create public links with password and
|
- **Sharing.** Invite people as viewer, editor or admin, create public links with password and
|
||||||
expiry, accept invitations.
|
expiry, accept invitations.
|
||||||
- **Sync overview.** What is uploading, what is waiting, what failed and why, and a history of
|
- **Sync overview.** What is uploading, what is waiting, what failed and why, and a history of
|
||||||
what happened.
|
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`)
|
- **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,
|
that searches Proton Drive and your home folder together, as its own window, through fuzzel,
|
||||||
rofi or wofi, or live in `fzf`.
|
rofi or wofi, or live in `fzf`.
|
||||||
@@ -146,7 +149,7 @@ keys before it leaves the machine, through a Rust implementation of Proton's Dri
|
|||||||
|
|
||||||
Files you open are decrypted into a local cache so programs can read them. What that means for
|
Files you open are decrypted into a local cache so programs can read them. What that means for
|
||||||
the security of the machine is spelled out in the
|
the security of the machine is spelled out in the
|
||||||
[threat model](docs/ARCHITECTURE.md#8-threat-model-what-this-client-writes-to-disk-in-plaintext).
|
[threat model](docs/ARCHITECTURE.md#10-threat-model-what-this-client-writes-to-disk-in-plaintext).
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
@@ -154,6 +157,13 @@ Bug reports, translations and patches are welcome. See [CONTRIBUTING.md](CONTRIB
|
|||||||
workflow and the checks CI runs, and [SECURITY.md](SECURITY.md) to report a vulnerability
|
workflow and the checks CI runs, and [SECURITY.md](SECURITY.md) to report a vulnerability
|
||||||
privately.
|
privately.
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
Proton Drive Linux is free and built in spare time. If it saves you a few headaches, you can
|
||||||
|
[buy me a coffee](https://buymeacoffee.com/narl).
|
||||||
|
|
||||||
|
<a href="https://buymeacoffee.com/narl"><img src="https://img.shields.io/badge/Buy%20me%20a%20coffee-FFDD00?logo=buymeacoffee&logoColor=black" alt="Buy me a coffee"></a>
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Released under the [MIT License](LICENSE).
|
Released under the [MIT License](LICENSE).
|
||||||
|
|||||||
+1
-1
@@ -31,7 +31,7 @@ the interesting boundaries are:
|
|||||||
or the wrong key material to Proton.
|
or the wrong key material to Proton.
|
||||||
|
|
||||||
What the client deliberately keeps in plaintext on disk, and why, is documented in the
|
What the client deliberately keeps in plaintext on disk, and why, is documented in the
|
||||||
[threat model](docs/ARCHITECTURE.md#8-threat-model-what-this-client-writes-to-disk-in-plaintext).
|
[threat model](docs/ARCHITECTURE.md#10-threat-model-what-this-client-writes-to-disk-in-plaintext).
|
||||||
Behaviour described there is known and not a vulnerability by itself; a way to make it worse
|
Behaviour described there is known and not a vulnerability by itself; a way to make it worse
|
||||||
than described is.
|
than described is.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
+306
-8
@@ -14,7 +14,7 @@ use pdfs_core::cache::ContentCache;
|
|||||||
use pdfs_core::config::AppDirs;
|
use pdfs_core::config::AppDirs;
|
||||||
use pdfs_core::control::{
|
use pdfs_core::control::{
|
||||||
ConflictKeep, ErrorKind, MountKind, RefreshScope, Request as CtlRequest,
|
ConflictKeep, ErrorKind, MountKind, RefreshScope, Request as CtlRequest,
|
||||||
Response as CtlResponse, RestoreItem as CtlRestoreItem, ShareEntryKind, SyncPhase,
|
Response as CtlResponse, RestoreItem as CtlRestoreItem, ShareEntryKind, SyncIssue, SyncPhase,
|
||||||
pending_summary,
|
pending_summary,
|
||||||
};
|
};
|
||||||
use pdfs_core::db::Db;
|
use pdfs_core::db::Db;
|
||||||
@@ -135,6 +135,24 @@ enum Command {
|
|||||||
/// Account email; prompted if omitted.
|
/// Account email; prompted if omitted.
|
||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
username: Option<String>,
|
username: Option<String>,
|
||||||
|
/// For a two-password account, keep the mailbox password's unlock in the
|
||||||
|
/// system keyring so the daemon starts without asking after every login.
|
||||||
|
/// Without it the unlock lives in kernel memory for this session only.
|
||||||
|
#[arg(long)]
|
||||||
|
remember: bool,
|
||||||
|
},
|
||||||
|
/// Enter the mailbox password of a two-password account so the daemon can
|
||||||
|
/// unlock your files. Needed once per login session.
|
||||||
|
Unlock {
|
||||||
|
/// Keep the unlock in the system keyring instead of kernel memory only.
|
||||||
|
#[arg(long)]
|
||||||
|
remember: bool,
|
||||||
|
},
|
||||||
|
/// Forget the unlock held in kernel memory; the account is locked again.
|
||||||
|
Lock {
|
||||||
|
/// Also remove an unlock kept in the keyring with `--remember`.
|
||||||
|
#[arg(long)]
|
||||||
|
forget: bool,
|
||||||
},
|
},
|
||||||
/// Forget the stored session.
|
/// Forget the stored session.
|
||||||
Logout,
|
Logout,
|
||||||
@@ -610,6 +628,15 @@ enum DeviceCmd {
|
|||||||
#[arg(long, conflicts_with = "uid")]
|
#[arg(long, conflicts_with = "uid")]
|
||||||
clear: bool,
|
clear: bool,
|
||||||
},
|
},
|
||||||
|
/// Move another computer's folders into this computer's backup and sync
|
||||||
|
/// them here. The other computer stays registered, without those folders.
|
||||||
|
Migrate {
|
||||||
|
/// Device uid (from `devices list`).
|
||||||
|
uid: String,
|
||||||
|
/// Skip the confirmation and accept every proposed local path.
|
||||||
|
#[arg(long)]
|
||||||
|
yes: bool,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
@@ -677,6 +704,25 @@ enum SyncCmd {
|
|||||||
},
|
},
|
||||||
/// List uploads and changes not yet on Proton Drive.
|
/// List uploads and changes not yet on Proton Drive.
|
||||||
Queue,
|
Queue,
|
||||||
|
/// List queued changes Proton Drive refused or that keep failing, and what to do.
|
||||||
|
Issues,
|
||||||
|
/// Save the content of a queued upload (by id from `sync queue`) to a file.
|
||||||
|
Export {
|
||||||
|
/// Queue id.
|
||||||
|
id: i64,
|
||||||
|
/// Where to save it: a new file, or an existing folder to save it in.
|
||||||
|
dest: PathBuf,
|
||||||
|
},
|
||||||
|
/// Drop a queued change (by id from `sync queue`) and undo it here: a new file
|
||||||
|
/// or folder goes, anything else goes back to what Proton Drive has. Its
|
||||||
|
/// content is lost unless saved with `sync export` first.
|
||||||
|
Discard {
|
||||||
|
/// Queue id.
|
||||||
|
id: i64,
|
||||||
|
/// Do not ask first.
|
||||||
|
#[arg(long)]
|
||||||
|
yes: bool,
|
||||||
|
},
|
||||||
/// Retry a queued operation now (by id from `sync queue`), or every failed one.
|
/// Retry a queued operation now (by id from `sync queue`), or every failed one.
|
||||||
Retry {
|
Retry {
|
||||||
/// Queue id; omit to retry every failed operation.
|
/// Queue id; omit to retry every failed operation.
|
||||||
@@ -784,7 +830,9 @@ fn main() -> Result<()> {
|
|||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
JSON_OUTPUT.store(cli.json, std::sync::atomic::Ordering::Relaxed);
|
JSON_OUTPUT.store(cli.json, std::sync::atomic::Ordering::Relaxed);
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Command::Login { username } => cmd_login(username),
|
Command::Login { username, remember } => cmd_login(username, remember),
|
||||||
|
Command::Unlock { remember } => cmd_unlock(remember),
|
||||||
|
Command::Lock { forget } => cmd_lock(forget),
|
||||||
Command::Logout => cmd_logout(),
|
Command::Logout => cmd_logout(),
|
||||||
Command::Status => cmd_status(),
|
Command::Status => cmd_status(),
|
||||||
Command::Locations => cmd_locations(),
|
Command::Locations => cmd_locations(),
|
||||||
@@ -928,6 +976,24 @@ fn cmd_devices(action: DeviceCmd) -> Result<()> {
|
|||||||
let uid = if clear { None } else { uid };
|
let uid = if clear { None } else { uid };
|
||||||
ok_or_bail(control_request(CtlRequest::AdoptDevice { uid })?)?
|
ok_or_bail(control_request(CtlRequest::AdoptDevice { uid })?)?
|
||||||
}
|
}
|
||||||
|
DeviceCmd::Migrate { uid, yes } => {
|
||||||
|
if !yes {
|
||||||
|
println!(
|
||||||
|
"Every folder that device backs up moves into this computer's backup.\n\
|
||||||
|
The device stays registered, without those folders. Stop Proton Drive\n\
|
||||||
|
on that computer first, or it sees its folders disappear."
|
||||||
|
);
|
||||||
|
confirm("Move them here?")?;
|
||||||
|
}
|
||||||
|
let response = control_request(CtlRequest::MigrateDevice { uid })?;
|
||||||
|
// A script gets the one reply and runs `sync restore` itself.
|
||||||
|
if emit_json(&response)? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
ok_or_bail(response)?;
|
||||||
|
// The folders are this device's now, so the plain restore offers them.
|
||||||
|
cmd_sync_restore(yes, None)?
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1028,7 +1094,17 @@ fn cmd_sync(action: SyncCmd) -> Result<()> {
|
|||||||
})?)?
|
})?)?
|
||||||
}
|
}
|
||||||
SyncCmd::Queue => return cmd_sync_queue(),
|
SyncCmd::Queue => return cmd_sync_queue(),
|
||||||
|
SyncCmd::Issues => return cmd_sync_issues(),
|
||||||
|
SyncCmd::Export { id, dest } => return cmd_sync_export(id, &dest),
|
||||||
SyncCmd::Limit { up, down } => return cmd_sync_limit(up, down),
|
SyncCmd::Limit { up, down } => return cmd_sync_limit(up, down),
|
||||||
|
SyncCmd::Discard { id, yes } => {
|
||||||
|
if !yes {
|
||||||
|
confirm(&format!(
|
||||||
|
"Discard queued change {id} and undo it here? Content not exported is lost."
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
ok_or_bail(control_request(CtlRequest::DiscardPendingOp { id })?)?
|
||||||
|
}
|
||||||
SyncCmd::Retry { id } => ok_or_bail(control_request(CtlRequest::RetryPendingOp { id })?)?,
|
SyncCmd::Retry { id } => ok_or_bail(control_request(CtlRequest::RetryPendingOp { id })?)?,
|
||||||
SyncCmd::Resume { folder: None } => {
|
SyncCmd::Resume { folder: None } => {
|
||||||
ok_or_bail(control_request(CtlRequest::SetSyncPaused {
|
ok_or_bail(control_request(CtlRequest::SetSyncPaused {
|
||||||
@@ -1106,6 +1182,9 @@ fn cmd_sync_queue() -> Result<()> {
|
|||||||
(false, _) => "due now".to_string(),
|
(false, _) => "due now".to_string(),
|
||||||
};
|
};
|
||||||
println!("[{}] {} {} ({state})", op.id, op.kind, op.path);
|
println!("[{}] {} {} ({state})", op.id, op.kind, op.path);
|
||||||
|
if let Some(issue) = op.issue {
|
||||||
|
println!(" needs attention: {}", issue_text(issue));
|
||||||
|
}
|
||||||
if let Some(error) = &op.last_error {
|
if let Some(error) = &op.last_error {
|
||||||
println!(" failed {} time(s): {error}", op.attempts);
|
println!(" failed {} time(s): {error}", op.attempts);
|
||||||
}
|
}
|
||||||
@@ -1117,6 +1196,91 @@ fn cmd_sync_queue() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `pdfs sync issues`: the queued changes that need the user, and what to do.
|
||||||
|
fn cmd_sync_issues() -> Result<()> {
|
||||||
|
let response = control_request(CtlRequest::ListPendingOps)?;
|
||||||
|
let items = match response {
|
||||||
|
CtlResponse::PendingOps { items } => items,
|
||||||
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||||
|
other => bail!("unexpected response: {other:?}"),
|
||||||
|
};
|
||||||
|
let issues: Vec<_> = items.into_iter().filter(|op| op.failing).collect();
|
||||||
|
if json_enabled() {
|
||||||
|
println!("{}", serde_json::to_string_pretty(&issues)?);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if issues.is_empty() {
|
||||||
|
println!("No sync issues.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
for op in issues {
|
||||||
|
println!("[{}] {} {}", op.id, op.kind, op.path);
|
||||||
|
match op.issue {
|
||||||
|
Some(issue) => println!(" {}", issue_text(issue)),
|
||||||
|
None => println!(" keeps failing; it is retried."),
|
||||||
|
}
|
||||||
|
if let Some(error) = &op.last_error {
|
||||||
|
println!(" last error: {error}");
|
||||||
|
}
|
||||||
|
if op.exportable {
|
||||||
|
println!(
|
||||||
|
" save a copy: pdfs sync export {} <file or folder>",
|
||||||
|
op.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a sync issue means, and what the user can do about it.
|
||||||
|
fn issue_text(issue: SyncIssue) -> &'static str {
|
||||||
|
match issue {
|
||||||
|
SyncIssue::Quota => {
|
||||||
|
"Proton Drive storage is full. Free up space or upgrade; the change is retried."
|
||||||
|
}
|
||||||
|
SyncIssue::Access => {
|
||||||
|
"you can no longer change this on Proton Drive, for example because a share was \
|
||||||
|
revoked. Save a copy to keep the content."
|
||||||
|
}
|
||||||
|
SyncIssue::Missing => {
|
||||||
|
"the file or its folder is gone from Proton Drive. Save a copy to keep the content."
|
||||||
|
}
|
||||||
|
SyncIssue::Limit => {
|
||||||
|
"the folder holds too many items or is nested too deep. Move the file to another \
|
||||||
|
folder."
|
||||||
|
}
|
||||||
|
SyncIssue::Rejected => {
|
||||||
|
"Proton Drive rejected the change, for example the name. Rename the file."
|
||||||
|
}
|
||||||
|
SyncIssue::Unknown => "Proton Drive refused the change.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `pdfs sync export`: save a queued upload's content. A folder as `dest`
|
||||||
|
/// gets the file under its own name.
|
||||||
|
fn cmd_sync_export(id: i64, dest: &Path) -> Result<()> {
|
||||||
|
let mut dest =
|
||||||
|
std::path::absolute(dest).with_context(|| format!("resolving {}", dest.display()))?;
|
||||||
|
if dest.is_dir() {
|
||||||
|
let items = match control_request(CtlRequest::ListPendingOps)? {
|
||||||
|
CtlResponse::PendingOps { items } => items,
|
||||||
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||||
|
other => bail!("unexpected response: {other:?}"),
|
||||||
|
};
|
||||||
|
let Some(op) = items.iter().find(|op| op.id == id) else {
|
||||||
|
bail!("no queued change {id}; see `pdfs sync queue`");
|
||||||
|
};
|
||||||
|
let name = Path::new(&op.path)
|
||||||
|
.file_name()
|
||||||
|
.with_context(|| format!("{} has no file name", op.path))?;
|
||||||
|
dest.push(name);
|
||||||
|
}
|
||||||
|
ok_or_bail(control_request(CtlRequest::ExportPendingOp {
|
||||||
|
id,
|
||||||
|
dest: dest.display().to_string(),
|
||||||
|
})?)
|
||||||
|
}
|
||||||
|
|
||||||
/// `pdfs sync restore`: list what this machine's device holds, confirm where
|
/// `pdfs sync restore`: list what this machine's device holds, confirm where
|
||||||
/// each folder should land, then hand the mapping to the daemon.
|
/// each folder should land, then hand the mapping to the daemon.
|
||||||
///
|
///
|
||||||
@@ -1664,7 +1828,7 @@ fn prompt_line(label: &str) -> Result<String> {
|
|||||||
Ok(line.trim().to_owned())
|
Ok(line.trim().to_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cmd_login(username: Option<String>) -> Result<()> {
|
fn cmd_login(username: Option<String>, remember: bool) -> Result<()> {
|
||||||
let username = match username {
|
let username = match username {
|
||||||
Some(u) => u,
|
Some(u) => u,
|
||||||
None => prompt_line("Proton email")?,
|
None => prompt_line("Proton email")?,
|
||||||
@@ -1676,11 +1840,14 @@ fn cmd_login(username: Option<String>) -> Result<()> {
|
|||||||
prompt_line("2FA code").map_err(|e| pdfs_core::Error::Other(format!("read 2FA code: {e}")))
|
prompt_line("2FA code").map_err(|e| pdfs_core::Error::Other(format!("read 2FA code: {e}")))
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Only a two-password account is ever asked for this.
|
||||||
|
let get_mailbox = |attempt: u32| prompt_mailbox(attempt, remember);
|
||||||
|
|
||||||
let rt = tokio::runtime::Runtime::new()?;
|
let rt = tokio::runtime::Runtime::new()?;
|
||||||
// Solving a CAPTCHA needs a browser engine, which the CLI has no business
|
// Solving a CAPTCHA needs a browser engine, which the CLI has no business
|
||||||
// carrying. Point at the app that does rather than failing with the raw API
|
// carrying. Point at the app that does rather than failing with the raw API
|
||||||
// message, which reads as "your login is broken".
|
// message, which reads as "your login is broken".
|
||||||
rt.block_on(auth::login(&username, &password, get_totp))
|
rt.block_on(auth::login(&username, &password, get_totp, get_mailbox))
|
||||||
.map_err(|e| match e {
|
.map_err(|e| match e {
|
||||||
pdfs_core::Error::HumanVerificationRequired(_) => anyhow::anyhow!(
|
pdfs_core::Error::HumanVerificationRequired(_) => anyhow::anyhow!(
|
||||||
"Proton is asking for a CAPTCHA before it will accept this sign-in.\n\
|
"Proton is asking for a CAPTCHA before it will accept this sign-in.\n\
|
||||||
@@ -1701,9 +1868,88 @@ fn cmd_login(username: Option<String>) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
println!("Logged in as {username}. Session stored in the system keyring.");
|
println!("Logged in as {username}. Session stored in the system keyring.");
|
||||||
|
report_key_state();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ask for the mailbox password of a two-password account, without echo.
|
||||||
|
fn prompt_mailbox(attempt: u32, remember: bool) -> pdfs_core::Result<auth::Mailbox> {
|
||||||
|
if attempt == 0 {
|
||||||
|
eprintln!("This account has a separate mailbox password.");
|
||||||
|
} else {
|
||||||
|
eprintln!("That mailbox password did not unlock the account. Try again.");
|
||||||
|
}
|
||||||
|
let password = rpassword::prompt_password("Mailbox password: ")
|
||||||
|
.map_err(|e| pdfs_core::Error::Other(format!("read mailbox password: {e}")))?;
|
||||||
|
Ok(auth::Mailbox {
|
||||||
|
password: zeroize::Zeroizing::new(password),
|
||||||
|
remember,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Say where the account's keys are held, and what that means for the next login.
|
||||||
|
fn report_key_state() {
|
||||||
|
match auth::key_state() {
|
||||||
|
Ok(auth::KeyState::Session) => println!(
|
||||||
|
"Unlocked for this login session only. After you log out or reboot, run `pdfs unlock`."
|
||||||
|
),
|
||||||
|
Ok(auth::KeyState::Locked) => println!("The account is locked. Run `pdfs unlock`."),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_unlock(remember: bool) -> Result<()> {
|
||||||
|
auth::load().context("not logged in (run `pdfs login` first)")?;
|
||||||
|
let rt = tokio::runtime::Runtime::new()?;
|
||||||
|
// Bounded like the login prompt: three wrong answers end the command.
|
||||||
|
let mut attempt = 0;
|
||||||
|
loop {
|
||||||
|
let mailbox = prompt_mailbox(attempt, remember)?;
|
||||||
|
match rt.block_on(auth::unlock(&mailbox.password, remember)) {
|
||||||
|
Ok(()) => break,
|
||||||
|
Err(pdfs_core::Error::WrongMailboxPassword) if attempt < 2 => attempt += 1,
|
||||||
|
Err(e) => return Err(anyhow::Error::new(e).context("unlock failed")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A daemon parked on the locked account would otherwise notice on its next
|
||||||
|
// poll; restarting it makes the mount appear now, as after `pdfs login`.
|
||||||
|
if service::is_active() {
|
||||||
|
service::restart();
|
||||||
|
}
|
||||||
|
println!("Unlocked.");
|
||||||
|
report_key_state();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_lock(forget: bool) -> Result<()> {
|
||||||
|
auth::lock(forget)?;
|
||||||
|
println!(
|
||||||
|
"Locked. {}",
|
||||||
|
if forget {
|
||||||
|
"Unlock removed from memory and keyring."
|
||||||
|
} else {
|
||||||
|
"Run `pdfs unlock` to unlock again."
|
||||||
|
}
|
||||||
|
);
|
||||||
|
// The running daemon keeps its keys until it restarts; say so, since
|
||||||
|
// "locked" would otherwise suggest the mount stopped.
|
||||||
|
if service::is_active() {
|
||||||
|
println!(
|
||||||
|
"The running daemon keeps its keys until it restarts: `systemctl --user restart proton-drive`."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `stored`, `session` or `locked`: where the account's keys are held.
|
||||||
|
fn key_state_name() -> &'static str {
|
||||||
|
match auth::key_state() {
|
||||||
|
Ok(auth::KeyState::Stored) => "stored",
|
||||||
|
Ok(auth::KeyState::Session) => "session",
|
||||||
|
_ => "locked",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn cmd_logout() -> Result<()> {
|
fn cmd_logout() -> Result<()> {
|
||||||
auth::logout()?;
|
auth::logout()?;
|
||||||
println!("Stored session cleared.");
|
println!("Stored session cleared.");
|
||||||
@@ -1731,6 +1977,7 @@ fn cmd_status() -> Result<()> {
|
|||||||
"username": session.as_ref().map(|s| s.username.clone()),
|
"username": session.as_ref().map(|s| s.username.clone()),
|
||||||
"user_id": session.as_ref().map(|s| s.user_id.clone()),
|
"user_id": session.as_ref().map(|s| s.user_id.clone()),
|
||||||
"scopes": session.as_ref().map(|s| s.scopes.clone()),
|
"scopes": session.as_ref().map(|s| s.scopes.clone()),
|
||||||
|
"keys": session.as_ref().map(|_| key_state_name()),
|
||||||
"mount": mount,
|
"mount": mount,
|
||||||
});
|
});
|
||||||
println!("{}", serde_json::to_string_pretty(&out)?);
|
println!("{}", serde_json::to_string_pretty(&out)?);
|
||||||
@@ -1741,6 +1988,11 @@ fn cmd_status() -> Result<()> {
|
|||||||
Ok(s) => {
|
Ok(s) => {
|
||||||
println!("Logged in as {} (user {})", s.username, s.user_id);
|
println!("Logged in as {} (user {})", s.username, s.user_id);
|
||||||
println!("Scopes: {}", s.scopes.join(", "));
|
println!("Scopes: {}", s.scopes.join(", "));
|
||||||
|
match key_state_name() {
|
||||||
|
"locked" => println!("Keys: locked — run `pdfs unlock`"),
|
||||||
|
"session" => println!("Keys: unlocked for this login session"),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(pdfs_core::Error::NotLoggedIn) => {
|
Err(pdfs_core::Error::NotLoggedIn) => {
|
||||||
println!("Not logged in. Run `pdfs login`.");
|
println!("Not logged in. Run `pdfs login`.");
|
||||||
@@ -1872,7 +2124,7 @@ fn login_poll_delay(attempts: u32) -> std::time::Duration {
|
|||||||
std::time::Duration::from_secs(BASE_SECS.saturating_mul(factor).min(MAX_SECS))
|
std::time::Duration::from_secs(BASE_SECS.saturating_mul(factor).min(MAX_SECS))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Block until a session is stored, backing off between checks.
|
/// Block until a session is stored and unlocked, backing off between checks.
|
||||||
///
|
///
|
||||||
/// `load` and `sleep` are parameters rather than direct calls so the backoff
|
/// `load` and `sleep` are parameters rather than direct calls so the backoff
|
||||||
/// schedule can be tested without a keyring and without real time passing.
|
/// schedule can be tested without a keyring and without real time passing.
|
||||||
@@ -1897,6 +2149,16 @@ where
|
|||||||
sleep(delay);
|
sleep(delay);
|
||||||
attempts = attempts.saturating_add(1);
|
attempts = attempts.saturating_add(1);
|
||||||
}
|
}
|
||||||
|
Err(pdfs_core::Error::Locked) => {
|
||||||
|
let delay = login_poll_delay(attempts);
|
||||||
|
if attempts == 0 {
|
||||||
|
tracing::info!("account is locked; waiting for `pdfs unlock`…");
|
||||||
|
} else {
|
||||||
|
tracing::debug!(delay_secs = delay.as_secs(), "account still locked");
|
||||||
|
}
|
||||||
|
sleep(delay);
|
||||||
|
attempts = attempts.saturating_add(1);
|
||||||
|
}
|
||||||
Err(e) => return Err(e.into()),
|
Err(e) => return Err(e.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1907,10 +2169,13 @@ where
|
|||||||
/// an external unmount triggers a remount; errors back off and retry. This is
|
/// an external unmount triggers a remount; errors back off and retry. This is
|
||||||
/// the entry point for the systemd user service.
|
/// the entry point for the systemd user service.
|
||||||
fn cmd_daemon(mountpoint: Option<PathBuf>) -> Result<()> {
|
fn cmd_daemon(mountpoint: Option<PathBuf>) -> Result<()> {
|
||||||
|
// The daemon holds the account's key passphrases in memory; keep them out
|
||||||
|
// of core dumps and away from same-user ptrace.
|
||||||
|
pdfs_core::kernelkey::forbid_dumps();
|
||||||
loop {
|
loop {
|
||||||
// Wait until a session is stored. The GUI enables this service on login,
|
// Wait until a session is stored. The GUI enables this service on login,
|
||||||
// but the service may also start at boot before the user has logged in.
|
// but the service may also start at boot before the user has logged in.
|
||||||
wait_for_session(|| auth::load().map(|_| ()), std::thread::sleep)?;
|
wait_for_session(auth::ready, pdfs_fuse::idle_sleep)?;
|
||||||
|
|
||||||
match mount_once(mountpoint.clone()) {
|
match mount_once(mountpoint.clone()) {
|
||||||
Ok(pdfs_fuse::MountOutcome::Shutdown) => {
|
Ok(pdfs_fuse::MountOutcome::Shutdown) => {
|
||||||
@@ -1919,13 +2184,13 @@ fn cmd_daemon(mountpoint: Option<PathBuf>) -> Result<()> {
|
|||||||
}
|
}
|
||||||
Ok(pdfs_fuse::MountOutcome::Unmounted) => {
|
Ok(pdfs_fuse::MountOutcome::Unmounted) => {
|
||||||
tracing::warn!("mount ended externally; remounting in 2s");
|
tracing::warn!("mount ended externally; remounting in 2s");
|
||||||
std::thread::sleep(std::time::Duration::from_secs(2));
|
pdfs_fuse::idle_sleep(std::time::Duration::from_secs(2));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// `{:#}` so the whole anyhow chain lands in the log — the top
|
// `{:#}` so the whole anyhow chain lands in the log — the top
|
||||||
// context alone ("mount failed") never says *why*.
|
// context alone ("mount failed") never says *why*.
|
||||||
tracing::error!(error = format!("{e:#}"), "mount failed; retrying in 5s");
|
tracing::error!(error = format!("{e:#}"), "mount failed; retrying in 5s");
|
||||||
std::thread::sleep(std::time::Duration::from_secs(5));
|
pdfs_fuse::idle_sleep(std::time::Duration::from_secs(5));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1983,6 +2248,7 @@ fn mount_once(mountpoint: Option<PathBuf>) -> Result<pdfs_fuse::MountOutcome> {
|
|||||||
sweep_mode: config.resolved_conflict_sweep(),
|
sweep_mode: config.resolved_conflict_sweep(),
|
||||||
upload_limit: config.upload_limit.unwrap_or(0),
|
upload_limit: config.upload_limit.unwrap_or(0),
|
||||||
download_limit: config.download_limit.unwrap_or(0),
|
download_limit: config.download_limit.unwrap_or(0),
|
||||||
|
local_first: config.local_first.unwrap_or(true),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1990,6 +2256,9 @@ fn mount_once(mountpoint: Option<PathBuf>) -> Result<pdfs_fuse::MountOutcome> {
|
|||||||
if let Err(e) = rt.block_on(auth::persist(&session)) {
|
if let Err(e) = rt.block_on(auth::persist(&session)) {
|
||||||
tracing::debug!(error = %e, "no new tokens to persist on shutdown");
|
tracing::debug!(error = %e, "no new tokens to persist on shutdown");
|
||||||
}
|
}
|
||||||
|
// Dropping the runtime waits for every blocking task, however long; a call
|
||||||
|
// still stuck on Drive would hold the stop until systemd kills it (B165).
|
||||||
|
rt.shutdown_timeout(std::time::Duration::from_secs(2));
|
||||||
|
|
||||||
let outcome = result.context("mount failed")?;
|
let outcome = result.context("mount failed")?;
|
||||||
Ok(outcome)
|
Ok(outcome)
|
||||||
@@ -3103,6 +3372,19 @@ fn cmd_diagnose() -> Result<()> {
|
|||||||
match pdfs_core::auth::load() {
|
match pdfs_core::auth::load() {
|
||||||
Ok(session) => {
|
Ok(session) => {
|
||||||
report.finding(DiagnoseLevel::Ok, " keyring session", session.username);
|
report.finding(DiagnoseLevel::Ok, " keyring session", session.username);
|
||||||
|
match key_state_name() {
|
||||||
|
"locked" => report.finding(
|
||||||
|
DiagnoseLevel::Fail,
|
||||||
|
" account keys",
|
||||||
|
"locked — run `pdfs unlock` (the daemon waits for it)",
|
||||||
|
),
|
||||||
|
"session" => report.finding(
|
||||||
|
DiagnoseLevel::Ok,
|
||||||
|
" account keys",
|
||||||
|
"unlocked for this login session",
|
||||||
|
),
|
||||||
|
_ => report.finding(DiagnoseLevel::Ok, " account keys", "stored in the keyring"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// A locked keyring and an absent login look the same from here, so
|
// A locked keyring and an absent login look the same from here, so
|
||||||
@@ -3401,6 +3683,21 @@ mod login_wait_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_locked_account_is_waited_on_until_it_is_unlocked() {
|
||||||
|
let mut checks = vec![
|
||||||
|
Ok(()),
|
||||||
|
Err(pdfs_core::Error::Locked),
|
||||||
|
Err(pdfs_core::Error::NotLoggedIn),
|
||||||
|
Err(pdfs_core::Error::Locked),
|
||||||
|
];
|
||||||
|
let mut slept = 0;
|
||||||
|
|
||||||
|
wait_for_session(|| checks.pop().unwrap(), |_| slept += 1).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(slept, 3, "locked and signed-out are both waited out");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_stored_session_is_not_waited_for_at_all() {
|
fn a_stored_session_is_not_waited_for_at_all() {
|
||||||
let mut slept = 0;
|
let mut slept = 0;
|
||||||
@@ -3433,6 +3730,7 @@ mod login_wait_tests {
|
|||||||
shared_by_unverified: false,
|
shared_by_unverified: false,
|
||||||
trashed_at: 0,
|
trashed_at: 0,
|
||||||
trashed_from: None,
|
trashed_from: None,
|
||||||
|
issue: None,
|
||||||
};
|
};
|
||||||
assert_eq!(shared_by_line(&entry), None, "no invitation, no line");
|
assert_eq!(shared_by_line(&entry), None, "no invitation, no line");
|
||||||
entry.shared_by = "alice@proton.me".into();
|
entry.shared_by = "alice@proton.me".into();
|
||||||
|
|||||||
@@ -28,3 +28,5 @@ dbus-secret-service-keyring-store.workspace = true
|
|||||||
parking_lot.workspace = true
|
parking_lot.workspace = true
|
||||||
async-trait.workspace = true
|
async-trait.workspace = true
|
||||||
libc.workspace = true
|
libc.workspace = true
|
||||||
|
zeroize.workspace = true
|
||||||
|
base64.workspace = true
|
||||||
+471
-119
@@ -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 std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use base64::Engine as _;
|
||||||
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||||
use keyring_core::Entry;
|
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()),
|
||||||
};
|
};
|
||||||
@@ -89,6 +128,26 @@ fn keyring_entry() -> Result<Entry> {
|
|||||||
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
|
||||||
@@ -96,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.
|
||||||
@@ -123,12 +187,20 @@ 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,
|
||||||
}
|
}
|
||||||
@@ -145,15 +217,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()?;
|
||||||
@@ -163,38 +238,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(),
|
||||||
@@ -207,10 +282,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(())
|
||||||
}
|
}
|
||||||
@@ -225,12 +393,93 @@ pub fn load() -> Result<StoredSession> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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()
|
||||||
@@ -242,6 +491,9 @@ 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_core::Error::NoEntry) => Ok(()),
|
Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(()),
|
||||||
Err(e) => Err(e.into()),
|
Err(e) => Err(e.into()),
|
||||||
@@ -250,44 +502,87 @@ pub fn logout() -> Result<()> {
|
|||||||
|
|
||||||
/// 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.
|
||||||
///
|
///
|
||||||
@@ -301,23 +596,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
|
||||||
@@ -327,49 +623,105 @@ 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}}}"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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)
|
||||||
@@ -311,8 +311,7 @@ struct PinFile {
|
|||||||
pins: BTreeMap<String, Pin>,
|
pins: BTreeMap<String, Pin>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What a staged file actually contains, written beside it as `<name>.json`
|
/// What a staged file actually contains, written beside it as `<name>.json`.
|
||||||
/// (offline.md Phase 2).
|
|
||||||
///
|
///
|
||||||
/// A staged file is **not** necessarily valid whole-file content, and that is
|
/// A staged file is **not** necessarily valid whole-file content, and that is
|
||||||
/// the whole reason this exists. A partial overwrite commits by filling the
|
/// the whole reason this exists. A partial overwrite commits by filling the
|
||||||
@@ -439,9 +438,9 @@ pub struct ContentCache {
|
|||||||
/// Subdirectory for write-handle scratch files (disk-backed write buffers).
|
/// Subdirectory for write-handle scratch files (disk-backed write buffers).
|
||||||
/// Emptied on open so a crashed run leaves no orphans.
|
/// Emptied on open so a crashed run leaves no orphans.
|
||||||
scratch_dir: PathBuf,
|
scratch_dir: PathBuf,
|
||||||
/// Subdirectory holding the bytes of writes that have not been uploaded yet
|
/// Subdirectory holding the bytes of writes that have not been uploaded yet.
|
||||||
/// (offline.md Phase 2/3) — every released write passes through here, since
|
/// Every released write passes through here, since the upload is a queued
|
||||||
/// the upload is a queued op performed later. Unlike `scratch_dir` this is
|
/// op performed later. Unlike `scratch_dir` this is
|
||||||
/// **never** emptied on open: these are the only copy of content the user
|
/// **never** emptied on open: these are the only copy of content the user
|
||||||
/// authored, and the whole point is that they outlive the daemon.
|
/// authored, and the whole point is that they outlive the daemon.
|
||||||
staging_dir: PathBuf,
|
staging_dir: PathBuf,
|
||||||
@@ -476,7 +475,7 @@ pub struct ContentCache {
|
|||||||
thumb_bytes: AtomicU64,
|
thumb_bytes: AtomicU64,
|
||||||
/// Unified metadata DB. Its `cache_entries` table is the LRU index: every
|
/// Unified metadata DB. Its `cache_entries` table is the LRU index: every
|
||||||
/// store/read/evict updates it, and the budget enforcers query it instead of
|
/// store/read/evict updates it, and the budget enforcers query it instead of
|
||||||
/// scanning the cache directories (plan.md P4).
|
/// scanning the cache directories.
|
||||||
db: Arc<Db>,
|
db: Arc<Db>,
|
||||||
/// LRU touches not yet written to `cache_entries`, keyed by cache key.
|
/// LRU touches not yet written to `cache_entries`, keyed by cache key.
|
||||||
///
|
///
|
||||||
@@ -845,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
|
||||||
@@ -986,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,
|
||||||
@@ -1488,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
|
||||||
@@ -2197,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();
|
||||||
@@ -2686,7 +2705,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;
|
||||||
@@ -2766,7 +2785,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();
|
||||||
@@ -2833,7 +2852,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();
|
||||||
@@ -2898,7 +2917,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]
|
||||||
|
|||||||
+117
-36
@@ -3,7 +3,7 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use directories::ProjectDirs;
|
use directories::ProjectDirs;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Deserializer, Serialize};
|
||||||
|
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
use crate::menu::PromptConfig;
|
use crate::menu::PromptConfig;
|
||||||
@@ -12,8 +12,19 @@ use crate::syncignore::DEFAULT_IGNORE_PATTERNS;
|
|||||||
|
|
||||||
/// Proton requires `external-drive-{name}@{semver}-{channel}` (channel ∈
|
/// Proton requires `external-drive-{name}@{semver}-{channel}` (channel ∈
|
||||||
/// stable/beta/alpha); a malformed value trips the 422 anti-abuse path.
|
/// stable/beta/alpha); a malformed value trips the 422 anti-abuse path.
|
||||||
pub const APP_VERSION: &str = "external-drive-linux@0.1.0-alpha";
|
/// Follows the release so Proton can tell releases apart and retire old ones
|
||||||
pub const USER_AGENT: &str = "proton-drive-linux/0.1.0";
|
/// without retiring every one.
|
||||||
|
pub const APP_VERSION: &str = concat!(
|
||||||
|
"external-drive-linux@",
|
||||||
|
env!("CARGO_PKG_VERSION"),
|
||||||
|
"-stable"
|
||||||
|
);
|
||||||
|
pub const USER_AGENT: &str = concat!("proton-drive-linux/", env!("CARGO_PKG_VERSION"));
|
||||||
|
|
||||||
|
/// The fixed identity every release up to 3.1.0 wrote into `config.json`. Read
|
||||||
|
/// back as "unset" so those configs follow the release instead of pinning it.
|
||||||
|
const LEGACY_APP_VERSION: &str = "external-drive-linux@0.1.0-alpha";
|
||||||
|
const LEGACY_USER_AGENT: &str = "proton-drive-linux/0.1.0";
|
||||||
|
|
||||||
/// Keyring service name; one entry per credential kind keyed by username.
|
/// Keyring service name; one entry per credential kind keyed by username.
|
||||||
pub const KEYRING_SERVICE: &str = "proton-drive-linux";
|
pub const KEYRING_SERVICE: &str = "proton-drive-linux";
|
||||||
@@ -76,10 +87,24 @@ fn resolve_sweep_mode(env: Option<&str>, stored: Option<SweepMode>) -> SweepMode
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Configuration structure allowing the user to customize client identification headers.
|
/// Configuration structure allowing the user to customize client identification headers.
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
|
||||||
pub struct AppConfig {
|
pub struct AppConfig {
|
||||||
pub app_version: String,
|
/// Override for [`APP_VERSION`]. `None` means "use the release's own";
|
||||||
pub user_agent: String,
|
/// the old fixed default reads as `None`, so it leaves the file on the
|
||||||
|
/// next save.
|
||||||
|
#[serde(
|
||||||
|
default,
|
||||||
|
deserialize_with = "identity_override",
|
||||||
|
skip_serializing_if = "Option::is_none"
|
||||||
|
)]
|
||||||
|
pub app_version: Option<String>,
|
||||||
|
/// Override for [`USER_AGENT`], handled like `app_version`.
|
||||||
|
#[serde(
|
||||||
|
default,
|
||||||
|
deserialize_with = "identity_override",
|
||||||
|
skip_serializing_if = "Option::is_none"
|
||||||
|
)]
|
||||||
|
pub user_agent: Option<String>,
|
||||||
/// Soft cap on the on-disk content cache, in bytes (`0` = unlimited).
|
/// Soft cap on the on-disk content cache, in bytes (`0` = unlimited).
|
||||||
/// `None` means "use [`DEFAULT_CACHE_BUDGET_BYTES`]"; the Settings page
|
/// `None` means "use [`DEFAULT_CACHE_BUDGET_BYTES`]"; the Settings page
|
||||||
/// writes an explicit value here. Defaulted for configs predating the field.
|
/// writes an explicit value here. Defaulted for configs predating the field.
|
||||||
@@ -154,6 +179,13 @@ pub struct AppConfig {
|
|||||||
/// configs predating the field.
|
/// configs predating the field.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub online_map: bool,
|
pub online_map: bool,
|
||||||
|
/// Record a new folder, a new file, a rename or a delete through the mount
|
||||||
|
/// locally and send it from the queue, instead of waiting for Drive in the
|
||||||
|
/// syscall. `None` means on. `false` brings back the old path for one
|
||||||
|
/// release (docs/ROADMAP.md). Defaulted for configs predating
|
||||||
|
/// the field.
|
||||||
|
#[serde(default)]
|
||||||
|
pub local_first: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The My Files page's view choices, remembered between runs.
|
/// The My Files page's view choices, remembered between runs.
|
||||||
@@ -209,30 +241,28 @@ impl FileSort {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppConfig {
|
/// Reads an `app_version` or `user_agent` override, dropping the fixed
|
||||||
fn default() -> Self {
|
/// identity older releases stored as the default.
|
||||||
Self {
|
fn identity_override<'de, D: Deserializer<'de>>(
|
||||||
app_version: APP_VERSION.to_string(),
|
deserializer: D,
|
||||||
user_agent: USER_AGENT.to_string(),
|
) -> std::result::Result<Option<String>, D::Error> {
|
||||||
cache_budget: None,
|
let value = Option::<String>::deserialize(deserializer)?;
|
||||||
upload_limit: None,
|
Ok(value.filter(|v| v != LEGACY_APP_VERSION && v != LEGACY_USER_AGENT))
|
||||||
download_limit: None,
|
|
||||||
mountpoint: None,
|
|
||||||
ignore_patterns: None,
|
|
||||||
device_uid: None,
|
|
||||||
conflict_sweep: None,
|
|
||||||
open_with: None,
|
|
||||||
prompt: None,
|
|
||||||
proton_theme: None,
|
|
||||||
files_view: FilesView::default(),
|
|
||||||
tray_hidden: false,
|
|
||||||
language: None,
|
|
||||||
online_map: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
@@ -424,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
|
||||||
@@ -475,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
|
||||||
@@ -496,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]
|
||||||
@@ -563,8 +593,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_app_config_serialization() {
|
fn test_app_config_serialization() {
|
||||||
let config = AppConfig {
|
let config = AppConfig {
|
||||||
app_version: "external-drive-test-client@1.0.0".to_string(),
|
app_version: Some("external-drive-test-client@1.0.0".to_string()),
|
||||||
user_agent: "test-agent/1.0".to_string(),
|
user_agent: Some("test-agent/1.0".to_string()),
|
||||||
cache_budget: Some(1234),
|
cache_budget: Some(1234),
|
||||||
upload_limit: Some(500_000),
|
upload_limit: Some(500_000),
|
||||||
download_limit: None,
|
download_limit: None,
|
||||||
@@ -584,11 +614,15 @@ mod tests {
|
|||||||
tray_hidden: true,
|
tray_hidden: true,
|
||||||
language: Some("de".to_string()),
|
language: Some("de".to_string()),
|
||||||
online_map: true,
|
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_eq!(decoded.proton_theme, Some(true));
|
||||||
assert!(decoded.tray_hidden);
|
assert!(decoded.tray_hidden);
|
||||||
assert!(decoded.online_map);
|
assert!(decoded.online_map);
|
||||||
@@ -619,8 +653,55 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_default_app_config() {
|
fn test_default_app_config() {
|
||||||
let default_config = AppConfig::default();
|
let default_config = AppConfig::default();
|
||||||
assert_eq!(default_config.app_version, APP_VERSION);
|
assert_eq!(default_config.resolved_app_version(), APP_VERSION);
|
||||||
assert_eq!(default_config.user_agent, USER_AGENT);
|
assert_eq!(default_config.resolved_user_agent(), USER_AGENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_client_identity_follows_the_release() {
|
||||||
|
let version = env!("CARGO_PKG_VERSION");
|
||||||
|
assert_eq!(
|
||||||
|
APP_VERSION,
|
||||||
|
format!("external-drive-linux@{version}-stable")
|
||||||
|
);
|
||||||
|
assert_eq!(USER_AGENT, format!("proton-drive-linux/{version}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_old_config_with_the_fixed_identity_follows_the_release() {
|
||||||
|
// Every config written up to 3.1.0 holds these two values, so they must
|
||||||
|
// not pin the identity once Proton retires 0.1.0.
|
||||||
|
let json = format!(
|
||||||
|
r#"{{"app_version":"{LEGACY_APP_VERSION}","user_agent":"{LEGACY_USER_AGENT}","tray_hidden":true}}"#
|
||||||
|
);
|
||||||
|
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(decoded.resolved_app_version(), APP_VERSION);
|
||||||
|
assert_eq!(decoded.resolved_user_agent(), USER_AGENT);
|
||||||
|
assert!(decoded.tray_hidden, "the rest of the config survives");
|
||||||
|
|
||||||
|
let saved = serde_json::to_string(&decoded).unwrap();
|
||||||
|
assert!(!saved.contains("app_version"), "{saved}");
|
||||||
|
assert!(!saved.contains("user_agent"), "{saved}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_hand_set_client_identity_is_kept() {
|
||||||
|
let json = r#"{"app_version":"external-drive-linux@9.9.9-beta","user_agent":"custom/1"}"#;
|
||||||
|
let decoded: AppConfig = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
decoded.resolved_app_version(),
|
||||||
|
"external-drive-linux@9.9.9-beta"
|
||||||
|
);
|
||||||
|
assert_eq!(decoded.resolved_user_agent(), "custom/1");
|
||||||
|
let saved = serde_json::to_string(&decoded).unwrap();
|
||||||
|
assert!(saved.contains("external-drive-linux@9.9.9-beta"), "{saved}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_config_without_a_client_identity_loads() {
|
||||||
|
let decoded: AppConfig = serde_json::from_str("{}").unwrap();
|
||||||
|
assert_eq!(decoded.resolved_app_version(), APP_VERSION);
|
||||||
|
assert_eq!(decoded.resolved_user_agent(), USER_AGENT);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -443,11 +443,18 @@ pub enum Request {
|
|||||||
DeleteDevice { uid: String },
|
DeleteDevice { uid: String },
|
||||||
/// Adopt an existing device as this machine's, pinning its uid in
|
/// Adopt an existing device as this machine's, pinning its uid in
|
||||||
/// `config.json` so a hostname change or reinstall re-attaches to it instead
|
/// `config.json` so a hostname change or reinstall re-attaches to it instead
|
||||||
/// of registering a duplicate (features.md 5.1). `uid: None` clears the pin.
|
/// of registering a duplicate. `uid: None` clears the pin.
|
||||||
/// Replies with [`Response::Ok`].
|
/// Replies with [`Response::Ok`].
|
||||||
AdoptDevice { uid: Option<String> },
|
AdoptDevice { uid: Option<String> },
|
||||||
|
/// Move every folder another device backs up into this machine's own
|
||||||
|
/// device, as server-side moves on the main volume. The other device stays
|
||||||
|
/// registered, without those folders. Sync folders here that already track
|
||||||
|
/// one of them follow it to its new share. Nothing is downloaded: the moved
|
||||||
|
/// folders are then offered by [`Request::ListRestorableFolders`].
|
||||||
|
/// Replies with [`Response::Ok`].
|
||||||
|
MigrateDevice { uid: String },
|
||||||
|
|
||||||
// ---- device folder sync (devices.md) ----------------------------------
|
// ---- device folder sync ----------------------------------
|
||||||
/// Add a local folder to this machine's device, uploading its tree and
|
/// Add a local folder to this machine's device, uploading its tree and
|
||||||
/// registering the device on first use. Replies with [`Response::Ok`].
|
/// registering the device on first use. Replies with [`Response::Ok`].
|
||||||
AddSyncFolder { local_path: String },
|
AddSyncFolder { local_path: String },
|
||||||
@@ -477,6 +484,15 @@ pub enum Request {
|
|||||||
/// Retry a backed-off queued op now instead of waiting out its backoff;
|
/// Retry a backed-off queued op now instead of waiting out its backoff;
|
||||||
/// every failed op when `id` is `None`. Replies with [`Response::Ok`].
|
/// every failed op when `id` is `None`. Replies with [`Response::Ok`].
|
||||||
RetryPendingOp { id: Option<i64> },
|
RetryPendingOp { id: Option<i64> },
|
||||||
|
/// Copy the content of a queued upload (`id` from
|
||||||
|
/// [`Request::ListPendingOps`]) to `dest`, an absolute path that must not
|
||||||
|
/// exist yet. The op stays queued. Replies with [`Response::Ok`].
|
||||||
|
ExportPendingOp { id: i64, dest: String },
|
||||||
|
/// Drop a queued change (`id` from [`Request::ListPendingOps`]) and undo
|
||||||
|
/// it locally: a node made here goes, a node Drive has goes back to what
|
||||||
|
/// Drive has, along with every other change queued on it. Its content is
|
||||||
|
/// lost unless it was exported first. Replies with [`Response::Ok`].
|
||||||
|
DiscardPendingOp { id: i64 },
|
||||||
/// List the `(sync-conflict …)` copies under My Files and in synced
|
/// List the `(sync-conflict …)` copies under My Files and in synced
|
||||||
/// folders. Replies with
|
/// folders. Replies with
|
||||||
/// [`Response::Conflicts`].
|
/// [`Response::Conflicts`].
|
||||||
@@ -487,7 +503,7 @@ pub enum Request {
|
|||||||
/// [`Response::Ok`].
|
/// [`Response::Ok`].
|
||||||
ResolveConflict { path: String, keep: ConflictKeep },
|
ResolveConflict { path: String, keep: ConflictKeep },
|
||||||
/// List the folders under this machine's device that can be synced here,
|
/// List the folders under this machine's device that can be synced here,
|
||||||
/// each with a proposed local path (features.md 5.2). Replies with
|
/// each with a proposed local path. Replies with
|
||||||
/// [`Response::RestorableFolders`].
|
/// [`Response::RestorableFolders`].
|
||||||
ListRestorableFolders,
|
ListRestorableFolders,
|
||||||
/// Attach the given remote device folders to local paths and sync them down.
|
/// Attach the given remote device folders to local paths and sync them down.
|
||||||
@@ -880,6 +896,59 @@ pub struct PendingOpInfo {
|
|||||||
pub parked: bool,
|
pub parked: bool,
|
||||||
/// Failed often enough to count as stuck.
|
/// Failed often enough to count as stuck.
|
||||||
pub failing: bool,
|
pub failing: bool,
|
||||||
|
/// Why Drive refused it, when it did; such an op needs attention at once.
|
||||||
|
#[serde(default)]
|
||||||
|
pub issue: Option<SyncIssue>,
|
||||||
|
/// Carries file content that [`Request::ExportPendingOp`] can save.
|
||||||
|
#[serde(default)]
|
||||||
|
pub exportable: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why Drive refused a queued change (in [`PendingOpInfo`]). The change stays
|
||||||
|
/// queued and keeps being retried; nothing is deleted.
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum SyncIssue {
|
||||||
|
/// The account's storage is full.
|
||||||
|
Quota,
|
||||||
|
/// The account may no longer change the file or its folder, for example
|
||||||
|
/// because a share was revoked.
|
||||||
|
Access,
|
||||||
|
/// The file or the folder it goes into is gone from Drive.
|
||||||
|
Missing,
|
||||||
|
/// The folder holds too many items, or is nested too deep.
|
||||||
|
Limit,
|
||||||
|
/// Drive rejected the change, for example a name it does not accept.
|
||||||
|
Rejected,
|
||||||
|
/// An issue from a newer daemon.
|
||||||
|
#[serde(other)]
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SyncIssue {
|
||||||
|
/// The name stored in the database and sent on the wire.
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
SyncIssue::Quota => "quota",
|
||||||
|
SyncIssue::Access => "access",
|
||||||
|
SyncIssue::Missing => "missing",
|
||||||
|
SyncIssue::Limit => "limit",
|
||||||
|
SyncIssue::Rejected => "rejected",
|
||||||
|
SyncIssue::Unknown => "unknown",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The issue stored as `name`.
|
||||||
|
pub fn parse(name: &str) -> Self {
|
||||||
|
match name {
|
||||||
|
"quota" => SyncIssue::Quota,
|
||||||
|
"access" => SyncIssue::Access,
|
||||||
|
"missing" => SyncIssue::Missing,
|
||||||
|
"limit" => SyncIssue::Limit,
|
||||||
|
"rejected" => SyncIssue::Rejected,
|
||||||
|
_ => SyncIssue::Unknown,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One synced local folder on this machine's device (in [`Response::SyncFolders`]).
|
/// One synced local folder on this machine's device (in [`Response::SyncFolders`]).
|
||||||
@@ -1234,6 +1303,10 @@ pub struct DirEntry {
|
|||||||
/// not known, and for anything not in the trash.
|
/// not known, and for anything not in the trash.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub trashed_from: Option<String>,
|
pub trashed_from: Option<String>,
|
||||||
|
/// Why Drive refused a change queued on the node, for a [`Request::ListDir`]
|
||||||
|
/// entry; `None` otherwise.
|
||||||
|
#[serde(default)]
|
||||||
|
pub issue: Option<SyncIssue>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One hit in a [`Request::Search`] result. Like [`DirEntry`] but carries the
|
/// One hit in a [`Request::Search`] result. Like [`DirEntry`] but carries the
|
||||||
@@ -1837,17 +1910,17 @@ pub enum Response {
|
|||||||
/// The pin registry.
|
/// The pin registry.
|
||||||
pins: Vec<Pin>,
|
pins: Vec<Pin>,
|
||||||
/// False when the daemon is serving the cached tree because the API is
|
/// False when the daemon is serving the cached tree because the API is
|
||||||
/// unreachable (offline.md Phase 1). Cached and pinned content still
|
/// unreachable. Cached and pinned content still
|
||||||
/// reads; anything else fails until the network is back.
|
/// reads; anything else fails until the network is back.
|
||||||
#[serde(default = "default_online")]
|
#[serde(default = "default_online")]
|
||||||
online: bool,
|
online: bool,
|
||||||
/// Writes accepted locally but not yet uploaded (offline.md Phase 3).
|
/// Writes accepted locally but not yet uploaded.
|
||||||
/// Non-zero means the mount is ahead of the remote — either a copy is
|
/// Non-zero means the mount is ahead of the remote — either a copy is
|
||||||
/// still draining, or it cannot drain because we are offline.
|
/// still draining, or it cannot drain because we are offline.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pending_uploads: u64,
|
pending_uploads: u64,
|
||||||
/// Queued mutations that carry no bytes: `mkdir`, `rename`, `trash`
|
/// Queued mutations that carry no bytes: `mkdir`, `rename`, `trash`.
|
||||||
/// (offline.md Phase 3b). Counted apart from `pending_uploads` because
|
/// Counted apart from `pending_uploads` because
|
||||||
/// calling a queued `mkdir` an upload is a lie.
|
/// calling a queued `mkdir` an upload is a lie.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pending_changes: u64,
|
pending_changes: u64,
|
||||||
@@ -3016,6 +3089,9 @@ mod tests {
|
|||||||
uid: Some("dev-1".into()),
|
uid: Some("dev-1".into()),
|
||||||
},
|
},
|
||||||
Request::AdoptDevice { uid: None },
|
Request::AdoptDevice { uid: None },
|
||||||
|
Request::MigrateDevice {
|
||||||
|
uid: "dev-2".into(),
|
||||||
|
},
|
||||||
Request::ListRestorableFolders,
|
Request::ListRestorableFolders,
|
||||||
Request::RestoreSyncFolders {
|
Request::RestoreSyncFolders {
|
||||||
items: vec![RestoreItem {
|
items: vec![RestoreItem {
|
||||||
@@ -3290,6 +3366,17 @@ mod tests {
|
|||||||
},
|
},
|
||||||
r#"{"SaveRevisionAsByUid":{"uid":"vol~link","revision_id":"rev-3","dest":"/tmp/out.bin"}}"#,
|
r#"{"SaveRevisionAsByUid":{"uid":"vol~link","revision_id":"rev-3","dest":"/tmp/out.bin"}}"#,
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
Request::ExportPendingOp {
|
||||||
|
id: 7,
|
||||||
|
dest: "/tmp/out.bin".into(),
|
||||||
|
},
|
||||||
|
r#"{"ExportPendingOp":{"id":7,"dest":"/tmp/out.bin"}}"#,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Request::DiscardPendingOp { id: 7 },
|
||||||
|
r#"{"DiscardPendingOp":{"id":7}}"#,
|
||||||
|
),
|
||||||
];
|
];
|
||||||
for (request, wire) in cases {
|
for (request, wire) in cases {
|
||||||
assert_eq!(serde_json::to_string(&request).unwrap(), wire);
|
assert_eq!(serde_json::to_string(&request).unwrap(), wire);
|
||||||
@@ -3298,6 +3385,25 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A daemon that predates sync issues sends neither field, and a newer one
|
||||||
|
/// may send an issue this build does not know.
|
||||||
|
#[test]
|
||||||
|
fn a_queued_op_from_another_daemon_version_still_decodes() {
|
||||||
|
let old = r#"{"id":1,"kind":"create","path":"a.txt","attempts":0,"last_error":null,
|
||||||
|
"queued_at":5,"next_attempt_at":5,"parked":false,"failing":false}"#;
|
||||||
|
let op: PendingOpInfo = serde_json::from_str(old).unwrap();
|
||||||
|
assert_eq!(op.issue, None);
|
||||||
|
assert!(!op.exportable);
|
||||||
|
|
||||||
|
let newer = old.replace(r#""failing":false"#, r#""failing":true,"issue":"embargo""#);
|
||||||
|
let op: PendingOpInfo = serde_json::from_str(&newer).unwrap();
|
||||||
|
assert_eq!(op.issue, Some(SyncIssue::Unknown));
|
||||||
|
assert_eq!(
|
||||||
|
SyncIssue::parse(SyncIssue::Quota.as_str()),
|
||||||
|
SyncIssue::Quota
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// A front-end that predates the favorites filter sends no `favorites`
|
/// A front-end that predates the favorites filter sends no `favorites`
|
||||||
/// field, and must keep getting the whole timeline rather than an empty page.
|
/// field, and must keep getting the whole timeline rather than an empty page.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -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 = 36;
|
pub(super) const SCHEMA_VERSION: i64 = 42;
|
||||||
|
|
||||||
impl Db {
|
impl Db {
|
||||||
pub(super) fn migrate(&self) -> Result<()> {
|
pub(super) fn migrate(&self) -> Result<()> {
|
||||||
@@ -348,6 +350,96 @@ impl Db {
|
|||||||
tx.execute_batch(MIGRATION_V36)?;
|
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",
|
||||||
@@ -485,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,
|
||||||
@@ -546,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
|
||||||
@@ -574,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
|
||||||
@@ -964,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
|
||||||
@@ -1076,8 +1168,8 @@ const MIGRATION_V35: &str = "
|
|||||||
ALTER TABLE trash ADD COLUMN trashed_at INTEGER;
|
ALTER TABLE trash ADD COLUMN trashed_at INTEGER;
|
||||||
";
|
";
|
||||||
|
|
||||||
/// Schema v36: every node row has a local id (`lid`), phase 2 of
|
/// Schema v36: every node row has a local id (`lid`); see
|
||||||
/// docs/MILESTONE-3.0.0.md.
|
/// docs/ARCHITECTURE.md §2.
|
||||||
///
|
///
|
||||||
/// The `uid` was the row's key, so a node had no identity until Drive minted
|
/// The `uid` was the row's key, so a node had no identity until Drive minted
|
||||||
/// one, and a create that landed moved its row to a new key. The `lid` is given
|
/// one, and a create that landed moved its row to a new key. The `lid` is given
|
||||||
@@ -1112,3 +1204,129 @@ CREATE INDEX idx_nodes_parent ON nodes(parent_uid);
|
|||||||
CREATE INDEX idx_nodes_name_nocase ON nodes(name COLLATE NOCASE);
|
CREATE INDEX idx_nodes_name_nocase ON nodes(name COLLATE NOCASE);
|
||||||
CREATE INDEX idx_nodes_parent_live ON nodes(parent_uid) WHERE trashed = 0;
|
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,9 +56,9 @@ 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, CreateLanding, FAILING_ATTEMPTS, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME,
|
AttachedBlob, CreateLanding, CreateRetired, FAILING_ATTEMPTS, LOCAL_VOLUME, OP_CREATE,
|
||||||
OP_REVISION, OP_TRASH, PARK_EXPIRY_MS, PARK_UNTIL, PendingCounts, PendingOp, RenameMeta,
|
OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_EXPIRY_MS, PARK_UNTIL, PendingCounts,
|
||||||
op_supersedes,
|
PendingOp, RenameMeta, local_lid, local_uid, op_supersedes,
|
||||||
};
|
};
|
||||||
pub use photos::{StoredPhoto, StoredPlace, 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;
|
||||||
|
|||||||
@@ -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};
|
||||||
@@ -64,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?)?);
|
||||||
@@ -266,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",
|
||||||
@@ -282,18 +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
|
&format!(
|
||||||
WHERE parent_uid = ?1 AND trashed = 0 AND node_json IS NOT NULL",
|
"SELECT COUNT(*) FROM nodes
|
||||||
params![uid_str],
|
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)
|
||||||
@@ -330,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",
|
||||||
@@ -383,12 +406,13 @@ 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
|
||||||
AND (?3 IS NULL OR path IS NULL OR path LIKE ?3 ESCAPE '\\')
|
WHERE n.name LIKE ?1 ESCAPE '\\' AND n.trashed = 0 AND n.node_json IS NOT NULL
|
||||||
ORDER BY name LIMIT ?2",
|
AND (?3 IS NULL OR n.path IS NULL OR n.path LIKE ?3 ESCAPE '\\')
|
||||||
)?;
|
ORDER BY n.name LIMIT ?2"
|
||||||
|
))?;
|
||||||
collect_hits(stmt.query_map(params![pat, limit as i64, scope_pat], hit_row)?)?
|
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
|
||||||
@@ -398,13 +422,14 @@ 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
|
||||||
AND (?3 IS NULL OR n.path IS NULL OR n.path LIKE ?3 ESCAPE '\\')
|
AND (?3 IS NULL OR n.path IS NULL OR n.path LIKE ?3 ESCAPE '\\')
|
||||||
ORDER BY f.rank LIMIT ?2",
|
ORDER BY f.rank LIMIT ?2"
|
||||||
)?;
|
))?;
|
||||||
collect_hits(stmt.query_map(params![phrase, limit as i64, scope_pat], hit_row)?)?
|
collect_hits(stmt.query_map(params![phrase, limit as i64, scope_pat], hit_row)?)?
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -441,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
|
||||||
@@ -464,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)),
|
||||||
@@ -505,18 +533,26 @@ 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, lid 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)?;
|
||||||
@@ -532,13 +568,15 @@ impl Db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Load one persisted node back by uid. Used to recover the My Files root
|
/// Load one persisted node back by uid. Used to recover the My Files root
|
||||||
/// when the API is unreachable, so the mount can still serve the cached tree
|
/// when the API is unreachable, so the mount can still serve the cached tree.
|
||||||
/// (offline.md Phase 1).
|
|
||||||
pub fn node_by_uid(&self, uid: &str) -> Result<Option<Node>> {
|
pub fn node_by_uid(&self, uid: &str) -> Result<Option<Node>> {
|
||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
let json: Option<String> = conn
|
let json: Option<String> = conn
|
||||||
.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),
|
||||||
)
|
)
|
||||||
@@ -549,16 +587,24 @@ impl Db {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The local id of the node stored under `uid`, or `None` when it has no
|
/// The uid of the node with local id `lid`: `None` when it has no row, or
|
||||||
/// row. It stays the same for as long as the row exists, including when a
|
/// is not on Drive yet.
|
||||||
/// create lands and the row takes the uid Drive gave it.
|
pub fn uid_of_lid(&self, lid: i64) -> Result<Option<String>> {
|
||||||
pub fn lid_of(&self, uid: &str) -> Result<Option<i64>> {
|
|
||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
Ok(conn
|
Ok(conn
|
||||||
.query_row("SELECT lid FROM nodes WHERE uid = ?1", params![uid], |r| {
|
.query_row("SELECT uid FROM nodes WHERE lid = ?1", params![lid], |r| {
|
||||||
r.get(0)
|
r.get(0)
|
||||||
})
|
})
|
||||||
.optional()?)
|
.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.
|
/// The local id of each node, in order, giving a row to each that has none.
|
||||||
@@ -574,23 +620,31 @@ impl Db {
|
|||||||
{
|
{
|
||||||
let mut known = tx.prepare_cached("SELECT lid FROM nodes WHERE uid = ?1")?;
|
let mut known = tx.prepare_cached("SELECT lid FROM nodes WHERE uid = ?1")?;
|
||||||
let mut stub = tx.prepare_cached(
|
let mut stub = tx.prepare_cached(
|
||||||
"INSERT INTO nodes (uid, parent_uid, name, is_dir, mtime, trashed)
|
"INSERT INTO nodes (uid, parent_uid, name, is_dir, mtime, trashed, parent_lid)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||||
)?;
|
)?;
|
||||||
for node in nodes {
|
for node in nodes {
|
||||||
let uid = node.uid.to_string();
|
let uid = node.uid.to_string();
|
||||||
let lid = match known.query_row(params![uid], |r| r.get(0)).optional()? {
|
let lid = match known.query_row(params![uid], |r| r.get(0)).optional()? {
|
||||||
Some(lid) => lid,
|
Some(lid) => lid,
|
||||||
None => {
|
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![
|
stub.execute(params![
|
||||||
uid,
|
uid,
|
||||||
node.parent_uid.as_ref().map(ToString::to_string),
|
parent,
|
||||||
node.name,
|
node.name,
|
||||||
node.is_folder() as i64,
|
node.is_folder() as i64,
|
||||||
node.modification_time,
|
node.modification_time,
|
||||||
node.trashed as i64,
|
node.trashed as i64,
|
||||||
|
parent_lid,
|
||||||
])?;
|
])?;
|
||||||
tx.last_insert_rowid()
|
let lid = tx.last_insert_rowid();
|
||||||
|
adopt_children_tx(&tx, &uid, lid)?;
|
||||||
|
lid
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
lids.push(lid);
|
lids.push(lid);
|
||||||
@@ -600,15 +654,51 @@ impl Db {
|
|||||||
Ok(lids)
|
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()?;
|
||||||
@@ -624,11 +714,12 @@ impl Db {
|
|||||||
/// had: a listing dropped as stale may miss or keep a child.
|
/// had: a listing dropped as stale may miss or keep a child.
|
||||||
pub fn known_children(&self, parent: &NodeUid) -> Result<Vec<Node>> {
|
pub fn known_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 node_json IS NOT NULL AND trashed = 0",
|
"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.to_string()], |r| r.get::<_, String>(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?)?);
|
||||||
@@ -636,6 +727,40 @@ impl Db {
|
|||||||
Ok(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) -------------------------------------
|
||||||
//
|
//
|
||||||
// Replaces the per-eviction `read_dir` scans in `ContentCache`. Each cached
|
// Replaces the per-eviction `read_dir` scans in `ContentCache`. Each cached
|
||||||
@@ -655,8 +780,53 @@ struct PriorRow {
|
|||||||
trashed: bool,
|
trashed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hand a drained placeholder's row, and the rows below it, to the real uid it
|
/// [`Db::lid_of`] on `conn`, which may be a transaction.
|
||||||
/// landed as.
|
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
|
/// 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
|
/// node left a window with no row for either uid. A queued child drained in
|
||||||
@@ -671,10 +841,13 @@ pub(super) fn adopt_placeholder_row_tx(
|
|||||||
local: &str,
|
local: &str,
|
||||||
real: &str,
|
real: &str,
|
||||||
) -> Result<()> {
|
) -> 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(
|
tx.execute(
|
||||||
"UPDATE nodes SET parent_uid = ?2 WHERE parent_uid = ?1",
|
"UPDATE OR IGNORE pins SET uid = ?2 WHERE uid = ?1",
|
||||||
params![local, real],
|
params![local, real],
|
||||||
)?;
|
)?;
|
||||||
|
tx.execute("DELETE FROM pins WHERE uid = ?1", params![local])?;
|
||||||
let row: Option<(i64, Option<String>)> = tx
|
let row: Option<(i64, Option<String>)> = tx
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT lid, node_json FROM nodes WHERE uid = ?1",
|
"SELECT lid, node_json FROM nodes WHERE uid = ?1",
|
||||||
@@ -682,6 +855,12 @@ pub(super) fn adopt_placeholder_row_tx(
|
|||||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||||
)
|
)
|
||||||
.optional()?;
|
.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 {
|
let Some((lid, json)) = row else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
@@ -693,6 +872,19 @@ pub(super) fn adopt_placeholder_row_tx(
|
|||||||
)
|
)
|
||||||
.optional()?;
|
.optional()?;
|
||||||
if let Some((known_lid, _)) = known {
|
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 WHERE lid = ?1", params![known_lid])?;
|
||||||
tx.execute("DELETE FROM nodes_fts WHERE rowid = ?1", params![known_lid])?;
|
tx.execute("DELETE FROM nodes_fts WHERE rowid = ?1", params![known_lid])?;
|
||||||
}
|
}
|
||||||
@@ -709,9 +901,36 @@ pub(super) fn adopt_placeholder_row_tx(
|
|||||||
upsert_node_tx(tx, &node)
|
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
|
||||||
@@ -733,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,
|
||||||
@@ -770,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(
|
||||||
@@ -777,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)?;
|
||||||
@@ -799,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(())
|
||||||
}
|
}
|
||||||
@@ -820,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",
|
||||||
@@ -876,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 {
|
||||||
@@ -901,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
|
||||||
@@ -916,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
|
||||||
@@ -935,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",
|
||||||
)?;
|
)?;
|
||||||
|
|||||||
+604
-87
File diff suppressed because it is too large.
Load diff
@@ -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 AND node_json IS NOT NULL)",
|
"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<()> {
|
||||||
@@ -345,7 +356,7 @@ impl Db {
|
|||||||
/// Drop the entire baseline for a folder. Used when flipping ondemand→mirror:
|
/// Drop the entire baseline for a folder. Used when flipping ondemand→mirror:
|
||||||
/// the local tree was evicted, so the old baseline is stale and would make the
|
/// the local tree was evicted, so the old baseline is stale and would make the
|
||||||
/// next reconcile mistake "locally deleted" for "must re-download". Clearing it
|
/// next reconcile mistake "locally deleted" for "must re-download". Clearing it
|
||||||
/// leaves an empty baseline + full remote = pure download (devices.md P3).
|
/// leaves an empty baseline + full remote = pure download.
|
||||||
pub fn sync_entries_clear(&self, folder_id: i64) -> Result<()> {
|
pub fn sync_entries_clear(&self, folder_id: i64) -> Result<()> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.conn.lock();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
|
|||||||
+1446
-19
File diff suppressed because it is too large.
Load diff
@@ -102,5 +102,5 @@ impl Db {
|
|||||||
Ok(seen)
|
Ok(seen)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- device sync (devices.md) -----------------------------------------
|
// ---- 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"
|
||||||
|
|||||||
@@ -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)).
|
||||||
@@ -199,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,6 +10,7 @@ pub mod config;
|
|||||||
pub mod control;
|
pub mod control;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod kernelkey;
|
||||||
pub mod localindex;
|
pub mod localindex;
|
||||||
pub mod menu;
|
pub mod menu;
|
||||||
pub mod mounts;
|
pub mod mounts;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! The machine profile that travels with a device (features.md 5.3).
|
//! The machine profile that travels with a device.
|
||||||
//!
|
//!
|
||||||
//! Sync folder mappings, pins, and the settings that shape them live in
|
//! Sync folder mappings, pins, and the settings that shape them live in
|
||||||
//! `cache.db` and `config.json` — both on the local disk. When that disk dies,
|
//! `cache.db` and `config.json` — both on the local disk. When that disk dies,
|
||||||
|
|||||||
@@ -1,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
|
||||||
|
|||||||
@@ -143,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
|
||||||
@@ -375,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
|
||||||
@@ -660,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -369,7 +369,7 @@ fn touched_topics(request: &CtlRequest) -> &'static [Topic] {
|
|||||||
RefreshScope::Photos { .. } => &[],
|
RefreshScope::Photos { .. } => &[],
|
||||||
},
|
},
|
||||||
R::RenameDevice { .. } | R::DeleteDevice { .. } => &[Topic::Devices],
|
R::RenameDevice { .. } | R::DeleteDevice { .. } => &[Topic::Devices],
|
||||||
R::AdoptDevice { .. } => &[Topic::Devices, Topic::Locations],
|
R::AdoptDevice { .. } | R::MigrateDevice { .. } => &[Topic::Devices, Topic::Locations],
|
||||||
R::AddSyncFolder { .. }
|
R::AddSyncFolder { .. }
|
||||||
| R::RemoveSyncFolder { .. }
|
| R::RemoveSyncFolder { .. }
|
||||||
| R::SetSyncFolderMode { .. }
|
| R::SetSyncFolderMode { .. }
|
||||||
@@ -377,6 +377,7 @@ fn touched_topics(request: &CtlRequest) -> &'static [Topic] {
|
|||||||
| R::RestoreSyncFolders { .. }
|
| R::RestoreSyncFolders { .. }
|
||||||
| R::RestoreDeviceFolders { .. } => &[Topic::Locations, Topic::Devices],
|
| R::RestoreDeviceFolders { .. } => &[Topic::Locations, Topic::Devices],
|
||||||
R::RetryPendingOp { .. } => &[Topic::Queue],
|
R::RetryPendingOp { .. } => &[Topic::Queue],
|
||||||
|
R::DiscardPendingOp { .. } => &[Topic::Queue, Topic::Files],
|
||||||
R::ResolveConflict { .. } => &[Topic::Files, Topic::Trash, Topic::Conflicts],
|
R::ResolveConflict { .. } => &[Topic::Files, Topic::Trash, Topic::Conflicts],
|
||||||
R::ShareNode { .. }
|
R::ShareNode { .. }
|
||||||
| R::ShareNodeByUid { .. }
|
| R::ShareNodeByUid { .. }
|
||||||
@@ -1296,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
|
||||||
@@ -1404,6 +1409,20 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
Ok(items) => CtlResponse::PendingOps { items },
|
Ok(items) => CtlResponse::PendingOps { items },
|
||||||
Err(e) => CtlResponse::error(e),
|
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(CtlRequest::RetryPendingOp { id }) => match core.retry_pending_ops(id) {
|
||||||
Ok(0) if id.is_some() => CtlResponse::Ok {
|
Ok(0) if id.is_some() => CtlResponse::Ok {
|
||||||
message: "nothing to retry: the operation is already due or waiting for a rename"
|
message: "nothing to retry: the operation is already due or waiting for a rename"
|
||||||
@@ -1801,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;
|
||||||
|
|||||||
+183
-18
@@ -1,5 +1,5 @@
|
|||||||
//! This machine as a Proton Drive Device, and the local folders it keeps in
|
//! This machine as a Proton Drive Device, and the local folders it keeps in
|
||||||
//! step with the remote (devices.md).
|
//! step with the remote.
|
||||||
//!
|
//!
|
||||||
//! A sync folder runs in one of two modes. `mirror` keeps a real local tree that
|
//! A sync folder runs in one of two modes. `mirror` keeps a real local tree that
|
||||||
//! the engine in [`super::sync`] reconciles both ways; `ondemand` evicts that
|
//! the engine in [`super::sync`] reconciles both ways; `ondemand` evicts that
|
||||||
@@ -19,6 +19,7 @@ use pdfs_core::control::{
|
|||||||
};
|
};
|
||||||
use pdfs_core::db::{StoredDevice, StoredSyncFolder};
|
use pdfs_core::db::{StoredDevice, StoredSyncFolder};
|
||||||
use pdfs_core::mounts::MountMode;
|
use pdfs_core::mounts::MountMode;
|
||||||
|
use pdfs_core::profile::PROFILE_DIR_NAME;
|
||||||
use pdfs_core::{CoreError, CoreResult};
|
use pdfs_core::{CoreError, CoreResult};
|
||||||
use proton_drive_rs::proton_sdk::ids::{DeviceUid, NodeUid};
|
use proton_drive_rs::proton_sdk::ids::{DeviceUid, NodeUid};
|
||||||
use proton_drive_rs::{DeviceType, Node};
|
use proton_drive_rs::{DeviceType, Node};
|
||||||
@@ -114,6 +115,22 @@ pub(crate) fn set_adopted_device_uid(uid: Option<&str>) -> CoreResult<()> {
|
|||||||
.map_err(|e| CoreError::internal(format!("write config: {e}")))
|
.map_err(|e| CoreError::internal(format!("write config: {e}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The name a folder moved in from `device` takes when `name` is already
|
||||||
|
/// taken under this device's root, or `None` when it can keep its own.
|
||||||
|
fn migrated_name(name: &str, device: &str, taken: &[String]) -> Option<String> {
|
||||||
|
let free = |candidate: &str| !taken.iter().any(|t| t == candidate);
|
||||||
|
if free(name) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let base = format!("{name} ({device})");
|
||||||
|
if free(&base) {
|
||||||
|
return Some(base);
|
||||||
|
}
|
||||||
|
(2..)
|
||||||
|
.map(|n| format!("{base} {n}"))
|
||||||
|
.find(|candidate| free(candidate))
|
||||||
|
}
|
||||||
|
|
||||||
impl Core {
|
impl Core {
|
||||||
// ---- devices ----------------------------------------------------------
|
// ---- devices ----------------------------------------------------------
|
||||||
|
|
||||||
@@ -184,8 +201,8 @@ impl Core {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Adopt an existing device as *this* machine's, pinning it in `config.json`
|
/// Adopt an existing device as *this* machine's, pinning it in `config.json`
|
||||||
/// so hostname changes and reinstalls stop registering duplicates
|
/// so hostname changes and reinstalls stop registering duplicates.
|
||||||
/// (features.md 5.1). `None` clears the pin and returns to hostname matching.
|
/// `None` clears the pin and returns to hostname matching.
|
||||||
///
|
///
|
||||||
/// Refuses a uid that is not in the account: the whole point of the pin is
|
/// Refuses a uid that is not in the account: the whole point of the pin is
|
||||||
/// that it is authoritative, so accepting a bad one would defer the failure
|
/// that it is authoritative, so accepting a bad one would defer the failure
|
||||||
@@ -260,7 +277,108 @@ impl Core {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- device folder sync (devices.md, Phase 1) -------------------------
|
/// Move every folder another device backs up into this machine's own
|
||||||
|
/// device, so this machine carries on backing them up as itself. The other
|
||||||
|
/// device stays registered, without those folders.
|
||||||
|
///
|
||||||
|
/// Device roots all live on the main volume, so each folder is one
|
||||||
|
/// server-side move: nothing is downloaded or uploaded, and no quota is
|
||||||
|
/// used twice. A folder whose name is taken here keeps its content under
|
||||||
|
/// "<name> (<device>)". A failed folder does not stop the others, and a
|
||||||
|
/// second run picks up whatever is still left on the other device.
|
||||||
|
///
|
||||||
|
/// Nothing is synced to this disk here. The moved folders are now this
|
||||||
|
/// device's, so the ordinary restore offers them. Only folders that were
|
||||||
|
/// already synced here from the other device are touched: their uid
|
||||||
|
/// survives the move but their share does not.
|
||||||
|
pub(crate) fn migrate_device(&self, uid: &str) -> CoreResult<String> {
|
||||||
|
let source = self.restore_source(Some(uid))?;
|
||||||
|
let target = self.ensure_device()?;
|
||||||
|
if source.uid == target.uid {
|
||||||
|
return Err(CoreError::invalid(format!(
|
||||||
|
"{} is this computer's own device",
|
||||||
|
source.name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let bad_root = |d: &StoredDevice| {
|
||||||
|
parse_uid(&d.root_uid)
|
||||||
|
.ok_or_else(|| CoreError::internal(format!("bad device root uid: {}", d.root_uid)))
|
||||||
|
};
|
||||||
|
let (source_root, target_root) = (bad_root(&source)?, bad_root(&target)?);
|
||||||
|
if source_root.volume_id != target_root.volume_id {
|
||||||
|
return Err(CoreError::invalid(
|
||||||
|
"the two devices are on different Proton Drive volumes; copy it instead",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut taken: Vec<String> = self
|
||||||
|
.device_root_nodes(&target_root)?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|n| !n.trashed)
|
||||||
|
.map(|n| n.name)
|
||||||
|
.collect();
|
||||||
|
let synced = self
|
||||||
|
.db
|
||||||
|
.sync_folder_list()
|
||||||
|
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
||||||
|
|
||||||
|
let mut moved = 0usize;
|
||||||
|
let mut renamed: Vec<String> = Vec::new();
|
||||||
|
let mut left: Vec<String> = Vec::new();
|
||||||
|
let mut failed: Vec<String> = Vec::new();
|
||||||
|
for node in self.device_root_nodes(&source_root)? {
|
||||||
|
if node.trashed || node.name == PROFILE_DIR_NAME {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// A device root only ever holds folders (B68); anything else was
|
||||||
|
// put there by hand and is left for the user to deal with.
|
||||||
|
if !node.is_folder() {
|
||||||
|
left.push(node.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let new_name = migrated_name(&node.name, &source.name, &taken);
|
||||||
|
if let Err(e) = self.move_rename_remote(&node.uid, &target_root, new_name.as_deref()) {
|
||||||
|
warn!(name = %node.name, error = %e, "migrate: could not move folder");
|
||||||
|
failed.push(format!("{}: {e}", node.name));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let landed = new_name.clone().unwrap_or_else(|| node.name.clone());
|
||||||
|
if new_name.is_some() {
|
||||||
|
renamed.push(format!("{} -> {landed}", node.name));
|
||||||
|
}
|
||||||
|
taken.push(landed);
|
||||||
|
moved += 1;
|
||||||
|
|
||||||
|
let uid = node.uid.to_string();
|
||||||
|
for folder in synced.iter().filter(|f| f.remote_uid == uid) {
|
||||||
|
if let Err(e) = self.db.sync_folder_set_share(folder.id, &target.share_id) {
|
||||||
|
warn!(id = folder.id, error = ?e, "migrate: could not re-point sync folder");
|
||||||
|
}
|
||||||
|
self.sync_now(Some(folder.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.invalidate_children_of(&source_root);
|
||||||
|
self.invalidate_children_of(&target_root);
|
||||||
|
|
||||||
|
let mut message = format!("moved {moved} folder(s) from {}", source.name);
|
||||||
|
if !renamed.is_empty() {
|
||||||
|
message.push_str(&format!("; renamed: {}", renamed.join(", ")));
|
||||||
|
}
|
||||||
|
if !left.is_empty() {
|
||||||
|
message.push_str(&format!("; left behind: {}", left.join(", ")));
|
||||||
|
}
|
||||||
|
if !failed.is_empty() {
|
||||||
|
message.push_str(&format!("; failed: {}", failed.join(", ")));
|
||||||
|
}
|
||||||
|
info!(from = %source.name, to = %target.name, moved, failed = failed.len(),
|
||||||
|
"device migrated");
|
||||||
|
if moved == 0 && !failed.is_empty() {
|
||||||
|
return Err(CoreError::conflict(message));
|
||||||
|
}
|
||||||
|
Ok(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- device folder sync -------------------------
|
||||||
|
|
||||||
/// Auto-register (or recover) this machine as a Proton Drive Device, caching
|
/// Auto-register (or recover) this machine as a Proton Drive Device, caching
|
||||||
/// it so restarts reuse the same device. Recovery matches an existing remote
|
/// it so restarts reuse the same device. Recovery matches an existing remote
|
||||||
@@ -270,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
|
||||||
@@ -361,21 +478,25 @@ impl Core {
|
|||||||
root_uid: &NodeUid,
|
root_uid: &NodeUid,
|
||||||
name: &str,
|
name: &str,
|
||||||
) -> CoreResult<Option<NodeUid>> {
|
) -> CoreResult<Option<NodeUid>> {
|
||||||
|
Ok(self
|
||||||
|
.device_root_nodes(root_uid)?
|
||||||
|
.into_iter()
|
||||||
|
.find(|n| n.is_folder() && !n.trashed && n.name == name)
|
||||||
|
.map(|n| n.uid))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything directly under a device root, trashed nodes included.
|
||||||
|
fn device_root_nodes(&self, root_uid: &NodeUid) -> CoreResult<Vec<Node>> {
|
||||||
let uids = self
|
let uids = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.drive.enumerate_folder_children_node_uids(root_uid))
|
.block_on(self.drive.enumerate_folder_children_node_uids(root_uid))
|
||||||
.map_err(|e| CoreError::from_api(&e, "list device root"))?;
|
.map_err(|e| CoreError::from_api(&e, "list device root"))?;
|
||||||
if uids.is_empty() {
|
if uids.is_empty() {
|
||||||
return Ok(None);
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
let nodes = self
|
self.rt
|
||||||
.rt
|
|
||||||
.block_on(self.drive.enumerate_nodes(&uids))
|
.block_on(self.drive.enumerate_nodes(&uids))
|
||||||
.map_err(|e| CoreError::from_api(&e, "resolve device root children"))?;
|
.map_err(|e| CoreError::from_api(&e, "resolve device root children"))
|
||||||
Ok(nodes
|
|
||||||
.into_iter()
|
|
||||||
.find(|n| n.is_folder() && !n.trashed && n.name == name)
|
|
||||||
.map(|n| n.uid))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add a local folder to this device's sync set: register the device if
|
/// Add a local folder to this device's sync set: register the device if
|
||||||
@@ -617,7 +738,7 @@ impl Core {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A sibling Core for a secondary FUSE session rooted at an `ondemand` sync
|
/// A sibling Core for a secondary FUSE session rooted at an `ondemand` sync
|
||||||
/// folder (devices.md Phase 3). It shares everything with this one,
|
/// folder. It shares everything with this one,
|
||||||
/// the tree included, except what belongs to a session: its root and its
|
/// the tree included, except what belongs to a session: its root and its
|
||||||
/// kernel channel, both filled in when the session is built.
|
/// kernel channel, both filled in when the session is built.
|
||||||
pub(crate) fn fork_state(&self) -> Core {
|
pub(crate) fn fork_state(&self) -> Core {
|
||||||
@@ -843,6 +964,23 @@ impl Core {
|
|||||||
if folder.state != "idle" {
|
if folder.state != "idle" {
|
||||||
return Err(SwitchBlocked::NotNow);
|
return Err(SwitchBlocked::NotNow);
|
||||||
}
|
}
|
||||||
|
// `idle` only says the last pass applied everything it tried.
|
||||||
|
// It never tries an ignored path, a symlink or a folder it
|
||||||
|
// cannot read, it defers a file open for writing, and a file can
|
||||||
|
// change after it ends. The eviction below deletes all of those
|
||||||
|
// for good, so it waits until nothing here is missing from Drive
|
||||||
|
// (B198).
|
||||||
|
let baseline = self
|
||||||
|
.db
|
||||||
|
.sync_entries(id)
|
||||||
|
.map_err(|e| SwitchBlocked::Failed(format!("db: {e:?}")))?;
|
||||||
|
if let Some(reason) =
|
||||||
|
sync::mirror_tree_unsynced(&local, &baseline, &self.ignore_rules(&local))
|
||||||
|
{
|
||||||
|
return Err(SwitchBlocked::Failed(format!(
|
||||||
|
"going online-only would delete what is only on this computer: {reason}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
let root_uid = parse_uid(&folder.remote_uid).ok_or_else(|| {
|
let root_uid = parse_uid(&folder.remote_uid).ok_or_else(|| {
|
||||||
SwitchBlocked::Failed(format!("bad remote uid: {}", folder.remote_uid))
|
SwitchBlocked::Failed(format!("bad remote uid: {}", folder.remote_uid))
|
||||||
})?;
|
})?;
|
||||||
@@ -986,8 +1124,7 @@ impl Core {
|
|||||||
/// restart (their local dirs are empty on disk — the files live in the cloud).
|
/// restart (their local dirs are empty on disk — the files live in the cloud).
|
||||||
/// Best-effort per folder: a missing local path or a failed remote fetch marks
|
/// Best-effort per folder: a missing local path or a failed remote fetch marks
|
||||||
/// the folder `error` and moves on rather than aborting the rest. Runs on its
|
/// the folder `error` and moves on rather than aborting the rest. Runs on its
|
||||||
/// own thread from `mount` so the network fetches never block startup
|
/// own thread from `mount` so the network fetches never block startup.
|
||||||
/// (devices.md Phase 4).
|
|
||||||
pub(crate) fn restore_ondemand_mounts(&self) {
|
pub(crate) fn restore_ondemand_mounts(&self) {
|
||||||
let folders = match self.db.sync_folder_list() {
|
let folders = match self.db.sync_folder_list() {
|
||||||
Ok(f) => f,
|
Ok(f) => f,
|
||||||
@@ -1109,10 +1246,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;
|
||||||
|
|||||||
+1188
-212
File diff suppressed because it is too large.
Load diff
@@ -3,7 +3,7 @@
|
|||||||
//! `Core` used to hold a concrete [`ProtonDriveClient`], so no test could make
|
//! `Core` used to hold a concrete [`ProtonDriveClient`], so no test could make
|
||||||
//! Drive answer late, answer stale, or not answer at all, and the interleavings
|
//! Drive answer late, answer stale, or not answer at all, and the interleavings
|
||||||
//! behind most recent bugs could only be found on a real account over a slow
|
//! behind most recent bugs could only be found on a real account over a slow
|
||||||
//! link (`docs/MILESTONE-3.0.0.md` §2, §8.1). [`DriveApi`] is the part of the
|
//! link. [`DriveApi`] is the part of the
|
||||||
//! client the mount, the drain, the event feed and the mirror engine use; the
|
//! client the mount, the drain, the event feed and the mirror engine use; the
|
||||||
//! real client implements it by forwarding, and the simulation tests implement
|
//! real client implements it by forwarding, and the simulation tests implement
|
||||||
//! it with an in-memory Drive that injects Drive's known faults.
|
//! it with an in-memory Drive that injects Drive's known faults.
|
||||||
@@ -153,6 +153,13 @@ pub(crate) trait DriveApi: Send + Sync {
|
|||||||
|
|
||||||
/// The account's storage, across every Proton product.
|
/// The account's storage, across every Proton product.
|
||||||
async fn quota(&self) -> Result<Quota>;
|
async fn quota(&self) -> Result<Quota>;
|
||||||
|
|
||||||
|
/// 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.
|
/// The generic signatures of the client, over the object-safe ones above.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
//! through one place (the status, transfers, sync progress) and publishes them
|
//! through one place (the status, transfers, sync progress) and publishes them
|
||||||
//! when they differ.
|
//! when they differ.
|
||||||
|
|
||||||
use std::collections::BTreeSet;
|
use std::collections::{BTreeSet, HashMap};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -153,13 +153,15 @@ pub(crate) fn transfers_line(items: Vec<TransferItem>, jobs: Vec<JobItem>) -> Op
|
|||||||
|
|
||||||
/// Watch what changes without a single place to publish it from, and publish it
|
/// 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,
|
/// when it differs from the last look: the status (queue counts, cache use,
|
||||||
/// online, pause), the transfers snapshot, and the synced folders with their
|
/// online, pause), the transfers snapshot, the synced folders with their
|
||||||
/// live progress. Looks only while someone is subscribed.
|
/// 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) {
|
pub(crate) fn run_event_sampler(core: Core, username: String, mountpoint: std::path::PathBuf) {
|
||||||
let mut transfers: Option<Arc<str>> = None;
|
let mut transfers: Option<Arc<str>> = None;
|
||||||
let mut status: Option<String> = None;
|
let mut status: Option<String> = None;
|
||||||
let mut queue: Option<String> = None;
|
let mut queue: Option<String> = None;
|
||||||
let mut locations: Option<String> = None;
|
let mut locations: Option<String> = None;
|
||||||
|
let mut issues: Option<HashMap<i64, String>> = None;
|
||||||
let mut tick = 0u32;
|
let mut tick = 0u32;
|
||||||
while core.shutdown.sleep(SAMPLE_INTERVAL) {
|
while core.shutdown.sleep(SAMPLE_INTERVAL) {
|
||||||
if !core.events.has_subscribers() {
|
if !core.events.has_subscribers() {
|
||||||
@@ -169,6 +171,7 @@ pub(crate) fn run_event_sampler(core: Core, username: String, mountpoint: std::p
|
|||||||
status = None;
|
status = None;
|
||||||
queue = None;
|
queue = None;
|
||||||
locations = None;
|
locations = None;
|
||||||
|
issues = None;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let line = transfers_line(core.transfers.snapshot(), core.jobs_snapshot());
|
let line = transfers_line(core.transfers.snapshot(), core.jobs_snapshot());
|
||||||
@@ -196,6 +199,15 @@ pub(crate) fn run_event_sampler(core: Core, username: String, mountpoint: std::p
|
|||||||
}
|
}
|
||||||
queue = Some(queue_key);
|
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() {
|
if let Ok(now) = core.list_locations() {
|
||||||
let key = serde_json::to_string(&now).unwrap_or_default();
|
let key = serde_json::to_string(&now).unwrap_or_default();
|
||||||
if locations.as_ref().is_some_and(|last| *last != key) {
|
if locations.as_ref().is_some_and(|last| *last != key) {
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ impl Filesystem for ProtonFs {
|
|||||||
// A provisional size is the *ciphertext* size, which is larger than the
|
// A provisional size is the *ciphertext* size, which is larger than the
|
||||||
// file. Publishing it makes every reader that trusts `st_size` — rsync,
|
// file. Publishing it makes every reader that trusts `st_size` — rsync,
|
||||||
// mmap, sendfile, a sized read loop — run off the end of the file and
|
// mmap, sendfile, a sized read loop — run off the end of the file and
|
||||||
// fail. So resolve it before answering rather than after (bugs.md B14).
|
// fail. So resolve it before answering rather than after (`docs/BUGS.md` B14).
|
||||||
//
|
//
|
||||||
// The cost is one batched round trip for the whole folder, not one per
|
// The cost is one batched round trip for the whole folder, not one per
|
||||||
// file: `ls -l` is one `getattr` per entry and they collapse onto a
|
// file: `ls -l` is one `getattr` per entry and they collapse onto a
|
||||||
@@ -172,7 +172,7 @@ impl Filesystem for ProtonFs {
|
|||||||
/// here — once per `opendir`, which the kernel pairs exactly with one
|
/// here — once per `opendir`, which the kernel pairs exactly with one
|
||||||
/// `releasedir` — is what makes the offsets stable, so a create or a trash
|
/// `releasedir` — is what makes the offsets stable, so a create or a trash
|
||||||
/// during an enumeration can no longer make the caller skip or repeat an
|
/// during an enumeration can no longer make the caller skip or repeat an
|
||||||
/// entry (bugs.md B43).
|
/// entry (`docs/BUGS.md` B43).
|
||||||
///
|
///
|
||||||
/// Enumerating a cold folder is a remote call, so it goes to a worker for
|
/// Enumerating a cold folder is a remote call, so it goes to a worker for
|
||||||
/// the same reason `lookup` and `readdir` do.
|
/// the same reason `lookup` and `readdir` do.
|
||||||
@@ -346,7 +346,7 @@ impl Filesystem for ProtonFs {
|
|||||||
self.core.workers.run(Lane::Transfer, "read", move || {
|
self.core.workers.run(Lane::Transfer, "read", move || {
|
||||||
// The revision disagrees with a provisional size, and B84 fails
|
// The revision disagrees with a provisional size, and B84 fails
|
||||||
// such a read outright, so settle the size first. On the network,
|
// such a read outright, so settle the size first. On the network,
|
||||||
// hence here and not above (bugs.md B100).
|
// hence here and not above (`docs/BUGS.md` B100).
|
||||||
let fsize = match provisional {
|
let fsize = match provisional {
|
||||||
true => core.resolve_provisional_size(ino.0, &uid).unwrap_or(fsize),
|
true => core.resolve_provisional_size(ino.0, &uid).unwrap_or(fsize),
|
||||||
false => fsize,
|
false => fsize,
|
||||||
@@ -517,7 +517,7 @@ impl Filesystem for ProtonFs {
|
|||||||
// `> file`. This is the second write path into the API, and
|
// `> file`. This is the second write path into the API, and
|
||||||
// queueing it is what lets a redirect work offline at all: it
|
// queueing it is what lets a redirect work offline at all: it
|
||||||
// never reaches `release`, so without this it failed before any
|
// never reaches `release`, so without this it failed before any
|
||||||
// byte was written (offline.md Phase 2/3b).
|
// byte was written.
|
||||||
//
|
//
|
||||||
// The one branch of `setattr` that can reach the network:
|
// The one branch of `setattr` that can reach the network:
|
||||||
// shrinking keeps the prefix, which `queue_truncate` gap-fills
|
// shrinking keeps the prefix, which `queue_truncate` gap-fills
|
||||||
@@ -607,7 +607,7 @@ impl Filesystem for ProtonFs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `close(2)` calls flush before release. The upload is queued in `release`
|
/// `close(2)` calls flush before release. The upload is queued in `release`
|
||||||
/// and performed in the background (offline.md Phase 3), so there is nothing
|
/// and performed in the background, so there is nothing
|
||||||
/// to push here — the written bytes are already in the scratch file.
|
/// to push here — the written bytes are already in the scratch file.
|
||||||
fn flush(
|
fn flush(
|
||||||
&self,
|
&self,
|
||||||
@@ -985,7 +985,7 @@ pub struct ProtonFs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// How many times a write `open` will re-read its base after a `release`
|
/// How many times a write `open` will re-read its base after a `release`
|
||||||
/// published a newer queued revision underneath it (bugs.md B32).
|
/// published a newer queued revision underneath it (`docs/BUGS.md` B32).
|
||||||
///
|
///
|
||||||
/// One retry covers the race, which needs a close and an open of the same file
|
/// One retry covers the race, which needs a close and an open of the same file
|
||||||
/// to overlap on different threads. More than that is not contention, it is a
|
/// to overlap on different threads. More than that is not contention, it is a
|
||||||
@@ -1021,7 +1021,7 @@ fn build_listing(st: &State, ino: u64) -> DirListing {
|
|||||||
///
|
///
|
||||||
/// The cookie handed back is the index of the *next* entry, so a resumed call
|
/// The cookie handed back is the index of the *next* entry, so a resumed call
|
||||||
/// continues exactly where the last one stopped — which is only true because
|
/// continues exactly where the last one stopped — which is only true because
|
||||||
/// the listing behind those indexes is a snapshot (bugs.md B43).
|
/// the listing behind those indexes is a snapshot (`docs/BUGS.md` B43).
|
||||||
fn reply_listing(listing: &DirListing, offset: u64, root: u64, mut reply: ReplyDirectory) {
|
fn reply_listing(listing: &DirListing, offset: u64, root: u64, mut reply: ReplyDirectory) {
|
||||||
for (i, (ino, is_dir, name)) in listing.iter().enumerate().skip(offset as usize) {
|
for (i, (ino, is_dir, name)) in listing.iter().enumerate().skip(offset as usize) {
|
||||||
let ft = if *is_dir {
|
let ft = if *is_dir {
|
||||||
@@ -1094,7 +1094,7 @@ impl ProtonFs {
|
|||||||
// `getattr` would, so `ls -l` — which is one `lookup`
|
// `getattr` would, so `ls -l` — which is one `lookup`
|
||||||
// per entry and no `getattr` at all — takes its sizes
|
// per entry and no `getattr` at all — takes its sizes
|
||||||
// from here. Resolving only in `getattr` left the whole
|
// from here. Resolving only in `getattr` left the whole
|
||||||
// listing provisional (bugs.md B14).
|
// listing provisional (`docs/BUGS.md` B14).
|
||||||
let provisional = matches!(
|
let provisional = matches!(
|
||||||
&e.node.kind,
|
&e.node.kind,
|
||||||
NodeKind::File {
|
NodeKind::File {
|
||||||
@@ -1217,7 +1217,7 @@ impl ProtonFs {
|
|||||||
/// open.
|
/// open.
|
||||||
fn serve_open(&self, ino: INodeNo, flags: OpenFlags, reply: ReplyOpen) {
|
fn serve_open(&self, ino: INodeNo, flags: OpenFlags, reply: ReplyOpen) {
|
||||||
let write_requested = flags.acc_mode() != OpenAccMode::O_RDONLY;
|
let write_requested = flags.acc_mode() != OpenAccMode::O_RDONLY;
|
||||||
let (uid, base_mtime, base_size, provisional, base_revision_id) = {
|
let (uid, mut base_mtime, mut base_size, mut provisional, mut base_revision_id) = {
|
||||||
let mut st = self.core.state();
|
let mut st = self.core.state();
|
||||||
match st.entries.get_mut(&ino.0) {
|
match st.entries.get_mut(&ino.0) {
|
||||||
Some(e) if e.node.is_file() => {
|
Some(e) if e.node.is_file() => {
|
||||||
@@ -1276,29 +1276,31 @@ impl ProtonFs {
|
|||||||
// revision in between sends this around again. Without the re-check the
|
// revision in between sends this around again. Without the re-check the
|
||||||
// new handle carried the *older* base, and its own release published
|
// new handle carried the *older* base, and its own release published
|
||||||
// content that silently discarded the revision closed a moment earlier
|
// content that silently discarded the revision closed a moment earlier
|
||||||
// (bugs.md B32). A second attempt is already unlikely and a third would
|
// (`docs/BUGS.md` B32). A second attempt is already unlikely and a third would
|
||||||
// mean something is republishing continuously, so give up rather than
|
// mean something is republishing continuously, so give up rather than
|
||||||
// spin: the caller's `open` fails instead of its data being lost.
|
// spin: the caller's `open` fails instead of its data being lost.
|
||||||
//
|
//
|
||||||
// `reply` is consumed by whichever arm answers, so the loop yields an
|
// `reply` is consumed by whichever arm answers, so the loop yields an
|
||||||
// outcome and the answer is sent once, after it.
|
// outcome and the answer is sent once, after it.
|
||||||
let outcome = 'install: {
|
let outcome = 'install: {
|
||||||
// The handle starts at the base's size. A provisional one is the
|
|
||||||
// ciphertext size, so the file would grow by the encryption overhead
|
|
||||||
// and reading the base would fail (bugs.md B100). A queued revision
|
|
||||||
// stamps a real size, so this only goes to the network for a file
|
|
||||||
// the listing has not sized yet.
|
|
||||||
let base_size = match provisional && self.core.pending_blob(&uid).is_none() {
|
|
||||||
false => base_size,
|
|
||||||
true => match self.core.resolve_provisional_size(ino.0, &uid) {
|
|
||||||
Some(size) => size,
|
|
||||||
None => {
|
|
||||||
error!(%uid, "could not settle the size of the base for a write open");
|
|
||||||
break 'install Err(Errno::EIO);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
for attempt in 0..OPEN_BASE_ATTEMPTS {
|
for attempt in 0..OPEN_BASE_ATTEMPTS {
|
||||||
|
// The handle starts at the base's size. A provisional one is the
|
||||||
|
// ciphertext size, so the file would grow by the encryption
|
||||||
|
// overhead and reading the base would fail (`docs/BUGS.md` B100). A
|
||||||
|
// queued revision stamps a real size, so this only goes to the
|
||||||
|
// network for a file the listing has not sized yet.
|
||||||
|
if provisional && self.core.pending_blob(&uid).is_none() {
|
||||||
|
match self.core.resolve_provisional_size(ino.0, &uid) {
|
||||||
|
Some(size) => {
|
||||||
|
base_size = size;
|
||||||
|
provisional = false;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
error!(%uid, "could not settle the size of the base for a write open");
|
||||||
|
break 'install Err(Errno::EIO);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let pending_base = self.core.pending.lock().get(&uid).cloned();
|
let pending_base = self.core.pending.lock().get(&uid).cloned();
|
||||||
if pending_base.as_ref().is_some_and(|p| !p.meta.complete) {
|
if pending_base.as_ref().is_some_and(|p| !p.meta.complete) {
|
||||||
error!(%uid, "refusing write over incomplete queued revision");
|
error!(%uid, "refusing write over incomplete queued revision");
|
||||||
@@ -1332,17 +1334,49 @@ impl ProtonFs {
|
|||||||
// a lock-tight comparison — against the multi-gigabyte copy
|
// a lock-tight comparison — against the multi-gigabyte copy
|
||||||
// above, which is the window that actually loses data.
|
// above, which is the window that actually loses data.
|
||||||
let current = self.core.pending_blob(&uid);
|
let current = self.core.pending_blob(&uid);
|
||||||
|
let landed = self.core.landed.lock().get(&uid).cloned();
|
||||||
let mut st = self.core.state();
|
let mut st = self.core.state();
|
||||||
// Only a handle this open is about to *create* can carry a stale
|
// Only a handle this open is about to *create* can carry a stale
|
||||||
// base; if one already exists, that handle is authoritative and
|
// base; if one already exists, that handle is authoritative and
|
||||||
// the scratch prepared above is discarded either way.
|
// the scratch prepared above is discarded either way.
|
||||||
let creating = !st.active_writes.contains_key(&ino.0);
|
let creating = !st.active_writes.contains_key(&ino.0);
|
||||||
if creating && current != pending_base.map(|p| p.path) {
|
if creating && current.as_ref() != pending_base.as_ref().map(|p| &p.path) {
|
||||||
drop(st);
|
drop(st);
|
||||||
let _ = std::fs::remove_file(&path);
|
let _ = std::fs::remove_file(&path);
|
||||||
warn!(%uid, attempt, "queued revision changed while opening for write; retrying");
|
warn!(%uid, attempt, "queued revision changed while opening for write; retrying");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// With no queued revision, the base's bytes come from the cache,
|
||||||
|
// keyed by the base's mtime and size. The ones sampled above can
|
||||||
|
// name a revision this daemon has since replaced: an upload that
|
||||||
|
// lands lets go of its blob before the tree has its node, and
|
||||||
|
// the cache holds its bytes under the node only. So the base is
|
||||||
|
// the landing node while there is one, and the tree's otherwise
|
||||||
|
// (docs/BUGS.md B170).
|
||||||
|
if creating && pending_base.is_none() {
|
||||||
|
let now = landed.or_else(|| st.entries.get(&ino.0).map(|e| e.node.clone()));
|
||||||
|
if let Some(node) = now
|
||||||
|
&& (node.modification_time != base_mtime
|
||||||
|
|| node_revision_id(&node) != base_revision_id)
|
||||||
|
{
|
||||||
|
base_mtime = node.modification_time;
|
||||||
|
base_size = node_size(&node);
|
||||||
|
base_revision_id = node_revision_id(&node);
|
||||||
|
provisional = matches!(
|
||||||
|
&node.kind,
|
||||||
|
NodeKind::File {
|
||||||
|
claimed_size: None,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
);
|
||||||
|
debug!(%uid, base_mtime, base_size, "write open took the base the file has now");
|
||||||
|
if provisional {
|
||||||
|
drop(st);
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let fh = st.next_fh;
|
let fh = st.next_fh;
|
||||||
st.next_fh += 1;
|
st.next_fh += 1;
|
||||||
let aw = st.active_writes.entry(ino.0).or_insert_with(|| {
|
let aw = st.active_writes.entry(ino.0).or_insert_with(|| {
|
||||||
@@ -1507,14 +1541,14 @@ impl ProtonFs {
|
|||||||
let transient = is_transient_name(name);
|
let transient = is_transient_name(name);
|
||||||
// Offline the server cannot mint a uid, so invent one and queue the
|
// Offline the server cannot mint a uid, so invent one and queue the
|
||||||
// create. The file is real to the caller either way; only its identity is
|
// create. The file is real to the caller either way; only its identity is
|
||||||
// provisional until the drain (offline.md Phase 3b).
|
// provisional until the drain.
|
||||||
//
|
//
|
||||||
// A parent that is itself still queued forces the same path even when we
|
// A parent that is itself still queued forces the same path even when we
|
||||||
// are online: the API has no folder to put this in yet.
|
// are online: the API has no folder to put this in yet.
|
||||||
//
|
//
|
||||||
// A create that set out online and lost the network on the way lands in
|
// A create that set out online and lost the network on the way lands in
|
||||||
// the queue too, so a Wi-Fi drop costs the caller a wait, never an EIO.
|
// the queue too, so a Wi-Fi drop costs the caller a wait, never an EIO.
|
||||||
let minted = if !transient && self.core.is_online() && !is_local_uid(&parent_uid) {
|
let minted = if !transient && self.core.sends_inline() && !is_local_uid(&parent_uid) {
|
||||||
if let Err(error) = self.core.require_uid_writable(&parent_uid) {
|
if let Err(error) = self.core.require_uid_writable(&parent_uid) {
|
||||||
reply.error(error);
|
reply.error(error);
|
||||||
return;
|
return;
|
||||||
@@ -1618,9 +1652,9 @@ impl ProtonFs {
|
|||||||
.ok();
|
.ok();
|
||||||
// As in `create`: offline — or under a parent that is itself still
|
// As in `create`: offline — or under a parent that is itself still
|
||||||
// queued — the folder becomes a placeholder that the drain turns into a
|
// queued — the folder becomes a placeholder that the drain turns into a
|
||||||
// real one (offline.md Phase 3b).
|
// real one.
|
||||||
// Losing the network on the way queues it the same way.
|
// Losing the network on the way queues it the same way.
|
||||||
let minted = if self.core.is_online() && !is_local_uid(&parent_uid) {
|
let minted = if self.core.sends_inline() && !is_local_uid(&parent_uid) {
|
||||||
if let Err(error) = self.core.require_uid_writable(&parent_uid) {
|
if let Err(error) = self.core.require_uid_writable(&parent_uid) {
|
||||||
reply.error(error);
|
reply.error(error);
|
||||||
return;
|
return;
|
||||||
@@ -1770,10 +1804,13 @@ impl ProtonFs {
|
|||||||
self.core.cache.clear_scratch_durable(&h.path);
|
self.core.cache.clear_scratch_durable(&h.path);
|
||||||
reply.ok();
|
reply.ok();
|
||||||
}
|
}
|
||||||
(Some(h), false) => match self.core.queue_revision(&h) {
|
(Some(mut h), false) => {
|
||||||
Ok(()) => reply.ok(),
|
self.core.follow_landed_create(&mut h);
|
||||||
Err(e) => reply.error(e),
|
match self.core.queue_revision(&mut h) {
|
||||||
},
|
Ok(()) => reply.ok(),
|
||||||
|
Err(e) => reply.error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
(None, _) => reply.ok(),
|
(None, _) => reply.ok(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1796,7 +1833,7 @@ impl ProtonFs {
|
|||||||
reply.error(error);
|
reply.error(error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let (ino, uid) = match self.core.lookup_child(parent, name) {
|
let (ino, mut uid) = match self.core.lookup_child(parent, name) {
|
||||||
Ok(x) => x,
|
Ok(x) => x,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
reply.error(e);
|
reply.error(e);
|
||||||
@@ -1837,7 +1874,7 @@ impl ProtonFs {
|
|||||||
// rename, so the victim has to be removed first and the operation stops
|
// rename, so the victim has to be removed first and the operation stops
|
||||||
// being atomic. Without this the 422 surfaced as a blanket EIO and every
|
// being atomic. Without this the 422 surfaced as a blanket EIO and every
|
||||||
// write-to-temp-then-rename tool — rsync, atomic editor saves — failed
|
// write-to-temp-then-rename tool — rsync, atomic editor saves — failed
|
||||||
// at the very end of its transfer (bugs.md B13).
|
// at the very end of its transfer (`docs/BUGS.md` B13).
|
||||||
//
|
//
|
||||||
// `RENAME_EXCHANGE` has no Proton primitive and cannot be emulated
|
// `RENAME_EXCHANGE` has no Proton primitive and cannot be emulated
|
||||||
// without a window in which one of the two names does not exist.
|
// without a window in which one of the two names does not exist.
|
||||||
@@ -1909,7 +1946,7 @@ impl ProtonFs {
|
|||||||
reply.error(error);
|
reply.error(error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Err(e) = self.core.remove_replaced(victim_uid, newname) {
|
if let Err(e) = self.core.remove_replaced(*victim_ino, victim_uid, newname) {
|
||||||
reply.error(e);
|
reply.error(e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1919,8 +1956,9 @@ impl ProtonFs {
|
|||||||
};
|
};
|
||||||
// A node whose own creation is still queued has no server-side identity
|
// A node whose own creation is still queued has no server-side identity
|
||||||
// to rename: the queued op *is* the node, so rewriting its target is the
|
// to rename: the queued op *is* the node, so rewriting its target is the
|
||||||
// whole rename. Nothing reaches the API, which is why this works offline
|
// whole rename. Nothing reaches the API, which is why this works offline.
|
||||||
// (offline.md Phase 3b).
|
// A create that lands meanwhile leaves no op to rewrite; the node is
|
||||||
|
// then renamed by the uid Drive gave it (docs/BUGS.md B142).
|
||||||
if is_local_uid(&uid) {
|
if is_local_uid(&uid) {
|
||||||
match self.core.db.rewrite_op_target(
|
match self.core.db.rewrite_op_target(
|
||||||
&uid.to_string(),
|
&uid.to_string(),
|
||||||
@@ -1931,6 +1969,8 @@ impl ProtonFs {
|
|||||||
self.core
|
self.core
|
||||||
.state()
|
.state()
|
||||||
.rename_in_place(ino, newparent, &new_parent_uid, newname);
|
.rename_in_place(ino, newparent, &new_parent_uid, newname);
|
||||||
|
// A create that failed on its old name is due again.
|
||||||
|
self.core.wake_drain();
|
||||||
// Finalize: a transient scratch file (its create parked, bytes
|
// Finalize: a transient scratch file (its create parked, bytes
|
||||||
// held back) renamed to a finished name is the moment the
|
// held back) renamed to a finished name is the moment the
|
||||||
// completed file is meant to reach Drive. Un-park the create so
|
// completed file is meant to reach Drive. Un-park the create so
|
||||||
@@ -1949,23 +1989,29 @@ impl ProtonFs {
|
|||||||
}
|
}
|
||||||
debug!(%uid, newname, "renamed a node whose create is still queued");
|
debug!(%uid, newname, "renamed a node whose create is still queued");
|
||||||
reply.ok();
|
reply.ok();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
// The create drained underneath us, so the node has a real uid
|
Ok(false) => match self.core.landed_row_uid(ino) {
|
||||||
// now and this handle's is stale. A retry resolves it.
|
Some(real) => {
|
||||||
Ok(false) => {
|
debug!(local = %uid, %real, newname, "create landed under a rename");
|
||||||
warn!(%uid, name, newname, "queued create vanished under a rename");
|
uid = real;
|
||||||
self.core
|
}
|
||||||
.restore_replaced(victim.as_ref(), &new_parent_uid, newname);
|
None => {
|
||||||
reply.error(Errno::EBUSY);
|
warn!(%uid, name, newname, "queued create vanished under a rename");
|
||||||
}
|
self.core
|
||||||
|
.restore_replaced(victim.as_ref(), &new_parent_uid, newname);
|
||||||
|
reply.error(Errno::EBUSY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
},
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!(%uid, error = %e, "rewriting a queued create's target failed");
|
error!(%uid, error = %e, "rewriting a queued create's target failed");
|
||||||
self.core
|
self.core
|
||||||
.restore_replaced(victim.as_ref(), &new_parent_uid, newname);
|
.restore_replaced(victim.as_ref(), &new_parent_uid, newname);
|
||||||
reply.error(Errno::EIO);
|
reply.error(Errno::EIO);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
// Offline, or into a folder that does not exist remotely yet: queue the
|
// Offline, or into a folder that does not exist remotely yet: queue the
|
||||||
// rename rather than 404 or fail. Online moves into a real parent still
|
// rename rather than 404 or fail. Online moves into a real parent still
|
||||||
@@ -2020,7 +2066,7 @@ impl ProtonFs {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
if rename_needs_queue(
|
if rename_needs_queue(
|
||||||
self.core.is_online(),
|
self.core.sends_inline(),
|
||||||
is_local_uid(&new_parent_uid),
|
is_local_uid(&new_parent_uid),
|
||||||
newparent != parent,
|
newparent != parent,
|
||||||
newname != name,
|
newname != name,
|
||||||
@@ -2093,7 +2139,7 @@ impl ProtonFs {
|
|||||||
reply.error(error);
|
reply.error(error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let (ino, uid) = match self.core.lookup_child(parent, name) {
|
let (ino, mut uid) = match self.core.lookup_child(parent, name) {
|
||||||
Ok(x) => x,
|
Ok(x) => x,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
reply.error(e);
|
reply.error(e);
|
||||||
@@ -2109,7 +2155,7 @@ impl ProtonFs {
|
|||||||
.is_some_and(|e| e.open_count > 0);
|
.is_some_and(|e| e.open_count > 0);
|
||||||
// A node the server has never heard of cannot be trashed there; deleting
|
// A node the server has never heard of cannot be trashed there; deleting
|
||||||
// it just means its queued creation is no longer wanted. This works
|
// it just means its queued creation is no longer wanted. This works
|
||||||
// offline, which the remote path below cannot (offline.md Phase 3b).
|
// offline, which the remote path below cannot.
|
||||||
if is_local_uid(&uid) {
|
if is_local_uid(&uid) {
|
||||||
let dropped = if open_now {
|
let dropped = if open_now {
|
||||||
self.core.withdraw_queued_ops(&uid)
|
self.core.withdraw_queued_ops(&uid)
|
||||||
@@ -2121,19 +2167,30 @@ impl ProtonFs {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.core.state().forget_or_unlink(&uid);
|
self.core.state().forget_or_unlink(&uid);
|
||||||
debug!(%uid, name, "deleted a node that had not been created remotely yet");
|
// A create that landed before the ops were dropped left none: Drive
|
||||||
reply.ok();
|
// has the node, and the tree had not taken its uid yet. It is
|
||||||
return;
|
// trashed there like any other (docs/BUGS.md B173).
|
||||||
|
match self.core.landed_row_uid(ino) {
|
||||||
|
Some(real) => {
|
||||||
|
debug!(local = %uid, %real, name, "create landed under a delete");
|
||||||
|
uid = real;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
debug!(%uid, name, "deleted a node that had not been created remotely yet");
|
||||||
|
reply.ok();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Offline: queue it. Trashing is the one mutation a user expects to work
|
// Offline: queue it. Trashing is the one mutation a user expects to work
|
||||||
// regardless — the file is gone from their point of view the moment the
|
// regardless — the file is gone from their point of view the moment the
|
||||||
// command returns (offline.md Phase 3b). The same when the network goes
|
// command returns. The same when the network goes
|
||||||
// away under the remote call below.
|
// away under the remote call below.
|
||||||
let queue_it = |reply: ReplyEmpty| match self.core.queue_trash(&uid, name) {
|
let queue_it = |reply: ReplyEmpty| match self.core.queue_trash(&uid, name) {
|
||||||
Ok(()) => reply.ok(),
|
Ok(()) => reply.ok(),
|
||||||
Err(e) => reply.error(e),
|
Err(e) => reply.error(e),
|
||||||
};
|
};
|
||||||
if !self.core.is_online() {
|
if !self.core.sends_inline() {
|
||||||
queue_it(reply);
|
queue_it(reply);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2176,7 +2233,7 @@ impl ProtonFs {
|
|||||||
self.core.invalidate_trash();
|
self.core.invalidate_trash();
|
||||||
// Every other trash site records itself; this one did not, which made a
|
// Every other trash site records itself; this one did not, which made a
|
||||||
// file found in the trash impossible to attribute after the fact — the
|
// file found in the trash impossible to attribute after the fact — the
|
||||||
// activity log was the only record and it showed nothing (bugs.md B2).
|
// activity log was the only record and it showed nothing (`docs/BUGS.md` B2).
|
||||||
self.core
|
self.core
|
||||||
.log_activity(ActivityKind::Trash, name, "trashed from the mount", true);
|
.log_activity(ActivityKind::Trash, name, "trashed from the mount", true);
|
||||||
reply.ok();
|
reply.ok();
|
||||||
|
|||||||
+768
-164
File diff suppressed because it is too large.
Load diff
@@ -86,13 +86,32 @@ pub(crate) fn no_answer(after: Duration) -> ProtonError {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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`].
|
/// Run a remote call, giving up after [`INTERACTIVE_CALL_TIMEOUT`].
|
||||||
pub(crate) async fn bounded<T>(
|
pub(crate) async fn bounded<T>(
|
||||||
call: impl Future<Output = Result<T, ProtonError>>,
|
call: impl Future<Output = Result<T, ProtonError>>,
|
||||||
) -> Result<T, ProtonError> {
|
) -> Result<T, ProtonError> {
|
||||||
match tokio::time::timeout(INTERACTIVE_CALL_TIMEOUT, call).await {
|
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,
|
Ok(result) => result,
|
||||||
Err(_) => Err(no_answer(INTERACTIVE_CALL_TIMEOUT)),
|
Err(_) => Err(no_answer(limit)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +132,15 @@ impl Core {
|
|||||||
self.online.load(Ordering::Relaxed)
|
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
|
/// Run a remote call a caller is waiting on, giving up after
|
||||||
/// [`INTERACTIVE_CALL_TIMEOUT`]. The timer is built inside the runtime: a
|
/// [`INTERACTIVE_CALL_TIMEOUT`]. The timer is built inside the runtime: a
|
||||||
/// FUSE worker has no reactor of its own.
|
/// FUSE worker has no reactor of its own.
|
||||||
@@ -123,6 +151,16 @@ impl Core {
|
|||||||
self.rt.block_on(bounded(call))
|
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
|
/// Report a failed remote call. Returns whether it failed for want of a
|
||||||
/// network, in which case the mount is now offline and the caller should
|
/// network, in which case the mount is now offline and the caller should
|
||||||
/// take its queued path instead of failing.
|
/// take its queued path instead of failing.
|
||||||
@@ -210,7 +248,7 @@ impl Core {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Watch the link for the life of the mount: sleep while it is up, and
|
/// Watch the link for the life of the mount: sleep while it is up, and
|
||||||
/// probe it back while it is down (offline.md Phase 1).
|
/// probe it back while it is down.
|
||||||
///
|
///
|
||||||
/// A mount that started from the cache is down from the first moment; one
|
/// A mount that started from the cache is down from the first moment; one
|
||||||
/// that started online goes down when [`Core::mark_offline`] says so.
|
/// that started online goes down when [`Core::mark_offline`] says so.
|
||||||
@@ -301,6 +339,26 @@ mod tests {
|
|||||||
assert!(!is_network_error(&ProtonError::invalid_operation("bug")));
|
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]
|
#[test]
|
||||||
fn a_young_outage_is_probed_often_and_an_old_one_rarely() {
|
fn a_young_outage_is_probed_often_and_an_old_one_rarely() {
|
||||||
let young = Duration::from_secs(60);
|
let young = Duration::from_secs(60);
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -244,6 +244,9 @@ pub struct MountOptions {
|
|||||||
/// Upload and download caps in bytes per second (`0` = no cap).
|
/// Upload and download caps in bytes per second (`0` = no cap).
|
||||||
pub upload_limit: u64,
|
pub upload_limit: u64,
|
||||||
pub download_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.
|
||||||
@@ -385,6 +388,7 @@ pub(crate) fn mount_with(
|
|||||||
sweep_mode,
|
sweep_mode,
|
||||||
upload_limit,
|
upload_limit,
|
||||||
download_limit,
|
download_limit,
|
||||||
|
local_first,
|
||||||
} = options;
|
} = options;
|
||||||
// Start the uptime clock here rather than at the first `pdfs diagnostics`,
|
// Start the uptime clock here rather than at the first `pdfs diagnostics`,
|
||||||
// so the age it reports is the daemon's own.
|
// so the age it reports is the daemon's own.
|
||||||
@@ -397,7 +401,7 @@ pub(crate) fn mount_with(
|
|||||||
.all_share_access()
|
.all_share_access()
|
||||||
.map_err(|error| std::io::Error::other(format!("load shared access: {error}")))?;
|
.map_err(|error| std::io::Error::other(format!("load shared access: {error}")))?;
|
||||||
|
|
||||||
// The folder-sync engine (devices.md Phase 2) runs on its own thread and is
|
// The folder-sync engine runs on its own thread and is
|
||||||
// nudged over this channel; the sender lives in Core so control-socket
|
// nudged over this channel; the sender lives in Core so control-socket
|
||||||
// handlers can trigger reconciles.
|
// handlers can trigger reconciles.
|
||||||
let (sync_tx, sync_rx) = std::sync::mpsc::channel::<sync::SyncMsg>();
|
let (sync_tx, sync_rx) = std::sync::mpsc::channel::<sync::SyncMsg>();
|
||||||
@@ -429,6 +433,10 @@ pub(crate) fn mount_with(
|
|||||||
sync_paused_until: Arc::new(AtomicI64::new(paused_until)),
|
sync_paused_until: Arc::new(AtomicI64::new(paused_until)),
|
||||||
pending: Arc::new(Mutex::new(HashMap::new())),
|
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||||
hidden: Arc::new(Mutex::new(HashSet::new())),
|
hidden: Arc::new(Mutex::new(HashSet::new())),
|
||||||
|
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())),
|
||||||
@@ -442,6 +450,7 @@ pub(crate) fn mount_with(
|
|||||||
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,
|
||||||
|
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(
|
||||||
@@ -466,6 +475,7 @@ pub(crate) fn mount_with(
|
|||||||
sync_tx,
|
sync_tx,
|
||||||
mounts: Arc::new(Mutex::new(SecondaryMountRegistry::default())),
|
mounts: Arc::new(Mutex::new(SecondaryMountRegistry::default())),
|
||||||
sync_locks: Arc::new(Mutex::new(HashMap::new())),
|
sync_locks: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
sync_unscanned: Arc::new(Mutex::new(HashMap::new())),
|
||||||
states: Arc::new(StateRegistry::default()),
|
states: Arc::new(StateRegistry::default()),
|
||||||
};
|
};
|
||||||
// Before anything can queue work against it: the drain thread below reaches
|
// Before anything can queue work against it: the drain thread below reaches
|
||||||
@@ -492,7 +502,7 @@ pub(crate) fn mount_with(
|
|||||||
// Every long-lived thread started from here on is kept in `workers` and
|
// Every long-lived thread started from here on is kept in `workers` and
|
||||||
// joined at the end of this function. Leaving them running turned an
|
// joined at the end of this function. Leaving them running turned an
|
||||||
// in-process remount into a second generation of drain workers, sync engines
|
// in-process remount into a second generation of drain workers, sync engines
|
||||||
// and sweeps all operating on a mount that no longer exists (bugs.md B44).
|
// and sweeps all operating on a mount that no longer exists (`docs/BUGS.md` B44).
|
||||||
let mut workers: Vec<std::thread::JoinHandle<()>> = Vec::new();
|
let mut workers: Vec<std::thread::JoinHandle<()>> = Vec::new();
|
||||||
for worker in 0..DRAIN_WORKERS {
|
for worker in 0..DRAIN_WORKERS {
|
||||||
let core = core.clone();
|
let core = core.clone();
|
||||||
@@ -670,7 +680,9 @@ pub(crate) fn mount_with(
|
|||||||
// The photos volume has its own event stream; without it the gallery only
|
// The photos volume has its own event stream; without it the gallery only
|
||||||
// learns about a phone-side deletion when the timeline goes stale.
|
// learns about a phone-side deletion when the timeline goes stale.
|
||||||
rt.spawn(run_photos_event_sync(core.clone()));
|
rt.spawn(run_photos_event_sync(core.clone()));
|
||||||
if online {
|
// 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()
|
||||||
@@ -727,7 +739,7 @@ pub(crate) fn mount_with(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Unmount every on-demand sync folder too, or the kernel mounts linger as
|
// Unmount every on-demand sync folder too, or the kernel mounts linger as
|
||||||
// stale and the next start fails with EBUSY (devices.md Phase 3).
|
// stale and the next start fails with EBUSY.
|
||||||
if let Some(worker) = restore_worker
|
if let Some(worker) = restore_worker
|
||||||
&& worker.join().is_err()
|
&& worker.join().is_err()
|
||||||
{
|
{
|
||||||
@@ -739,6 +751,7 @@ pub(crate) fn mount_with(
|
|||||||
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.
|
||||||
@@ -775,9 +788,10 @@ fn signal_stop(rt: &tokio::runtime::Handle) -> std::sync::mpsc::Receiver<()> {
|
|||||||
sig_rx
|
sig_rx
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How long teardown waits for the FUSE worker pool before proceeding without
|
/// How long teardown waits for the background workers and then the FUSE worker
|
||||||
/// it. Long enough for an in-flight block to finish, short enough that
|
/// pool before proceeding without them. Long enough for an in-flight block to
|
||||||
/// `systemctl --user stop` still returns inside `TimeoutStopSec`.
|
/// finish, short enough that `systemctl --user stop` still returns inside
|
||||||
|
/// `TimeoutStopSec`.
|
||||||
const WORKER_JOIN_DEADLINE: Duration = Duration::from_secs(10);
|
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.
|
||||||
@@ -785,14 +799,15 @@ const WORKER_JOIN_DEADLINE: Duration = Duration::from_secs(10);
|
|||||||
/// The mount used to return with its drain workers, sync engine, sweep, online
|
/// The mount used to return with its drain workers, sync engine, sweep, online
|
||||||
/// probe, indexer and control listener all still running on `Core` clones. The
|
/// probe, indexer and control listener all still running on `Core` clones. The
|
||||||
/// process usually exited straight afterwards and hid it; an in-process remount
|
/// process usually exited straight afterwards and hid it; an in-process remount
|
||||||
/// did not, and started a second full set on top of the first (bugs.md B44).
|
/// did not, and started a second full set on top of the first (`docs/BUGS.md` B44).
|
||||||
///
|
///
|
||||||
/// Two of the waits need waking rather than merely flagging: the drain workers
|
/// Two of the waits need waking rather than merely flagging: the drain workers
|
||||||
/// block on their own condvar (`wake_drain`), and the control listener blocks
|
/// block on their own condvar (`wake_drain`), and the control listener blocks
|
||||||
/// 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
|
// 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.
|
// a missed watchdog ping and turned into a kill.
|
||||||
@@ -806,25 +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);
|
||||||
for worker in workers {
|
let deadline = Instant::now() + WORKER_JOIN_DEADLINE;
|
||||||
if worker.join().is_err() {
|
let running = join_until(workers, deadline);
|
||||||
warn!("a background worker panicked before shutdown");
|
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
|
// 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
|
// 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
|
// 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
|
// shutdown" (seen 2026-09-18 09:58:35). Bounded, so a worker wedged on the
|
||||||
// network cannot hold the stop the user asked for.
|
// network cannot hold the stop the user asked for.
|
||||||
core.workers.stop_and_join(WORKER_JOIN_DEADLINE);
|
core.workers
|
||||||
|
.stop_and_join(deadline.saturating_duration_since(Instant::now()));
|
||||||
debug!("background workers stopped");
|
debug!("background workers stopped");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Join `workers` until `deadline`, and name the ones still running then.
|
||||||
|
///
|
||||||
|
/// A stop used to wait for each one however long it took. The sync engine's
|
||||||
|
/// mirror pass waits on Drive with no deadline, and a stop that waited for it
|
||||||
|
/// was killed by systemd (`docs/BUGS.md` B165).
|
||||||
|
fn join_until(workers: Vec<std::thread::JoinHandle<()>>, deadline: Instant) -> Vec<String> {
|
||||||
|
let mut running = Vec::new();
|
||||||
|
for worker in workers {
|
||||||
|
while !worker.is_finished() && Instant::now() < deadline {
|
||||||
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
if !worker.is_finished() {
|
||||||
|
running.push(worker.thread().name().unwrap_or("unnamed").to_string());
|
||||||
|
} else if worker.join().is_err() {
|
||||||
|
warn!("a background worker panicked before shutdown");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
running
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[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() {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
//! Reading and writing this machine's `profile.json` in its device root, and
|
//! Reading and writing this machine's `profile.json` in its device root, and
|
||||||
//! the restore that rebuilds a machine from one (features.md 5.2 and 5.3).
|
//! the restore that rebuilds a machine from one.
|
||||||
//!
|
//!
|
||||||
//! The document itself is defined in [`pdfs_core::profile`]; this is the half
|
//! The document itself is defined in [`pdfs_core::profile`]; this is the half
|
||||||
//! that talks to Drive. Saving is best-effort and never blocks a user action:
|
//! that talks to Drive. Saving is best-effort and never blocks a user action:
|
||||||
@@ -246,7 +246,7 @@ impl Core {
|
|||||||
Profile::parse(&buf).map(Some).map_err(CoreError::invalid)
|
Profile::parse(&buf).map(Some).map_err(CoreError::invalid)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- restore (features.md 5.2) ----------------------------------------
|
// ---- restore ----------------------------------------
|
||||||
|
|
||||||
/// The folders under a device that could be synced here, each carrying a
|
/// The folders under a device that could be synced here, each carrying a
|
||||||
/// proposed local path. `device` names another registered device by uid;
|
/// proposed local path. `device` names another registered device by uid;
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
//! The user's view of the pending-op queue: what has not reached the remote
|
//! 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.
|
//! 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.
|
//! Read-only apart from "retry now", which only moves a row's next-attempt time,
|
||||||
//! Nothing here drops a queued op: its staged blob is the only copy of the
|
//! "export", which copies a staged blob out, and "discard". Only a discard the
|
||||||
//! user's write (see `docs/RECOVERY.md`).
|
//! 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 super::*;
|
||||||
use pdfs_core::control::PendingOpInfo;
|
use std::io::Write;
|
||||||
|
|
||||||
|
use pdfs_core::control::{PendingOpInfo, SyncIssue};
|
||||||
use pdfs_core::db::{FAILING_ATTEMPTS, PARK_UNTIL};
|
use pdfs_core::db::{FAILING_ATTEMPTS, PARK_UNTIL};
|
||||||
|
|
||||||
impl Core {
|
impl Core {
|
||||||
@@ -16,10 +20,15 @@ impl Core {
|
|||||||
.db
|
.db
|
||||||
.pending_ops()
|
.pending_ops()
|
||||||
.map_err(|e| CoreError::internal(format!("listing the queue: {e}")))?;
|
.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
|
Ok(ops
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|op| {
|
.map(|op| {
|
||||||
let parked = op.next_attempt_at >= PARK_UNTIL;
|
let parked = op.next_attempt_at >= PARK_UNTIL;
|
||||||
|
let exportable = exportable(&op).is_some();
|
||||||
let path = self
|
let path = self
|
||||||
.db
|
.db
|
||||||
.node_path(&op.uid)
|
.node_path(&op.uid)
|
||||||
@@ -37,7 +46,9 @@ impl Core {
|
|||||||
queued_at: op.created_at / 1000,
|
queued_at: op.created_at / 1000,
|
||||||
next_attempt_at: (!parked).then_some(op.next_attempt_at / 1000),
|
next_attempt_at: (!parked).then_some(op.next_attempt_at / 1000),
|
||||||
parked,
|
parked,
|
||||||
failing: op.attempts >= FAILING_ATTEMPTS,
|
failing: op.attempts >= FAILING_ATTEMPTS || issues.contains_key(&op.id),
|
||||||
|
issue: issues.get(&op.id).map(|issue| SyncIssue::parse(issue)),
|
||||||
|
exportable,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
@@ -57,4 +68,235 @@ impl Core {
|
|||||||
}
|
}
|
||||||
Ok(moved)
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -464,7 +464,7 @@ impl Core {
|
|||||||
/// it, and the [`BLOCK_SIZE`] assumption when it has not — which is every
|
/// it, and the [`BLOCK_SIZE`] assumption when it has not — which is every
|
||||||
/// first read, because learning it means opening a `RevisionReader`, and
|
/// first read, because learning it means opening a `RevisionReader`, and
|
||||||
/// opening one per read is precisely the per-file key derivation the block
|
/// opening one per read is precisely the per-file key derivation the block
|
||||||
/// cache exists to avoid (bugs.md B12, B85).
|
/// cache exists to avoid (`docs/BUGS.md` B12, B85).
|
||||||
///
|
///
|
||||||
/// Being wrong here is not a correctness problem: `read_at` plans over the
|
/// Being wrong here is not a correctness problem: `read_at` plans over the
|
||||||
/// real sizes and clamps to what was asked for either way. It costs a
|
/// real sizes and clamps to what was asked for either way. It costs a
|
||||||
@@ -493,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)
|
||||||
}
|
}
|
||||||
@@ -631,7 +636,7 @@ impl Core {
|
|||||||
///
|
///
|
||||||
/// A block that comes back shorter than the file's size says it must be is
|
/// A block that comes back shorter than the file's size says it must be is
|
||||||
/// never served, cached or promoted: it is repaired from the whole-file
|
/// never served, cached or promoted: it is repaired from the whole-file
|
||||||
/// download, or the read fails. See [`Core::repair_block`] (bugs.md B84).
|
/// download, or the read fails. See [`Core::repair_block`] (`docs/BUGS.md` B84).
|
||||||
async fn read_block(
|
async fn read_block(
|
||||||
&self,
|
&self,
|
||||||
reader: &Arc<dyn RevisionRead>,
|
reader: &Arc<dyn RevisionRead>,
|
||||||
@@ -646,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,
|
||||||
@@ -659,7 +664,7 @@ impl Core {
|
|||||||
// Learned here rather than on a path of its own: this is where a
|
// Learned here rather than on a path of its own: this is where a
|
||||||
// reader is open anyway, and recording the real geometry is what
|
// reader is open anyway, and recording the real geometry is what
|
||||||
// lets the *next* read plan its blocks on the revision's own
|
// lets the *next* read plan its blocks on the revision's own
|
||||||
// boundaries instead of the 4 MiB fallback (bugs.md B85).
|
// boundaries instead of the 4 MiB fallback (`docs/BUGS.md` B85).
|
||||||
if cache_blocks {
|
if cache_blocks {
|
||||||
self.cache
|
self.cache
|
||||||
.store_block_geometry(uid, mtime, fsize, reader.block_sizes());
|
.store_block_geometry(uid, mtime, fsize, reader.block_sizes());
|
||||||
@@ -701,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
|
||||||
@@ -913,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);
|
||||||
@@ -1033,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,
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ fn shared_entry_of(core: &Core, n: Node) -> DirEntry {
|
|||||||
shared_by_unverified,
|
shared_by_unverified,
|
||||||
trashed_at: 0,
|
trashed_at: 0,
|
||||||
trashed_from: None,
|
trashed_from: None,
|
||||||
|
issue: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,4 @@
|
|||||||
//! An in-memory Drive for the simulation tests (`docs/MILESTONE-3.0.0.md` §8.1).
|
//! An in-memory Drive for the simulation tests (`docs/TESTING.md`).
|
||||||
//!
|
//!
|
||||||
//! [`FakeDrive`] is the server: one tree of nodes with uids, revisions and
|
//! [`FakeDrive`] is the server: one tree of nodes with uids, revisions and
|
||||||
//! names, and an event log. Each simulated daemon reaches it through its own
|
//! names, and an event log. Each simulated daemon reaches it through its own
|
||||||
@@ -33,7 +33,13 @@
|
|||||||
//!
|
//!
|
||||||
//! A test can also hold the next read of one node on its way to Drive
|
//! A test can also hold the next read of one node on its way to Drive
|
||||||
//! ([`FakeClient::hold_next_read`]), to make a change while the daemon waits
|
//! ([`FakeClient::hold_next_read`]), to make a change while the daemon waits
|
||||||
//! for the answer.
|
//! for the answer, or on its way back ([`FakeClient::hold_answer_to_next_read`]),
|
||||||
|
//! so the daemon gets an answer a change made meanwhile has overtaken. It can
|
||||||
|
//! also have a new file's next reads answered as Drive lists it before its
|
||||||
|
//! create is committed ([`FakeClient::answer_reads_unrevised`]; B163), and
|
||||||
|
//! hold a folder's listing on its way back with one of its files listed that
|
||||||
|
//! way ([`FakeClient::hold_answer_to_next_listing`]), or hold a new file's
|
||||||
|
//! create on its way to Drive ([`FakeClient::hold_next_create`]).
|
||||||
|
|
||||||
use std::collections::{BTreeMap, HashMap};
|
use std::collections::{BTreeMap, HashMap};
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
@@ -330,6 +336,18 @@ impl FakeDrive {
|
|||||||
online: AtomicBool::new(true),
|
online: AtomicBool::new(true),
|
||||||
requests: AtomicU64::new(0),
|
requests: AtomicU64::new(0),
|
||||||
hold: Mutex::new(None),
|
hold: Mutex::new(None),
|
||||||
|
hold_answer: Mutex::new(None),
|
||||||
|
hold_trash: Mutex::new(None),
|
||||||
|
hold_landing: Mutex::new(None),
|
||||||
|
drop_after_create: AtomicBool::new(false),
|
||||||
|
lose_create_reply: AtomicBool::new(false),
|
||||||
|
hold_create_reply: Mutex::new(None),
|
||||||
|
hold_create: Mutex::new(None),
|
||||||
|
lose_revision_reply: AtomicBool::new(false),
|
||||||
|
lose_revision_read_back: AtomicBool::new(false),
|
||||||
|
lose_read: Mutex::new(None),
|
||||||
|
unrevised_reads: Mutex::new(None),
|
||||||
|
hold_listing: Mutex::new(None),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,6 +408,23 @@ impl FakeDrive {
|
|||||||
tree
|
tree
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every name a node has had in the root, trashed or not, as far back as
|
||||||
|
/// Drive keeps its versions.
|
||||||
|
pub(crate) fn names_ever_in_root(&self) -> Vec<String> {
|
||||||
|
let server = self.server.lock();
|
||||||
|
let mut names: Vec<String> = server
|
||||||
|
.nodes
|
||||||
|
.values()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|(_, remote)| remote.as_ref())
|
||||||
|
.filter(|remote| remote.parent.as_ref() == Some(&server.root))
|
||||||
|
.map(|remote| remote.name.clone())
|
||||||
|
.collect();
|
||||||
|
names.sort();
|
||||||
|
names.dedup();
|
||||||
|
names
|
||||||
|
}
|
||||||
|
|
||||||
/// The uid at `path` below the root, as Drive has it now.
|
/// The uid at `path` below the root, as Drive has it now.
|
||||||
pub(crate) fn lookup(&self, path: &str) -> Option<NodeUid> {
|
pub(crate) fn lookup(&self, path: &str) -> Option<NodeUid> {
|
||||||
let server = self.server.lock();
|
let server = self.server.lock();
|
||||||
@@ -577,6 +612,32 @@ struct ClientInner {
|
|||||||
requests: AtomicU64,
|
requests: AtomicU64,
|
||||||
/// The read [`FakeClient::hold_next_read`] is waiting for.
|
/// The read [`FakeClient::hold_next_read`] is waiting for.
|
||||||
hold: Mutex<Option<Arc<HoldState>>>,
|
hold: Mutex<Option<Arc<HoldState>>>,
|
||||||
|
/// The read [`FakeClient::hold_answer_to_next_read`] is waiting for.
|
||||||
|
hold_answer: Mutex<Option<Arc<HoldState>>>,
|
||||||
|
/// The trash [`FakeClient::hold_next_trash`] is waiting for.
|
||||||
|
hold_trash: Mutex<Option<Arc<HoldState>>>,
|
||||||
|
/// The landing [`FakeClient::hold_next_landing`] is waiting for.
|
||||||
|
hold_landing: Mutex<Option<Arc<HoldState>>>,
|
||||||
|
/// Set by [`FakeClient::drop_link_after_next_create`].
|
||||||
|
drop_after_create: AtomicBool,
|
||||||
|
/// Set by [`FakeClient::lose_reply_to_next_create`].
|
||||||
|
lose_create_reply: AtomicBool,
|
||||||
|
/// The create [`FakeClient::hold_reply_to_next_create`] is waiting for.
|
||||||
|
hold_create_reply: Mutex<Option<Arc<HoldState>>>,
|
||||||
|
/// The create [`FakeClient::hold_next_create`] is waiting for.
|
||||||
|
hold_create: Mutex<Option<Arc<HoldState>>>,
|
||||||
|
/// Set by [`FakeClient::lose_reply_to_next_revision`].
|
||||||
|
lose_revision_reply: AtomicBool,
|
||||||
|
/// Set by [`FakeClient::lose_read_back_of_next_revision`].
|
||||||
|
lose_revision_read_back: AtomicBool,
|
||||||
|
/// The file whose next read goes unanswered, once its revision is made.
|
||||||
|
lose_read: Mutex<Option<NodeUid>>,
|
||||||
|
/// Set by [`FakeClient::answer_reads_unrevised`]: the file and how many
|
||||||
|
/// more of its reads are answered without an active revision.
|
||||||
|
unrevised_reads: Mutex<Option<(NodeUid, usize)>>,
|
||||||
|
/// The listing [`FakeClient::hold_answer_to_next_listing`] is waiting
|
||||||
|
/// for, and the file it lists without an active revision.
|
||||||
|
hold_listing: Mutex<Option<(Arc<HoldState>, NodeUid)>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct HoldState {
|
struct HoldState {
|
||||||
@@ -585,8 +646,37 @@ struct HoldState {
|
|||||||
released: AtomicBool,
|
released: AtomicBool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A read held on its way to Drive, from [`FakeClient::hold_next_read`]. It
|
/// Wait while `state` holds a call.
|
||||||
/// goes on when this is dropped.
|
/// `node` as Drive lists a file whose create it has not committed yet: no
|
||||||
|
/// active revision, no size.
|
||||||
|
fn unrevise(node: &mut Node) {
|
||||||
|
if let NodeKind::File {
|
||||||
|
total_size_on_storage,
|
||||||
|
active_revision_state,
|
||||||
|
active_revision_id,
|
||||||
|
claimed_size,
|
||||||
|
content_sha1,
|
||||||
|
..
|
||||||
|
} = &mut node.kind
|
||||||
|
{
|
||||||
|
*total_size_on_storage = 0;
|
||||||
|
*active_revision_state = None;
|
||||||
|
*active_revision_id = None;
|
||||||
|
*claimed_size = None;
|
||||||
|
*content_sha1 = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_until_released(state: &HoldState) {
|
||||||
|
state.reached.store(true, Ordering::SeqCst);
|
||||||
|
while !state.released.load(Ordering::SeqCst) {
|
||||||
|
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A call held on its way to or from Drive, from [`FakeClient::hold_next_read`]
|
||||||
|
/// or [`FakeClient::hold_reply_to_next_create`]. It goes on when this is
|
||||||
|
/// dropped.
|
||||||
pub(crate) struct Held(Arc<HoldState>);
|
pub(crate) struct Held(Arc<HoldState>);
|
||||||
|
|
||||||
impl Held {
|
impl Held {
|
||||||
@@ -616,6 +706,18 @@ impl FakeClient {
|
|||||||
*self.0.faults.lock() = faults;
|
*self.0.faults.lock() = faults;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Take the link down as soon as the next file create has been answered,
|
||||||
|
/// so whatever the daemon asks next goes unanswered.
|
||||||
|
pub(crate) fn drop_link_after_next_create(&self) {
|
||||||
|
self.0.drop_after_create.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make the next file create on Drive and lose its answer, as a link that
|
||||||
|
/// drops it on the way back does.
|
||||||
|
pub(crate) fn lose_reply_to_next_create(&self) {
|
||||||
|
self.0.lose_create_reply.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
|
||||||
/// Hold the next read of `uid` alone, before it reaches Drive, until the
|
/// Hold the next read of `uid` alone, before it reaches Drive, until the
|
||||||
/// answer is dropped. Only one read is held at a time.
|
/// answer is dropped. Only one read is held at a time.
|
||||||
pub(crate) fn hold_next_read(&self, uid: &NodeUid) -> Held {
|
pub(crate) fn hold_next_read(&self, uid: &NodeUid) -> Held {
|
||||||
@@ -628,20 +730,115 @@ impl FakeClient {
|
|||||||
Held(state)
|
Held(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wait here if `uids` is the read a [`Held`] is for.
|
/// Answer the next read of `uid` alone with what Drive holds now, then
|
||||||
async fn wait_if_held(&self, uids: &[NodeUid]) {
|
/// hold the answer until it is dropped, as a slow link does. Only one
|
||||||
|
/// answer is held at a time.
|
||||||
|
pub(crate) fn hold_answer_to_next_read(&self, uid: &NodeUid) -> Held {
|
||||||
|
let state = Arc::new(HoldState {
|
||||||
|
uid: uid.clone(),
|
||||||
|
reached: AtomicBool::new(false),
|
||||||
|
released: AtomicBool::new(false),
|
||||||
|
});
|
||||||
|
*self.0.hold_answer.lock() = Some(state.clone());
|
||||||
|
Held(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hold the next trash of `uid` alone, before it reaches Drive, until the
|
||||||
|
/// answer is dropped.
|
||||||
|
pub(crate) fn hold_next_trash(&self, uid: &NodeUid) -> Held {
|
||||||
|
let state = Arc::new(HoldState {
|
||||||
|
uid: uid.clone(),
|
||||||
|
reached: AtomicBool::new(false),
|
||||||
|
released: AtomicBool::new(false),
|
||||||
|
});
|
||||||
|
*self.0.hold_trash.lock() = Some(state.clone());
|
||||||
|
Held(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hold the drain once the next upload to `uid` has landed and its blob
|
||||||
|
/// is let go of, before the tree has the node (`DriveApi::landing`), until
|
||||||
|
/// the answer is dropped.
|
||||||
|
pub(crate) fn hold_next_landing(&self, uid: &NodeUid) -> Held {
|
||||||
|
let state = Arc::new(HoldState {
|
||||||
|
uid: uid.clone(),
|
||||||
|
reached: AtomicBool::new(false),
|
||||||
|
released: AtomicBool::new(false),
|
||||||
|
});
|
||||||
|
*self.0.hold_landing.lock() = Some(state.clone());
|
||||||
|
Held(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make the next file create on Drive, then hold its answer until the
|
||||||
|
/// answer is dropped, as a slow link does.
|
||||||
|
pub(crate) fn hold_reply_to_next_create(&self) -> Held {
|
||||||
|
let state = Arc::new(HoldState {
|
||||||
|
uid: self.0.drive.root(),
|
||||||
|
reached: AtomicBool::new(false),
|
||||||
|
released: AtomicBool::new(false),
|
||||||
|
});
|
||||||
|
*self.0.hold_create_reply.lock() = Some(state.clone());
|
||||||
|
Held(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hold the next file create before it reaches Drive, until the answer
|
||||||
|
/// is dropped.
|
||||||
|
pub(crate) fn hold_next_create(&self) -> Held {
|
||||||
|
let state = Arc::new(HoldState {
|
||||||
|
uid: self.0.drive.root(),
|
||||||
|
reached: AtomicBool::new(false),
|
||||||
|
released: AtomicBool::new(false),
|
||||||
|
});
|
||||||
|
*self.0.hold_create.lock() = Some(state.clone());
|
||||||
|
Held(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make the next revision of a file on Drive and lose its answer.
|
||||||
|
pub(crate) fn lose_reply_to_next_revision(&self) {
|
||||||
|
self.0.lose_revision_reply.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make the next revision of a file on Drive and answer it, then lose the
|
||||||
|
/// answer to the next read of that file alone, as the read-back after an
|
||||||
|
/// upload meets on a link that drops it.
|
||||||
|
pub(crate) fn lose_read_back_of_next_revision(&self) {
|
||||||
|
self.0.lose_revision_read_back.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Answer the next `reads` reads of file `uid` alone that find it as Drive
|
||||||
|
/// lists a file whose create it has not committed yet: no active revision,
|
||||||
|
/// no size.
|
||||||
|
pub(crate) fn answer_reads_unrevised(&self, uid: &NodeUid, reads: usize) {
|
||||||
|
*self.0.unrevised_reads.lock() = Some((uid.clone(), reads));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Answer the next listing of `folder`'s nodes with what Drive holds now,
|
||||||
|
/// `unrevised` in it as Drive lists a file whose create it has not
|
||||||
|
/// committed yet, then hold the answer until it is dropped.
|
||||||
|
pub(crate) fn hold_answer_to_next_listing(
|
||||||
|
&self,
|
||||||
|
folder: &NodeUid,
|
||||||
|
unrevised: &NodeUid,
|
||||||
|
) -> Held {
|
||||||
|
let state = Arc::new(HoldState {
|
||||||
|
uid: folder.clone(),
|
||||||
|
reached: AtomicBool::new(false),
|
||||||
|
released: AtomicBool::new(false),
|
||||||
|
});
|
||||||
|
*self.0.hold_listing.lock() = Some((state.clone(), unrevised.clone()));
|
||||||
|
Held(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait here if `uids` is the read a [`Held`] in `slot` is for.
|
||||||
|
async fn wait_if_held(&self, slot: &Mutex<Option<Arc<HoldState>>>, uids: &[NodeUid]) {
|
||||||
let state = {
|
let state = {
|
||||||
let mut hold = self.0.hold.lock();
|
let mut hold = slot.lock();
|
||||||
match hold.as_ref() {
|
match hold.as_ref() {
|
||||||
Some(state) if uids == std::slice::from_ref(&state.uid) => hold.take(),
|
Some(state) if uids == std::slice::from_ref(&state.uid) => hold.take(),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let Some(state) = state else { return };
|
let Some(state) = state else { return };
|
||||||
state.reached.store(true, Ordering::SeqCst);
|
wait_until_released(&state).await;
|
||||||
while !state.released.load(Ordering::SeqCst) {
|
|
||||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Requests made through this client so far.
|
/// Requests made through this client so far.
|
||||||
@@ -868,7 +1065,17 @@ impl DriveApi for FakeClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn enumerate_nodes(&self, uids: &[NodeUid]) -> Result<Vec<Node>> {
|
async fn enumerate_nodes(&self, uids: &[NodeUid]) -> Result<Vec<Node>> {
|
||||||
self.wait_if_held(uids).await;
|
self.wait_if_held(&self.0.hold, uids).await;
|
||||||
|
{
|
||||||
|
let mut lose = self.0.lose_read.lock();
|
||||||
|
if lose
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|uid| uids == std::slice::from_ref(uid))
|
||||||
|
{
|
||||||
|
*lose = None;
|
||||||
|
return Err(no_answer());
|
||||||
|
}
|
||||||
|
}
|
||||||
let lag = self.lag();
|
let lag = self.lag();
|
||||||
let nodes: Vec<Node> = self
|
let nodes: Vec<Node> = self
|
||||||
.request(false, |server| {
|
.request(false, |server| {
|
||||||
@@ -879,20 +1086,49 @@ impl DriveApi for FakeClient {
|
|||||||
.collect())
|
.collect())
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
self.wait_if_held(&self.0.hold_answer, uids).await;
|
||||||
|
let mut nodes = nodes;
|
||||||
|
{
|
||||||
|
let mut unrevised = self.0.unrevised_reads.lock();
|
||||||
|
if let Some((uid, left)) = unrevised.as_mut()
|
||||||
|
&& uids == std::slice::from_ref(uid)
|
||||||
|
&& !nodes.is_empty()
|
||||||
|
{
|
||||||
|
*left -= 1;
|
||||||
|
if *left == 0 {
|
||||||
|
*unrevised = None;
|
||||||
|
}
|
||||||
|
nodes.iter_mut().for_each(unrevise);
|
||||||
|
}
|
||||||
|
}
|
||||||
self.cache(&nodes);
|
self.cache(&nodes);
|
||||||
Ok(nodes)
|
Ok(nodes)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn enumerate_nodes_light(&self, uids: &[NodeUid]) -> Result<Vec<Node>> {
|
async fn enumerate_nodes_light(&self, uids: &[NodeUid]) -> Result<Vec<Node>> {
|
||||||
let lag = self.lag();
|
let lag = self.lag();
|
||||||
self.request(false, |server| {
|
let mut nodes: Vec<Node> = self
|
||||||
let now = Instant::now();
|
.request(false, |server| {
|
||||||
Ok(uids
|
let now = Instant::now();
|
||||||
|
Ok(uids
|
||||||
|
.iter()
|
||||||
|
.filter_map(|uid| Some(to_node(uid, &server.visible(uid, lag, now)?, true)))
|
||||||
|
.collect())
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
let held = self.0.hold_listing.lock().take_if(|(state, _)| {
|
||||||
|
nodes
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|uid| Some(to_node(uid, &server.visible(uid, lag, now)?, true)))
|
.any(|node| node.parent_uid.as_ref() == Some(&state.uid))
|
||||||
.collect())
|
});
|
||||||
})
|
if let Some((state, unrevised)) = held {
|
||||||
.await
|
nodes
|
||||||
|
.iter_mut()
|
||||||
|
.filter(|node| node.uid == unrevised)
|
||||||
|
.for_each(unrevise);
|
||||||
|
wait_until_released(&state).await;
|
||||||
|
}
|
||||||
|
Ok(nodes)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn enumerate_folder_children_node_uids(
|
async fn enumerate_folder_children_node_uids(
|
||||||
@@ -1038,16 +1274,34 @@ impl DriveApi for FakeClient {
|
|||||||
_aead: bool,
|
_aead: bool,
|
||||||
) -> Result<NodeUid> {
|
) -> Result<NodeUid> {
|
||||||
let content = read_all(reader)?;
|
let content = read_all(reader)?;
|
||||||
self.request(true, |server| {
|
let held = self.0.hold_create.lock().take();
|
||||||
server.new_file(
|
if let Some(state) = held {
|
||||||
parent_uid,
|
wait_until_released(&state).await;
|
||||||
name,
|
}
|
||||||
media_type,
|
let made = self
|
||||||
content,
|
.request(true, |server| {
|
||||||
last_modification_time,
|
server.new_file(
|
||||||
)
|
parent_uid,
|
||||||
})
|
name,
|
||||||
.await
|
media_type,
|
||||||
|
content,
|
||||||
|
last_modification_time,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
if made.is_ok() && self.0.drop_after_create.swap(false, Ordering::SeqCst) {
|
||||||
|
self.set_online(false);
|
||||||
|
}
|
||||||
|
let held = self.0.hold_create_reply.lock().take();
|
||||||
|
if made.is_ok()
|
||||||
|
&& let Some(state) = held
|
||||||
|
{
|
||||||
|
wait_until_released(&state).await;
|
||||||
|
}
|
||||||
|
if made.is_ok() && self.0.lose_create_reply.swap(false, Ordering::SeqCst) {
|
||||||
|
return Err(no_answer());
|
||||||
|
}
|
||||||
|
made
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn upload_file_replacing_draft_from_dyn(
|
async fn upload_file_replacing_draft_from_dyn(
|
||||||
@@ -1103,7 +1357,14 @@ impl DriveApi for FakeClient {
|
|||||||
server.put(file_uid, Some(remote));
|
server.put(file_uid, Some(remote));
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.await
|
.await?;
|
||||||
|
if self.0.lose_revision_reply.swap(false, Ordering::SeqCst) {
|
||||||
|
return Err(no_answer());
|
||||||
|
}
|
||||||
|
if self.0.lose_revision_read_back.swap(false, Ordering::SeqCst) {
|
||||||
|
*self.0.lose_read.lock() = Some(file_uid.clone());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn open_revision(&self, uid: &NodeUid) -> Result<Arc<dyn RevisionRead>> {
|
async fn open_revision(&self, uid: &NodeUid) -> Result<Arc<dyn RevisionRead>> {
|
||||||
@@ -1204,6 +1465,7 @@ impl DriveApi for FakeClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn trash_nodes(&self, uids: &[NodeUid]) -> Result<NodeOutcomes> {
|
async fn trash_nodes(&self, uids: &[NodeUid]) -> Result<NodeOutcomes> {
|
||||||
|
self.wait_if_held(&self.0.hold_trash, uids).await;
|
||||||
self.batch(uids, trash_one).await
|
self.batch(uids, trash_one).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1219,6 +1481,21 @@ impl DriveApi for FakeClient {
|
|||||||
outcome_stream(self.batch(uids, delete_one))
|
outcome_stream(self.batch(uids, delete_one))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn landing(&self, uid: &NodeUid) {
|
||||||
|
let state = {
|
||||||
|
let mut hold = self.0.hold_landing.lock();
|
||||||
|
match hold.as_ref() {
|
||||||
|
Some(state) if state.uid == *uid => hold.take(),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let Some(state) = state else { return };
|
||||||
|
state.reached.store(true, Ordering::SeqCst);
|
||||||
|
while !state.released.load(Ordering::SeqCst) {
|
||||||
|
std::thread::sleep(Duration::from_millis(5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn quota(&self) -> Result<Quota> {
|
async fn quota(&self) -> Result<Quota> {
|
||||||
self.request(false, |server| {
|
self.request(false, |server| {
|
||||||
Ok(Quota {
|
Ok(Quota {
|
||||||
@@ -1554,6 +1831,29 @@ mod tests {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_held_answer_is_what_drive_held_when_read() {
|
||||||
|
rt().block_on(async {
|
||||||
|
let drive = FakeDrive::new();
|
||||||
|
let client = drive.client(1, Faults::none());
|
||||||
|
let root = drive.root();
|
||||||
|
let file = client.upload_file(&root, "a", "", b"1").await.unwrap();
|
||||||
|
let held = client.hold_answer_to_next_read(&file);
|
||||||
|
let read = tokio::spawn({
|
||||||
|
let client = client.clone();
|
||||||
|
let file = file.clone();
|
||||||
|
async move { client.enumerate_nodes(std::slice::from_ref(&file)).await }
|
||||||
|
});
|
||||||
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||||
|
assert!(held.reached());
|
||||||
|
assert!(!read.is_finished());
|
||||||
|
drive.device().trash("a");
|
||||||
|
drop(held);
|
||||||
|
let nodes = read.await.unwrap().unwrap();
|
||||||
|
assert!(!nodes[0].trashed);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_rename_lags_only_by_the_update_lag() {
|
fn a_rename_lags_only_by_the_update_lag() {
|
||||||
rt().block_on(async {
|
rt().block_on(async {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! The simulation tests of `docs/MILESTONE-3.0.0.md` §8: a fake Drive with
|
//! The simulation tests of `docs/TESTING.md`: a fake Drive with
|
||||||
//! the faults the real one has, and the tools to drive daemons against it.
|
//! the faults the real one has, and the tools to drive daemons against it.
|
||||||
|
|
||||||
pub(crate) mod daemon;
|
pub(crate) mod daemon;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
//! The reference file system of the simulation (`docs/MILESTONE-3.0.0.md`
|
//! The reference file system of the simulation (`docs/TESTING.md`,
|
||||||
//! §8.2, property 4): what each syscall a simulated client makes should
|
//! "Simulation runs", property 1): what each syscall a simulated client makes should
|
||||||
//! answer, and the tree it should leave, on a POSIX file system.
|
//! answer, and the tree it should leave, on a POSIX file system.
|
||||||
//!
|
//!
|
||||||
//! A [`Model`] covers one client's own folder; paths are relative to it. The
|
//! A [`Model`] covers one client's own folder; paths are relative to it. The
|
||||||
|
|||||||
+49
-403
@@ -1,4 +1,4 @@
|
|||||||
//! The simulation runs of `docs/MILESTONE-3.0.0.md` §8.2.
|
//! The simulation runs described in `docs/TESTING.md`.
|
||||||
//!
|
//!
|
||||||
//! A run starts one or more daemons on a shared [`FakeDrive`], each owning a
|
//! A run starts one or more daemons on a shared [`FakeDrive`], each owning a
|
||||||
//! folder `c<n>` that only it writes, so there is one writer per file. A seed
|
//! folder `c<n>` that only it writes, so there is one writer per file. A seed
|
||||||
@@ -22,14 +22,9 @@
|
|||||||
//! one wrote down, but in-flight work is let finish rather than cut. A kill
|
//! 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.
|
//! at an arbitrary point needs the daemon in a process of its own.
|
||||||
//!
|
//!
|
||||||
//! A call that answers otherwise than the model says fails the run, unless it
|
//! Any of these fails the seed. No open bug is tolerated: the matchers that
|
||||||
//! is one of the [`KNOWN`] open bugs. Those are counted instead and the model
|
//! counted the ones still open instead (B125 to B134) went once a run on
|
||||||
//! takes the call back, or the seed ends there when the bug leaves the mount
|
//! 812eb3d passed every profile without them.
|
||||||
//! where the model cannot follow, so the runs stay a regression net until
|
|
||||||
//! they are fixed. A file gone, or a conflict copy the drain made of one of
|
|
||||||
//! the client's own, is counted too when a known bug explains it
|
|
||||||
//! (`Run::known_damage`), and so is a hang the stacks show is one of the
|
|
||||||
//! [`KNOWN_HANGS`]. `PDFS_SIM_KNOWN=fail` fails on all of them.
|
|
||||||
//!
|
//!
|
||||||
//! The runs are ignored by default because they mount FUSE; run them with
|
//! 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`.
|
//! `cargo test -p pdfs-fuse --lib sim::run -- --ignored --test-threads=1`.
|
||||||
@@ -42,105 +37,11 @@ use std::io::{self, Read, Seek, SeekFrom, Write};
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
|
|
||||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
|
||||||
|
|
||||||
use super::daemon::{Daemon, scratch, take_logged, wait_until};
|
use super::daemon::{Daemon, scratch, take_logged, wait_until};
|
||||||
use super::fake_drive::{Entry, FakeDrive, Faults};
|
use super::fake_drive::{Entry, FakeDrive, Faults};
|
||||||
use super::model::{FsOp, Model, Tree, data, join, name_of, parent_of};
|
use super::model::{FsOp, Model, Tree, data, join};
|
||||||
use super::rng::Rng;
|
use super::rng::Rng;
|
||||||
use super::watchdog::{Hung, Watchdog};
|
use super::watchdog::Watchdog;
|
||||||
|
|
||||||
/// An open bug in `docs/BUGS.md` the runs tolerate.
|
|
||||||
struct Known {
|
|
||||||
bug: &'static str,
|
|
||||||
/// Whether a call that answered `errno` against the model's word, with
|
|
||||||
/// lines `logged` at `INFO` and above meanwhile and `before` it, is this
|
|
||||||
/// bug. `model` is from before the call.
|
|
||||||
is: fn(model: &Model, op: &FsOp, errno: i32, logged: &[String], before: &[String]) -> bool,
|
|
||||||
/// Whether it also left the mount where the model cannot follow, so the
|
|
||||||
/// seed ends there.
|
|
||||||
ends_seed: fn(model: &Model, op: &FsOp) -> bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
const KNOWN: &[Known] = &[
|
|
||||||
// A replacing rename trashes what it replaces first, so Drive refuses the
|
|
||||||
// name only when it holds a node the mount has forgotten: the create of
|
|
||||||
// the replaced file landed after its op was dropped.
|
|
||||||
Known {
|
|
||||||
bug: "B129",
|
|
||||||
is: |model, op, errno, logged, _| {
|
|
||||||
let FsOp::Rename { to, .. } = op else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
errno == libc::EIO
|
|
||||||
&& model.tree().contains_key(to)
|
|
||||||
&& logged
|
|
||||||
.iter()
|
|
||||||
.any(|line| line.starts_with("rename failed") && line.contains("AlreadyExists"))
|
|
||||||
},
|
|
||||||
// The model made the rename, and Drive holds a node the mount does
|
|
||||||
// not know.
|
|
||||||
ends_seed: |_, _| true,
|
|
||||||
},
|
|
||||||
// A folder made offline is listed on Drive once its listing has been
|
|
||||||
// invalidated, and Drive does not know it, or does not list it yet. Only
|
|
||||||
// the client writes its folder, so a folder of its own is never really
|
|
||||||
// gone.
|
|
||||||
Known {
|
|
||||||
bug: "B125",
|
|
||||||
is: |_, _, errno, logged, _| {
|
|
||||||
matches!(errno, libc::EIO | libc::ENOENT)
|
|
||||||
&& logged.iter().any(|line| {
|
|
||||||
line.starts_with("enumerate folder children failed")
|
|
||||||
&& line.contains("DoesNotExist")
|
|
||||||
})
|
|
||||||
},
|
|
||||||
// The folder keeps failing to list, and what is in it can come back
|
|
||||||
// as gone, so the mount no longer follows the model.
|
|
||||||
ends_seed: |_, _| true,
|
|
||||||
},
|
|
||||||
// A restart forgets the listings served offline, so the first lookup
|
|
||||||
// lists a folder on Drive, which does not hold what is still queued for
|
|
||||||
// it.
|
|
||||||
Known {
|
|
||||||
bug: "B132",
|
|
||||||
is: |_, _, errno, _, before| {
|
|
||||||
errno == libc::ENOENT
|
|
||||||
&& before
|
|
||||||
.iter()
|
|
||||||
.any(|line| line.starts_with("lost the connection to Proton"))
|
|
||||||
&& before
|
|
||||||
.iter()
|
|
||||||
.any(|line| line.starts_with("restored pending ops"))
|
|
||||||
},
|
|
||||||
// The listing stays without it until something relists the folder.
|
|
||||||
ends_seed: |_, _| true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
/// An open bug in `docs/BUGS.md` that hangs a call, which the runs tolerate.
|
|
||||||
/// The seed ends there, since the watchdog aborted the mounts.
|
|
||||||
struct KnownHang {
|
|
||||||
bug: &'static str,
|
|
||||||
/// Whether the stacks the watchdog kept show this bug.
|
|
||||||
is: fn(stacks: &str) -> bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
const KNOWN_HANGS: &[KnownHang] = &[
|
|
||||||
// A runtime worker invalidating a file's pages waits for the page lock
|
|
||||||
// of a read, whose fetch waits for a timer that no worker drives.
|
|
||||||
KnownHang {
|
|
||||||
bug: "B130",
|
|
||||||
is: |stacks| {
|
|
||||||
stacks.split("\nthread ").any(|thread| {
|
|
||||||
thread.contains("\"tokio-rt-worker\", state D")
|
|
||||||
&& thread.contains("NotifyBatch>::flush")
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
/// What a run looks like.
|
/// What a run looks like.
|
||||||
pub(crate) struct Profile {
|
pub(crate) struct Profile {
|
||||||
@@ -178,16 +79,6 @@ const LOG_TAIL: usize = 40;
|
|||||||
/// for the last run and the mount.
|
/// for the last run and the mount.
|
||||||
const STALL: Duration = Duration::from_secs(120);
|
const STALL: Duration = Duration::from_secs(120);
|
||||||
|
|
||||||
/// What a known bug left on Drive in place of a file, and how the model
|
|
||||||
/// takes it in.
|
|
||||||
struct KnownDamage {
|
|
||||||
bug: &'static str,
|
|
||||||
path: String,
|
|
||||||
/// The conflict copy it made, if it made one.
|
|
||||||
copy: Option<String>,
|
|
||||||
change: FsOp,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Client {
|
struct Client {
|
||||||
daemon: Option<Daemon>,
|
daemon: Option<Daemon>,
|
||||||
model: Model,
|
model: Model,
|
||||||
@@ -213,25 +104,14 @@ struct Run {
|
|||||||
clients: Vec<Client>,
|
clients: Vec<Client>,
|
||||||
log: Vec<String>,
|
log: Vec<String>,
|
||||||
started: Instant,
|
started: Instant,
|
||||||
/// How often each [`KNOWN`] bug turned up.
|
|
||||||
known: BTreeMap<&'static str, usize>,
|
|
||||||
/// The known bug that ended the seed early, if one did.
|
|
||||||
ended_by: Option<&'static str>,
|
|
||||||
/// What the daemons logged at `INFO` and above so far.
|
/// What the daemons logged at `INFO` and above so far.
|
||||||
logged: Vec<String>,
|
logged: Vec<String>,
|
||||||
/// Files deleted while the drain held a write to them.
|
|
||||||
held: Vec<NodeUid>,
|
|
||||||
/// Those of them made again before it went on.
|
|
||||||
remade: Vec<NodeUid>,
|
|
||||||
watchdog: Watchdog,
|
watchdog: Watchdog,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run `profile` on `seed`. It answers how often each known bug turned up,
|
/// Run `profile` on `seed`. It answers a report of what went wrong, if
|
||||||
/// or a report of what went wrong.
|
/// anything did.
|
||||||
pub(crate) fn run(
|
pub(crate) fn run(seed: u64, profile: &'static Profile) -> Result<(), String> {
|
||||||
seed: u64,
|
|
||||||
profile: &'static Profile,
|
|
||||||
) -> Result<BTreeMap<&'static str, usize>, String> {
|
|
||||||
let dir = scratch(&format!("{}-{seed}", profile.name));
|
let dir = scratch(&format!("{}-{seed}", profile.name));
|
||||||
let mut rng = Rng::new(seed);
|
let mut rng = Rng::new(seed);
|
||||||
let drive = FakeDrive::new();
|
let drive = FakeDrive::new();
|
||||||
@@ -242,18 +122,14 @@ pub(crate) fn run(
|
|||||||
clients: Vec::new(),
|
clients: Vec::new(),
|
||||||
log: Vec::new(),
|
log: Vec::new(),
|
||||||
started: Instant::now(),
|
started: Instant::now(),
|
||||||
known: BTreeMap::new(),
|
|
||||||
ended_by: None,
|
|
||||||
logged: Vec::new(),
|
logged: Vec::new(),
|
||||||
held: Vec::new(),
|
|
||||||
remade: Vec::new(),
|
|
||||||
watchdog: Watchdog::new(&dir),
|
watchdog: Watchdog::new(&dir),
|
||||||
};
|
};
|
||||||
let outcome = run.execute(&dir, &mut rng);
|
let outcome = run.execute(&dir, &mut rng);
|
||||||
run.watchdog.stop();
|
run.watchdog.stop();
|
||||||
// A hang is what failed, whatever the aborted call answered.
|
// A hang is what failed, whatever the aborted call answered.
|
||||||
let outcome = match run.watchdog.fired() {
|
let outcome = match run.watchdog.fired() {
|
||||||
Some(hung) => run.known_hang(&hung),
|
Some(hung) => Err(format!("liveness: {}", hung.what)),
|
||||||
None => outcome,
|
None => outcome,
|
||||||
};
|
};
|
||||||
for client in &mut run.clients {
|
for client in &mut run.clients {
|
||||||
@@ -264,27 +140,13 @@ pub(crate) fn run(
|
|||||||
match outcome {
|
match outcome {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
Ok(run.known)
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(failure) => Err(run.report(&failure, &dir)),
|
Err(failure) => Err(run.report(&failure, &dir)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Run {
|
impl Run {
|
||||||
/// Count `hung` if it is one of the [`KNOWN_HANGS`], or fail on it.
|
|
||||||
fn known_hang(&mut self, hung: &Hung) -> Result<(), String> {
|
|
||||||
let known = KNOWN_HANGS.iter().find(|known| (known.is)(&hung.stacks));
|
|
||||||
match known {
|
|
||||||
Some(known) if tolerate_known() => {
|
|
||||||
*self.known.entry(known.bug).or_default() += 1;
|
|
||||||
self.note(format!("known bug {}; the seed ends here", known.bug));
|
|
||||||
self.ended_by = Some(known.bug);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
_ => Err(format!("liveness: {}", hung.what)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn execute(&mut self, dir: &Path, rng: &mut Rng) -> Result<(), String> {
|
fn execute(&mut self, dir: &Path, rng: &mut Rng) -> Result<(), String> {
|
||||||
for index in 0..self.profile.clients {
|
for index in 0..self.profile.clients {
|
||||||
let client = self.drive.client(rng.next_u64(), (self.profile.faults)());
|
let client = self.drive.client(rng.next_u64(), (self.profile.faults)());
|
||||||
@@ -302,9 +164,6 @@ impl Run {
|
|||||||
for step in 0..self.profile.steps {
|
for step in 0..self.profile.steps {
|
||||||
let _watching = self.watchdog.arm(self.step_limit(), format!("step {step}"));
|
let _watching = self.watchdog.arm(self.step_limit(), format!("step {step}"));
|
||||||
self.step(rng)?;
|
self.step(rng)?;
|
||||||
if self.ended_by.is_some() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
let _watching = self.watchdog.arm(
|
let _watching = self.watchdog.arm(
|
||||||
self.profile.drain_budget + STALL,
|
self.profile.drain_budget + STALL,
|
||||||
@@ -363,7 +222,11 @@ impl Run {
|
|||||||
fn settle(&mut self, budget: Duration) -> Result<(), String> {
|
fn settle(&mut self, budget: Duration) -> Result<(), String> {
|
||||||
self.note("settle".into());
|
self.note("settle".into());
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
if !wait_until(budget, || self.landed()) {
|
// 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!(
|
return Err(format!(
|
||||||
"liveness: the queues did not drain within {budget:?}\n{}",
|
"liveness: the queues did not drain within {budget:?}\n{}",
|
||||||
self.difference()
|
self.difference()
|
||||||
@@ -409,18 +272,11 @@ impl Run {
|
|||||||
data: bytes,
|
data: bytes,
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
if self.ended_by.is_some() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
if !wait_until(PICKUP_BUDGET, || held.reached()) {
|
if !wait_until(PICKUP_BUDGET, || held.reached()) {
|
||||||
self.note(format!("client {index}: nothing read {path} back"));
|
self.note(format!("client {index}: nothing read {path} back"));
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
self.syscall(index, FsOp::Unlink { path: path.clone() })?;
|
self.syscall(index, FsOp::Unlink { path: path.clone() })?;
|
||||||
if self.ended_by.is_some() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
self.held.push(uid.clone());
|
|
||||||
if rng.chance(0.5) {
|
if rng.chance(0.5) {
|
||||||
self.syscall(
|
self.syscall(
|
||||||
index,
|
index,
|
||||||
@@ -429,7 +285,6 @@ impl Run {
|
|||||||
data: data(rng),
|
data: data(rng),
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
self.remade.push(uid);
|
|
||||||
}
|
}
|
||||||
let faults = (self.profile.faults)();
|
let faults = (self.profile.faults)();
|
||||||
let latency = Duration::from_millis(faults.latency_ms.1);
|
let latency = Duration::from_millis(faults.latency_ms.1);
|
||||||
@@ -444,11 +299,9 @@ impl Run {
|
|||||||
fn syscall(&mut self, index: usize, op: FsOp) -> Result<(), String> {
|
fn syscall(&mut self, index: usize, op: FsOp) -> Result<(), String> {
|
||||||
let client = &mut self.clients[index];
|
let client = &mut self.clients[index];
|
||||||
let root = client.root(index);
|
let root = client.root(index);
|
||||||
let model = client.model.clone();
|
|
||||||
let before = client.model.content(target(&op)).cloned();
|
let before = client.model.content(target(&op)).cloned();
|
||||||
let expected = client.model.apply(&op);
|
let expected = client.model.apply(&op);
|
||||||
self.logged.extend(take_logged());
|
self.logged.extend(take_logged());
|
||||||
let logged_before = self.logged.len();
|
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
let actual = perform(&root, &op, before.as_deref());
|
let actual = perform(&root, &op, before.as_deref());
|
||||||
let took = start.elapsed();
|
let took = start.elapsed();
|
||||||
@@ -458,25 +311,7 @@ impl Run {
|
|||||||
outcome(&actual)
|
outcome(&actual)
|
||||||
));
|
));
|
||||||
let actual = actual?;
|
let actual = actual?;
|
||||||
let logged = take_logged();
|
self.logged.extend(take_logged());
|
||||||
self.logged.extend(logged.iter().cloned());
|
|
||||||
if actual != expected
|
|
||||||
&& let Err(errno) = actual
|
|
||||||
&& tolerate_known()
|
|
||||||
&& let Some(known) = KNOWN.iter().find(|known| {
|
|
||||||
(known.is)(&model, &op, errno, &logged, &self.logged[..logged_before])
|
|
||||||
})
|
|
||||||
{
|
|
||||||
*self.known.entry(known.bug).or_default() += 1;
|
|
||||||
if (known.ends_seed)(&model, &op) {
|
|
||||||
self.note(format!("known bug {}; the seed ends here", known.bug));
|
|
||||||
self.ended_by = Some(known.bug);
|
|
||||||
} else {
|
|
||||||
self.note(format!("known bug {}; taken back", known.bug));
|
|
||||||
}
|
|
||||||
self.clients[index].model = model;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
if actual != expected {
|
if actual != expected {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"posix: client {index}: {} answered {}, expected {}",
|
"posix: client {index}: {} answered {}, expected {}",
|
||||||
@@ -513,61 +348,15 @@ impl Run {
|
|||||||
}
|
}
|
||||||
// Each client's folder is its own, so a file of it gone from Drive was
|
// Each client's folder is its own, so a file of it gone from Drive was
|
||||||
// deleted by the client.
|
// deleted by the client.
|
||||||
let own_deletes: Vec<String> = self
|
if let Some(line) = self.logged.iter().find(|line| is_own_delete_kept(line)) {
|
||||||
.logged
|
return Err(format!(
|
||||||
.iter()
|
"no false conflicts: the drain took a client's own delete for another device's: {line}"
|
||||||
.filter(|line| is_own_delete_kept(line))
|
));
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
for line in own_deletes {
|
|
||||||
let bug = if self
|
|
||||||
.remade
|
|
||||||
.iter()
|
|
||||||
.any(|uid| line.contains(&format!(" uid={uid} ")))
|
|
||||||
{
|
|
||||||
// Made again while the drain held its write: the new file's
|
|
||||||
// write took the freed op id.
|
|
||||||
"B133"
|
|
||||||
} else if !self
|
|
||||||
.held
|
|
||||||
.iter()
|
|
||||||
.any(|uid| line.contains(&format!(" uid={uid} ")))
|
|
||||||
&& !line.contains(" name=\"recovered-")
|
|
||||||
{
|
|
||||||
// The tree still placed the file, so the drain looked before
|
|
||||||
// the unlink had dropped its write.
|
|
||||||
"B134"
|
|
||||||
} else {
|
|
||||||
return Err(format!(
|
|
||||||
"no false conflicts: the drain took a client's own delete for another device's: {line}"
|
|
||||||
));
|
|
||||||
};
|
|
||||||
if !tolerate_known() {
|
|
||||||
return Err(format!(
|
|
||||||
"no false conflicts: the drain took a client's own delete for another device's: {line}"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
*self.known.entry(bug).or_default() += 1;
|
|
||||||
self.note(format!("known bug {bug}: {line}"));
|
|
||||||
}
|
}
|
||||||
let drive = self.drive_tree();
|
let drive = self.drive_tree();
|
||||||
let mut explained = Vec::new();
|
|
||||||
for index in 0..self.clients.len() {
|
|
||||||
for damage in self.known_damage(index, &drive) {
|
|
||||||
*self.known.entry(damage.bug).or_default() += 1;
|
|
||||||
self.note(format!(
|
|
||||||
"known bug {}: client {index}'s {} landed as {:?}",
|
|
||||||
damage.bug, damage.path, damage.copy
|
|
||||||
));
|
|
||||||
let _ = self.clients[index].model.apply(&damage.change);
|
|
||||||
if let Some(copy) = damage.copy {
|
|
||||||
explained.push(join(&owned_folder(index), ©));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let conflicts: Vec<String> = drive
|
let conflicts: Vec<String> = drive
|
||||||
.into_keys()
|
.into_keys()
|
||||||
.filter(|path| is_conflict_copy(path) && !explained.contains(path))
|
.filter(|path| is_conflict_copy(path))
|
||||||
.collect();
|
.collect();
|
||||||
if !conflicts.is_empty() {
|
if !conflicts.is_empty() {
|
||||||
return Err(format!("no false conflicts: {conflicts:?}"));
|
return Err(format!("no false conflicts: {conflicts:?}"));
|
||||||
@@ -589,155 +378,18 @@ impl Run {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Whether every client's queue is empty and Drive holds what its model
|
/// Whether every client's queue is empty and Drive holds what its model
|
||||||
/// says, but for what known bugs explain.
|
/// says.
|
||||||
fn landed(&self) -> bool {
|
fn landed(&self) -> bool {
|
||||||
let drive = self.drive_tree();
|
let drive = self.drive_tree();
|
||||||
self.clients.iter().enumerate().all(|(index, client)| {
|
self.clients.iter().enumerate().all(|(index, client)| {
|
||||||
let mut model = client.model.clone();
|
|
||||||
for damage in self.known_damage(index, &drive) {
|
|
||||||
let _ = model.apply(&damage.change);
|
|
||||||
}
|
|
||||||
client
|
client
|
||||||
.daemon()
|
.daemon()
|
||||||
.pending()
|
.pending()
|
||||||
.is_ok_and(|items| items.is_empty())
|
.is_ok_and(|items| items.is_empty())
|
||||||
&& subtree(&drive, &owned_folder(index)) == *model.tree()
|
&& subtree(&drive, &owned_folder(index)) == *client.model.tree()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The files of client `index` that a known bug left otherwise on Drive
|
|
||||||
/// than the model says. A conflict copy takes Drive holding it with the
|
|
||||||
/// model's bytes, and the drain saying it made that copy for that name:
|
|
||||||
///
|
|
||||||
/// - B126: the file's queued create ran into its name while a queued
|
|
||||||
/// rename was about to free it, so it is there only as the copy.
|
|
||||||
/// - B127: the file's queued create landed but its answer was lost, so
|
|
||||||
/// the retry found it and made the copy next to it.
|
|
||||||
///
|
|
||||||
/// A lost file takes the drain saying it adopted a node for that name
|
|
||||||
/// whose queued trash then landed:
|
|
||||||
///
|
|
||||||
/// - B128: the file's queued create ran into the name of the file it
|
|
||||||
/// replaced and took that over, trash and all.
|
|
||||||
///
|
|
||||||
/// A conflict copy of a file the client deleted takes the drain saying it
|
|
||||||
/// kept that file's write as the copy, because Drive had the file in the
|
|
||||||
/// trash while the tree still placed it:
|
|
||||||
///
|
|
||||||
/// - B134: the unlink trashed the file on Drive before it dropped the
|
|
||||||
/// write.
|
|
||||||
fn known_damage(&self, index: usize, drive: &Tree) -> Vec<KnownDamage> {
|
|
||||||
if !tolerate_known() {
|
|
||||||
return Vec::new();
|
|
||||||
}
|
|
||||||
let drive = subtree(drive, &owned_folder(index));
|
|
||||||
let model = self.clients[index].model.tree();
|
|
||||||
let made_copy = |path: &str, copy: &str| {
|
|
||||||
let wanted = format!("wanted={:?}", name_of(path));
|
|
||||||
let name = format!("name={:?}", name_of(copy));
|
|
||||||
self.logged.iter().any(|line| {
|
|
||||||
line.starts_with("name is taken remotely; creating under a conflict name")
|
|
||||||
&& line.contains(&wanted)
|
|
||||||
&& line.contains(&name)
|
|
||||||
})
|
|
||||||
};
|
|
||||||
let adopted_trashed = |path: &str| {
|
|
||||||
let wanted = format!("wanted={:?}", name_of(path));
|
|
||||||
self.logged.iter().any(|line| {
|
|
||||||
line.starts_with("the name is held by our own unanswered create; adopting it")
|
|
||||||
&& line.contains(&wanted)
|
|
||||||
&& line.split(' ').any(|field| {
|
|
||||||
field.strip_prefix("twin=").is_some_and(|twin| {
|
|
||||||
let uid = format!("uid={twin}");
|
|
||||||
self.logged.iter().any(|line| {
|
|
||||||
line.starts_with("pending trash landed")
|
|
||||||
&& line.split(' ').any(|field| field == uid)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
};
|
|
||||||
let kept_deleted = |copy: &str| {
|
|
||||||
let alt = format!(" alt={:?}", name_of(copy));
|
|
||||||
self.logged.iter().any(|line| {
|
|
||||||
line.starts_with("queued write landed as a conflict copy")
|
|
||||||
&& line.ends_with(&alt)
|
|
||||||
&& line.split(' ').any(|field| {
|
|
||||||
field.starts_with("uid=")
|
|
||||||
&& self.logged.iter().any(|kept| {
|
|
||||||
is_own_delete_kept(kept)
|
|
||||||
&& !kept.contains(" name=\"recovered-")
|
|
||||||
&& kept.split(' ').any(|other| other == field)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
};
|
|
||||||
let mut damage = Vec::new();
|
|
||||||
for (copy, content) in &drive {
|
|
||||||
let Some(content) = content else { continue };
|
|
||||||
if !is_conflict_copy(copy) || model.contains_key(copy) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if !kept_deleted(copy) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
damage.push(KnownDamage {
|
|
||||||
bug: "B134",
|
|
||||||
path: copy.clone(),
|
|
||||||
copy: Some(copy.clone()),
|
|
||||||
change: FsOp::Create {
|
|
||||||
path: copy.clone(),
|
|
||||||
data: content.clone(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
for (path, content) in model {
|
|
||||||
let Some(content) = content else { continue };
|
|
||||||
let copy = drive.iter().find(|(copy, other)| {
|
|
||||||
is_conflict_copy(copy)
|
|
||||||
&& !model.contains_key(*copy)
|
|
||||||
&& parent_of(copy) == parent_of(path)
|
|
||||||
&& other.as_ref() == Some(content)
|
|
||||||
&& made_copy(path, copy)
|
|
||||||
});
|
|
||||||
let Some((copy, _)) = copy else {
|
|
||||||
if !drive.contains_key(path) && adopted_trashed(path) {
|
|
||||||
damage.push(KnownDamage {
|
|
||||||
bug: "B128",
|
|
||||||
path: path.clone(),
|
|
||||||
copy: None,
|
|
||||||
change: FsOp::Unlink { path: path.clone() },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let (bug, change) = match drive.get(path) {
|
|
||||||
None => (
|
|
||||||
"B126",
|
|
||||||
FsOp::Rename {
|
|
||||||
from: path.clone(),
|
|
||||||
to: copy.clone(),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
Some(Some(landed)) if landed == content => (
|
|
||||||
"B127",
|
|
||||||
FsOp::Create {
|
|
||||||
path: copy.clone(),
|
|
||||||
data: content.clone(),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
Some(_) => continue,
|
|
||||||
};
|
|
||||||
damage.push(KnownDamage {
|
|
||||||
bug,
|
|
||||||
path: path.clone(),
|
|
||||||
copy: Some(copy.clone()),
|
|
||||||
change,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
damage
|
|
||||||
}
|
|
||||||
|
|
||||||
fn difference(&self) -> String {
|
fn difference(&self) -> String {
|
||||||
let drive = self.drive_tree();
|
let drive = self.drive_tree();
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
@@ -890,11 +542,6 @@ fn subtree(tree: &Tree, folder: &str) -> Tree {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a known bug is counted rather than failing the run.
|
|
||||||
fn tolerate_known() -> bool {
|
|
||||||
std::env::var("PDFS_SIM_KNOWN").as_deref() != Ok("fail")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn owned_folder(index: usize) -> String {
|
fn owned_folder(index: usize) -> String {
|
||||||
format!("c{index}")
|
format!("c{index}")
|
||||||
}
|
}
|
||||||
@@ -970,21 +617,10 @@ pub(crate) fn run_seeds(profile: &'static Profile, default_seeds: u64) {
|
|||||||
(1..=count).collect()
|
(1..=count).collect()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let mut failures = Vec::new();
|
let failures: Vec<String> = seeds
|
||||||
let mut known = BTreeMap::<&str, usize>::new();
|
.into_iter()
|
||||||
for seed in seeds {
|
.filter_map(|seed| run(seed, profile).err())
|
||||||
match run(seed, profile) {
|
.collect();
|
||||||
Ok(hits) => {
|
|
||||||
for (bug, count) in hits {
|
|
||||||
*known.entry(bug).or_default() += count;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(failure) => failures.push(failure),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !known.is_empty() {
|
|
||||||
eprintln!("{}: known bugs turned up: {known:?}", profile.name);
|
|
||||||
}
|
|
||||||
assert!(failures.is_empty(), "\n{}", failures.join("\n\n"));
|
assert!(failures.is_empty(), "\n{}", failures.join("\n\n"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1044,6 +680,21 @@ mod tests {
|
|||||||
drain_budget: Duration::from_secs(120),
|
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 {
|
static THREE_CLIENTS: Profile = Profile {
|
||||||
name: "three-clients",
|
name: "three-clients",
|
||||||
clients: 3,
|
clients: 3,
|
||||||
@@ -1081,6 +732,12 @@ mod tests {
|
|||||||
run_seeds(&ONE_CLIENT_FLAKY, 4);
|
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]
|
#[test]
|
||||||
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse --lib sim::run -- --ignored`"]
|
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse --lib sim::run -- --ignored`"]
|
||||||
fn three_clients_one_writer_each() {
|
fn three_clients_one_writer_each() {
|
||||||
@@ -1106,15 +763,4 @@ mod tests {
|
|||||||
&kept.replace("the file was trashed remotely", "revision changed")
|
&kept.replace("the file was trashed remotely", "revision changed")
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn an_invalidation_stuck_on_a_page_lock_is_b130() {
|
|
||||||
let b130 = |stacks: &str| KNOWN_HANGS.iter().any(|known| (known.is)(stacks));
|
|
||||||
let stuck = "thread 1 \"fuser-0\", state S, waiting in fuse_dev_do_read\n\
|
|
||||||
\nthread 2 \"tokio-rt-worker\", state D, waiting in __folio_lock\n\
|
|
||||||
\x20 11: <pdfs_fuse::NotifyBatch>::flush\n";
|
|
||||||
assert!(b130(stuck));
|
|
||||||
assert!(!b130(&stuck.replace("state D", "state S")));
|
|
||||||
assert!(!b130(&stuck.replace("NotifyBatch", "Other")));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -183,7 +183,7 @@ impl WriteHandle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A released write whose upload has not happened yet (offline.md Phase 3).
|
/// A released write whose upload has not happened yet.
|
||||||
///
|
///
|
||||||
/// The bytes live in the content cache's staging dir and the intent lives in the
|
/// The bytes live in the content cache's staging dir and the intent lives in the
|
||||||
/// `pending_op` table; this pairs them in memory so a read can be served without
|
/// `pending_op` table; this pairs them in memory so a read can be served without
|
||||||
@@ -212,6 +212,12 @@ pub(crate) fn lid_ino(lid: i64) -> u64 {
|
|||||||
lid.unsigned_abs() + crate::ROOT_INO
|
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`.
|
||||||
///
|
///
|
||||||
@@ -247,7 +253,7 @@ pub(crate) struct State {
|
|||||||
/// two pages shifted every later entry, and the caller silently skipped or
|
/// two pages shifted every later entry, and the caller silently skipped or
|
||||||
/// repeated whatever crossed the page boundary. Each entry is
|
/// repeated whatever crossed the page boundary. Each entry is
|
||||||
/// `(ino, is_dir, name)` — the kernel type is mapped at reply time so this
|
/// `(ino, is_dir, name)` — the kernel type is mapped at reply time so this
|
||||||
/// module stays free of `fuser` (bugs.md B43).
|
/// module stays free of `fuser` (`docs/BUGS.md` B43).
|
||||||
pub(crate) dir_snapshots: HashMap<u64, Arc<DirListing>>,
|
pub(crate) dir_snapshots: HashMap<u64, Arc<DirListing>>,
|
||||||
/// Resident inode attributes whose effective access changed during an
|
/// Resident inode attributes whose effective access changed during an
|
||||||
/// intern/root refresh. Core drains this set and notifies the matching
|
/// intern/root refresh. Core drains this set and notifies the matching
|
||||||
@@ -878,6 +884,11 @@ impl State {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
pub(crate) fn intern(&mut self, parent: u64, mut node: Node) -> u64 {
|
||||||
self.keep_revision_mtime(&mut node);
|
self.keep_revision_mtime(&mut node);
|
||||||
self.outbox.push(DbWrite::Upsert(vec![node.clone()]));
|
self.outbox.push(DbWrite::Upsert(vec![node.clone()]));
|
||||||
@@ -1187,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,
|
||||||
@@ -1306,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
|
||||||
@@ -1325,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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+595
-121
File diff suppressed because it is too large.
Load diff
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ impl LocalSig {
|
|||||||
/// Nanoseconds decide it when both sides have them; otherwise the comparison
|
/// Nanoseconds decide it when both sides have them; otherwise the comparison
|
||||||
/// falls back to whole seconds, which is what a baseline row written before
|
/// falls back to whole seconds, which is what a baseline row written before
|
||||||
/// schema 28 — or a filesystem that reports no sub-second time — can support
|
/// schema 28 — or a filesystem that reports no sub-second time — can support
|
||||||
/// (bugs.md B25).
|
/// (`docs/BUGS.md` B25).
|
||||||
pub(super) fn same_content(&self, other: &Self) -> bool {
|
pub(super) fn same_content(&self, other: &Self) -> bool {
|
||||||
if self.size != other.size {
|
if self.size != other.size {
|
||||||
return false;
|
return false;
|
||||||
@@ -98,6 +98,11 @@ pub(super) struct Outcome {
|
|||||||
pub(super) errors: usize,
|
pub(super) errors: usize,
|
||||||
/// Files skipped because another process held them open for writing.
|
/// Files skipped because another process held them open for writing.
|
||||||
pub(super) deferred: usize,
|
pub(super) deferred: usize,
|
||||||
|
/// Paths the local scan could not read, left out of the pass. Not part of
|
||||||
|
/// [`is_empty`](Self::is_empty) or the summary: an unreadable folder stays
|
||||||
|
/// unreadable from one poll to the next, so the pass reports it once, when
|
||||||
|
/// the set changes, rather than on every poll.
|
||||||
|
pub(super) unscanned: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Outcome {
|
impl Outcome {
|
||||||
@@ -161,7 +166,7 @@ pub(super) enum Pending {
|
|||||||
/// was there. A download can run for minutes, so the apply step revalidates
|
/// was there. A download can run for minutes, so the apply step revalidates
|
||||||
/// against it immediately before it renames over the destination: a writer
|
/// against it immediately before it renames over the destination: a writer
|
||||||
/// that finished an edit in the meantime must not be overwritten without a
|
/// that finished an edit in the meantime must not be overwritten without a
|
||||||
/// conflict copy (bugs.md B39).
|
/// conflict copy (`docs/BUGS.md` B39).
|
||||||
Download {
|
Download {
|
||||||
rel: String,
|
rel: String,
|
||||||
uid: NodeUid,
|
uid: NodeUid,
|
||||||
@@ -227,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);
|
||||||
|
|||||||
@@ -74,6 +74,26 @@ pub(crate) fn watchdog_ping() {
|
|||||||
let _ = notify("WATCHDOG=1\n");
|
let _ = notify("WATCHDOG=1\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sleep for `duration`, pinging the watchdog on the way.
|
||||||
|
///
|
||||||
|
/// For the stretches where the daemon is alive but has no mount, and so no
|
||||||
|
/// supervisor: waiting for a login, or backing off after a failed mount. The
|
||||||
|
/// manager arms the watchdog when the process starts, so without these pings a
|
||||||
|
/// daemon that is merely retrying is aborted after `WatchdogSec` and leaves a
|
||||||
|
/// coredump instead of the error it was logging. Never use this while mounted:
|
||||||
|
/// there the ping must prove the control socket answers.
|
||||||
|
pub fn idle_sleep(duration: Duration) {
|
||||||
|
const CHUNK: Duration = Duration::from_secs(10);
|
||||||
|
let mut left = duration;
|
||||||
|
watchdog_ping();
|
||||||
|
while !left.is_zero() {
|
||||||
|
let step = left.min(CHUNK);
|
||||||
|
std::thread::sleep(step);
|
||||||
|
left -= step;
|
||||||
|
watchdog_ping();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Tell the manager the daemon is stopping, so a slow teardown is not mistaken
|
/// Tell the manager the daemon is stopping, so a slow teardown is not mistaken
|
||||||
/// for a hang by the watchdog it is racing.
|
/// for a hang by the watchdog it is racing.
|
||||||
pub(crate) fn stopping() {
|
pub(crate) fn stopping() {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
.badge-pinned { color: var(--success-color); }
|
.badge-pinned { color: var(--success-color); }
|
||||||
.badge-cached { color: var(--success-color); }
|
.badge-cached { color: var(--success-color); }
|
||||||
.badge-cloud { color: color-mix(in srgb, currentColor 55%, transparent); }
|
.badge-cloud { color: color-mix(in srgb, currentColor 55%, transparent); }
|
||||||
|
.badge-issue { color: var(--error-color); }
|
||||||
.browser-statusbar { background-color: alpha(currentColor, 0.025); }
|
.browser-statusbar { background-color: alpha(currentColor, 0.025); }
|
||||||
scale.browser-status-meter trough { min-width: 104px; min-height: 6px; }
|
scale.browser-status-meter trough { min-width: 104px; min-height: 6px; }
|
||||||
.bulk-bar { padding: 6px 8px; border-radius: 12px; background: color-mix(in srgb, var(--accent-bg-color) 12%, transparent); }
|
.bulk-bar { padding: 6px 8px; border-radius: 12px; background: color-mix(in srgb, var(--accent-bg-color) 12%, transparent); }
|
||||||
|
|||||||
@@ -75,8 +75,8 @@ use pdfs_core::control::{
|
|||||||
DirEntry, ErrorKind, ImportSummary, InvitationInfo, JobItem, MappingProgress, PendingOpInfo,
|
DirEntry, ErrorKind, ImportSummary, InvitationInfo, JobItem, MappingProgress, PendingOpInfo,
|
||||||
PhotoItem, PhotoKind, PhotoMonth, PlaceInfo, PublicLinkInfo, RefreshScope, Request, Response,
|
PhotoItem, PhotoKind, PhotoMonth, PlaceInfo, PublicLinkInfo, RefreshScope, Request, Response,
|
||||||
RestorableFolder, RestoreItem, RevisionInfo, SearchHit, ShareEntry, ShareEntryKind, SharedItem,
|
RestorableFolder, RestoreItem, RevisionInfo, SearchHit, ShareEntry, ShareEntryKind, SharedItem,
|
||||||
SyncFolderInfo, SyncPhase, SyncProgress, ThumbnailBuildStatus, Topic, TransferDirection,
|
SyncFolderInfo, SyncIssue, SyncPhase, SyncProgress, ThumbnailBuildStatus, Topic,
|
||||||
TransferItem, send,
|
TransferDirection, TransferItem, send,
|
||||||
};
|
};
|
||||||
|
|
||||||
use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec};
|
use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec};
|
||||||
@@ -119,6 +119,9 @@ struct Ui {
|
|||||||
/// keyring (a DBus round-trip). Populated at startup and updated only on
|
/// keyring (a DBus round-trip). Populated at startup and updated only on
|
||||||
/// login / logout. `None` = signed out.
|
/// login / logout. `None` = signed out.
|
||||||
session: RefCell<Option<auth::StoredSession>>,
|
session: RefCell<Option<auth::StoredSession>>,
|
||||||
|
/// Whether this run already asked for the mailbox password of a locked
|
||||||
|
/// account, so a refresh never re-opens a dialog the user cancelled.
|
||||||
|
unlock_offered: Cell<bool>,
|
||||||
/// Whether the last refresh saw a live mount daemon. Gates the unpin buttons
|
/// Whether the last refresh saw a live mount daemon. Gates the unpin buttons
|
||||||
/// (which need the daemon to evict + re-hydrate) and every mutating action.
|
/// (which need the daemon to evict + re-hydrate) and every mutating action.
|
||||||
mounted: RefCell<bool>,
|
mounted: RefCell<bool>,
|
||||||
@@ -199,12 +202,20 @@ impl Ui {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> glib::ExitCode {
|
fn main() -> glib::ExitCode {
|
||||||
tracing_subscriber::fmt()
|
let verify = std::env::args().nth(1).as_deref() == Some(VERIFY_ARG);
|
||||||
.with_env_filter(
|
let logs = tracing_subscriber::fmt().with_env_filter(
|
||||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||||||
)
|
);
|
||||||
.init();
|
// The verification window's stdout carries its token back to the app.
|
||||||
|
if verify {
|
||||||
|
logs.with_writer(std::io::stderr).init();
|
||||||
|
} else {
|
||||||
|
logs.init();
|
||||||
|
}
|
||||||
i18n::init();
|
i18n::init();
|
||||||
|
if verify {
|
||||||
|
return run_verification_window();
|
||||||
|
}
|
||||||
|
|
||||||
// The tray launches the app with arguments ("--page locations",
|
// The tray launches the app with arguments ("--page locations",
|
||||||
// "--confirm-stop"), which a running instance must receive too.
|
// "--confirm-stop"), which a running instance must receive too.
|
||||||
@@ -300,6 +311,11 @@ fn build_window(app: &adw::Application) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// On a fresh install the daemon has not run yet to make these, and the
|
||||||
|
// Takeout page keeps its staged archives in the state dir (B194).
|
||||||
|
if let Err(e) = dirs.ensure() {
|
||||||
|
tracing::warn!("cannot create the app's directories: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
let stack = adw::ViewStack::new();
|
let stack = adw::ViewStack::new();
|
||||||
let (login_page, login_widgets) = build_login_page();
|
let (login_page, login_widgets) = build_login_page();
|
||||||
@@ -362,6 +378,7 @@ fn build_window(app: &adw::Application) {
|
|||||||
busy: Cell::new(0),
|
busy: Cell::new(0),
|
||||||
opening: RefCell::new(HashSet::new()),
|
opening: RefCell::new(HashSet::new()),
|
||||||
session: RefCell::new(auth::load().ok()),
|
session: RefCell::new(auth::load().ok()),
|
||||||
|
unlock_offered: Cell::new(false),
|
||||||
mounted: RefCell::new(false),
|
mounted: RefCell::new(false),
|
||||||
sidebar: sidebar_list.clone(),
|
sidebar: sidebar_list.clone(),
|
||||||
nav: split.clone(),
|
nav: split.clone(),
|
||||||
@@ -1206,7 +1223,7 @@ fn install_window_actions(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
|||||||
let dialog = adw::AboutDialog::builder()
|
let dialog = adw::AboutDialog::builder()
|
||||||
.application_name(gettext("Proton Drive for Linux"))
|
.application_name(gettext("Proton Drive for Linux"))
|
||||||
.application_icon("io.narl.proton-drive-linux")
|
.application_icon("io.narl.proton-drive-linux")
|
||||||
.version(pdfs_core::config::APP_VERSION)
|
.version(env!("CARGO_PKG_VERSION"))
|
||||||
.developer_name("Nils Pukropp")
|
.developer_name("Nils Pukropp")
|
||||||
.website("https://proton-drive.narl.io")
|
.website("https://proton-drive.narl.io")
|
||||||
.issue_url("https://github.com/narrrl/proton-drive-linux/issues")
|
.issue_url("https://github.com/narrrl/proton-drive-linux/issues")
|
||||||
@@ -1346,8 +1363,8 @@ fn debug_info(ui: &Rc<Ui>) -> String {
|
|||||||
format!(
|
format!(
|
||||||
"App version: {}\nUser agent: {}\nlibadwaita: {}.{}.{}\nGTK: {}.{}.{}\n\
|
"App version: {}\nUser agent: {}\nlibadwaita: {}.{}.{}\nGTK: {}.{}.{}\n\
|
||||||
Mountpoint: {}\nControl socket: {}\nMount service running: {}\n",
|
Mountpoint: {}\nControl socket: {}\nMount service running: {}\n",
|
||||||
pdfs_core::config::APP_VERSION,
|
config.resolved_app_version(),
|
||||||
pdfs_core::config::USER_AGENT,
|
config.resolved_user_agent(),
|
||||||
adw::major_version(),
|
adw::major_version(),
|
||||||
adw::minor_version(),
|
adw::minor_version(),
|
||||||
adw::micro_version(),
|
adw::micro_version(),
|
||||||
|
|||||||
@@ -341,6 +341,59 @@ fn detail_label(entry: &ActivityEntry) -> Option<String> {
|
|||||||
&[("name", alt)],
|
&[("name", alt)],
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
if let Some((reason, copy)) = detail.split_once("; local changes uploaded as ") {
|
||||||
|
let args = [("name", copy)];
|
||||||
|
return Some(match edit_conflict(reason) {
|
||||||
|
// Translators: {name} is the copy the changes made on this
|
||||||
|
// computer were uploaded as.
|
||||||
|
EditConflict::Gone => gettext_f(
|
||||||
|
"It no longer exists on Proton Drive, so your changes were uploaded as {name}",
|
||||||
|
&args,
|
||||||
|
),
|
||||||
|
// Translators: {name} is the copy the changes made on this
|
||||||
|
// computer were uploaded as.
|
||||||
|
EditConflict::Trashed => gettext_f(
|
||||||
|
"It was moved to the Trash on Proton Drive, so your changes were uploaded as {name}",
|
||||||
|
&args,
|
||||||
|
),
|
||||||
|
// Translators: {name} is the copy the changes made on this
|
||||||
|
// computer were uploaded as.
|
||||||
|
EditConflict::Changed => gettext_f(
|
||||||
|
"It was changed on Proton Drive, so your changes were uploaded as {name}",
|
||||||
|
&args,
|
||||||
|
),
|
||||||
|
// Translators: {name} is the copy the changes made on this
|
||||||
|
// computer were uploaded as.
|
||||||
|
EditConflict::Other => gettext_f("Your changes were uploaded as {name}", &args),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some((reason, path)) = detail.split_once("; local changes kept at ") {
|
||||||
|
let path = tilde_path(path);
|
||||||
|
let args = [("path", path.as_str())];
|
||||||
|
return Some(match edit_conflict(reason) {
|
||||||
|
// Translators: {path} is the file on this computer that holds the
|
||||||
|
// changes which could not be uploaded.
|
||||||
|
EditConflict::Gone => gettext_f(
|
||||||
|
"It no longer exists on Proton Drive, so your changes were kept in {path}",
|
||||||
|
&args,
|
||||||
|
),
|
||||||
|
// Translators: {path} is the file on this computer that holds the
|
||||||
|
// changes which could not be uploaded.
|
||||||
|
EditConflict::Trashed => gettext_f(
|
||||||
|
"It was moved to the Trash on Proton Drive, so your changes were kept in {path}",
|
||||||
|
&args,
|
||||||
|
),
|
||||||
|
// Translators: {path} is the file on this computer that holds the
|
||||||
|
// changes which could not be uploaded.
|
||||||
|
EditConflict::Changed => gettext_f(
|
||||||
|
"It was changed on Proton Drive, so your changes were kept in {path}",
|
||||||
|
&args,
|
||||||
|
),
|
||||||
|
// Translators: {path} is the file on this computer that holds the
|
||||||
|
// changes which could not be uploaded.
|
||||||
|
EditConflict::Other => gettext_f("Your changes were kept in {path}", &args),
|
||||||
|
});
|
||||||
|
}
|
||||||
if let Some(base) = detail.strip_prefix("differs from ") {
|
if let Some(base) = detail.strip_prefix("differs from ") {
|
||||||
// Translators: {name} is the file the conflicting copy was made from.
|
// Translators: {name} is the file the conflicting copy was made from.
|
||||||
return Some(gettext_f("Differs from {name}", &[("name", base)]));
|
return Some(gettext_f("Differs from {name}", &[("name", base)]));
|
||||||
@@ -388,6 +441,26 @@ fn detail_label(entry: &ActivityEntry) -> Option<String> {
|
|||||||
Some(capitalize(detail))
|
Some(capitalize(detail))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Why a queued edit could not land on its file, from the reason the daemon
|
||||||
|
/// gives before "; local changes uploaded as" or "; local changes kept at".
|
||||||
|
enum EditConflict {
|
||||||
|
Gone,
|
||||||
|
Trashed,
|
||||||
|
Changed,
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn edit_conflict(reason: &str) -> EditConflict {
|
||||||
|
match reason {
|
||||||
|
"the file no longer exists remotely" => EditConflict::Gone,
|
||||||
|
"the file was trashed remotely" => EditConflict::Trashed,
|
||||||
|
r if r.starts_with("the remote revision changed under the queued write") => {
|
||||||
|
EditConflict::Changed
|
||||||
|
}
|
||||||
|
_ => EditConflict::Other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A sync pass summary such as "2 uploaded, 1 folder(s) created", as a
|
/// A sync pass summary such as "2 uploaded, 1 folder(s) created", as a
|
||||||
/// list of counts. `None` when a part is not one the daemon writes.
|
/// list of counts. `None` when a part is not one the daemon writes.
|
||||||
fn sync_summary(detail: &str) -> Option<String> {
|
fn sync_summary(detail: &str) -> Option<String> {
|
||||||
@@ -596,6 +669,38 @@ mod tests {
|
|||||||
assert_eq!(d.details, vec!["1 person can view"]);
|
assert_eq!(d.details, vec!["1 person can view"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_edit_kept_as_a_copy_says_why_in_words() {
|
||||||
|
let d = describe(&entry(
|
||||||
|
ActivityKind::Upload,
|
||||||
|
"notes.txt",
|
||||||
|
"the remote revision changed under the queued write \
|
||||||
|
(based on revision r1, remote now at r2); \
|
||||||
|
local changes uploaded as notes (sync-conflict 1700000000).txt",
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
d.details,
|
||||||
|
vec![
|
||||||
|
"It was changed on Proton Drive, so your changes were uploaded as \
|
||||||
|
notes (sync-conflict 1700000000).txt"
|
||||||
|
]
|
||||||
|
);
|
||||||
|
let d = describe(&entry(
|
||||||
|
ActivityKind::Upload,
|
||||||
|
"notes.txt",
|
||||||
|
"the file was trashed remotely; local changes kept at /var/staging/7",
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
d.details,
|
||||||
|
vec![
|
||||||
|
"It was moved to the Trash on Proton Drive, so your changes were kept in \
|
||||||
|
/var/staging/7"
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unknown_details_show_as_written() {
|
fn unknown_details_show_as_written() {
|
||||||
let d = describe(&entry(
|
let d = describe(&entry(
|
||||||
|
|||||||
@@ -832,8 +832,8 @@ fn show_location_column(ui: &Rc<Ui>, visible: bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The menu for one entry, in the order of `docs/UI_UX_PLAN.md` §6: open,
|
/// The menu for one entry, in a fixed order: open, offline, sharing,
|
||||||
/// offline, sharing, organising, and the destructive item last on its own.
|
/// organising, and the destructive item last on its own.
|
||||||
pub(crate) fn entry_context_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
|
pub(crate) fn entry_context_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
|
||||||
let mut menu = ActionMenu::new();
|
let mut menu = ActionMenu::new();
|
||||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
@@ -2495,9 +2495,13 @@ pub(crate) fn start_upload(ui: &Rc<Ui>, sources: Vec<String>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The sync-state badge for an entry: `(icon, css-class)`, or `None` for folders
|
/// The sync-state badge for an entry: `(icon, css-class)`, or `None` for folders
|
||||||
/// (which carry no per-file cache state). Pinned (kept offline) ranks above merely
|
/// (which carry no per-file cache state). A sync issue ranks first, on folders
|
||||||
|
/// too: the change waits for the user. Pinned (kept offline) ranks above merely
|
||||||
/// cached (downloaded, evictable); everything else is online-only.
|
/// cached (downloaded, evictable); everything else is online-only.
|
||||||
pub(crate) fn badge_for(entry: &DirEntry) -> Option<(&'static str, &'static str)> {
|
pub(crate) fn badge_for(entry: &DirEntry) -> Option<(&'static str, &'static str)> {
|
||||||
|
if entry.issue.is_some() {
|
||||||
|
return Some(("dialog-warning-symbolic", "badge-issue"));
|
||||||
|
}
|
||||||
if entry.is_dir {
|
if entry.is_dir {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -2513,9 +2517,11 @@ pub(crate) fn badge_for(entry: &DirEntry) -> Option<(&'static str, &'static str)
|
|||||||
/// Paint `badge` to reflect the entry's sync state (see [`badge_for`]). Clears any
|
/// Paint `badge` to reflect the entry's sync state (see [`badge_for`]). Clears any
|
||||||
/// prior colour class first, since list factories recycle cells.
|
/// prior colour class first, since list factories recycle cells.
|
||||||
pub(crate) fn apply_badge(badge: >k4::Image, entry: &DirEntry) {
|
pub(crate) fn apply_badge(badge: >k4::Image, entry: &DirEntry) {
|
||||||
for class in ["badge-pinned", "badge-cached", "badge-cloud"] {
|
for class in ["badge-pinned", "badge-cached", "badge-cloud", "badge-issue"] {
|
||||||
badge.remove_css_class(class);
|
badge.remove_css_class(class);
|
||||||
}
|
}
|
||||||
|
let issue = entry.issue.map(super::locations::issue_text);
|
||||||
|
badge.set_tooltip_text(issue.as_deref());
|
||||||
match badge_for(entry) {
|
match badge_for(entry) {
|
||||||
Some((icon, class)) => {
|
Some((icon, class)) => {
|
||||||
badge.set_icon_name(Some(icon));
|
badge.set_icon_name(Some(icon));
|
||||||
@@ -2915,6 +2921,7 @@ pub(crate) fn repaint_search(ui: &Rc<Ui>, hits: &[SearchHit]) {
|
|||||||
shared_by_unverified: false,
|
shared_by_unverified: false,
|
||||||
trashed_at: 0,
|
trashed_at: 0,
|
||||||
trashed_from: None,
|
trashed_from: None,
|
||||||
|
issue: None,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
sort_entries(&mut entries, ui.browser.view.get());
|
sort_entries(&mut entries, ui.browser.view.get());
|
||||||
@@ -2952,11 +2959,11 @@ fn listing_summary(files: usize, folders: usize, file_bytes: u64) -> String {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
MoveTarget, hit_location, listing_summary, move_target_allowed,
|
MoveTarget, badge_for, hit_location, listing_summary, move_target_allowed,
|
||||||
show_thumbnail_build_progress, sort_entries,
|
show_thumbnail_build_progress, sort_entries,
|
||||||
};
|
};
|
||||||
use pdfs_core::config::{FileSort, FilesView};
|
use pdfs_core::config::{FileSort, FilesView};
|
||||||
use pdfs_core::control::{DirEntry, ThumbnailBuildStatus};
|
use pdfs_core::control::{DirEntry, SyncIssue, ThumbnailBuildStatus};
|
||||||
|
|
||||||
fn entry(name: &str, is_dir: bool, size: u64, modified: i64) -> DirEntry {
|
fn entry(name: &str, is_dir: bool, size: u64, modified: i64) -> DirEntry {
|
||||||
DirEntry {
|
DirEntry {
|
||||||
@@ -2974,6 +2981,7 @@ mod tests {
|
|||||||
shared_by_unverified: false,
|
shared_by_unverified: false,
|
||||||
trashed_at: 0,
|
trashed_at: 0,
|
||||||
trashed_from: None,
|
trashed_from: None,
|
||||||
|
issue: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2981,6 +2989,20 @@ mod tests {
|
|||||||
entries.iter().map(|e| e.name.as_str()).collect()
|
entries.iter().map(|e| e.name.as_str()).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_sync_issue_shows_on_the_node_before_its_cache_state() {
|
||||||
|
let mut file = entry("a.txt", false, 1, 0);
|
||||||
|
file.pinned = true;
|
||||||
|
assert_eq!(badge_for(&file).unwrap().1, "badge-pinned");
|
||||||
|
file.issue = Some(SyncIssue::Quota);
|
||||||
|
assert_eq!(badge_for(&file).unwrap().1, "badge-issue");
|
||||||
|
|
||||||
|
let mut folder = entry("d", true, 0, 0);
|
||||||
|
assert_eq!(badge_for(&folder), None);
|
||||||
|
folder.issue = Some(SyncIssue::Missing);
|
||||||
|
assert_eq!(badge_for(&folder).unwrap().1, "badge-issue");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_search_hit_location_is_its_parent_folder() {
|
fn a_search_hit_location_is_its_parent_folder() {
|
||||||
assert_eq!(hit_location("Work/Old/a.txt"), "Work/Old");
|
assert_eq!(hit_location("Work/Old/a.txt"), "Work/Old");
|
||||||
|
|||||||
@@ -439,6 +439,10 @@ fn other_computer_row(ui: &Rc<Ui>, dev: &DeviceInfo) -> adw::ActionRow {
|
|||||||
menu.item(&gettext("Restore to This Computer…"), move || {
|
menu.item(&gettext("Restore to This Computer…"), move || {
|
||||||
prompt_restore_folders(&ui_c, Some((uid_c.clone(), name_c.clone())))
|
prompt_restore_folders(&ui_c, Some((uid_c.clone(), name_c.clone())))
|
||||||
});
|
});
|
||||||
|
let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone());
|
||||||
|
menu.item(&gettext("Move to This Computer…"), move || {
|
||||||
|
prompt_migrate_device(&ui_c, &uid_c, &name_c)
|
||||||
|
});
|
||||||
menu.section();
|
menu.section();
|
||||||
let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone());
|
let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone());
|
||||||
menu.item(&gettext("Rename…"), move || {
|
menu.item(&gettext("Rename…"), move || {
|
||||||
@@ -1096,6 +1100,67 @@ pub(crate) fn prompt_adopt_device(ui: &Rc<Ui>, uid: &str, name: &str) {
|
|||||||
dialog.present(win.as_ref());
|
dialog.present(win.as_ref());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ask before moving another computer's folders into this computer's backup,
|
||||||
|
/// then offer them for syncing here.
|
||||||
|
///
|
||||||
|
/// Destructive-looking on purpose: the other computer keeps its device but
|
||||||
|
/// loses its folders, and a copy of Proton Drive still running there would
|
||||||
|
/// watch them vanish.
|
||||||
|
pub(crate) fn prompt_migrate_device(ui: &Rc<Ui>, uid: &str, name: &str) {
|
||||||
|
let win = ui_window(ui);
|
||||||
|
let dialog = adw::AlertDialog::builder()
|
||||||
|
.heading(gettext("Move Backup to This Computer?"))
|
||||||
|
// Translators: {name} is the name of the other computer whose folders move to this one.
|
||||||
|
.body(gettext_f(
|
||||||
|
"The folders backed up by “{name}” move into this computer's backup in Proton Drive, and you choose where they go on this computer. “{name}” stays in your account, without those folders.\n\nStop Proton Drive on “{name}” first. Otherwise it sees its folders disappear.",
|
||||||
|
&[("name", name)],
|
||||||
|
))
|
||||||
|
.build();
|
||||||
|
dialog.add_response("cancel", &gettext("Cancel"));
|
||||||
|
dialog.add_response("move", &gettext("Move Here"));
|
||||||
|
dialog.set_response_appearance("move", adw::ResponseAppearance::Destructive);
|
||||||
|
dialog.set_default_response(Some("cancel"));
|
||||||
|
dialog.set_close_response("cancel");
|
||||||
|
let ui = ui.clone();
|
||||||
|
let uid = uid.to_string();
|
||||||
|
// Translators: {name} is the name of the computer whose folders moved to this one.
|
||||||
|
let done = gettext_f("Moved the backup of “{name}”", &[("name", name)]);
|
||||||
|
dialog.connect_response(None, move |_, resp| {
|
||||||
|
if resp != "move" {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ui.busy_begin();
|
||||||
|
let rx = spawn_request(
|
||||||
|
ui.dirs.control_socket(),
|
||||||
|
Request::MigrateDevice { uid: uid.clone() },
|
||||||
|
);
|
||||||
|
let (ui, done) = (ui.clone(), done.clone());
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let result = rx.recv().await;
|
||||||
|
ui.busy_end();
|
||||||
|
let failed = gettext("Couldn't move that backup");
|
||||||
|
match result {
|
||||||
|
Ok(Ok(Response::Ok { .. })) => {
|
||||||
|
reload_sync_pages(&ui);
|
||||||
|
toast(&ui, &done);
|
||||||
|
// The folders are this computer's now, so its own restore
|
||||||
|
// picker is where the user chooses their local paths.
|
||||||
|
prompt_restore_folders(&ui, None);
|
||||||
|
}
|
||||||
|
Ok(Ok(Response::Error { message, kind })) => {
|
||||||
|
toast_failure(&ui, &failed, &message, kind)
|
||||||
|
}
|
||||||
|
_ => toast_error(
|
||||||
|
&ui,
|
||||||
|
&failed,
|
||||||
|
&gettext("The Proton Drive service didn't respond."),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
dialog.present(win.as_ref());
|
||||||
|
}
|
||||||
|
|
||||||
/// Run a mutation raised from Computers or Locations and reload the page on success.
|
/// Run a mutation raised from Computers or Locations and reload the page on success.
|
||||||
pub(crate) fn run_devices_mutation(ui: &Rc<Ui>, req: Request, done: &str, failed: &str) {
|
pub(crate) fn run_devices_mutation(ui: &Rc<Ui>, req: Request, done: &str, failed: &str) {
|
||||||
ui.busy_begin();
|
ui.busy_begin();
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
//! `~/ProtonDrive` mount, plus each folder this computer backs up, whether it is
|
//! `~/ProtonDrive` mount, plus each folder this computer backs up, whether it is
|
||||||
//! a mirrored local directory or an on-demand FUSE session. The page is called
|
//! a mirrored local directory or an on-demand FUSE session. The page is called
|
||||||
//! *Locations* rather than *Mounts* because a mirror folder is a plain directory
|
//! *Locations* rather than *Mounts* because a mirror folder is a plain directory
|
||||||
//! with no FUSE session behind it (mount-architecture.md §4).
|
//! with no FUSE session behind it.
|
||||||
//!
|
//!
|
||||||
//! The device rows drive the same control requests the Computers page used to:
|
//! The device rows drive the same control requests the Computers page used to:
|
||||||
//! mode switch, sync now, remove. What is new here is that the primary mount is
|
//! mode switch, sync now, remove. What is new here is that the primary mount is
|
||||||
@@ -70,8 +70,9 @@ pub(crate) struct LocationRow {
|
|||||||
progress: gtk4::ProgressBar,
|
progress: gtk4::ProgressBar,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What a queue row shows that can change: id, attempts, next attempt, parked.
|
/// What a queue row shows that can change: id, attempts, next attempt, parked,
|
||||||
pub(crate) type QueueKey = (i64, i64, Option<i64>, bool);
|
/// issue.
|
||||||
|
pub(crate) type QueueKey = (i64, i64, Option<i64>, bool, Option<SyncIssue>);
|
||||||
|
|
||||||
/// The "Waiting to Upload" list: the daemon's pending-op queue.
|
/// The "Waiting to Upload" list: the daemon's pending-op queue.
|
||||||
pub(crate) struct QueueState {
|
pub(crate) struct QueueState {
|
||||||
@@ -591,7 +592,7 @@ fn repaint_queue(ui: &Rc<Ui>, items: &[PendingOpInfo]) {
|
|||||||
items.sort_by_key(|op| (!op.failing, op.attempts == 0, op.id));
|
items.sort_by_key(|op| (!op.failing, op.attempts == 0, op.id));
|
||||||
let key: Vec<QueueKey> = items
|
let key: Vec<QueueKey> = items
|
||||||
.iter()
|
.iter()
|
||||||
.map(|op| (op.id, op.attempts, op.next_attempt_at, op.parked))
|
.map(|op| (op.id, op.attempts, op.next_attempt_at, op.parked, op.issue))
|
||||||
.collect();
|
.collect();
|
||||||
if *queue.painted.borrow() == key {
|
if *queue.painted.borrow() == key {
|
||||||
return;
|
return;
|
||||||
@@ -648,6 +649,10 @@ fn queue_row(ui: &Rc<Ui>, op: &PendingOpInfo, now: i64) -> adw::ActionRow {
|
|||||||
"{action} · {state}",
|
"{action} · {state}",
|
||||||
&[("action", &action), ("state", &state)],
|
&[("action", &action), ("state", &state)],
|
||||||
);
|
);
|
||||||
|
if let Some(issue) = op.issue {
|
||||||
|
subtitle.push('\n');
|
||||||
|
subtitle.push_str(&issue_text(issue));
|
||||||
|
}
|
||||||
if let Some(error) = &op.last_error {
|
if let Some(error) = &op.last_error {
|
||||||
subtitle.push('\n');
|
subtitle.push('\n');
|
||||||
// Translators: {n} is how many attempts failed, {error} the last error message.
|
// Translators: {n} is how many attempts failed, {error} the last error message.
|
||||||
@@ -662,7 +667,7 @@ fn queue_row(ui: &Rc<Ui>, op: &PendingOpInfo, now: i64) -> adw::ActionRow {
|
|||||||
.title(glib::markup_escape_text(&op.path).as_str())
|
.title(glib::markup_escape_text(&op.path).as_str())
|
||||||
.subtitle(glib::markup_escape_text(&subtitle).as_str())
|
.subtitle(glib::markup_escape_text(&subtitle).as_str())
|
||||||
.title_lines(1)
|
.title_lines(1)
|
||||||
.subtitle_lines(3)
|
.subtitle_lines(if op.issue.is_some() { 5 } else { 3 })
|
||||||
.tooltip_text(&op.path)
|
.tooltip_text(&op.path)
|
||||||
.build();
|
.build();
|
||||||
let image = gtk4::Image::from_icon_name(if op.failing {
|
let image = gtk4::Image::from_icon_name(if op.failing {
|
||||||
@@ -687,9 +692,157 @@ fn queue_row(ui: &Rc<Ui>, op: &PendingOpInfo, now: i64) -> adw::ActionRow {
|
|||||||
retry.connect_clicked(move |_| retry_queued(&ui, Some(id)));
|
retry.connect_clicked(move |_| retry_queued(&ui, Some(id)));
|
||||||
row.add_suffix(&retry);
|
row.add_suffix(&retry);
|
||||||
}
|
}
|
||||||
|
if op.failing && op.exportable {
|
||||||
|
let export = gtk4::Button::builder()
|
||||||
|
.label(gettext("Export"))
|
||||||
|
.tooltip_text(gettext("Save a copy of this file's content elsewhere"))
|
||||||
|
.valign(gtk4::Align::Center)
|
||||||
|
.build();
|
||||||
|
export.add_css_class("flat");
|
||||||
|
let ui = ui.clone();
|
||||||
|
let id = op.id;
|
||||||
|
let name = Path::new(&op.path)
|
||||||
|
.file_name()
|
||||||
|
.map(|name| name.to_string_lossy().into_owned())
|
||||||
|
.unwrap_or_default();
|
||||||
|
export.connect_clicked(move |_| prompt_export_queued(&ui, id, &name));
|
||||||
|
row.add_suffix(&export);
|
||||||
|
}
|
||||||
|
if op.failing {
|
||||||
|
let discard = gtk4::Button::builder()
|
||||||
|
.label(gettext("Discard"))
|
||||||
|
.tooltip_text(gettext("Undo this change here instead of sending it"))
|
||||||
|
.valign(gtk4::Align::Center)
|
||||||
|
.build();
|
||||||
|
discard.add_css_class("flat");
|
||||||
|
let ui = ui.clone();
|
||||||
|
let id = op.id;
|
||||||
|
let path = op.path.clone();
|
||||||
|
let exportable = op.exportable;
|
||||||
|
discard.connect_clicked(move |_| prompt_discard_queued(&ui, id, &path, exportable));
|
||||||
|
row.add_suffix(&discard);
|
||||||
|
}
|
||||||
row
|
row
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What a sync issue means and what the user can do about it. The change stays
|
||||||
|
/// queued either way.
|
||||||
|
pub(crate) fn issue_text(issue: SyncIssue) -> String {
|
||||||
|
match issue {
|
||||||
|
SyncIssue::Quota => gettext(
|
||||||
|
"Your Proton Drive storage is full. Free up space or upgrade your plan, and this change uploads on its own.",
|
||||||
|
),
|
||||||
|
SyncIssue::Access => gettext(
|
||||||
|
"You can no longer change this on Proton Drive. Ask the owner for edit access, or export the file to keep your version.",
|
||||||
|
),
|
||||||
|
SyncIssue::Missing => gettext(
|
||||||
|
"The folder this change belongs in is gone from Proton Drive. Export the file to keep your version.",
|
||||||
|
),
|
||||||
|
SyncIssue::Limit => gettext(
|
||||||
|
"Proton Drive's limit on items in a folder or on folder depth is reached. Make room in the folder, then retry.",
|
||||||
|
),
|
||||||
|
SyncIssue::Rejected | SyncIssue::Unknown => {
|
||||||
|
gettext("Proton Drive refused this change. It stays queued and is tried again.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask where to save a copy of a queued file's content, then have the daemon
|
||||||
|
/// write it there. The change itself stays queued.
|
||||||
|
fn prompt_export_queued(ui: &Rc<Ui>, id: i64, name: &str) {
|
||||||
|
let window = ui_window(ui);
|
||||||
|
let dialog = gtk4::FileDialog::builder()
|
||||||
|
.title(gettext("Export File"))
|
||||||
|
.initial_name(name)
|
||||||
|
.build();
|
||||||
|
let ui = ui.clone();
|
||||||
|
dialog.save(window.as_ref(), gio::Cancellable::NONE, move |res| {
|
||||||
|
let Ok(file) = res else { return };
|
||||||
|
let Some(dest) = file.path().and_then(|p| p.to_str().map(str::to_string)) else {
|
||||||
|
toast_error(
|
||||||
|
&ui,
|
||||||
|
&gettext("Couldn't export the file"),
|
||||||
|
&gettext("That location isn't a local file."),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let rx = spawn_request(
|
||||||
|
ui.dirs.control_socket(),
|
||||||
|
Request::ExportPendingOp { id, dest },
|
||||||
|
);
|
||||||
|
let ui = ui.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
match rx.recv().await {
|
||||||
|
Ok(Ok(Response::Ok { .. })) => toast(&ui, &gettext("File exported")),
|
||||||
|
Ok(Ok(Response::Error { message, kind })) => {
|
||||||
|
toast_failure(&ui, &gettext("Couldn't export the file"), &message, kind)
|
||||||
|
}
|
||||||
|
_ => toast_error(
|
||||||
|
&ui,
|
||||||
|
&gettext("Couldn't export the file"),
|
||||||
|
&gettext("The Proton Drive service didn't respond."),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Confirm, then have the daemon drop a queued change and undo it here.
|
||||||
|
fn prompt_discard_queued(ui: &Rc<Ui>, id: i64, path: &str, exportable: bool) {
|
||||||
|
let body = if exportable {
|
||||||
|
// Translators: {path} is the file whose queued change would be dropped.
|
||||||
|
gettext_f(
|
||||||
|
"Your changes to {path} are undone here and never reach Proton Drive. Export the file first to keep your version.",
|
||||||
|
&[("path", path)],
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Translators: {path} is the file or folder whose queued change would be dropped.
|
||||||
|
gettext_f(
|
||||||
|
"The change to {path} is undone here and never reaches Proton Drive.",
|
||||||
|
&[("path", path)],
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let dialog = adw::AlertDialog::builder()
|
||||||
|
.heading(gettext("Discard Change?"))
|
||||||
|
.body(body)
|
||||||
|
.build();
|
||||||
|
dialog.add_response("cancel", &gettext("Cancel"));
|
||||||
|
dialog.add_response("discard", &gettext("Discard"));
|
||||||
|
dialog.set_response_appearance("discard", adw::ResponseAppearance::Destructive);
|
||||||
|
dialog.set_default_response(Some("cancel"));
|
||||||
|
dialog.set_close_response("cancel");
|
||||||
|
let window = ui_window(ui);
|
||||||
|
let ui = ui.clone();
|
||||||
|
dialog.connect_response(None, move |_, resp| {
|
||||||
|
if resp == "discard" {
|
||||||
|
discard_queued(&ui, id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
dialog.present(window.as_ref());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn discard_queued(ui: &Rc<Ui>, id: i64) {
|
||||||
|
let rx = spawn_request(ui.dirs.control_socket(), Request::DiscardPendingOp { id });
|
||||||
|
let ui = ui.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
match rx.recv().await {
|
||||||
|
Ok(Ok(Response::Ok { .. })) => {
|
||||||
|
toast(&ui, &gettext("Change discarded"));
|
||||||
|
ui.locations.queue.painted.borrow_mut().clear();
|
||||||
|
refresh_queue(&ui);
|
||||||
|
}
|
||||||
|
Ok(Ok(Response::Error { message, kind })) => {
|
||||||
|
toast_failure(&ui, &gettext("Couldn't discard the change"), &message, kind)
|
||||||
|
}
|
||||||
|
_ => toast_error(
|
||||||
|
&ui,
|
||||||
|
&gettext("Couldn't discard the change"),
|
||||||
|
&gettext("The Proton Drive service didn't respond."),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Ask the daemon to stop waiting out one op's backoff, or every failed op's.
|
/// Ask the daemon to stop waiting out one op's backoff, or every failed op's.
|
||||||
fn retry_queued(ui: &Rc<Ui>, id: Option<i64>) {
|
fn retry_queued(ui: &Rc<Ui>, id: Option<i64>) {
|
||||||
let rx = spawn_request(ui.dirs.control_socket(), Request::RetryPendingOp { id });
|
let rx = spawn_request(ui.dirs.control_socket(), Request::RetryPendingOp { id });
|
||||||
|
|||||||
@@ -128,7 +128,12 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
|||||||
|
|
||||||
set_signing_in(&ui, true);
|
set_signing_in(&ui, true);
|
||||||
ui.login.login_status.set_text(&gettext("Signing in…"));
|
ui.login.login_status.set_text(&gettext("Signing in…"));
|
||||||
let (rx, totp_req_rx, hv_req_rx) = spawn_login(username, password);
|
let LoginChannels {
|
||||||
|
result: rx,
|
||||||
|
totp: totp_req_rx,
|
||||||
|
hv: hv_req_rx,
|
||||||
|
mailbox: mailbox_req_rx,
|
||||||
|
} = spawn_login(username, password);
|
||||||
|
|
||||||
// Surface the 2FA dialog only if the SDK actually asks for a code (i.e.
|
// Surface the 2FA dialog only if the SDK actually asks for a code (i.e.
|
||||||
// the account has 2FA enabled). The worker blocks until the dialog feeds
|
// the account has 2FA enabled). The worker blocks until the dialog feeds
|
||||||
@@ -145,15 +150,42 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Same shape for human verification: only fires when the API gates the
|
// Same shape for human verification: only fires when the API gates the
|
||||||
// sign-in, and the worker is blocked on the token until it does.
|
// sign-in, and the worker is blocked on the token until it does. A
|
||||||
|
// window that never came up is told apart from one the user closed, so
|
||||||
|
// the result below can say why the sign-in stopped.
|
||||||
let ui_hv = ui.clone();
|
let ui_hv = ui.clone();
|
||||||
|
let verification_failed = Rc::new(Cell::new(false));
|
||||||
|
let hv_failed = verification_failed.clone();
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
if let Ok((url, token_tx)) = hv_req_rx.recv().await {
|
if let Ok((url, token_tx)) = hv_req_rx.recv().await {
|
||||||
ui_hv
|
ui_hv
|
||||||
.login
|
.login
|
||||||
.login_status
|
.login_status
|
||||||
.set_text(&gettext("Complete the verification to continue…"));
|
.set_text(&gettext("Complete the verification to continue…"));
|
||||||
prompt_human_verification(&ui_hv, &url, token_tx);
|
// Anything but a token drops the sender, which the worker reads
|
||||||
|
// as a cancelled sign-in.
|
||||||
|
match verify_human(url).await {
|
||||||
|
Verification::Solved(token) => {
|
||||||
|
let _ = token_tx.send(token);
|
||||||
|
}
|
||||||
|
Verification::Cancelled => {}
|
||||||
|
Verification::Failed => hv_failed.set(true),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// And for the mailbox password, which only a two-password account has.
|
||||||
|
// The worker asks again after a wrong answer, so this loops too.
|
||||||
|
let ui_mailbox = ui.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
while let Ok((attempt, answer_tx)) = mailbox_req_rx.recv().await {
|
||||||
|
ask_mailbox(&ui_mailbox, attempt > 0, move |answer| {
|
||||||
|
// A cancelled dialog drops the sender, which the worker
|
||||||
|
// reads as a cancelled login.
|
||||||
|
if let Some(answer) = answer {
|
||||||
|
let _ = answer_tx.send(answer);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -174,6 +206,9 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
|||||||
let _ = gio::spawn_blocking(service::enable_start).await;
|
let _ = gio::spawn_blocking(service::enable_start).await;
|
||||||
refresh(&ui);
|
refresh(&ui);
|
||||||
}
|
}
|
||||||
|
Err(_) if verification_failed.get() => ui.login.login_status.set_text(&gettext(
|
||||||
|
"The verification page couldn't be opened, so the sign-in was cancelled. Your system may keep the web view's sandbox from starting; the troubleshooting guide explains what to do.",
|
||||||
|
)),
|
||||||
Err(e) => ui.login.login_status.set_text(&e),
|
Err(e) => ui.login.login_status.set_text(&e),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -209,6 +244,12 @@ pub(crate) fn login_error_message(error: &pdfs_core::Error) -> String {
|
|||||||
pdfs_core::Error::Other(message) if message.contains("two-factor") => {
|
pdfs_core::Error::Other(message) if message.contains("two-factor") => {
|
||||||
gettext("Sign-in cancelled: no two-factor code was entered.")
|
gettext("Sign-in cancelled: no two-factor code was entered.")
|
||||||
}
|
}
|
||||||
|
pdfs_core::Error::Other(message) if message.contains("mailbox") => {
|
||||||
|
gettext("Sign-in cancelled: no mailbox password was entered.")
|
||||||
|
}
|
||||||
|
pdfs_core::Error::WrongMailboxPassword => {
|
||||||
|
gettext("That mailbox password didn't unlock your account.")
|
||||||
|
}
|
||||||
pdfs_core::Error::Other(message) if message.contains("verification") => gettext(
|
pdfs_core::Error::Other(message) if message.contains("verification") => gettext(
|
||||||
"Sign-in cancelled: the verification wasn't completed. Sign in again to get a new one.",
|
"Sign-in cancelled: the verification wasn't completed. Sign in again to get a new one.",
|
||||||
),
|
),
|
||||||
@@ -220,26 +261,31 @@ pub(crate) fn login_error_message(error: &pdfs_core::Error) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What the login worker asks the UI for, and how it reports back.
|
||||||
|
pub(crate) struct LoginChannels {
|
||||||
|
/// The final outcome, once.
|
||||||
|
pub(crate) result: async_channel::Receiver<Result<(), String>>,
|
||||||
|
/// Fires only if the account needs a 2FA code; carries the sender for it.
|
||||||
|
pub(crate) totp: async_channel::Receiver<std::sync::mpsc::Sender<String>>,
|
||||||
|
/// Fires only if the sign-in is gated: the page to show, and the sender for
|
||||||
|
/// the token the user earns on it.
|
||||||
|
pub(crate) hv: async_channel::Receiver<(String, std::sync::mpsc::Sender<String>)>,
|
||||||
|
/// Fires only for a two-password account: how many answers were already
|
||||||
|
/// wrong, and the sender for the password and the "remember" choice.
|
||||||
|
pub(crate) mailbox: async_channel::Receiver<(u32, std::sync::mpsc::Sender<(String, bool)>)>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Run the async SRP + optional 2FA login on a dedicated current-thread Tokio
|
/// Run the async SRP + optional 2FA login on a dedicated current-thread Tokio
|
||||||
/// runtime. Returns two channels: the first yields the final login result once;
|
/// runtime. The returned [`LoginChannels`] carry the final result and the lazy
|
||||||
/// the second fires *only if* the account needs a 2FA code, carrying a
|
/// prompts. Each login closure blocks the worker on a sender the UI answers, so
|
||||||
/// [`std::sync::mpsc::Sender`] the UI uses to feed the code back. The login
|
/// a code is requested lazily and can't expire before the password proof.
|
||||||
/// closure blocks the worker on that sender until the dialog answers, so the
|
pub(crate) fn spawn_login(username: String, password: String) -> LoginChannels {
|
||||||
/// code is requested lazily and can't expire before the password proof.
|
|
||||||
#[allow(clippy::type_complexity)]
|
|
||||||
pub(crate) fn spawn_login(
|
|
||||||
username: String,
|
|
||||||
password: String,
|
|
||||||
) -> (
|
|
||||||
async_channel::Receiver<Result<(), String>>,
|
|
||||||
async_channel::Receiver<std::sync::mpsc::Sender<String>>,
|
|
||||||
async_channel::Receiver<(String, std::sync::mpsc::Sender<String>)>,
|
|
||||||
) {
|
|
||||||
let (tx, rx) = async_channel::bounded(1);
|
let (tx, rx) = async_channel::bounded(1);
|
||||||
// Two, not one: a CAPTCHA-gated login restarts, so a 2FA account is asked
|
// Two, not one: a CAPTCHA-gated login restarts, so a 2FA account is asked
|
||||||
// for a code on each attempt and a single slot would deadlock the worker.
|
// for a code on each attempt and a single slot would deadlock the worker.
|
||||||
let (totp_req_tx, totp_req_rx) = async_channel::bounded(2);
|
let (totp_req_tx, totp_req_rx) = async_channel::bounded(2);
|
||||||
let (hv_req_tx, hv_req_rx) = async_channel::bounded(1);
|
let (hv_req_tx, hv_req_rx) = async_channel::bounded(1);
|
||||||
|
let (mailbox_req_tx, mailbox_req_rx) = async_channel::bounded(1);
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||||
.enable_all()
|
.enable_all()
|
||||||
@@ -267,6 +313,19 @@ pub(crate) fn spawn_login(
|
|||||||
.recv()
|
.recv()
|
||||||
.map_err(|_| pdfs_core::Error::Other("two-factor entry cancelled".into()))
|
.map_err(|_| pdfs_core::Error::Other("two-factor entry cancelled".into()))
|
||||||
},
|
},
|
||||||
|
|attempt| {
|
||||||
|
let (answer_tx, answer_rx) = std::sync::mpsc::channel::<(String, bool)>();
|
||||||
|
mailbox_req_tx
|
||||||
|
.send_blocking((attempt, answer_tx))
|
||||||
|
.map_err(|_| pdfs_core::Error::Other("mailbox prompt closed".into()))?;
|
||||||
|
let (password, remember) = answer_rx
|
||||||
|
.recv()
|
||||||
|
.map_err(|_| pdfs_core::Error::Other("mailbox entry cancelled".into()))?;
|
||||||
|
Ok(auth::Mailbox {
|
||||||
|
password: password.into(),
|
||||||
|
remember,
|
||||||
|
})
|
||||||
|
},
|
||||||
|hv| {
|
|hv| {
|
||||||
// Gated: hand the UI the page to show, block on the token the
|
// Gated: hand the UI the page to show, block on the token the
|
||||||
// user earns by solving it.
|
// user earns by solving it.
|
||||||
@@ -287,7 +346,143 @@ pub(crate) fn spawn_login(
|
|||||||
});
|
});
|
||||||
let _ = tx.send_blocking(result);
|
let _ = tx.send_blocking(result);
|
||||||
});
|
});
|
||||||
(rx, totp_req_rx, hv_req_rx)
|
LoginChannels {
|
||||||
|
result: rx,
|
||||||
|
totp: totp_req_rx,
|
||||||
|
hv: hv_req_rx,
|
||||||
|
mailbox: mailbox_req_rx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask for the mailbox password of a two-password account.
|
||||||
|
///
|
||||||
|
/// `done` gets the password and the "keep unlocked" choice, or `None` when the
|
||||||
|
/// dialog was cancelled. `retry` says the last answer was wrong. The choice
|
||||||
|
/// defaults to off: storing the mailbox unlock in the keyring trades away what
|
||||||
|
/// a separate mailbox password is for, so it is something to opt into.
|
||||||
|
pub(crate) fn ask_mailbox(
|
||||||
|
ui: &Rc<Ui>,
|
||||||
|
retry: bool,
|
||||||
|
done: impl FnOnce(Option<(String, bool)>) + 'static,
|
||||||
|
) {
|
||||||
|
let body = if retry {
|
||||||
|
gettext("That mailbox password didn't unlock your account. Try again.")
|
||||||
|
} else {
|
||||||
|
gettext("This account has a separate mailbox password. Enter it to unlock your files.")
|
||||||
|
};
|
||||||
|
let dialog = adw::AlertDialog::builder()
|
||||||
|
.heading(gettext("Mailbox Password"))
|
||||||
|
.body(body)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let group = adw::PreferencesGroup::new();
|
||||||
|
let entry = adw::PasswordEntryRow::builder()
|
||||||
|
.title(gettext("Mailbox password"))
|
||||||
|
.activates_default(true)
|
||||||
|
.build();
|
||||||
|
let remember = adw::SwitchRow::builder()
|
||||||
|
.title(gettext("Keep unlocked on this computer"))
|
||||||
|
.subtitle(gettext(
|
||||||
|
"Stores the unlock in the system keyring, so Proton Drive opens without asking after every login. Otherwise you enter the password once per session.",
|
||||||
|
))
|
||||||
|
.build();
|
||||||
|
group.add(&entry);
|
||||||
|
group.add(&remember);
|
||||||
|
dialog.set_extra_child(Some(&group));
|
||||||
|
|
||||||
|
dialog.add_response("cancel", &gettext("Cancel"));
|
||||||
|
dialog.add_response("unlock", &gettext("Unlock"));
|
||||||
|
dialog.set_response_appearance("unlock", adw::ResponseAppearance::Suggested);
|
||||||
|
dialog.set_default_response(Some("unlock"));
|
||||||
|
dialog.set_close_response("cancel");
|
||||||
|
|
||||||
|
let done = RefCell::new(Some(done));
|
||||||
|
dialog.connect_response(None, move |_, resp| {
|
||||||
|
let Some(done) = done.borrow_mut().take() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let password = entry.text().to_string();
|
||||||
|
if resp == "unlock" && !password.is_empty() {
|
||||||
|
done(Some((password, remember.is_active())));
|
||||||
|
} else {
|
||||||
|
done(None);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let parent = ui_window(ui).map(|w| w.upcast::<gtk4::Window>());
|
||||||
|
dialog.present(parent.as_ref());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// If the signed-in account is locked, ask for its mailbox password — once per
|
||||||
|
/// run; after a cancel, a toast keeps an Unlock button within reach.
|
||||||
|
///
|
||||||
|
/// The key state is a keyring and kernel-keyring read, so it runs on a worker.
|
||||||
|
pub(crate) fn offer_unlock(ui: &Rc<Ui>) {
|
||||||
|
if ui.unlock_offered.get() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let ui = ui.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let state = gio::spawn_blocking(|| auth::key_state().ok()).await;
|
||||||
|
if matches!(state, Ok(Some(auth::KeyState::Locked))) && !ui.unlock_offered.replace(true) {
|
||||||
|
prompt_unlock(&ui, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
enum UnlockOutcome {
|
||||||
|
Unlocked,
|
||||||
|
Wrong,
|
||||||
|
Failed(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask for the mailbox password and unlock the account with it.
|
||||||
|
fn prompt_unlock(ui: &Rc<Ui>, retry: bool) {
|
||||||
|
let ui_done = ui.clone();
|
||||||
|
ask_mailbox(ui, retry, move |answer| {
|
||||||
|
let Some((password, remember)) = answer else {
|
||||||
|
toast_action(
|
||||||
|
&ui_done,
|
||||||
|
&gettext("Proton Drive is locked. Enter your mailbox password to unlock it."),
|
||||||
|
&gettext("Unlock"),
|
||||||
|
|ui| prompt_unlock(ui, false),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
// Verifying talks to Proton and the restart talks to systemd, so
|
||||||
|
// both run on a worker, never on the main loop.
|
||||||
|
let outcome = gio::spawn_blocking(move || {
|
||||||
|
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(rt) => rt,
|
||||||
|
Err(e) => return UnlockOutcome::Failed(e.to_string()),
|
||||||
|
};
|
||||||
|
match rt.block_on(auth::unlock(&password, remember)) {
|
||||||
|
Ok(()) => {
|
||||||
|
service::restart();
|
||||||
|
UnlockOutcome::Unlocked
|
||||||
|
}
|
||||||
|
Err(pdfs_core::Error::WrongMailboxPassword) => UnlockOutcome::Wrong,
|
||||||
|
Err(e) => UnlockOutcome::Failed(login_error_message(&e)),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
match outcome {
|
||||||
|
Ok(UnlockOutcome::Unlocked) => {
|
||||||
|
toast(&ui_done, &gettext("Unlocked."));
|
||||||
|
refresh(&ui_done);
|
||||||
|
}
|
||||||
|
Ok(UnlockOutcome::Wrong) => prompt_unlock(&ui_done, true),
|
||||||
|
Ok(UnlockOutcome::Failed(message)) => {
|
||||||
|
toast_error(&ui_done, &gettext("Couldn't unlock"), &message)
|
||||||
|
}
|
||||||
|
Err(_) => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Show the lazy two-factor dialog and feed the entered code back to the waiting
|
/// Show the lazy two-factor dialog and feed the entered code back to the waiting
|
||||||
@@ -374,6 +569,12 @@ mod tests {
|
|||||||
assert!(login_error_message(&hv).contains("verification wasn't completed"));
|
assert!(login_error_message(&hv).contains("verification wasn't completed"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_cancelled_mailbox_prompt_is_reported_as_such() {
|
||||||
|
let error = pdfs_core::Error::Other("mailbox entry cancelled".into());
|
||||||
|
assert!(login_error_message(&error).contains("no mailbox password"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_unmapped_error_keeps_its_text() {
|
fn an_unmapped_error_keeps_its_text() {
|
||||||
let error = pdfs_core::Error::Other("something odd".into());
|
let error = pdfs_core::Error::Other("something odd".into());
|
||||||
|
|||||||
@@ -618,6 +618,7 @@ pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry {
|
|||||||
shared_by_unverified: false,
|
shared_by_unverified: false,
|
||||||
trashed_at: 0,
|
trashed_at: 0,
|
||||||
trashed_from: None,
|
trashed_from: None,
|
||||||
|
issue: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -860,6 +860,7 @@ pub(crate) fn refresh(ui: &Rc<Ui>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
offer_unlock(ui);
|
||||||
refresh_status(ui);
|
refresh_status(ui);
|
||||||
refresh_transfers(ui);
|
refresh_transfers(ui);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -533,6 +533,7 @@ mod tests {
|
|||||||
shared_by_unverified: false,
|
shared_by_unverified: false,
|
||||||
trashed_at,
|
trashed_at,
|
||||||
trashed_from: from.map(str::to_string),
|
trashed_from: from.map(str::to_string),
|
||||||
|
issue: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! The human-verification (CAPTCHA) dialog.
|
//! The human-verification (CAPTCHA) window.
|
||||||
//!
|
//!
|
||||||
//! Proton gates logins it does not recognise — a new IP, a VPN exit — behind a
|
//! Proton gates logins it does not recognise — a new IP, a VPN exit — behind a
|
||||||
//! CAPTCHA. The API answers the login with a challenge instead of a session, and
|
//! CAPTCHA. The API answers the login with a challenge instead of a session, and
|
||||||
@@ -8,10 +8,26 @@
|
|||||||
//! The page is hosted in a `WebKitWebView` rather than handed to the system
|
//! The page is hosted in a `WebKitWebView` rather than handed to the system
|
||||||
//! browser because it reports completion by posting a message to its host, not
|
//! browser because it reports completion by posting a message to its host, not
|
||||||
//! by redirecting: a browser has nowhere to post it back to.
|
//! by redirecting: a browser has nowhere to post it back to.
|
||||||
|
//!
|
||||||
|
//! The webview runs in a child `pdfs-app` started with [`VERIFY_ARG`], not in the
|
||||||
|
//! app itself. WebKit aborts its whole process when its sandbox cannot start, as
|
||||||
|
//! on hardened systems that keep bubblewrap from the journal socket
|
||||||
|
//! (`docs/BUGS.md` B195); in a child that costs the verification, not the app.
|
||||||
|
//! The URL goes to the child on stdin, so it never shows in a process list, and
|
||||||
|
//! the token comes back on stdout.
|
||||||
|
|
||||||
use crate::*;
|
use crate::*;
|
||||||
|
use std::io::{BufRead, BufReader, Write as _};
|
||||||
|
use std::process::{ExitStatus, Stdio};
|
||||||
use webkit6::prelude::*;
|
use webkit6::prelude::*;
|
||||||
|
|
||||||
|
/// The argument that makes `pdfs-app` the verification window instead of the app.
|
||||||
|
pub(crate) const VERIFY_ARG: &str = "--human-verification";
|
||||||
|
|
||||||
|
/// Marks the token among whatever else lands on the child's stdout: GLib and
|
||||||
|
/// WebKit write debug output there.
|
||||||
|
const TOKEN_PREFIX: &str = "hv-token ";
|
||||||
|
|
||||||
/// Bridge the verification page's `postMessage` to a handler this side can read.
|
/// Bridge the verification page's `postMessage` to a handler this side can read.
|
||||||
///
|
///
|
||||||
/// The page targets its embedding host (`window.parent`), which in a webview is
|
/// The page targets its embedding host (`window.parent`), which in a webview is
|
||||||
@@ -26,15 +42,100 @@ window.addEventListener('message', function (event) {
|
|||||||
});
|
});
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
/// Present the challenge and hand the solved token back through `token_tx`.
|
/// How the verification window ended.
|
||||||
///
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
/// Closing the dialog without solving drops the sender, which the waiting login
|
pub(crate) enum Verification {
|
||||||
/// worker reads as a cancelled sign-in — the same contract as the 2FA prompt.
|
Solved(String),
|
||||||
pub(crate) fn prompt_human_verification(
|
/// Closed without solving.
|
||||||
ui: &Rc<Ui>,
|
Cancelled,
|
||||||
url: &str,
|
/// Never came up, or died: most likely WebKit could not start its sandbox.
|
||||||
token_tx: std::sync::mpsc::Sender<String>,
|
Failed,
|
||||||
) {
|
}
|
||||||
|
|
||||||
|
/// Show the challenge in the verification window and wait until it closes.
|
||||||
|
pub(crate) async fn verify_human(url: String) -> Verification {
|
||||||
|
match gio::spawn_blocking(move || run_verification_child(&url)).await {
|
||||||
|
Ok(Ok(verification)) => verification,
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
tracing::error!("cannot run the verification window: {e}");
|
||||||
|
Verification::Failed
|
||||||
|
}
|
||||||
|
Err(_) => Verification::Failed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_verification_child(url: &str) -> std::io::Result<Verification> {
|
||||||
|
let mut child = Command::new(own_exe())
|
||||||
|
.arg(VERIFY_ARG)
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.spawn()?;
|
||||||
|
if let Some(mut stdin) = child.stdin.take() {
|
||||||
|
// A failed write means the child is already gone; its exit status says
|
||||||
|
// how. Dropping `stdin` closes it.
|
||||||
|
let _ = writeln!(stdin, "{url}");
|
||||||
|
}
|
||||||
|
// Stops reading at the token rather than at EOF: WebKit's helper processes
|
||||||
|
// inherit the pipe and may hold it open a moment after the child quits.
|
||||||
|
let token = child
|
||||||
|
.stdout
|
||||||
|
.take()
|
||||||
|
.and_then(|out| find_token(BufReader::new(out)));
|
||||||
|
let status = child.wait()?;
|
||||||
|
Ok(outcome(token, status))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This very binary, so a development build opens its own window. After an
|
||||||
|
/// upgrade replaced the file, `current_exe` names a deleted path, and the
|
||||||
|
/// installed `pdfs-app` is the next best.
|
||||||
|
fn own_exe() -> PathBuf {
|
||||||
|
std::env::current_exe()
|
||||||
|
.ok()
|
||||||
|
.filter(|path| path.is_file())
|
||||||
|
.unwrap_or_else(|| PathBuf::from("pdfs-app"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_token(out: impl BufRead) -> Option<String> {
|
||||||
|
out.lines()
|
||||||
|
.map_while(Result::ok)
|
||||||
|
.find_map(|line| line.strip_prefix(TOKEN_PREFIX).map(str::to_owned))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A token wins over the exit status: the user solved the page, even if
|
||||||
|
/// WebKit's teardown crashed after it.
|
||||||
|
fn outcome(token: Option<String>, status: ExitStatus) -> Verification {
|
||||||
|
match token {
|
||||||
|
Some(token) => Verification::Solved(token),
|
||||||
|
None if status.success() => Verification::Cancelled,
|
||||||
|
None => {
|
||||||
|
tracing::error!(%status, "the verification window failed");
|
||||||
|
Verification::Failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run as the verification window: read the page's URL from stdin, show it, and
|
||||||
|
/// print the token once the user has solved it. Closing the window exits
|
||||||
|
/// without a token, which the app reads as a cancelled sign-in.
|
||||||
|
pub(crate) fn run_verification_window() -> glib::ExitCode {
|
||||||
|
let mut url = String::new();
|
||||||
|
if std::io::stdin().lock().read_line(&mut url).is_err() || url.trim().is_empty() {
|
||||||
|
tracing::error!("{VERIFY_ARG} expects the verification URL on stdin");
|
||||||
|
return glib::ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
let url = url.trim().to_owned();
|
||||||
|
|
||||||
|
// Not unique: the app that started it already holds APP_ID.
|
||||||
|
let app = adw::Application::builder()
|
||||||
|
.flags(gio::ApplicationFlags::NON_UNIQUE)
|
||||||
|
.build();
|
||||||
|
app.connect_activate(move |app| build_verification_window(app, &url));
|
||||||
|
// GApplication would refuse VERIFY_ARG as an unknown option.
|
||||||
|
let argv0 = std::env::args().next().unwrap_or_default();
|
||||||
|
app.run_with_args(&[argv0])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_verification_window(app: &adw::Application, url: &str) {
|
||||||
let content = webkit6::UserContentManager::new();
|
let content = webkit6::UserContentManager::new();
|
||||||
content.add_script(&webkit6::UserScript::new(
|
content.add_script(&webkit6::UserScript::new(
|
||||||
BRIDGE,
|
BRIDGE,
|
||||||
@@ -53,24 +154,19 @@ pub(crate) fn prompt_human_verification(
|
|||||||
.hexpand(true)
|
.hexpand(true)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let dialog = adw::Dialog::builder()
|
let body = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
||||||
|
body.append(&adw::HeaderBar::new());
|
||||||
|
body.append(&webview);
|
||||||
|
let window = adw::ApplicationWindow::builder()
|
||||||
|
.application(app)
|
||||||
.title(gettext("Verification"))
|
.title(gettext("Verification"))
|
||||||
.content_width(420)
|
.icon_name(APP_ID)
|
||||||
.content_height(560)
|
.default_width(420)
|
||||||
|
.default_height(560)
|
||||||
|
.content(&body)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let header = adw::HeaderBar::new();
|
let app = app.downgrade();
|
||||||
let body = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
|
||||||
body.append(&header);
|
|
||||||
body.append(&webview);
|
|
||||||
dialog.set_child(Some(&body));
|
|
||||||
|
|
||||||
// Taken on the first success *or* on close, so the token is sent at most
|
|
||||||
// once and a cancelled dialog always drops the sender.
|
|
||||||
let token_tx = Rc::new(RefCell::new(Some(token_tx)));
|
|
||||||
|
|
||||||
let tx = token_tx.clone();
|
|
||||||
let dlg_weak = dialog.downgrade();
|
|
||||||
content.connect_script_message_received(Some("hv"), move |_, value| {
|
content.connect_script_message_received(Some("hv"), move |_, value| {
|
||||||
let Some(token) = extract_token(&value.to_str()) else {
|
let Some(token) = extract_token(&value.to_str()) else {
|
||||||
// Every `postMessage` on the page reaches here, most of them the
|
// Every `postMessage` on the page reaches here, most of them the
|
||||||
@@ -78,25 +174,17 @@ pub(crate) fn prompt_human_verification(
|
|||||||
// is simply not ours.
|
// is simply not ours.
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if let Some(tx) = tx.borrow_mut().take() {
|
let mut stdout = std::io::stdout().lock();
|
||||||
let _ = tx.send(token);
|
if let Err(e) = writeln!(stdout, "{TOKEN_PREFIX}{token}").and_then(|()| stdout.flush()) {
|
||||||
|
tracing::error!("cannot hand the verification token back: {e}");
|
||||||
}
|
}
|
||||||
if let Some(dlg) = dlg_weak.upgrade() {
|
if let Some(app) = app.upgrade() {
|
||||||
dlg.close();
|
app.quit();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Covers the close button, Escape, and the user giving up.
|
|
||||||
dialog.connect_closed(move |_| {
|
|
||||||
token_tx.borrow_mut().take();
|
|
||||||
});
|
|
||||||
|
|
||||||
webview.load_uri(url);
|
webview.load_uri(url);
|
||||||
|
window.present();
|
||||||
let parent = ui.login.login_button.root().and_downcast::<gtk4::Window>();
|
|
||||||
// Returns immediately; the login worker stays blocked on the channel until
|
|
||||||
// the handler above sends, or the dialog closes and drops the sender.
|
|
||||||
dialog.present(parent.as_ref());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pull the verification token out of a message posted by the verification page.
|
/// Pull the verification token out of a message posted by the verification page.
|
||||||
@@ -133,7 +221,12 @@ fn extract_token(raw: &str) -> Option<String> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::extract_token;
|
use super::{Verification, extract_token, find_token, outcome};
|
||||||
|
use std::os::unix::process::ExitStatusExt as _;
|
||||||
|
use std::process::ExitStatus;
|
||||||
|
|
||||||
|
/// The wait status of a process killed by SIGABRT.
|
||||||
|
const ABORTED: i32 = 6;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_completion_message_yields_its_token() {
|
fn a_completion_message_yields_its_token() {
|
||||||
@@ -163,4 +256,39 @@ mod tests {
|
|||||||
assert!(extract_token(raw).is_none(), "accepted: {raw}");
|
assert!(extract_token(raw).is_none(), "accepted: {raw}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_token_is_found_among_other_output() {
|
||||||
|
let out = "Gtk-Message: something\nhv-token tok-1\nmore\n";
|
||||||
|
assert_eq!(find_token(out.as_bytes()).as_deref(), Some("tok-1"));
|
||||||
|
assert_eq!(find_token("only chatter\n".as_bytes()), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_window_closed_without_a_token_cancels() {
|
||||||
|
assert_eq!(
|
||||||
|
outcome(None, ExitStatus::from_raw(0)),
|
||||||
|
Verification::Cancelled
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WebKit aborts when its sandbox cannot start (B195). That must read as a
|
||||||
|
/// failure the app explains, not as the user giving up.
|
||||||
|
#[test]
|
||||||
|
fn a_window_that_died_fails() {
|
||||||
|
assert_eq!(
|
||||||
|
outcome(None, ExitStatus::from_raw(ABORTED)),
|
||||||
|
Verification::Failed
|
||||||
|
);
|
||||||
|
let exited_1 = ExitStatus::from_raw(1 << 8);
|
||||||
|
assert_eq!(outcome(None, exited_1), Verification::Failed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_token_counts_even_if_the_window_died_after_it() {
|
||||||
|
assert_eq!(
|
||||||
|
outcome(Some("tok-1".into()), ExitStatus::from_raw(ABORTED)),
|
||||||
|
Verification::Solved("tok-1".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -133,7 +133,7 @@ impl Drop for Busy {
|
|||||||
/// from Shared or Shared-with-me may not: it can live under a device folder's
|
/// from Shared or Shared-with-me may not: it can live under a device folder's
|
||||||
/// own inode space, or on someone else's volume, neither of which the primary
|
/// own inode space, or on someone else's volume, neither of which the primary
|
||||||
/// mount's path space can name. Those carry a uid instead, and the daemon
|
/// mount's path space can name. Those carry a uid instead, and the daemon
|
||||||
/// resolves it with `Core::resolve_anywhere` (mount-architecture.md P4).
|
/// resolves it with `Core::resolve_anywhere`.
|
||||||
///
|
///
|
||||||
/// The two are separate request variants rather than one request with an
|
/// The two are separate request variants rather than one request with an
|
||||||
/// optional field on purpose: an older daemon that ignored `uid` would resolve
|
/// optional field on purpose: an older daemon that ignored `uid` would resolve
|
||||||
|
|||||||
@@ -166,6 +166,15 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
|
|||||||
// No daemon: describe login state instead so the menu is still useful.
|
// No daemon: describe login state instead so the menu is still useful.
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
let (line, phase) = match auth::load() {
|
let (line, phase) = match auth::load() {
|
||||||
|
Ok(s) if matches!(auth::key_state(), Ok(auth::KeyState::Locked)) => (
|
||||||
|
// Translators: {username} is the Proton account name. The
|
||||||
|
// account has a mailbox password that has to be entered.
|
||||||
|
gettext_f(
|
||||||
|
"Signed in as {username} — locked",
|
||||||
|
&[("username", &s.username)],
|
||||||
|
),
|
||||||
|
Phase::Attention,
|
||||||
|
),
|
||||||
Ok(s) => (
|
Ok(s) => (
|
||||||
// Translators: {username} is the Proton account name.
|
// Translators: {username} is the Proton account name.
|
||||||
gettext_f(
|
gettext_f(
|
||||||
@@ -476,6 +485,10 @@ fn main() -> Result<()> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The socket lives in the state dir, which nothing has made yet when the app
|
||||||
|
// starts the tray on a fresh install (B194).
|
||||||
|
dirs.ensure().context("create the app's directories")?;
|
||||||
|
|
||||||
// Ensure only one instance of the tray runs.
|
// Ensure only one instance of the tray runs.
|
||||||
let tray_sock = dirs.tray_socket();
|
let tray_sock = dirs.tray_socket();
|
||||||
if std::os::unix::net::UnixStream::connect(&tray_sock).is_ok() {
|
if std::os::unix::net::UnixStream::connect(&tray_sock).is_ok() {
|
||||||
|
|||||||
+532
-468
File diff suppressed because it is too large.
Load diff
+5086
-3158
File diff suppressed because it is too large.
Load diff
@@ -11,6 +11,248 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **One unreadable folder stopped a synced folder from syncing at all.** A folder you may not
|
||||||
|
read, such as a cache a container created, failed every pass, so nothing else in the synced
|
||||||
|
folder reached Drive. Such a folder is now left out and named once in the activity feed, and
|
||||||
|
everything around it syncs. A file deleted during a scan, and a name that is not UTF-8, no
|
||||||
|
longer fail the pass either.
|
||||||
|
([#33](https://github.com/narrrl/proton-drive-linux/issues/33))
|
||||||
|
- **Switching a folder to online-only could delete files that were never on Drive.** Ignored
|
||||||
|
folders such as `.git/`, symlinks, unreadable folders and files changed after the last sync were
|
||||||
|
deleted with the local copy. The switch now refuses and names what is only on this computer.
|
||||||
|
- **Switching to online-only trashed synced files that had become ignored.** Adding a synced path
|
||||||
|
to `.pdfsignore` and then switching moved it to the Drive trash. Ignoring never deletes now, as
|
||||||
|
the user guide says.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Move another computer's backup to this one.** **Move to This Computer…** on the Computers page,
|
||||||
|
or `pdfs devices migrate UID`, moves every folder another computer backs up, for example an old
|
||||||
|
Windows PC, into this computer's backup on Proton Drive. Then it offers them for syncing here. The
|
||||||
|
folders move on Proton Drive itself, so nothing is uploaded again. The other computer stays in the
|
||||||
|
account, without those folders.
|
||||||
|
|
||||||
|
## [3.1.2] — 2026-10-08
|
||||||
|
|
||||||
|
Schema: **42**, unchanged.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **The app crashed when Proton asked for a CAPTCHA on hardened systems.** On secureblue, WebKit
|
||||||
|
cannot start the sandbox for the verification page and ends its process, which took the app
|
||||||
|
down with it. The page now opens in a separate window process: if that process fails, the
|
||||||
|
sign-in stops with a message that says why, and TROUBLESHOOTING.md has a workaround.
|
||||||
|
([#29](https://github.com/narrrl/proton-drive-linux/issues/29))
|
||||||
|
- **On a fresh install the tray icon did not start.** The tray and the app now create their state
|
||||||
|
directory themselves instead of waiting for the service to do it. This also lets the Takeout
|
||||||
|
page remember staged archives before the first mount.
|
||||||
|
([#29](https://github.com/narrrl/proton-drive-linux/issues/29))
|
||||||
|
|
||||||
|
## [3.1.1] — 2026-10-07
|
||||||
|
|
||||||
|
Schema: **42**, unchanged.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **The client identifies itself with its release.** Sign-in and every API call used to send the
|
||||||
|
fixed `external-drive-linux@0.1.0-alpha` and `proton-drive-linux/0.1.0`. If Proton retired that
|
||||||
|
version, every release would fail to sign in at once and no update could fix it. The client now
|
||||||
|
sends `external-drive-linux@<version>-stable` and `proton-drive-linux/<version>`. `app_version`
|
||||||
|
and `user_agent` in `config.json` are optional overrides now. The fixed values that releases up
|
||||||
|
to 3.1.0 wrote there count as unset and are removed on the next save. A value you set by hand is
|
||||||
|
kept.
|
||||||
|
- The About dialog shows the release version, and its debug info shows the identity that is
|
||||||
|
actually sent.
|
||||||
|
|
||||||
|
## [3.1.0] — 2026-10-07
|
||||||
|
|
||||||
|
Schema: **42**, unchanged.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Two-password accounts.** An account with a separate mailbox password now signs in: the app
|
||||||
|
and `pdfs login` ask for it after the login password and check it against the account, so a
|
||||||
|
typo is a retry and not a daemon that never mounts. The mailbox password is never stored.
|
||||||
|
What it unlocks is kept in kernel memory for the login session, so it is asked for once per
|
||||||
|
session and is gone after a reboot or when you log out of the machine.
|
||||||
|
- **`pdfs unlock` and `pdfs lock`.** `pdfs unlock` enters the mailbox password of a locked
|
||||||
|
account and starts the daemon's mount; `pdfs lock` forgets it again. `--remember` on `login`
|
||||||
|
and `unlock` (and the "Keep unlocked on this computer" switch in the app) keeps the unlock in
|
||||||
|
the system keyring instead, so the daemon starts without asking; `pdfs lock --forget` removes
|
||||||
|
it. Off by default: it gives up the separation a second password is for.
|
||||||
|
- **Locked state.** `pdfs status`, `pdfs diagnose` and the tray say when the account is waiting
|
||||||
|
for its mailbox password, and the app asks for it when it opens.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **The keyring no longer holds a password.** The session blob keeps the keys' passphrases
|
||||||
|
instead of the mailbox password, and a blob written by 3.0.x is converted on the first start.
|
||||||
|
A stored password the account rejects (a two-password account that was signed in with only
|
||||||
|
its login password) leaves the account locked instead of failing every start.
|
||||||
|
- **The daemon is not dumpable.** It holds the unlock in memory, so core dumps and same-user
|
||||||
|
`ptrace` are off for it.
|
||||||
|
- **The offline cache is re-keyed** from the new secret, so the first start after the update
|
||||||
|
reads it as cold and fetches the tree again once.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **systemd killed a daemon that was only retrying.** `WatchdogSec` was answered only once a
|
||||||
|
mount existed, so a daemon waiting for a login or retrying a failed mount was aborted after
|
||||||
|
two minutes and restarted in a loop, hiding the error it logged. It now answers the watchdog
|
||||||
|
while it waits. ([#26](https://github.com/narrrl/proton-drive-linux/issues/26))
|
||||||
|
- **A refreshed token could overwrite a newer unlock or revive a signed-out session.** Token
|
||||||
|
rotation now updates only the tokens in the stored blob.
|
||||||
|
|
||||||
|
## [3.0.0] — 2026-10-05
|
||||||
|
|
||||||
|
Schema: **42**. The queue table is rebuilt so an id is never given out twice; queued changes
|
||||||
|
are kept. Queued changes get a column for the reason Drive refused them, and two for the
|
||||||
|
content a new file was sent with and where, and name their file and folder by local id as well
|
||||||
|
as by Drive's id. Stored files and folders name their parent
|
||||||
|
folder by local id too. A file or folder made on this machine that Drive does not have yet goes
|
||||||
|
by its local id, in the queue, the stored tree and pins alike.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Sync issues.** A change Drive refuses because the storage is full, access was taken away,
|
||||||
|
its folder is gone, a folder limit is reached or Drive rejected it shows up at once as needing
|
||||||
|
attention, with what it means and what to do, instead of after six failed attempts. The
|
||||||
|
change stays queued and is still retried.
|
||||||
|
- **Export a queued file.** The Sync page's Export button and `pdfs sync export <id> <dest>`
|
||||||
|
save a copy of a queued upload's content. The upload stays queued.
|
||||||
|
- **`pdfs sync issues`** lists the changes that need attention; `pdfs sync queue` shows the
|
||||||
|
issue next to each change.
|
||||||
|
- **Discard a queued change.** The Sync page's Discard button and `pdfs sync discard <id>` drop
|
||||||
|
a change Drive will not take and undo it here: a new file or folder goes, anything else goes
|
||||||
|
back to what Drive has. Export a file first to keep its content.
|
||||||
|
- **Sync issues show on the file.** A file or folder with a change Drive refused carries a
|
||||||
|
warning badge in the file browser, and its tooltip says what it means and what to do.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Making, renaming and deleting through the mount no longer waits for Drive.** A new folder,
|
||||||
|
a new file, a rename or a delete is recorded locally and the call returns at once; the queue
|
||||||
|
sends it, as it does offline. `"local_first": false` in `config.json` brings back the old way
|
||||||
|
for this release.
|
||||||
|
- **The queue sends up to 16 changes at once instead of 3.** A thousand new files in a hundred
|
||||||
|
folders reach Drive about three times as fast on a LAN, and four times as fast on Wi-Fi.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **A file made under the name of a file whose upload is still going out keeps its name.** It
|
||||||
|
no longer lands as a conflict copy, and the mount no longer shows that copy's name (B154).
|
||||||
|
- **A change to a file no longer lands as a conflict copy of itself when the link loses Drive's
|
||||||
|
answer to its upload** (B155).
|
||||||
|
- **A file written again while its upload was being retried no longer lands as a conflict copy
|
||||||
|
of itself when the link lost Drive's answer to the first try** (B156).
|
||||||
|
- **Writing to a file twice in a row no longer fails with "Input/output error" when the link
|
||||||
|
lost Drive's answer to reading back the file's last upload** (B157).
|
||||||
|
- **A file renamed twice in a row, while the first name was still taken, keeps the second name**
|
||||||
|
(B158).
|
||||||
|
- **Opening a file right after the daemon restarts no longer fails with "Resource temporarily
|
||||||
|
unavailable"** (B159).
|
||||||
|
- **A new folder, or a new empty file, reaches Drive at once**, not up to half a minute later
|
||||||
|
(B160, B192).
|
||||||
|
- **A file moved while it was still being uploaded keeps its new name in the mount** on a slow
|
||||||
|
link (B161).
|
||||||
|
- **A folder removed and made again at once keeps what goes into it.** It used to be created
|
||||||
|
inside the removed folder and was trashed along with it (B162).
|
||||||
|
- **A new file no longer reads as empty just after it is uploaded.** Drive can list it a moment
|
||||||
|
before its contents are committed, and the mount took that answer (B163).
|
||||||
|
- **A folder no longer lists a new file twice while it is uploaded.** A listing taken as the
|
||||||
|
upload landed showed it both as Drive had it and as the queue did (B164).
|
||||||
|
- **Stopping the daemon no longer hangs until systemd kills it.** A stop waits up to 10 s for
|
||||||
|
work in progress, then exits and logs what was still running (B165).
|
||||||
|
- **A folder no longer drops or empties a new file while others are uploaded.** A file whose
|
||||||
|
upload landed while the folder was being listed was missing from that listing, or listed as
|
||||||
|
empty (B166).
|
||||||
|
- **The mount comes up faster on a large Drive.** Rebuilding the folder listings from the cache
|
||||||
|
took close to 2 s with 37,000 files at every start (B167).
|
||||||
|
- **Files deleted with their folder while uploading stay deleted.** The mount no longer tries
|
||||||
|
to upload them to the top of your Drive instead (B168).
|
||||||
|
- **Stopping the daemon during a folder sync no longer waits for the sync.** The uploads in
|
||||||
|
progress finish, and the rest waits for the next start (B169).
|
||||||
|
- **Writing to a file again just as its upload finishes no longer fails.** A write right after
|
||||||
|
it failed with "Input/output error" (B170).
|
||||||
|
- **A file deleted while its upload was retrying is removed from Drive right away.** It no
|
||||||
|
longer waits for the upload's next retry (B171).
|
||||||
|
- **A file deleted just as it reached Drive is removed from Drive too.** It no longer stayed
|
||||||
|
there, and the next file with that name no longer became a conflict copy (B173).
|
||||||
|
- **Renaming a file just as it reaches Drive no longer fails with "Permission denied"**
|
||||||
|
(B174).
|
||||||
|
- **A file replaced by a rename just as it reached Drive is removed from Drive too.** It no
|
||||||
|
longer stayed there, and the next rename of its name no longer moved it instead (B175).
|
||||||
|
- **A write closed just as its file reached Drive is no longer lost** (B176).
|
||||||
|
- **Opening a folder just as it reaches Drive no longer fails with "Input/output error"**
|
||||||
|
(B177).
|
||||||
|
- **Deleting a folder while sync is paused no longer leaves a queued change behind** for a
|
||||||
|
file in it that was never uploaded (B178).
|
||||||
|
- **A file read just as it reaches Drive no longer reads empty** (B179).
|
||||||
|
- **Stopping the daemon no longer takes 10 seconds more than it needs** when an idle upload
|
||||||
|
worker missed the stop (B180).
|
||||||
|
- **A folder made while offline or with sync paused no longer fails with "Input/output
|
||||||
|
error"** once it is refreshed or the connection comes back (B125).
|
||||||
|
- **The daemon no longer hangs when a file being read changes on Drive** (B130).
|
||||||
|
- **The Activity page says in your language why an edit was kept as a conflicting copy**,
|
||||||
|
instead of the daemon's English (B181).
|
||||||
|
- **A file written after a restart no longer lands as a conflict copy of itself** when the
|
||||||
|
connection dropped just as it first reached Drive (B182).
|
||||||
|
- **A file deleted after a restart is removed from Drive too** when the connection lost Drive's
|
||||||
|
answer to its upload and the file was written again meanwhile. It no longer stayed there and
|
||||||
|
turned the next file under its name into a conflict copy (B183).
|
||||||
|
- **A file renamed while the connection lost Drive's answer to its upload lands once.** It no
|
||||||
|
longer stayed on Drive under its old name too, or, when deleted, under its old name alone
|
||||||
|
(B184).
|
||||||
|
- **A new file written again just as it starts uploading no longer waits out a retry** before
|
||||||
|
it reaches Drive (B185).
|
||||||
|
- **A file deleted while open can still be read and written until it is closed**, offline too.
|
||||||
|
A write into it no longer failed with "Input/output error" (B186).
|
||||||
|
- **An uploaded file no longer leaves a copy of itself in the cache** until the next start
|
||||||
|
(B187).
|
||||||
|
- **Changes that failed during a short outage go out soon after the connection is back**, not
|
||||||
|
after a retry delay of up to five minutes (B189).
|
||||||
|
- **A file saved just before the daemon stops reaches Drive** with what was written. It could
|
||||||
|
keep its old content, or stay empty (B190).
|
||||||
|
- **A file renamed just as its upload finished keeps its new name.** The mount no longer showed
|
||||||
|
the old name, and a second rename no longer failed with "No such file or directory" (B152,
|
||||||
|
B172).
|
||||||
|
- **A change that waits for a name to be freed no longer waits seconds after it is.** A rename or
|
||||||
|
upload over a file being deleted goes out as soon as the delete lands (B153).
|
||||||
|
- **A file deleted right after it was made no longer stays on Drive when the link lost Drive's
|
||||||
|
answer.** The node Drive made is trashed (B151).
|
||||||
|
- **A new file no longer lands twice when the link loses Drive's answer to its upload.** The
|
||||||
|
file Drive made is taken as it is, instead of a second upload as a conflict copy (B127).
|
||||||
|
- **A change to a file deleted elsewhere, together with its folder, no longer retries forever.**
|
||||||
|
The conflict copy that keeps it now lands in the location's root (B150).
|
||||||
|
- **`pdfs rename` and `pdfs move` right after a change through the mount are no longer undone.**
|
||||||
|
They are queued behind it, and they work on a file that has not reached Drive yet (B149).
|
||||||
|
- **A folder kept offline before it reached Drive stays kept offline.** Its pin was lost when the
|
||||||
|
folder landed (B148).
|
||||||
|
- **A queued change no longer holds up the queue forever on a link that stops answering.**
|
||||||
|
Sending a new folder, a delete or a file waits at most 20 seconds for Drive, plus time for
|
||||||
|
the file's size, and then the queue treats the link as down and tries again.
|
||||||
|
- **A file deleted while its first upload is on the wire no longer stays on Drive.** The node
|
||||||
|
the upload made is trashed as soon as it lands, so its name is free again (B129).
|
||||||
|
- **A delete queued while a rename is sent is no longer lost.** The rename landing could remove
|
||||||
|
the delete from the queue, leaving the file on Drive (B137).
|
||||||
|
- **A file made offline over one deleted offline is no longer lost.** Its upload could take
|
||||||
|
over the deleted file, and the delete then took the new bytes along with it (B128).
|
||||||
|
- **A file deleted while its upload is on the wire no longer comes back as a `recovered-…`
|
||||||
|
copy** when another file is made right after (B133).
|
||||||
|
- **A file made where another one is leaving no longer lands as a conflict copy.** Its upload
|
||||||
|
waits for the rename or trash that frees the name (B126, B138).
|
||||||
|
- **A file closed just as its first upload lands keeps its last write.** The write was refused
|
||||||
|
and left in staging (B139).
|
||||||
|
- **Files made in a new folder are still there after a restart.** A folder that landed as the
|
||||||
|
daemon stopped, or one listed from Drive while files in it were queued, came back empty
|
||||||
|
(B132, B140).
|
||||||
|
- **A file renamed twice in quick succession ends up under the second name.** The first rename
|
||||||
|
could land after the second and undo it (B141).
|
||||||
|
- **Renaming a new file just as its upload lands no longer fails with "Device or resource
|
||||||
|
busy".** The rename goes to the file Drive made (B142).
|
||||||
|
- **A file whose upload landed just as the connection dropped no longer gets a conflict copy at
|
||||||
|
its next save.** The daemon reads the file back once the connection returns (B143).
|
||||||
|
- **A rename whose target name was just freed no longer lands as a conflict copy.** The rename
|
||||||
|
is sent once more under the name it asked for (B144).
|
||||||
|
- **Saving a download under its final name no longer fails with "Device or resource busy".**
|
||||||
|
A file made a moment before was taken for deleted and dropped from the queue (B145).
|
||||||
|
- **A file renamed just as its upload lands keeps its new name.** The tree could take back the
|
||||||
|
name Drive still had (B146).
|
||||||
|
- **A new file made under a name another file just left no longer takes that file's place on
|
||||||
|
Drive.** Both could land on one node, and one file's content was lost (B147).
|
||||||
|
|
||||||
## [2.10.0] — 2026-10-03
|
## [2.10.0] — 2026-10-03
|
||||||
|
|
||||||
Schema: **36**. Every node row gets a local id that never changes; existing rows keep their
|
Schema: **36**. Every node row gets a local id that never changes; existing rows keep their
|
||||||
@@ -1444,6 +1686,14 @@ First stable release: FUSE files-on-demand mount, sync daemon under `proton-driv
|
|||||||
- The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated
|
- The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated
|
||||||
by a new POSIX compliance suite for the filesystem.
|
by a new POSIX compliance suite for the filesystem.
|
||||||
|
|
||||||
|
[3.1.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.1.2
|
||||||
|
|
||||||
|
[3.1.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.1.1
|
||||||
|
|
||||||
|
[3.1.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.1.0
|
||||||
|
|
||||||
|
[3.0.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.0.0
|
||||||
|
|
||||||
[2.10.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.10.0
|
[2.10.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.10.0
|
||||||
[2.9.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.9.0
|
[2.9.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.9.0
|
||||||
[2.8.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.8.2
|
[2.8.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.8.2
|
||||||
|
|||||||
@@ -82,6 +82,9 @@ commands are for scripts and for cases where the mount is not available.
|
|||||||
| `pdfs sync resume [--folder ID]` | Resume after a pause. |
|
| `pdfs sync resume [--folder ID]` | Resume after a pause. |
|
||||||
| `pdfs sync limit [--up RATE] [--down RATE]` | Cap bandwidth, for example `500K` or `2M` per second. `0` removes a cap. Without options, prints the current caps. |
|
| `pdfs sync limit [--up RATE] [--down RATE]` | Cap bandwidth, for example `500K` or `2M` per second. `0` removes a cap. Without options, prints the current caps. |
|
||||||
| `pdfs sync queue` | List changes that have not reached Proton Drive yet. |
|
| `pdfs sync queue` | List changes that have not reached Proton Drive yet. |
|
||||||
|
| `pdfs sync issues` | List queued changes Proton Drive refused or that keep failing, and what to do. |
|
||||||
|
| `pdfs sync export ID DEST` | Save a copy of a queued upload's content. `DEST` may be a folder; an existing file is never overwritten. The upload stays queued. |
|
||||||
|
| `pdfs sync discard ID [--yes]` | Drop a queued change and undo it here: a new file or folder goes, anything else goes back to what Proton Drive has. Content not exported first is lost. |
|
||||||
| `pdfs sync retry [ID]` | Retry one queued operation, or every failed one. |
|
| `pdfs sync retry [ID]` | Retry one queued operation, or every failed one. |
|
||||||
| `pdfs sync restore [--device UID] [--yes]` | Re-attach this computer's backed-up folders to local folders and download them. `--device` restores another computer's folders. See [RECOVERY.md](RECOVERY.md). |
|
| `pdfs sync restore [--device UID] [--yes]` | Re-attach this computer's backed-up folders to local folders and download them. `--device` restores another computer's folders. See [RECOVERY.md](RECOVERY.md). |
|
||||||
|
|
||||||
@@ -100,6 +103,7 @@ commands are for scripts and for cases where the mount is not available.
|
|||||||
| `pdfs devices rename UID NAME` | Rename a computer. |
|
| `pdfs devices rename UID NAME` | Rename a computer. |
|
||||||
| `pdfs devices rm UID` | Remove a computer and its backup from the account. |
|
| `pdfs devices rm UID` | Remove a computer and its backup from the account. |
|
||||||
| `pdfs devices adopt UID` | Make this machine continue another computer's backup, for example after a reinstall. `--clear` returns to matching by hostname. |
|
| `pdfs devices adopt UID` | Make this machine continue another computer's backup, for example after a reinstall. `--clear` returns to matching by hostname. |
|
||||||
|
| `pdfs devices migrate UID [--yes]` | Move another computer's folders into this computer's backup, then restore them here like `pdfs sync restore`. The other computer stays registered, without those folders. `--yes` skips the confirmation and accepts every proposed path. See [RECOVERY.md](RECOVERY.md). |
|
||||||
|
|
||||||
## Trash
|
## Trash
|
||||||
|
|
||||||
|
|||||||
+23
-6
@@ -38,7 +38,7 @@ Inside `content/`:
|
|||||||
> not reached Proton Drive and exists nowhere else.
|
> not reached Proton Drive and exists nowhere else.
|
||||||
|
|
||||||
Everything in these directories except the keyring is **decrypted**. See
|
Everything in these directories except the keyring is **decrypted**. See
|
||||||
[ARCHITECTURE.md §8](ARCHITECTURE.md#8-threat-model-what-this-client-writes-to-disk-in-plaintext).
|
[ARCHITECTURE.md §10](ARCHITECTURE.md#10-threat-model-what-this-client-writes-to-disk-in-plaintext).
|
||||||
|
|
||||||
## `config.json`
|
## `config.json`
|
||||||
|
|
||||||
@@ -80,6 +80,7 @@ fix it.
|
|||||||
| `ignore_patterns` | list | see below | — | Global ignore rules for synced folders |
|
| `ignore_patterns` | list | see below | — | Global ignore rules for synced folders |
|
||||||
| `device_uid` | string | none | Computers → Continue This Backup Here… | The computer this machine continues. Unset means "match by hostname" |
|
| `device_uid` | string | none | Computers → Continue This Backup Here… | The computer this machine continues. Unset means "match by hostname" |
|
||||||
| `conflict_sweep` | `off` / `report` / `enforce` | `report` | — | See [`conflict_sweep`](#conflict_sweep) |
|
| `conflict_sweep` | `off` / `report` / `enforce` | `report` | — | See [`conflict_sweep`](#conflict_sweep) |
|
||||||
|
| `local_first` | bool | `true` | — | See [`local_first`](#local_first) |
|
||||||
| `language` | gettext code | system | General → Appearance | Language of the app, tray and prompt, such as `de` or `pt_BR` |
|
| `language` | gettext code | system | General → Appearance | Language of the app, tray and prompt, such as `de` or `pt_BR` |
|
||||||
| `proton_theme` | bool | follow system | General → Appearance | Proton's colours instead of the system theme |
|
| `proton_theme` | bool | follow system | General → Appearance | Proton's colours instead of the system theme |
|
||||||
| `online_map` | bool | `false` | General → Appearance | Street map for Places from OpenFreeMap |
|
| `online_map` | bool | `false` | General → Appearance | Street map for Places from OpenFreeMap |
|
||||||
@@ -88,7 +89,11 @@ fix it.
|
|||||||
| `open_with` | object | `xdg-open` | — | See [`open_with`](#open_with) |
|
| `open_with` | object | `xdg-open` | — | See [`open_with`](#open_with) |
|
||||||
| `prompt` | object | built-in window | — | See [`prompt`](#prompt) |
|
| `prompt` | object | built-in window | — | See [`prompt`](#prompt) |
|
||||||
|
|
||||||
`app_version` and `user_agent` identify the client to Proton. Leave them alone.
|
`app_version` and `user_agent` identify the client to Proton. When the keys are absent, the
|
||||||
|
client sends its own release (`external-drive-linux@<version>-stable`, `proton-drive-linux/<version>`).
|
||||||
|
Leave them out unless a support case asks for a different value. The fixed
|
||||||
|
`external-drive-linux@0.1.0-alpha` and `proton-drive-linux/0.1.0` that releases up to 3.1.0 wrote
|
||||||
|
here are treated as absent and removed on the next save.
|
||||||
|
|
||||||
### `ignore_patterns`
|
### `ignore_patterns`
|
||||||
|
|
||||||
@@ -116,6 +121,15 @@ were copied from.
|
|||||||
|
|
||||||
`PDFS_CONFLICT_SWEEP` overrides the setting for one run of the service.
|
`PDFS_CONFLICT_SWEEP` overrides the setting for one run of the service.
|
||||||
|
|
||||||
|
### `local_first`
|
||||||
|
|
||||||
|
A new folder, a new file, a rename or a delete made through the mount is recorded locally and the
|
||||||
|
call returns at once. The change goes to Drive from the queue, the same way it does offline, and
|
||||||
|
`pdfs status` counts it until it lands.
|
||||||
|
|
||||||
|
Set it to `false` to have these calls wait for Drive again, as before 3.0.0. The switch is there
|
||||||
|
for one release, in case the queued path misbehaves for you; please report it if it does.
|
||||||
|
|
||||||
### `open_with`
|
### `open_with`
|
||||||
|
|
||||||
Decides how files picked in the search launcher or the app are opened. Rules are tried in order;
|
Decides how files picked in the search launcher or the app are opened. Rules are tried in order;
|
||||||
@@ -180,13 +194,16 @@ Command-line flags win over the file: `--gtk`, `--dmenu`, `--fzf` and `--menu`.
|
|||||||
| `PDFS_LOCALEDIR` | app, tray, prompt | Load translations from another directory (development) |
|
| `PDFS_LOCALEDIR` | app, tray, prompt | Load translations from another directory (development) |
|
||||||
| `PDFS_STAND_IN_WIDGETS` | app | `1` uses the libadwaita 1.5 fallback widgets on newer systems (development) |
|
| `PDFS_STAND_IN_WIDGETS` | app | `1` uses the libadwaita 1.5 fallback widgets on newer systems (development) |
|
||||||
|
|
||||||
The acceptance suite has its own `PDFS_ACCEPTANCE_*` variables, listed in [TESTING.md](TESTING.md).
|
The tests have their own variables (`PDFS_ACCEPTANCE_*`, `PDFS_SIM_*`, `PDFS_MIGRATE_DB`),
|
||||||
|
listed in [TESTING.md](TESTING.md).
|
||||||
|
|
||||||
## The systemd service
|
## The systemd service
|
||||||
|
|
||||||
`proton-drive.service` is a user unit. It restarts the service whenever it exits, restarts it when
|
`proton-drive.service` is a user unit started with your graphical session
|
||||||
it stops answering for two minutes (`WatchdogSec=120`), and caps its memory at 6 GiB. To change
|
(`WantedBy=graphical-session.target`). It restarts the service whenever it exits, restarts it
|
||||||
the unit without editing the packaged file, use a drop-in:
|
when it stops answering for two minutes (`WatchdogSec=120`), throttles it above 2 GiB of memory
|
||||||
|
(`MemoryHigh`) and stops it at 6 GiB (`MemoryMax`). To change the unit without editing the
|
||||||
|
packaged file, use a drop-in:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
systemctl --user edit proton-drive.service
|
systemctl --user edit proton-drive.service
|
||||||
|
|||||||
+16
-6
@@ -31,12 +31,12 @@ time when the installed library has them (`crates/pdfs-gui/src/compat.rs`).
|
|||||||
| Path | Contents |
|
| Path | Contents |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `crates/pdfs-core` | Shared library: authentication and keyring (`auth`), configuration (`config`), SQLite schema and queries (`db/`), content cache (`cache`), control protocol (`control`), search scoring, sync ignore rules, Google Takeout parsing, machine profile |
|
| `crates/pdfs-core` | Shared library: authentication and keyring (`auth`), configuration (`config`), SQLite schema and queries (`db/`), content cache (`cache`), control protocol (`control`), search scoring, sync ignore rules, Google Takeout parsing, machine profile |
|
||||||
| `crates/pdfs-fuse` | The daemon: FUSE filesystem, read path (`reads`), write queue (`queue`, `drain`, `upload`), sync engine (`sync/`), remote events (`background`), push events for front ends (`events`), control handlers (`control`), photos, devices, supervisor |
|
| `crates/pdfs-fuse` | The daemon: FUSE handlers (`filesystem`), the tree (`state`), reads (`reads`), the queue and the drain (`queue`, `drain`, `upload`), link state (`link`), the sync engine (`sync`, `sync/`), moves between locations (`relocate`), remote events (`background`), push events (`events`), control handlers (`control`), photos, devices, the supervisor, and the simulation tests (`sim/`) |
|
||||||
| `crates/pdfs-cli` | The `pdfs` binary |
|
| `crates/pdfs-cli` | The `pdfs` binary |
|
||||||
| `crates/pdfs-gui` | `pdfs-app`, `pdfs-tray` and `pdfs-prompt`. Pages in `src/app/pages/`, shared widgets in `src/app/widgets/`, CSS and icons in `resources/` |
|
| `crates/pdfs-gui` | `pdfs-app`, `pdfs-tray` and `pdfs-prompt`. Pages in `src/app/pages/`, shared widgets in `src/app/widgets/`, CSS and icons in `resources/` |
|
||||||
| `po/` | Translation template, catalogs and the scripts that maintain them |
|
| `po/` | Translation template, catalogs and the scripts that maintain them |
|
||||||
| `packaging/` | systemd unit, desktop files, icon, PKGBUILD, RPM spec, AUR recipes |
|
| `packaging/` | systemd unit, desktop files, icon, PKGBUILD, RPM spec, AUR recipes |
|
||||||
| `scripts/` | FUSE acceptance suite, map-data generators |
|
| `scripts/` | FUSE acceptance suite, map-data generators, the wiki publisher |
|
||||||
| `docs/` | This documentation |
|
| `docs/` | This documentation |
|
||||||
|
|
||||||
The GUI talks to the daemon only through the control socket. Anything a front end needs that the
|
The GUI talks to the daemon only through the control socket. Anything a front end needs that the
|
||||||
@@ -87,8 +87,9 @@ cargo test --workspace --locked
|
|||||||
scripts/fuse-acceptance.sh --offline-only
|
scripts/fuse-acceptance.sh --offline-only
|
||||||
```
|
```
|
||||||
|
|
||||||
For changes to the FUSE layer, the write queue or the sync engine, also run the live acceptance
|
For changes to the FUSE layer, the queue or the sync engine, also run the
|
||||||
cases described in [TESTING.md](TESTING.md).
|
[simulation runs](TESTING.md#simulation-runs), which CI runs only after a push to `main`, and the
|
||||||
|
[acceptance suite](TESTING.md#acceptance-suite) on a test account.
|
||||||
|
|
||||||
For changes to GUI strings, check that every catalog is complete:
|
For changes to GUI strings, check that every catalog is complete:
|
||||||
|
|
||||||
@@ -136,6 +137,15 @@ file to `po/POTFILES.in`, run `po/update.sh`, and translate the new entries in e
|
|||||||
`#[serde(default)]`, so older files still load, and document it in
|
`#[serde(default)]`, so older files still load, and document it in
|
||||||
[CONFIGURATION.md](CONFIGURATION.md).
|
[CONFIGURATION.md](CONFIGURATION.md).
|
||||||
|
|
||||||
|
**A bug fix.** Add or update the entry in [BUGS.md](BUGS.md#adding-an-entry), cover the fix with a
|
||||||
|
unit test, a simulation scenario or a `regression B<n>` acceptance case, and cite the entry in the
|
||||||
|
code as `docs/BUGS.md B<n>`. Do not cite planning notes that are not in the repository.
|
||||||
|
|
||||||
|
**A user-visible change.** Update the matching page (user guide, CLI reference, configuration)
|
||||||
|
and the `[Unreleased]` section of [CHANGELOG.md](CHANGELOG.md). `scripts/publish-wiki.sh` copies
|
||||||
|
the six user pages (`INSTALL`, `USER_GUIDE`, `CLI`, `CONFIGURATION`, `TROUBLESHOOTING`, `RECOVERY`)
|
||||||
|
to the GitHub wiki, so keep their file names.
|
||||||
|
|
||||||
## Releasing
|
## Releasing
|
||||||
|
|
||||||
Releases are cut from `main` by pushing a `v*` tag. `.github/workflows/release.yml` then:
|
Releases are cut from `main` by pushing a `v*` tag. `.github/workflows/release.yml` then:
|
||||||
@@ -160,5 +170,5 @@ git tag vX.Y.Z
|
|||||||
git push origin main vX.Y.Z
|
git push origin main vX.Y.Z
|
||||||
```
|
```
|
||||||
|
|
||||||
Before a release that touches the filesystem or sync, run the managed live mode matrix from
|
Before a release, work through [Before a release](TESTING.md#before-a-release). Open
|
||||||
[TESTING.md](TESTING.md). Open release-assurance items are listed in [ROADMAP.md](ROADMAP.md).
|
release-assurance items are listed in [ROADMAP.md](ROADMAP.md#release-assurance).
|
||||||
+4
-5
@@ -5,7 +5,7 @@
|
|||||||
| Page | What it covers |
|
| Page | What it covers |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [Installation](INSTALL.md) | Packages, building from source, first sign-in, updating and uninstalling |
|
| [Installation](INSTALL.md) | Packages, building from source, first sign-in, updating and uninstalling |
|
||||||
| [User guide](USER_GUIDE.md) | My files, available offline, synced folders, conflicts, Photos, sharing, Trash and versions, Computers, the search launcher, the tray |
|
| [User guide](USER_GUIDE.md) | My files, available offline, synced folders, sync issues and conflicts, Photos, sharing, Trash and versions, Computers, the search launcher, the tray |
|
||||||
| [Command-line reference](CLI.md) | Every `pdfs` command, grouped by task, and scripting with `--json` |
|
| [Command-line reference](CLI.md) | Every `pdfs` command, grouped by task, and scripting with `--json` |
|
||||||
| [Configuration](CONFIGURATION.md) | `config.json`, environment variables, file locations, the systemd service |
|
| [Configuration](CONFIGURATION.md) | `config.json`, environment variables, file locations, the systemd service |
|
||||||
| [Troubleshooting](TROUBLESHOOTING.md) | Diagnosing problems, common symptoms, starting over, reporting a bug |
|
| [Troubleshooting](TROUBLESHOOTING.md) | Diagnosing problems, common symptoms, starting over, reporting a bug |
|
||||||
@@ -16,13 +16,12 @@
|
|||||||
|
|
||||||
| Page | What it covers |
|
| Page | What it covers |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [Development guide](DEVELOPMENT.md) | Building, running and debugging from source; releasing |
|
| [Development guide](DEVELOPMENT.md) | Building, running and debugging from source; common changes; releasing |
|
||||||
| [Architecture](ARCHITECTURE.md) | Crates, read and write paths, sync engine, control protocol, threads, threat model |
|
| [Architecture](ARCHITECTURE.md) | Crates, the local tree, read and write paths, the queue, the sync engine, the control protocol, threads, threat model |
|
||||||
| [Testing](TESTING.md) | Unit tests and the FUSE acceptance suite |
|
| [Testing](TESTING.md) | Unit tests, simulation runs and the FUSE acceptance suite |
|
||||||
| [Translating](TRANSLATING.md) | Adding and updating translations |
|
| [Translating](TRANSLATING.md) | Adding and updating translations |
|
||||||
| [Roadmap](ROADMAP.md) | Planned work and verification still owed |
|
| [Roadmap](ROADMAP.md) | Planned work and verification still owed |
|
||||||
| [Bug ledger](BUGS.md) | Every tracked defect with its analysis and status |
|
| [Bug ledger](BUGS.md) | Every tracked defect with its analysis and status |
|
||||||
| [UI/UX plan](UI_UX_PLAN.md) | Design audit and notes behind the 2.x desktop app |
|
|
||||||
|
|
||||||
Also see [CONTRIBUTING.md](../CONTRIBUTING.md) and [SECURITY.md](../SECURITY.md) in the repository
|
Also see [CONTRIBUTING.md](../CONTRIBUTING.md) and [SECURITY.md](../SECURITY.md) in the repository
|
||||||
root.
|
root.
|
||||||
@@ -133,6 +133,29 @@ pdfs sync restore --device UID
|
|||||||
The folders stay part of the other computer's backup and sync in both directions. This machine
|
The folders stay part of the other computer's backup and sync in both directions. This machine
|
||||||
does not take over that computer's backup.
|
does not take over that computer's backup.
|
||||||
|
|
||||||
|
### Moving another computer's backup here
|
||||||
|
|
||||||
|
To switch computers for good, for example from a Windows PC to this Linux machine, move the old
|
||||||
|
computer's folders into this computer's backup instead. Use **Move to This Computer…** on that
|
||||||
|
computer's row, or:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pdfs devices list # find the old computer's UID
|
||||||
|
pdfs devices migrate UID
|
||||||
|
```
|
||||||
|
|
||||||
|
Each folder moves on Proton Drive itself, so nothing is uploaded again and no storage is used
|
||||||
|
twice. A folder whose name this computer already uses gets the old computer's name added, for
|
||||||
|
example `Documents (DESKTOP-1)`. The restore picker then opens, as in
|
||||||
|
[Step 3](#step-3-restore-the-folders), so you choose where each folder goes here.
|
||||||
|
|
||||||
|
The old computer stays in your account, without those folders. Unlike
|
||||||
|
[adoption](#step-2-continue-the-old-computers-backup), this machine keeps its own name and backup.
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> Stop Proton Drive on the old computer first. A client still running there sees its folders
|
||||||
|
> disappear, and may upload them again as new.
|
||||||
|
|
||||||
## 3. How the restore works
|
## 3. How the restore works
|
||||||
|
|
||||||
This section is for the curious and for anyone debugging a restore.
|
This section is for the curious and for anyone debugging a restore.
|
||||||
|
|||||||
+60
-25
@@ -1,24 +1,54 @@
|
|||||||
# Roadmap
|
# Roadmap
|
||||||
|
|
||||||
Planned work and open verification, as of 2.5.0. Individual defects and their status live in
|
Planned work and open verification after 3.0.0. Individual defects and their status are in
|
||||||
[BUGS.md](BUGS.md), which is the authoritative ledger; this page groups what is left by theme.
|
[BUGS.md](BUGS.md); this page groups what is left by theme. Nothing here has a date.
|
||||||
Nothing here is a promise of a date.
|
|
||||||
|
|
||||||
|
- [Sync engine](#sync-engine)
|
||||||
- [Data safety and recovery](#data-safety-and-recovery)
|
- [Data safety and recovery](#data-safety-and-recovery)
|
||||||
- [Features](#features)
|
- [Features](#features)
|
||||||
- [Performance and correctness](#performance-and-correctness)
|
- [Performance and correctness](#performance-and-correctness)
|
||||||
- [Verification still owed](#verification-still-owed)
|
- [Verification still owed](#verification-still-owed)
|
||||||
- [Release assurance](#release-assurance)
|
- [Release assurance](#release-assurance)
|
||||||
|
|
||||||
|
## Sync engine
|
||||||
|
|
||||||
|
3.0.0 records changes made through the mount locally and sends them from the queue
|
||||||
|
([ARCHITECTURE.md §4](ARCHITECTURE.md#4-write-path)). What comes next:
|
||||||
|
|
||||||
|
| Item | State | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Remove the online path | Not started | The old way of sending a change inside the syscall, and the `local_first` switch that brings it back, go in the next release |
|
||||||
|
| One applier for remote changes | Not started | Everything that comes from Drive (events, listings) changes the tree in one place, which is also the only place that tells the kernel. Replaces today's invalidation helpers and in-memory overlays |
|
||||||
|
| Synced folders on the queue | Not started | Mirrored folders feed the local tree and use the same queue as the mount; `sync_entry` becomes the record of the last agreed state. Switching a folder's mode then moves no data except to evict or fetch content |
|
||||||
|
| Batch trashes and moves | Deferred | See below |
|
||||||
|
| Provisional values as types | Not started | A size or block geometry that is not known yet as its own type, so it cannot reach the kernel as if it were known |
|
||||||
|
| Deadlines on every remote call | Partly done | The drain's calls and calls a syscall waits on have one; background calls outside the drain do not yet |
|
||||||
|
|
||||||
|
**Batching trashes and moves.** `trash_nodes` and `move_nodes_streaming` take many nodes per call,
|
||||||
|
but the queue sends one node per call. It was left out of 3.0.0 on purpose:
|
||||||
|
|
||||||
|
- The 3.0.0 speed target is met without it. Making 1,000 files in 100 folders through the mount
|
||||||
|
takes 5.2 s on a LAN profile and 5.0 s on a Wi-Fi profile in the simulation
|
||||||
|
(`PDFS_SIM_MEASURE`, see [TESTING.md](TESTING.md#simulation-runs)).
|
||||||
|
- The drain spends its time on creates. Each waits for Drive to list the new node before it
|
||||||
|
counts as landed, and batching does not shorten that.
|
||||||
|
- One call answering for many ops has to be split back into results per op. Races in exactly that
|
||||||
|
step were the largest class of bugs found for 3.0.0 (B15x to B17x in [BUGS.md](BUGS.md)).
|
||||||
|
|
||||||
|
Before building it, measure `rm -r` of a folder with 1,000 files on Wi-Fi. If that is slow, the
|
||||||
|
cheaper fix is to drop the queued trashes of a trashed folder's contents: trashing the folder takes
|
||||||
|
them along.
|
||||||
|
|
||||||
## Data safety and recovery
|
## Data safety and recovery
|
||||||
|
|
||||||
| Item | State | Notes |
|
| Item | State | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Protect queued changes at shutdown | Not started | A systemd inhibitor lock while `staging/` holds unsent data, a warning when signing out, and a blocking `pdfs sync flush`. Today the queue is visible through `pdfs sync queue`, the tray and **Sync → Overview**, but nothing stops a shutdown (B66) |
|
| Protect queued changes at shutdown | Not started | A systemd inhibitor lock while `staging/` holds unsent data, a warning when signing out, and a `pdfs sync flush` that waits for the queue. Today the queue is visible in `pdfs sync queue`, the tray and **Sync → Overview**, but nothing stops a shutdown (B66) |
|
||||||
| Restore pins from the machine profile | Not started | `profile.json` records pins; `sync restore` does not apply them yet |
|
| Restore pins from the machine profile | Not started | `profile.json` records pins; `pdfs sync restore` does not apply them yet |
|
||||||
| Profile backup health | Not started | A failed profile upload is a `WARN` in the log and nothing in the UI (B68) |
|
| Profile backup health | Not started | A failed profile upload is a `WARN` in the log and nothing in the app (B68) |
|
||||||
| Rotated credentials survive a keyring failure | Open | Surface and retry keyring write failures so a rotated single-use refresh token is not lost (B61) |
|
| Rotated credentials survive a keyring failure | Open | Report and retry keyring write failures, so a rotated single-use refresh token is not lost (B61) |
|
||||||
| Encrypt local state at rest | Not started | SQLCipher for `cache.db` and an AEAD for the content cache, keyed from the keyring. `sdk_cache.db` is already encrypted. See [ARCHITECTURE.md §8](ARCHITECTURE.md#8-threat-model-what-this-client-writes-to-disk-in-plaintext) |
|
| Restore never picks a new device silently | Open | Ask when the machine's device is ambiguous after a loss of local state (B67) |
|
||||||
|
| Encrypt local state at rest | Not started | SQLCipher for `cache.db` and an AEAD for the content cache, keyed from the keyring. `sdk_cache.db` is already encrypted. See [ARCHITECTURE.md §10](ARCHITECTURE.md#10-threat-model-what-this-client-writes-to-disk-in-plaintext) |
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -26,9 +56,10 @@ Nothing here is a promise of a date.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| File-manager integration | Not started | Context-menu actions (available offline, share link, versions) and sync badges for Nautilus, Dolphin and Thunar |
|
| File-manager integration | Not started | Context-menu actions (available offline, share link, versions) and sync badges for Nautilus, Dolphin and Thunar |
|
||||||
| Terminal UI | Not started | `pdfs tui` with transfers, sync state and logs |
|
| Terminal UI | Not started | `pdfs tui` with transfers, sync state and logs |
|
||||||
| Multiple accounts | Not started | Separate config, state and mountpoint per profile, one daemon each |
|
| Multiple accounts | Not started | Separate config, state and mountpoint per account, one daemon each |
|
||||||
| Mount options | Not started | Read-only mount, `allow_other` |
|
| Mount options | Not started | Read-only mount, `allow_other` |
|
||||||
| Symbolic links | Not started | Proton Drive has no link type; links would need a placeholder format. Until then, creating one fails with `EOPNOTSUPP` |
|
| Symbolic links | Not started | Proton Drive has no link type; links would need a placeholder format. Until then, `symlink` fails with `EPERM` |
|
||||||
|
| `fsync` that waits for Drive | Idea | `fsync` means "durable locally". A mount option or an xattr could offer "on Drive" without slowing the default |
|
||||||
| Add someone else's photo to your album | Blocked | Needs `copyPhoto` in the SDK |
|
| Add someone else's photo to your album | Blocked | Needs `copyPhoto` in the SDK |
|
||||||
| Photo name search | Not started | Photos search by date and filters only |
|
| Photo name search | Not started | Photos search by date and filters only |
|
||||||
| Automatic photo upload from a folder | Not started | |
|
| Automatic photo upload from a folder | Not started | |
|
||||||
@@ -39,34 +70,38 @@ Nothing here is a promise of a date.
|
|||||||
|
|
||||||
| Item | Notes |
|
| Item | Notes |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Saturating stream-ring accounting | `self.bytes -= …` in `crates/pdfs-fuse/src/reads.rs` should saturate so an accounting drift cannot wrap in release builds |
|
| Saturating stream-ring accounting | `self.bytes -= …` in `crates/pdfs-fuse/src/reads.rs` should saturate, so an accounting drift cannot wrap in release builds |
|
||||||
| Block geometry learned one read late | The first read of a file plans on 4 MiB blocks; accepted as a bounded cost (B87) |
|
| Block geometry learned one read late | The first read of a file plans on 4 MiB blocks; accepted as a bounded cost (B87) |
|
||||||
| Path-based commands under secondary mounts | `pin`, `rename`, `move`, `rm` and sharing cannot name a path inside an on-demand synced folder yet (B88) |
|
| Path-based commands under online-only folders | `pin`, `unpin`, opening a file, uploads, sharing and versions cannot name a path inside an online-only synced folder yet. `rm`, `rename`, `mkdir` and `move` can (B88) |
|
||||||
| Unicode normalization | NFC and NFD names are distinct, deliberately (B36) |
|
| Unicode normalization | NFC and NFD names are distinct, on purpose (B36) |
|
||||||
| Pipelined node enumeration | Faster cold listings of large folders |
|
| Pipelined node enumeration | Faster first listings of large folders |
|
||||||
|
|
||||||
## Verification still owed
|
## Verification still owed
|
||||||
|
|
||||||
Fixes marked *unverified* in [BUGS.md](BUGS.md) have unit or offline coverage but have not been
|
Fixes marked *unverified* in [BUGS.md](BUGS.md) have unit or simulation coverage but have not
|
||||||
driven against a live account. The larger outstanding runs:
|
been driven against a live account. The larger runs still owed:
|
||||||
|
|
||||||
|
- **A clean account run on 3.0.0.** The last account run before the release (2026-10-04, on Wi-Fi)
|
||||||
|
failed only its cleanup, on a script bug fixed since. No full run with `--journal-check` has
|
||||||
|
passed on the released code yet.
|
||||||
- **Disaster-recovery drill.** Register a device, sync a folder, delete all local state, restore
|
- **Disaster-recovery drill.** Register a device, sync a folder, delete all local state, restore
|
||||||
with `pdfs sync restore` and compare the tree byte for byte. See [RECOVERY.md](RECOVERY.md).
|
with `pdfs sync restore`, and compare the tree byte for byte. See [RECOVERY.md](RECOVERY.md).
|
||||||
- **Second-account sharing.** Shared trees on a foreign volume, accepting invitations, changing
|
- **Second-account sharing.** Shared folders on a foreign volume, accepting invitations, changing
|
||||||
roles, the viewer cases of `regression B34`, and an editor writing into someone else's share.
|
roles, the viewer cases of `regression B34`, and an editor writing into someone else's share.
|
||||||
- **Fault injection.** Network loss mid-upload, a full disk during staging, and crash points
|
- **Fault injection on a real account.** Network loss mid-upload, a full disk during staging, and
|
||||||
across the write publication path (after each fsync, rename and queue commit).
|
crashes at each step of publishing a write. The simulation covers link loss and restarts against
|
||||||
|
the fake Drive, not a kill at an arbitrary point.
|
||||||
|
- **Acceptance runs across a network drop.** A case that spans an outage times out while the queue
|
||||||
|
replays, instead of being reported as interrupted.
|
||||||
- **CAPTCHA sign-in** against a real verification request (B8).
|
- **CAPTCHA sign-in** against a real verification request (B8).
|
||||||
- **Managed live mode matrix** before each release:
|
- **The overnight hang** (B91) is not diagnosed. The supervisor and `pdfs diagnostics` exist to
|
||||||
`scripts/fuse-acceptance.sh --managed-live <empty-a> <empty-b>`. See [TESTING.md](TESTING.md).
|
catch the next one.
|
||||||
- **The overnight hang** (B91) is not diagnosed yet. The supervisor and `pdfs diagnostics` exist to
|
|
||||||
catch the next occurrence.
|
|
||||||
|
|
||||||
## Release assurance
|
## Release assurance
|
||||||
|
|
||||||
| Item | State |
|
| Item | State |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Tag, workspace and PKGBUILD version agreement | Enforced by the release workflow |
|
| Tag, workspace and PKGBUILD version agree | Enforced by the release workflow |
|
||||||
| Clean-install test of the `.deb` and `.rpm` in a VM | Pending (B62) |
|
| Clean-install test of the `.deb` and `.rpm` in a VM | Pending (B62) |
|
||||||
| Protocol version identity between front ends and daemon | Open (B63) |
|
| Protocol version identity between front ends and daemon | Open (B63) |
|
||||||
| SBOM, provenance and checksums for release artifacts | Open (B65). The RPM is GPG-signed |
|
| SBOM, provenance and checksums for release artifacts | Open (B65). The RPM is GPG-signed |
|
||||||
+207
-451
@@ -1,509 +1,265 @@
|
|||||||
# Testing
|
# Testing
|
||||||
|
|
||||||
The normal Rust suite exercises the metadata database, sync planner, offline
|
Three layers check the client, from fast and offline to slow and against a real account:
|
||||||
queue, write staging, cache, and FUSE handler state without requiring a Proton
|
|
||||||
account:
|
|
||||||
|
|
||||||
```console
|
| Layer | Needs | Runs in CI |
|
||||||
|
|---|---|---|
|
||||||
|
| [Unit tests](#unit-tests) | Nothing | Every push and pull request |
|
||||||
|
| [Simulation runs](#simulation-runs) | `/dev/fuse` | Every push to `main` |
|
||||||
|
| [Acceptance suite](#acceptance-suite) | Nothing for the reference run; a signed-in account for the rest | The reference run only |
|
||||||
|
|
||||||
|
## Unit tests
|
||||||
|
|
||||||
|
```bash
|
||||||
cargo test --workspace
|
cargo test --workspace
|
||||||
cargo clippy --workspace --all-targets -- -D warnings
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
```
|
```
|
||||||
|
|
||||||
## Filesystem API and live FUSE acceptance suite
|
Tests sit in `#[cfg(test)]` modules next to the code. Database tests are in
|
||||||
|
`crates/pdfs-core/src/db/tests.rs`; a schema change adds one that migrates from the previous
|
||||||
|
version.
|
||||||
|
|
||||||
The account-free suite exercises the filesystem syscall contract against a
|
To check the migrations against a real database, point `PDFS_MIGRATE_DB` at it and run the
|
||||||
temporary local directory. This validates the runner and covers creation/open
|
ignored test. It migrates a copy taken with `VACUUM INTO` and never writes the database named:
|
||||||
flags, positioned and vectored I/O, truncation and sparse files, `mmap`,
|
|
||||||
`sendfile`, `copy_file_range`, durability barriers, namespace/error semantics,
|
|
||||||
`renameat2` flags, open-file lifetime, names and enumeration, readdir stability
|
|
||||||
under concurrent mutation, metadata updates, extended attributes, allocation and
|
|
||||||
punched holes, refusal semantics for unimplemented operations, concurrent I/O,
|
|
||||||
and real application workloads. It never reads application state or contacts
|
|
||||||
Proton:
|
|
||||||
|
|
||||||
```console
|
```bash
|
||||||
scripts/fuse-acceptance.sh --offline-only
|
PDFS_MIGRATE_DB=~/.local/state/proton-drive-linux/cache.db \
|
||||||
|
cargo test -p pdfs-core a_copy_of_a_real_database -- --ignored
|
||||||
```
|
```
|
||||||
|
|
||||||
Kernel/FUSE behavior and remote convergence need a real mount. With no
|
## Simulation runs
|
||||||
arguments, the script tests the account the running service is logged in to:
|
|
||||||
|
|
||||||
```console
|
The simulation runs (`crates/pdfs-fuse/src/sim/`) start real daemons with real FUSE mounts against
|
||||||
scripts/fuse-acceptance.sh # every case, every mount kind
|
an in-memory Drive, `FakeDrive`. The daemon runs as in production (drain workers, sync engine,
|
||||||
scripts/fuse-acceptance.sh --quick # one on-demand/mirror pair instead of four
|
event feed, online probe, control socket); only its Drive is fake. `FakeDrive` has the faults the
|
||||||
|
real one has: listings that lag a create, renames refused for a stale name hash, no atomic
|
||||||
|
replace, delayed and reordered events, requests that stall or lose their answer, per-request
|
||||||
|
latency, and blocks of uneven size.
|
||||||
|
|
||||||
|
A run draws a sequence of steps from a seed: syscalls through a client's mount, the link going
|
||||||
|
down and up, restarts, settles (every queue must drain within a budget), and files deleted while
|
||||||
|
the drain holds a write to them. Every syscall is checked against an in-memory model of the
|
||||||
|
filesystem as it happens. At the end every link comes up and the run checks:
|
||||||
|
|
||||||
|
1. **No loss and POSIX.** Drive's copy of each client's folder is what its model says.
|
||||||
|
2. **No false conflicts.** No conflict copy anywhere; each file has one writer.
|
||||||
|
3. **Convergence.** Each mount shows Drive's tree.
|
||||||
|
4. **Liveness.** No syscall took longer than its budget, and every queue drained once the links
|
||||||
|
were up. A call that never returns is caught by a watchdog that writes every thread's stack to
|
||||||
|
`stacks.txt` in the run's state directory.
|
||||||
|
|
||||||
|
| Test | Seeds |
|
||||||
|
|---|---|
|
||||||
|
| `one_client_on_a_good_link` | 12 |
|
||||||
|
| `one_client_deleting_files_as_they_upload` | 3 |
|
||||||
|
| `one_client_with_stale_hashes_and_reordered_echoes` | 6 |
|
||||||
|
| `one_client_on_a_flaky_link` | 4 |
|
||||||
|
| `one_client_on_a_slow_link` | 4 |
|
||||||
|
| `three_clients_one_writer_each` | 3 |
|
||||||
|
|
||||||
|
`sim/daemon.rs` holds scenario tests for single bugs, run the same way.
|
||||||
|
|
||||||
|
The tests mount FUSE, so they are ignored by default:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p pdfs-fuse --lib sim:: -- --ignored --test-threads=1 # what CI runs
|
||||||
|
PDFS_SIM_SEEDS=50 cargo test -p pdfs-fuse --lib sim::run -- --ignored --test-threads=1
|
||||||
|
PDFS_SIM_SEED=7 RUST_LOG=pdfs_fuse=debug cargo test -p pdfs-fuse --lib \
|
||||||
|
sim::run::tests::one_client_on_a_flaky_link -- --ignored --exact --nocapture
|
||||||
```
|
```
|
||||||
|
|
||||||
An account run does this, in order:
|
| Variable | Effect |
|
||||||
|
|---|---|
|
||||||
|
| `PDFS_SIM_SEEDS` | How many seeds each test runs, from 1 |
|
||||||
|
| `PDFS_SIM_SEED` | Replay one seed. A failing run prints the seed, the last steps, and where it kept its state |
|
||||||
|
| `PDFS_SIM_MEASURE` | Run `a_thousand_files_drain_as_fast_on_wifi_as_on_lan`, which times 1,000 files in 100 folders on a LAN and a Wi-Fi profile and prints the times |
|
||||||
|
|
||||||
1. Runs the account-free contract as the reference.
|
A failure is a seed that can be replayed, but the daemon uses real threads, so a race may need
|
||||||
2. Runs the contract in a fresh `pdfs-acceptance-<id>` folder in My files and
|
several replays. In CI, the `simulation` job uploads the stacks of a hung run as an artifact.
|
||||||
diffs it against the reference. After the last case it reads back a file
|
|
||||||
the run wrote and records its digest. A read that fails there, as one did
|
|
||||||
with the link down (B119), is a failed result named "a file of this run
|
|
||||||
reads back at the end", and the cleanup still runs.
|
|
||||||
3. Registers two new sync folders under `~/.cache/pdfs-acceptance/run-<id>/`
|
|
||||||
and runs the contract and the move cases in every on-demand/mirror pairing.
|
|
||||||
4. Removes everything it created, locally and remotely, and reports a
|
|
||||||
`cleanup` result. The run fails if anything is left behind.
|
|
||||||
|
|
||||||
Cleanup identifies remote folders by uid, never by name alone, so nothing of
|
## Acceptance suite
|
||||||
yours is touched. It removes each sync folder with `sync rm --delete-remote`,
|
|
||||||
trashes the My files folder, and then deletes both permanently from the trash.
|
|
||||||
It only counts a folder as gone once a fresh trash listing no longer shows it.
|
|
||||||
It then reads `pdfs sync queue` and counts any queued op that still names one
|
|
||||||
of its folders as left behind too, since such an op keeps its staged bytes and
|
|
||||||
retries forever (B102).
|
|
||||||
|
|
||||||
Ops that were queued before a target starts are not the run's. The run prints
|
`scripts/fuse-acceptance.sh` checks the filesystem contract a program sees: creation and open
|
||||||
one `NOTE` line naming them, and the queue waits ignore them. They are told
|
flags, positioned and vectored I/O, truncation and sparse files, `mmap`, `sendfile`,
|
||||||
apart by id and queue time together, since SQLite can give a new op the id of
|
`copy_file_range`, durability barriers, renames and their `renameat2` flags, open-file lifetime,
|
||||||
one that has drained.
|
names, `readdir` under concurrent change, metadata, extended attributes, holes, refusals of
|
||||||
|
unsupported operations, concurrent I/O, and real application workloads.
|
||||||
|
|
||||||
A write is queued when the kernel releases the file, and `close(2)` returns
|
> [!CAUTION]
|
||||||
without waiting for that. A case that reads `pdfs sync queue` right after
|
> Every mode except `--offline-only` creates and deletes real files in Proton Drive. Use a test
|
||||||
writing therefore waits for the ops it expects (`Daemon.queued`) instead of
|
> account with nothing you cannot lose.
|
||||||
reading once. "regression B102" saw one revision of two before it did.
|
|
||||||
|
|
||||||
Cleanup also runs on a failure, Ctrl-C, `SIGTERM` or `SIGHUP`. While it runs,
|
### Modes
|
||||||
two more interrupts are ignored; a third abandons it.
|
|
||||||
|
|
||||||
Each run records what it is about to create in
|
```bash
|
||||||
`~/.cache/pdfs-acceptance/run-<id>/manifest.json` before it creates it, and
|
scripts/fuse-acceptance.sh --offline-only # the reference run; no account
|
||||||
holds a lock on that directory. If a run dies without cleaning up (`kill -9`, a
|
scripts/fuse-acceptance.sh # the signed-in account, every mode pairing
|
||||||
hard timeout, a power cut), the next run finds the unlocked manifest and
|
scripts/fuse-acceptance.sh --quick # one on-demand/mirror pairing instead of four
|
||||||
finishes that cleanup first. It then removes stray `pdfs-acceptance-<32 hex>`
|
scripts/fuse-acceptance.sh --live /mnt/testmount # mounts you set up yourself
|
||||||
folders that no live run claims: in My files (older than
|
scripts/fuse-acceptance.sh --managed-live /mnt/a /mnt/b
|
||||||
`PDFS_ACCEPTANCE_REAP_AGE`, default one hour), under the acceptance home, and
|
|
||||||
in the trash. A run does not start while that reap leaves anything behind.
|
|
||||||
|
|
||||||
The run creates and destroys real remote folders, so a dedicated test account
|
|
||||||
without irreplaceable data is still the safest target.
|
|
||||||
|
|
||||||
`--live PATH...` tests mounts you set up yourself and leaves the sync folder
|
|
||||||
configuration alone. It runs destructive POSIX operations only below a fresh
|
|
||||||
`pdfs-acceptance-*` directory, refuses paths that are not FUSE mounts, and
|
|
||||||
removes its directory on success, failure, or interruption:
|
|
||||||
|
|
||||||
```console
|
|
||||||
scripts/fuse-acceptance.sh --live /mnt/on-demand-testmount
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**The reference run** always runs first, against a temporary local directory. It records the
|
||||||
|
facts each case establishes, and every mount is then **compared** against those facts. A case
|
||||||
|
fails when the mount differs from an ordinary filesystem, not only when it raises. A fact that may
|
||||||
|
legitimately differ (inode numbers, block counts, which optional syscalls exist) is **noted**
|
||||||
|
instead, and listed under the target's `accepted` line. The choice is made in the test body, so
|
||||||
|
the accepted differences are visible where they are accepted. Prefer `record`; use `note` only
|
||||||
|
for something a network filesystem cannot do.
|
||||||
|
|
||||||
|
**An account run** (no mode):
|
||||||
|
|
||||||
|
1. Runs the reference.
|
||||||
|
2. Runs the contract in a new `pdfs-acceptance-<id>` folder in My files, and at the end reads back
|
||||||
|
a file the run wrote.
|
||||||
|
3. Registers two sync folders under `~/.cache/pdfs-acceptance/run-<id>/` and runs the contract and
|
||||||
|
the move cases in every pairing of on-demand and mirror. Before a folder switches to on-demand,
|
||||||
|
a new file is written into it; once mounted, the file must be listed with its bytes. In every
|
||||||
|
pairing each folder must still hold the file written into it at setup.
|
||||||
|
4. Removes everything it made, locally and on Drive, and reports `cleanup`. A run that leaves
|
||||||
|
anything behind fails.
|
||||||
|
|
||||||
|
Cleanup finds remote folders by uid, never by name, so nothing of yours is touched. It removes
|
||||||
|
each sync folder with `sync rm --delete-remote`, trashes the My files folder, deletes both from
|
||||||
|
the trash for good, and checks that a fresh trash listing no longer shows them. Queued ops that
|
||||||
|
still name one of its folders count as left behind. Cleanup also runs on a failure, Ctrl-C,
|
||||||
|
`SIGTERM` or `SIGHUP`; while it runs, two more interrupts are ignored and a third abandons it.
|
||||||
|
|
||||||
|
Each run writes what it is about to create to `~/.cache/pdfs-acceptance/run-<id>/manifest.json`
|
||||||
|
first and holds a lock on that directory. A later run finishes the cleanup of a run that died, and
|
||||||
|
removes stray `pdfs-acceptance-<32 hex>` folders no live run claims: in My files (older than
|
||||||
|
`PDFS_ACCEPTANCE_REAP_AGE`, default one hour), under the acceptance home, and in the trash. It
|
||||||
|
does not start while anything is left.
|
||||||
|
|
||||||
|
Ops that were queued before the run started are not the run's: it prints one `NOTE` line naming
|
||||||
|
them, and its queue waits ignore them.
|
||||||
|
|
||||||
|
**`--live PATH...`** runs the contract on mounts you set up, and leaves the sync configuration
|
||||||
|
alone. It works only below a new `pdfs-acceptance-*` directory, refuses paths that are not FUSE
|
||||||
|
mounts, and removes its directory afterwards. The first path must be a FUSE mount; later paths may
|
||||||
|
be another mount or a mirrored folder. If all paths show the **same remote folder**, set
|
||||||
|
`PDFS_ACCEPTANCE_CONVERGENCE=1` to also check that bytes written through the first appear through
|
||||||
|
the others.
|
||||||
|
|
||||||
|
**`--managed-live A B`** takes two existing, empty, unmounted directories, registers them as sync
|
||||||
|
folders, and runs every pairing as an account run does, then deletes the remote folders. It
|
||||||
|
refuses non-empty paths, mounts, and paths already in `pdfs sync list`.
|
||||||
|
|
||||||
### Reading the output
|
### Reading the output
|
||||||
|
|
||||||
A run opens with what it is about to test: the mode, the `pdfs` binary and its
|
A run opens with the mode, the `pdfs` binary and version, Python, the kernel, the per-case limit
|
||||||
version, Python and the kernel, the per-case limit, the options and the start
|
and the options. Each target then names its root, the storage behind it, the daemon and the
|
||||||
time. Each target then names its root, the storage behind it, the daemon it
|
number of cases, followed by one line per case:
|
||||||
talks to and how many cases it runs, followed by one line per case:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
==> live FUSE /mnt/testmount
|
==> live FUSE /mnt/testmount
|
||||||
-> root /mnt/testmount/pdfs-acceptance-…
|
-> root /mnt/testmount/pdfs-acceptance-…
|
||||||
-> storage FUSE mount /mnt/testmount (fuse.protondrive)
|
-> storage FUSE mount /mnt/testmount (fuse.protondrive)
|
||||||
-> daemon pdfs 2.8.3, user@proton.me, online, 0 queued ops
|
-> daemon pdfs 3.0.0, user@proton.me, online, 0 queued ops
|
||||||
-> cases 45 of 45
|
-> cases 45 of 45
|
||||||
[ 1/45] creation and open flags .................................. ok 0.41s
|
[ 1/45] creation and open flags .................................. ok 0.41s
|
||||||
…
|
…
|
||||||
[21/45] throughput floors ........................................ ok 48.20s
|
[21/45] throughput floors ........................................ ok 48.20s
|
||||||
write 31 MiB/s, read 54 MiB/s, 4 KiB writes 12.0 MiB/s, metadata 9 ops/s
|
write 31 MiB/s, read 54 MiB/s, 4 KiB writes 12.0 MiB/s, metadata 9 ops/s
|
||||||
…
|
…
|
||||||
[43/45] regression B115: pdfs ls reports a file's real size ...... ok 6.12s
|
|
||||||
-> reference matches the ordinary filesystem in 412 compared observations
|
-> reference matches the ordinary filesystem in 412 compared observations
|
||||||
-> result 44 passed, 1 skipped in 11m02s
|
-> result 44 passed, 1 skipped in 11m02s
|
||||||
```
|
```
|
||||||
|
|
||||||
A case ends as `ok`, `FAIL`, `TIMEOUT`, `skip` when it does not apply to the
|
A case ends as `ok`, `FAIL`, `TIMEOUT`, `skip` (it does not apply to the target), or `known` (an
|
||||||
target (no daemon, no share at that role), or `known` when an `open B<n>` case
|
`open B<n>` case reproduced a bug that is still open). Dimmed lines under a case hold its details.
|
||||||
reproduced a bug that is still open (see below). The dimmed lines under a case
|
A failed `regression B<n>` case points to its entry in [BUGS.md](BUGS.md).
|
||||||
are its details: the reason for a skip, measured rates, a hash. A failed
|
|
||||||
`regression B<n>` case also points to its entry in `docs/BUGS.md`. When a case
|
|
||||||
prints something itself, such as a service restart, the case line is printed
|
|
||||||
again once the case ends.
|
|
||||||
|
|
||||||
The run ends with a table of every target's counts and time, the report paths,
|
The run ends with a table of every target, the report paths, the slowest cases, cases over
|
||||||
the slowest cases, the cases over `--budget`, the known issues that still
|
`--budget`, known issues that still reproduce, errors the daemon logged, and each failure. The
|
||||||
reproduce, any errors the daemon logged, and each failure with its target. The
|
last line is `PASS:` or `FAIL:` with the totals. Colour is used only on a terminal and never with
|
||||||
last line is `PASS:` or `FAIL:` and the totals. Colour is used only on a
|
`NO_COLOR` set.
|
||||||
terminal and never with `NO_COLOR` set.
|
|
||||||
|
|
||||||
### Edge cases and performance
|
### What the cases cover
|
||||||
|
|
||||||
Besides the syscall contract, the suite covers:
|
Besides the syscall contract:
|
||||||
|
|
||||||
- **Block boundaries.** Files of 0 bytes up to two 4 MiB blocks plus 3 bytes,
|
- **Block boundaries.** Files from 0 bytes to two 4 MiB blocks plus 3 bytes, every size next to a
|
||||||
including every size next to a boundary, then overwrites that grow, shrink
|
boundary, and overwrites that grow, shrink and reshape them.
|
||||||
and reshape them.
|
- **Unusual names.** 255 bytes, NFC and NFD forms of the same text, newline, tab, emoji,
|
||||||
- **Unusual names.** A 255-byte name, NFC and NFD forms of the same text,
|
characters Windows forbids, leading and trailing spaces, a case-only rename.
|
||||||
newline, tab, emoji, characters Windows forbids, leading and trailing
|
- **A deep tree and a wide folder.** 24 levels renamed at the top; 128 entries created
|
||||||
spaces, and a case-only rename.
|
concurrently, unlinked concurrently, then `rm -rf`.
|
||||||
- **A deep tree and a wide directory.** 24 levels of nesting renamed at the
|
- **Rename patterns.** Swaps, chains, log rotation, replacing a file under an open reader, the
|
||||||
top; 128 entries created concurrently, concurrent unlinks, then `rm -rf`.
|
`EISDIR`, `ENOTDIR` and `ENOENT` refusals.
|
||||||
Online, each create is a round trip to the server, and the kernel runs
|
- **Handle coherency.** Unsaved writes seen by a second handle, two `O_APPEND` writers, 30 rewrite
|
||||||
creates in one directory one at a time. So the wide case gets twice the
|
generations, a truncate under a reader.
|
||||||
per-case timeout and notes the time per create as `wide.create_ms`.
|
- **Throughput.** 64 MiB written and read, 1 MiB in 4 KiB writes, metadata on 100 files. The case
|
||||||
- **Rename patterns.** Swaps, chains, log rotation, replacing a file under an
|
fails below `PDFS_ACCEPTANCE_MIN_MIBPS` (default 10) or `PDFS_ACCEPTANCE_MIN_OPS` (default 20
|
||||||
open reader, the `EISDIR`/`ENOTDIR`/`ENOENT` refusals, and a folder moved out
|
operations per second, 3 on a FUSE mount). `0` turns a floor off.
|
||||||
and back.
|
- **Operations the filesystem does not implement.** `git`, `rsync`, `cp -a`, `tar` and installers
|
||||||
- **Handle coherency.** Unsynced writes seen by a second handle, two `O_APPEND`
|
probe for symlinks, hard links, FIFOs, device nodes, locks, `O_DIRECT` and `SEEK_HOLE`. Each must
|
||||||
writers, 30 rewrite generations, and a truncate under a reader.
|
work or be refused cleanly: a known errno, never `EIO`, never a hang.
|
||||||
- **Throughput.** 64 MiB written and read sequentially, 1 MiB in 4 KiB writes,
|
- **Moves between locations** (`managed` in `--list`). Trees moved between two sync folders in
|
||||||
and metadata operations on 100 files. The rates are printed. The case fails
|
both directions, several sources in one `pdfs move`, and a tree through My files and out again.
|
||||||
below `PDFS_ACCEPTANCE_MIN_MIBPS` (default 10) or `PDFS_ACCEPTANCE_MIN_OPS`
|
The refusals must move nothing and lose nothing: a name already taken, a folder into itself, a
|
||||||
(default 20 operations per second, or 3 on a FUSE mount, where each create
|
mirrored source holding something Drive lacks, a file still open for writing.
|
||||||
and unlink waits for Drive before it returns).
|
- **The CLI.** `pdfs` into a closed pipe exits 0 with nothing on stderr; `pdfs mkdir`, `rename`
|
||||||
|
and `rm` by local path, and their refusals.
|
||||||
|
|
||||||
### The mount is diffed against an ordinary filesystem
|
### Regressions and open bugs
|
||||||
|
|
||||||
The local reference run is not only a self-test of the runner. Every case
|
A case named `regression B<n>` reproduces an entry of [BUGS.md](BUGS.md) so it stays fixed. Most
|
||||||
records the facts it established, and a live run is compared against those
|
need a live mount and a reachable daemon, and are skipped without one. `--list` prints every case
|
||||||
recordings, so a case fails when the mount *differs* from an ordinary
|
and the targets it runs on.
|
||||||
filesystem — not only when it raises. The target's `reference` line names the
|
|
||||||
exact key:
|
|
||||||
|
|
||||||
```
|
A regression case checks the outcome **on Drive**, so it waits for the queue first. Reading back
|
||||||
-> reference 1 observation of 412 differs from the ordinary filesystem:
|
through the mount proves nothing: the local caches return the bytes just written whether or not
|
||||||
! creation and open flags.truncated.size: live FUSE /mnt/testmount=6 reference=0
|
they reached Drive. A write is queued when the kernel releases the file, after `close(2)` has
|
||||||
```
|
returned, so a case that reads `pdfs sync queue` right after writing waits for the ops it expects.
|
||||||
|
|
||||||
Facts are recorded in one of two ways, chosen in the test body:
|
A case named `open B<n>` checks a bug that is still open. While it reproduces, the case ends as
|
||||||
|
`known`: listed at the end, but not a failure. When it passes, the bug is fixed: rename the case to
|
||||||
|
`regression B<n>` and make it fail with `check`. No case is open at the moment.
|
||||||
|
|
||||||
- **compared** — a claim about filesystem semantics. The mount must agree.
|
Some cases need what a test account cannot provide alone and skip without it: B34 needs a folder
|
||||||
- **noted** — context that legitimately differs: inode numbers, block counts,
|
shared read-only by a **second account**, B34b one shared with editor rights, and B79 an on-demand
|
||||||
reported mode bits, and which optional syscalls exist at all. Listed under
|
synced folder.
|
||||||
the target's `accepted` line, never a failure.
|
|
||||||
|
|
||||||
Because the choice is made at each call site, the set of accepted divergences
|
### Timeouts, reports and hung mounts
|
||||||
is visible in the test that accepts it. There is no separate allowlist to drift
|
|
||||||
out of date. When adding a case, prefer `record`; reach for `note` only when an
|
|
||||||
ordinary filesystem can do something a network filesystem genuinely cannot.
|
|
||||||
|
|
||||||
### Operations the filesystem does not implement
|
A hung FUSE call cannot be interrupted from Python. Each case runs under a soft alarm (`--timeout`,
|
||||||
|
default 180 s, or `PDFS_ACCEPTANCE_TIMEOUT`) that reports the case and lets cleanup run, backed by
|
||||||
|
a hard stop that dumps every thread's stack and exits. After a timeout the run checks whether the
|
||||||
|
mount still answers a `stat` and a listing within 15 s. If it does, the target goes on; if not, its
|
||||||
|
remaining cases are skipped. Cases that make many round trips by design get twice the limit.
|
||||||
|
|
||||||
`git`, `rsync`, `cp -a`, `tar`, `df` and most installers probe for symlinks,
|
| Option | Effect |
|
||||||
hard links, FIFOs, device nodes, `statfs`, file locks, `copy_file_range`,
|
|---|---|
|
||||||
`O_DIRECT` and `SEEK_HOLE` whether or not a filesystem offers them. The suite
|
| `--report-json PATH` | Every result and recorded observation. Attach it to a bug report |
|
||||||
asserts that each either works or is *refused cleanly* — a recognised errno,
|
| `--report-junit PATH` | JUnit XML. `known` is reported as skipped |
|
||||||
never `EIO` and never a hang. A refusal that is merely unimplemented is fine;
|
| `--budget SECONDS` | Flags cases that pass but take longer (`PDFS_ACCEPTANCE_BUDGET`) |
|
||||||
one that is dirty breaks software that never asked for the feature.
|
| `--journal-check` | Fails the run if `proton-drive.service` logged an error during it, or the warnings "queued write conflicts; keeping a conflict copy" and "a fuse worker has held the same job for a long time" |
|
||||||
|
| `--durability` | Restarts the service mid-suite and checks the bytes written before it. Proves the queue survives a restart. `PDFS_ACCEPTANCE_UNIT` names another unit |
|
||||||
### Regressions from the bug ledger
|
| `--fail-fast` | Stops at the first failure |
|
||||||
|
| `--list` | Prints every case and its targets |
|
||||||
Cases named `regression B<n>` reproduce a bug from `docs/BUGS.md` so it stays
|
|
||||||
fixed. B69 (identical rewrite must not fork a `(sync-conflict …)` copy), B70 (an
|
|
||||||
in-flight `.crdownload` must not be sealed as a revision) and B74 (a file
|
|
||||||
renamed after close must keep its content) need a real mount and a reachable
|
|
||||||
daemon; they are skipped, not failed, without one.
|
|
||||||
|
|
||||||
**B100** (a copied tree reads back at its exact sizes) copies 48 files of
|
|
||||||
assorted sizes into the mount at once, as `cp -r` does. On a live mount it
|
|
||||||
waits for the queue, checks every size and byte, runs `pdfs refresh` on each
|
|
||||||
folder to drop the cached listing, and checks again. The refresh is what
|
|
||||||
brought back the provisional sizes the bug reported.
|
|
||||||
|
|
||||||
**B101** (a write during its create's upload reaches Drive) holds new files open for
|
|
||||||
different times before writing, so their empty creates land before, during and after the
|
|
||||||
bytes arrive, then checks every file after the queue drains.
|
|
||||||
|
|
||||||
**B113** (moving a file before its upload makes no conflict) pauses sync with
|
|
||||||
`pdfs sync pause --for 10m` and writes three files. While their uploads are held,
|
|
||||||
it renames one, moves one into a folder, and renames and moves the third. It then
|
|
||||||
resumes sync, waits for the queue, and checks the bytes and names and that no
|
|
||||||
conflict copy appeared. It skips when sync was already paused. If the run dies
|
|
||||||
before the resume, the pause ends by itself after ten minutes.
|
|
||||||
|
|
||||||
**B88** (`pdfs mkdir`, `rename` and `rm` by local path) creates, renames and
|
|
||||||
removes a folder and a file by their paths in the mount. Then it checks the
|
|
||||||
refusals: `pdfs rename` of the mount's root to its own name, and `pdfs rm` of a
|
|
||||||
file outside every mount. `pdfs rm` of the root itself is tried only on a sync
|
|
||||||
folder the run created. In a mirrored sync folder all three commands must be
|
|
||||||
refused instead, and nothing may change.
|
|
||||||
|
|
||||||
**pdfs output into a closed pipe** runs `pdfs ls`, `pdfs --json ls` and
|
|
||||||
`pdfs status` with stdout on a pipe whose reader is already gone. Each must exit
|
|
||||||
0 with nothing on stderr, as `pdfs ls | head -1` needs.
|
|
||||||
|
|
||||||
**B102** (removing a folder drops the uploads queued inside it) uploads a file
|
|
||||||
into each of two nested folders, pauses sync and rewrites both. While the
|
|
||||||
revisions are held, it removes one folder with `pdfs rm` and the other through
|
|
||||||
the mount. Neither revision may stay queued, and neither folder may come back
|
|
||||||
after the resume. It skips when sync was already paused.
|
|
||||||
|
|
||||||
**B94** (a deleted `.part` file leaves nothing queued) writes a `.part` file,
|
|
||||||
checks that its create is parked, and unlinks it. The parked op must leave the
|
|
||||||
queue within 30 seconds. The sweep that lets an hour-old park through is not
|
|
||||||
driven.
|
|
||||||
|
|
||||||
**B95** (a `.tmp` file uploads at once) writes `index.tmp` and `preview.temp`
|
|
||||||
and waits for the queue, which fails at once on a parked op. `pdfs ls` must
|
|
||||||
list both, and their bytes must read back.
|
|
||||||
|
|
||||||
**B111** (renaming a file straight back works) renames a file there and back
|
|
||||||
eight times through the mount. On a mount it also does so three times with
|
|
||||||
`pdfs rename`, after the queue is empty, so the renames race nothing but Drive.
|
|
||||||
It runs against the reference too.
|
|
||||||
|
|
||||||
**B121** (a file renamed and moved at once lands) renames a file, moves it into
|
|
||||||
a subfolder and renames it back across folders through the mount, with no pause
|
|
||||||
between the steps. On a mount it then runs `pdfs rename` and `pdfs move` back to
|
|
||||||
back. The file must end up where it was sent, with its bytes, and the case
|
|
||||||
reports how long each half took. It runs against the reference too.
|
|
||||||
|
|
||||||
**B98** (an upload superseded by a write ends) writes 64 MiB and waits for its
|
|
||||||
upload to show in `pdfs transfers`, then appends to the file. The queue must
|
|
||||||
drain, the old upload must leave the transfer list, and the file must hold
|
|
||||||
every byte with no conflict copy. It skips when the upload is never seen on
|
|
||||||
the wire, and it gets twice the per-case timeout.
|
|
||||||
|
|
||||||
**B80** (`pdfs search` finds a new file after a rewrite) writes a file whose
|
|
||||||
name carries a unique token, waits for the queue, rewrites it and waits again.
|
|
||||||
`pdfs search` must find it within 30 seconds.
|
|
||||||
|
|
||||||
**B48** (an emptied folder can be removed at once) writes twelve files into a
|
|
||||||
folder, lists it, and moves one more in from elsewhere. It unlinks every
|
|
||||||
child, runs `pdfs refresh` on a mount, and checks that nothing is listed and
|
|
||||||
that `rmdir` works. It runs against the reference too.
|
|
||||||
|
|
||||||
**B114** (a name `pdfs rm` or `rename` took away stops resolving) looks up two
|
|
||||||
files, removes one with `pdfs rm` and renames the other with `pdfs rename`. At
|
|
||||||
once, neither old name may be listed or resolve.
|
|
||||||
|
|
||||||
**B115** (`pdfs ls` reports a file's real size) writes files of 6 bytes, 4097
|
|
||||||
bytes and one block plus 7, and compares the sizes `pdfs ls` lists with the
|
|
||||||
written ones, after the upload and again after `pdfs refresh`.
|
|
||||||
|
|
||||||
**B116** (trashing a folder drops the creates queued in its subfolders) makes
|
|
||||||
`b116/sub/deeper` and waits for Drive. With sync paused, it writes a `.part`
|
|
||||||
file there and renames it to its final name, so its create is due but held.
|
|
||||||
It then runs `pdfs rm b116`, and the create must be gone from the queue. It
|
|
||||||
restores the folder with `pdfs restore` before it resumes sync. If the file
|
|
||||||
ends up in the mount's root anyway, the case fails and moves the file to the
|
|
||||||
trash.
|
|
||||||
|
|
||||||
**B117** (a moved file's queued upload keeps its path) rewrites an uploaded
|
|
||||||
file with sync paused and moves it with `pdfs move`. `pdfs sync queue` must
|
|
||||||
list the upload under its new path, and the journal must not show the upload
|
|
||||||
put off with "pending operation deferred". The bytes must arrive at the new
|
|
||||||
path with no conflict copy. Without `journalctl`, only the queue is checked.
|
|
||||||
|
|
||||||
**B119** (an uploaded file stays cached) writes a small file and one of a block
|
|
||||||
plus 119 bytes, waits for the queue, and expects `pdfs ls` to list both as
|
|
||||||
cached, so reading them does not need the network.
|
|
||||||
|
|
||||||
B74 was found by exactly this mechanism: the B70 case failed, and narrowing it
|
|
||||||
produced a smaller reproduction that got its own case. The defect is fixed and
|
|
||||||
live-validated (2026-07-26); the case now guards against its return.
|
|
||||||
|
|
||||||
**B79** (an on-demand device folder accepts writes) discovers its target from
|
|
||||||
`pdfs locations --json` and skips when this machine has no mounted on-demand
|
|
||||||
folder. It is the cheap standing guard against a *cross-mount* permission
|
|
||||||
regression: the primary mount holds device-folder nodes too, and the queue guard
|
|
||||||
intersects what every live inode space says about a uid, so a fail-closed
|
|
||||||
classification in one mount denies writes in another.
|
|
||||||
|
|
||||||
**B34** (a viewer-role share is read-only) and **B34b** (an editor-role share
|
|
||||||
still writes) locate their subject through `pdfs shared-with-me --json`, which
|
|
||||||
now carries `role` and a mount-relative `path`. Both skip when the account has no
|
|
||||||
accepted share at that role — B34 needs a **second account** to have shared a
|
|
||||||
folder read-only, which is the one part of the sharing suite that cannot be
|
|
||||||
self-served. B34b degrades gracefully in the other direction: a shared *file*
|
|
||||||
gets its mode bits and `W_OK` checked but is never written to, because it is
|
|
||||||
someone else's document.
|
|
||||||
|
|
||||||
B34's fourth assertion is the load-bearing one: `pending_uploads` and
|
|
||||||
`pending_changes` must be **unchanged** across the refused writes. Mode bits
|
|
||||||
alone would pass a fix that still admitted the write into `pending_op`, which is
|
|
||||||
the actual harm — a background drain failing 403 forever.
|
|
||||||
|
|
||||||
**Synthetic `Shared with me/` directory contract** asserts the virtual directory
|
|
||||||
enumerates, reports `0555`, and refuses `mkdir`/`rmdir`/`rename`. It skips when
|
|
||||||
the directory is absent (an account with no accepted shares).
|
|
||||||
|
|
||||||
A regression case asserts against the *remote* outcome, so it must wait for the
|
|
||||||
queue to drain before reading. Reading straight back through the mount proves
|
|
||||||
nothing: the local attribute and content caches will happily return the bytes
|
|
||||||
that were just written even when nothing reached Drive, which is precisely how
|
|
||||||
B74 stayed invisible.
|
|
||||||
|
|
||||||
### Bugs that are still open
|
|
||||||
|
|
||||||
Cases named `open B<n>` check a bug that `docs/BUGS.md` still lists as open.
|
|
||||||
While the bug reproduces, the case ends as `known`: it is listed at the end of
|
|
||||||
the run, but does not fail it. So a known bug does not hide new failures, and
|
|
||||||
the run notices when a fix lands, because the case then passes. Rename it to
|
|
||||||
`regression B<n>` then, and fail it with `check` instead of `KnownIssue`. No
|
|
||||||
bug is open at the moment. B114 to B117 started as such cases.
|
|
||||||
|
|
||||||
### What a run verifies
|
|
||||||
|
|
||||||
A full account run with `--journal-check` checks these `docs/BUGS.md` entries,
|
|
||||||
many of them fixed but not yet verified:
|
|
||||||
|
|
||||||
- B48, B80, B94, B95, B98, B100, B101, B102, B111, B114, B115, B116, B117, B119
|
|
||||||
and B121, each with its own `regression B<n>` case.
|
|
||||||
- B118, with "application workloads" and "block boundaries", which failed on
|
|
||||||
it. It needs Drive to be slow to list a new node, so a clean run is weak
|
|
||||||
evidence.
|
|
||||||
- B120, with `--journal-check`, but only in a run that goes offline while it
|
|
||||||
writes. A mkdir made online goes to Drive at once, so only then does a
|
|
||||||
folder land from the queue.
|
|
||||||
- B86, with the mode-switch check in the full matrix.
|
|
||||||
- B104, B106, B108 and B109, with the cases that found them: "rename
|
|
||||||
patterns", "application workloads" and the account run's own setup.
|
|
||||||
|
|
||||||
B105, B107 and B110 are races. The suite hit each of them once, so a clean run
|
|
||||||
is weak evidence for them. `--journal-check` reports B110's stall warning, and
|
|
||||||
every case that writes checks for B105's conflict copies.
|
|
||||||
|
|
||||||
The suite does not drive:
|
|
||||||
|
|
||||||
- the GUI and the photo gallery (B81, B92, B93, B97);
|
|
||||||
- states it cannot set up: a `parent_uid` cycle (B82), staged bytes that name
|
|
||||||
no node (B96), a stuck trash refresh (B103), conflict copies left from an
|
|
||||||
earlier start (B99), a foreign FUSE mount in the local-file index (B90),
|
|
||||||
revisions without 4 MiB blocks (B85), and several shares from one owner
|
|
||||||
(B112);
|
|
||||||
- B94's hour-long expiry of a park;
|
|
||||||
- load and failure injection (B22, B27, B30, B37, B42).
|
|
||||||
|
|
||||||
Check those by hand, or with the unit tests their entries name.
|
|
||||||
|
|
||||||
### Timeouts, reports, and hung mounts
|
|
||||||
|
|
||||||
A wedged FUSE operation cannot be interrupted from Python, so each case runs
|
|
||||||
under a soft alarm (`--timeout`, default 180s) that reports the case and lets
|
|
||||||
cleanup run, backed by a hard stop that dumps every thread's stack and exits.
|
|
||||||
That backstop is the only way to learn *where* a mount hung. After a timeout the
|
|
||||||
run checks whether the mount still answers a `stat` and a listing within 15
|
|
||||||
seconds. If it does, the target goes on with its next case, so one slow stretch
|
|
||||||
of the link does not cost every later case. If it does not, the target's later
|
|
||||||
cases are not run: against a wedged mount they produce noise, not information.
|
|
||||||
Cases that make many round trips by design ("application workloads", "unusual
|
|
||||||
but legal names", "a wide directory" and "regression B98") get twice the limit.
|
|
||||||
|
|
||||||
```console
|
|
||||||
scripts/fuse-acceptance.sh --live /mnt/testmount \
|
|
||||||
--timeout 300 --report-junit results.xml --budget 30
|
|
||||||
```
|
|
||||||
|
|
||||||
`--report-json` also writes every recorded observation, which is what to attach
|
|
||||||
to a bug report. A known issue has the status `known` there; JUnit has no such
|
|
||||||
status, so `--report-junit` reports it as skipped. `--budget SECONDS` flags cases that pass but got slower —
|
|
||||||
B5 was a 186 ms-per-call regression that a pass/fail suite could not see.
|
|
||||||
`--list` prints every case and which targets it runs against, and `--fail-fast`
|
|
||||||
stops at the first failure.
|
|
||||||
|
|
||||||
`--journal-check` fails the run if `proton-drive.service` logged any error while
|
|
||||||
it ran. A suite can pass every assertion while the daemon logs failures behind
|
|
||||||
it; that has happened, and it was caught only by reading the journal by hand.
|
|
||||||
Two warnings count as errors too: "queued write conflicts; keeping a conflict
|
|
||||||
copy" (how B113 showed) and "a fuse worker has held the same job for a long
|
|
||||||
time" (how B110 showed).
|
|
||||||
|
|
||||||
With `--live`, abandoned `pdfs-acceptance-*` roots from a crashed run are
|
|
||||||
removed at startup, but only if they match the exact generated name and are
|
|
||||||
over an hour old (`PDFS_ACCEPTANCE_REAP_AGE`), so a concurrent run is never
|
|
||||||
disturbed. An account run instead uses the manifest and reaper described
|
|
||||||
above.
|
|
||||||
|
|
||||||
### Automated mode matrix
|
|
||||||
|
|
||||||
An account run drives this matrix itself, in folders it creates. To use
|
|
||||||
directories of your choice instead, the managed live runner accepts two
|
|
||||||
existing, empty, unmounted directories. It
|
|
||||||
registers both as new sync folders, waits for initial synchronization, then
|
|
||||||
tests all four pairs in order: on-demand/on-demand, on-demand/mirror,
|
|
||||||
mirror/on-demand, and mirror/mirror. Every transition must become idle, and an
|
|
||||||
on-demand folder must actually appear as a mount before its contract runs.
|
|
||||||
|
|
||||||
```console
|
|
||||||
mkdir -p /mnt/pdfs-test-a /mnt/pdfs-test-b
|
|
||||||
scripts/fuse-acceptance.sh --managed-live /mnt/pdfs-test-a /mnt/pdfs-test-b
|
|
||||||
```
|
|
||||||
|
|
||||||
This creates remote folders and permanently deletes those test remotes during
|
|
||||||
cleanup. It refuses non-empty paths, existing mounts, and paths already present
|
|
||||||
in `pdfs sync list`. Use `PDFS_ACCEPTANCE_SYNC_TIMEOUT` to extend transition
|
|
||||||
timeouts and `PDFS_ACCEPTANCE_PDFS` to select a non-installed CLI binary, for
|
|
||||||
example `target/debug/pdfs`.
|
|
||||||
|
|
||||||
After the contract, each mode pair also runs the move cases (`--list` shows
|
|
||||||
them as `managed`). They move trees between the two folders in both
|
|
||||||
directions, move several sources in one `pdfs move`, and pass a tree through
|
|
||||||
My files and out again. Every case waits for Drive before and after the move.
|
|
||||||
It then checks that the tree is only at the destination, that every byte
|
|
||||||
arrived, and that neither side made conflict copies. The refusal cases prove
|
|
||||||
that nothing moves and nothing is lost when:
|
|
||||||
|
|
||||||
- the destination already has the name;
|
|
||||||
- a folder would move into itself;
|
|
||||||
- a mirror source holds something Drive lacks (a symlink);
|
|
||||||
- a file in an on-demand source is still open for writing.
|
|
||||||
|
|
||||||
The My files case writes a test folder into your real My files and removes it
|
|
||||||
afterwards. It skips when the daemon reports no My files mount, or when the
|
|
||||||
locations are on different volumes.
|
|
||||||
|
|
||||||
Before a mirror folder switches to on-demand, the runner writes a new file into
|
|
||||||
it and syncs. Once the folder is mounted, the file must be listed with its
|
|
||||||
bytes (B86). Each switch reports a `regression B86` result under a separate
|
|
||||||
`mode switches` target. The bug only shows in a folder that was mounted in an
|
|
||||||
earlier pairing, because that mount's listing is stale. That happens in the
|
|
||||||
third pairing, so `--quick` runs the check but does not replay the bug.
|
|
||||||
|
|
||||||
In every pairing, each folder must still hold the file the runner wrote into it
|
|
||||||
at setup, byte for byte. These checks report under `mode switches` as well. A
|
|
||||||
switch to on-demand frees the local copy, so these reads go to Drive. If the
|
|
||||||
link drops, the daemon fails such a read with EIO once its two-minute read
|
|
||||||
budget runs out. That is correct, so the runner waits until the daemon reports
|
|
||||||
online again, tries once more and notes the retry under the result. Only the
|
|
||||||
second try decides the result.
|
|
||||||
|
|
||||||
### Durability drill
|
|
||||||
|
|
||||||
`--durability` restarts `proton-drive.service` in the middle of the suite and
|
|
||||||
re-verifies the bytes written before it. `staging/` and `recovery/` can hold the
|
|
||||||
only copy of a file whose upload has not finished, so this is the case that
|
|
||||||
proves the queue is persistent rather than in-memory. It is opt-in because it
|
|
||||||
interrupts a running daemon; `PDFS_ACCEPTANCE_UNIT` selects a different unit.
|
|
||||||
|
|
||||||
### Selecting cases
|
### Selecting cases
|
||||||
|
|
||||||
During development, `PDFS_ACCEPTANCE_ONLY` runs tests whose descriptive name
|
`PDFS_ACCEPTANCE_ONLY` runs the cases whose name contains the text, ignoring case. Separate several
|
||||||
contains the supplied text, ignoring case (for example
|
with commas (`PDFS_ACCEPTANCE_ONLY=B116,B117`). A filter that matches no case at all fails, as the
|
||||||
`PDFS_ACCEPTANCE_ONLY=namespace`). Separate several texts with commas to run
|
typo it is. A release run leaves it unset.
|
||||||
every case that matches any of them (`PDFS_ACCEPTANCE_ONLY=B116,B117`). A
|
|
||||||
release acceptance run must leave it unset so the complete contract executes.
|
|
||||||
|
|
||||||
A filter naming a live-only case (every `regression B<n>` is one) selects nothing
|
| Variable | Effect |
|
||||||
in the account-free reference run, which is not an error — that target simply has
|
|---|---|
|
||||||
nothing to do. Only a filter matching *no case at all* fails, as the typo it is.
|
| `PDFS_ACCEPTANCE_PDFS` | The `pdfs` binary to use, for example `target/debug/pdfs` |
|
||||||
|
| `PDFS_ACCEPTANCE_SYNC_TIMEOUT` | Seconds a sync folder may take to settle after a change of mode |
|
||||||
|
| `PDFS_ACCEPTANCE_REAP_AGE` | Age in seconds after which a stray `pdfs-acceptance-*` folder is removed (default 3600) |
|
||||||
|
| `PDFS_ACCEPTANCE_CONVERGENCE` | `1` checks that several `--live` paths show the same bytes |
|
||||||
|
| `PDFS_ACCEPTANCE_MIN_MIBPS`, `PDFS_ACCEPTANCE_MIN_OPS` | Throughput floors |
|
||||||
|
| `PDFS_ACCEPTANCE_UNIT` | The systemd unit `--durability` restarts |
|
||||||
|
|
||||||
Passing more paths runs the same filesystem suite independently against each.
|
## Before a release
|
||||||
The first path must be the FUSE mount under test; later paths may be another
|
|
||||||
FUSE mount or a normal local mirror filesystem:
|
|
||||||
|
|
||||||
```console
|
1. The quality gates and the simulation runs pass in CI.
|
||||||
PDFS_ACCEPTANCE_SYNC_TIMEOUT=180 \
|
2. A full account run with `--journal-check` passes on a test account:
|
||||||
scripts/fuse-acceptance.sh --live /mnt/on-demand-testmount /mnt/testmount
|
`scripts/fuse-acceptance.sh --journal-check`.
|
||||||
```
|
3. Fixes marked *unverified* in [BUGS.md](BUGS.md) that a run now covers are marked verified.
|
||||||
|
|
||||||
If two paths really are views of the **same remote folder**, enable an
|
What no automated run covers yet is listed under
|
||||||
additional byte-for-byte convergence check explicitly:
|
[Verification still owed](ROADMAP.md#verification-still-owed).
|
||||||
|
|
||||||
```console
|
|
||||||
PDFS_ACCEPTANCE_CONVERGENCE=1 \
|
|
||||||
scripts/fuse-acceptance.sh --live /mnt/first-view /mnt/second-view
|
|
||||||
```
|
|
||||||
|
|
||||||
Do not enable that flag merely because two sync folders belong to the same
|
|
||||||
account. Their configured remote UIDs must be identical.
|
|
||||||
|
|
||||||
Normally the live suite applies the same API contract independently to each
|
|
||||||
supplied mount. In convergence mode it exercises the primary once and verifies
|
|
||||||
that the resulting bytes appear through every secondary view. A pass is
|
|
||||||
strong evidence for those paths, not a blanket data-loss guarantee. Before a
|
|
||||||
release, also run the recovery drills in `docs/RECOVERY.md` and inspect
|
|
||||||
`docs/BUGS.md` for live-verification items.
|
|
||||||
|
|
||||||
The runner accepts already-mounted paths instead of starting a daemon. This
|
|
||||||
keeps authentication and keyring setup explicit and prevents it from silently
|
|
||||||
reusing or replacing the normal application state directory.
|
|
||||||
+38
-6
@@ -72,6 +72,20 @@ pdfs status
|
|||||||
unfamiliar network. The CLI cannot show it. Sign in with `pdfs-app`, which displays the challenge;
|
unfamiliar network. The CLI cannot show it. Sign in with `pdfs-app`, which displays the challenge;
|
||||||
the stored session then works for the CLI too.
|
the stored session then works for the CLI too.
|
||||||
|
|
||||||
|
**"The verification page couldn't be opened"** — WebKit, which shows the CAPTCHA page, could not
|
||||||
|
start its sandbox. Hardened systems such as secureblue keep its bubblewrap from the journal socket,
|
||||||
|
and the log then shows `bwrap: Can't get type of source /run/systemd/journal/socket: Permission
|
||||||
|
denied` and `Failed to fully launch dbus-proxy`. Check that `bwrap` and `xdg-dbus-proxy` are
|
||||||
|
installed. If they are, sign in once with the sandbox switched off for this one run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS=1 pdfs-app
|
||||||
|
```
|
||||||
|
|
||||||
|
Quit the app first, so this starts a new instance. The page then runs without WebKit's isolation,
|
||||||
|
so use this only for the sign-in. The stored session keeps working without it. Signing in from
|
||||||
|
your usual network, without a VPN, often avoids the CAPTCHA in the first place.
|
||||||
|
|
||||||
**Sign-in works but is forgotten after a restart** — no Secret Service provider is running, or its
|
**Sign-in works but is forgotten after a restart** — no Secret Service provider is running, or its
|
||||||
keyring is locked. Install and unlock GNOME Keyring, KWallet or KeePassXC (with Secret Service
|
keyring is locked. Install and unlock GNOME Keyring, KWallet or KeePassXC (with Secret Service
|
||||||
enabled). `pdfs diagnose` shows `keyring session` as failing in that case.
|
enabled). `pdfs diagnose` shows `keyring session` as failing in that case.
|
||||||
@@ -113,22 +127,40 @@ pdfs refresh Documents
|
|||||||
**"Permission denied" when writing** — the item is in a share where you are a viewer, or under
|
**"Permission denied" when writing** — the item is in a share where you are a viewer, or under
|
||||||
`Shared with me/`, which is read-only as a folder. Check your role with `pdfs shared-with-me`.
|
`Shared with me/`, which is read-only as a folder. Check your role with `pdfs shared-with-me`.
|
||||||
|
|
||||||
**A program says "Operation not supported"** — Proton Drive cannot store symbolic links, hard
|
**`ln` or another program fails with "Operation not permitted"** — Proton Drive cannot store
|
||||||
links, device files or FIFOs. The error is intentional. Use a synced folder for
|
symbolic links, hard links, device files or FIFOs, so creating one fails at once. The error is
|
||||||
projects that need them, or exclude those paths.
|
intentional. Use a synced folder for projects that need them, or exclude those paths.
|
||||||
|
|
||||||
## Uploads and sync
|
## Uploads and sync
|
||||||
|
|
||||||
**Something is stuck uploading** — list the queue and retry:
|
**A change shows here but not on other devices** — changes are recorded on this computer first
|
||||||
|
and sent from a queue. List what is still waiting, and what needs attention:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pdfs sync queue
|
pdfs sync queue
|
||||||
|
pdfs sync issues
|
||||||
|
```
|
||||||
|
|
||||||
|
The app shows the same on **Sync → Overview**. A change waits while the service is offline or
|
||||||
|
sync is paused (`pdfs status`), and backs off after a failure. To retry at once:
|
||||||
|
|
||||||
|
```bash
|
||||||
pdfs sync retry # every failed operation
|
pdfs sync retry # every failed operation
|
||||||
pdfs sync retry 42 # one operation
|
pdfs sync retry 42 # one operation
|
||||||
```
|
```
|
||||||
|
|
||||||
The app shows the same list on **Sync → Overview**. An operation that keeps failing shows its
|
A change Proton Drive refuses (storage full, access taken away, folder gone) is listed by
|
||||||
error there.
|
`pdfs sync issues` with what to do. It stays queued until you fix the cause or drop it. Dropping
|
||||||
|
it undoes it here; save a file's content first if you want to keep it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pdfs sync export 42 ~/Desktop/
|
||||||
|
pdfs sync discard 42
|
||||||
|
```
|
||||||
|
|
||||||
|
If you suspect the local-first behaviour of 3.0.0 itself, `"local_first": false` in `config.json`
|
||||||
|
makes the Proton Drive folder wait for Proton Drive again while online (see
|
||||||
|
[CONFIGURATION.md](CONFIGURATION.md#local_first)), and please report the problem.
|
||||||
|
|
||||||
**A file in a synced folder does not upload** — check that:
|
**A file in a synced folder does not upload** — check that:
|
||||||
|
|
||||||
|
|||||||
@@ -1,354 +0,0 @@
|
|||||||
# UI/UX audit and improvement plan
|
|
||||||
|
|
||||||
Audit date: 2026-09-23. Scope: `pdfs-gui` (all pages, dialogs, photo viewer), `pdfs-tray`, and the
|
|
||||||
backend capabilities the UI would need. File references are to `crates/pdfs-gui/src/app/` unless
|
|
||||||
stated otherwise.
|
|
||||||
|
|
||||||
## 1. Summary
|
|
||||||
|
|
||||||
The client is functionally rich but the UI is built page by page, and it shows:
|
|
||||||
|
|
||||||
- **No sync control surface.** There is no way to pause or resume sync, no list of what is stuck or
|
|
||||||
failing, and no conflict overview. The backend has no pause at all, no per-op listing, and no
|
|
||||||
per-file conflict record (conflicts exist only as `(sync-conflict <ts>)` files plus activity rows).
|
|
||||||
The GUI reads `online`, `pending_uploads` and `pending_changes` from `Status` and ignores
|
|
||||||
`failing_ops`, `failing_error`, `parked_uploads` and `staged_*`.
|
|
||||||
- **Information architecture is scattered.** Live transfers are a group on *Settings* while the
|
|
||||||
*Activity* page is history only. *Locations* and *Computers* point at each other. Import lives under
|
|
||||||
Settings but is launched from Gallery and returns to Settings. Sign-out, quota, mount status,
|
|
||||||
pinned files, import and developer info all share the Settings page.
|
|
||||||
- **Chrome is non-standard.** Every page draws its own title and toolbar row under an almost empty
|
|
||||||
`AdwHeaderBar` whose title is always "Proton Drive". Two stacked bars, wasted height, no current
|
|
||||||
folder in the title.
|
|
||||||
- **Icons and wording drift.** Upload uses `document-send-symbolic` (reads as "send"), offline uses
|
|
||||||
stars (reads as "favorite"), trash icons mean "stop syncing", "remove device" and "remove
|
|
||||||
bookmark". "Keep offline" / "Unpin" / "Remove offline copy" / "Available offline" are one concept.
|
|
||||||
"Computers" vs "device", "Gallery" vs "Photos", "mount service" vs "daemon" vs "Connect".
|
|
||||||
- **Theming is hardcoded.** `PROTON_PURPLE` overrides the system accent; badge, viewer and tile
|
|
||||||
colours are hex/rgba literals and ignore light, dark and high-contrast modes.
|
|
||||||
- **Several visible bugs** (section 9).
|
|
||||||
|
|
||||||
## 2. Design principles
|
|
||||||
|
|
||||||
1. **Follow GNOME HIG and current libadwaita.** The system has libadwaita 1.9; the crate is pinned to
|
|
||||||
feature `v1_5`. Raise it to at least `v1_7` to get `adw::Spinner`, `ToggleGroup`,
|
|
||||||
`InlineViewSwitcher`, `ButtonRow`, `WrapBox`, `BottomSheet`, and (1.8) `ShortcutsDialog`.
|
|
||||||
2. **Sync state is first class.** The user must always see: up to date / syncing / paused / offline /
|
|
||||||
needs attention, and be one click from pausing or from the list of problems.
|
|
||||||
3. **One term, one icon, one place** per concept (glossary in section 8).
|
|
||||||
4. **Secondary and destructive actions go into menus.** Rows get at most one visible suffix action
|
|
||||||
plus a `view-more-symbolic` menu. Destructive actions are red inside the menu and in the confirm
|
|
||||||
dialog, not as filled buttons in headers.
|
|
||||||
5. **Never block on the network visibly.** Keep old content on screen while reloading, show a header
|
|
||||||
spinner, never flash the whole page to a "Loading…" status page.
|
|
||||||
6. **Respect the system.** Use the system accent by default (brand purple stays on login, app icon
|
|
||||||
and an opt-in toggle). Use named colours (`@success_color`, `@warning_color`, `@error_color`).
|
|
||||||
|
|
||||||
## 3. New information architecture
|
|
||||||
|
|
||||||
### Sidebar
|
|
||||||
|
|
||||||
```
|
|
||||||
Proton Drive (header: app name)
|
|
||||||
My files
|
|
||||||
Photos
|
|
||||||
Shared with me (badge: pending invitations)
|
|
||||||
Shared by me
|
|
||||||
Computers
|
|
||||||
Trash
|
|
||||||
─────────────
|
|
||||||
Sync (badge: issue count, icon reflects state)
|
|
||||||
───────────── footer
|
|
||||||
[●] Up to date · Pause ← sync status strip, click opens Sync
|
|
||||||
▓▓▓░░░ 269 GiB of 1.6 TiB ← quota
|
|
||||||
(avatar) user@proton.me ⋮ ← account menu: Preferences, Sign out…
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Remove** sidebar rows: *Locations* (merged into Sync), *Activity* (becomes the Sync "History"
|
|
||||||
view), *Settings* (becomes an `AdwPreferencesDialog`, `Ctrl+,`).
|
|
||||||
- **Rename** *Gallery* to *Photos* and *Shared* to *Shared by me*.
|
|
||||||
- Primary menu: Preferences, Keyboard Shortcuts, About. The About dialog's `debug_info` gets app
|
|
||||||
version, user agent, daemon status and config path (replacing the "Developer" group).
|
|
||||||
|
|
||||||
### Sync page (new, replaces Locations + Activity + Settings→Activity)
|
|
||||||
|
|
||||||
Top: an `AdwBanner`-style status card with state icon, one-line summary ("Syncing 12 files · 3.4
|
|
||||||
MB/s", "Paused until you resume", "2 files need attention") and one primary button: **Pause** /
|
|
||||||
**Resume** (split button with "Pause for 1 hour / until tomorrow / until resumed").
|
|
||||||
|
|
||||||
Below, an `InlineViewSwitcher` with four views:
|
|
||||||
|
|
||||||
1. **Issues** (default when non-empty): conflicts, failing uploads (`last_error`, attempts, next
|
|
||||||
retry), folders in `error`/`conflict` state, parked uploads older than N minutes. Each row has a
|
|
||||||
primary fix action (Resolve…, Retry now) and a menu (Show in folder, Discard change…).
|
|
||||||
2. **Transfers**: in-flight uploads and downloads with progress, speed, cancel; background jobs
|
|
||||||
(thumbnail build, Takeout import, re-date) with their own cancel.
|
|
||||||
3. **Folders**: the old Locations list. Row: path, mode as a dropdown suffix (Synced / On-demand, with
|
|
||||||
confirm when going on-demand deletes local copies), state subtitle, `Sync now` and a menu (Open,
|
|
||||||
Change location…, Pause this folder, Stop syncing…). "Add folder…" as a `ButtonRow` at the end.
|
|
||||||
The primary mount appears first and is not removable.
|
|
||||||
4. **History**: the old Activity feed, with a kind filter, failures in `@error_color`, rows
|
|
||||||
activatable (open or reveal the target), and "Clear history".
|
|
||||||
|
|
||||||
### Preferences dialog (slimmed Settings)
|
|
||||||
|
|
||||||
| Page | Contents |
|
|
||||||
|---|---|
|
|
||||||
| General | Start on login; Mount location (path + Change…); Notifications (on sync issues, on import done) |
|
|
||||||
| Storage | Cache usage bar; Cache budget (spin row, in GB, "Unlimited" switch instead of magic 0); Clear cache…; Offline files (count + "Manage" → Sync › Folders filter or a sub-page) |
|
|
||||||
| Sync | Ignore patterns (editable list); Conflict clean-up: Off / Report / Remove identical copies (`conflict_sweep`); later: bandwidth limits |
|
|
||||||
| Advanced | Open-with rules, search prompt config (only if kept in GUI) |
|
|
||||||
|
|
||||||
Moved out: account row and Sign out (sidebar account menu, with confirm), quota (sidebar footer),
|
|
||||||
mount status (sidebar status strip), live transfers (Sync), Google Photos import (Photos page menu),
|
|
||||||
version/user agent (About → Debug info).
|
|
||||||
|
|
||||||
## 4. Backend work required
|
|
||||||
|
|
||||||
Everything below is new; nothing like it exists today.
|
|
||||||
|
|
||||||
### B-1 Pause and resume (global, then per folder)
|
|
||||||
|
|
||||||
- `Request::SetSyncPaused { paused: bool, until: Option<i64> }`, persisted (config key
|
|
||||||
`sync_paused_until` or a DB setting) so a pause survives restarts; `Response::Status` gains
|
|
||||||
`paused` and `paused_until`.
|
|
||||||
- Drain: `run_pending_drain` (`pdfs-fuse/src/drain.rs:265`) already releases claimed ops while
|
|
||||||
offline; gate on `paused` at the same point and reuse the wake path.
|
|
||||||
- Sync engine: add `SyncMsg::Pause/Resume` or gate `reconcile_all` / `reconcile_folder`; the 120 s
|
|
||||||
poll and watcher keep recording, they just do not apply.
|
|
||||||
- FUSE reads must keep working while paused (on-demand hydration is a read, not a sync); document that
|
|
||||||
pause means "no uploads, no mirror reconcile".
|
|
||||||
- Per folder later: `sync_folder.paused` column, skipped in `reconcile_all`.
|
|
||||||
- Bandwidth limits later: token bucket in `CountingReader` / `CountingWriter`
|
|
||||||
(`pdfs-fuse/src/transfers.rs`).
|
|
||||||
|
|
||||||
### B-2 Pending ops and transfers
|
|
||||||
|
|
||||||
- `Request::ListPendingOps` → id, kind, path/name, attempts, `last_error` (classified as
|
|
||||||
`ErrorKind`), `next_attempt_at`, parked flag, staged bytes.
|
|
||||||
- `Request::RetryOp { id }` (set `next_attempt_at = now`, wake drain) and `RetryAllFailing`.
|
|
||||||
- `Request::DiscardOp { id }` for a create/revision whose bytes should be dropped. Must save the
|
|
||||||
staged blob to a local recovery folder first; never silent data loss (see `docs/RECOVERY.md`).
|
|
||||||
- `TransferItem` gains a stable id and `Request::CancelTransfer { id }`.
|
|
||||||
- Per-folder last error message on `SyncFolderInfo` (today the text only exists in the activity feed).
|
|
||||||
|
|
||||||
### B-3 Conflicts
|
|
||||||
|
|
||||||
- `Request::ListConflicts`: walk nodes whose name parses with `sweep.rs` `conflict_base_name`, for
|
|
||||||
the mount and every mirror folder. Return copy path, original path (if it exists), both sizes and
|
|
||||||
mtimes, whether content is identical (size + `content_sha1`, as the sweep already proves), and
|
|
||||||
which side is local vs remote when known.
|
|
||||||
- Optionally a `conflict` table written by `keep_as_conflict_copy` / `preserve_conflict_copy` so the
|
|
||||||
origin (drain vs mirror, device) is recorded instead of inferred from names.
|
|
||||||
- `Request::ResolveConflict { copy, keep: Original | Copy | Both }`:
|
|
||||||
- Original: trash the copy (re-verify like the enforcing sweep does).
|
|
||||||
- Copy: upload the copy's content as a new revision of the original, then trash the copy (the old
|
|
||||||
content stays in version history, so this is reversible).
|
|
||||||
- Both: rename the copy to a user-chosen name, drop the `(sync-conflict …)` stamp.
|
|
||||||
- Activity rows for each resolution.
|
|
||||||
|
|
||||||
## 5. Shell and visual polish (all pages)
|
|
||||||
|
|
||||||
- **Per-page header bars.** Give each stack page its own `adw::ToolbarView` + `adw::HeaderBar` (or
|
|
||||||
inject page widgets into the shared bar on page switch). Title = page name, or the current folder
|
|
||||||
in My files. Page actions move into the header; the extra toolbar row disappears.
|
|
||||||
- **One page skeleton.** Same margins everywhere (today 12 vs 18), same `Clamp` rule (lists that are
|
|
||||||
settings-like clamp at 720, file/photo grids are full width), same status pages (`compact`,
|
|
||||||
`vexpand`, crossfade), same Retry semantics (Retry reloads; "Start Proton Drive" is a separate,
|
|
||||||
explicitly labelled button when the service is down).
|
|
||||||
- **Loading.** `adw::Spinner` in the header; keep the old model until the new one lands; status page
|
|
||||||
only on first load.
|
|
||||||
- **Theme.** Drop the global `accent_*` override; offer "Use Proton purple accent" in Preferences.
|
|
||||||
Replace hex colours with named colours; test light, dark and high contrast.
|
|
||||||
- **Move the 70-line CSS string** in `main.rs:232` to `resources/style.css` in the gresource
|
|
||||||
(`style-dark.css` if needed), so it can be edited and linted.
|
|
||||||
- **Shortcuts.** Replace the hand-rolled cheatsheet with `adw::ShortcutsDialog` (or
|
|
||||||
`GtkShortcutsWindow` before 1.8) listing all bindings, including F5/Ctrl+R and the viewer keys.
|
|
||||||
- **Toasts vs dialogs.** Dialogs report their own errors inline (versions, share, add bookmark); a
|
|
||||||
toast behind a modal is invisible. Pluralise properly (`ngettext`-ready helpers, no "item(s)").
|
|
||||||
- **i18n groundwork.** Wrap user strings in a `gettext!`-style macro now, even without translations,
|
|
||||||
so later localisation is mechanical.
|
|
||||||
|
|
||||||
## 6. Page-by-page plan
|
|
||||||
|
|
||||||
### My files
|
|
||||||
|
|
||||||
Header: `[‹ ›] Path bar (breadcrumbs, scroll to end, Ctrl+L to edit) … [search] [view ▾] [+ New ▾]`
|
|
||||||
|
|
||||||
- **New ▾** (`list-add-symbolic`, suggested): New folder (Ctrl+Shift+N), Upload files (Ctrl+U),
|
|
||||||
Upload folder. Replaces three separate icons, fixes the "send" icon.
|
|
||||||
- **View ▾** split button: Grid/List (Ctrl+1/2), Sort by Name/Size/Modified, ascending/descending,
|
|
||||||
Folders first, zoom slider. Persist choice (config or GSettings).
|
|
||||||
- **Folder ⋮ menu** (in header or background context menu): Refresh, Build thumbnails, Make folder
|
|
||||||
available offline, Open in file manager, Share folder…. Build thumbnails leaves the main toolbar.
|
|
||||||
- **Navigation.** Back/forward history (Alt+←/→), Up (Alt+↑); searching then pressing Back clears
|
|
||||||
the search consistently.
|
|
||||||
- **Context menus** as `gtk::PopoverMenu` from `gio::Menu` + actions: keyboard navigable, Menu /
|
|
||||||
Shift+F10, same action set as details pane. Add a background menu. Order: Open, Open with…, Play
|
|
||||||
(media) · Available offline (check item) · Share…, Copy link · Rename…, Move to…, Versions… ·
|
|
||||||
Move to Trash (destructive).
|
|
||||||
- **Offline state.** One concept "Available offline", toggle semantics. Badges: cloud-only = no
|
|
||||||
badge or subtle `cloud-outline`; downloaded = check; available offline = pin/`folder-download`
|
|
||||||
style filled icon. Use named colours. Folders can be made available offline too, or the toggle is
|
|
||||||
hidden for them (fix `details.rs:259`).
|
|
||||||
- **List view.** Sortable, resizable columns: Name, Status, Size, Modified (date + time), and
|
|
||||||
Location for search results. Rubberband selection. One selection model shared between views.
|
|
||||||
- **Details pane.** On wide windows a real side pane (breakpoint, not overlay); header bar with
|
|
||||||
close; real thumbnail; sections: Info, Offline, Sharing (people count, link), Versions (latest 3 +
|
|
||||||
"All versions…"). Same actions as the context menu.
|
|
||||||
- **Selection bar** (≥1 selected, not ≥2): count + size, Available offline, Move to…, Share (single),
|
|
||||||
Move to Trash; Select all (Ctrl+A). Drop the confirm dialog for trash since Undo exists (trash is
|
|
||||||
reversible), or keep confirm and drop Undo — not both.
|
|
||||||
- **Drag and drop.** Drop external files/folders to upload (reuse Takeout's `FileList` target), drag
|
|
||||||
the whole selection, highlight drop targets, breadcrumbs accept drops, Undo toast on move.
|
|
||||||
- **Move to…** a folder-picker dialog (tree/list of Drive folders) instead of a free-text path.
|
|
||||||
- **Rename** pre-selects the stem, not the extension; drop the redundant "Rename “x”." body.
|
|
||||||
- **Search.** Scope toggle (This folder / Everywhere), "Showing first 200 results" note, Location
|
|
||||||
column, correct cached badges (search hits hardcode `cached: false`).
|
|
||||||
- **Upload feedback.** Toast "Uploading 3 items" with "View" → Sync › Transfers.
|
|
||||||
|
|
||||||
### Photos (was Gallery)
|
|
||||||
|
|
||||||
- Header: title "Photos", `InlineViewSwitcher` Timeline | Albums in the header, Select toggle,
|
|
||||||
`+ Upload` (fix: use `adw::ButtonContent`, today `.icon_name()` overwrites `.label()` at
|
|
||||||
`photos.rs:426-432`), ⋮ menu: Import from Google Photos…, Refresh, Rebuild library.
|
|
||||||
- Filters: a single filter dropdown/`ToggleGroup` (All, Photos, Videos, RAW, Favorites) instead of
|
|
||||||
two segmented rows where "Photos" appears twice. Counts follow the active filter.
|
|
||||||
- **Month scrubber** on the right edge that *jumps* (not filters); the month dropdown becomes a real
|
|
||||||
"Filter by date" if kept.
|
|
||||||
- Infinite scroll with prefetch (no "Load more" pill; page size ≥ 200; also trigger when the first
|
|
||||||
page does not fill the viewport).
|
|
||||||
- Tiles: `ContentFit::Cover` (crop) in justified rows, known aspect ratio from metadata to prevent
|
|
||||||
reflow, placeholder as a tinted card of the final size, subtle hover (no 1.06 scale with heavy
|
|
||||||
shadow on a 2 px gap grid), last row justified up to a max stretch.
|
|
||||||
- Context menu on tiles: Open, Favorite, Add to album, Download/Save copy, Show in My files, Move to
|
|
||||||
Trash. Selection: Shift range, Ctrl+A, bulk Favorite, Add to album, Download.
|
|
||||||
- Empty states per filter ("No favorites yet", "No videos in June 2024") instead of the global
|
|
||||||
"No photos yet" with upload buttons.
|
|
||||||
- Import: runs show a banner on Photos ("Importing from Google Photos — 1,204 of 5,000 · View") and
|
|
||||||
Back returns to where the user came from.
|
|
||||||
- **Albums:** Create, rename, delete, remove from album (not trash). Fix state bugs: filter row
|
|
||||||
hidden on the album grid, subtitle count, scroll position kept when switching back.
|
|
||||||
- **Viewer:** `adw::Window` (or full-window overlay), controls auto-hide after 2 s idle, zoom/pan
|
|
||||||
(pinch, Ctrl+scroll, double-click), info panel beside the image instead of over the top bar,
|
|
||||||
pages beyond the loaded 60, counter against the real total, videos play inline with
|
|
||||||
`gtk::Video`, trash with the same confirm/undo rule as the grid, real filename in "Save a copy",
|
|
||||||
success toast, "Open with…" app chooser, favorite tooltip reflects state, readable dates.
|
|
||||||
|
|
||||||
### Shared with me / Shared by me
|
|
||||||
|
|
||||||
- Shared with me: header title shows the drill-down path, with back; invitations as a banner at
|
|
||||||
the top ("2 invitations · Review") opening a list, Reject confirmed; bookmarks become a separate
|
|
||||||
view or section via a switcher. Rows: Open, Save to My files, Available offline where supported.
|
|
||||||
- Shared by me: rows activatable (open), suffix "Manage" becomes a menu with Copy link, Manage
|
|
||||||
access…, Stop sharing….
|
|
||||||
- Share dialog: inline validation, loading spinner, confirm "Remove access" and "Delete link",
|
|
||||||
human role names, link expiry.
|
|
||||||
|
|
||||||
### Computers
|
|
||||||
|
|
||||||
- Show the real device name for "This computer" with rename inline.
|
|
||||||
- Other computers: one suffix menu (Rename, Use this identity…, Remove…). Remove uses a destructive
|
|
||||||
confirm that requires typing the device name since it deletes backups.
|
|
||||||
- "Restore folders" becomes a per-device action ("Restore to this computer…") with an inline-empty
|
|
||||||
state when nothing is restorable, and moves the "Add folder" responsibility fully to Sync.
|
|
||||||
- Terminology: "computer" everywhere in UI copy.
|
|
||||||
|
|
||||||
### Trash
|
|
||||||
|
|
||||||
- Same skeleton as other list pages (clamped `boxed-list`), sort by deleted date, multi-select with
|
|
||||||
Restore / Delete permanently, "Empty Trash…" moved into a header ⋮ menu or kept as a flat button
|
|
||||||
with a destructive confirm, proper plurals, Retry on error.
|
|
||||||
|
|
||||||
### Login
|
|
||||||
|
|
||||||
- Human error messages mapped from error kinds, spinner in the button, "Create account" and "Forgot
|
|
||||||
password" links, recovery-code hint in 2FA, cancelling CAPTCHA returns to the form with a message.
|
|
||||||
|
|
||||||
## 7. Tray
|
|
||||||
|
|
||||||
- Symbolic icon variants: `pdfs-synced`, `pdfs-syncing`, `pdfs-paused`, `pdfs-offline`,
|
|
||||||
`pdfs-attention`; tooltip with the one-line state; `NeedsAttention` status for issues.
|
|
||||||
- Menu: status line · "N issues — View" (opens Sync › Issues) · Pause sync ▸ (1 h, until tomorrow,
|
|
||||||
until resumed) / Resume · Open Proton Drive · Open folder · "Sign in…" when logged out (no
|
|
||||||
"Connect") · Stop Proton Drive (confirm) · "Hide tray icon" instead of "Quit" (daemon keeps
|
|
||||||
running and the label must not suggest otherwise).
|
|
||||||
|
|
||||||
## 8. Glossary (one term, one icon)
|
|
||||||
|
|
||||||
| Concept | UI term | Icon |
|
|
||||||
|---|---|---|
|
|
||||||
| Root of Drive | My files | `folder-symbolic` |
|
|
||||||
| Background service | Proton Drive (running / stopped) — never "daemon" or "mount service" in UI | — |
|
|
||||||
| Cloud-only file | Online only | none / `cloud-outline` |
|
|
||||||
| Pinned file | Available offline | pin/download-filled custom icon, not stars |
|
|
||||||
| Upload | Upload files / Upload folder | `document-upload`-style arrow up, custom pair |
|
|
||||||
| Folder sync mode | Synced / Online only | — |
|
|
||||||
| Where My files appears on disk | Proton Drive folder — never "mountpoint" in UI | — |
|
|
||||||
| Stop syncing a folder | Stop syncing… | `media-playback-stop-symbolic` in a menu, not trash |
|
|
||||||
| Remove a computer | Remove computer… | destructive, in menu |
|
|
||||||
| Conflict copy | Conflict | `dialog-warning-symbolic` + warning colour |
|
|
||||||
| Failure | Failed / Needs attention | `dialog-error-symbolic` + error colour |
|
|
||||||
| Pause | Pause sync / Resume sync | `media-playback-pause-symbolic` / `-start-` |
|
|
||||||
| Photos section | Photos | `image-x-generic-symbolic` |
|
|
||||||
| Sharing | Shared by me / Shared with me | distinct icons (`emblem-shared` / `folder-publicshare`) |
|
|
||||||
|
|
||||||
Spelling: US English throughout ("Favorite", "color"), matching the `pdfs favorite` command and
|
|
||||||
Proton's own apps. Title Case for
|
|
||||||
dialog headings and buttons in dialogs, Sentence case for menu items and rows (HIG).
|
|
||||||
|
|
||||||
## 9. Bugs found during the audit (quick wins)
|
|
||||||
|
|
||||||
1. Photos Upload pill loses its label (`photos.rs:426-432`); same for viewer "Show on map"
|
|
||||||
(`photo_viewer.rs:594-598`).
|
|
||||||
2. Takeout Back always goes to Settings, sidebar highlights Settings when opened from Photos
|
|
||||||
(`main.rs:779`, `takeout.rs:131-136`).
|
|
||||||
3. Thumbnail-build completion message computed then hidden (`browser.rs:1632-1667`).
|
|
||||||
4. Search results show cached files as cloud-only (`browser.rs:2344`).
|
|
||||||
5. Details "Available offline" switch shown for folders (`details.rs:259`).
|
|
||||||
6. Grid and list keep separate selections; details pane can describe a hidden-view item.
|
|
||||||
7. F2 silently does nothing with multiple items selected.
|
|
||||||
8. Filter changes on the Albums grid swap to the timeline while the Albums toggle stays active
|
|
||||||
(`photos.rs:958-997`).
|
|
||||||
9. Subtitle "60 photos" next to "All 1,416" (loaded count vs total, `photos.rs:2007-2015`); viewer
|
|
||||||
counter "12 of 60".
|
|
||||||
10. Viewer info panel covers Close/Trash/Save (`photo_viewer.rs:588` vs `648`).
|
|
||||||
11. Locations mode switch and removal reload Computers, not Locations; a rejected mode switch stays
|
|
||||||
flipped until the next tick (`devices.rs:612`, `devices.rs:809`, `locations.rs:374`).
|
|
||||||
12. Going on-demand deletes local copies without confirmation.
|
|
||||||
13. Retry on Not-connected silently restarts the systemd unit; Locations' Retry does not.
|
|
||||||
14. Toasts from Versions/Share dialogs land behind the modal.
|
|
||||||
15. "Emptied trash —" dangling dash, "item(s)", "archive(s)", raw revision id as version title,
|
|
||||||
raw lowercase link role.
|
|
||||||
16. Bulk context menu shows a separator directly under the header when a folder is selected.
|
|
||||||
17. Sign out, Reject invitation, Remove access, Delete link, tray Disconnect: no confirmation.
|
|
||||||
18. Stale doc comments (`devices.rs:46-48`, `locations.rs:329-331`, `albums.rs:4`,
|
|
||||||
`browser.rs:1155`).
|
|
||||||
|
|
||||||
## 10. Phased roadmap
|
|
||||||
|
|
||||||
Each phase is shippable on its own. Estimates are rough, for one developer.
|
|
||||||
|
|
||||||
| Phase | Content | Crates | Size |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 0 Quick wins | Section 9 bugs, glossary wording, icon swaps (upload, offline, stop syncing), confirmations, plurals | gui, tray | S (2–3 days) |
|
|
||||||
| 1 Shell | libadwaita feature bump, per-page header bars, sidebar restructure + footer (status, quota, account), Preferences dialog, About debug info, theme/colour cleanup, CSS to gresource, shortcuts dialog | gui | M (1 week) |
|
|
||||||
| 2 Sync hub + pause | B-1 global pause, B-2 list/retry pending ops, Sync page (status card, Issues, Transfers, Folders, History), tray states and pause menu, GUI uses `failing_*` / `parked_*` | core, fuse, gui, tray, cli | L (1.5–2 weeks) |
|
|
||||||
| 3 Conflicts | B-3 list + resolve, Issues rows with a compare/resolve dialog (both versions side by side: size, date, device, preview for images/text), CLI `pdfs conflicts` | core, fuse, gui, cli | M–L |
|
|
||||||
| 4 File browser | Header layout, New menu, sort/persisted view, PopoverMenu context menus, details side pane, DnD upload, folder picker for Move, search scope | gui | L |
|
|
||||||
| 5 Photos | Rename, header switcher, filters, infinite scroll + scrubber, tile layout, context menu, album management, viewer rewrite (auto-hide, zoom, inline video) | gui (+ core for album mutations) | L |
|
|
||||||
| 6 Rest | Shared pages, Computers, Trash, Login, per-folder pause, bandwidth limits, i18n wrapping | all | M |
|
|
||||||
|
|
||||||
Phase 2 is where the user-reported gaps (pause, resume, stuck items) get closed; phase 3 closes the
|
|
||||||
conflict overview. Phases 0–1 fix most of the "unpolished" impression cheaply and should come first
|
|
||||||
because later phases build on the new header/page skeleton.
|
|
||||||
|
|
||||||
### Verification per phase
|
|
||||||
|
|
||||||
- `cargo fmt --all -- --check`, `cargo clippy --workspace --all-targets -- -D warnings`,
|
|
||||||
`cargo test --workspace`.
|
|
||||||
- Backend phases: DB migration tests in `crates/pdfs-core/src/db/tests.rs` (paused state, conflict
|
|
||||||
table if added), drain tests proving no op is claimed while paused, resolve-conflict tests proving
|
|
||||||
the losing side stays recoverable (trash or version history).
|
|
||||||
- GUI phases: before/after screenshots in `images/` (light and dark), keyboard-only walkthrough of
|
|
||||||
each page, narrow-window check (sidebar collapse breakpoint).
|
|
||||||
+46
-6
@@ -7,6 +7,7 @@ How Proton Drive for Linux works and how to use it day to day. For installation
|
|||||||
- [My files](#my-files)
|
- [My files](#my-files)
|
||||||
- [Keeping files on this computer](#keeping-files-on-this-computer)
|
- [Keeping files on this computer](#keeping-files-on-this-computer)
|
||||||
- [Syncing local folders](#syncing-local-folders)
|
- [Syncing local folders](#syncing-local-folders)
|
||||||
|
- [Changes waiting for Proton Drive](#changes-waiting-for-proton-drive)
|
||||||
- [Conflicts](#conflicts)
|
- [Conflicts](#conflicts)
|
||||||
- [Pausing and limiting sync](#pausing-and-limiting-sync)
|
- [Pausing and limiting sync](#pausing-and-limiting-sync)
|
||||||
- [Photos](#photos)
|
- [Photos](#photos)
|
||||||
@@ -33,7 +34,7 @@ the service to do things over a local socket:
|
|||||||
|
|
||||||
Your Drive, your file names and your photos are end-to-end encrypted by Proton. On this computer,
|
Your Drive, your file names and your photos are end-to-end encrypted by Proton. On this computer,
|
||||||
the service must decrypt what it shows you, and it keeps a decrypted cache and index on disk. Use
|
the service must decrypt what it shows you, and it keeps a decrypted cache and index on disk. Use
|
||||||
full-disk or home-directory encryption. [ARCHITECTURE.md §8](ARCHITECTURE.md#8-threat-model-what-this-client-writes-to-disk-in-plaintext)
|
full-disk or home-directory encryption. [ARCHITECTURE.md §10](ARCHITECTURE.md#10-threat-model-what-this-client-writes-to-disk-in-plaintext)
|
||||||
lists exactly what is stored.
|
lists exactly what is stored.
|
||||||
|
|
||||||
The app uses these words consistently, and so does this guide:
|
The app uses these words consistently, and so does this guide:
|
||||||
@@ -54,9 +55,11 @@ downloads only when a program reads it:
|
|||||||
|
|
||||||
- **Reads fetch only what is needed.** A video player that seeks, or a tool that reads a file
|
- **Reads fetch only what is needed.** A video player that seeks, or a tool that reads a file
|
||||||
header, downloads only those parts. Media streams while it plays.
|
header, downloads only those parts. Media streams while it plays.
|
||||||
- **Writes are safe offline.** When a program closes a file it wrote, the service stores the new
|
- **Changes work at local speed.** Creating, renaming, moving and deleting files and folders
|
||||||
content on disk and queues the upload. The queue survives network loss, restarts and crashes.
|
returns at once, online or offline. When a program closes a file it wrote, the new content is
|
||||||
`pdfs sync queue` lists what has not reached Proton Drive yet.
|
stored on disk. Every change is queued and sent to Proton Drive in the background; the changes
|
||||||
|
to one file are sent in the order you made them. The queue survives network loss, restarts and
|
||||||
|
crashes. See [Changes waiting for Proton Drive](#changes-waiting-for-proton-drive).
|
||||||
- **Recently read content is cached** on disk up to the cache size limit (5 GiB by default) and
|
- **Recently read content is cached** on disk up to the cache size limit (5 GiB by default) and
|
||||||
evicted oldest first.
|
evicted oldest first.
|
||||||
- **Items shared with you** appear under `Shared with me/` inside the Proton Drive folder. Shares
|
- **Items shared with you** appear under `Shared with me/` inside the Proton Drive folder. Shares
|
||||||
@@ -93,7 +96,9 @@ app and in the web app.
|
|||||||
Each synced folder can also be switched to **Online only**. The local copy is replaced by a mount
|
Each synced folder can also be switched to **Online only**. The local copy is replaced by a mount
|
||||||
of the folder, so the files stay visible but take no disk space until opened. Switch with the
|
of the folder, so the files stay visible but take no disk space until opened. Switch with the
|
||||||
**Synced / Online only** switch on the folder's card, or `pdfs sync mode <id> ondemand` and back
|
**Synced / Online only** switch on the folder's card, or `pdfs sync mode <id> ondemand` and back
|
||||||
with `pdfs sync mode <id> mirror`. `pdfs locations` lists every local place the service occupies.
|
with `pdfs sync mode <id> mirror`. The switch waits until everything in the folder is on Proton
|
||||||
|
Drive, and refuses while the folder holds something that never goes there: an ignored path such
|
||||||
|
as `.git/`, a symlink, or a folder you may not read. The message names it. `pdfs locations` lists every local place the service occupies.
|
||||||
|
|
||||||
### Moving between synced folders and My files
|
### Moving between synced folders and My files
|
||||||
|
|
||||||
@@ -143,6 +148,39 @@ places, and both apply:
|
|||||||
Rules are re-read on every pass. Ignoring never deletes anything: a file that becomes ignored is
|
Rules are re-read on every pass. Ignoring never deletes anything: a file that becomes ignored is
|
||||||
simply no longer tracked, and its copy on Proton Drive stays.
|
simply no longer tracked, and its copy on Proton Drive stays.
|
||||||
|
|
||||||
|
A folder you may not read, such as a cache a container created as root, is skipped the same way,
|
||||||
|
and the activity feed names it once. Add it to `.pdfsignore` to stop that message.
|
||||||
|
|
||||||
|
## Changes waiting for Proton Drive
|
||||||
|
|
||||||
|
A change you made in the Proton Drive folder exists only on this computer until it reaches
|
||||||
|
Proton Drive. `pdfs status`, the tray and **Sync → Overview** count what is still waiting, and
|
||||||
|
`pdfs sync queue` lists it.
|
||||||
|
|
||||||
|
Sometimes Proton Drive refuses a change: the storage is full, a share was taken away, the folder
|
||||||
|
is gone, the folder holds too many items, or Drive does not accept a name. The change then needs
|
||||||
|
your attention. It shows at once in **Sync → Overview**, in the tray, and in `pdfs sync issues`,
|
||||||
|
with what it means and what you can do. A file or folder with such a change carries a warning
|
||||||
|
badge in **Files**; hover it to see why.
|
||||||
|
|
||||||
|
Nothing is deleted. The change stays queued and is retried. You can:
|
||||||
|
|
||||||
|
- **Retry** it now, from the same list or with `pdfs sync retry`, for example after freeing space.
|
||||||
|
- **Export** a file's content to keep your version (`pdfs sync export <id> <file>`).
|
||||||
|
- **Discard** the change (`pdfs sync discard <id>`). It is undone here and never sent: a new file
|
||||||
|
or folder goes away, anything else goes back to what Proton Drive has. Export first if you want
|
||||||
|
to keep the content.
|
||||||
|
|
||||||
|
Two cases are not refused, and the activity log records both:
|
||||||
|
|
||||||
|
- A new file or folder whose name is already taken on Proton Drive by something this computer had
|
||||||
|
not seen yet lands under a `(sync-conflict …)` name, as described below.
|
||||||
|
- A new file whose folder was deleted on another device meanwhile lands at the top of My files,
|
||||||
|
so its content is not lost.
|
||||||
|
|
||||||
|
Before you shut down a computer for good, check that nothing is waiting: see
|
||||||
|
[RECOVERY.md](RECOVERY.md#lost-with-the-machine).
|
||||||
|
|
||||||
## Conflicts
|
## Conflicts
|
||||||
|
|
||||||
When the same file changed on this computer and somewhere else since the last sync, neither side
|
When the same file changed on this computer and somewhere else since the last sync, neither side
|
||||||
@@ -166,7 +204,6 @@ The version you do not keep goes to the Trash, so a wrong choice can be undone.
|
|||||||
reading files keeps working. `pdfs sync resume` continues.
|
reading files keeps working. `pdfs sync resume` continues.
|
||||||
- **Limit bandwidth** under **Preferences → General → Network**, or with
|
- **Limit bandwidth** under **Preferences → General → Network**, or with
|
||||||
`pdfs sync limit --up 2M --down 10M`. `0` removes a limit.
|
`pdfs sync limit --up 2M --down 10M`. `0` removes a limit.
|
||||||
- **Retry** a failed upload from **Sync → Overview**, or with `pdfs sync retry`.
|
|
||||||
|
|
||||||
## Photos
|
## Photos
|
||||||
|
|
||||||
@@ -246,6 +283,9 @@ Proton Drive keeps earlier versions of every file. **Details → Versions…** i
|
|||||||
**Restore to This Computer…** downloads its folders here.
|
**Restore to This Computer…** downloads its folders here.
|
||||||
- **Continue This Backup Here…** makes this computer take over another computer's backup, for
|
- **Continue This Backup Here…** makes this computer take over another computer's backup, for
|
||||||
example after reinstalling or replacing a machine. See [RECOVERY.md](RECOVERY.md).
|
example after reinstalling or replacing a machine. See [RECOVERY.md](RECOVERY.md).
|
||||||
|
- **Move to This Computer…** moves another computer's folders into this computer's backup, for
|
||||||
|
example when you switch from Windows to Linux, and then offers them for syncing here. The other
|
||||||
|
computer stays in the list, without those folders.
|
||||||
|
|
||||||
## Search launcher
|
## Search launcher
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 363 KiB |
@@ -0,0 +1,156 @@
|
|||||||
|
<svg width="1920" height="1080" viewBox="0 0 1920 1080" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1920" y2="1080" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#1A1240"/>
|
||||||
|
<stop offset="1" stop-color="#0A0618"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="glow1" cx="1390" cy="520" r="640" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#6D4AFF" stop-opacity="0.55"/>
|
||||||
|
<stop offset="0.5" stop-color="#6D4AFF" stop-opacity="0.15"/>
|
||||||
|
<stop offset="1" stop-color="#6D4AFF" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<radialGradient id="glow2" cx="160" cy="1080" r="700" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#A855F7" stop-opacity="0.25"/>
|
||||||
|
<stop offset="1" stop-color="#A855F7" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<pattern id="dots" width="32" height="32" patternUnits="userSpaceOnUse">
|
||||||
|
<circle cx="2" cy="2" r="1.6" fill="#FFFFFF"/>
|
||||||
|
</pattern>
|
||||||
|
<radialGradient id="dotfade" cx="1390" cy="520" r="900" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF" stop-opacity="1"/>
|
||||||
|
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<mask id="dotmask" maskUnits="userSpaceOnUse" x="0" y="0" width="1920" height="1080">
|
||||||
|
<rect width="1920" height="1080" fill="url(#dotfade)"/>
|
||||||
|
</mask>
|
||||||
|
<linearGradient id="title2" x1="140" y1="0" x2="760" y2="0" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#C9BBFF"/>
|
||||||
|
<stop offset="1" stop-color="#8A6CFF"/>
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<!-- app logo -->
|
||||||
|
<linearGradient id="pdl-face" x1="12" y1="12" x2="116" y2="112" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#A48BFF"/>
|
||||||
|
<stop offset="0.55" stop-color="#7552FF"/>
|
||||||
|
<stop offset="1" stop-color="#5B36F2"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="pdl-cloud" x1="0" y1="34" x2="0" y2="88" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF"/>
|
||||||
|
<stop offset="1" stop-color="#ECE6FF"/>
|
||||||
|
</linearGradient>
|
||||||
|
<mask id="pdl-cut" maskUnits="userSpaceOnUse" x="0" y="0" width="128" height="128">
|
||||||
|
<g fill="#FFFFFF">
|
||||||
|
<circle cx="66" cy="57" r="23"/>
|
||||||
|
<circle cx="42" cy="68" r="16"/>
|
||||||
|
<circle cx="88" cy="69" r="15"/>
|
||||||
|
<rect x="26" y="68" width="77" height="20" rx="10"/>
|
||||||
|
</g>
|
||||||
|
<g stroke="#000000" stroke-width="7.5" stroke-linecap="round" stroke-linejoin="round" fill="none">
|
||||||
|
<path d="M64 49v26"/>
|
||||||
|
<path d="M54.5 65.5l9.5 9.5 9.5-9.5"/>
|
||||||
|
</g>
|
||||||
|
</mask>
|
||||||
|
<filter id="logoshadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<feGaussianBlur stdDeviation="6"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="chipshadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<feGaussianBlur stdDeviation="14"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- background -->
|
||||||
|
<rect width="1920" height="1080" fill="url(#bg)"/>
|
||||||
|
<rect width="1920" height="1080" fill="url(#glow2)"/>
|
||||||
|
<rect width="1920" height="1080" fill="url(#glow1)"/>
|
||||||
|
<rect width="1920" height="1080" fill="url(#dots)" opacity="0.07" mask="url(#dotmask)"/>
|
||||||
|
|
||||||
|
<!-- orbit rings -->
|
||||||
|
<g fill="none" stroke="#FFFFFF">
|
||||||
|
<circle cx="1390" cy="520" r="300" stroke-opacity="0.10" stroke-width="2"/>
|
||||||
|
<circle cx="1390" cy="520" r="420" stroke-opacity="0.06" stroke-width="2" stroke-dasharray="4 14" stroke-linecap="round"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- logo, 128 -> 384 -->
|
||||||
|
<g transform="translate(1198 328) scale(3)">
|
||||||
|
<rect x="16" y="24" width="96" height="92" rx="24" fill="#2A1080" opacity="0.8" filter="url(#logoshadow)"/>
|
||||||
|
<rect x="12" y="16" width="104" height="100" rx="26" fill="#4424C0"/>
|
||||||
|
<rect x="12" y="12" width="104" height="100" rx="26" fill="url(#pdl-face)"/>
|
||||||
|
<rect x="12.5" y="12.5" width="103" height="99" rx="25.5" fill="none" stroke="#FFFFFF" stroke-opacity="0.2"/>
|
||||||
|
<rect y="3" width="128" height="128" fill="#2E1596" fill-opacity="0.3" mask="url(#pdl-cut)"/>
|
||||||
|
<rect width="128" height="128" fill="url(#pdl-cloud)" mask="url(#pdl-cut)"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- feature chips around the logo -->
|
||||||
|
<g font-family="Adwaita Sans" font-size="26" font-weight="600" fill="#FFFFFF">
|
||||||
|
<!-- folder, top left -->
|
||||||
|
<g transform="translate(1000 200)">
|
||||||
|
<rect x="0" y="10" width="236" height="76" rx="22" fill="#000000" opacity="0.35" filter="url(#chipshadow)"/>
|
||||||
|
<rect x="0" y="0" width="236" height="76" rx="22" fill="#2A2058" fill-opacity="0.85" stroke="#FFFFFF" stroke-opacity="0.14" stroke-width="1.5"/>
|
||||||
|
<g transform="translate(22 20)">
|
||||||
|
<rect width="36" height="36" rx="10" fill="#6D4AFF"/>
|
||||||
|
<path d="M8 13a2 2 0 0 1 2-2h5l2.5 2.5H26a2 2 0 0 1 2 2V25a2 2 0 0 1-2 2H10a2 2 0 0 1-2-2z" fill="#FFFFFF"/>
|
||||||
|
</g>
|
||||||
|
<text x="74" y="47">On demand</text>
|
||||||
|
</g>
|
||||||
|
<!-- sync, top right -->
|
||||||
|
<g transform="translate(1590 290)">
|
||||||
|
<rect x="0" y="10" width="164" height="76" rx="22" fill="#000000" opacity="0.35" filter="url(#chipshadow)"/>
|
||||||
|
<rect x="0" y="0" width="164" height="76" rx="22" fill="#2A2058" fill-opacity="0.85" stroke="#FFFFFF" stroke-opacity="0.14" stroke-width="1.5"/>
|
||||||
|
<g transform="translate(22 20)">
|
||||||
|
<rect width="36" height="36" rx="10" fill="#1EA885"/>
|
||||||
|
<g fill="none" stroke="#FFFFFF" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M26 15a8.5 8.5 0 0 0-15-2"/>
|
||||||
|
<path d="M10 9v4.5h4.5"/>
|
||||||
|
<path d="M10 21a8.5 8.5 0 0 0 15 2"/>
|
||||||
|
<path d="M26 27v-4.5h-4.5"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<text x="74" y="47">Sync</text>
|
||||||
|
</g>
|
||||||
|
<!-- photos, bottom right -->
|
||||||
|
<g transform="translate(1560 690)">
|
||||||
|
<rect x="0" y="10" width="206" height="76" rx="22" fill="#000000" opacity="0.35" filter="url(#chipshadow)"/>
|
||||||
|
<rect x="0" y="0" width="206" height="76" rx="22" fill="#2A2058" fill-opacity="0.85" stroke="#FFFFFF" stroke-opacity="0.14" stroke-width="1.5"/>
|
||||||
|
<g transform="translate(22 20)">
|
||||||
|
<rect width="36" height="36" rx="10" fill="#E8618C"/>
|
||||||
|
<rect x="8" y="9" width="20" height="18" rx="3" fill="none" stroke="#FFFFFF" stroke-width="2.4"/>
|
||||||
|
<circle cx="14" cy="15" r="2" fill="#FFFFFF"/>
|
||||||
|
<path d="M9.5 25l6-6 4 4 3-3 4 4" fill="none" stroke="#FFFFFF" stroke-width="2.4" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
<text x="74" y="47">Photos</text>
|
||||||
|
</g>
|
||||||
|
<!-- terminal, bottom left -->
|
||||||
|
<g transform="translate(980 790)">
|
||||||
|
<rect x="0" y="10" width="206" height="76" rx="22" fill="#000000" opacity="0.35" filter="url(#chipshadow)"/>
|
||||||
|
<rect x="0" y="0" width="206" height="76" rx="22" fill="#2A2058" fill-opacity="0.85" stroke="#FFFFFF" stroke-opacity="0.14" stroke-width="1.5"/>
|
||||||
|
<g transform="translate(22 20)">
|
||||||
|
<rect width="36" height="36" rx="10" fill="#3B3F5C"/>
|
||||||
|
<path d="M9 12l6 6-6 6" fill="none" stroke="#FFFFFF" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M18 25h9" stroke="#FFFFFF" stroke-width="2.6" stroke-linecap="round"/>
|
||||||
|
</g>
|
||||||
|
<text x="74" y="48" font-family="JetBrainsMono NF" font-size="25" font-weight="700">pdfs</text>
|
||||||
|
<text x="150" y="48" font-weight="500" fill="#B9B0D9">CLI</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- text block -->
|
||||||
|
<g font-family="Adwaita Sans">
|
||||||
|
<g transform="translate(140 292)">
|
||||||
|
<rect width="410" height="48" rx="24" fill="#6D4AFF" fill-opacity="0.18" stroke="#8A6CFF" stroke-opacity="0.6" stroke-width="1.5"/>
|
||||||
|
<circle cx="28" cy="24" r="6" fill="#A48BFF"/>
|
||||||
|
<text x="46" y="32" font-size="21" font-weight="700" letter-spacing="2.4" fill="#D6CCFF">UNOFFICIAL · OPEN SOURCE</text>
|
||||||
|
</g>
|
||||||
|
<text x="134" y="478" font-size="124" font-weight="800" letter-spacing="-3" fill="#FFFFFF">Proton Drive</text>
|
||||||
|
<text x="134" y="610" font-size="124" font-weight="800" letter-spacing="-3" fill="url(#title2)">for Linux</text>
|
||||||
|
<text x="140" y="702" font-size="38" font-weight="400" fill="#B9B0D9">Your Drive as a folder, and a native GTK4 app.</text>
|
||||||
|
<text x="140" y="752" font-size="38" font-weight="400" fill="#B9B0D9">No Electron.</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- footer -->
|
||||||
|
<g font-family="JetBrainsMono NF" font-size="24" fill="#8C83B0">
|
||||||
|
<text x="140" y="968">github.com/narrrl/proton-drive-linux</text>
|
||||||
|
</g>
|
||||||
|
<g font-family="Adwaita Sans" font-size="22" fill="#6F6894">
|
||||||
|
<text x="1780" y="968" text-anchor="end">Not affiliated with Proton AG</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 8.3 KiB |
+1
-1
@@ -9,7 +9,7 @@
|
|||||||
# Build + install: cd packaging && makepkg -fi
|
# Build + install: cd packaging && makepkg -fi
|
||||||
|
|
||||||
pkgname=proton-drive-linux
|
pkgname=proton-drive-linux
|
||||||
pkgver=2.10.0
|
pkgver=3.1.2
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
|
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
pkgbase = proton-drive-for-linux-bin
|
pkgbase = proton-drive-for-linux-bin
|
||||||
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)
|
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)
|
||||||
pkgver = 2.8.2
|
pkgver = 3.1.2
|
||||||
pkgrel = 2
|
pkgrel = 1
|
||||||
url = https://proton-drive.narl.io
|
url = https://proton-drive.narl.io
|
||||||
arch = x86_64
|
arch = x86_64
|
||||||
license = MIT
|
license = MIT
|
||||||
@@ -21,21 +21,21 @@ pkgbase = proton-drive-for-linux-bin
|
|||||||
optdepends = xdg-utils: "Open folder" action in the tray menu
|
optdepends = xdg-utils: "Open folder" action in the tray menu
|
||||||
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
||||||
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
||||||
provides = proton-drive-for-linux=2.8.2
|
provides = proton-drive-for-linux=3.1.2
|
||||||
provides = proton-drive-linux=2.8.2
|
provides = proton-drive-linux=3.1.2
|
||||||
provides = pdfs
|
provides = pdfs
|
||||||
conflicts = proton-drive-linux
|
conflicts = proton-drive-linux
|
||||||
conflicts = proton-drive-for-linux
|
conflicts = proton-drive-for-linux
|
||||||
conflicts = proton-drive-for-linux-git
|
conflicts = proton-drive-for-linux-git
|
||||||
options = !strip
|
options = !strip
|
||||||
options = !debug
|
options = !debug
|
||||||
source = proton-drive-linux-2.8.2-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v2.8.2/proton-drive-linux-2.8.2-x86_64.tar.gz
|
source = proton-drive-linux-3.1.2-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v3.1.2/proton-drive-linux-3.1.2-x86_64.tar.gz
|
||||||
source = io.narl.proton-drive-linux-2.8.2.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.8.2/packaging/io.narl.proton-drive-linux.desktop
|
source = io.narl.proton-drive-linux-3.1.2.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.2/packaging/io.narl.proton-drive-linux.desktop
|
||||||
source = io.narl.proton-drive-linux-tray-2.8.2.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.8.2/packaging/io.narl.proton-drive-linux-tray.desktop
|
source = io.narl.proton-drive-linux-tray-3.1.2.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.2/packaging/io.narl.proton-drive-linux-tray.desktop
|
||||||
source = io.narl.proton-drive-linux-2.8.2.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.8.2/packaging/io.narl.proton-drive-linux.svg
|
source = io.narl.proton-drive-linux-3.1.2.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.2/packaging/io.narl.proton-drive-linux.svg
|
||||||
source = proton-drive-2.8.2.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.8.2/packaging/proton-drive.service
|
source = proton-drive-3.1.2.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.2/packaging/proton-drive.service
|
||||||
source = LICENSE-2.8.2::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.8.2/LICENSE
|
source = LICENSE-3.1.2::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.2/LICENSE
|
||||||
sha256sums = c0af4ff57dc6f587b1b8431f3180b00d45211389f3dc675569af45f64e89cb36
|
sha256sums = 71fc50775cbd795717ed17c4a3994d8fdfb5ab30a223dcbc7f0e313f9ab615f7
|
||||||
sha256sums = d190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123
|
sha256sums = d190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123
|
||||||
sha256sums = 1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0
|
sha256sums = 1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0
|
||||||
sha256sums = e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828
|
sha256sums = e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||||
|
|
||||||
pkgname=proton-drive-for-linux-bin
|
pkgname=proton-drive-for-linux-bin
|
||||||
pkgver=2.8.2
|
pkgver=3.1.2
|
||||||
pkgrel=2
|
pkgrel=1
|
||||||
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)"
|
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
url="https://proton-drive.narl.io"
|
url="https://proton-drive.narl.io"
|
||||||
@@ -34,7 +34,7 @@ source=("proton-drive-linux-$pkgver-x86_64.tar.gz::$_repo/releases/download/$_ta
|
|||||||
"io.narl.proton-drive-linux-$pkgver.svg::$_raw/packaging/io.narl.proton-drive-linux.svg"
|
"io.narl.proton-drive-linux-$pkgver.svg::$_raw/packaging/io.narl.proton-drive-linux.svg"
|
||||||
"proton-drive-$pkgver.service::$_raw/packaging/proton-drive.service"
|
"proton-drive-$pkgver.service::$_raw/packaging/proton-drive.service"
|
||||||
"LICENSE-$pkgver::$_raw/LICENSE")
|
"LICENSE-$pkgver::$_raw/LICENSE")
|
||||||
sha256sums=('c0af4ff57dc6f587b1b8431f3180b00d45211389f3dc675569af45f64e89cb36'
|
sha256sums=('71fc50775cbd795717ed17c4a3994d8fdfb5ab30a223dcbc7f0e313f9ab615f7'
|
||||||
'd190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123'
|
'd190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123'
|
||||||
'1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0'
|
'1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0'
|
||||||
'e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828'
|
'e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
pkgbase = proton-drive-for-linux-git
|
pkgbase = proton-drive-for-linux-git
|
||||||
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)
|
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)
|
||||||
pkgver = 2.8.2.r0.g50483bf
|
pkgver = 3.1.2.r0.g07c9b67
|
||||||
pkgrel = 1
|
pkgrel = 1
|
||||||
url = https://proton-drive.narl.io
|
url = https://proton-drive.narl.io
|
||||||
arch = x86_64
|
arch = x86_64
|
||||||
@@ -24,8 +24,8 @@ pkgbase = proton-drive-for-linux-git
|
|||||||
optdepends = xdg-utils: "Open folder" action in the tray menu
|
optdepends = xdg-utils: "Open folder" action in the tray menu
|
||||||
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
||||||
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
||||||
provides = proton-drive-for-linux=2.8.2.r0.g50483bf
|
provides = proton-drive-for-linux=3.1.2.r0.g07c9b67
|
||||||
provides = proton-drive-linux=2.8.2.r0.g50483bf
|
provides = proton-drive-linux=3.1.2.r0.g07c9b67
|
||||||
provides = pdfs
|
provides = pdfs
|
||||||
conflicts = proton-drive-linux
|
conflicts = proton-drive-linux
|
||||||
conflicts = proton-drive-for-linux
|
conflicts = proton-drive-for-linux
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||||
|
|
||||||
pkgname=proton-drive-for-linux-git
|
pkgname=proton-drive-for-linux-git
|
||||||
pkgver=2.8.2.r0.g50483bf
|
pkgver=3.1.2.r0.g07c9b67
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)"
|
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
pkgbase = proton-drive-for-linux
|
pkgbase = proton-drive-for-linux
|
||||||
pkgdesc = Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray
|
pkgdesc = Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray
|
||||||
pkgver = 2.8.2
|
pkgver = 3.1.2
|
||||||
pkgrel = 1
|
pkgrel = 1
|
||||||
url = https://proton-drive.narl.io
|
url = https://proton-drive.narl.io
|
||||||
arch = x86_64
|
arch = x86_64
|
||||||
@@ -23,13 +23,13 @@ pkgbase = proton-drive-for-linux
|
|||||||
optdepends = xdg-utils: "Open folder" action in the tray menu
|
optdepends = xdg-utils: "Open folder" action in the tray menu
|
||||||
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
||||||
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
||||||
provides = proton-drive-linux=2.8.2
|
provides = proton-drive-linux=3.1.2
|
||||||
provides = pdfs
|
provides = pdfs
|
||||||
conflicts = proton-drive-linux
|
conflicts = proton-drive-linux
|
||||||
conflicts = proton-drive-for-linux-bin
|
conflicts = proton-drive-for-linux-bin
|
||||||
conflicts = proton-drive-for-linux-git
|
conflicts = proton-drive-for-linux-git
|
||||||
options = !lto
|
options = !lto
|
||||||
source = proton-drive-for-linux-2.8.2.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v2.8.2.tar.gz
|
source = proton-drive-for-linux-3.1.2.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v3.1.2.tar.gz
|
||||||
sha256sums = 7f2df1d2748c3d8daaa4c05d242fcd293cb1983572e44bb61a35188dbe5f342c
|
sha256sums = 260ed05c8b041cf38c5904f7543780de0d0cb06d9695a427a98250d2a9ec0eb1
|
||||||
|
|
||||||
pkgname = proton-drive-for-linux
|
pkgname = proton-drive-for-linux
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||||
|
|
||||||
pkgname=proton-drive-for-linux
|
pkgname=proton-drive-for-linux
|
||||||
pkgver=2.8.2
|
pkgver=3.1.2
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray"
|
pkgdesc="Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
@@ -24,7 +24,7 @@ conflicts=('proton-drive-linux' 'proton-drive-for-linux-bin' 'proton-drive-for-l
|
|||||||
# LTO has broken GTK/Rust links for this workspace; the release profile keeps it off.
|
# LTO has broken GTK/Rust links for this workspace; the release profile keeps it off.
|
||||||
options=('!lto')
|
options=('!lto')
|
||||||
source=("$pkgname-$pkgver.tar.gz::$_repo/archive/refs/tags/v$pkgver.tar.gz")
|
source=("$pkgname-$pkgver.tar.gz::$_repo/archive/refs/tags/v$pkgver.tar.gz")
|
||||||
sha256sums=('7f2df1d2748c3d8daaa4c05d242fcd293cb1983572e44bb61a35188dbe5f342c')
|
sha256sums=('260ed05c8b041cf38c5904f7543780de0d0cb06d9695a427a98250d2a9ec0eb1')
|
||||||
|
|
||||||
_srcname="proton-drive-linux-$pkgver"
|
_srcname="proton-drive-linux-$pkgver"
|
||||||
|
|
||||||
|
|||||||
+642
-380
File diff suppressed because it is too large.
Load diff
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user