mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
Compare commits
146
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8cfd30ff3 | ||
|
|
9fa88ff397 | ||
|
|
f105159186 | ||
|
|
a14a63c94c | ||
|
|
ed3477d1b3 | ||
|
|
aa384ae2eb | ||
|
|
10c69b0d64 | ||
|
|
39be354bd2 | ||
|
|
2cca8afba9 | ||
|
|
6e9beb4b16 | ||
|
|
63484bf4b6 | ||
|
|
37c66b7220 | ||
|
|
41b6edbecb | ||
|
|
217738318e | ||
|
|
9291580fd6 | ||
|
|
812eb3d0bc | ||
|
|
31863a1da1 | ||
|
|
57a825887c | ||
|
|
1436c5f37e | ||
|
|
f0a43fb923 | ||
|
|
b47620651f | ||
|
|
2baad6720d | ||
|
|
c271a1897c | ||
|
|
d26dc8ada4 | ||
|
|
93f02672e6 | ||
|
|
307305282e | ||
|
|
a8ed6060af | ||
|
|
9dfe4c5450 | ||
|
|
da013c29d6 | ||
|
|
457a9d26f4 | ||
|
|
a68dae9422 | ||
|
|
e743cd60cf | ||
|
|
f9b1638603 | ||
|
|
6539c7314b | ||
|
|
903452a15b | ||
|
|
4e0fd3c0b1 | ||
|
|
a6f019ebe6 | ||
|
|
0f3c06f982 | ||
|
|
4f141c78ca | ||
|
|
ce60e006ac | ||
|
|
1612a9d07a | ||
|
|
378667fb37 | ||
|
|
3b81cc5240 | ||
|
|
d850265225 | ||
|
|
e32f72bfca | ||
|
|
2502af6e3c | ||
|
|
4757b957d2 | ||
|
|
8e54ed0517 | ||
|
|
9bbc994a4f | ||
|
|
ffa0fe698d | ||
|
|
25c46cfe0a | ||
|
|
573a29e596 | ||
|
|
ce2bc0ac84 | ||
|
|
b37d5e7928 | ||
|
|
18587124ac | ||
|
|
cd18029944 | ||
|
|
d6dd41407f | ||
|
|
60445d3b26 | ||
|
|
c2f93449ad | ||
|
|
4676a4e32b | ||
|
|
16fca10e6c | ||
|
|
dbc4d9a8c5 | ||
|
|
9c93860cd8 | ||
|
|
85ec91d6b3 | ||
|
|
d5d3b2da3f | ||
|
|
e5f3f612f8 | ||
|
|
3f74b95b34 | ||
|
|
7269cb5118 | ||
|
|
6d75a56b00 | ||
|
|
eb1c105bbf | ||
|
|
85e80d8aac | ||
|
|
23dc5c3723 | ||
|
|
50ae25bb88 | ||
|
|
9b2b9f24c5 | ||
|
|
ae6650f7e0 | ||
|
|
f029e652a1 | ||
|
|
9b3651e4e8 | ||
|
|
0dc476dfb7 | ||
|
|
de258cbd60 | ||
|
|
4ea18e9187 | ||
|
|
a6f36d56f3 | ||
|
|
77d0f254fc | ||
|
|
4eccdeeda6 | ||
|
|
473842eefc | ||
|
|
5e0f30cc2c | ||
|
|
b3e33a7002 | ||
|
|
f6456829fb | ||
|
|
327f538fed | ||
|
|
ad8b225cfd | ||
|
|
ea27f2bcd0 | ||
|
|
e4062cc97b | ||
|
|
c34cc93c45 | ||
|
|
7e3df7602d | ||
|
|
61a0d31408 | ||
|
|
644a47eb36 | ||
|
|
cd90c566cd | ||
|
|
1c262eb601 | ||
|
|
bf73a395e5 | ||
|
|
a5138639a8 | ||
|
|
988b78e810 | ||
|
|
2c46d62429 | ||
|
|
97743f17e3 | ||
|
|
3d10338807 | ||
|
|
d53ba1be21 | ||
|
|
c73d5c9436 | ||
|
|
8d92c69279 | ||
|
|
e32959e40a | ||
|
|
0b27c884a7 | ||
|
|
37c04f1e69 | ||
|
|
625776081b | ||
|
|
8cde79b8e3 | ||
|
|
eb53d122cc | ||
|
|
c64252193f | ||
|
|
cc70a21c05 | ||
|
|
644995cc56 | ||
|
|
50483bf3e1 | ||
|
|
4caba0a34c | ||
|
|
e8554d6434 | ||
|
|
ba1fdd65be | ||
|
|
0d4163b9bf | ||
|
|
f3c7a809f4 | ||
|
|
c1b5378045 | ||
|
|
3d6f53c16d | ||
|
|
8ceee5285b | ||
|
|
a181f1a5f0 | ||
|
|
7251f00e91 | ||
|
|
cb38f79aa3 | ||
|
|
54e9921efb | ||
|
|
f252b09757 | ||
|
|
a2992b3b9d | ||
|
|
7b67db5ced | ||
|
|
84c9ade8e3 | ||
|
|
9e26cd0285 | ||
|
|
73e60f1151 | ||
|
|
088e8f3b0d | ||
|
|
261d5dbf84 | ||
|
|
5f9d07aff1 | ||
|
|
3fb7b9be6b | ||
|
|
6c3a50ce7f | ||
|
|
f8304a0e86 | ||
|
|
e63f39f8c2 | ||
|
|
6f100dbe5e | ||
|
|
7c5ef269cd | ||
|
|
f13b6fc0ef | ||
|
|
3d61fc637b | ||
|
|
889b2aa4a9 |
No files matched your search
@@ -67,7 +67,13 @@ jobs:
|
||||
User aur
|
||||
EOF
|
||||
# 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'
|
||||
chown -R builder: "$HOME/.ssh"
|
||||
chmod 600 "$HOME/.ssh/aur"
|
||||
|
||||
@@ -32,8 +32,14 @@ jobs:
|
||||
with:
|
||||
toolchain: '1.96'
|
||||
components: rustfmt, clippy
|
||||
cache: false
|
||||
|
||||
# Tags can only restore caches saved on main, so only main saves one. The
|
||||
# release workflow restores this key for its quality gates.
|
||||
- uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: debug
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Formatting
|
||||
run: cargo fmt --all -- --check
|
||||
@@ -57,3 +63,84 @@ jobs:
|
||||
|
||||
# Real Proton credentials and /dev/fuse deliberately do not run on pull
|
||||
# requests. Follow docs/TESTING.md on a dedicated test-account runner.
|
||||
|
||||
# The seeded simulation runs (docs/TESTING.md): real FUSE
|
||||
# daemons against an in-memory Drive, so they need /dev/fuse but no account.
|
||||
simulation:
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install native dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
pkg-config \
|
||||
fuse3 \
|
||||
libfuse3-dev \
|
||||
libgtk-4-dev \
|
||||
libadwaita-1-dev \
|
||||
libwebkitgtk-6.0-dev \
|
||||
libsecret-1-dev \
|
||||
libdbus-1-dev
|
||||
|
||||
- name: Install minimum supported Rust
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: '1.96'
|
||||
cache: false
|
||||
|
||||
- uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: debug
|
||||
save-if: false
|
||||
|
||||
- name: Simulation runs
|
||||
run: 'cargo test -p pdfs-fuse --lib --locked sim:: -- --ignored --test-threads=1'
|
||||
|
||||
# A hung step writes every daemon thread's stack next to the run's
|
||||
# state; without them a hang that does not replay cannot be told apart.
|
||||
- name: Keep the stacks of a hung run
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: simulation-stacks
|
||||
path: /tmp/pdfs-sim-*/stacks.txt
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Keeps the release-profile dependencies cached on main, where the release
|
||||
# workflow can restore them; a tag cannot reuse the caches of earlier tags.
|
||||
release-cache:
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install native dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
pkg-config \
|
||||
libfuse3-dev \
|
||||
libgtk-4-dev \
|
||||
libadwaita-1-dev \
|
||||
libwebkitgtk-6.0-dev \
|
||||
libsecret-1-dev \
|
||||
libdbus-1-dev
|
||||
|
||||
- name: Install minimum supported Rust
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: '1.96'
|
||||
cache: false
|
||||
|
||||
- uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: release
|
||||
|
||||
- name: Release build
|
||||
run: cargo build --release --locked
|
||||
@@ -10,8 +10,60 @@ permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
name: Release quality gates
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
pkg-config \
|
||||
libfuse3-dev \
|
||||
libgtk-4-dev \
|
||||
libadwaita-1-dev \
|
||||
libwebkitgtk-6.0-dev \
|
||||
libsecret-1-dev \
|
||||
libdbus-1-dev
|
||||
|
||||
- name: Set up Rust
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: '1.96'
|
||||
components: rustfmt, clippy
|
||||
cache: false
|
||||
|
||||
# A tag can read the caches of main but its own caches are useless to the next
|
||||
# tag, so this only restores what CI saved on main.
|
||||
- name: Cache Cargo target
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: debug
|
||||
save-if: false
|
||||
|
||||
- name: Verify tag and manifest versions
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
shell: bash
|
||||
run: |
|
||||
tag_version="${GITHUB_REF_NAME#v}"
|
||||
manifest_version="$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -n1)"
|
||||
pkgbuild_version="$(sed -n 's/^pkgver=//p' packaging/PKGBUILD | head -n1)"
|
||||
test "$tag_version" = "$manifest_version"
|
||||
test "$tag_version" = "$pkgbuild_version"
|
||||
|
||||
- name: Release quality gates
|
||||
run: |
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo test --workspace --locked
|
||||
scripts/fuse-acceptance.sh --offline-only
|
||||
|
||||
build-release:
|
||||
name: Build and Release
|
||||
name: Build packages
|
||||
runs-on: ubuntu-24.04
|
||||
environment: 'rpm signing'
|
||||
|
||||
@@ -38,27 +90,14 @@ jobs:
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: '1.96'
|
||||
components: rustfmt, clippy
|
||||
cache: false
|
||||
|
||||
# Restores the release-profile dependencies that CI builds on main.
|
||||
- name: Cache Cargo target
|
||||
uses: swatinem/rust-cache@v2
|
||||
|
||||
- name: Verify tag and manifest versions
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
shell: bash
|
||||
run: |
|
||||
tag_version="${GITHUB_REF_NAME#v}"
|
||||
manifest_version="$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -n1)"
|
||||
pkgbuild_version="$(sed -n 's/^pkgver=//p' packaging/PKGBUILD | head -n1)"
|
||||
test "$tag_version" = "$manifest_version"
|
||||
test "$tag_version" = "$pkgbuild_version"
|
||||
|
||||
- name: Release quality gates
|
||||
run: |
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo test --workspace --locked
|
||||
scripts/fuse-acceptance.sh --offline-only
|
||||
with:
|
||||
shared-key: release
|
||||
save-if: false
|
||||
|
||||
- name: Build release binaries
|
||||
run: |
|
||||
@@ -127,7 +166,7 @@ jobs:
|
||||
Maintainer: Nils Pukropp <contact@narl.io>
|
||||
Depends: fuse3, libgtk-4-1 (>= 4.12.0), libadwaita-1-0 (>= 1.5.0), libsecret-1-0, libwebkitgtk-6.0-4, libimage-exiftool-perl
|
||||
Installed-Size: ${INSTALLED_SIZE}
|
||||
Homepage: https://github.com/narrrl/proton-drive-linux
|
||||
Homepage: https://proton-drive.narl.io
|
||||
Description: Unofficial Proton Drive client for Linux
|
||||
Files-on-demand Proton Drive for the Linux desktop, unaffiliated with Proton AG:
|
||||
- FUSE virtual mount with block-level caching
|
||||
@@ -152,6 +191,7 @@ jobs:
|
||||
--define "_topdir $PWD/rpmbuild" \
|
||||
--define "_sourcedir $PWD" \
|
||||
--define "git_dir $PWD" \
|
||||
--define "prebuilt 1" \
|
||||
--define "version ${RPM_VERSION}" \
|
||||
--define "_dbpath $PWD/rpmbuild/db" \
|
||||
packaging/proton-drive-linux.spec
|
||||
@@ -190,8 +230,19 @@ jobs:
|
||||
*.rpm
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
name: Create GitHub Release
|
||||
needs: [checks, build-release]
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- name: Download packages
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: proton-drive-linux-builds
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
files: |
|
||||
@@ -205,7 +256,7 @@ jobs:
|
||||
|
||||
aur:
|
||||
name: Publish AUR packages
|
||||
needs: build-release
|
||||
needs: publish
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: ./.github/workflows/aur.yml
|
||||
with:
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
Changes to the FUSE layer, sync, or the pending-operation queue should also be exercised against a
|
||||
real mount before review — see [`docs/TESTING.md`](docs/TESTING.md). Live runs are destructive:
|
||||
use a dedicated test account, never one holding data you cannot lose.
|
||||
Changes to the FUSE layer, sync, or the queue should also pass the simulation runs and be
|
||||
exercised against a real mount before review — see [`docs/TESTING.md`](docs/TESTING.md). Live
|
||||
runs are destructive: use a dedicated test account, never one holding data you cannot lose.
|
||||
|
||||
## Making changes
|
||||
|
||||
|
||||
Generated
+16
-10
@@ -3474,7 +3474,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-cli"
|
||||
version = "2.6.0"
|
||||
version = "3.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -3485,13 +3485,15 @@ dependencies = [
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-core"
|
||||
version = "2.6.0"
|
||||
version = "3.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.23.1",
|
||||
"dbus-secret-service-keyring-store",
|
||||
"directories",
|
||||
"ignore",
|
||||
@@ -3508,13 +3510,15 @@ dependencies = [
|
||||
"thiserror 2.0.20",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"zeroize",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-fuse"
|
||||
version = "2.6.0"
|
||||
version = "3.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.23.1",
|
||||
"fuser",
|
||||
"futures",
|
||||
@@ -3528,12 +3532,13 @@ dependencies = [
|
||||
"sha1 0.11.0",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-gui"
|
||||
version = "2.6.0"
|
||||
version = "3.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3570,9 +3575,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pgp"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1cfa4743b28656065ff4c0ba09e46b357a65e8c00fc2341e89084b82f87cbdf1"
|
||||
checksum = "ae70f4d9325a391db30d115d6191a7bb67cec856aa0bba131290f0c4e09a532a"
|
||||
dependencies = [
|
||||
"aead 0.5.2",
|
||||
"aes 0.8.4",
|
||||
@@ -3773,9 +3778,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proton-drive-rs"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0f6996724e51e99f03d2843828de9477afc39baa764403cb69490cabc1c4d93"
|
||||
checksum = "5bde475139afb5fc2649621e1d283e243f9df2bb412f08802cdf3f4ffaf97c44"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.23.1",
|
||||
@@ -3800,9 +3805,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proton-sdk"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91cc25654133bd5714407a1df0996eaf214a6e8e0bb95d8139bc42e7cee31c34"
|
||||
checksum = "faf32d0a03f7d13bd9bdcc8c9503e35e9df9318ca9f2a100dcf06cf50cb7ac8c"
|
||||
dependencies = [
|
||||
"aes-gcm 0.11.1",
|
||||
"async-trait",
|
||||
@@ -3829,6 +3834,7 @@ dependencies = [
|
||||
"thiserror 2.0.20",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+5
-4
@@ -3,20 +3,20 @@ resolver = "2"
|
||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||
|
||||
[workspace.package]
|
||||
version = "2.6.0"
|
||||
version = "3.1.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||
rust-version = "1.96"
|
||||
repository = "https://github.com/narrrl/proton-drive-linux"
|
||||
homepage = "https://github.com/narrrl/proton-drive-linux"
|
||||
homepage = "https://proton-drive.narl.io"
|
||||
readme = "README.md"
|
||||
keywords = ["proton", "drive", "fuse", "sync", "linux"]
|
||||
categories = ["filesystem", "command-line-utilities"]
|
||||
|
||||
[workspace.dependencies]
|
||||
proton-sdk = { version = "0.7.0" }
|
||||
proton-drive-rs = { version = "0.7.0" }
|
||||
proton-sdk = { version = "0.8.0" }
|
||||
proton-drive-rs = { version = "0.8.0" }
|
||||
|
||||
# Internal crates.
|
||||
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_json = "1"
|
||||
base64 = "0.23"
|
||||
zeroize = "1"
|
||||
thiserror = "2"
|
||||
anyhow = "1"
|
||||
# Non-poisoning mutexes. A handler that panics while holding a lock must not
|
||||
|
||||
@@ -8,11 +8,14 @@
|
||||
local folders, Photos, sharing, and a native GTK4 app with tray and search launcher, plus a
|
||||
scriptable CLI.
|
||||
|
||||
**[proton-drive.narl.io](https://proton-drive.narl.io)**
|
||||
|
||||
[](https://github.com/narrrl/proton-drive-linux/actions/workflows/ci.yml)
|
||||
[](https://github.com/narrrl/proton-drive-linux/releases/latest)
|
||||
[](https://aur.archlinux.org/packages/proton-drive-for-linux)
|
||||
[](LICENSE)
|
||||
[](https://www.rust-lang.org)
|
||||
[](https://buymeacoffee.com/narl)
|
||||
|
||||
<img src="images/files.png" alt="My files in the Proton Drive app" width="820">
|
||||
|
||||
@@ -36,8 +39,9 @@ scriptable CLI.
|
||||
- **Synced folders.** Back up folders such as `~/Documents` under your computer in Proton Drive,
|
||||
as a full two-way copy or online only. Gitignore-style `.pdfsignore` rules, conflict copies
|
||||
instead of overwrites, pause and bandwidth limits.
|
||||
- **Safe writes.** Changes are staged on disk and queued before they are acknowledged, so they
|
||||
survive a lost connection, a crash or a reboot and upload when they can.
|
||||
- **Works at local speed.** Making, renaming and deleting files and folders returns at once, online
|
||||
or offline. Changes are staged on disk and queued before they are acknowledged, so they survive
|
||||
a lost connection, a crash or a reboot and reach Drive when they can.
|
||||
|
||||
**Desktop app**
|
||||
|
||||
@@ -48,7 +52,8 @@ scriptable CLI.
|
||||
- **Sharing.** Invite people as viewer, editor or admin, create public links with password and
|
||||
expiry, accept invitations.
|
||||
- **Sync overview.** What is uploading, what is waiting, what failed and why, and a history of
|
||||
what happened.
|
||||
what happened. A change Drive refuses (storage full, access taken away) shows at once, on the
|
||||
file too, and can be exported or discarded.
|
||||
- **Tray icon** with status, pause and quick actions, and a **search launcher** (`pdfs-prompt`)
|
||||
that searches Proton Drive and your home folder together, as its own window, through fuzzel,
|
||||
rofi or wofi, or live in `fzf`.
|
||||
@@ -144,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
|
||||
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
|
||||
|
||||
@@ -152,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
|
||||
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
|
||||
|
||||
Released under the [MIT License](LICENSE).
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ the interesting boundaries are:
|
||||
or the wrong key material to Proton.
|
||||
|
||||
What the client deliberately keeps in plaintext on disk, and why, is documented in the
|
||||
[threat model](docs/ARCHITECTURE.md#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
|
||||
than described is.
|
||||
|
||||
|
||||
@@ -23,3 +23,4 @@ tracing-subscriber.workspace = true
|
||||
clap.workspace = true
|
||||
rpassword.workspace = true
|
||||
serde_json.workspace = true
|
||||
zeroize.workspace = true
|
||||
+365
-27
@@ -14,12 +14,45 @@ use pdfs_core::cache::ContentCache;
|
||||
use pdfs_core::config::AppDirs;
|
||||
use pdfs_core::control::{
|
||||
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,
|
||||
};
|
||||
use pdfs_core::db::Db;
|
||||
use pdfs_core::service;
|
||||
|
||||
/// `println!` for this binary, shadowing std's, which panics when stdout is
|
||||
/// closed: `pdfs ls | head` ended in a "Broken pipe" panic once `head` had its
|
||||
/// lines. This one hands the failure to [`stdout_written`] instead.
|
||||
///
|
||||
/// Restoring the default `SIGPIPE` would also stop the panic, but the same
|
||||
/// binary runs the daemon, which must not die when a socket's peer goes away.
|
||||
macro_rules! println {
|
||||
($($arg:tt)*) => {{
|
||||
use std::io::Write as _;
|
||||
$crate::stdout_written(writeln!(std::io::stdout(), $($arg)*))
|
||||
}};
|
||||
}
|
||||
|
||||
/// [`println!`] without the newline.
|
||||
macro_rules! print {
|
||||
($($arg:tt)*) => {{
|
||||
use std::io::Write as _;
|
||||
$crate::stdout_written(write!(std::io::stdout(), $($arg)*))
|
||||
}};
|
||||
}
|
||||
|
||||
/// Exit on a failed write to stdout. A closed pipe means the reader has all it
|
||||
/// wanted, so that exit is a success.
|
||||
fn stdout_written(result: std::io::Result<()>) {
|
||||
if let Err(error) = result {
|
||||
if error.kind() == std::io::ErrorKind::BrokenPipe {
|
||||
std::process::exit(0);
|
||||
}
|
||||
eprintln!("Error: writing to stdout: {error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "pdfs",
|
||||
@@ -30,7 +63,7 @@ struct Cli {
|
||||
/// Emit machine-readable JSON instead of formatted text.
|
||||
///
|
||||
/// Applies to the query commands (`status`, `ls`, `pins`, `sync list`,
|
||||
/// `devices list`, `locations`, `transfers`, `activity`, `cache inspect`). Commands that
|
||||
/// `devices list`, `locations`, `transfers`, `activity`, `trash`, `cache inspect`). Commands that
|
||||
/// perform an action keep their human output — a script that needs to know
|
||||
/// whether one succeeded has the exit code.
|
||||
#[arg(long, global = true)]
|
||||
@@ -102,6 +135,24 @@ enum Command {
|
||||
/// Account email; prompted if omitted.
|
||||
#[arg(short, long)]
|
||||
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.
|
||||
Logout,
|
||||
@@ -285,26 +336,34 @@ enum Command {
|
||||
},
|
||||
/// Rename a file or folder via the running daemon.
|
||||
Rename {
|
||||
/// File/folder path, inside the mountpoint or relative to it.
|
||||
/// File/folder path, inside My files or an on-demand synced folder, or
|
||||
/// relative to the mountpoint.
|
||||
path: PathBuf,
|
||||
/// New name (a single path component).
|
||||
new_name: String,
|
||||
},
|
||||
/// Move a file or folder into another folder via the running daemon.
|
||||
/// Move files or folders into another folder via the running daemon.
|
||||
///
|
||||
/// The paths may be in different locations: My files and any synced
|
||||
/// folder, on-demand or mirrored. The move happens on Proton Drive, so
|
||||
/// nothing is downloaded or uploaded again, unlike `mv` between two
|
||||
/// locations. The last path is the destination folder.
|
||||
Move {
|
||||
/// File/folder path, inside the mountpoint or relative to it.
|
||||
path: PathBuf,
|
||||
/// Destination folder path, inside the mountpoint or relative to it.
|
||||
new_parent: PathBuf,
|
||||
/// Files/folders to move, then the destination folder: each inside a
|
||||
/// location, or relative to the mountpoint.
|
||||
#[arg(required = true, num_args = 2.., value_name = "PATH")]
|
||||
paths: Vec<PathBuf>,
|
||||
},
|
||||
/// Trash a file or folder via the running daemon.
|
||||
Rm {
|
||||
/// File/folder path, inside the mountpoint or relative to it.
|
||||
/// File/folder path, inside My files or an on-demand synced folder, or
|
||||
/// relative to the mountpoint.
|
||||
path: PathBuf,
|
||||
},
|
||||
/// Create a folder via the running daemon.
|
||||
Mkdir {
|
||||
/// Parent folder path, inside the mountpoint or relative to it.
|
||||
/// Parent folder path, inside My files or an on-demand synced folder,
|
||||
/// or relative to the mountpoint.
|
||||
parent: PathBuf,
|
||||
/// New folder name (a single path component).
|
||||
name: String,
|
||||
@@ -636,6 +695,25 @@ enum SyncCmd {
|
||||
},
|
||||
/// List uploads and changes not yet on Proton Drive.
|
||||
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 {
|
||||
/// Queue id; omit to retry every failed operation.
|
||||
@@ -743,7 +821,9 @@ fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
JSON_OUTPUT.store(cli.json, std::sync::atomic::Ordering::Relaxed);
|
||||
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::Status => cmd_status(),
|
||||
Command::Locations => cmd_locations(),
|
||||
@@ -800,7 +880,10 @@ fn main() -> Result<()> {
|
||||
scope,
|
||||
} => cmd_search(query, limit, scope),
|
||||
Command::Rename { path, new_name } => cmd_rename(path, new_name),
|
||||
Command::Move { path, new_parent } => cmd_move(path, new_parent),
|
||||
Command::Move { mut paths } => {
|
||||
let new_parent = paths.pop().expect("clap requires at least two paths");
|
||||
cmd_move(paths, new_parent)
|
||||
}
|
||||
Command::Rm { path } => cmd_rm(path),
|
||||
Command::Mkdir { parent, name } => cmd_mkdir(parent, name),
|
||||
Command::Upload { sources, parent } => cmd_upload(sources, parent),
|
||||
@@ -984,7 +1067,17 @@ fn cmd_sync(action: SyncCmd) -> Result<()> {
|
||||
})?)?
|
||||
}
|
||||
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::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::Resume { folder: None } => {
|
||||
ok_or_bail(control_request(CtlRequest::SetSyncPaused {
|
||||
@@ -1062,6 +1155,9 @@ fn cmd_sync_queue() -> Result<()> {
|
||||
(false, _) => "due now".to_string(),
|
||||
};
|
||||
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 {
|
||||
println!(" failed {} time(s): {error}", op.attempts);
|
||||
}
|
||||
@@ -1073,6 +1169,91 @@ fn cmd_sync_queue() -> Result<()> {
|
||||
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
|
||||
/// each folder should land, then hand the mapping to the daemon.
|
||||
///
|
||||
@@ -1620,7 +1801,7 @@ fn prompt_line(label: &str) -> Result<String> {
|
||||
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 {
|
||||
Some(u) => u,
|
||||
None => prompt_line("Proton email")?,
|
||||
@@ -1632,11 +1813,14 @@ fn cmd_login(username: Option<String>) -> Result<()> {
|
||||
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()?;
|
||||
// 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
|
||||
// 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 {
|
||||
pdfs_core::Error::HumanVerificationRequired(_) => anyhow::anyhow!(
|
||||
"Proton is asking for a CAPTCHA before it will accept this sign-in.\n\
|
||||
@@ -1657,9 +1841,88 @@ fn cmd_login(username: Option<String>) -> Result<()> {
|
||||
}
|
||||
|
||||
println!("Logged in as {username}. Session stored in the system keyring.");
|
||||
report_key_state();
|
||||
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<()> {
|
||||
auth::logout()?;
|
||||
println!("Stored session cleared.");
|
||||
@@ -1687,6 +1950,7 @@ fn cmd_status() -> Result<()> {
|
||||
"username": session.as_ref().map(|s| s.username.clone()),
|
||||
"user_id": session.as_ref().map(|s| s.user_id.clone()),
|
||||
"scopes": session.as_ref().map(|s| s.scopes.clone()),
|
||||
"keys": session.as_ref().map(|_| key_state_name()),
|
||||
"mount": mount,
|
||||
});
|
||||
println!("{}", serde_json::to_string_pretty(&out)?);
|
||||
@@ -1697,6 +1961,11 @@ fn cmd_status() -> Result<()> {
|
||||
Ok(s) => {
|
||||
println!("Logged in as {} (user {})", s.username, s.user_id);
|
||||
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) => {
|
||||
println!("Not logged in. Run `pdfs login`.");
|
||||
@@ -1828,7 +2097,7 @@ fn login_poll_delay(attempts: u32) -> std::time::Duration {
|
||||
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
|
||||
/// schedule can be tested without a keyring and without real time passing.
|
||||
@@ -1853,6 +2122,16 @@ where
|
||||
sleep(delay);
|
||||
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()),
|
||||
}
|
||||
}
|
||||
@@ -1863,10 +2142,13 @@ where
|
||||
/// an external unmount triggers a remount; errors back off and retry. This is
|
||||
/// the entry point for the systemd user service.
|
||||
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 {
|
||||
// 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.
|
||||
wait_for_session(|| auth::load().map(|_| ()), std::thread::sleep)?;
|
||||
wait_for_session(auth::ready, pdfs_fuse::idle_sleep)?;
|
||||
|
||||
match mount_once(mountpoint.clone()) {
|
||||
Ok(pdfs_fuse::MountOutcome::Shutdown) => {
|
||||
@@ -1875,13 +2157,13 @@ fn cmd_daemon(mountpoint: Option<PathBuf>) -> Result<()> {
|
||||
}
|
||||
Ok(pdfs_fuse::MountOutcome::Unmounted) => {
|
||||
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) => {
|
||||
// `{:#}` so the whole anyhow chain lands in the log — the top
|
||||
// context alone ("mount failed") never says *why*.
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1939,6 +2221,7 @@ fn mount_once(mountpoint: Option<PathBuf>) -> Result<pdfs_fuse::MountOutcome> {
|
||||
sweep_mode: config.resolved_conflict_sweep(),
|
||||
upload_limit: config.upload_limit.unwrap_or(0),
|
||||
download_limit: config.download_limit.unwrap_or(0),
|
||||
local_first: config.local_first.unwrap_or(true),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1946,6 +2229,9 @@ fn mount_once(mountpoint: Option<PathBuf>) -> Result<pdfs_fuse::MountOutcome> {
|
||||
if let Err(e) = rt.block_on(auth::persist(&session)) {
|
||||
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")?;
|
||||
Ok(outcome)
|
||||
@@ -2439,14 +2725,33 @@ fn cmd_rename(path: PathBuf, new_name: String) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_move(path: PathBuf, new_parent: PathBuf) -> Result<()> {
|
||||
match control_request(CtlRequest::Move {
|
||||
path: path_arg(&path)?,
|
||||
new_parent: path_arg(&new_parent)?,
|
||||
})? {
|
||||
CtlResponse::Ok { message } => println!("{message}"),
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
/// Move each of `sources` into `new_parent`, one request each. A refusal names
|
||||
/// its source and the rest still move; the command fails if any did not.
|
||||
fn cmd_move(sources: Vec<PathBuf>, new_parent: PathBuf) -> Result<()> {
|
||||
let new_parent = path_arg(&new_parent)?;
|
||||
let mut failed = 0;
|
||||
for source in &sources {
|
||||
let outcome = path_arg(source).and_then(|path| {
|
||||
match control_request(CtlRequest::Move {
|
||||
path,
|
||||
new_parent: new_parent.clone(),
|
||||
})? {
|
||||
CtlResponse::Ok { message } => Ok(message),
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
}
|
||||
});
|
||||
match outcome {
|
||||
Ok(message) => println!("{message}"),
|
||||
Err(e) if sources.len() == 1 => return Err(e),
|
||||
Err(e) => {
|
||||
eprintln!("{}: {e:#}", source.display());
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if failed > 0 {
|
||||
bail!("{failed} of {} could not be moved", sources.len());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -2524,7 +2829,11 @@ fn cmd_diagnostics() -> Result<()> {
|
||||
}
|
||||
|
||||
fn cmd_trash() -> Result<()> {
|
||||
match control_request(CtlRequest::ListTrash)? {
|
||||
let response = control_request(CtlRequest::ListTrash)?;
|
||||
if emit_json(&response)? {
|
||||
return Ok(());
|
||||
}
|
||||
match response {
|
||||
CtlResponse::Entries { entries } if entries.is_empty() => println!("(trash is empty)"),
|
||||
CtlResponse::Entries { entries } => {
|
||||
for e in entries {
|
||||
@@ -3036,6 +3345,19 @@ fn cmd_diagnose() -> Result<()> {
|
||||
match pdfs_core::auth::load() {
|
||||
Ok(session) => {
|
||||
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) => {
|
||||
// A locked keyring and an absent login look the same from here, so
|
||||
@@ -3334,6 +3656,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]
|
||||
fn a_stored_session_is_not_waited_for_at_all() {
|
||||
let mut slept = 0;
|
||||
@@ -3366,6 +3703,7 @@ mod login_wait_tests {
|
||||
shared_by_unverified: false,
|
||||
trashed_at: 0,
|
||||
trashed_from: None,
|
||||
issue: None,
|
||||
};
|
||||
assert_eq!(shared_by_line(&entry), None, "no invitation, no line");
|
||||
entry.shared_by = "alice@proton.me".into();
|
||||
|
||||
@@ -28,3 +28,5 @@ dbus-secret-service-keyring-store.workspace = true
|
||||
parking_lot.workspace = true
|
||||
async-trait.workspace = true
|
||||
libc.workspace = true
|
||||
zeroize.workspace = true
|
||||
base64.workspace = true
|
||||
+467
-118
@@ -1,22 +1,50 @@
|
||||
//! Login, session persistence, and Drive-client construction.
|
||||
//!
|
||||
//! 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
|
||||
//! 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.
|
||||
//! holding the resumable session tokens, the account's key salts and — unless
|
||||
//! the user chose otherwise — the per-key *passphrases* derived from the
|
||||
//! mailbox password. The password itself is never stored: the passphrases are
|
||||
//! 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 proton_drive_rs::{KeySalt, ProtonDriveClient};
|
||||
use proton_sdk::account::KeyPassphrases;
|
||||
use proton_sdk::api::{HumanVerification, HumanVerificationCredential};
|
||||
use proton_sdk::cache::EncryptedCacheRepository;
|
||||
use proton_sdk::config::ProtonClientConfiguration;
|
||||
use proton_sdk::error::ProtonError;
|
||||
use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
|
||||
use proton_sdk::telemetry::TracingTelemetry;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::config::{APP_VERSION, AppDirs, KEYRING_SERVICE, USER_AGENT};
|
||||
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.
|
||||
const KEYRING_USER: &str = "session";
|
||||
@@ -32,8 +60,11 @@ pub struct StoredSession {
|
||||
pub scopes: Vec<String>,
|
||||
/// `1` = single password, `2` = dual (Proton wire value).
|
||||
pub password_mode: u8,
|
||||
/// Mailbox (data) password — required by `ProtonDriveClient::new` to derive
|
||||
/// the key chain. Lives only in the OS keyring, never on disk in cleartext.
|
||||
/// The mailbox password of a blob written by 3.0.x and earlier.
|
||||
///
|
||||
/// 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,
|
||||
/// The account's key salts, captured at login.
|
||||
///
|
||||
@@ -49,6 +80,11 @@ pub struct StoredSession {
|
||||
/// backfilling) them.
|
||||
#[serde(default)]
|
||||
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 {
|
||||
@@ -89,6 +125,26 @@ fn keyring_entry() -> Result<Entry> {
|
||||
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.
|
||||
///
|
||||
/// `get_totp` is only invoked when the account requires a second factor, so
|
||||
@@ -96,14 +152,19 @@ fn keyring_entry() -> Result<Entry> {
|
||||
/// 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.
|
||||
///
|
||||
/// `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
|
||||
/// that can present the challenge should use [`login_interactive`] instead.
|
||||
pub async fn login(
|
||||
username: &str,
|
||||
password: &str,
|
||||
get_totp: impl Fn() -> Result<String>,
|
||||
get_mailbox: impl Fn(u32) -> Result<Mailbox>,
|
||||
) -> 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.
|
||||
@@ -123,12 +184,20 @@ pub async fn login_interactive(
|
||||
username: &str,
|
||||
password: &str,
|
||||
get_totp: impl Fn() -> Result<String>,
|
||||
get_mailbox: impl Fn(u32) -> Result<Mailbox>,
|
||||
get_hv: impl FnOnce(HumanVerification) -> Result<HumanVerificationCredential>,
|
||||
) -> 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)) => {
|
||||
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,
|
||||
}
|
||||
@@ -145,15 +214,18 @@ pub async fn login_verified(
|
||||
password: &str,
|
||||
verification: Option<&HumanVerificationCredential>,
|
||||
get_totp: impl Fn() -> Result<String>,
|
||||
get_mailbox: impl Fn(u32) -> Result<Mailbox>,
|
||||
) -> Result<()> {
|
||||
let mut session = ProtonApiSession::begin_verified(
|
||||
let password = Zeroizing::new(password.to_owned());
|
||||
let session = ProtonApiSession::begin_verified(
|
||||
client_config(),
|
||||
username,
|
||||
password.as_bytes(),
|
||||
verification,
|
||||
)
|
||||
.await
|
||||
.map_err(classify_verification_gate)?;
|
||||
.map_err(classify_verification_gate);
|
||||
let mut session = session?;
|
||||
|
||||
if session.is_waiting_for_second_factor() {
|
||||
let code = get_totp()?;
|
||||
@@ -163,38 +235,38 @@ pub async fn login_verified(
|
||||
// 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
|
||||
// resume can unlock the key chain. See `StoredSession::key_salts`.
|
||||
let client = ProtonDriveClient::new(&session, password.as_bytes().to_vec());
|
||||
let key_salts = client.account().key_salts().await?;
|
||||
let key_salts = ProtonDriveClient::new(&session, Vec::new())
|
||||
.account()
|
||||
.key_salts()
|
||||
.await?;
|
||||
|
||||
register_refresh_handler(&session, password.to_owned(), key_salts.clone());
|
||||
|
||||
save(&session, password, key_salts).await?;
|
||||
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: proton_sdk::error::ProtonError) -> Error {
|
||||
let proton_sdk::error::ProtonError::Api(api) = &e else {
|
||||
return e.into();
|
||||
// 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
|
||||
// against the account now — a typo found at login is a retry, found later it
|
||||
// is a daemon that never mounts.
|
||||
let (passphrases, remember) = match session.password_mode() {
|
||||
PasswordMode::Single => (
|
||||
derive_verified(&session, password.as_bytes(), key_salts.clone()).await?,
|
||||
true,
|
||||
),
|
||||
PasswordMode::Dual => {
|
||||
let mut attempt = 0;
|
||||
loop {
|
||||
let mailbox = get_mailbox(attempt)?;
|
||||
match derive_verified(&session, mailbox.password.as_bytes(), key_salts.clone())
|
||||
.await
|
||||
{
|
||||
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.
|
||||
pub async fn save(
|
||||
session: &ProtonApiSession,
|
||||
mailbox_password: &str,
|
||||
key_salts: Vec<KeySalt>,
|
||||
) -> Result<()> {
|
||||
register_refresh_handler(&session);
|
||||
let tokens = session.current_tokens().await;
|
||||
let stored = StoredSession {
|
||||
session_id: session.session_id().as_str().to_owned(),
|
||||
@@ -207,10 +279,103 @@ pub async fn save(
|
||||
PasswordMode::Single => 1,
|
||||
PasswordMode::Dual => 2,
|
||||
},
|
||||
mailbox_password: mailbox_password.to_owned(),
|
||||
mailbox_password: String::new(),
|
||||
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)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -225,12 +390,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).
|
||||
pub fn logout() -> Result<()> {
|
||||
// The persisted entity cache describes the account being logged out of. It
|
||||
// is encrypted under that account's mailbox password, but leaving it behind
|
||||
// would also mean the next account inherits a store it can only ever read as
|
||||
// misses. Best-effort: failing to remove a cache must not block the logout.
|
||||
// is encrypted under a key derived from that account's passphrases, but
|
||||
// leaving it behind would also mean the next account inherits a store it can
|
||||
// only ever read as misses. Best-effort: failing to remove a cache must not
|
||||
// block the logout.
|
||||
if let Ok(dirs) = AppDirs::new() {
|
||||
let path = dirs.state_dir().join("sdk_cache.db");
|
||||
if let Some(cache) = crate::sdkcache::SdkCache::opened()
|
||||
@@ -242,6 +488,9 @@ pub fn logout() -> Result<()> {
|
||||
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() {
|
||||
Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(()),
|
||||
Err(e) => Err(e.into()),
|
||||
@@ -250,44 +499,87 @@ pub fn logout() -> Result<()> {
|
||||
|
||||
/// Resume a persisted session and build an authenticated Drive client.
|
||||
///
|
||||
/// Returns `Error::NotLoggedIn` when no session has been saved. The caller is
|
||||
/// responsible for persisting rotated tokens over the session's lifetime via
|
||||
/// [`persist`] — Proton refresh tokens are single-use, so a refresh that is not
|
||||
/// written back leaves the keyring holding a stale (now-invalid) refresh token,
|
||||
/// and the next resume fails with `InvalidRefreshToken`.
|
||||
/// Returns `Error::NotLoggedIn` when no session has been saved and
|
||||
/// [`Error::Locked`] when the account is waiting for its mailbox password. Tokens
|
||||
/// the session rotates are written back to the keyring as they happen (see
|
||||
/// [`register_refresh_handler`]); Proton refresh tokens are single-use, so a
|
||||
/// 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)> {
|
||||
let stored = load()?;
|
||||
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
|
||||
// more and write them back, 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() {
|
||||
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
|
||||
let passphrases = match find_passphrases(&stored)? {
|
||||
Some((passphrases, _)) => passphrases,
|
||||
None if !stored.mailbox_password.is_empty() => migrate_legacy(&session, &stored).await?,
|
||||
None => return Err(Error::Locked),
|
||||
};
|
||||
|
||||
register_refresh_handler(&session, password.clone(), key_salts.clone());
|
||||
|
||||
let client = tune(
|
||||
ProtonDriveClient::with_key_salts(&session, password.clone().into_bytes(), key_salts),
|
||||
password.as_bytes(),
|
||||
ProtonDriveClient::with_key_passphrases(&session, passphrases.clone()),
|
||||
&passphrases,
|
||||
);
|
||||
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
|
||||
/// [`ProtonDriveClient`] is built for real work.
|
||||
///
|
||||
@@ -301,23 +593,24 @@ pub async fn resume_client() -> Result<(ProtonDriveClient, ProtonApiSession)> {
|
||||
/// failure point per write instead of three.
|
||||
/// - **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
|
||||
/// wrapped in the SDK's [`EncryptedCacheRepository`] keyed by the mailbox
|
||||
/// password, so the decrypted node names it holds are not readable from the
|
||||
/// file alone — and a password change simply reads as a cold cache, since the
|
||||
/// SDK treats an undecryptable entry as a miss and clears the store.
|
||||
/// wrapped in the SDK's [`EncryptedCacheRepository`] keyed by a secret derived
|
||||
/// from the key passphrases, so the decrypted node names it holds are not
|
||||
/// readable from the file alone — and a password change simply reads as a cold
|
||||
/// 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
|
||||
/// hits, not the session, so the client falls back to the SDK's in-memory
|
||||
/// default.
|
||||
fn tune(client: ProtonDriveClient, mailbox_password: &[u8]) -> ProtonDriveClient {
|
||||
fn tune(client: ProtonDriveClient, passphrases: &KeyPassphrases) -> ProtonDriveClient {
|
||||
let client = client
|
||||
.with_telemetry(TracingTelemetry::shared())
|
||||
.with_small_file_upload(true);
|
||||
match AppDirs::new().and_then(|dirs| crate::sdkcache::SdkCache::shared(&dirs)) {
|
||||
Ok(cache) => client.with_entity_repository(EncryptedCacheRepository::shared(
|
||||
cache,
|
||||
mailbox_password.to_vec(),
|
||||
)),
|
||||
Ok(cache) => {
|
||||
let key = passphrases.derive_secret(ENTITY_CACHE_PURPOSE);
|
||||
client.with_entity_repository(EncryptedCacheRepository::shared(cache, key.to_vec()))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "persistent SDK entity cache unavailable; using memory");
|
||||
client
|
||||
@@ -327,49 +620,105 @@ fn tune(client: ProtonDriveClient, mailbox_password: &[u8]) -> ProtonDriveClient
|
||||
|
||||
/// Write the session's current tokens back to the keyring.
|
||||
///
|
||||
/// The session does not carry the mailbox password (it is only needed to rebuild
|
||||
/// the key chain on resume), so it is re-read from the existing stored blob and
|
||||
/// preserved. Call whenever the session may have rotated its tokens through the
|
||||
/// 401-refresh path so a later [`resume_client`] presents a live refresh token.
|
||||
/// Everything else in the stored blob — salts, passphrases — is preserved, so
|
||||
/// this never undoes an [`unlock`] or [`lock`] made since the session started.
|
||||
/// Call whenever the session may have rotated its tokens so a later
|
||||
/// [`resume_client`] presents a live refresh token.
|
||||
pub async fn persist(session: &ProtonApiSession) -> Result<()> {
|
||||
let stored = load()?;
|
||||
save(session, &stored.mailbox_password, stored.key_salts).await
|
||||
let tokens = session.current_tokens().await;
|
||||
store_tokens(tokens.access_token, tokens.refresh_token)
|
||||
}
|
||||
|
||||
fn register_refresh_handler(
|
||||
session: &ProtonApiSession,
|
||||
mailbox_password: String,
|
||||
key_salts: Vec<KeySalt>,
|
||||
) {
|
||||
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,
|
||||
};
|
||||
fn store_tokens(access_token: String, refresh_token: String) -> Result<()> {
|
||||
let mut stored = load()?;
|
||||
stored.access_token = access_token;
|
||||
stored.refresh_token = refresh_token;
|
||||
write_stored(&stored)
|
||||
}
|
||||
|
||||
/// 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| {
|
||||
let stored = StoredSession {
|
||||
session_id: session_id.clone(),
|
||||
username: username.clone(),
|
||||
user_id: user_id.clone(),
|
||||
access_token: tokens.access_token,
|
||||
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");
|
||||
match store_tokens(tokens.access_token, tokens.refresh_token) {
|
||||
Ok(()) => tracing::info!("successfully auto-persisted refreshed tokens in keyring"),
|
||||
Err(Error::NotLoggedIn) => {}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "failed to auto-persist 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());
|
||||
}
|
||||
}
|
||||
+147
-15
@@ -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
|
||||
/// 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 {
|
||||
let start = idx.saturating_mul(BLOCK_SIZE);
|
||||
size.saturating_sub(start).min(BLOCK_SIZE)
|
||||
@@ -311,8 +311,7 @@ struct PinFile {
|
||||
pins: BTreeMap<String, Pin>,
|
||||
}
|
||||
|
||||
/// What a staged file actually contains, written beside it as `<name>.json`
|
||||
/// (offline.md Phase 2).
|
||||
/// What a staged file actually contains, written beside it as `<name>.json`.
|
||||
///
|
||||
/// 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
|
||||
@@ -365,6 +364,14 @@ pub struct StagedWrite {
|
||||
pub based_on: Option<Baseline>,
|
||||
}
|
||||
|
||||
impl StagedWrite {
|
||||
/// Whether `authored` ranges cover a file of `len` bytes, so the staged
|
||||
/// bytes are its complete new content. An empty file has nothing to cover.
|
||||
pub fn covers(authored: &[(u64, u64)], len: u64) -> bool {
|
||||
len == 0 || authored == [(0, len)]
|
||||
}
|
||||
}
|
||||
|
||||
/// The identity of a remote revision, as far as we can observe it.
|
||||
///
|
||||
/// `revision_id` is the authoritative identity when present: the server assigns a
|
||||
@@ -431,9 +438,9 @@ pub struct ContentCache {
|
||||
/// Subdirectory for write-handle scratch files (disk-backed write buffers).
|
||||
/// Emptied on open so a crashed run leaves no orphans.
|
||||
scratch_dir: PathBuf,
|
||||
/// Subdirectory holding the bytes of writes that have not been uploaded yet
|
||||
/// (offline.md Phase 2/3) — every released write passes through here, since
|
||||
/// the upload is a queued op performed later. Unlike `scratch_dir` this is
|
||||
/// Subdirectory holding the bytes of writes that have not been uploaded yet.
|
||||
/// Every released write passes through here, since the upload is a queued
|
||||
/// op performed later. Unlike `scratch_dir` this is
|
||||
/// **never** emptied on open: these are the only copy of content the user
|
||||
/// authored, and the whole point is that they outlive the daemon.
|
||||
staging_dir: PathBuf,
|
||||
@@ -468,7 +475,7 @@ pub struct ContentCache {
|
||||
thumb_bytes: AtomicU64,
|
||||
/// 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
|
||||
/// scanning the cache directories (plan.md P4).
|
||||
/// scanning the cache directories.
|
||||
db: Arc<Db>,
|
||||
/// LRU touches not yet written to `cache_entries`, keyed by cache key.
|
||||
///
|
||||
@@ -730,6 +737,60 @@ impl ContentCache {
|
||||
Some(buf)
|
||||
}
|
||||
|
||||
/// Write the whole content of `uid` at `(mtime, size)` to `dst` from the
|
||||
/// cache alone, and report whether that worked.
|
||||
///
|
||||
/// A whole-file blob is cloned. Otherwise every block of the revision has to
|
||||
/// be cached, under its recorded geometry or, failing that, the uniform one
|
||||
/// the read path would use. Anything short of the full content leaves
|
||||
/// nothing at `dst`, so a caller falls back to a download with a clean slate.
|
||||
///
|
||||
/// This is what lets a mirror folder take a file an on-demand mount has
|
||||
/// already read, after a move between them, without fetching it again. The
|
||||
/// trust is the read path's: the same `(mtime, size)` key, and blocks that
|
||||
/// were validated when they were fetched.
|
||||
pub fn copy_cached_to(&self, uid: &NodeUid, mtime: i64, size: u64, dst: &Path) -> bool {
|
||||
if size == 0 {
|
||||
return false;
|
||||
}
|
||||
if let Some(blob) = self.valid_blob(uid, mtime, size) {
|
||||
if Self::clone_or_copy(&blob, dst).is_ok_and(|len| len == size) {
|
||||
self.touch(Self::key(uid));
|
||||
return true;
|
||||
}
|
||||
let _ = std::fs::remove_file(dst);
|
||||
return false;
|
||||
}
|
||||
let geometry = self
|
||||
.block_geometry(uid, mtime, size)
|
||||
.unwrap_or_else(|| BlockGeometry::uniform(size));
|
||||
if geometry.size() != size {
|
||||
return false;
|
||||
}
|
||||
let spans = geometry.spans(0, size);
|
||||
// Checked up front so a file with one block missing costs stats, not a
|
||||
// copy of every block before it.
|
||||
if !spans
|
||||
.iter()
|
||||
.all(|&span| self.has_block(uid, mtime, size, span))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let written = (|| -> Option<()> {
|
||||
use std::io::Write;
|
||||
let mut out = std::fs::File::create(dst).ok()?;
|
||||
for span in spans {
|
||||
out.write_all(&self.cached_block(uid, mtime, size, span)?)
|
||||
.ok()?;
|
||||
}
|
||||
(out.metadata().ok()?.len() == size).then_some(())
|
||||
})();
|
||||
if written.is_none() {
|
||||
let _ = std::fs::remove_file(dst);
|
||||
}
|
||||
written.is_some()
|
||||
}
|
||||
|
||||
/// Store `bytes` as the cached content for `uid`, tagged with `(mtime,
|
||||
/// size)`. The blob is written to a temp file then renamed so a concurrent
|
||||
/// reader never sees a partial blob; the meta tag is written last so a
|
||||
@@ -783,6 +844,9 @@ impl ContentCache {
|
||||
}
|
||||
let bytes = std::fs::metadata(&tmp)?.len();
|
||||
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 })?;
|
||||
std::fs::write(self.meta_path(uid), meta)?;
|
||||
self.db
|
||||
@@ -924,7 +988,7 @@ impl ContentCache {
|
||||
/// a miss ([`cached_block`](Self::cached_block) checks), so a bad block
|
||||
/// 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
|
||||
/// `Core::read_block` (bugs.md B84).
|
||||
/// `Core::read_block` (`docs/BUGS.md` B84).
|
||||
pub fn store_block(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
@@ -1426,9 +1490,8 @@ impl ContentCache {
|
||||
/// 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
|
||||
/// on the remote, staging holds the only copy. Every dirty handle goes
|
||||
/// through here — the upload is a queued op performed later (offline.md
|
||||
/// Phase 3), and a staged file is also what a human can recover from if the
|
||||
/// queue never drains.
|
||||
/// through here — the upload is a queued op performed later, and a staged
|
||||
/// file is also what a human can recover from if the queue never drains.
|
||||
///
|
||||
/// 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
|
||||
@@ -2002,6 +2065,17 @@ mod tests {
|
||||
NodeUid::new(VolumeId::from("vol"), LinkId::from(link))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_write_covers_the_file_and_a_gap_does_not() {
|
||||
assert!(
|
||||
StagedWrite::covers(&[], 0),
|
||||
"an empty file has nothing to cover"
|
||||
);
|
||||
assert!(StagedWrite::covers(&[(0, 7)], 7));
|
||||
assert!(!StagedWrite::covers(&[(0, 3), (4, 7)], 7));
|
||||
assert!(!StagedWrite::covers(&[], 7));
|
||||
}
|
||||
|
||||
/// A unique temp directory removed on drop; avoids a dev-dependency.
|
||||
struct TempDir(PathBuf);
|
||||
impl TempDir {
|
||||
@@ -2124,6 +2198,24 @@ mod tests {
|
||||
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]
|
||||
fn reconcile_rebuilds_index_from_disk() {
|
||||
let dir = TempDir::new();
|
||||
@@ -2561,6 +2653,46 @@ mod tests {
|
||||
assert!(c.cached_block(&u, 100, 5000, blk(5000, 0)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cached_blob_is_copied_out_whole() {
|
||||
let (c, d) = cache();
|
||||
let u = uid("blob");
|
||||
c.store(&u, 7, 5, b"hello").unwrap();
|
||||
let dst = d.path().join("out");
|
||||
assert!(c.copy_cached_to(&u, 7, 5, &dst));
|
||||
assert_eq!(std::fs::read(&dst).unwrap(), b"hello");
|
||||
// Another revision is not this one.
|
||||
assert!(!c.copy_cached_to(&u, 8, 5, &d.path().join("stale")));
|
||||
assert!(!d.path().join("stale").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_blocks_are_copied_out_in_order() {
|
||||
let (c, d) = cache();
|
||||
let u = uid("blocks");
|
||||
let size = BLOCK_SIZE + 3;
|
||||
let first = vec![1_u8; BLOCK_SIZE as usize];
|
||||
c.store_block(&u, 7, size, blk(size, 0), &first).unwrap();
|
||||
c.store_block(&u, 7, size, blk(size, 1), b"end").unwrap();
|
||||
let dst = d.path().join("out");
|
||||
assert!(c.copy_cached_to(&u, 7, size, &dst));
|
||||
let bytes = std::fs::read(&dst).unwrap();
|
||||
assert_eq!(bytes.len() as u64, size);
|
||||
assert_eq!(&bytes[..BLOCK_SIZE as usize], first.as_slice());
|
||||
assert_eq!(&bytes[BLOCK_SIZE as usize..], b"end");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_with_a_block_missing_is_not_copied_out() {
|
||||
let (c, d) = cache();
|
||||
let u = uid("partial");
|
||||
let size = BLOCK_SIZE + 3;
|
||||
c.store_block(&u, 7, size, blk(size, 1), b"end").unwrap();
|
||||
let dst = d.path().join("out");
|
||||
assert!(!c.copy_cached_to(&u, 7, size, &dst));
|
||||
assert!(!dst.exists(), "a partial copy must not be left behind");
|
||||
}
|
||||
|
||||
/// The [`BlockSpan`] the uniform geometry gives block `idx` of a file of
|
||||
/// `size` bytes — the shape these tests were written against, and the one
|
||||
/// this client's own uploads still have.
|
||||
@@ -2573,7 +2705,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn block_len_is_full_blocks_then_the_remainder() {
|
||||
let size = BLOCK_SIZE * 2 + 17;
|
||||
@@ -2653,7 +2785,7 @@ mod tests {
|
||||
/// 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
|
||||
/// blocks the uniform fallback cached, rather than serve their bytes at the
|
||||
/// wrong offset (bugs.md B85).
|
||||
/// wrong offset (`docs/BUGS.md` B85).
|
||||
#[test]
|
||||
fn a_cached_block_is_not_served_to_a_different_geometry() {
|
||||
let (c, _d) = cache();
|
||||
@@ -2720,7 +2852,7 @@ mod tests {
|
||||
|
||||
/// 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
|
||||
/// next read refetches rather than serving a hole (bugs.md B84).
|
||||
/// next read refetches rather than serving a hole (`docs/BUGS.md` B84).
|
||||
#[test]
|
||||
fn a_block_of_the_wrong_length_reads_as_a_miss() {
|
||||
let (c, _d) = cache();
|
||||
@@ -2785,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 way scratch deliberately does not.
|
||||
#[test]
|
||||
|
||||
@@ -154,6 +154,13 @@ pub struct AppConfig {
|
||||
/// configs predating the field.
|
||||
#[serde(default)]
|
||||
pub online_map: bool,
|
||||
/// Record a new folder, a new file, a rename or a delete through the mount
|
||||
/// locally and send it from the queue, instead of waiting for Drive in the
|
||||
/// syscall. `None` means on. `false` brings back the old path for one
|
||||
/// release (docs/ROADMAP.md). Defaulted for configs predating
|
||||
/// the field.
|
||||
#[serde(default)]
|
||||
pub local_first: Option<bool>,
|
||||
}
|
||||
|
||||
/// The My Files page's view choices, remembered between runs.
|
||||
@@ -228,6 +235,7 @@ impl Default for AppConfig {
|
||||
tray_hidden: false,
|
||||
language: None,
|
||||
online_map: false,
|
||||
local_first: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -424,7 +432,7 @@ impl AppDirs {
|
||||
/// typical umask makes them `0755`. Until this was fixed, the only thing
|
||||
/// keeping another local user out was `~/.cache` and `~/.local/state`
|
||||
/// happening to be `0700`, which is a convention of the user's system and
|
||||
/// not something this client established (bugs.md B6).
|
||||
/// not something this client established (`docs/BUGS.md` B6).
|
||||
///
|
||||
/// Applied on every start, not just at creation: a directory that already
|
||||
/// exists with a permissive mode — restored from a backup that flattened
|
||||
@@ -475,7 +483,7 @@ fn restrict_dir(dir: &Path) -> Result<()> {
|
||||
/// session — enumerate, read, upload, trash, share — without presenting any
|
||||
/// credential, since the daemon already holds one. It is an authority boundary,
|
||||
/// not merely private data, and `UnixListener::bind` applies the umask like any
|
||||
/// other file (bugs.md B6).
|
||||
/// other file (`docs/BUGS.md` B6).
|
||||
///
|
||||
/// There is a window between `bind` and this call during which the socket
|
||||
/// carries the umask's mode. It is closed in practice by the containing
|
||||
@@ -496,7 +504,7 @@ mod tests {
|
||||
std::fs::metadata(p).unwrap().permissions().mode() & 0o777
|
||||
}
|
||||
|
||||
/// bugs.md B6. A permissive mode here is not a cosmetic problem: these
|
||||
/// `docs/BUGS.md` B6. A permissive mode here is not a cosmetic problem: these
|
||||
/// directories hold decrypted content, a plaintext index of every node name,
|
||||
/// and the control socket that commands the daemon's session.
|
||||
#[test]
|
||||
@@ -584,6 +592,7 @@ mod tests {
|
||||
tray_hidden: true,
|
||||
language: Some("de".to_string()),
|
||||
online_map: true,
|
||||
local_first: Some(false),
|
||||
};
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
|
||||
|
||||
@@ -339,7 +339,10 @@ pub enum Request {
|
||||
/// single path component (no separators). Replies with [`Response::Ok`].
|
||||
Rename { path: String, new_name: String },
|
||||
/// Move a file or folder into a new parent folder. Both `path` and
|
||||
/// `new_parent` are mountpoint-relative. Replies with [`Response::Ok`].
|
||||
/// `new_parent` are mountpoint-relative, or both absolute local paths; two
|
||||
/// absolute paths may lie in different locations (My files, an on-demand
|
||||
/// or a mirror synced folder), and the move is still made on Proton Drive
|
||||
/// without transferring content. Replies with [`Response::Ok`].
|
||||
Move { path: String, new_parent: String },
|
||||
/// Trash a file or folder. `path` is mountpoint-relative. Replies with
|
||||
/// [`Response::Ok`].
|
||||
@@ -440,11 +443,11 @@ pub enum Request {
|
||||
DeleteDevice { uid: String },
|
||||
/// 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
|
||||
/// 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`].
|
||||
AdoptDevice { uid: Option<String> },
|
||||
|
||||
// ---- device folder sync (devices.md) ----------------------------------
|
||||
// ---- device folder sync ----------------------------------
|
||||
/// Add a local folder to this machine's device, uploading its tree and
|
||||
/// registering the device on first use. Replies with [`Response::Ok`].
|
||||
AddSyncFolder { local_path: String },
|
||||
@@ -474,6 +477,15 @@ pub enum Request {
|
||||
/// Retry a backed-off queued op now instead of waiting out its backoff;
|
||||
/// every failed op when `id` is `None`. Replies with [`Response::Ok`].
|
||||
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
|
||||
/// folders. Replies with
|
||||
/// [`Response::Conflicts`].
|
||||
@@ -484,7 +496,7 @@ pub enum Request {
|
||||
/// [`Response::Ok`].
|
||||
ResolveConflict { path: String, keep: ConflictKeep },
|
||||
/// 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`].
|
||||
ListRestorableFolders,
|
||||
/// Attach the given remote device folders to local paths and sync them down.
|
||||
@@ -877,6 +889,59 @@ pub struct PendingOpInfo {
|
||||
pub parked: bool,
|
||||
/// Failed often enough to count as stuck.
|
||||
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`]).
|
||||
@@ -1231,6 +1296,10 @@ pub struct DirEntry {
|
||||
/// not known, and for anything not in the trash.
|
||||
#[serde(default)]
|
||||
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
|
||||
@@ -1834,17 +1903,17 @@ pub enum Response {
|
||||
/// The pin registry.
|
||||
pins: Vec<Pin>,
|
||||
/// 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.
|
||||
#[serde(default = "default_online")]
|
||||
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
|
||||
/// still draining, or it cannot drain because we are offline.
|
||||
#[serde(default)]
|
||||
pending_uploads: u64,
|
||||
/// Queued mutations that carry no bytes: `mkdir`, `rename`, `trash`
|
||||
/// (offline.md Phase 3b). Counted apart from `pending_uploads` because
|
||||
/// Queued mutations that carry no bytes: `mkdir`, `rename`, `trash`.
|
||||
/// Counted apart from `pending_uploads` because
|
||||
/// calling a queued `mkdir` an upload is a lie.
|
||||
#[serde(default)]
|
||||
pending_changes: u64,
|
||||
@@ -3287,6 +3356,17 @@ mod tests {
|
||||
},
|
||||
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 {
|
||||
assert_eq!(serde_json::to_string(&request).unwrap(), wire);
|
||||
@@ -3295,6 +3375,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`
|
||||
/// field, and must keep getting the whole timeline rather than an empty page.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
/* WARNING: Script requires that SQLITE_DBCONFIG_DEFENSIVE be disabled */
|
||||
PRAGMA foreign_keys=OFF;
|
||||
BEGIN TRANSACTION;
|
||||
CREATE TABLE sync_state (key TEXT PRIMARY KEY, value TEXT);
|
||||
INSERT INTO sync_state VALUES('schema_version','35');
|
||||
CREATE TABLE nodes (
|
||||
uid TEXT PRIMARY KEY,
|
||||
parent_uid TEXT,
|
||||
name TEXT NOT NULL,
|
||||
is_dir INTEGER NOT NULL,
|
||||
size INTEGER,
|
||||
mtime INTEGER NOT NULL,
|
||||
revision_hash TEXT,
|
||||
trashed INTEGER NOT NULL DEFAULT 0
|
||||
, node_json TEXT, listed INTEGER NOT NULL DEFAULT 0, path TEXT);
|
||||
INSERT INTO nodes VALUES('vol~root',NULL,'My Files',1,0,200,NULL,0,'{"uid":{"volume_id":"vol","link_id":"root"},"parent_uid":null,"kind":"Folder","name":"My Files","creation_time":100,"modification_time":200,"trashed":false,"is_shared":false,"is_shared_publicly":false,"signature_email":null,"membership":null,"direct_role":null,"share_id":null,"photo":null,"album":null,"verification":{"name":"NotSigned","passphrase":"NotSigned","content_key":null,"extended_attributes":null}}',0,'');
|
||||
INSERT INTO nodes VALUES('vol~docs','vol~root','Docs',1,0,200,NULL,0,'{"uid":{"volume_id":"vol","link_id":"docs"},"parent_uid":{"volume_id":"vol","link_id":"root"},"kind":"Folder","name":"Docs","creation_time":100,"modification_time":200,"trashed":false,"is_shared":false,"is_shared_publicly":false,"signature_email":null,"membership":null,"direct_role":null,"share_id":null,"photo":null,"album":null,"verification":{"name":"NotSigned","passphrase":"NotSigned","content_key":null,"extended_attributes":null}}',0,'Docs');
|
||||
INSERT INTO nodes VALUES('vol~report','vol~docs','report.txt',0,10,200,NULL,0,'{"uid":{"volume_id":"vol","link_id":"report"},"parent_uid":{"volume_id":"vol","link_id":"docs"},"kind":{"File":{"media_type":"text/plain","total_size_on_storage":20,"active_revision_state":null,"active_revision_id":null,"claimed_size":10,"claimed_modification_time":null,"content_sha1":null}},"name":"report.txt","creation_time":100,"modification_time":200,"trashed":false,"is_shared":false,"is_shared_publicly":false,"signature_email":null,"membership":null,"direct_role":null,"share_id":null,"photo":null,"album":null,"verification":{"name":"NotSigned","passphrase":"NotSigned","content_key":null,"extended_attributes":null}}',0,'Docs/report.txt');
|
||||
INSERT INTO nodes VALUES('vol~old','vol~docs','old.txt',0,3,200,NULL,0,'{"uid":{"volume_id":"vol","link_id":"old"},"parent_uid":{"volume_id":"vol","link_id":"docs"},"kind":{"File":{"media_type":"text/plain","total_size_on_storage":13,"active_revision_state":null,"active_revision_id":null,"claimed_size":3,"claimed_modification_time":null,"content_sha1":null}},"name":"old.txt","creation_time":100,"modification_time":200,"trashed":false,"is_shared":false,"is_shared_publicly":false,"signature_email":null,"membership":null,"direct_role":null,"share_id":null,"photo":null,"album":null,"verification":{"name":"NotSigned","passphrase":"NotSigned","content_key":null,"extended_attributes":null}}',0,'Docs/old.txt');
|
||||
INSERT INTO nodes VALUES('vol~shared','vol~docs','shared.txt',0,4,200,NULL,0,'{"uid":{"volume_id":"vol","link_id":"shared"},"parent_uid":{"volume_id":"vol","link_id":"docs"},"kind":{"File":{"media_type":"text/plain","total_size_on_storage":14,"active_revision_state":null,"active_revision_id":null,"claimed_size":4,"claimed_modification_time":null,"content_sha1":null}},"name":"shared.txt","creation_time":100,"modification_time":200,"trashed":false,"is_shared":false,"is_shared_publicly":false,"signature_email":null,"membership":null,"direct_role":null,"share_id":null,"photo":null,"album":null,"verification":{"name":"NotSigned","passphrase":"NotSigned","content_key":null,"extended_attributes":null}}',0,'Docs/shared.txt');
|
||||
INSERT INTO nodes VALUES('local~dir','vol~root','Offline folder',1,0,200,NULL,0,'{"uid":{"volume_id":"local","link_id":"dir"},"parent_uid":{"volume_id":"vol","link_id":"root"},"kind":"Folder","name":"Offline folder","creation_time":100,"modification_time":200,"trashed":false,"is_shared":false,"is_shared_publicly":false,"signature_email":null,"membership":null,"direct_role":null,"share_id":null,"photo":null,"album":null,"verification":{"name":"NotSigned","passphrase":"NotSigned","content_key":null,"extended_attributes":null}}',1,'Offline folder');
|
||||
INSERT INTO nodes VALUES('local~inner','local~dir','inner.txt',0,5,200,NULL,0,'{"uid":{"volume_id":"local","link_id":"inner"},"parent_uid":{"volume_id":"local","link_id":"dir"},"kind":{"File":{"media_type":"text/plain","total_size_on_storage":15,"active_revision_state":null,"active_revision_id":null,"claimed_size":5,"claimed_modification_time":null,"content_sha1":null}},"name":"inner.txt","creation_time":100,"modification_time":200,"trashed":false,"is_shared":false,"is_shared_publicly":false,"signature_email":null,"membership":null,"direct_role":null,"share_id":null,"photo":null,"album":null,"verification":{"name":"NotSigned","passphrase":"NotSigned","content_key":null,"extended_attributes":null}}',0,'Offline folder/inner.txt');
|
||||
INSERT INTO nodes VALUES('local~part','vol~root','movie.part',0,7,200,NULL,0,'{"uid":{"volume_id":"local","link_id":"part"},"parent_uid":{"volume_id":"vol","link_id":"root"},"kind":{"File":{"media_type":"text/plain","total_size_on_storage":17,"active_revision_state":null,"active_revision_id":null,"claimed_size":7,"claimed_modification_time":null,"content_sha1":null}},"name":"movie.part","creation_time":100,"modification_time":200,"trashed":false,"is_shared":false,"is_shared_publicly":false,"signature_email":null,"membership":null,"direct_role":null,"share_id":null,"photo":null,"album":null,"verification":{"name":"NotSigned","passphrase":"NotSigned","content_key":null,"extended_attributes":null}}',0,'movie.part');
|
||||
CREATE TABLE cache_entries (
|
||||
cache_key TEXT PRIMARY KEY,
|
||||
size_bytes INTEGER,
|
||||
last_accessed INTEGER,
|
||||
is_pinned INTEGER NOT NULL DEFAULT 0
|
||||
, kind TEXT NOT NULL DEFAULT 'blob');
|
||||
CREATE TABLE pins (
|
||||
uid TEXT PRIMARY KEY,
|
||||
path TEXT NOT NULL,
|
||||
recursive INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE local_files (
|
||||
id INTEGER PRIMARY KEY,
|
||||
path TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
is_dir INTEGER NOT NULL,
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
mtime INTEGER NOT NULL DEFAULT 0,
|
||||
scan_gen INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE photos (
|
||||
uid TEXT PRIMARY KEY,
|
||||
capture_time INTEGER NOT NULL,
|
||||
name TEXT,
|
||||
ratio REAL,
|
||||
thumb_state INTEGER NOT NULL DEFAULT 0,
|
||||
seq INTEGER NOT NULL
|
||||
, media_type TEXT, kind INTEGER NOT NULL DEFAULT 0, favorite INTEGER NOT NULL DEFAULT 0, content_hash TEXT, main_uid TEXT, group_key TEXT, resolved_at INTEGER, latitude REAL, longitude REAL, place_id INTEGER, similar_hash INTEGER);
|
||||
CREATE TABLE trash (
|
||||
uid TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
is_dir INTEGER NOT NULL,
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
mtime INTEGER NOT NULL DEFAULT 0
|
||||
, parent_uid TEXT, trashed_at INTEGER);
|
||||
CREATE TABLE device (
|
||||
uid TEXT PRIMARY KEY,
|
||||
share_id TEXT NOT NULL,
|
||||
root_uid TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
created INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE sync_folder (
|
||||
id INTEGER PRIMARY KEY,
|
||||
local_path TEXT NOT NULL UNIQUE,
|
||||
remote_uid TEXT NOT NULL,
|
||||
remote_share_id TEXT NOT NULL,
|
||||
mode TEXT NOT NULL DEFAULT 'mirror',
|
||||
state TEXT NOT NULL DEFAULT 'idle',
|
||||
last_sync INTEGER NOT NULL DEFAULT 0
|
||||
, pending_mode TEXT);
|
||||
CREATE TABLE sync_entry (
|
||||
folder_id INTEGER NOT NULL,
|
||||
rel_path TEXT NOT NULL,
|
||||
remote_uid TEXT,
|
||||
local_mtime INTEGER NOT NULL DEFAULT 0,
|
||||
local_size INTEGER NOT NULL DEFAULT 0,
|
||||
remote_hash TEXT,
|
||||
remote_rev TEXT, local_mtime_ns INTEGER,
|
||||
PRIMARY KEY (folder_id, rel_path)
|
||||
);
|
||||
CREATE TABLE activity (
|
||||
id INTEGER PRIMARY KEY,
|
||||
time INTEGER NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
detail TEXT NOT NULL DEFAULT '',
|
||||
ok INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
CREATE TABLE pending_op (
|
||||
id INTEGER PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
uid TEXT NOT NULL,
|
||||
blob_path TEXT,
|
||||
meta_json TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
next_attempt_at INTEGER NOT NULL DEFAULT 0
|
||||
, parent_uid TEXT, name TEXT, claimed_at INTEGER NOT NULL DEFAULT 0, access_deferred_since INTEGER NOT NULL DEFAULT 0);
|
||||
INSERT INTO pending_op VALUES(1,'mkdir','local~dir',NULL,NULL,1700000000000,0,NULL,0,'vol~root','Offline folder',4611686018427387903,0);
|
||||
INSERT INTO pending_op VALUES(2,'create','local~inner','/blobs/inner','{"len":5,"mtime":1700000000,"based_on":null}',1700000000000,0,NULL,0,'local~dir','inner.txt',0,0);
|
||||
INSERT INTO pending_op VALUES(3,'create','local~part','/blobs/part','{"len":5,"mtime":1700000000,"based_on":null}',1700000000000,0,NULL,8000000000000,'vol~root','movie.part',0,0);
|
||||
INSERT INTO pending_op VALUES(4,'revision','vol~report','/blobs/report','{"len":5,"mtime":1700000000,"based_on":null}',1700000000000,2,'network unreachable',1700000900000,NULL,NULL,0,0);
|
||||
INSERT INTO pending_op VALUES(5,'rename','vol~shared',NULL,'{"original_parent_uid":"vol~docs","original_name":"shared.txt"}',1700000000000,0,NULL,1700000700000,'vol~root','shared-moved.txt',0,1700000100000);
|
||||
INSERT INTO pending_op VALUES(6,'trash','vol~old',NULL,NULL,1700000000000,0,NULL,0,NULL,NULL,0,0);
|
||||
CREATE TABLE IF NOT EXISTS 'local_fts_data'(id INTEGER PRIMARY KEY, block BLOB);
|
||||
INSERT INTO local_fts_data VALUES(1,x'000000');
|
||||
INSERT INTO local_fts_data VALUES(10,x'00000000000000');
|
||||
CREATE TABLE IF NOT EXISTS 'local_fts_idx'(segid, term, pgno, PRIMARY KEY(segid, term)) WITHOUT ROWID;
|
||||
CREATE TABLE IF NOT EXISTS 'local_fts_docsize'(id INTEGER PRIMARY KEY, sz BLOB);
|
||||
CREATE TABLE IF NOT EXISTS 'local_fts_config'(k PRIMARY KEY, v) WITHOUT ROWID;
|
||||
INSERT INTO local_fts_config VALUES('version',4);
|
||||
CREATE TABLE share_access (
|
||||
root_uid TEXT PRIMARY KEY,
|
||||
access TEXT NOT NULL
|
||||
CHECK (access IN ('owner', 'editor', 'viewer', 'unknown'))
|
||||
);
|
||||
CREATE TABLE mount (
|
||||
id INTEGER PRIMARY KEY,
|
||||
kind TEXT NOT NULL
|
||||
CHECK (kind IN ('myfiles', 'device', 'shared')),
|
||||
sync_folder_id INTEGER REFERENCES sync_folder(id) ON DELETE CASCADE,
|
||||
share_root_uid TEXT,
|
||||
local_path TEXT NOT NULL DEFAULT '',
|
||||
root_uid TEXT NOT NULL DEFAULT '',
|
||||
root_share_id TEXT NOT NULL DEFAULT '',
|
||||
mode TEXT NOT NULL DEFAULT 'unknown',
|
||||
access TEXT NOT NULL DEFAULT 'rw'
|
||||
CHECK (access IN ('rw', 'ro')),
|
||||
CHECK (
|
||||
(kind = 'myfiles' AND sync_folder_id IS NULL AND share_root_uid IS NULL)
|
||||
OR
|
||||
(kind = 'device' AND sync_folder_id IS NOT NULL AND share_root_uid IS NULL)
|
||||
OR
|
||||
(kind = 'shared' AND sync_folder_id IS NULL AND share_root_uid IS NOT NULL)
|
||||
)
|
||||
);
|
||||
CREATE TABLE albums (
|
||||
uid TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
photo_count INTEGER NOT NULL DEFAULT 0,
|
||||
cover_uid TEXT,
|
||||
last_activity INTEGER,
|
||||
shared INTEGER NOT NULL DEFAULT 0,
|
||||
seq INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE album_photos (
|
||||
album_uid TEXT NOT NULL,
|
||||
uid TEXT NOT NULL,
|
||||
capture_time INTEGER NOT NULL,
|
||||
name TEXT,
|
||||
media_type TEXT,
|
||||
kind INTEGER NOT NULL DEFAULT 0,
|
||||
ratio REAL,
|
||||
thumb_state INTEGER NOT NULL DEFAULT 0,
|
||||
seq INTEGER NOT NULL,
|
||||
PRIMARY KEY (album_uid, uid)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS 'nodes_fts_data'(id INTEGER PRIMARY KEY, block BLOB);
|
||||
INSERT INTO nodes_fts_data VALUES(1,x'083850');
|
||||
INSERT INTO nodes_fts_data VALUES(10,x'000000000108080008010101020101030101040101050101060101070101080101');
|
||||
INSERT INTO nodes_fts_data VALUES(137438953473,x'000000340430206669010204010366696c0102050103696c6501020601036c657301020701036d79200102020103792066010203040808080808');
|
||||
INSERT INTO nodes_fts_data VALUES(274877906945,x'0000001a0430646f6302080201010201036f6373020803010103040b');
|
||||
INSERT INTO nodes_fts_data VALUES(412316860417,x'0000008b04302e747803080801010d01032f72650306010106010363732f03060101040103646f630306010102010365706f03080301010801036f637303060101030202727403080501010a0103706f7203080401010901037265700308020101070202742e03080601010b0103732f7203060101050103742e7403080701010c0202787403080901010e040b0a0a0a0b0a0a0b0b0a0a0b');
|
||||
INSERT INTO nodes_fts_data VALUES(549755813889,x'0000006b04302e747804080501010a01032f6f6c0406010106010363732f04060101040103642e7404080401010902026f63040601010201036c642e04080301010801036f6373040601010302026c640408020101070103732f6f0406010105010374787404080601010b040b0a0a0b090b0a0a0a');
|
||||
INSERT INTO nodes_fts_data VALUES(687194767361,x'0000008c04302e747805080801010d01032f736805060101060103617265050804010109010363732f05060101040103642e7405080701010c02026f630506010102010365642e05080601010b010368617205080301010801036f63730506010103010372656405080501010a0103732f73050601010502026861050802010107010374787405080901010e040b0a0b0a0b090b0b0a0b0a0a');
|
||||
INSERT INTO nodes_fts_data VALUES(824633720833,x'00000084043020666f060809010109010364657206080d01010d0103652066060808010108010366666c06080301010302026c6906080401010402026f6c06080a01010a0103696e6506080601010601036c646506080c01010c0202696e06080501010501036e652006080701010701036f666606080201010202026c6406080b01010b040b0b0b0b0a0a0b0b0a0b0b');
|
||||
INSERT INTO nodes_fts_data VALUES(962072674305,x'000000da043020666f070601010901032e747807080701011601032f696e07060101100103646572070601010d010365206607060101080202722e07080501011403012f070601010e010366666c070601010302026c69070601010402026f6c070601010a0103696e65070601010603016e07080201011101036c6465070601010c0202696e070601010501036e6520070601010703017207080401011302026e6507080301011201036f6666070601010202026c64070601010b0103722e7407080601011502022f69070601010f0103747874070808010117040a0b0a0a0a0a080a09090a090a090a090a0a090b09');
|
||||
INSERT INTO nodes_fts_data VALUES(1099511627777,x'0000005c04302e706108080701010701036172740808090101090103652e70080806010106010369652e08080501010501036d6f7608080201010201036f766908080301010301037061720808080101080103766965080804010104040b0b0b0b0b0b0b');
|
||||
CREATE TABLE IF NOT EXISTS 'nodes_fts_idx'(segid, term, pgno, PRIMARY KEY(segid, term)) WITHOUT ROWID;
|
||||
INSERT INTO nodes_fts_idx VALUES(1,x'',2);
|
||||
INSERT INTO nodes_fts_idx VALUES(2,x'',2);
|
||||
INSERT INTO nodes_fts_idx VALUES(3,x'',2);
|
||||
INSERT INTO nodes_fts_idx VALUES(4,x'',2);
|
||||
INSERT INTO nodes_fts_idx VALUES(5,x'',2);
|
||||
INSERT INTO nodes_fts_idx VALUES(6,x'',2);
|
||||
INSERT INTO nodes_fts_idx VALUES(7,x'',2);
|
||||
INSERT INTO nodes_fts_idx VALUES(8,x'',2);
|
||||
CREATE TABLE IF NOT EXISTS 'nodes_fts_content'(id INTEGER PRIMARY KEY, c0, c1);
|
||||
INSERT INTO nodes_fts_content VALUES(1,'My Files','');
|
||||
INSERT INTO nodes_fts_content VALUES(2,'Docs','Docs');
|
||||
INSERT INTO nodes_fts_content VALUES(3,'report.txt','Docs/report.txt');
|
||||
INSERT INTO nodes_fts_content VALUES(4,'old.txt','Docs/old.txt');
|
||||
INSERT INTO nodes_fts_content VALUES(5,'shared.txt','Docs/shared.txt');
|
||||
INSERT INTO nodes_fts_content VALUES(6,'Offline folder','Offline folder');
|
||||
INSERT INTO nodes_fts_content VALUES(7,'inner.txt','Offline folder/inner.txt');
|
||||
INSERT INTO nodes_fts_content VALUES(8,'movie.part','movie.part');
|
||||
CREATE TABLE IF NOT EXISTS 'nodes_fts_docsize'(id INTEGER PRIMARY KEY, sz BLOB);
|
||||
INSERT INTO nodes_fts_docsize VALUES(1,x'0600');
|
||||
INSERT INTO nodes_fts_docsize VALUES(2,x'0202');
|
||||
INSERT INTO nodes_fts_docsize VALUES(3,x'080d');
|
||||
INSERT INTO nodes_fts_docsize VALUES(4,x'050a');
|
||||
INSERT INTO nodes_fts_docsize VALUES(5,x'080d');
|
||||
INSERT INTO nodes_fts_docsize VALUES(6,x'0c0c');
|
||||
INSERT INTO nodes_fts_docsize VALUES(7,x'0716');
|
||||
INSERT INTO nodes_fts_docsize VALUES(8,x'0808');
|
||||
CREATE TABLE IF NOT EXISTS 'nodes_fts_config'(k PRIMARY KEY, v) WITHOUT ROWID;
|
||||
INSERT INTO nodes_fts_config VALUES('version',4);
|
||||
CREATE TABLE own_sealed_rev (
|
||||
uid TEXT PRIMARY KEY,
|
||||
revision_id TEXT NOT NULL,
|
||||
sealed_at INTEGER NOT NULL
|
||||
);
|
||||
INSERT INTO own_sealed_rev VALUES('vol~report','rev-1',1700000000000);
|
||||
PRAGMA writable_schema=ON;
|
||||
INSERT INTO sqlite_schema(type,name,tbl_name,rootpage,sql)VALUES('table','local_fts','local_fts',0,'CREATE VIRTUAL TABLE local_fts USING fts5(
|
||||
name, path, content=''local_files'', content_rowid=''id'', tokenize=''trigram''
|
||||
)');
|
||||
INSERT INTO sqlite_schema(type,name,tbl_name,rootpage,sql)VALUES('table','nodes_fts','nodes_fts',0,'CREATE VIRTUAL TABLE nodes_fts USING fts5(
|
||||
name, path, tokenize=''trigram''
|
||||
)');
|
||||
CREATE TRIGGER mount_sync_folder_insert
|
||||
AFTER INSERT ON sync_folder
|
||||
BEGIN
|
||||
INSERT OR IGNORE INTO mount (kind, sync_folder_id)
|
||||
VALUES ('device', NEW.id);
|
||||
END;
|
||||
CREATE INDEX idx_nodes_parent ON nodes(parent_uid);
|
||||
CREATE INDEX idx_photos_seq ON photos(seq);
|
||||
CREATE INDEX pending_op_uid ON pending_op(uid);
|
||||
CREATE INDEX pending_op_parent ON pending_op(parent_uid);
|
||||
CREATE INDEX cache_entries_lru ON cache_entries(kind, last_accessed);
|
||||
CREATE INDEX idx_nodes_name_nocase ON nodes(name COLLATE NOCASE);
|
||||
CREATE INDEX idx_local_files_name_nocase
|
||||
ON local_files(name COLLATE NOCASE);
|
||||
CREATE UNIQUE INDEX mount_myfiles
|
||||
ON mount(kind) WHERE kind = 'myfiles';
|
||||
CREATE UNIQUE INDEX mount_device
|
||||
ON mount(sync_folder_id) WHERE kind = 'device';
|
||||
CREATE UNIQUE INDEX mount_shared
|
||||
ON mount(share_root_uid) WHERE kind = 'shared';
|
||||
CREATE INDEX idx_album_photos_seq ON album_photos(album_uid, seq);
|
||||
CREATE INDEX idx_album_photos_uid ON album_photos(uid);
|
||||
CREATE INDEX idx_photos_favorite ON photos(favorite, seq);
|
||||
CREATE INDEX idx_sync_entry_remote_uid ON sync_entry(remote_uid);
|
||||
CREATE INDEX idx_local_files_scan_gen ON local_files(scan_gen);
|
||||
CREATE INDEX idx_nodes_parent_live ON nodes(parent_uid) WHERE trashed = 0;
|
||||
CREATE INDEX idx_pending_op_claimed
|
||||
ON pending_op(uid) WHERE claimed_at <> 0;
|
||||
CREATE INDEX idx_trash_parent ON trash(parent_uid);
|
||||
CREATE INDEX idx_photos_group ON photos(group_key);
|
||||
CREATE INDEX idx_photos_unresolved ON photos(resolved_at);
|
||||
CREATE INDEX idx_photos_place ON photos(place_id);
|
||||
PRAGMA writable_schema=OFF;
|
||||
COMMIT;
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Database health reporting and repair (features.md 4.3).
|
||||
//! Database health reporting and repair.
|
||||
//!
|
||||
//! Everything here exists for the case where something has already gone wrong:
|
||||
//! 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
|
||||
//! what an existing install actually has. Add a new one instead.
|
||||
|
||||
use rusqlite::OptionalExtension;
|
||||
|
||||
use super::Db;
|
||||
use crate::Result;
|
||||
|
||||
/// Current schema version. Bump on every forward migration added below.
|
||||
pub(super) const SCHEMA_VERSION: i64 = 35;
|
||||
pub(super) const SCHEMA_VERSION: i64 = 42;
|
||||
|
||||
impl Db {
|
||||
pub(super) fn migrate(&self) -> Result<()> {
|
||||
@@ -332,6 +334,112 @@ impl Db {
|
||||
tx.execute_batch(MIGRATION_V35)?;
|
||||
}
|
||||
}
|
||||
if current < 36 {
|
||||
// Same guards as V26-V35.
|
||||
let has_column: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('nodes') WHERE name = 'lid'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
let has_nodes: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'nodes'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
if has_nodes && !has_column {
|
||||
tx.execute_batch(MIGRATION_V36)?;
|
||||
}
|
||||
}
|
||||
if current < 37 {
|
||||
// Same guards as V26-V36.
|
||||
let sql: Option<String> = tx
|
||||
.query_row(
|
||||
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'pending_op'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
if sql.is_some_and(|sql| !sql.contains("AUTOINCREMENT")) {
|
||||
tx.execute_batch(MIGRATION_V37)?;
|
||||
}
|
||||
}
|
||||
if current < 38 {
|
||||
// Same guards as V26-V37.
|
||||
let has_column: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('pending_op') WHERE name = 'issue'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
let has_ops: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'pending_op'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
if has_ops && !has_column {
|
||||
tx.execute_batch(MIGRATION_V38)?;
|
||||
}
|
||||
}
|
||||
if current < 39 {
|
||||
// Same guards as V26-V38.
|
||||
let has_column: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('pending_op') WHERE name = 'lid'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
let has_ops: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'pending_op'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
if has_ops && !has_column {
|
||||
tx.execute_batch(MIGRATION_V39)?;
|
||||
}
|
||||
}
|
||||
if current < 40 {
|
||||
// Same guards as V26-V39.
|
||||
let has_column: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('nodes') WHERE name = 'parent_lid'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
let has_nodes: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'nodes'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
if has_nodes && !has_column {
|
||||
tx.execute_batch(MIGRATION_V40)?;
|
||||
}
|
||||
}
|
||||
if current < 41 {
|
||||
// Needs both tables; on a database that never had them there is
|
||||
// nothing to rewrite.
|
||||
let tables: i64 = tx.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master
|
||||
WHERE type = 'table' AND name IN ('nodes', 'pending_op', 'pins')",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if tables == 3 {
|
||||
tx.execute_batch(MIGRATION_V41)?;
|
||||
}
|
||||
}
|
||||
if current < 42 {
|
||||
// Same guards as V26-V40.
|
||||
let has_column: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('pending_op') WHERE name = 'sent_sha1'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
let has_ops: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'pending_op'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
if has_ops && !has_column {
|
||||
tx.execute_batch(MIGRATION_V42)?;
|
||||
}
|
||||
}
|
||||
tx.execute(
|
||||
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
@@ -469,7 +577,7 @@ CREATE TABLE trash (
|
||||
);
|
||||
";
|
||||
|
||||
/// Schema v8: device sync (devices.md). This machine registers as one Proton Drive
|
||||
/// Schema v8: device sync. This machine registers as one Proton Drive
|
||||
/// **Device** (a share + root folder on the main volume); `device` is a singleton
|
||||
/// row cached so we reuse the same device across restarts instead of creating a
|
||||
/// new one each run. `sync_folder` is one row per local folder the user added,
|
||||
@@ -530,8 +638,8 @@ ALTER TABLE sync_folder ADD COLUMN pending_mode TEXT;
|
||||
";
|
||||
|
||||
/// Schema v11: writes no longer upload inside the FUSE handler. `release` stages
|
||||
/// the bytes and records the intended upload here, and a drain worker performs it
|
||||
/// (offline.md Phase 3). The row outlives the process, so a write survives both a
|
||||
/// the bytes and records the intended upload here, and a drain worker performs it.
|
||||
/// The row outlives the process, so a write survives both a
|
||||
/// dead network and a restart.
|
||||
///
|
||||
/// `blob_path` points into the content cache's `staging/` dir and `meta_json` is
|
||||
@@ -558,7 +666,7 @@ CREATE INDEX pending_op_uid ON pending_op(uid);
|
||||
|
||||
/// Schema v12: `pending_op` also carries mutations that *create* a node, which a
|
||||
/// revision op never had to describe — it always addressed a node the server had
|
||||
/// already minted a uid for (offline.md Phase 3b).
|
||||
/// already minted a uid for.
|
||||
///
|
||||
/// An offline `create`/`mkdir` cannot get a real uid, so the node is invented
|
||||
/// locally under a `local~<uuid>` placeholder and the op records where it goes
|
||||
@@ -948,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
|
||||
/// rewritten, an image re-exported, a database page updated in place — read as
|
||||
/// *unchanged* and was never uploaded. The remote copy silently stayed at the
|
||||
/// older content until something else about the file moved (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
|
||||
/// nanosecond part of an existing baseline is genuinely unknown, and inventing
|
||||
@@ -1059,3 +1167,166 @@ ALTER TABLE photos ADD COLUMN similar_hash INTEGER;
|
||||
const MIGRATION_V35: &str = "
|
||||
ALTER TABLE trash ADD COLUMN trashed_at INTEGER;
|
||||
";
|
||||
|
||||
/// Schema v36: every node row has a local id (`lid`); see
|
||||
/// docs/ARCHITECTURE.md §2.
|
||||
///
|
||||
/// The `uid` was the row's key, so a node had no identity until Drive minted
|
||||
/// one, and a create that landed moved its row to a new key. The `lid` is given
|
||||
/// when the row is first written and never changes or comes back, which is what
|
||||
/// `AUTOINCREMENT` adds over a plain rowid. It takes the old rowid, so the
|
||||
/// search index, keyed by rowid since v15, stays valid. The `uid` becomes
|
||||
/// nullable for a node that is not on Drive yet.
|
||||
const MIGRATION_V36: &str = "
|
||||
CREATE TABLE nodes_v36 (
|
||||
lid INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uid TEXT UNIQUE,
|
||||
parent_uid TEXT,
|
||||
name TEXT NOT NULL,
|
||||
is_dir INTEGER NOT NULL,
|
||||
size INTEGER,
|
||||
mtime INTEGER NOT NULL,
|
||||
revision_hash TEXT,
|
||||
trashed INTEGER NOT NULL DEFAULT 0,
|
||||
node_json TEXT,
|
||||
listed INTEGER NOT NULL DEFAULT 0,
|
||||
path TEXT
|
||||
);
|
||||
INSERT INTO nodes_v36
|
||||
(lid, uid, parent_uid, name, is_dir, size, mtime, revision_hash, trashed, node_json, listed,
|
||||
path)
|
||||
SELECT rowid, uid, parent_uid, name, is_dir, size, mtime, revision_hash, trashed, node_json,
|
||||
listed, path
|
||||
FROM nodes;
|
||||
DROP TABLE nodes;
|
||||
ALTER TABLE nodes_v36 RENAME TO nodes;
|
||||
CREATE INDEX idx_nodes_parent ON nodes(parent_uid);
|
||||
CREATE INDEX idx_nodes_name_nocase ON nodes(name COLLATE NOCASE);
|
||||
CREATE INDEX idx_nodes_parent_live ON nodes(parent_uid) WHERE trashed = 0;
|
||||
";
|
||||
|
||||
/// Schema v37: a `pending_op` id is never given out twice.
|
||||
///
|
||||
/// A plain rowid is the largest one plus one, so an op queued right after the
|
||||
/// newest one was dropped took its id. The drain retires the op it is
|
||||
/// working on by id, and so deleted the new op: a trash queued over a rename
|
||||
/// on the wire was lost when the rename landed. `AUTOINCREMENT` never reuses
|
||||
/// an id. Ids are kept, as is every index.
|
||||
const MIGRATION_V37: &str = "
|
||||
CREATE TABLE pending_op_v37 (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kind TEXT NOT NULL,
|
||||
uid TEXT NOT NULL,
|
||||
blob_path TEXT,
|
||||
meta_json TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
next_attempt_at INTEGER NOT NULL DEFAULT 0,
|
||||
parent_uid TEXT,
|
||||
name TEXT,
|
||||
claimed_at INTEGER NOT NULL DEFAULT 0,
|
||||
access_deferred_since INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
INSERT INTO pending_op_v37
|
||||
(id, kind, uid, blob_path, meta_json, created_at, attempts, last_error, next_attempt_at,
|
||||
parent_uid, name, claimed_at, access_deferred_since)
|
||||
SELECT id, kind, uid, blob_path, meta_json, created_at, attempts, last_error,
|
||||
next_attempt_at, parent_uid, name, claimed_at, access_deferred_since
|
||||
FROM pending_op;
|
||||
DROP TABLE pending_op;
|
||||
ALTER TABLE pending_op_v37 RENAME TO pending_op;
|
||||
CREATE INDEX pending_op_uid ON pending_op(uid);
|
||||
CREATE INDEX pending_op_parent ON pending_op(parent_uid);
|
||||
CREATE INDEX idx_pending_op_claimed
|
||||
ON pending_op(uid) WHERE claimed_at <> 0;
|
||||
";
|
||||
|
||||
/// Schema v38: why Drive refused a queued op, when it did (a sync issue).
|
||||
/// `NULL` for an op that has not been refused, which is every op migrated.
|
||||
const MIGRATION_V38: &str = "
|
||||
ALTER TABLE pending_op ADD COLUMN issue TEXT;
|
||||
";
|
||||
|
||||
/// Schema v39: a queued op names its node and its parent by local id too.
|
||||
///
|
||||
/// A create made inside a folder that had not landed named the folder by its
|
||||
/// placeholder uid, and every landing had to find and rewrite those strings.
|
||||
/// The local id stays the same when the folder lands. An op whose node has no
|
||||
/// row keeps `NULL` and is matched by uid, as before.
|
||||
const MIGRATION_V39: &str = "
|
||||
ALTER TABLE pending_op ADD COLUMN lid INTEGER;
|
||||
ALTER TABLE pending_op ADD COLUMN parent_lid INTEGER;
|
||||
UPDATE pending_op
|
||||
SET lid = (SELECT lid FROM nodes WHERE nodes.uid = pending_op.uid),
|
||||
parent_lid = (SELECT lid FROM nodes WHERE nodes.uid = pending_op.parent_uid);
|
||||
CREATE INDEX pending_op_lid ON pending_op(lid);
|
||||
CREATE INDEX pending_op_parent_lid ON pending_op(parent_lid);
|
||||
";
|
||||
|
||||
/// Schema v40: a node row names its parent by local id too.
|
||||
///
|
||||
/// The local id stays when a folder made here lands and its row takes the uid
|
||||
/// Drive gave it, so a child linked by it needs no rewrite. `NULL` while the
|
||||
/// parent has no row, as `parent_uid` then names nothing either.
|
||||
const MIGRATION_V40: &str = "
|
||||
ALTER TABLE nodes ADD COLUMN parent_lid INTEGER;
|
||||
UPDATE nodes SET parent_lid = (SELECT p.lid FROM nodes p WHERE p.uid = nodes.parent_uid);
|
||||
CREATE INDEX idx_nodes_parent_lid ON nodes(parent_lid);
|
||||
";
|
||||
|
||||
/// Schema v41: a node made on this machine goes by `local~<lid>` until it
|
||||
/// lands, a function of its row, where it used to get a `local~<ms>-<n>`
|
||||
/// placeholder. Every placeholder still stored is rewritten to its row's
|
||||
/// stand-in: the row itself and its `node_json`, its children, the ops on it
|
||||
/// or into it, and its pins. A placeholder op with no row keeps its uid and
|
||||
/// drains as before.
|
||||
const MIGRATION_V41: &str = "
|
||||
CREATE TEMP TABLE local_v41 AS
|
||||
SELECT uid AS old, 'local~' || lid AS new, lid FROM nodes
|
||||
WHERE uid LIKE 'local~%' AND uid <> 'local~' || lid;
|
||||
UPDATE pending_op SET uid = (SELECT new FROM local_v41 WHERE old = pending_op.uid)
|
||||
WHERE uid IN (SELECT old FROM local_v41);
|
||||
UPDATE pending_op SET parent_uid = (SELECT new FROM local_v41 WHERE old = pending_op.parent_uid)
|
||||
WHERE parent_uid IN (SELECT old FROM local_v41);
|
||||
UPDATE pending_op
|
||||
SET meta_json = json_set(meta_json, '$.uid',
|
||||
(SELECT new FROM local_v41 WHERE old = json_extract(pending_op.meta_json, '$.uid')))
|
||||
WHERE json_valid(meta_json)
|
||||
AND json_extract(meta_json, '$.uid') IN (SELECT old FROM local_v41);
|
||||
UPDATE pending_op
|
||||
SET meta_json = json_set(meta_json, '$.original_parent_uid',
|
||||
(SELECT new FROM local_v41
|
||||
WHERE old = json_extract(pending_op.meta_json, '$.original_parent_uid')))
|
||||
WHERE json_valid(meta_json)
|
||||
AND json_extract(meta_json, '$.original_parent_uid') IN (SELECT old FROM local_v41);
|
||||
UPDATE pins SET uid = (SELECT new FROM local_v41 WHERE old = pins.uid)
|
||||
WHERE uid IN (SELECT old FROM local_v41);
|
||||
UPDATE nodes
|
||||
SET node_json = json_set(node_json, '$.parent_uid.link_id',
|
||||
(SELECT CAST(lid AS TEXT) FROM local_v41 WHERE old = nodes.parent_uid))
|
||||
WHERE parent_uid IN (SELECT old FROM local_v41)
|
||||
AND json_valid(node_json)
|
||||
AND json_extract(node_json, '$.parent_uid.volume_id') = 'local';
|
||||
UPDATE nodes SET parent_uid = (SELECT new FROM local_v41 WHERE old = nodes.parent_uid)
|
||||
WHERE parent_uid IN (SELECT old FROM local_v41);
|
||||
UPDATE nodes
|
||||
SET node_json = json_set(node_json, '$.uid.link_id', CAST(lid AS TEXT))
|
||||
WHERE lid IN (SELECT lid FROM local_v41)
|
||||
AND json_valid(node_json)
|
||||
AND json_extract(node_json, '$.uid.volume_id') = 'local';
|
||||
UPDATE nodes SET uid = 'local~' || lid WHERE lid IN (SELECT lid FROM local_v41);
|
||||
DROP TABLE local_v41;
|
||||
";
|
||||
|
||||
/// Schema v42: the SHA-1 of each blob a queued create sent, space-separated.
|
||||
/// A create whose answer was lost may have made its file with any of them,
|
||||
/// and the op that finds that file again may run after a restart. `NULL` for
|
||||
/// an op that has sent nothing, which is every op migrated: what the daemon
|
||||
/// that wrote it sent is not known. Likewise the parent and name each attempt
|
||||
/// was sent under, as a JSON list of pairs: a rename since then moves the op
|
||||
/// but not the file Drive may have made.
|
||||
const MIGRATION_V42: &str = "
|
||||
ALTER TABLE pending_op ADD COLUMN sent_sha1 TEXT;
|
||||
ALTER TABLE pending_op ADD COLUMN sent_to TEXT;
|
||||
";
|
||||
@@ -56,8 +56,9 @@ pub use local::LocalFileHit;
|
||||
pub use maintenance::{DbStats, VacuumOutcome};
|
||||
pub use nodes::{PublishedSharedRoot, SearchHit, StoredNode};
|
||||
pub use ops::{
|
||||
AttachedBlob, FAILING_ATTEMPTS, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION,
|
||||
OP_TRASH, PARK_EXPIRY_MS, PARK_UNTIL, PendingCounts, PendingOp, RenameMeta, op_supersedes,
|
||||
AttachedBlob, CreateLanding, CreateRetired, FAILING_ATTEMPTS, LOCAL_VOLUME, OP_CREATE,
|
||||
OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_EXPIRY_MS, PARK_UNTIL, PendingCounts,
|
||||
PendingOp, RenameMeta, local_lid, local_uid, op_supersedes,
|
||||
};
|
||||
pub use photos::{StoredPhoto, StoredPlace, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN, TimelineRow};
|
||||
pub use pins::PinRow;
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use rusqlite::types::Value;
|
||||
use rusqlite::{OptionalExtension, Transaction, params};
|
||||
|
||||
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 proton_drive_rs::proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
||||
use proton_drive_rs::{Node, NodeKind};
|
||||
@@ -17,6 +19,8 @@ use super::utils::{
|
||||
pub struct StoredNode {
|
||||
pub node: Node,
|
||||
pub listed: bool,
|
||||
/// The row's local id ([`Db::lid_of`]).
|
||||
pub lid: i64,
|
||||
}
|
||||
|
||||
/// One full-text search match: the stored [`Node`] plus its mountpoint-relative
|
||||
@@ -62,12 +66,13 @@ impl Db {
|
||||
/// snapshot while offline, including after a live event expired its TTL.
|
||||
pub fn visible_children(&self, parent: &NodeUid) -> Result<Vec<Node>> {
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT node_json FROM nodes
|
||||
WHERE parent_uid = ?1 AND trashed = 0 AND node_json IS NOT NULL
|
||||
ORDER BY name, uid",
|
||||
)?;
|
||||
let rows = stmt.query_map([parent.to_string()], |row| row.get::<_, String>(0))?;
|
||||
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.trashed = 0 AND n.node_json IS NOT NULL
|
||||
ORDER BY n.name, n.uid"
|
||||
))?;
|
||||
let rows = stmt.query_map([parent], |row| row.get::<_, String>(0))?;
|
||||
let mut nodes = Vec::new();
|
||||
for row in rows {
|
||||
nodes.push(serde_json::from_str(&row?)?);
|
||||
@@ -264,10 +269,25 @@ impl Db {
|
||||
/// resolve its shared-tree access. Removing the op first could resurrect
|
||||
/// that row after a crash; removing the row first could strand a now
|
||||
/// 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 mut conn = self.conn.lock();
|
||||
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
|
||||
.query_row(
|
||||
"SELECT rowid FROM nodes WHERE uid = ?1",
|
||||
@@ -280,17 +300,23 @@ impl Db {
|
||||
tx.execute("DELETE FROM nodes_fts WHERE rowid = ?1", params![rowid])?;
|
||||
}
|
||||
tx.execute("DELETE FROM pending_op WHERE id = ?1", params![op_id])?;
|
||||
if let Some(name) = name {
|
||||
wake_ops_waiting_for_tx(&tx, &name)?;
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
Ok(blob)
|
||||
}
|
||||
|
||||
/// Check if a folder node has any non-trashed children in the database.
|
||||
pub fn has_children(&self, parent_uid: &NodeUid) -> Result<bool> {
|
||||
let uid_str = parent_uid.to_string();
|
||||
let conn = self.read();
|
||||
let (key, parent) = parent_key(&conn, &parent_uid.to_string())?;
|
||||
let count: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM nodes WHERE parent_uid = ?1 AND trashed = 0",
|
||||
params![uid_str],
|
||||
&format!(
|
||||
"SELECT COUNT(*) FROM nodes
|
||||
WHERE {key} = ?1 AND trashed = 0 AND node_json IS NOT NULL"
|
||||
),
|
||||
params![parent],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
Ok(count > 0)
|
||||
@@ -305,9 +331,11 @@ impl Db {
|
||||
pub fn node_path(&self, uid: &str) -> Result<Option<String>> {
|
||||
let conn = self.read();
|
||||
let exists: Option<i64> = conn
|
||||
.query_row("SELECT 1 FROM nodes WHERE uid = ?1", params![uid], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.query_row(
|
||||
"SELECT 1 FROM nodes WHERE uid = ?1 AND node_json IS NOT NULL",
|
||||
params![uid],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
if exists.is_none() {
|
||||
return Ok(None);
|
||||
@@ -325,11 +353,11 @@ impl Db {
|
||||
pub fn path_relative_to(&self, ancestor_uid: &str, uid: &str) -> Result<Option<String>> {
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"WITH RECURSIVE anc(uid, parent_uid, name, depth) AS (
|
||||
SELECT uid, parent_uid, name, 0 FROM nodes WHERE uid = ?1
|
||||
"WITH RECURSIVE anc(uid, parent_lid, name, depth) AS (
|
||||
SELECT uid, parent_lid, name, 0 FROM nodes WHERE uid = ?1
|
||||
UNION ALL
|
||||
SELECT n.uid, n.parent_uid, n.name, anc.depth + 1
|
||||
FROM nodes n JOIN anc ON n.uid = anc.parent_uid
|
||||
SELECT n.uid, n.parent_lid, n.name, anc.depth + 1
|
||||
FROM nodes n JOIN anc ON n.lid = anc.parent_lid
|
||||
WHERE anc.depth < 1024
|
||||
)
|
||||
SELECT uid, name FROM anc ORDER BY depth",
|
||||
@@ -378,12 +406,13 @@ impl Db {
|
||||
let conn = self.read();
|
||||
let rows: Vec<HitRow> = if query.chars().count() < TRIGRAM_MIN {
|
||||
let pat = format!("%{}%", like_escape(query));
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT node_json, uid, path FROM nodes
|
||||
WHERE name LIKE ?1 ESCAPE '\\' AND trashed = 0 AND node_json IS NOT NULL
|
||||
AND (?3 IS NULL OR path IS NULL OR path LIKE ?3 ESCAPE '\\')
|
||||
ORDER BY name LIMIT ?2",
|
||||
)?;
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {NODE_NOW}, n.uid, n.path
|
||||
FROM nodes n LEFT JOIN nodes p ON p.lid = n.parent_lid
|
||||
WHERE n.name LIKE ?1 ESCAPE '\\' AND n.trashed = 0 AND n.node_json IS NOT NULL
|
||||
AND (?3 IS NULL OR n.path IS NULL OR n.path LIKE ?3 ESCAPE '\\')
|
||||
ORDER BY n.name LIMIT ?2"
|
||||
))?;
|
||||
collect_hits(stmt.query_map(params![pat, limit as i64, scope_pat], hit_row)?)?
|
||||
} else {
|
||||
// Escape double quotes and quote each term, then combine with AND so
|
||||
@@ -393,13 +422,14 @@ impl Db {
|
||||
.map(|word| format!("\"{}\"", word.replace('"', "\"\"")))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" AND ");
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT n.node_json, n.uid, n.path
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {NODE_NOW}, n.uid, n.path
|
||||
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
|
||||
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)?)?
|
||||
};
|
||||
|
||||
@@ -436,20 +466,22 @@ impl Db {
|
||||
let lane_limit = limit.div_ceil(2);
|
||||
let mut rows: Vec<HitRow> = if terms.is_empty() {
|
||||
let pat = format!("%{}%", like_escape(query));
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT node_json, uid, path FROM nodes
|
||||
WHERE name LIKE ?1 ESCAPE '\\' AND trashed = 0 AND node_json IS NOT NULL
|
||||
ORDER BY name LIMIT ?2",
|
||||
)?;
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {NODE_NOW}, n.uid, n.path
|
||||
FROM nodes n LEFT JOIN nodes p ON p.lid = n.parent_lid
|
||||
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)?)?
|
||||
} else {
|
||||
let expression = terms.join(" OR ");
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT n.node_json, n.uid, n.path
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {NODE_NOW}, n.uid, n.path
|
||||
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
|
||||
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)?)?
|
||||
};
|
||||
// Trigram rank does not know that a name *starts* with the query, and a
|
||||
@@ -459,12 +491,13 @@ impl Db {
|
||||
if !terms.is_empty()
|
||||
&& let Some(first) = query.chars().next()
|
||||
{
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT node_json, uid, path FROM nodes
|
||||
WHERE name COLLATE NOCASE LIKE ?1 ESCAPE '\\'
|
||||
AND trashed = 0 AND node_json IS NOT NULL
|
||||
ORDER BY name COLLATE NOCASE LIMIT ?2",
|
||||
)?;
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {NODE_NOW}, n.uid, n.path
|
||||
FROM nodes n LEFT JOIN nodes p ON p.lid = n.parent_lid
|
||||
WHERE n.name COLLATE NOCASE LIKE ?1 ESCAPE '\\'
|
||||
AND n.trashed = 0 AND n.node_json IS NOT NULL
|
||||
ORDER BY n.name COLLATE NOCASE LIMIT ?2"
|
||||
))?;
|
||||
let mut extra = Vec::new();
|
||||
for pattern in [
|
||||
format!("{}%", like_escape(query)),
|
||||
@@ -500,40 +533,50 @@ impl Db {
|
||||
/// re-enumerates from the remote on next access.
|
||||
pub fn set_listed(&self, uid: &NodeUid, listed: bool) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
let (key, uid) = row_key(&uid.to_string());
|
||||
conn.execute(
|
||||
"UPDATE nodes SET listed = ?2 WHERE uid = ?1",
|
||||
params![uid.to_string(), listed as i64],
|
||||
&format!("UPDATE nodes SET listed = ?2 WHERE {key} = ?1"),
|
||||
params![uid, listed as i64],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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>> {
|
||||
let conn = self.read();
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT node_json, listed FROM nodes WHERE node_json IS NOT NULL")?;
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"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 json: String = row.get(0)?;
|
||||
let listed: i64 = row.get(1)?;
|
||||
Ok((json, listed != 0))
|
||||
Ok((json, listed != 0, row.get(2)?))
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
let (json, listed) = row?;
|
||||
let (json, listed, lid) = row?;
|
||||
let node: Node = serde_json::from_str(&json)?;
|
||||
out.push(StoredNode { node, listed });
|
||||
out.push(StoredNode { node, listed, lid });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// (offline.md Phase 1).
|
||||
/// when the API is unreachable, so the mount can still serve the cached tree.
|
||||
pub fn node_by_uid(&self, uid: &str) -> Result<Option<Node>> {
|
||||
let conn = self.read();
|
||||
let json: Option<String> = conn
|
||||
.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],
|
||||
|r| r.get(0),
|
||||
)
|
||||
@@ -544,31 +587,178 @@ impl Db {
|
||||
}
|
||||
}
|
||||
|
||||
/// The uid of the node with local id `lid`: `None` when it has no row, or
|
||||
/// is not on Drive yet.
|
||||
pub fn uid_of_lid(&self, lid: i64) -> Result<Option<String>> {
|
||||
let conn = self.read();
|
||||
Ok(conn
|
||||
.query_row("SELECT uid FROM nodes WHERE lid = ?1", params![lid], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.optional()?
|
||||
.flatten())
|
||||
}
|
||||
|
||||
/// The local id of the node stored under `uid`, or `None` when it has no
|
||||
/// row. It stays the same for as long as the row exists, including when a
|
||||
/// create lands and the row takes the uid Drive gave it: a node made on
|
||||
/// this machine is found by its stand-in ([`local_uid`]) before and after.
|
||||
pub fn lid_of(&self, uid: &str) -> Result<Option<i64>> {
|
||||
node_lid(&self.read(), uid)
|
||||
}
|
||||
|
||||
/// The local id of each node, in order, giving a row to each that has none.
|
||||
///
|
||||
/// A new row is only a stub: it has no `node_json`, so no listing or search
|
||||
/// returns it until the node is upserted, which the caller still owes. That
|
||||
/// keeps this cheap enough to call with the inode lock held, which is
|
||||
/// where an inode number is first needed.
|
||||
pub fn lids_for(&self, nodes: &[&Node]) -> Result<Vec<i64>> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
let mut lids = Vec::with_capacity(nodes.len());
|
||||
{
|
||||
let mut known = tx.prepare_cached("SELECT lid FROM nodes WHERE uid = ?1")?;
|
||||
let mut stub = tx.prepare_cached(
|
||||
"INSERT INTO nodes (uid, parent_uid, name, is_dir, mtime, trashed, parent_lid)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
)?;
|
||||
for node in nodes {
|
||||
let uid = node.uid.to_string();
|
||||
let lid = match known.query_row(params![uid], |r| r.get(0)).optional()? {
|
||||
Some(lid) => lid,
|
||||
None => {
|
||||
let parent = node.parent_uid.as_ref().map(ToString::to_string);
|
||||
let parent_lid = match &parent {
|
||||
Some(parent) => node_lid(&tx, parent)?,
|
||||
None => None,
|
||||
};
|
||||
stub.execute(params![
|
||||
uid,
|
||||
parent,
|
||||
node.name,
|
||||
node.is_folder() as i64,
|
||||
node.modification_time,
|
||||
node.trashed as i64,
|
||||
parent_lid,
|
||||
])?;
|
||||
let lid = tx.last_insert_rowid();
|
||||
adopt_children_tx(&tx, &uid, lid)?;
|
||||
lid
|
||||
}
|
||||
};
|
||||
lids.push(lid);
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(lids)
|
||||
}
|
||||
|
||||
/// Give a node being made on this machine its row under `parent_uid`, and
|
||||
/// return its local id. The row goes by [`local_uid`] of that id until
|
||||
/// the node lands.
|
||||
///
|
||||
/// Like [`Db::lids_for`], only a stub: the caller upserts the node.
|
||||
pub fn add_local_row(
|
||||
&self,
|
||||
parent_uid: &str,
|
||||
name: &str,
|
||||
is_dir: bool,
|
||||
mtime: i64,
|
||||
) -> Result<i64> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(
|
||||
"INSERT INTO nodes (uid, parent_uid, name, is_dir, mtime, trashed, parent_lid)
|
||||
VALUES (NULL, ?1, ?2, ?3, ?4, 0,
|
||||
COALESCE(?5, (SELECT lid FROM nodes WHERE uid = ?1)))",
|
||||
params![
|
||||
parent_uid,
|
||||
name,
|
||||
is_dir as i64,
|
||||
mtime,
|
||||
local_lid(parent_uid)
|
||||
],
|
||||
)?;
|
||||
let lid = tx.last_insert_rowid();
|
||||
tx.execute(
|
||||
"UPDATE nodes SET uid = ?2 WHERE lid = ?1",
|
||||
params![lid, local_uid(lid)],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(lid)
|
||||
}
|
||||
|
||||
/// Read the persisted incremental-sync cursor (a `DriveEventId`), if any.
|
||||
/// The daemon resumes from this on restart instead of reseeding to the
|
||||
/// server head, so changes made while unmounted are still applied (P2).
|
||||
pub fn children_if_listed(&self, parent: &NodeUid) -> Result<Option<Vec<Node>>> {
|
||||
let conn = self.read();
|
||||
let (key, folder) = row_key(&parent.to_string());
|
||||
let listed: Option<i64> = conn
|
||||
.query_row(
|
||||
"SELECT listed FROM nodes WHERE uid = ?1",
|
||||
params![parent.to_string()],
|
||||
&format!("SELECT listed FROM nodes WHERE {key} = ?1"),
|
||||
params![folder],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
if listed != Some(1) {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT node_json FROM nodes
|
||||
WHERE parent_uid = ?1 AND node_json IS NOT NULL AND trashed = 0",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![parent.to_string()], |r| r.get::<_, String>(0))?;
|
||||
drop(conn);
|
||||
self.known_children(parent).map(Some)
|
||||
}
|
||||
|
||||
/// The children the DB knows under `parent`, whether or not the folder's
|
||||
/// listing is still marked complete. Only for when nothing better can be
|
||||
/// had: a listing dropped as stale may miss or keep a child.
|
||||
pub fn known_children(&self, parent: &NodeUid) -> Result<Vec<Node>> {
|
||||
let conn = self.read();
|
||||
let (key, parent) = parent_key(&conn, &parent.to_string())?;
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {NODE_NOW} FROM nodes n LEFT JOIN nodes p ON p.lid = n.parent_lid
|
||||
WHERE n.{key} = ?1 AND n.node_json IS NOT NULL AND n.trashed = 0"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![parent], |r| r.get::<_, String>(0))?;
|
||||
let mut out = Vec::new();
|
||||
for json in rows {
|
||||
out.push(serde_json::from_str(&json?)?);
|
||||
}
|
||||
Ok(Some(out))
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// What a queued change does to `parent`'s listing, which Drive does not
|
||||
/// show until it lands: the children made or moved in by a queued create,
|
||||
/// mkdir or rename, as the DB holds them, and the nodes a queued rename
|
||||
/// moves elsewhere.
|
||||
///
|
||||
/// A listing read from Drive is laid over with these, or a folder listed
|
||||
/// while a create in it is queued, after a restart say, loses the file
|
||||
/// until the create lands (`docs/BUGS.md` B132).
|
||||
pub fn queued_children(&self, parent: &NodeUid) -> Result<(Vec<Node>, HashSet<String>)> {
|
||||
let conn = self.read();
|
||||
let (key, parent) = parent_key(&conn, &parent.to_string())?;
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {NODE_NOW} FROM nodes n LEFT JOIN nodes p ON p.lid = n.parent_lid
|
||||
WHERE n.{key} = ?1 AND n.node_json IS NOT NULL AND n.trashed = 0
|
||||
AND EXISTS (SELECT 1 FROM pending_op o
|
||||
WHERE (o.lid = n.lid OR o.uid = n.uid) AND o.kind IN (?2, ?3, ?4))"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![parent, OP_CREATE, OP_MKDIR, OP_RENAME], |r| {
|
||||
r.get::<_, String>(0)
|
||||
})?;
|
||||
let mut here = Vec::new();
|
||||
for json in rows {
|
||||
here.push(serde_json::from_str(&json?)?);
|
||||
}
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT DISTINCT n.uid FROM pending_op p JOIN nodes n ON n.lid = p.lid OR n.uid = p.uid
|
||||
WHERE p.kind = ?2 AND n.{key} IS NOT ?1"
|
||||
))?;
|
||||
let gone = stmt
|
||||
.query_map(params![parent, OP_RENAME], |r| r.get::<_, String>(0))?
|
||||
.collect::<std::result::Result<HashSet<_>, _>>()?;
|
||||
Ok((here, gone))
|
||||
}
|
||||
|
||||
// --- Content-cache LRU index (P4) -------------------------------------
|
||||
@@ -590,9 +780,157 @@ struct PriorRow {
|
||||
trashed: bool,
|
||||
}
|
||||
|
||||
/// [`Db::lid_of`] on `conn`, which may be a transaction.
|
||||
pub(super) fn node_lid(conn: &rusqlite::Connection, uid: &str) -> Result<Option<i64>> {
|
||||
let row = match local_lid(uid) {
|
||||
Some(lid) => conn.query_row("SELECT lid FROM nodes WHERE lid = ?1", params![lid], |r| {
|
||||
r.get(0)
|
||||
}),
|
||||
None => conn.query_row("SELECT lid FROM nodes WHERE uid = ?1", params![uid], |r| {
|
||||
r.get(0)
|
||||
}),
|
||||
};
|
||||
Ok(row.optional()?)
|
||||
}
|
||||
|
||||
/// The column and value that find `uid`'s own row. A stand-in is found by its
|
||||
/// local id: a create that lands gives the row the uid Drive made before the
|
||||
/// tree has it, and the tree asks by the stand-in until then (`docs/BUGS.md`
|
||||
/// B177).
|
||||
fn row_key(uid: &str) -> (&'static str, Value) {
|
||||
match local_lid(uid) {
|
||||
Some(lid) => ("lid", Value::Integer(lid)),
|
||||
None => ("uid", Value::Text(uid.to_owned())),
|
||||
}
|
||||
}
|
||||
|
||||
/// The column and value a row names `parent` by: its local id when it has a
|
||||
/// row, which the children keep when it lands, or its uid when it has none,
|
||||
/// as a device folder's root does.
|
||||
fn parent_key(conn: &rusqlite::Connection, parent: &str) -> Result<(&'static str, Value)> {
|
||||
Ok(match node_lid(conn, parent)? {
|
||||
Some(lid) => ("parent_lid", Value::Integer(lid)),
|
||||
None => ("parent_uid", Value::Text(parent.to_owned())),
|
||||
})
|
||||
}
|
||||
|
||||
/// `n.node_json` naming its parent as the parent's row `p`, joined by
|
||||
/// `n.parent_lid`, has it now. A folder that lands takes its real uid in its
|
||||
/// own row only, so a node stored below it still names the stand-in.
|
||||
const NODE_NOW: &str = "CASE WHEN p.uid IS NULL OR p.uid = n.parent_uid THEN n.node_json
|
||||
ELSE json_set(n.node_json, '$.parent_uid', json_object(
|
||||
'volume_id', substr(p.uid, 1, instr(p.uid, '~') - 1),
|
||||
'link_id', substr(p.uid, instr(p.uid, '~') + 1))) END";
|
||||
|
||||
/// Hand a drained placeholder's row to the real uid it landed as.
|
||||
///
|
||||
/// Nothing else is rewritten. The rows below it and the ops made inside it
|
||||
/// name it by its local id, which stays; what reads them for a uid takes the
|
||||
/// one the row has now ([`NODE_NOW`], `PARENT_NOW` in `ops.rs`).
|
||||
///
|
||||
/// Dropping the placeholder row and waiting for the server's copy of the real
|
||||
/// node left a window with no row for either uid. A queued child drained in
|
||||
/// that window found no authority for its parent, re-read it over the network
|
||||
/// and was deferred five seconds behind a folder that had just landed
|
||||
/// (`docs/BUGS.md` B120). The placeholder's own node, readdressed, stands in
|
||||
/// until the server's copy is upserted over it. A row the real uid already
|
||||
/// has is newer than the placeholder, so its node is kept, under the
|
||||
/// placeholder's `lid`: the node was known by that one first.
|
||||
pub(super) fn adopt_placeholder_row_tx(
|
||||
tx: &Transaction<'_>,
|
||||
local: &str,
|
||||
real: &str,
|
||||
) -> Result<()> {
|
||||
// A folder pinned before it landed stays pinned: the pin keyed by the
|
||||
// placeholder was dropped with it (`docs/BUGS.md` B148).
|
||||
tx.execute(
|
||||
"UPDATE OR IGNORE pins SET uid = ?2 WHERE uid = ?1",
|
||||
params![local, real],
|
||||
)?;
|
||||
tx.execute("DELETE FROM pins WHERE uid = ?1", params![local])?;
|
||||
let row: Option<(i64, Option<String>)> = tx
|
||||
.query_row(
|
||||
"SELECT lid, node_json FROM nodes WHERE uid = ?1",
|
||||
params![local],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.optional()?;
|
||||
// Ops queued before schema 39 have no `parent_lid`, nor do ops made
|
||||
// under a placeholder that never had a row.
|
||||
tx.execute(
|
||||
"UPDATE pending_op SET parent_uid = ?2 WHERE parent_lid IS NULL AND parent_uid = ?1",
|
||||
params![local, real],
|
||||
)?;
|
||||
let Some((lid, json)) = row else {
|
||||
return Ok(());
|
||||
};
|
||||
let known: Option<(i64, Option<String>)> = tx
|
||||
.query_row(
|
||||
"SELECT lid, node_json FROM nodes WHERE uid = ?1",
|
||||
params![real],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.optional()?;
|
||||
if let Some((known_lid, _)) = known {
|
||||
// Ops queued against the row Drive listed now name the landing one.
|
||||
tx.execute(
|
||||
"UPDATE pending_op SET lid = ?2 WHERE lid = ?1",
|
||||
params![known_lid, lid],
|
||||
)?;
|
||||
tx.execute(
|
||||
"UPDATE pending_op SET parent_lid = ?2 WHERE parent_lid = ?1",
|
||||
params![known_lid, lid],
|
||||
)?;
|
||||
tx.execute(
|
||||
"UPDATE nodes SET parent_lid = ?2 WHERE parent_lid = ?1",
|
||||
params![known_lid, lid],
|
||||
)?;
|
||||
tx.execute("DELETE FROM nodes WHERE lid = ?1", params![known_lid])?;
|
||||
tx.execute("DELETE FROM nodes_fts WHERE rowid = ?1", params![known_lid])?;
|
||||
}
|
||||
tx.execute(
|
||||
"UPDATE nodes SET uid = ?2 WHERE lid = ?1",
|
||||
params![lid, real],
|
||||
)?;
|
||||
let json = known.and_then(|(_, known_json)| known_json).or(json);
|
||||
let (Some(json), Some(real)) = (json, parse_node_uid(real)) else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut node: Node = serde_json::from_str(&json)?;
|
||||
node.uid = real;
|
||||
upsert_node_tx(tx, &node)
|
||||
}
|
||||
|
||||
/// Link the rows that name `uid` as their parent to its new row `lid`: they
|
||||
/// were stored before it, or under a row of the same uid since dropped.
|
||||
fn adopt_children_tx(tx: &Transaction<'_>, uid: &str, lid: i64) -> Result<()> {
|
||||
tx.execute(
|
||||
"UPDATE nodes SET parent_lid = ?2 WHERE parent_uid = ?1 AND parent_lid IS NOT ?2",
|
||||
params![uid, lid],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn upsert_node_tx(tx: &Transaction<'_>, node: &Node) -> Result<()> {
|
||||
let json = serde_json::to_string(node)?;
|
||||
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 prior: Option<PriorRow> = tx
|
||||
@@ -614,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
|
||||
// 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.
|
||||
let path = match &parent_uid {
|
||||
None => String::new(),
|
||||
let (parent_lid, path) = match &parent_uid {
|
||||
None => (None, String::new()),
|
||||
Some(parent) => {
|
||||
let parent_path: Option<Option<String>> = tx
|
||||
.query_row(
|
||||
"SELECT path FROM nodes WHERE uid = ?1",
|
||||
params![parent],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
join_path(parent_path.flatten().as_deref().unwrap_or(""), &node.name)
|
||||
let parent_row: Option<(i64, Option<String>)> = match node_lid(tx, parent)? {
|
||||
Some(lid) => tx
|
||||
.query_row(
|
||||
"SELECT lid, path FROM nodes WHERE lid = ?1",
|
||||
params![lid],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.optional()?,
|
||||
None => None,
|
||||
};
|
||||
let (lid, parent_path) = parent_row.unzip();
|
||||
(
|
||||
lid,
|
||||
join_path(parent_path.flatten().as_deref().unwrap_or(""), &node.name),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
tx.execute(
|
||||
"INSERT INTO nodes
|
||||
(uid, parent_uid, name, is_dir, size, mtime, trashed, node_json, path)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
||||
(uid, parent_uid, name, is_dir, size, mtime, trashed, node_json, path, parent_lid)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
|
||||
ON CONFLICT(uid) DO UPDATE SET
|
||||
parent_uid = excluded.parent_uid,
|
||||
parent_lid = excluded.parent_lid,
|
||||
name = excluded.name,
|
||||
is_dir = excluded.is_dir,
|
||||
size = excluded.size,
|
||||
@@ -651,6 +997,7 @@ fn upsert_node_tx(tx: &Transaction<'_>, node: &Node) -> Result<()> {
|
||||
node.trashed as i64,
|
||||
json,
|
||||
path,
|
||||
parent_lid,
|
||||
],
|
||||
)?;
|
||||
let rowid: i64 = tx.query_row(
|
||||
@@ -658,6 +1005,9 @@ fn upsert_node_tx(tx: &Transaction<'_>, node: &Node) -> Result<()> {
|
||||
params![uid],
|
||||
|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.
|
||||
let indexable = node_is_indexable_tx(tx, &uid)?;
|
||||
@@ -680,7 +1030,7 @@ fn upsert_node_tx(tx: &Transaction<'_>, node: &Node) -> Result<()> {
|
||||
|| prior.trashed != node.trashed
|
||||
});
|
||||
if node.is_folder() && subtree_moved {
|
||||
reindex_subtree_tx(tx, &uid, &path, indexable)?;
|
||||
reindex_subtree_tx(tx, rowid, &path, indexable)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -701,22 +1051,22 @@ fn upsert_node_tx(tx: &Transaction<'_>, node: &Node) -> Result<()> {
|
||||
/// this deep.
|
||||
fn reindex_subtree_tx(
|
||||
tx: &Transaction<'_>,
|
||||
folder: &str,
|
||||
folder: i64,
|
||||
folder_path: &str,
|
||||
folder_indexable: bool,
|
||||
) -> Result<()> {
|
||||
let descendants: Vec<(i64, String, bool)> = {
|
||||
let mut stmt = tx.prepare(
|
||||
"WITH RECURSIVE sub(rowid, uid, name, path, blocked, depth) AS (
|
||||
SELECT n.rowid, n.uid, n.name,
|
||||
"WITH RECURSIVE sub(rowid, name, path, blocked, depth) AS (
|
||||
SELECT n.rowid, n.name,
|
||||
CASE WHEN ?2 = '' THEN n.name ELSE ?2 || '/' || n.name END,
|
||||
n.trashed, 0
|
||||
FROM nodes n WHERE n.parent_uid = ?1
|
||||
FROM nodes n WHERE n.parent_lid = ?1
|
||||
UNION ALL
|
||||
SELECT n.rowid, n.uid, n.name,
|
||||
SELECT n.rowid, n.name,
|
||||
sub.path || '/' || n.name,
|
||||
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
|
||||
)
|
||||
SELECT rowid, path, blocked FROM sub",
|
||||
@@ -741,8 +1091,10 @@ fn reindex_subtree_tx(
|
||||
let mut set_path = tx.prepare("UPDATE nodes SET path = ?2 WHERE rowid = ?1")?;
|
||||
let mut unindex = tx.prepare("DELETE FROM nodes_fts WHERE rowid = ?1")?;
|
||||
let mut index = tx.prepare(
|
||||
// Not a stub row (`Db::lids_for`): it is indexed when its node is
|
||||
// written.
|
||||
"INSERT INTO nodes_fts (rowid, name, path)
|
||||
SELECT rowid, name, ?2 FROM nodes WHERE rowid = ?1",
|
||||
SELECT rowid, name, ?2 FROM nodes WHERE rowid = ?1 AND node_json IS NOT NULL",
|
||||
)?;
|
||||
for (rowid, path, blocked) in descendants {
|
||||
set_path.execute(params![rowid, path])?;
|
||||
@@ -755,7 +1107,8 @@ fn reindex_subtree_tx(
|
||||
}
|
||||
|
||||
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 mut uids = Vec::new();
|
||||
for row in rows {
|
||||
@@ -780,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.
|
||||
fn node_is_indexable_tx(tx: &Transaction<'_>, uid: &str) -> Result<bool> {
|
||||
let indexable: i64 = tx.query_row(
|
||||
"WITH RECURSIVE ancestors(uid, parent_uid, trashed, depth, path) AS (
|
||||
SELECT uid, parent_uid, trashed, 0, char(31) || uid || char(31)
|
||||
"WITH RECURSIVE ancestors(lid, parent_lid, trashed, depth, path) AS (
|
||||
SELECT lid, parent_lid, trashed, 0, ',' || lid || ','
|
||||
FROM nodes WHERE uid = ?1
|
||||
UNION ALL
|
||||
SELECT n.uid, n.parent_uid, n.trashed, a.depth + 1,
|
||||
a.path || n.uid || char(31)
|
||||
FROM ancestors a JOIN nodes n ON n.uid = a.parent_uid
|
||||
WHERE instr(a.path, char(31) || n.uid || char(31)) = 0
|
||||
SELECT n.lid, n.parent_lid, n.trashed, a.depth + 1,
|
||||
a.path || n.lid || ','
|
||||
FROM ancestors a JOIN nodes n ON n.lid = a.parent_lid
|
||||
WHERE instr(a.path, ',' || n.lid || ',') = 0
|
||||
)
|
||||
SELECT CASE
|
||||
WHEN COUNT(*) = 0 THEN 0
|
||||
@@ -795,8 +1148,7 @@ fn node_is_indexable_tx(tx: &Transaction<'_>, uid: &str) -> Result<bool> {
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM ancestors a
|
||||
WHERE a.depth = (SELECT MAX(depth) FROM ancestors)
|
||||
AND a.parent_uid IS NOT NULL
|
||||
AND EXISTS (SELECT 1 FROM nodes p WHERE p.uid = a.parent_uid)
|
||||
AND EXISTS (SELECT 1 FROM nodes p WHERE p.lid = a.parent_lid)
|
||||
) THEN 0
|
||||
ELSE 1
|
||||
END
|
||||
@@ -814,7 +1166,7 @@ fn tombstone_subtree_tx(tx: &Transaction<'_>, root_uid: &str) -> Result<()> {
|
||||
SELECT rowid, uid, node_json FROM nodes WHERE uid = ?1
|
||||
UNION
|
||||
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",
|
||||
)?;
|
||||
|
||||
+794
-102
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
|
||||
/// is pinned recursively. The ancestor check walks `parent_uid` to the root
|
||||
/// is pinned recursively. The ancestor check walks `parent_lid` to the root
|
||||
/// via a CTE; a direct pin is honoured even when the node has no `nodes` row
|
||||
/// yet (e.g. a CLI that never hydrates the node cache).
|
||||
///
|
||||
/// The walk is depth-capped like [`super::utils::path_of`]: a `parent_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
|
||||
/// terminates — while holding the daemon's only SQLite connection, on a path
|
||||
/// that runs per cached read.
|
||||
@@ -85,11 +85,11 @@ impl Db {
|
||||
let anc: Option<i64> = conn
|
||||
.query_row(
|
||||
&format!(
|
||||
"WITH RECURSIVE anc(uid, parent_uid, depth) AS (
|
||||
SELECT uid, parent_uid, 0 FROM nodes WHERE uid = ?1
|
||||
"WITH RECURSIVE anc(uid, parent_lid, depth) AS (
|
||||
SELECT uid, parent_lid, 0 FROM nodes WHERE uid = ?1
|
||||
UNION ALL
|
||||
SELECT n.uid, n.parent_uid, anc.depth + 1
|
||||
FROM nodes n JOIN anc ON n.uid = anc.parent_uid
|
||||
SELECT n.uid, n.parent_lid, anc.depth + 1
|
||||
FROM nodes n JOIN anc ON n.lid = anc.parent_lid
|
||||
WHERE anc.depth < {MAX_PATH_DEPTH}
|
||||
)
|
||||
SELECT 1 FROM anc a JOIN pins p ON p.uid = a.uid
|
||||
@@ -109,10 +109,11 @@ impl Db {
|
||||
pub fn pinned_uids(&self) -> Result<Vec<String>> {
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"WITH RECURSIVE sub(uid) AS (
|
||||
SELECT uid FROM pins WHERE recursive = 1
|
||||
"WITH RECURSIVE sub(uid, lid) AS (
|
||||
SELECT p.uid, n.lid FROM pins p LEFT JOIN nodes n ON n.uid = p.uid
|
||||
WHERE p.recursive = 1
|
||||
UNION
|
||||
SELECT n.uid FROM nodes n JOIN sub ON n.parent_uid = sub.uid
|
||||
SELECT n.uid, n.lid FROM nodes n JOIN sub ON n.parent_lid = sub.lid
|
||||
)
|
||||
SELECT uid FROM sub
|
||||
UNION
|
||||
@@ -126,15 +127,16 @@ impl Db {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Every descendant uid of `folder` (all depths), via a `parent_uid` CTE.
|
||||
/// Every descendant uid of `folder` (all depths), via a `parent_lid` CTE.
|
||||
/// Used to evict a recursively-pinned subtree's cached blobs on unpin.
|
||||
pub fn descendants(&self, folder: &str) -> Result<Vec<String>> {
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"WITH RECURSIVE sub(uid) AS (
|
||||
SELECT uid FROM nodes WHERE parent_uid = ?1
|
||||
"WITH RECURSIVE sub(uid, lid) AS (
|
||||
SELECT c.uid, c.lid FROM nodes c JOIN nodes f ON c.parent_lid = f.lid
|
||||
WHERE f.uid = ?1
|
||||
UNION
|
||||
SELECT n.uid FROM nodes n JOIN sub ON n.parent_uid = sub.uid
|
||||
SELECT n.uid, n.lid FROM nodes n JOIN sub ON n.parent_lid = sub.lid
|
||||
)
|
||||
SELECT uid FROM sub",
|
||||
)?;
|
||||
|
||||
@@ -95,11 +95,27 @@ impl Db {
|
||||
/// Resolve a persisted node's effective access through its nearest recorded
|
||||
/// share root. `None` means the node itself is not persisted, which callers
|
||||
/// must not treat as owned: stale handles need to fail closed.
|
||||
///
|
||||
/// A stand-in ([`local_uid`](super::local_uid)) finds its row by local id,
|
||||
/// so it still does once its create has landed and the row has the uid
|
||||
/// Drive gave it. The tree learns that uid a moment later, and a rename in
|
||||
/// between was refused (`docs/BUGS.md` B174).
|
||||
pub fn effective_node_access(&self, uid: &NodeUid) -> Result<Option<Access>> {
|
||||
let conn = self.read();
|
||||
let key = uid.to_string();
|
||||
let key = match super::local_lid(&key) {
|
||||
Some(lid) => conn
|
||||
.query_row("SELECT uid FROM nodes WHERE lid = ?1", [lid], |row| {
|
||||
row.get::<_, Option<String>>(0)
|
||||
})
|
||||
.optional()?
|
||||
.flatten()
|
||||
.unwrap_or(key),
|
||||
None => key,
|
||||
};
|
||||
let exists: bool = conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM nodes WHERE uid = ?1)",
|
||||
[uid.to_string()],
|
||||
"SELECT EXISTS(SELECT 1 FROM nodes WHERE uid = ?1 AND node_json IS NOT NULL)",
|
||||
[&key],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if !exists {
|
||||
@@ -107,25 +123,26 @@ impl Db {
|
||||
}
|
||||
let value = conn
|
||||
.query_row(
|
||||
"WITH RECURSIVE ancestors(uid, parent_uid, depth, path) AS (
|
||||
SELECT uid, parent_uid, 0, char(31) || uid || char(31)
|
||||
"WITH RECURSIVE ancestors(uid, parent_uid, parent_lid, depth, path) AS (
|
||||
SELECT uid, parent_uid, parent_lid, 0, char(31) || uid || char(31)
|
||||
FROM nodes WHERE uid = ?1
|
||||
UNION ALL
|
||||
SELECT a.parent_uid, n.parent_uid, a.depth + 1,
|
||||
a.path || a.parent_uid || char(31)
|
||||
SELECT COALESCE(n.uid, a.parent_uid), n.parent_uid, n.parent_lid,
|
||||
a.depth + 1,
|
||||
a.path || COALESCE(n.uid, a.parent_uid) || char(31)
|
||||
FROM ancestors a
|
||||
LEFT JOIN nodes n ON n.uid = a.parent_uid
|
||||
LEFT JOIN nodes n ON n.lid = a.parent_lid
|
||||
WHERE a.parent_uid IS NOT NULL
|
||||
AND instr(
|
||||
a.path,
|
||||
char(31) || a.parent_uid || char(31)
|
||||
char(31) || COALESCE(n.uid, a.parent_uid) || char(31)
|
||||
) = 0
|
||||
)
|
||||
SELECT sa.access
|
||||
FROM ancestors a JOIN share_access sa ON sa.root_uid = a.uid
|
||||
ORDER BY a.depth ASC
|
||||
LIMIT 1",
|
||||
[uid.to_string()],
|
||||
[&key],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.optional()?;
|
||||
|
||||
@@ -25,8 +25,8 @@ pub struct StoredSyncFolder {
|
||||
}
|
||||
|
||||
/// One per-file sync baseline row: what a path looked like on both sides at the
|
||||
/// last successful sync, so the next reconcile can tell which side changed
|
||||
/// (devices.md Phase 2). `remote_rev`/`remote_hash` hold the remote signature —
|
||||
/// last successful sync, so the next reconcile can tell which side changed.
|
||||
/// `remote_rev`/`remote_hash` hold the remote signature —
|
||||
/// its modification time and size as strings — since no cheap content hash is
|
||||
/// exposed; change detection is `(mtime, size)` on each side.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -39,7 +39,7 @@ pub struct StoredSyncEntry {
|
||||
///
|
||||
/// `local_mtime` alone cannot tell an edit that landed inside the same
|
||||
/// second as the last sync — and left the file the same length — from no
|
||||
/// edit at all, so those never uploaded (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
|
||||
/// seconds", so upgrading does not make an entire mirror look changed.
|
||||
pub local_mtime_ns: Option<i64>,
|
||||
@@ -260,10 +260,92 @@ impl Db {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop the baseline rows of `rel_path` and everything under it.
|
||||
///
|
||||
/// Used when a subtree leaves a mirror folder through a move on Drive: its
|
||||
/// local copy is about to be removed, and rows left behind would read as
|
||||
/// "deleted locally" and trash the moved nodes at their new place.
|
||||
pub fn sync_entries_remove_subtree(&self, folder_id: i64, rel_path: &str) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"DELETE FROM sync_entry
|
||||
WHERE folder_id = ?1
|
||||
AND (rel_path = ?2 OR substr(rel_path, 1, length(?2) + 1) = ?2 || '/')",
|
||||
params![folder_id, rel_path],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Move the baseline rows of `from_rel` and everything under it to `to_rel`
|
||||
/// in folder `to_folder`, in one transaction.
|
||||
///
|
||||
/// Used when a move on Drive took a mirror subtree along with its local
|
||||
/// copy: the rows still describe both sides correctly, only under a new
|
||||
/// path, so the next pass on either folder has nothing to do. The two
|
||||
/// folders may be the same one.
|
||||
pub fn sync_entries_move(
|
||||
&self,
|
||||
from_folder: i64,
|
||||
from_rel: &str,
|
||||
to_folder: i64,
|
||||
to_rel: &str,
|
||||
) -> Result<()> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
{
|
||||
let mut select = tx.prepare(
|
||||
"SELECT rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash,
|
||||
local_mtime_ns
|
||||
FROM sync_entry
|
||||
WHERE folder_id = ?1
|
||||
AND (rel_path = ?2 OR substr(rel_path, 1, length(?2) + 1) = ?2 || '/')",
|
||||
)?;
|
||||
let rows = select
|
||||
.query_map(params![from_folder, from_rel], |r| {
|
||||
Ok(StoredSyncEntry {
|
||||
rel_path: r.get(0)?,
|
||||
remote_uid: r.get(1)?,
|
||||
local_mtime: r.get(2)?,
|
||||
local_size: r.get(3)?,
|
||||
remote_rev: r.get(4)?,
|
||||
remote_hash: r.get(5)?,
|
||||
local_mtime_ns: r.get(6)?,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
let mut delete =
|
||||
tx.prepare("DELETE FROM sync_entry WHERE folder_id = ?1 AND rel_path = ?2")?;
|
||||
for row in &rows {
|
||||
delete.execute(params![from_folder, row.rel_path])?;
|
||||
}
|
||||
let mut insert = tx.prepare(
|
||||
"INSERT OR REPLACE INTO sync_entry
|
||||
(folder_id, rel_path, remote_uid, local_mtime, local_size, remote_rev,
|
||||
remote_hash, local_mtime_ns)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
)?;
|
||||
for row in rows {
|
||||
let rel = format!("{to_rel}{}", &row.rel_path[from_rel.len()..]);
|
||||
insert.execute(params![
|
||||
to_folder,
|
||||
rel,
|
||||
row.remote_uid,
|
||||
row.local_mtime,
|
||||
row.local_size,
|
||||
row.remote_rev,
|
||||
row.remote_hash,
|
||||
row.local_mtime_ns,
|
||||
])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop the entire baseline for a folder. Used when flipping ondemand→mirror:
|
||||
/// the local tree was evicted, so the old baseline is stale and would make the
|
||||
/// next reconcile mistake "locally deleted" for "must re-download". Clearing it
|
||||
/// leaves an empty baseline + full remote = pure download (devices.md P3).
|
||||
/// leaves an empty baseline + full remote = pure download.
|
||||
pub fn sync_entries_clear(&self, folder_id: i64) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -102,5 +102,5 @@ impl Db {
|
||||
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
|
||||
/// and returns the truncated path rather than hanging the caller.
|
||||
pub(super) fn walk_path_of(conn: &Connection, uid: &str) -> Result<String> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"WITH RECURSIVE anc(uid, parent_uid, name, depth) AS (
|
||||
SELECT uid, parent_uid, name, 0 FROM nodes WHERE uid = ?1
|
||||
"WITH RECURSIVE anc(parent_lid, parent_uid, name, depth) AS (
|
||||
SELECT parent_lid, parent_uid, name, 0 FROM nodes WHERE uid = ?1
|
||||
UNION ALL
|
||||
SELECT n.uid, n.parent_uid, n.name, anc.depth + 1
|
||||
FROM nodes n JOIN anc ON n.uid = anc.parent_uid
|
||||
SELECT n.parent_lid, n.parent_uid, n.name, anc.depth + 1
|
||||
FROM nodes n JOIN anc ON n.lid = anc.parent_lid
|
||||
WHERE anc.depth < {MAX_PATH_DEPTH}
|
||||
)
|
||||
SELECT name, parent_uid FROM anc ORDER BY depth DESC"
|
||||
|
||||
@@ -32,6 +32,16 @@ pub enum Error {
|
||||
#[error("saved session can no longer unlock your keys — run `pdfs login` again")]
|
||||
ReloginRequired,
|
||||
|
||||
/// The account has a separate mailbox password that has not been entered
|
||||
/// since login, so its keys cannot be unlocked. The fix is
|
||||
/// [`auth::unlock`](crate::auth::unlock), not a new login.
|
||||
#[error("account is locked — enter the mailbox password with `pdfs unlock`")]
|
||||
Locked,
|
||||
|
||||
/// The mailbox password was checked against the account and is wrong.
|
||||
#[error("that mailbox password does not unlock your account")]
|
||||
WrongMailboxPassword,
|
||||
|
||||
/// The login was gated behind human verification. Not a failure the user can
|
||||
/// fix by retrying: the challenge has to be solved and the login restarted
|
||||
/// with the resulting token (see [`auth::login_verified`](crate::auth::login_verified)).
|
||||
@@ -199,9 +209,11 @@ impl From<serde_json::Error> for CoreError {
|
||||
impl From<Error> for CoreError {
|
||||
fn from(e: Error) -> Self {
|
||||
let kind = match &e {
|
||||
Error::NotLoggedIn | Error::ReloginRequired | Error::HumanVerificationRequired(_) => {
|
||||
ErrorKind::Denied
|
||||
}
|
||||
Error::NotLoggedIn
|
||||
| Error::ReloginRequired
|
||||
| Error::Locked
|
||||
| Error::WrongMailboxPassword
|
||||
| Error::HumanVerificationRequired(_) => ErrorKind::Denied,
|
||||
Error::Proton(proton_sdk::ProtonError::Api(api)) => api_kind(api.code),
|
||||
// A `ProtonError` that never reached the API is a transport failure.
|
||||
Error::Proton(_) => ErrorKind::Offline,
|
||||
|
||||
@@ -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 db;
|
||||
pub mod error;
|
||||
pub mod kernelkey;
|
||||
pub mod localindex;
|
||||
pub mod menu;
|
||||
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
|
||||
//! `cache.db` and `config.json` — both on the local disk. When that disk dies,
|
||||
|
||||
@@ -55,9 +55,10 @@ fn systemctl_query(args: &[&str]) -> bool {
|
||||
/// keyring checks). `--now` is a no-op on an active unit, so the daemon would
|
||||
/// keep sleeping and the mount would not appear until the next poll fired.
|
||||
/// Restarting makes the new session take effect immediately, and starts the
|
||||
/// unit when it is not running at all.
|
||||
/// unit when it is not running at all. `reenable` for the reason given on
|
||||
/// [`enable`].
|
||||
pub fn enable_start() -> bool {
|
||||
let enabled = systemctl(&["enable"]);
|
||||
let enabled = systemctl(&["reenable"]);
|
||||
systemctl(&["restart"]) && enabled
|
||||
}
|
||||
|
||||
@@ -97,8 +98,14 @@ pub fn is_enabled() -> bool {
|
||||
/// Enable the service so it auto-starts on future logins, without necessarily
|
||||
/// starting it now beyond what `enable` does. Used by the Settings "Start on
|
||||
/// login" switch; pairs with [`disable`](disable).
|
||||
///
|
||||
/// `reenable` rather than `enable`: older installs were enabled
|
||||
/// into `default.target.wants`, and a plain `enable` would add the new
|
||||
/// `graphical-session.target.wants` link while leaving the old one to start the
|
||||
/// unit too early. `reenable` drops every existing link and recreates them from
|
||||
/// the current `[Install]` section.
|
||||
pub fn enable() -> bool {
|
||||
systemctl(&["enable"])
|
||||
systemctl(&["reenable"])
|
||||
}
|
||||
|
||||
/// Disable auto-start without stopping the running mount. Used by the Settings
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Selective-sync ignore rules for mirror folders (features.md 1.1).
|
||||
//! Selective-sync ignore rules for mirror folders.
|
||||
//!
|
||||
//! A mirror folder otherwise uploads every path beneath its root, which for a
|
||||
//! project directory means `node_modules/`, `.git/`, and `target/` — quota
|
||||
|
||||
@@ -15,6 +15,7 @@ zip.workspace = true
|
||||
sha1.workspace = true
|
||||
proton-drive-rs.workspace = true
|
||||
futures.workspace = true
|
||||
async-trait.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
fuser.workspace = true
|
||||
@@ -24,3 +25,6 @@ base64.workspace = true
|
||||
image.workspace = true
|
||||
notify = "8"
|
||||
parking_lot.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tracing-subscriber.workspace = true
|
||||
@@ -9,7 +9,7 @@ use crate::shutdown::Shutdown;
|
||||
///
|
||||
/// Returns whether this mount actually held the node, so the caller can evict
|
||||
/// its content blob once if *any* mount did.
|
||||
fn forget_and_notify(st: &mut State, notify: &mut NotifyBatch, uid: &NodeUid) -> bool {
|
||||
pub(crate) fn forget_and_notify(st: &mut State, notify: &mut NotifyBatch, uid: &NodeUid) -> bool {
|
||||
// Capture the inode before `forget` clears the uid mapping.
|
||||
let child = st.by_uid.get(uid).copied();
|
||||
let Some((parent, name)) = st.forget(uid) else {
|
||||
@@ -97,15 +97,13 @@ fn event_serializes_shared_publication(event: &DriveEvent, own_volume: &VolumeId
|
||||
/// entry forces the next `lookup`/`readdir` to re-enumerate from the remote, so
|
||||
/// most events only need to invalidate listings rather than re-fetch eagerly.
|
||||
///
|
||||
/// Applied to **every** mounted inode space, not just the primary one. This task
|
||||
/// is per-daemon while node state is per-mount, and a `DriveEvent` names a uid,
|
||||
/// not a mount — so a file trashed from another device has to be withdrawn from
|
||||
/// whichever of our sessions is showing it, which for anything under a sync
|
||||
/// folder is a [`Core::fork_state`] fork rather than `core.state`. Reaching for
|
||||
/// the primary state alone left forks serving a deleted file indefinitely, the
|
||||
/// read-side twin of `docs/BUGS.md` B74. Inode numbers are per-mount, so each
|
||||
/// mount must be notified through its **own** channel; that pairing is what
|
||||
/// [`Core::for_each_mount`] exists to preserve.
|
||||
/// Applied to the tree every mount shares, and told to **every** mounted
|
||||
/// session. A `DriveEvent` names a uid, not a mount, so a file trashed from
|
||||
/// another device has to be withdrawn from whichever of our sessions is showing
|
||||
/// it — which for anything under a sync folder is a [`Core::fork_state`] fork.
|
||||
/// Telling the primary session alone left forks serving a deleted file, the
|
||||
/// read-side twin of `docs/BUGS.md` B74. [`Core::for_each_mount`] sends what
|
||||
/// the kernel needs to hear to every session.
|
||||
fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdfs_core::Result<()> {
|
||||
let foreign_delete = is_foreign_node_delete(event, &core.primary_root_uid.volume_id);
|
||||
let serializes_shared_publication =
|
||||
@@ -145,7 +143,7 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdf
|
||||
// create, and re-fetching would replace the size and mtime of the
|
||||
// write we just accepted with the stale revision's — making a file
|
||||
// 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:
|
||||
// the drain brought the tree level with the revision it sealed, so
|
||||
@@ -233,6 +231,48 @@ fn acknowledge_applied_event(
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// How many applied node-update ids [`RecentEvents`] remembers. Far more than a
|
||||
/// replay can reach back: one comes from a batch whose last event was not its
|
||||
/// newest, so it repeats at most what that batch held.
|
||||
const RECENT_EVENTS: usize = 4096;
|
||||
|
||||
/// The node updates applied lately, by event id, so one that comes round again
|
||||
/// is skipped rather than applied twice.
|
||||
///
|
||||
/// The feed can deliver the same event twice in a batch, and a batch whose
|
||||
/// events come out of order leaves the cursor short of its newest event, so the
|
||||
/// next poll replays what lay past it. An update applied twice is not harmless:
|
||||
/// the first copy of our own change claims the echo [`Core::note_self_change`]
|
||||
/// recorded, and the second, unclaimed, reads as someone else's edit and evicts
|
||||
/// the bytes a partial write is about to gap-fill from (docs/BUGS.md B123).
|
||||
///
|
||||
/// Only node updates: a delete is idempotent, and a continuity loss names the
|
||||
/// cursor it lost, which is an id already applied.
|
||||
#[derive(Default)]
|
||||
struct RecentEvents {
|
||||
ids: HashSet<DriveEventId>,
|
||||
order: VecDeque<DriveEventId>,
|
||||
}
|
||||
|
||||
impl RecentEvents {
|
||||
/// Record `event` as applied, reporting whether it was already.
|
||||
fn repeat(&mut self, event: &DriveEvent) -> bool {
|
||||
let DriveEvent::NodeUpdated { id, .. } = event else {
|
||||
return false;
|
||||
};
|
||||
if !self.ids.insert(id.clone()) {
|
||||
return true;
|
||||
}
|
||||
self.order.push_back(id.clone());
|
||||
if self.order.len() > RECENT_EVENTS
|
||||
&& let Some(old) = self.order.pop_front()
|
||||
{
|
||||
self.ids.remove(&old);
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// The folders one batch of events changed, collected so the listings are
|
||||
/// dropped once at the end of the batch rather than once per event.
|
||||
///
|
||||
@@ -320,10 +360,10 @@ fn flush_dirty_parents(core: &Core, dirty: &DirtyParents) {
|
||||
/// only on fatal error.
|
||||
///
|
||||
/// Takes the whole `Core` rather than the primary mount's pieces: an event has
|
||||
/// to be applied to every mounted inode space, and the registry that enumerates
|
||||
/// them lives on the `Core` (see [`apply_event`]).
|
||||
/// to reach every mounted session, and the registry that enumerates them lives
|
||||
/// on the `Core` (see [`apply_event`]).
|
||||
pub(super) async fn run_event_sync(
|
||||
client: ProtonDriveClient,
|
||||
client: Arc<dyn DriveApi>,
|
||||
scope: DriveEventScopeId,
|
||||
core: Core,
|
||||
) {
|
||||
@@ -335,7 +375,7 @@ pub(super) async fn run_event_sync(
|
||||
// server head; persist it so the next restart resumes instead of
|
||||
// reseeding (which would skip everything that changed offline).
|
||||
// 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.
|
||||
Ok(None) => {
|
||||
// Nothing says what changed before this cursor, so a persisted SDK
|
||||
@@ -377,10 +417,12 @@ pub(super) async fn run_event_sync(
|
||||
};
|
||||
info!(?cursor, "event sync started");
|
||||
|
||||
let mut recent = RecentEvents::default();
|
||||
loop {
|
||||
tokio::time::sleep(POLL_INTERVAL).await;
|
||||
let events = match client.enumerate_events(&scope, cursor.as_ref()).await {
|
||||
core.next_poll(POLL_INTERVAL).await;
|
||||
let events = match link::bounded(client.enumerate_events(&scope, cursor.as_ref())).await {
|
||||
Ok(events) => events,
|
||||
Err(e) if core.lost_link(&e, "poll events") => continue,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "event poll failed; retrying after interval");
|
||||
continue;
|
||||
@@ -396,6 +438,11 @@ pub(super) async fn run_event_sync(
|
||||
// did apply.
|
||||
let mut dirty = DirtyParents::default();
|
||||
for event in &events {
|
||||
if recent.repeat(event) {
|
||||
debug!(id = %event.id().as_str(), "skipping an event already applied");
|
||||
cursor = Some(event.id().clone());
|
||||
continue;
|
||||
}
|
||||
// Converge the SDK's own caches (folder keys, entity cache) on the
|
||||
// server before applying the event to our tree. Without this, a node
|
||||
// re-keyed/moved by another client keeps a stale key in the SDK for
|
||||
@@ -471,9 +518,10 @@ pub(super) async fn run_photos_event_sync(core: Core) {
|
||||
info!(?cursor, "photo event sync started");
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(POLL_INTERVAL).await;
|
||||
let events = match photos.enumerate_events(&scope, cursor.as_ref()).await {
|
||||
core.next_poll(POLL_INTERVAL).await;
|
||||
let events = match link::bounded(photos.enumerate_events(&scope, cursor.as_ref())).await {
|
||||
Ok(events) => events,
|
||||
Err(error) if core.lost_link(&error, "poll photo events") => continue,
|
||||
Err(error) => {
|
||||
warn!(%error, "photo event poll failed; retrying after interval");
|
||||
continue;
|
||||
@@ -612,7 +660,7 @@ pub(super) fn run_local_index(
|
||||
scan_local_once(&db, &indexing, &transfers, &mountpoint);
|
||||
}
|
||||
// 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) {
|
||||
return;
|
||||
}
|
||||
@@ -715,6 +763,43 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_node_update_seen_again_is_skipped_but_other_events_are_not() {
|
||||
let update = |id: &str| DriveEvent::NodeUpdated {
|
||||
id: DriveEventId::from(id),
|
||||
node_uid: uid("file"),
|
||||
parent_node_uid: None,
|
||||
is_trashed: false,
|
||||
is_shared: false,
|
||||
};
|
||||
let mut recent = RecentEvents::default();
|
||||
assert!(!recent.repeat(&update("e1")));
|
||||
assert!(!recent.repeat(&update("e2")));
|
||||
assert!(recent.repeat(&update("e1")), "a duplicate or replay");
|
||||
let lost = DriveEvent::ContinuityLost {
|
||||
id: DriveEventId::from("e2"),
|
||||
};
|
||||
assert!(!recent.repeat(&lost), "names the cursor, which was applied");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_events_forget_the_oldest_past_their_bound() {
|
||||
let update = |n: usize| DriveEvent::NodeUpdated {
|
||||
id: DriveEventId::from(format!("e{n}")),
|
||||
node_uid: uid("file"),
|
||||
parent_node_uid: None,
|
||||
is_trashed: false,
|
||||
is_shared: false,
|
||||
};
|
||||
let mut recent = RecentEvents::default();
|
||||
for n in 0..=RECENT_EVENTS {
|
||||
assert!(!recent.repeat(&update(n)));
|
||||
}
|
||||
assert_eq!(recent.ids.len(), RECENT_EVENTS);
|
||||
assert!(!recent.repeat(&update(0)), "the oldest was forgotten");
|
||||
assert!(recent.repeat(&update(RECENT_EVENTS)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreign_delete_invalidates_an_earlier_refresh_generation() {
|
||||
let own_volume = VolumeId::from("own");
|
||||
|
||||
+103
-29
@@ -163,8 +163,8 @@ fn read_request_line(reader: &mut impl BufRead) -> std::io::Result<Option<String
|
||||
/// Turn a CLI-supplied path into the mount that owns it and the path relative
|
||||
/// to *that* mount's root.
|
||||
///
|
||||
/// [`rel_to_mount`] is the older, primary-only reading, and it is what every
|
||||
/// mutating request still uses. It rejects anything outside the primary
|
||||
/// [`rel_to_mount`] is the older, primary-only reading, and it is what most
|
||||
/// requests still use. It rejects anything outside the primary
|
||||
/// mountpoint, which leaves a secondary on-demand mount — a synced folder
|
||||
/// switched out of mirror mode — with no way to be named at all: `pdfs ls` and
|
||||
/// `pdfs refresh` both answered "is not under the mountpoint", so a folder
|
||||
@@ -185,11 +185,14 @@ fn route_to_mount(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Cor
|
||||
rel_to_mount(mountpoint, path).map(|rel| (core.clone(), rel))
|
||||
}
|
||||
|
||||
/// [`route_to_mount`] for a conflict copy. The listing names a copy inside a
|
||||
/// synced folder by its absolute local path (`docs/BUGS.md` B99), so the
|
||||
/// resolve has to run against the on-demand mount serving it. A mirror folder
|
||||
/// has no mount to run against: its copy is an ordinary local file.
|
||||
fn route_conflict(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Core, PathBuf)> {
|
||||
/// [`route_to_mount`] for a request that changes what it names: trash, rename,
|
||||
/// create a folder, resolve a conflict. Each has to run against the on-demand
|
||||
/// mount serving the path, and `pdfs rm ~/Downloads/x` answered "is not under
|
||||
/// the mountpoint" while `pdfs move` took the same path. The conflict listing
|
||||
/// names a copy in a synced folder by its absolute local path (`docs/BUGS.md`
|
||||
/// B99), so resolving one needs this too. A mirror folder has no mount to run
|
||||
/// against: a file there is an ordinary local file.
|
||||
fn route_change(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Core, PathBuf)> {
|
||||
let routed = route_to_mount(core, mountpoint, path).map_err(|e| {
|
||||
let p = Path::new(path);
|
||||
let mirrored = core
|
||||
@@ -200,7 +203,7 @@ fn route_conflict(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Cor
|
||||
.any(|folder| folder.mode == "mirror" && p.starts_with(&folder.local_path));
|
||||
if mirrored {
|
||||
CoreError::invalid(format!(
|
||||
"{path} is in a mirrored folder; rename or delete the local file there"
|
||||
"{path} is in a mirrored folder; change the local file there instead"
|
||||
))
|
||||
} else {
|
||||
e
|
||||
@@ -217,6 +220,27 @@ fn route_conflict(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Cor
|
||||
}
|
||||
}
|
||||
|
||||
/// [`route_change`] for a request that trashes or renames the node it names.
|
||||
/// A mount's root is My files or a whole synced folder. An on-demand folder's
|
||||
/// root has a parent on Drive, so nothing further down would stop `pdfs rm
|
||||
/// ~/Downloads` from trashing the folder out from under its own mount.
|
||||
fn route_node(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Core, PathBuf)> {
|
||||
let routed = route_change(core, mountpoint, path)?;
|
||||
if below_root(&routed.1) {
|
||||
Ok(routed)
|
||||
} else {
|
||||
Err(CoreError::invalid(format!(
|
||||
"{path} is the root of a synced location and cannot be changed"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a mount-relative path names something below the mount's root.
|
||||
fn below_root(rel: &Path) -> bool {
|
||||
rel.components()
|
||||
.any(|component| matches!(component, Component::Normal(_)))
|
||||
}
|
||||
|
||||
/// Turn a CLI-supplied path into a mountpoint-relative path. An absolute path
|
||||
/// must live under `mountpoint`; a relative path is taken as already relative to
|
||||
/// the mount root.
|
||||
@@ -291,6 +315,9 @@ impl Drop for RefreshGuard {
|
||||
pub(crate) fn status_response(core: &Core, username: &str, mountpoint: &Path) -> CtlResponse {
|
||||
let pins = core.cache.list_pins();
|
||||
let queued = core.db.pending_op_counts().unwrap_or_default();
|
||||
// Read after the queue: the drain counts an upload as landing before it
|
||||
// retires the op, so one of the two always has it (B119).
|
||||
let landing = core.landing_uploads.load(Ordering::SeqCst);
|
||||
let staging = core.cache.staging_usage();
|
||||
CtlResponse::Status {
|
||||
parked_uploads: queued.parked.max(0) as u64,
|
||||
@@ -307,7 +334,7 @@ pub(crate) fn status_response(core: &Core, username: &str, mountpoint: &Path) ->
|
||||
budget: core.cache.budget(),
|
||||
pins,
|
||||
online: core.online.load(Ordering::Relaxed),
|
||||
pending_uploads: queued.uploads.max(0) as u64,
|
||||
pending_uploads: queued.uploads.max(0) as u64 + landing,
|
||||
pending_changes: queued.changes.max(0) as u64,
|
||||
}
|
||||
}
|
||||
@@ -350,6 +377,7 @@ fn touched_topics(request: &CtlRequest) -> &'static [Topic] {
|
||||
| R::RestoreSyncFolders { .. }
|
||||
| R::RestoreDeviceFolders { .. } => &[Topic::Locations, Topic::Devices],
|
||||
R::RetryPendingOp { .. } => &[Topic::Queue],
|
||||
R::DiscardPendingOp { .. } => &[Topic::Queue, Topic::Files],
|
||||
R::ResolveConflict { .. } => &[Topic::Files, Topic::Trash, Topic::Conflicts],
|
||||
R::ShareNode { .. }
|
||||
| R::ShareNodeByUid { .. }
|
||||
@@ -959,8 +987,8 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::Rename { path, new_name }) => match rel_to_mount(mountpoint, &path) {
|
||||
Ok(rel) => match core.rename(&rel, &new_name) {
|
||||
Ok(CtlRequest::Rename { path, new_name }) => match route_node(core, mountpoint, &path) {
|
||||
Ok((core, rel)) => match core.rename(&rel, &new_name) {
|
||||
Ok(name) => {
|
||||
core.log_activity(ActivityKind::Rename, &name, format!("was {path}"), true);
|
||||
CtlResponse::Ok {
|
||||
@@ -974,6 +1002,24 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
// Two absolute paths may name two different locations, which only
|
||||
// `move_between` can resolve; a relative pair keeps the old meaning.
|
||||
Ok(CtlRequest::Move { path, new_parent })
|
||||
if Path::new(&path).is_absolute() && Path::new(&new_parent).is_absolute() =>
|
||||
{
|
||||
match core.move_between(Path::new(&path), Path::new(&new_parent)) {
|
||||
Ok(name) => {
|
||||
core.log_activity(ActivityKind::Move, &name, format!("to {new_parent}"), true);
|
||||
CtlResponse::Ok {
|
||||
message: format!("moved {name}"),
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
core.log_activity(ActivityKind::Move, &path, &e, false);
|
||||
CtlResponse::error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::Move { path, new_parent }) => {
|
||||
match (
|
||||
rel_to_mount(mountpoint, &path),
|
||||
@@ -999,8 +1045,8 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
(Err(e), _) | (_, Err(e)) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::Delete { path }) => match rel_to_mount(mountpoint, &path) {
|
||||
Ok(rel) => match core.delete(&rel) {
|
||||
Ok(CtlRequest::Delete { path }) => match route_node(core, mountpoint, &path) {
|
||||
Ok((core, rel)) => match core.delete(&rel) {
|
||||
Ok(name) => {
|
||||
core.log_activity(ActivityKind::Trash, &name, "", true);
|
||||
CtlResponse::Ok {
|
||||
@@ -1014,21 +1060,23 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::CreateFolder { parent, name }) => match rel_to_mount(mountpoint, &parent) {
|
||||
Ok(parent_rel) => match core.create_folder(&parent_rel, &name) {
|
||||
Ok(name) => {
|
||||
core.log_activity(ActivityKind::CreateFolder, &name, "", true);
|
||||
CtlResponse::Ok {
|
||||
message: format!("created folder {name}"),
|
||||
Ok(CtlRequest::CreateFolder { parent, name }) => {
|
||||
match route_change(core, mountpoint, &parent) {
|
||||
Ok((core, parent_rel)) => match core.create_folder(&parent_rel, &name) {
|
||||
Ok(name) => {
|
||||
core.log_activity(ActivityKind::CreateFolder, &name, "", true);
|
||||
CtlResponse::Ok {
|
||||
message: format!("created folder {name}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
core.log_activity(ActivityKind::CreateFolder, &name, &e, false);
|
||||
CtlResponse::error(e)
|
||||
}
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Err(e) => {
|
||||
core.log_activity(ActivityKind::CreateFolder, &name, &e, false);
|
||||
CtlResponse::error(e)
|
||||
}
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::UploadPaths { parent, sources }) => {
|
||||
match rel_to_mount(mountpoint, &parent) {
|
||||
// Ack immediately and upload on a background thread: a directory tree
|
||||
@@ -1345,7 +1393,7 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::ResolveConflict { path, keep }) => {
|
||||
match route_conflict(core, mountpoint, &path) {
|
||||
match route_change(core, mountpoint, &path) {
|
||||
Ok((core, rel)) => match core.resolve_conflict(&rel, &keep) {
|
||||
Ok(message) => CtlResponse::Ok { message },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
@@ -1357,6 +1405,20 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
Ok(items) => CtlResponse::PendingOps { items },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::ExportPendingOp { id, dest }) => {
|
||||
match core.export_pending_op(id, Path::new(&dest)) {
|
||||
Ok(written) => CtlResponse::Ok {
|
||||
message: format!("wrote {} to {dest}", human_bytes(written)),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::DiscardPendingOp { id }) => match core.discard_pending_op(id) {
|
||||
Ok(path) => CtlResponse::Ok {
|
||||
message: format!("discarded the queued change to {path}"),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::RetryPendingOp { id }) => match core.retry_pending_ops(id) {
|
||||
Ok(0) if id.is_some() => CtlResponse::Ok {
|
||||
message: "nothing to retry: the operation is already due or waiting for a rename"
|
||||
@@ -1754,7 +1816,7 @@ pub(crate) fn run_control_socket(
|
||||
info!("control socket listening");
|
||||
for conn in listener.incoming() {
|
||||
// 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() {
|
||||
info!("control socket closing");
|
||||
return;
|
||||
@@ -1846,6 +1908,18 @@ mod request_limit_tests {
|
||||
assert!(rel_to_mount(mountpoint, "Photos/../../escape").is_err());
|
||||
assert!(rel_to_mount(mountpoint, "/mnt/elsewhere").is_err());
|
||||
}
|
||||
|
||||
/// `pdfs rm` and `pdfs rename` reach on-demand mounts, whose root is a whole
|
||||
/// synced folder with a parent on Drive. Only a path below a root may get
|
||||
/// through to the trash or rename call.
|
||||
#[test]
|
||||
fn only_a_path_below_a_mount_root_names_a_node() {
|
||||
assert!(below_root(Path::new("Photos")));
|
||||
assert!(below_root(Path::new("./Photos/2026")));
|
||||
assert!(!below_root(Path::new("")));
|
||||
assert!(!below_root(Path::new(".")));
|
||||
assert!(!below_root(Path::new("./.")));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! 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
|
||||
//! the engine in [`super::sync`] reconciles both ways; `ondemand` evicts that
|
||||
@@ -27,7 +27,7 @@ use tracing::{info, warn};
|
||||
|
||||
use super::sync::{self, base_name};
|
||||
use super::{
|
||||
Core, SecondaryInsertRejection, SecondaryMount, State, SwitchBlocked, clear_stale_mount,
|
||||
Core, SecondaryInsertRejection, SecondaryMount, SwitchBlocked, clear_stale_mount,
|
||||
device_type_str, dir_is_empty, evict_dir_contents, fuse_connection_id, is_stale_mount,
|
||||
now_secs, parse_uid, spawn_session, sync_folder_info, this_hostname,
|
||||
};
|
||||
@@ -184,8 +184,8 @@ impl Core {
|
||||
}
|
||||
|
||||
/// Adopt an existing device as *this* machine's, pinning it in `config.json`
|
||||
/// so hostname changes and reinstalls stop registering duplicates
|
||||
/// (features.md 5.1). `None` clears the pin and returns to hostname matching.
|
||||
/// so hostname changes and reinstalls stop registering duplicates.
|
||||
/// `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
|
||||
/// that it is authoritative, so accepting a bad one would defer the failure
|
||||
@@ -260,7 +260,7 @@ impl Core {
|
||||
})
|
||||
}
|
||||
|
||||
// ---- device folder sync (devices.md, Phase 1) -------------------------
|
||||
// ---- device folder sync -------------------------
|
||||
|
||||
/// Auto-register (or recover) this machine as a Proton Drive Device, caching
|
||||
/// it so restarts reuse the same device. Recovery matches an existing remote
|
||||
@@ -270,8 +270,7 @@ impl Core {
|
||||
/// Resolution order: the *adopted* uid pinned in `config.json`, then the
|
||||
/// cached DB row (validated remotely), then a hostname match, then create.
|
||||
/// The pin exists because the hostname heuristic silently registers a second
|
||||
/// device after a rename or reinstall, orphaning the first one's folders
|
||||
/// (features.md 5.1).
|
||||
/// device after a rename or reinstall, orphaning the first one's folders.
|
||||
pub(crate) fn ensure_device(&self) -> CoreResult<StoredDevice> {
|
||||
let name = this_hostname();
|
||||
// Enumerate the remote devices once: used to validate the pin, validate
|
||||
@@ -363,14 +362,14 @@ impl Core {
|
||||
) -> CoreResult<Option<NodeUid>> {
|
||||
let uids = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_folder_children_node_uids(root_uid))
|
||||
.block_on(self.drive.enumerate_folder_children_node_uids(root_uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list device root"))?;
|
||||
if uids.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let nodes = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_nodes(&uids))
|
||||
.block_on(self.drive.enumerate_nodes(&uids))
|
||||
.map_err(|e| CoreError::from_api(&e, "resolve device root children"))?;
|
||||
Ok(nodes
|
||||
.into_iter()
|
||||
@@ -446,10 +445,7 @@ impl Core {
|
||||
}
|
||||
None => self
|
||||
.rt
|
||||
.block_on(
|
||||
self.client
|
||||
.create_folder(&root_uid, &name, Some(now_secs())),
|
||||
)
|
||||
.block_on(self.drive.create_folder(&root_uid, &name, Some(now_secs())))
|
||||
.map_err(|e| CoreError::from_api(&e, &format!("create device folder {name}")))?,
|
||||
};
|
||||
|
||||
@@ -603,7 +599,7 @@ impl Core {
|
||||
&& let Some(uid) = parse_uid(&folder.remote_uid)
|
||||
&& let Err(e) = self
|
||||
.rt
|
||||
.block_on(self.client.trash_nodes(&[uid]))
|
||||
.block_on(self.drive.trash_nodes(&[uid]))
|
||||
.and_then(batch::into_unit)
|
||||
{
|
||||
warn!(id, error = %e, "delete remote device folder failed");
|
||||
@@ -619,23 +615,16 @@ impl Core {
|
||||
};
|
||||
}
|
||||
|
||||
/// A sibling Core that shares this one's client/rt/cache/db (and transfer,
|
||||
/// activity, mount registries) but gets a **fresh, empty `State`** — its own
|
||||
/// inode space starting at [`ROOT_INO`]. Used to root a secondary FUSE session
|
||||
/// at an `ondemand` sync folder without colliding with the main mount's inodes
|
||||
/// (devices.md Phase 3).
|
||||
/// A sibling Core for a secondary FUSE session rooted at an `ondemand` sync
|
||||
/// folder. It shares everything with this one,
|
||||
/// the tree included, except what belongs to a session: its root and its
|
||||
/// kernel channel, both filled in when the session is built.
|
||||
pub(crate) fn fork_state(&self) -> Core {
|
||||
let mut fork = self.clone();
|
||||
fork.primary = false;
|
||||
let share_access = self.state().share_access.clone();
|
||||
fork.state = Arc::new(Mutex::new(State::new(self.db.clone(), share_access, 2)));
|
||||
// A fresh inode space needs a fresh notification channel: this fork's
|
||||
// session is the only one that knows these inodes, so it must be the one
|
||||
// notified about them. Filled in by `spawn_ondemand_mount`.
|
||||
fork.root = Arc::new(OnceLock::new());
|
||||
fork.notifier = Arc::new(OnceLock::new());
|
||||
fork.session_live = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
// Size upgrades are keyed by inode, which is per-fork too.
|
||||
fork.size_upgrades = Arc::new(Mutex::new(HashMap::new()));
|
||||
fork
|
||||
}
|
||||
|
||||
@@ -858,7 +847,7 @@ impl Core {
|
||||
})?;
|
||||
let root = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_nodes(std::slice::from_ref(&root_uid)))
|
||||
.block_on(self.drive.enumerate_nodes(std::slice::from_ref(&root_uid)))
|
||||
.map_err(|e| SwitchBlocked::Failed(format!("fetch remote root: {e}")))?
|
||||
.into_iter()
|
||||
.next()
|
||||
@@ -937,8 +926,8 @@ impl Core {
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a secondary FUSE session rooted at `root` over `local` on a forked
|
||||
/// inode space. Clears any stale kernel mount first (a crashed run can leave
|
||||
/// Spawn a secondary FUSE session rooted at `root` over `local` from a
|
||||
/// [`Core::fork_state`] sibling. Clears any stale kernel mount first (a crashed run can leave
|
||||
/// one, which would fail the fresh mount with EBUSY).
|
||||
pub(crate) fn spawn_ondemand_mount(
|
||||
&self,
|
||||
@@ -996,8 +985,7 @@ impl Core {
|
||||
/// 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
|
||||
/// 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
|
||||
/// (devices.md Phase 4).
|
||||
/// own thread from `mount` so the network fetches never block startup.
|
||||
pub(crate) fn restore_ondemand_mounts(&self) {
|
||||
let folders = match self.db.sync_folder_list() {
|
||||
Ok(f) => f,
|
||||
@@ -1017,7 +1005,7 @@ impl Core {
|
||||
};
|
||||
let root = match self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_nodes(std::slice::from_ref(&root_uid)))
|
||||
.block_on(self.drive.enumerate_nodes(std::slice::from_ref(&root_uid)))
|
||||
{
|
||||
Ok(v) => match v.into_iter().next() {
|
||||
Some(n) => n,
|
||||
|
||||
+1607
-249
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,473 @@
|
||||
//! The calls the sync core makes to Drive, behind a trait.
|
||||
//!
|
||||
//! `Core` used to hold a concrete [`ProtonDriveClient`], so no test could make
|
||||
//! Drive answer late, answer stale, or not answer at all, and the interleavings
|
||||
//! behind most recent bugs could only be found on a real account over a slow
|
||||
//! link. [`DriveApi`] is the part of the
|
||||
//! client the mount, the drain, the event feed and the mirror engine use; the
|
||||
//! real client implements it by forwarding, and the simulation tests implement
|
||||
//! it with an in-memory Drive that injects Drive's known faults.
|
||||
//!
|
||||
//! Sharing, Photos, devices, invitations and public links stay on the concrete
|
||||
//! client (`Core::client`) until a test needs them faked.
|
||||
//!
|
||||
//! The method names match the client's own, so a call site reads the same
|
||||
//! whichever it goes through. The client's generic methods (a reader or writer
|
||||
//! of any type) cannot be part of an object-safe trait, so the trait takes them
|
||||
//! as trait objects and `impl dyn DriveApi` puts the generic signatures back.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures::StreamExt as _;
|
||||
use futures::stream::BoxStream;
|
||||
use proton_drive_rs::proton_sdk::account::Quota;
|
||||
use proton_drive_rs::proton_sdk::error::Result;
|
||||
use proton_drive_rs::proton_sdk::ids::{DriveEventId, NodeUid};
|
||||
use proton_drive_rs::{
|
||||
DriveEvent, DriveEventScopeId, Node, NodeMoveItem, ProtonDriveClient, RevisionReader, Thumbnail,
|
||||
};
|
||||
|
||||
/// One outcome per node of a batch request; see [`ProtonDriveClient::trash_nodes`].
|
||||
pub(crate) type NodeOutcomes = Vec<(NodeUid, Result<()>)>;
|
||||
|
||||
/// The outcomes of a streaming batch request, one item per node as it lands.
|
||||
pub(crate) type OutcomeStream<'a> = BoxStream<'a, Result<(NodeUid, Result<()>)>>;
|
||||
|
||||
/// Drive as the sync core sees it. See the module documentation.
|
||||
#[async_trait]
|
||||
pub(crate) trait DriveApi: Send + Sync {
|
||||
async fn get_my_files_folder(&self) -> Result<Node>;
|
||||
|
||||
async fn get_node(&self, uid: &NodeUid) -> Result<Option<Node>>;
|
||||
|
||||
async fn enumerate_nodes(&self, uids: &[NodeUid]) -> Result<Vec<Node>>;
|
||||
|
||||
/// As [`DriveApi::enumerate_nodes`], but files carry no claimed size or
|
||||
/// modification time; see [`ProtonDriveClient::enumerate_nodes_light`].
|
||||
async fn enumerate_nodes_light(&self, uids: &[NodeUid]) -> Result<Vec<Node>>;
|
||||
|
||||
async fn enumerate_folder_children_node_uids(
|
||||
&self,
|
||||
folder_uid: &NodeUid,
|
||||
) -> Result<Vec<NodeUid>>;
|
||||
|
||||
async fn enumerate_events(
|
||||
&self,
|
||||
scope: &DriveEventScopeId,
|
||||
cursor: Option<&DriveEventId>,
|
||||
) -> Result<Vec<DriveEvent>>;
|
||||
|
||||
async fn invalidate_caches_for_event(&self, event: &DriveEvent) -> Result<()>;
|
||||
|
||||
async fn create_folder(
|
||||
&self,
|
||||
parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
last_modification_time: Option<i64>,
|
||||
) -> Result<NodeUid>;
|
||||
|
||||
async fn upload_file(
|
||||
&self,
|
||||
parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
media_type: &str,
|
||||
contents: &[u8],
|
||||
) -> Result<NodeUid>;
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn upload_file_from_dyn(
|
||||
&self,
|
||||
parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
media_type: &str,
|
||||
reader: &mut (dyn Read + Send),
|
||||
intended_size: i64,
|
||||
thumbnails: Vec<Thumbnail>,
|
||||
last_modification_time: Option<i64>,
|
||||
aead: bool,
|
||||
) -> Result<NodeUid>;
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn upload_file_replacing_draft_from_dyn(
|
||||
&self,
|
||||
parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
media_type: &str,
|
||||
reader: &mut (dyn Read + Send),
|
||||
intended_size: i64,
|
||||
thumbnails: Vec<Thumbnail>,
|
||||
last_modification_time: Option<i64>,
|
||||
aead: bool,
|
||||
) -> Result<NodeUid>;
|
||||
|
||||
async fn upload_new_revision_from_dyn(
|
||||
&self,
|
||||
file_uid: &NodeUid,
|
||||
reader: &mut (dyn Read + Send),
|
||||
intended_size: i64,
|
||||
thumbnails: Vec<Thumbnail>,
|
||||
last_modification_time: Option<i64>,
|
||||
) -> Result<()>;
|
||||
|
||||
async fn open_revision(&self, uid: &NodeUid) -> Result<Arc<dyn RevisionRead>>;
|
||||
|
||||
async fn download_file_to_dyn(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
output: &mut (dyn Write + Send),
|
||||
) -> Result<()>;
|
||||
|
||||
async fn download_revision_to_dyn(
|
||||
&self,
|
||||
file_uid: &NodeUid,
|
||||
revision_id: &str,
|
||||
writer: &mut (dyn Write + Send),
|
||||
) -> Result<()>;
|
||||
|
||||
async fn rename_node(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
new_name: &str,
|
||||
new_media_type: Option<&str>,
|
||||
) -> Result<()>;
|
||||
|
||||
async fn move_node(&self, uid: &NodeUid, new_parent: &NodeUid) -> Result<()>;
|
||||
|
||||
fn move_nodes_streaming<'a>(
|
||||
&'a self,
|
||||
items: Vec<NodeMoveItem>,
|
||||
new_parent: NodeUid,
|
||||
) -> OutcomeStream<'a>;
|
||||
|
||||
async fn trash_nodes(&self, uids: &[NodeUid]) -> Result<NodeOutcomes>;
|
||||
|
||||
async fn restore_nodes(&self, uids: &[NodeUid]) -> Result<NodeOutcomes>;
|
||||
|
||||
fn restore_nodes_streaming<'a>(&'a self, uids: &[NodeUid]) -> OutcomeStream<'a>;
|
||||
|
||||
fn delete_nodes_streaming<'a>(&'a self, uids: &[NodeUid]) -> OutcomeStream<'a>;
|
||||
|
||||
async fn enumerate_trash_node_uids(&self) -> Result<Vec<NodeUid>>;
|
||||
|
||||
/// The account's storage, across every Proton product.
|
||||
async fn quota(&self) -> Result<Quota>;
|
||||
|
||||
/// Called by the drain once it has let go of the blob of an upload to
|
||||
/// `uid` that landed, or retired the create that made `uid`, before the
|
||||
/// tree has the node, so a simulation can hold it there. Blocks the drain
|
||||
/// worker it is called on.
|
||||
#[cfg(test)]
|
||||
fn landing(&self, _uid: &NodeUid) {}
|
||||
}
|
||||
|
||||
/// The generic signatures of the client, over the object-safe ones above.
|
||||
impl dyn DriveApi {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn upload_file_from<R: Read + Send>(
|
||||
&self,
|
||||
parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
media_type: &str,
|
||||
mut reader: R,
|
||||
intended_size: i64,
|
||||
thumbnails: Vec<Thumbnail>,
|
||||
last_modification_time: Option<i64>,
|
||||
aead: bool,
|
||||
) -> Result<NodeUid> {
|
||||
self.upload_file_from_dyn(
|
||||
parent_uid,
|
||||
name,
|
||||
media_type,
|
||||
&mut reader,
|
||||
intended_size,
|
||||
thumbnails,
|
||||
last_modification_time,
|
||||
aead,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn upload_file_replacing_draft_from<R: Read + Send>(
|
||||
&self,
|
||||
parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
media_type: &str,
|
||||
mut reader: R,
|
||||
intended_size: i64,
|
||||
thumbnails: Vec<Thumbnail>,
|
||||
last_modification_time: Option<i64>,
|
||||
aead: bool,
|
||||
) -> Result<NodeUid> {
|
||||
self.upload_file_replacing_draft_from_dyn(
|
||||
parent_uid,
|
||||
name,
|
||||
media_type,
|
||||
&mut reader,
|
||||
intended_size,
|
||||
thumbnails,
|
||||
last_modification_time,
|
||||
aead,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn upload_new_revision_from<R: Read + Send>(
|
||||
&self,
|
||||
file_uid: &NodeUid,
|
||||
mut reader: R,
|
||||
intended_size: i64,
|
||||
thumbnails: Vec<Thumbnail>,
|
||||
last_modification_time: Option<i64>,
|
||||
) -> Result<()> {
|
||||
self.upload_new_revision_from_dyn(
|
||||
file_uid,
|
||||
&mut reader,
|
||||
intended_size,
|
||||
thumbnails,
|
||||
last_modification_time,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn download_file_to<W: Write + Send>(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
output: &mut W,
|
||||
) -> Result<()> {
|
||||
self.download_file_to_dyn(uid, output).await
|
||||
}
|
||||
|
||||
pub(crate) async fn download_revision_to<W: Write + Send>(
|
||||
&self,
|
||||
file_uid: &NodeUid,
|
||||
revision_id: &str,
|
||||
writer: &mut W,
|
||||
) -> Result<()> {
|
||||
self.download_revision_to_dyn(file_uid, revision_id, writer)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
/// An open revision that block reads are served from; see [`RevisionReader`].
|
||||
#[async_trait]
|
||||
pub(crate) trait RevisionRead: Send + Sync {
|
||||
/// Plaintext size of the revision.
|
||||
fn size(&self) -> u64;
|
||||
|
||||
/// Plaintext size of each block, in block order.
|
||||
fn block_sizes(&self) -> &[u64];
|
||||
|
||||
async fn read_at(&self, offset: u64, length: u64) -> Result<Vec<u8>>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RevisionRead for RevisionReader {
|
||||
fn size(&self) -> u64 {
|
||||
RevisionReader::size(self)
|
||||
}
|
||||
|
||||
fn block_sizes(&self) -> &[u64] {
|
||||
RevisionReader::block_sizes(self)
|
||||
}
|
||||
|
||||
async fn read_at(&self, offset: u64, length: u64) -> Result<Vec<u8>> {
|
||||
RevisionReader::read_at(self, offset, length).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DriveApi for ProtonDriveClient {
|
||||
async fn get_my_files_folder(&self) -> Result<Node> {
|
||||
ProtonDriveClient::get_my_files_folder(self).await
|
||||
}
|
||||
|
||||
async fn get_node(&self, uid: &NodeUid) -> Result<Option<Node>> {
|
||||
ProtonDriveClient::get_node(self, uid).await
|
||||
}
|
||||
|
||||
async fn enumerate_nodes(&self, uids: &[NodeUid]) -> Result<Vec<Node>> {
|
||||
ProtonDriveClient::enumerate_nodes(self, uids).await
|
||||
}
|
||||
|
||||
async fn enumerate_nodes_light(&self, uids: &[NodeUid]) -> Result<Vec<Node>> {
|
||||
ProtonDriveClient::enumerate_nodes_light(self, uids).await
|
||||
}
|
||||
|
||||
async fn enumerate_folder_children_node_uids(
|
||||
&self,
|
||||
folder_uid: &NodeUid,
|
||||
) -> Result<Vec<NodeUid>> {
|
||||
ProtonDriveClient::enumerate_folder_children_node_uids(self, folder_uid).await
|
||||
}
|
||||
|
||||
async fn enumerate_events(
|
||||
&self,
|
||||
scope: &DriveEventScopeId,
|
||||
cursor: Option<&DriveEventId>,
|
||||
) -> Result<Vec<DriveEvent>> {
|
||||
ProtonDriveClient::enumerate_events(self, scope, cursor).await
|
||||
}
|
||||
|
||||
async fn invalidate_caches_for_event(&self, event: &DriveEvent) -> Result<()> {
|
||||
ProtonDriveClient::invalidate_caches_for_event(self, event).await
|
||||
}
|
||||
|
||||
async fn create_folder(
|
||||
&self,
|
||||
parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
last_modification_time: Option<i64>,
|
||||
) -> Result<NodeUid> {
|
||||
ProtonDriveClient::create_folder(self, parent_uid, name, last_modification_time).await
|
||||
}
|
||||
|
||||
async fn upload_file(
|
||||
&self,
|
||||
parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
media_type: &str,
|
||||
contents: &[u8],
|
||||
) -> Result<NodeUid> {
|
||||
ProtonDriveClient::upload_file(self, parent_uid, name, media_type, contents).await
|
||||
}
|
||||
|
||||
async fn upload_file_from_dyn(
|
||||
&self,
|
||||
parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
media_type: &str,
|
||||
reader: &mut (dyn Read + Send),
|
||||
intended_size: i64,
|
||||
thumbnails: Vec<Thumbnail>,
|
||||
last_modification_time: Option<i64>,
|
||||
aead: bool,
|
||||
) -> Result<NodeUid> {
|
||||
ProtonDriveClient::upload_file_from(
|
||||
self,
|
||||
parent_uid,
|
||||
name,
|
||||
media_type,
|
||||
reader,
|
||||
intended_size,
|
||||
thumbnails,
|
||||
last_modification_time,
|
||||
aead,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn upload_file_replacing_draft_from_dyn(
|
||||
&self,
|
||||
parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
media_type: &str,
|
||||
reader: &mut (dyn Read + Send),
|
||||
intended_size: i64,
|
||||
thumbnails: Vec<Thumbnail>,
|
||||
last_modification_time: Option<i64>,
|
||||
aead: bool,
|
||||
) -> Result<NodeUid> {
|
||||
ProtonDriveClient::upload_file_replacing_draft_from(
|
||||
self,
|
||||
parent_uid,
|
||||
name,
|
||||
media_type,
|
||||
reader,
|
||||
intended_size,
|
||||
thumbnails,
|
||||
last_modification_time,
|
||||
aead,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn upload_new_revision_from_dyn(
|
||||
&self,
|
||||
file_uid: &NodeUid,
|
||||
reader: &mut (dyn Read + Send),
|
||||
intended_size: i64,
|
||||
thumbnails: Vec<Thumbnail>,
|
||||
last_modification_time: Option<i64>,
|
||||
) -> Result<()> {
|
||||
ProtonDriveClient::upload_new_revision_from(
|
||||
self,
|
||||
file_uid,
|
||||
reader,
|
||||
intended_size,
|
||||
thumbnails,
|
||||
last_modification_time,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn open_revision(&self, uid: &NodeUid) -> Result<Arc<dyn RevisionRead>> {
|
||||
let reader = ProtonDriveClient::open_revision(self, uid).await?;
|
||||
Ok(Arc::new(reader))
|
||||
}
|
||||
|
||||
async fn download_file_to_dyn(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
output: &mut (dyn Write + Send),
|
||||
) -> Result<()> {
|
||||
ProtonDriveClient::download_file_to(self, uid, &mut &mut *output).await
|
||||
}
|
||||
|
||||
async fn download_revision_to_dyn(
|
||||
&self,
|
||||
file_uid: &NodeUid,
|
||||
revision_id: &str,
|
||||
writer: &mut (dyn Write + Send),
|
||||
) -> Result<()> {
|
||||
ProtonDriveClient::download_revision_to(self, file_uid, revision_id, &mut &mut *writer)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn rename_node(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
new_name: &str,
|
||||
new_media_type: Option<&str>,
|
||||
) -> Result<()> {
|
||||
ProtonDriveClient::rename_node(self, uid, new_name, new_media_type).await
|
||||
}
|
||||
|
||||
async fn move_node(&self, uid: &NodeUid, new_parent: &NodeUid) -> Result<()> {
|
||||
ProtonDriveClient::move_node(self, uid, new_parent).await
|
||||
}
|
||||
|
||||
fn move_nodes_streaming<'a>(
|
||||
&'a self,
|
||||
items: Vec<NodeMoveItem>,
|
||||
new_parent: NodeUid,
|
||||
) -> OutcomeStream<'a> {
|
||||
ProtonDriveClient::move_nodes_streaming(self, items, new_parent).boxed()
|
||||
}
|
||||
|
||||
async fn trash_nodes(&self, uids: &[NodeUid]) -> Result<NodeOutcomes> {
|
||||
ProtonDriveClient::trash_nodes(self, uids).await
|
||||
}
|
||||
|
||||
async fn restore_nodes(&self, uids: &[NodeUid]) -> Result<NodeOutcomes> {
|
||||
ProtonDriveClient::restore_nodes(self, uids).await
|
||||
}
|
||||
|
||||
fn restore_nodes_streaming<'a>(&'a self, uids: &[NodeUid]) -> OutcomeStream<'a> {
|
||||
ProtonDriveClient::restore_nodes_streaming(self, uids).boxed()
|
||||
}
|
||||
|
||||
fn delete_nodes_streaming<'a>(&'a self, uids: &[NodeUid]) -> OutcomeStream<'a> {
|
||||
ProtonDriveClient::delete_nodes_streaming(self, uids).boxed()
|
||||
}
|
||||
|
||||
async fn enumerate_trash_node_uids(&self) -> Result<Vec<NodeUid>> {
|
||||
ProtonDriveClient::enumerate_trash_node_uids(self).await
|
||||
}
|
||||
|
||||
async fn quota(&self) -> Result<Quota> {
|
||||
ProtonDriveClient::quota(self).await
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
//! through one place (the status, transfers, sync progress) and publishes them
|
||||
//! when they differ.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::sync::Arc;
|
||||
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
|
||||
/// when it differs from the last look: the status (queue counts, cache use,
|
||||
/// online, pause), the transfers snapshot, and the synced folders with their
|
||||
/// live progress. Looks only while someone is subscribed.
|
||||
/// online, pause), the transfers snapshot, the synced folders with their
|
||||
/// live progress, and which nodes have a sync issue. Looks only while someone
|
||||
/// is subscribed.
|
||||
pub(crate) fn run_event_sampler(core: Core, username: String, mountpoint: std::path::PathBuf) {
|
||||
let mut transfers: Option<Arc<str>> = None;
|
||||
let mut status: Option<String> = None;
|
||||
let mut queue: Option<String> = None;
|
||||
let mut locations: Option<String> = None;
|
||||
let mut issues: Option<HashMap<i64, String>> = None;
|
||||
let mut tick = 0u32;
|
||||
while core.shutdown.sleep(SAMPLE_INTERVAL) {
|
||||
if !core.events.has_subscribers() {
|
||||
@@ -169,6 +171,7 @@ pub(crate) fn run_event_sampler(core: Core, username: String, mountpoint: std::p
|
||||
status = None;
|
||||
queue = None;
|
||||
locations = None;
|
||||
issues = None;
|
||||
continue;
|
||||
}
|
||||
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);
|
||||
|
||||
// An issue shows on its node in the file browser: raised, cleared,
|
||||
// landed or discarded, the listing is out of date.
|
||||
if let Ok(now) = core.db.node_issues() {
|
||||
if issues.as_ref().is_some_and(|last| *last != now) {
|
||||
core.events.publish(&[Topic::Files]);
|
||||
}
|
||||
issues = Some(now);
|
||||
}
|
||||
|
||||
if let Ok(now) = core.list_locations() {
|
||||
let key = serde_json::to_string(&now).unwrap_or_default();
|
||||
if locations.as_ref().is_some_and(|last| *last != key) {
|
||||
|
||||
+347
-255
File diff suppressed because it is too large.
Load diff
+2406
-840
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,374 @@
|
||||
//! Noticing that the network went away mid-session, and that it came back.
|
||||
//!
|
||||
//! The mount used to learn it was offline only at startup: a daemon that had
|
||||
//! connected once kept `online` true for the rest of its life, so a Wi-Fi drop
|
||||
//! turned every create, mkdir, delete and rename into a two-minute wait for an
|
||||
//! `EIO`, while the queue that exists for exactly this sat unused. Now any
|
||||
//! remote call a handler waits on reports a failure to reach Proton through
|
||||
//! [`Core::lost_link`], which flips the mount offline; the handler then takes its
|
||||
//! queued path, and the probe thread here flips it back when Proton answers.
|
||||
|
||||
use std::future::Future;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use parking_lot::{Condvar, Mutex};
|
||||
use proton_drive_rs::proton_sdk::api::ResponseCode;
|
||||
use proton_drive_rs::proton_sdk::error::{ProtonApiError, ProtonError};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::{Core, ONLINE_PROBE_MAX, ONLINE_PROBE_MIN};
|
||||
|
||||
/// How long a remote call a FUSE handler is waiting on may take before the link
|
||||
/// is treated as down.
|
||||
///
|
||||
/// The SDK's own budget for one request is four attempts of up to 30 seconds
|
||||
/// each, so a dead link held the caller for about two minutes. A metadata call
|
||||
/// that has had no answer in 20 seconds is not going to be one the user wants to
|
||||
/// keep waiting for; queueing the change is the better answer.
|
||||
pub(crate) const INTERACTIVE_CALL_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
||||
/// How long one probe may wait for an answer. A probe is a single small request,
|
||||
/// and a probe stuck in the SDK's retry budget would delay noticing the link is
|
||||
/// back by as long as it waits.
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// First delay between probes after a drop seen mid-session, and the ceiling it
|
||||
/// doubles to while the outage is young. A blip should end in seconds, not in
|
||||
/// the minutes [`ONLINE_PROBE_MAX`] allows.
|
||||
const PROBE_AFTER_DROP: Duration = Duration::from_secs(2);
|
||||
const PROBE_YOUNG_MAX: Duration = Duration::from_secs(30);
|
||||
/// How long an outage counts as young. Past it the probe backs off to
|
||||
/// [`ONLINE_PROBE_MAX`], because a laptop can sit offline for days and each
|
||||
/// probe is a real API round trip.
|
||||
const YOUNG_OUTAGE: Duration = Duration::from_secs(10 * 60);
|
||||
|
||||
/// How often the idle probe thread checks for teardown while the link is up.
|
||||
const IDLE_CHECK: Duration = Duration::from_secs(1);
|
||||
|
||||
/// The shared half of the online flag: when the link went down, and a condvar
|
||||
/// that fires on every change so waiters need not poll.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Link {
|
||||
lost_at: Mutex<Option<Instant>>,
|
||||
changed: Condvar,
|
||||
/// Folders listed from the DB alone while offline, to relist once back.
|
||||
pub(crate) stale_listings: Mutex<std::collections::HashSet<u64>>,
|
||||
}
|
||||
|
||||
/// Whether a failed call failed because Proton could not be reached, rather
|
||||
/// than because it answered and refused.
|
||||
///
|
||||
/// A transport error is the network. So is a request timeout: the SDK reports
|
||||
/// Proton's own "try again" codes that way, and [`no_answer`] reports our
|
||||
/// deadline that way. Anything else Proton actually said, and says again on a
|
||||
/// retry, so it must reach the caller rather than be queued.
|
||||
pub(crate) fn is_network_error(e: &ProtonError) -> bool {
|
||||
match e {
|
||||
ProtonError::Transport(_) => true,
|
||||
ProtonError::Api(api) => {
|
||||
matches!(
|
||||
api.code,
|
||||
ResponseCode::RequestTimeout | ResponseCode::Timeout
|
||||
)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The error a call reports when it runs past `after` without an answer.
|
||||
pub(crate) fn no_answer(after: Duration) -> ProtonError {
|
||||
ProtonError::Api(ProtonApiError {
|
||||
code: ResponseCode::RequestTimeout,
|
||||
http_status: 408,
|
||||
message: format!("no answer within {}s", after.as_secs()),
|
||||
details: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The slowest upload rate an upload's deadline allows for, past the
|
||||
/// metadata budget it starts with. Far below any link that works, so only a
|
||||
/// transfer that has stopped moving runs into it.
|
||||
const SLOWEST_UPLOAD: u64 = 32 * 1024;
|
||||
|
||||
/// How long an upload of `len` bytes may take before the link is treated as
|
||||
/// down: the budget of a metadata call, plus the bytes at [`SLOWEST_UPLOAD`].
|
||||
pub(crate) fn upload_deadline(len: u64) -> Duration {
|
||||
INTERACTIVE_CALL_TIMEOUT + Duration::from_secs(len / SLOWEST_UPLOAD)
|
||||
}
|
||||
|
||||
/// Run a remote call, giving up after [`INTERACTIVE_CALL_TIMEOUT`].
|
||||
pub(crate) async fn bounded<T>(
|
||||
call: impl Future<Output = Result<T, ProtonError>>,
|
||||
) -> Result<T, ProtonError> {
|
||||
bounded_by(INTERACTIVE_CALL_TIMEOUT, call).await
|
||||
}
|
||||
|
||||
/// Run a remote call, giving up after `limit`.
|
||||
pub(crate) async fn bounded_by<T>(
|
||||
limit: Duration,
|
||||
call: impl Future<Output = Result<T, ProtonError>>,
|
||||
) -> Result<T, ProtonError> {
|
||||
match tokio::time::timeout(limit, call).await {
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(no_answer(limit)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The next delay between probes, given the current one and how long the
|
||||
/// outage has lasted.
|
||||
fn next_probe_delay(delay: Duration, outage: Duration) -> Duration {
|
||||
let cap = if outage < YOUNG_OUTAGE {
|
||||
PROBE_YOUNG_MAX
|
||||
} else {
|
||||
ONLINE_PROBE_MAX
|
||||
};
|
||||
(delay * 2).min(cap)
|
||||
}
|
||||
|
||||
impl Core {
|
||||
/// Whether the mount currently believes Proton is reachable.
|
||||
pub(crate) fn is_online(&self) -> bool {
|
||||
self.online.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Whether a namespace change through the mount goes to Drive inside the
|
||||
/// syscall. Only with [`AppConfig::local_first`] off, and only online;
|
||||
/// otherwise it is queued and the drain sends it.
|
||||
///
|
||||
/// [`AppConfig::local_first`]: pdfs_core::config::AppConfig::local_first
|
||||
pub(crate) fn sends_inline(&self) -> bool {
|
||||
!self.local_first && self.is_online()
|
||||
}
|
||||
|
||||
/// Run a remote call a caller is waiting on, giving up after
|
||||
/// [`INTERACTIVE_CALL_TIMEOUT`]. The timer is built inside the runtime: a
|
||||
/// FUSE worker has no reactor of its own.
|
||||
pub(crate) fn block_on_bounded<T>(
|
||||
&self,
|
||||
call: impl Future<Output = Result<T, ProtonError>>,
|
||||
) -> Result<T, ProtonError> {
|
||||
self.rt.block_on(bounded(call))
|
||||
}
|
||||
|
||||
/// Run a remote call, giving up after `limit`: an upload, whose budget
|
||||
/// grows with its size ([`upload_deadline`]).
|
||||
pub(crate) fn block_on_within<T>(
|
||||
&self,
|
||||
limit: Duration,
|
||||
call: impl Future<Output = Result<T, ProtonError>>,
|
||||
) -> Result<T, ProtonError> {
|
||||
self.rt.block_on(bounded_by(limit, call))
|
||||
}
|
||||
|
||||
/// Report a failed remote call. Returns whether it failed for want of a
|
||||
/// network, in which case the mount is now offline and the caller should
|
||||
/// take its queued path instead of failing.
|
||||
pub(crate) fn lost_link(&self, e: &ProtonError, what: &str) -> bool {
|
||||
if !is_network_error(e) {
|
||||
return false;
|
||||
}
|
||||
self.mark_offline(what, e);
|
||||
true
|
||||
}
|
||||
|
||||
/// Flip the mount offline and wake the probe. Only the first caller of an
|
||||
/// outage logs it; the rest are the same outage.
|
||||
pub(crate) fn mark_offline(&self, what: &str, error: &dyn std::fmt::Display) {
|
||||
let mut lost_at = self.link.lost_at.lock();
|
||||
if self.online.swap(false, Ordering::Relaxed) {
|
||||
*lost_at = Some(Instant::now());
|
||||
warn!(during = what, %error,
|
||||
"lost the connection to Proton; queueing changes until it returns");
|
||||
self.link.changed.notify_all();
|
||||
}
|
||||
}
|
||||
|
||||
/// Flip the mount back online, wake everything waiting for it, and let the
|
||||
/// queue drain.
|
||||
fn mark_online(&self) {
|
||||
{
|
||||
let mut lost_at = self.link.lost_at.lock();
|
||||
self.online.store(true, Ordering::Relaxed);
|
||||
match lost_at.take() {
|
||||
Some(since) => {
|
||||
info!(
|
||||
offline_secs = since.elapsed().as_secs(),
|
||||
"connection to Proton is back"
|
||||
)
|
||||
}
|
||||
None => info!("back online"),
|
||||
}
|
||||
self.link.changed.notify_all();
|
||||
}
|
||||
self.relist_offline_listings();
|
||||
// Anything written while offline is queued and waiting on exactly this,
|
||||
// including ops that failed on the way down and are sitting out a
|
||||
// backoff sized for a server that keeps refusing.
|
||||
if let Err(e) = self.db.retry_failed_ops_now(crate::now_millis()) {
|
||||
warn!(error = %e, "moving backed-off ops up after a reconnect failed");
|
||||
}
|
||||
self.wake_drain();
|
||||
}
|
||||
|
||||
/// Wait up to `budget` for the link to come back. Returns whether it is up.
|
||||
pub(crate) fn wait_online(&self, budget: Duration) -> bool {
|
||||
let deadline = Instant::now() + budget;
|
||||
let mut lost_at = self.link.lost_at.lock();
|
||||
while !self.is_online() {
|
||||
if self.shutdown.is_stopping()
|
||||
|| self
|
||||
.link
|
||||
.changed
|
||||
.wait_until(&mut lost_at, deadline)
|
||||
.timed_out()
|
||||
{
|
||||
return self.is_online();
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Wait out `interval` before a background poll of Proton, and while the
|
||||
/// link is down, until it is back. Returns as soon as it comes back, so the
|
||||
/// poll catches up on what changed during the outage at once instead of
|
||||
/// one interval later. A poll into a dead link would only wait out the
|
||||
/// SDK's retries and fail.
|
||||
pub(crate) async fn next_poll(&self, interval: Duration) {
|
||||
let due = Instant::now() + interval;
|
||||
let mut was_offline = false;
|
||||
loop {
|
||||
if !self.is_online() {
|
||||
was_offline = true;
|
||||
} else if was_offline || Instant::now() >= due {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(IDLE_CHECK).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Watch the link for the life of the mount: sleep while it is up, and
|
||||
/// probe it back while it is down.
|
||||
///
|
||||
/// A mount that started from the cache is down from the first moment; one
|
||||
/// that started online goes down when [`Core::mark_offline`] says so.
|
||||
pub(crate) fn run_online_probe(&self) {
|
||||
// Only an offline start never had a chance to repair its share id.
|
||||
let mut repair_share_id = !self.is_online();
|
||||
loop {
|
||||
{
|
||||
let mut lost_at = self.link.lost_at.lock();
|
||||
while self.is_online() {
|
||||
if self.shutdown.is_stopping() {
|
||||
return;
|
||||
}
|
||||
self.link.changed.wait_for(&mut lost_at, IDLE_CHECK);
|
||||
}
|
||||
}
|
||||
if !self.probe_until_online() {
|
||||
return;
|
||||
}
|
||||
if std::mem::take(&mut repair_share_id) {
|
||||
self.repair_primary_share_id(&self.primary_root_uid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe until Proton answers, then flip the mount online. Returns `false`
|
||||
/// if the daemon is stopping.
|
||||
fn probe_until_online(&self) -> bool {
|
||||
let started = self.link.lost_at.lock().unwrap_or_else(Instant::now);
|
||||
let mut delay = if started.elapsed() < YOUNG_OUTAGE {
|
||||
PROBE_AFTER_DROP
|
||||
} else {
|
||||
ONLINE_PROBE_MIN
|
||||
};
|
||||
loop {
|
||||
if !self.shutdown.sleep(delay) {
|
||||
return false;
|
||||
}
|
||||
let probe = self.rt.block_on(async {
|
||||
match tokio::time::timeout(PROBE_TIMEOUT, self.drive.get_my_files_folder()).await {
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(no_answer(PROBE_TIMEOUT)),
|
||||
}
|
||||
});
|
||||
match probe {
|
||||
Ok(root) => {
|
||||
{
|
||||
let root_ino = self.root_ino();
|
||||
let mut st = self.state();
|
||||
if let Some(e) = st.entries.get_mut(&root_ino) {
|
||||
e.node = root.clone();
|
||||
}
|
||||
}
|
||||
if let Err(e) = self.db.upsert_node(&root) {
|
||||
warn!(error = %e, "refresh root after reconnect failed");
|
||||
}
|
||||
self.mark_online();
|
||||
return true;
|
||||
}
|
||||
Err(e) => {
|
||||
debug!(error = %e, ?delay, "online probe failed; still offline");
|
||||
delay = next_probe_delay(delay, started.elapsed());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn api(code: ResponseCode) -> ProtonError {
|
||||
ProtonError::Api(ProtonApiError {
|
||||
code,
|
||||
http_status: 422,
|
||||
message: String::new(),
|
||||
details: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_an_unanswered_call_counts_as_a_lost_link() {
|
||||
assert!(is_network_error(&no_answer(INTERACTIVE_CALL_TIMEOUT)));
|
||||
assert!(is_network_error(&api(ResponseCode::Timeout)));
|
||||
assert!(!is_network_error(&api(ResponseCode::AlreadyExists)));
|
||||
assert!(!is_network_error(&api(ResponseCode::InvalidRequirements)));
|
||||
assert!(!is_network_error(&ProtonError::invalid_operation("bug")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_upload_gets_longer_the_bigger_it_is() {
|
||||
assert_eq!(upload_deadline(0), INTERACTIVE_CALL_TIMEOUT);
|
||||
assert_eq!(
|
||||
upload_deadline(1 << 30),
|
||||
INTERACTIVE_CALL_TIMEOUT + Duration::from_secs(32_768)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_call_past_its_deadline_reads_as_a_lost_link() {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_time()
|
||||
.build()
|
||||
.unwrap();
|
||||
let limit = Duration::from_millis(20);
|
||||
let result: Result<(), _> = rt.block_on(bounded_by(limit, std::future::pending()));
|
||||
assert!(result.is_err_and(|e| is_network_error(&e)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_young_outage_is_probed_often_and_an_old_one_rarely() {
|
||||
let young = Duration::from_secs(60);
|
||||
let old = YOUNG_OUTAGE + Duration::from_secs(1);
|
||||
assert_eq!(
|
||||
next_probe_delay(PROBE_AFTER_DROP, young),
|
||||
2 * PROBE_AFTER_DROP
|
||||
);
|
||||
assert_eq!(next_probe_delay(PROBE_YOUNG_MAX, young), PROBE_YOUNG_MAX);
|
||||
assert_eq!(next_probe_delay(PROBE_YOUNG_MAX, old), 2 * PROBE_YOUNG_MAX);
|
||||
assert_eq!(next_probe_delay(ONLINE_PROBE_MAX, old), ONLINE_PROBE_MAX);
|
||||
}
|
||||
}
|
||||
+171
-56
@@ -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
|
||||
/// recover it from `nodes` without the API (offline.md Phase 1).
|
||||
/// recover it from `nodes` without the API.
|
||||
const ROOT_UID_KEY: &str = "root_uid";
|
||||
|
||||
/// The My Files root, and whether we got it from the API (`true`) or from the
|
||||
@@ -207,11 +207,11 @@ const ROOT_UID_KEY: &str = "root_uid";
|
||||
/// means an empty tree, and mounting that would show the user an empty Drive
|
||||
/// rather than an honest error.
|
||||
fn fetch_or_recall_root(
|
||||
client: &ProtonDriveClient,
|
||||
drive: &dyn DriveApi,
|
||||
rt: &tokio::runtime::Handle,
|
||||
db: &Db,
|
||||
) -> std::io::Result<(Node, bool)> {
|
||||
let err = match rt.block_on(client.get_my_files_folder()) {
|
||||
let err = match rt.block_on(drive.get_my_files_folder()) {
|
||||
Ok(root) => {
|
||||
if let Err(e) = db.set_state_str(ROOT_UID_KEY, &root.uid.to_string()) {
|
||||
warn!(error = %e, "persist root uid failed");
|
||||
@@ -244,6 +244,9 @@ pub struct MountOptions {
|
||||
/// Upload and download caps in bytes per second (`0` = no cap).
|
||||
pub upload_limit: u64,
|
||||
pub download_limit: u64,
|
||||
/// Namespace changes through the mount are queued, not sent in the
|
||||
/// syscall. See [`pdfs_core::config::AppConfig::local_first`].
|
||||
pub local_first: bool,
|
||||
}
|
||||
|
||||
/// Spawn one FUSE session rooted at an arbitrary remote node.
|
||||
@@ -276,13 +279,14 @@ pub(super) fn spawn_session(
|
||||
info!(mountpoint = %mountpoint.display(), "mounting Proton Drive location");
|
||||
let fs = ProtonFs::new(core.clone(), root);
|
||||
if core.primary {
|
||||
// Only My Files rehydrates the daemon-wide node cache. A secondary
|
||||
// inode space is rooted at one device subtree and must not import every
|
||||
// unrelated persisted node.
|
||||
// Only My Files rehydrates the daemon-wide node cache, once. A
|
||||
// secondary mount shares the tree it fills.
|
||||
core.hydrate();
|
||||
}
|
||||
let session = Session::new(fs, mountpoint, &config)?.spawn()?;
|
||||
let _ = core.notifier.set(session.notifier());
|
||||
let _ = core
|
||||
.notifier
|
||||
.set(KernelChannel::new(session.notifier(), core.root_ino()));
|
||||
core.session_live.store(true, Ordering::Release);
|
||||
Ok(session)
|
||||
}
|
||||
@@ -339,17 +343,57 @@ pub fn mount(
|
||||
control_socket: &Path,
|
||||
db: Arc<Db>,
|
||||
options: MountOptions,
|
||||
) -> std::io::Result<MountOutcome> {
|
||||
let drive = Arc::new(client.clone());
|
||||
mount_with(
|
||||
client,
|
||||
drive,
|
||||
rt,
|
||||
mountpoint,
|
||||
cache,
|
||||
control_socket,
|
||||
db,
|
||||
options,
|
||||
Host::Daemon,
|
||||
)
|
||||
}
|
||||
|
||||
/// What a mount runs in.
|
||||
pub(crate) enum Host {
|
||||
/// The daemon: stopped by SIGTERM or SIGINT, and indexing the user's home
|
||||
/// for the launcher prompt.
|
||||
Daemon,
|
||||
/// One of several simulated daemons in a test process (`sim`): stopped by
|
||||
/// a message on this channel, and with no home to index.
|
||||
#[cfg(test)]
|
||||
Simulation(std::sync::mpsc::Receiver<()>),
|
||||
}
|
||||
|
||||
/// [`mount`], reaching Drive through `drive` and hosted by `host`; `client`
|
||||
/// serves only what [`DriveApi`] does not cover.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn mount_with(
|
||||
client: ProtonDriveClient,
|
||||
drive: Arc<dyn DriveApi>,
|
||||
rt: tokio::runtime::Handle,
|
||||
mountpoint: &Path,
|
||||
cache: ContentCache,
|
||||
control_socket: &Path,
|
||||
db: Arc<Db>,
|
||||
options: MountOptions,
|
||||
host: Host,
|
||||
) -> std::io::Result<MountOutcome> {
|
||||
let MountOptions {
|
||||
username,
|
||||
sweep_mode,
|
||||
upload_limit,
|
||||
download_limit,
|
||||
local_first,
|
||||
} = options;
|
||||
// Start the uptime clock here rather than at the first `pdfs diagnostics`,
|
||||
// so the age it reports is the daemon's own.
|
||||
super::diagnostics::start_clock();
|
||||
let (root, online) = fetch_or_recall_root(&client, &rt, &db)?;
|
||||
let (root, online) = fetch_or_recall_root(drive.as_ref(), &rt, &db)?;
|
||||
let scope = root.tree_event_scope_id();
|
||||
db.mount_upsert_my_files(&mountpoint.to_string_lossy(), &root.uid.to_string(), None)
|
||||
.map_err(|error| std::io::Error::other(format!("project My Files mount: {error}")))?;
|
||||
@@ -357,19 +401,21 @@ pub fn mount(
|
||||
.all_share_access()
|
||||
.map_err(|error| std::io::Error::other(format!("load shared access: {error}")))?;
|
||||
|
||||
// The folder-sync engine (devices.md Phase 2) runs on its own thread and is
|
||||
// The folder-sync engine runs on its own thread and is
|
||||
// nudged over this channel; the sender lives in Core so control-socket
|
||||
// handlers can trigger reconciles.
|
||||
let (sync_tx, sync_rx) = std::sync::mpsc::channel::<sync::SyncMsg>();
|
||||
|
||||
let paused_until = pause::load_paused_until(&db);
|
||||
let core = Core {
|
||||
client: client.clone(),
|
||||
drive,
|
||||
client,
|
||||
rt: rt.clone(),
|
||||
maintenance: Arc::new(Mutex::new(Default::default())),
|
||||
primary_root_uid: root.uid.clone(),
|
||||
primary: true,
|
||||
state: Arc::new(Mutex::new(State::new(db.clone(), share_access, 2))),
|
||||
state: Arc::new(Mutex::new(State::new(db.clone(), share_access))),
|
||||
root: Arc::new(OnceLock::new()),
|
||||
cache: Arc::new(cache),
|
||||
readers: Arc::new(Mutex::new(HashMap::new())),
|
||||
block_ring: Arc::new(Mutex::new(BlockRing::default())),
|
||||
@@ -383,13 +429,19 @@ pub fn mount(
|
||||
shared_generation: Arc::new(AtomicU64::new(0)),
|
||||
shared_refresh_deadlines: Arc::new(Mutex::new(SharedRefreshDeadlines::default())),
|
||||
online: Arc::new(AtomicBool::new(online)),
|
||||
link: Arc::new(Default::default()),
|
||||
sync_paused_until: Arc::new(AtomicI64::new(paused_until)),
|
||||
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||
hidden: Arc::new(Mutex::new(HashSet::new())),
|
||||
creating: Arc::new(Mutex::new(HashMap::new())),
|
||||
sealed_unread: Arc::new(Mutex::new(HashMap::new())),
|
||||
landed: Arc::new(Mutex::new(HashMap::new())),
|
||||
unadopted: Arc::new(Mutex::new(HashMap::new())),
|
||||
drain_wake: Arc::new((Mutex::new(false), Condvar::new())),
|
||||
shutdown: Arc::new(crate::shutdown::Shutdown::default()),
|
||||
upload_times: Arc::new(Mutex::new(HashMap::new())),
|
||||
upload_cancel: Arc::new(Mutex::new(HashMap::new())),
|
||||
landing_uploads: Arc::new(AtomicU64::new(0)),
|
||||
timeline_refreshing: Arc::new(AtomicBool::new(false)),
|
||||
timeline_progress: Arc::new(Mutex::new(None)),
|
||||
similar_hashing: Arc::new(AtomicBool::new(false)),
|
||||
@@ -398,7 +450,7 @@ pub fn mount(
|
||||
trash_progress: Arc::new(tokio::sync::Notify::new()),
|
||||
conflict_notified: Arc::new(Mutex::new(HashSet::new())),
|
||||
sweep_mode,
|
||||
own_sealed_revs: Arc::new(Mutex::new(HashMap::new())),
|
||||
local_first,
|
||||
self_changes: Arc::new(Mutex::new(HashMap::new())),
|
||||
thumb_gen: Arc::new(Mutex::new(HashSet::new())),
|
||||
thumb_gen_budget: Arc::new(tokio::sync::Semaphore::new(
|
||||
@@ -411,6 +463,7 @@ pub fn mount(
|
||||
thumbnail_build_cancel: Arc::new(tokio::sync::Notify::new()),
|
||||
thumbnail_misses: Arc::new(Mutex::new(Default::default())),
|
||||
quota: Arc::new(Mutex::new(None)),
|
||||
quota_refreshing: Arc::new(AtomicBool::new(false)),
|
||||
size_upgrades: Arc::new(Mutex::new(HashMap::new())),
|
||||
size_waiters: Arc::new(Default::default()),
|
||||
notifier: Arc::new(OnceLock::new()),
|
||||
@@ -425,7 +478,7 @@ pub fn mount(
|
||||
states: Arc::new(StateRegistry::default()),
|
||||
};
|
||||
// Before anything can queue work against it: the drain thread below reaches
|
||||
// every mount's inode space through this registry, not through `core.state`.
|
||||
// every mount's session through this registry.
|
||||
core.register_state(mountpoint);
|
||||
core.transfers.set_limits(upload_limit, download_limit);
|
||||
|
||||
@@ -448,7 +501,7 @@ pub fn mount(
|
||||
// Every long-lived thread started from here on is kept in `workers` and
|
||||
// joined at the end of this function. Leaving them running turned an
|
||||
// in-process remount into a second generation of drain workers, sync engines
|
||||
// and sweeps all operating on a mount that no longer exists (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();
|
||||
for worker in 0..DRAIN_WORKERS {
|
||||
let core = core.clone();
|
||||
@@ -484,9 +537,10 @@ pub fn mount(
|
||||
);
|
||||
}
|
||||
|
||||
// Mounted from the cache: watch for the network coming back so the mount can
|
||||
// stop being read-only-ish without the user restarting the daemon.
|
||||
if !online {
|
||||
// Watch the link for the life of the mount: a mount from the cache waits for
|
||||
// the network to come back, and an online one can lose it at any moment and
|
||||
// must notice it coming back just the same.
|
||||
{
|
||||
let core = core.clone();
|
||||
workers.push(
|
||||
std::thread::Builder::new()
|
||||
@@ -497,7 +551,7 @@ pub fn mount(
|
||||
|
||||
// Keep the launcher prompt's "This computer" index fresh. Its own thread:
|
||||
// the walk is I/O-heavy and must never sit in front of a FUSE callback.
|
||||
{
|
||||
if matches!(host, Host::Daemon) {
|
||||
let db = core.db.clone();
|
||||
let indexing = core.indexing.clone();
|
||||
let transfers = core.transfers.clone();
|
||||
@@ -621,11 +675,13 @@ pub fn mount(
|
||||
}
|
||||
}
|
||||
};
|
||||
rt.spawn(run_event_sync(client, scope, core.clone()));
|
||||
rt.spawn(run_event_sync(core.drive.clone(), scope, core.clone()));
|
||||
// The photos volume has its own event stream; without it the gallery only
|
||||
// learns about a phone-side deletion when the timeline goes stale.
|
||||
rt.spawn(run_photos_event_sync(core.clone()));
|
||||
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 root_uid = core.primary_root_uid.clone();
|
||||
if let Err(error) = std::thread::Builder::new()
|
||||
@@ -640,30 +696,11 @@ pub fn mount(
|
||||
// are delivered onto the async runtime; bridge them onto a sync channel so
|
||||
// the loop below can react without blocking a worker thread. A bounded
|
||||
// channel of 1 is enough — we only need to know that *a* stop arrived.
|
||||
let (sig_tx, sig_rx) = std::sync::mpsc::sync_channel::<()>(1);
|
||||
rt.spawn(async move {
|
||||
let mut sigterm =
|
||||
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "install SIGTERM handler failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut sigint =
|
||||
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt()) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "install SIGINT handler failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
tokio::select! {
|
||||
_ = sigterm.recv() => info!("received SIGTERM"),
|
||||
_ = sigint.recv() => info!("received SIGINT"),
|
||||
}
|
||||
let _ = sig_tx.try_send(());
|
||||
});
|
||||
let sig_rx = match host {
|
||||
Host::Daemon => signal_stop(&rt),
|
||||
#[cfg(test)]
|
||||
Host::Simulation(rx) => rx,
|
||||
};
|
||||
|
||||
// Wait for whichever happens first: a stop signal (→ we unmount ourselves
|
||||
// via the lazy MNT_DETACH path, clean even mid-download), or the kernel
|
||||
@@ -701,7 +738,7 @@ pub fn mount(
|
||||
};
|
||||
|
||||
// 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
|
||||
&& worker.join().is_err()
|
||||
{
|
||||
@@ -713,6 +750,7 @@ pub fn mount(
|
||||
warn!(id, error = %e, "unmount on-demand folder failed");
|
||||
}
|
||||
}
|
||||
core.queue_unreleased_writes();
|
||||
|
||||
// Last, because the workers may legitimately be finishing an upload, and
|
||||
// nothing they can still do is harmful once the mounts are down.
|
||||
@@ -720,9 +758,39 @@ pub fn mount(
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// How long teardown waits for the FUSE worker pool before proceeding without
|
||||
/// it. Long enough for an in-flight block to finish, short enough that
|
||||
/// `systemctl --user stop` still returns inside `TimeoutStopSec`.
|
||||
/// A channel that receives once SIGTERM or SIGINT arrives.
|
||||
fn signal_stop(rt: &tokio::runtime::Handle) -> std::sync::mpsc::Receiver<()> {
|
||||
let (sig_tx, sig_rx) = std::sync::mpsc::sync_channel::<()>(1);
|
||||
rt.spawn(async move {
|
||||
let mut sigterm =
|
||||
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "install SIGTERM handler failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut sigint =
|
||||
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt()) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "install SIGINT handler failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
tokio::select! {
|
||||
_ = sigterm.recv() => info!("received SIGTERM"),
|
||||
_ = sigint.recv() => info!("received SIGINT"),
|
||||
}
|
||||
let _ = sig_tx.try_send(());
|
||||
});
|
||||
sig_rx
|
||||
}
|
||||
|
||||
/// How long teardown waits for the background workers and then the FUSE worker
|
||||
/// pool before proceeding without them. Long enough for an in-flight block to
|
||||
/// finish, short enough that `systemctl --user stop` still returns inside
|
||||
/// `TimeoutStopSec`.
|
||||
const WORKER_JOIN_DEADLINE: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Signal every background loop, wake the ones that are blocked, and join them.
|
||||
@@ -730,14 +798,15 @@ const WORKER_JOIN_DEADLINE: Duration = Duration::from_secs(10);
|
||||
/// The mount used to return with its drain workers, sync engine, sweep, online
|
||||
/// probe, indexer and control listener all still running on `Core` clones. The
|
||||
/// process usually exited straight afterwards and hid it; an in-process remount
|
||||
/// did not, and started a second full set on top of the first (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
|
||||
/// block on their own condvar (`wake_drain`), and the control listener blocks
|
||||
/// inside `accept`, which only returns when a connection arrives — so it gets
|
||||
/// one, from us, after the flag is already set. A join that takes a while is
|
||||
/// expected and correct: a worker part-way through an upload finishes it rather
|
||||
/// than abandoning the user's bytes mid-flight.
|
||||
/// than abandoning the user's bytes mid-flight. Up to [`WORKER_JOIN_DEADLINE`]:
|
||||
/// past it, systemd kills the daemon anyway, and a queued op is sent again.
|
||||
fn stop_workers(core: &Core, control_socket: &Path, workers: Vec<std::thread::JoinHandle<()>>) {
|
||||
// Say so before the joins: a teardown that takes a while must not be read as
|
||||
// a missed watchdog ping and turned into a kill.
|
||||
@@ -751,25 +820,71 @@ fn stop_workers(core: &Core, control_socket: &Path, workers: Vec<std::thread::Jo
|
||||
// unlink cannot put it back to sleep.
|
||||
let _ = std::os::unix::net::UnixStream::connect(control_socket);
|
||||
let _ = std::fs::remove_file(control_socket);
|
||||
for worker in workers {
|
||||
if worker.join().is_err() {
|
||||
warn!("a background worker panicked before shutdown");
|
||||
}
|
||||
let deadline = Instant::now() + WORKER_JOIN_DEADLINE;
|
||||
let running = join_until(workers, deadline);
|
||||
if !running.is_empty() {
|
||||
warn!(
|
||||
running = running.join(","),
|
||||
"background workers did not finish in time; shutting down around them"
|
||||
);
|
||||
}
|
||||
// The FUSE worker pool last, and *before* the caller drops the tokio
|
||||
// runtime: a worker still inside a job when the runtime goes away panics on
|
||||
// its next timer with "A Tokio 1.x context was found, but it is being
|
||||
// shutdown" (seen 2026-09-18 09:58:35). Bounded, so a worker wedged on the
|
||||
// network cannot hold the stop the user asked for.
|
||||
core.workers.stop_and_join(WORKER_JOIN_DEADLINE);
|
||||
core.workers
|
||||
.stop_and_join(deadline.saturating_duration_since(Instant::now()));
|
||||
debug!("background workers stopped");
|
||||
}
|
||||
|
||||
/// Join `workers` until `deadline`, and name the ones still running then.
|
||||
///
|
||||
/// A stop used to wait for each one however long it took. The sync engine's
|
||||
/// mirror pass waits on Drive with no deadline, and a stop that waited for it
|
||||
/// was killed by systemd (`docs/BUGS.md` B165).
|
||||
fn join_until(workers: Vec<std::thread::JoinHandle<()>>, deadline: Instant) -> Vec<String> {
|
||||
let mut running = Vec::new();
|
||||
for worker in workers {
|
||||
while !worker.is_finished() && Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
if !worker.is_finished() {
|
||||
running.push(worker.thread().name().unwrap_or("unnamed").to_string());
|
||||
} else if worker.join().is_err() {
|
||||
warn!("a background worker panicked before shutdown");
|
||||
}
|
||||
}
|
||||
running
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::{SecondaryInsertRejection, SecondaryMountRegistry, 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]
|
||||
fn session_is_unroutable_before_teardown_starts() {
|
||||
|
||||
@@ -1038,7 +1038,7 @@ impl Core {
|
||||
}
|
||||
let outcomes = self
|
||||
.rt
|
||||
.block_on(self.client.trash_nodes(uids))
|
||||
.block_on(self.drive.trash_nodes(uids))
|
||||
.map_err(|e| CoreError::from_api(&e, "trash"))?;
|
||||
let (trashed, failed) = pdfs_core::batch::split(outcomes);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Reading and writing this machine's `profile.json` in its device root, and
|
||||
//! the restore that rebuilds a machine from one (features.md 5.2 and 5.3).
|
||||
//! the restore that rebuilds a machine from one.
|
||||
//!
|
||||
//! The document itself is defined in [`pdfs_core::profile`]; this is the half
|
||||
//! that talks to Drive. Saving is best-effort and never blocks a user action:
|
||||
@@ -101,7 +101,7 @@ impl Core {
|
||||
match existing {
|
||||
Some(uid) => self
|
||||
.rt
|
||||
.block_on(self.client.upload_new_revision_from(
|
||||
.block_on(self.drive.upload_new_revision_from(
|
||||
&uid,
|
||||
Cursor::new(bytes),
|
||||
len,
|
||||
@@ -112,7 +112,7 @@ impl Core {
|
||||
.map_err(|e| CoreError::from_api(&e, "upload profile revision"))?,
|
||||
None => {
|
||||
self.rt
|
||||
.block_on(self.client.upload_file_replacing_draft_from(
|
||||
.block_on(self.drive.upload_file_replacing_draft_from(
|
||||
&dir_uid,
|
||||
PROFILE_FILE_NAME,
|
||||
"application/json",
|
||||
@@ -182,14 +182,14 @@ impl Core {
|
||||
) -> CoreResult<Option<NodeUid>> {
|
||||
let uids = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_folder_children_node_uids(parent_uid))
|
||||
.block_on(self.drive.enumerate_folder_children_node_uids(parent_uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list device root"))?;
|
||||
if uids.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let nodes = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_nodes(&uids))
|
||||
.block_on(self.drive.enumerate_nodes(&uids))
|
||||
.map_err(|e| CoreError::from_api(&e, "resolve device root children"))?;
|
||||
Ok(nodes
|
||||
.into_iter()
|
||||
@@ -210,7 +210,7 @@ impl Core {
|
||||
let uid = self
|
||||
.rt
|
||||
.block_on(
|
||||
self.client
|
||||
self.drive
|
||||
.create_folder(root_uid, PROFILE_DIR_NAME, Some(now_secs())),
|
||||
)
|
||||
.map_err(|e| CoreError::from_api(&e, "create profile folder"))?;
|
||||
@@ -241,12 +241,12 @@ impl Core {
|
||||
};
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
self.rt
|
||||
.block_on(self.client.download_file_to(&uid, &mut buf))
|
||||
.block_on(self.drive.download_file_to(&uid, &mut buf))
|
||||
.map_err(|e| CoreError::from_api(&e, "download profile"))?;
|
||||
Profile::parse(&buf).map(Some).map_err(CoreError::invalid)
|
||||
}
|
||||
|
||||
// ---- restore (features.md 5.2) ----------------------------------------
|
||||
// ---- restore ----------------------------------------
|
||||
|
||||
/// The folders under a device that could be synced here, each carrying a
|
||||
/// proposed local path. `device` names another registered device by uid;
|
||||
@@ -277,13 +277,13 @@ impl Core {
|
||||
|
||||
let uids = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_folder_children_node_uids(&root_uid))
|
||||
.block_on(self.drive.enumerate_folder_children_node_uids(&root_uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list device root"))?;
|
||||
let nodes = if uids.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
self.rt
|
||||
.block_on(self.client.enumerate_nodes(&uids))
|
||||
.block_on(self.drive.enumerate_nodes(&uids))
|
||||
.map_err(|e| CoreError::from_api(&e, "resolve device root children"))?
|
||||
};
|
||||
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
//! The user's view of the pending-op queue: what has not reached the remote
|
||||
//! yet, why, and a way to stop waiting out a backoff.
|
||||
//!
|
||||
//! Read-only apart from "retry now", which only moves a row's next-attempt time.
|
||||
//! Nothing here drops a queued op: its staged blob is the only copy of the
|
||||
//! user's write (see `docs/RECOVERY.md`).
|
||||
//! Read-only apart from "retry now", which only moves a row's next-attempt time,
|
||||
//! "export", which copies a staged blob out, and "discard". Only a discard the
|
||||
//! user asks for drops a queued op: its staged blob is the only copy of the
|
||||
//! user's write (see `docs/RECOVERY.md`), so it is the user's call, made after
|
||||
//! an export or for a change with no content.
|
||||
|
||||
use super::*;
|
||||
use pdfs_core::control::PendingOpInfo;
|
||||
use std::io::Write;
|
||||
|
||||
use pdfs_core::control::{PendingOpInfo, SyncIssue};
|
||||
use pdfs_core::db::{FAILING_ATTEMPTS, PARK_UNTIL};
|
||||
|
||||
impl Core {
|
||||
@@ -16,10 +20,15 @@ impl Core {
|
||||
.db
|
||||
.pending_ops()
|
||||
.map_err(|e| CoreError::internal(format!("listing the queue: {e}")))?;
|
||||
let issues = self
|
||||
.db
|
||||
.op_issues()
|
||||
.map_err(|e| CoreError::internal(format!("listing sync issues: {e}")))?;
|
||||
Ok(ops
|
||||
.into_iter()
|
||||
.map(|op| {
|
||||
let parked = op.next_attempt_at >= PARK_UNTIL;
|
||||
let exportable = exportable(&op).is_some();
|
||||
let path = self
|
||||
.db
|
||||
.node_path(&op.uid)
|
||||
@@ -37,7 +46,9 @@ impl Core {
|
||||
queued_at: op.created_at / 1000,
|
||||
next_attempt_at: (!parked).then_some(op.next_attempt_at / 1000),
|
||||
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())
|
||||
@@ -57,4 +68,235 @@ impl Core {
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
+102
-41
@@ -36,6 +36,21 @@ pub(super) const REPAIR_MAX: u64 = 512 * 1024 * 1024;
|
||||
/// demand read in the SDK's flat block semaphore.
|
||||
pub(super) const PREFETCH_BUDGET: usize = 8;
|
||||
|
||||
/// How long one read may wait on the network before it fails with `EIO`.
|
||||
///
|
||||
/// The SDK bounds each request, not a read: a block that times out is retried,
|
||||
/// and a read can need several blocks, a block table and a repair. On a link
|
||||
/// that stalled without dropping, one `read` held a FUSE worker for 34 minutes
|
||||
/// and the application behind it with it (`docs/BUGS.md` B107). An error the
|
||||
/// caller can retry beats a process stuck in `D` state.
|
||||
pub(super) const READ_FETCH_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
|
||||
/// How long a read of blocks nothing has cached waits for a lost link to come
|
||||
/// back before failing. Long enough to ride out a Wi-Fi blip, short enough
|
||||
/// that a program reading while the laptop is really offline gets its `EIO`
|
||||
/// before the user gives up on it.
|
||||
const READ_OFFLINE_WAIT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Cap on tracked sequential streams. Only reads in progress matter, so the map
|
||||
/// is cleared wholesale rather than aged — losing the ramp costs one read.
|
||||
const MAX_PREFETCH_STREAMS: usize = 256;
|
||||
@@ -55,7 +70,7 @@ const MAX_OPEN_READERS: usize = 64;
|
||||
/// `(mtime, size)` — the same validity pair the content cache uses (a new
|
||||
/// revision bumps mtime). On a mismatch the reader is dropped and reopened.
|
||||
pub(super) struct CachedReader {
|
||||
reader: Arc<RevisionReader>,
|
||||
reader: Arc<dyn RevisionRead>,
|
||||
mtime: i64,
|
||||
size: u64,
|
||||
/// For LRU eviction.
|
||||
@@ -86,7 +101,7 @@ pub(super) struct PendingOpen {
|
||||
/// must not join this open — it would get a reader for the wrong bytes.
|
||||
mtime: i64,
|
||||
size: u64,
|
||||
rx: tokio::sync::watch::Receiver<Option<Result<Arc<RevisionReader>, Errno>>>,
|
||||
rx: tokio::sync::watch::Receiver<Option<Result<Arc<dyn RevisionRead>, Errno>>>,
|
||||
}
|
||||
|
||||
/// Distinguishes `PendingOpen`s. Only uniqueness matters, so a plain counter
|
||||
@@ -298,16 +313,16 @@ impl Core {
|
||||
uid: &NodeUid,
|
||||
mtime: i64,
|
||||
fsize: u64,
|
||||
) -> Result<Arc<RevisionReader>, Errno> {
|
||||
) -> Result<Arc<dyn RevisionRead>, Errno> {
|
||||
loop {
|
||||
// Decide under the lock, act outside it — network I/O must never run
|
||||
// under a std `Mutex`, and an await must never hold one.
|
||||
enum Act {
|
||||
Hit(Arc<RevisionReader>),
|
||||
Join(tokio::sync::watch::Receiver<Option<Result<Arc<RevisionReader>, Errno>>>),
|
||||
Hit(Arc<dyn RevisionRead>),
|
||||
Join(tokio::sync::watch::Receiver<Option<Result<Arc<dyn RevisionRead>, Errno>>>),
|
||||
Lead(
|
||||
u64,
|
||||
tokio::sync::watch::Sender<Option<Result<Arc<RevisionReader>, Errno>>>,
|
||||
tokio::sync::watch::Sender<Option<Result<Arc<dyn RevisionRead>, Errno>>>,
|
||||
),
|
||||
}
|
||||
let act = {
|
||||
@@ -361,9 +376,10 @@ impl Core {
|
||||
}
|
||||
Act::Lead(id, tx) => {
|
||||
debug!(%uid, mtime, fsize, "opening revision");
|
||||
let result = match self.client.open_revision(uid).await {
|
||||
Ok(reader) => Ok(Arc::new(reader)),
|
||||
let result = match self.drive.open_revision(uid).await {
|
||||
Ok(reader) => Ok(reader),
|
||||
Err(e) => {
|
||||
self.lost_link(&e, "open a revision");
|
||||
warn!(%uid, error = %e, "open_revision failed");
|
||||
Err(Errno::EIO)
|
||||
}
|
||||
@@ -448,7 +464,7 @@ impl Core {
|
||||
/// it, and the [`BLOCK_SIZE`] assumption when it has not — which is every
|
||||
/// first read, because learning it means opening a `RevisionReader`, and
|
||||
/// 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
|
||||
/// real sizes and clamps to what was asked for either way. It costs a
|
||||
@@ -477,15 +493,20 @@ impl Core {
|
||||
) -> Result<Vec<u8>, Errno> {
|
||||
// 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
|
||||
// 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() {
|
||||
return self.read_pending(&pending, offset, len);
|
||||
}
|
||||
// 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
|
||||
// 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) {
|
||||
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)
|
||||
}
|
||||
@@ -552,7 +573,7 @@ impl Core {
|
||||
/// leader's claim is gone by then, so this cannot recurse.
|
||||
async fn fetch_block(
|
||||
&self,
|
||||
reader: &Arc<RevisionReader>,
|
||||
reader: &Arc<dyn RevisionRead>,
|
||||
uid: &NodeUid,
|
||||
mtime: i64,
|
||||
fsize: u64,
|
||||
@@ -615,10 +636,10 @@ impl Core {
|
||||
///
|
||||
/// 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
|
||||
/// 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(
|
||||
&self,
|
||||
reader: &Arc<RevisionReader>,
|
||||
reader: &Arc<dyn RevisionRead>,
|
||||
uid: &NodeUid,
|
||||
mtime: i64,
|
||||
fsize: u64,
|
||||
@@ -630,7 +651,7 @@ impl Core {
|
||||
// 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
|
||||
// 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 {
|
||||
warn!(
|
||||
%uid, bidx = span.idx, fsize,
|
||||
@@ -643,12 +664,13 @@ impl Core {
|
||||
// Learned here rather than on a path of its own: this is where a
|
||||
// reader is open anyway, and recording the real geometry is what
|
||||
// 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 {
|
||||
self.cache
|
||||
.store_block_geometry(uid, mtime, fsize, reader.block_sizes());
|
||||
}
|
||||
let bytes = reader.read_at(span.start, span.len).await.map_err(|e| {
|
||||
self.lost_link(&e, "read a block");
|
||||
warn!(%uid, start = span.start, len = span.len, error = %e, "block read failed");
|
||||
Errno::EIO
|
||||
})?;
|
||||
@@ -684,7 +706,7 @@ impl Core {
|
||||
/// The range path derives every block boundary from the revision's recorded
|
||||
/// block sizes. When those understate the file, reads come back short with
|
||||
/// 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
|
||||
/// 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
|
||||
@@ -761,7 +783,7 @@ impl Core {
|
||||
error!(%uid, %error, "creating a scratch file for a repair download failed");
|
||||
Errno::EIO
|
||||
})?;
|
||||
let outcome = self.client.download_file_to(uid, &mut file).await;
|
||||
let outcome = self.drive.download_file_to(uid, &mut file).await;
|
||||
let stored = (|| {
|
||||
outcome.map_err(|error| {
|
||||
warn!(%uid, %error, "repair download failed");
|
||||
@@ -896,7 +918,7 @@ impl Core {
|
||||
let end = offset.saturating_add(len).min(fsize);
|
||||
let mut out = Vec::with_capacity((end - offset) as usize);
|
||||
// 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 (Some(&head), Some(&tail)) = (wanted.first(), wanted.last()) else {
|
||||
return Ok(out);
|
||||
@@ -939,28 +961,67 @@ impl Core {
|
||||
}
|
||||
|
||||
if !misses.is_empty() {
|
||||
let fetched = self.rt.block_on(async {
|
||||
// Resolve the file's keys and block table once, then read every
|
||||
// missing block through the shared reader. Previously each block
|
||||
// called `download_range`, which redid that resolution per block.
|
||||
let reader = self.revision_reader(uid, mtime, fsize).await?;
|
||||
// One deadline for the whole read, including any wait for a lost
|
||||
// link. A fetch that failed because the link went down is tried
|
||||
// once more when it is back, so a blip is a slow read, not an `EIO`.
|
||||
let deadline = Instant::now() + READ_FETCH_TIMEOUT;
|
||||
let mut retried = false;
|
||||
let fetched = loop {
|
||||
if !self.is_online() {
|
||||
let wait =
|
||||
READ_OFFLINE_WAIT.min(deadline.saturating_duration_since(Instant::now()));
|
||||
if !self.wait_online(wait) {
|
||||
debug!(%uid, offset, len, "read of uncached blocks while offline; failing it");
|
||||
return Err(Errno::EIO);
|
||||
}
|
||||
}
|
||||
let fetch = async {
|
||||
// Resolve the file's keys and block table once, then read every
|
||||
// missing block through the shared reader. Previously each block
|
||||
// called `download_range`, which redid that resolution per block.
|
||||
let reader = self.revision_reader(uid, mtime, fsize).await?;
|
||||
|
||||
let mut set = tokio::task::JoinSet::new();
|
||||
for &span in &misses {
|
||||
let (core, reader, uid) = (self.clone(), reader.clone(), uid.clone());
|
||||
set.spawn(async move {
|
||||
core.fetch_block(&reader, &uid, mtime, fsize, span, cache_blocks)
|
||||
.await
|
||||
.map(|bytes| (span.idx, bytes))
|
||||
});
|
||||
let mut set = tokio::task::JoinSet::new();
|
||||
for &span in &misses {
|
||||
let (core, reader, uid) = (self.clone(), reader.clone(), uid.clone());
|
||||
set.spawn(async move {
|
||||
core.fetch_block(&reader, &uid, mtime, fsize, span, cache_blocks)
|
||||
.await
|
||||
.map(|bytes| (span.idx, bytes))
|
||||
});
|
||||
}
|
||||
let mut out = Vec::with_capacity(misses.len());
|
||||
while let Some(joined) = set.join_next().await {
|
||||
// A join error means the task panicked; surface it as EIO.
|
||||
out.push(joined.map_err(|_| Errno::EIO)??);
|
||||
}
|
||||
Ok::<_, Errno>(out)
|
||||
};
|
||||
// Dropping the fetch on the deadline aborts the block tasks with
|
||||
// it; blocks that already landed stay cached for the retry. The
|
||||
// timer is built inside the runtime: a FUSE worker has no reactor
|
||||
// of its own, and building it out here panicked every remote read.
|
||||
let budget = deadline.saturating_duration_since(Instant::now());
|
||||
match self
|
||||
.rt
|
||||
.block_on(async { tokio::time::timeout(budget, fetch).await })
|
||||
{
|
||||
Ok(Ok(fetched)) => break fetched,
|
||||
// The fetch saw the link go down; the top of the loop waits
|
||||
// for it within what is left of the deadline.
|
||||
Ok(Err(_)) if !retried && !self.is_online() => retried = true,
|
||||
Ok(Err(e)) => return Err(e),
|
||||
Err(_) => {
|
||||
warn!(
|
||||
%uid, offset, len, secs = READ_FETCH_TIMEOUT.as_secs(),
|
||||
"read got no answer from the network in time; failing it"
|
||||
);
|
||||
self.evict_reader(uid);
|
||||
self.mark_offline("read", &crate::link::no_answer(READ_FETCH_TIMEOUT));
|
||||
return Err(Errno::EIO);
|
||||
}
|
||||
}
|
||||
let mut out = Vec::with_capacity(misses.len());
|
||||
while let Some(joined) = set.join_next().await {
|
||||
// A join error means the task panicked; surface it as EIO.
|
||||
out.push(joined.map_err(|_| Errno::EIO)??);
|
||||
}
|
||||
Ok::<_, Errno>(out)
|
||||
})?;
|
||||
};
|
||||
for (bidx, bytes) in fetched {
|
||||
blocks[(bidx - first) as usize] = Some(bytes);
|
||||
}
|
||||
@@ -977,7 +1038,7 @@ impl Core {
|
||||
// — the cache validates it on read, the network path in `read_block`
|
||||
// — so this refuses rather than quietly contributing fewer bytes
|
||||
// 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 {
|
||||
error!(
|
||||
%uid, bidx = span.idx, len = block.len(), expected = span.len, fsize,
|
||||
|
||||
@@ -248,10 +248,7 @@ impl Core {
|
||||
|
||||
let outcomes = self
|
||||
.rt
|
||||
.block_on(
|
||||
self.client
|
||||
.trash_nodes(std::slice::from_ref(&candidate.uid)),
|
||||
)
|
||||
.block_on(self.drive.trash_nodes(std::slice::from_ref(&candidate.uid)))
|
||||
.map_err(|e| CoreError::from_api(&e, "trash the mis-dated original"))?;
|
||||
if let Some((_, Err(e))) = outcomes.into_iter().next() {
|
||||
return Err(CoreError::from_api(&e, "trash the mis-dated original"));
|
||||
|
||||
@@ -0,0 +1,611 @@
|
||||
//! Moving a file or folder between two locations without moving its content.
|
||||
//!
|
||||
//! `~/ProtonDrive` and every on-demand synced folder are separate FUSE
|
||||
//! sessions, and a mirror folder is a plain local directory. The kernel refuses
|
||||
//! `rename(2)` between two mounts with `EXDEV` before the daemon hears of it, so
|
||||
//! `mv` falls back to copy and delete: every file is downloaded, uploaded again,
|
||||
//! and the original is trashed.
|
||||
//!
|
||||
//! On Proton Drive the same move is one metadata change, because My files and
|
||||
//! this computer's device folders all live on the main volume. So a move
|
||||
//! request that names two absolute paths is resolved here, against every
|
||||
//! location this daemon knows, and done as a server-side move. The local side
|
||||
//! is then brought level: a mount forgets the node and re-lists, and a mirror
|
||||
//! folder either renames its copy along or drops it once Drive has it.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use pdfs_core::db::{StoredSyncEntry, StoredSyncFolder};
|
||||
use pdfs_core::{CoreError, CoreResult};
|
||||
use proton_drive_rs::NodeKind;
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use super::sync::mirror_subtree_unsynced;
|
||||
use super::{Core, is_local_uid, parse_uid};
|
||||
|
||||
/// How long a move waits for a mirror folder's sync pass to finish before it
|
||||
/// reports the folder busy. A pass can run for hours on a first sync, so the
|
||||
/// move does not wait it out.
|
||||
const MIRROR_LOCK_WAIT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Where one side of a move lives.
|
||||
enum Place {
|
||||
/// My files or an on-demand folder: a live FUSE mount. `core` is rooted at
|
||||
/// that mount and `rel` is relative to its mountpoint.
|
||||
Mounted { core: Box<Core>, rel: PathBuf },
|
||||
/// A mirror folder: a local directory kept in step by the sync engine.
|
||||
/// `rel` is `/`-separated, relative to the folder, as the baseline keys it.
|
||||
Mirror {
|
||||
folder: StoredSyncFolder,
|
||||
rel: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Place {
|
||||
fn mirror_id(&self) -> Option<i64> {
|
||||
match self {
|
||||
Place::Mirror { folder, .. } => Some(folder.id),
|
||||
Place::Mounted { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_root(&self) -> bool {
|
||||
match self {
|
||||
Place::Mounted { rel, .. } => rel.as_os_str().is_empty(),
|
||||
Place::Mirror { rel, .. } => rel.is_empty(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The mirror folder that holds `abs`, and `abs` relative to it. The deepest
|
||||
/// folder wins, although folders are not expected to nest.
|
||||
fn mirror_covering(
|
||||
folders: Vec<StoredSyncFolder>,
|
||||
abs: &Path,
|
||||
) -> Option<(StoredSyncFolder, String)> {
|
||||
let folder = folders
|
||||
.into_iter()
|
||||
.filter(|f| f.mode == "mirror" && abs.starts_with(&f.local_path))
|
||||
.max_by_key(|f| Path::new(&f.local_path).components().count())?;
|
||||
let rel = abs.strip_prefix(&folder.local_path).ok()?;
|
||||
let parts = rel
|
||||
.components()
|
||||
.map(|c| match c {
|
||||
Component::Normal(part) => part.to_str(),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
Some((folder, parts.join("/")))
|
||||
}
|
||||
|
||||
/// `name` inside the folder at `parent`, in baseline form.
|
||||
fn join_rel(parent: &str, name: &str) -> String {
|
||||
if parent.is_empty() {
|
||||
name.to_string()
|
||||
} else {
|
||||
format!("{parent}/{name}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Rename `from` to `to`, failing with `EEXIST` rather than replacing
|
||||
/// whatever appeared at `to` since it was checked. A filesystem that cannot
|
||||
/// promise that gets a plain rename after one more look.
|
||||
fn rename_noreplace(from: &Path, to: &Path) -> std::io::Result<()> {
|
||||
use std::ffi::CString;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
|
||||
let cstr = |p: &Path| {
|
||||
CString::new(p.as_os_str().as_bytes())
|
||||
.map_err(|_| std::io::Error::from_raw_os_error(libc::EINVAL))
|
||||
};
|
||||
let (c_from, c_to) = (cstr(from)?, cstr(to)?);
|
||||
// SAFETY: both are valid NUL-terminated paths that outlive the call.
|
||||
let rc = unsafe {
|
||||
libc::syscall(
|
||||
libc::SYS_renameat2,
|
||||
libc::AT_FDCWD,
|
||||
c_from.as_ptr(),
|
||||
libc::AT_FDCWD,
|
||||
c_to.as_ptr(),
|
||||
libc::RENAME_NOREPLACE,
|
||||
)
|
||||
};
|
||||
if rc == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let err = std::io::Error::last_os_error();
|
||||
match err.raw_os_error() {
|
||||
Some(libc::EINVAL | libc::ENOSYS) => {
|
||||
if std::fs::symlink_metadata(to).is_ok() {
|
||||
return Err(std::io::Error::from_raw_os_error(libc::EEXIST));
|
||||
}
|
||||
std::fs::rename(from, to)
|
||||
}
|
||||
_ => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `uid` is `root` or lies under it, as far as the nodes table knows.
|
||||
fn under(core: &Core, root: &NodeUid, uid: &str) -> bool {
|
||||
let root = root.to_string();
|
||||
uid == root || matches!(core.db.path_relative_to(&root, uid), Ok(Some(_)))
|
||||
}
|
||||
|
||||
impl Core {
|
||||
/// Move the file or folder at `src` into the folder at `dest_parent`. Both
|
||||
/// are absolute local paths and may be in different locations: My files,
|
||||
/// an on-demand folder, or a mirror folder. Returns the moved name.
|
||||
///
|
||||
/// Within one mount this is [`Core::move_to`]. Across locations it
|
||||
/// is a server-side move followed by local bookkeeping; see the module
|
||||
/// documentation. Anything the move could lose is refused up front.
|
||||
pub(crate) fn move_between(&self, src: &Path, dest_parent: &Path) -> CoreResult<String> {
|
||||
let source = self.locate(src)?;
|
||||
let dest = self.locate(dest_parent)?;
|
||||
if let (
|
||||
Place::Mounted { core, rel },
|
||||
Place::Mounted {
|
||||
core: dest_core,
|
||||
rel: dest_rel,
|
||||
},
|
||||
) = (&source, &dest)
|
||||
&& Arc::ptr_eq(&core.root, &dest_core.root)
|
||||
{
|
||||
return core.move_to(rel, dest_rel);
|
||||
}
|
||||
if source.is_root() {
|
||||
return Err(CoreError::invalid(format!(
|
||||
"{} is the root of a synced location and cannot be moved",
|
||||
src.display()
|
||||
)));
|
||||
}
|
||||
if dest_parent.starts_with(src) {
|
||||
return Err(CoreError::invalid("cannot move a folder into itself"));
|
||||
}
|
||||
let name = src
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.ok_or_else(|| CoreError::invalid(format!("{} has no usable name", src.display())))?
|
||||
.to_string();
|
||||
|
||||
// Hold every mirror folder involved, lowest id first, so no sync pass
|
||||
// sees the move half done and two moves cannot deadlock.
|
||||
let mut ids: Vec<i64> = [source.mirror_id(), dest.mirror_id()]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
let locks: Vec<Arc<Mutex<()>>> = ids.iter().map(|&id| self.sync_lock(id)).collect();
|
||||
let mut guards = Vec::with_capacity(locks.len());
|
||||
for lock in &locks {
|
||||
guards.push(lock.try_lock_for(MIRROR_LOCK_WAIT).ok_or_else(|| {
|
||||
CoreError::conflict("a synced folder is busy syncing; try again when it is done")
|
||||
})?);
|
||||
}
|
||||
|
||||
let mut baselines: HashMap<i64, HashMap<String, StoredSyncEntry>> = HashMap::new();
|
||||
for &id in &ids {
|
||||
let baseline = self
|
||||
.db
|
||||
.sync_entries(id)
|
||||
.map_err(|e| CoreError::internal(format!("db: {e}")))?;
|
||||
baselines.insert(id, baseline);
|
||||
}
|
||||
|
||||
let src_uid = self.source_uid(&source, &baselines)?;
|
||||
let dest_uid = self.dest_uid(&dest, &baselines, &name)?;
|
||||
if src_uid.volume_id != dest_uid.volume_id {
|
||||
return Err(CoreError::invalid(
|
||||
"the two locations are on different Proton Drive volumes; copy it instead",
|
||||
));
|
||||
}
|
||||
if under(self, &src_uid, &dest_uid.to_string()) {
|
||||
return Err(CoreError::invalid("cannot move a folder into itself"));
|
||||
}
|
||||
|
||||
// Mirror to mirror renames the local copy along, so it needs nothing
|
||||
// proven about it. Another filesystem makes that impossible, and the
|
||||
// pair then drops the source copy like any other move out of a mirror.
|
||||
let mut renamed = None;
|
||||
if let (
|
||||
Place::Mirror { folder, rel },
|
||||
Place::Mirror {
|
||||
folder: dest_folder,
|
||||
rel: dest_rel,
|
||||
},
|
||||
) = (&source, &dest)
|
||||
{
|
||||
let from = src.to_path_buf();
|
||||
let to = dest_parent.join(&name);
|
||||
match rename_noreplace(&from, &to) {
|
||||
Ok(()) => renamed = Some((from, to)),
|
||||
Err(e) if e.raw_os_error() == Some(libc::EXDEV) => {
|
||||
info!(from = %folder.local_path, to = %dest_folder.local_path,
|
||||
"move: mirror folders are on different filesystems");
|
||||
}
|
||||
Err(e) if e.raw_os_error() == Some(libc::EEXIST) => {
|
||||
return Err(CoreError::conflict(format!(
|
||||
"{name} already exists in {}",
|
||||
dest_parent.display()
|
||||
)));
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(CoreError::internal(format!(
|
||||
"could not move {} to {}: {e}",
|
||||
rel,
|
||||
join_rel(dest_rel, &name)
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if renamed.is_none()
|
||||
&& let Place::Mirror { folder, rel } = &source
|
||||
&& let Some(reason) =
|
||||
mirror_subtree_unsynced(Path::new(&folder.local_path), rel, &baselines[&folder.id])
|
||||
{
|
||||
return Err(CoreError::conflict(format!(
|
||||
"{name} is not fully synced yet ({reason}); wait for the sync to finish"
|
||||
)));
|
||||
}
|
||||
|
||||
if let Err(e) = self.move_remote(&src_uid, &dest_uid) {
|
||||
if let Some((from, to)) = &renamed
|
||||
&& let Err(back) = std::fs::rename(to, from)
|
||||
{
|
||||
warn!(from = %to.display(), to = %from.display(), error = %back,
|
||||
"move: could not put the local copy back after the move failed");
|
||||
}
|
||||
return Err(CoreError::from_api(&e, "move"));
|
||||
}
|
||||
|
||||
self.settle_source(
|
||||
&source,
|
||||
&src_uid,
|
||||
renamed.is_some(),
|
||||
&dest,
|
||||
&dest_uid,
|
||||
&name,
|
||||
src,
|
||||
);
|
||||
self.settle_dest(&dest, &dest_uid, &name);
|
||||
drop(guards);
|
||||
if renamed.is_none()
|
||||
&& let Place::Mirror { folder, .. } = &dest
|
||||
{
|
||||
// Nothing local exists yet; the folder's next pass downloads it.
|
||||
self.sync_now(Some(folder.id));
|
||||
}
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
/// Which location `abs` is in. A mount is looked for first: a mirror folder
|
||||
/// cannot live inside a FUSE mount, and a mount is what the kernel sees.
|
||||
fn locate(&self, abs: &Path) -> CoreResult<Place> {
|
||||
if let Some((core, rel)) = self.rooted_at(abs) {
|
||||
if !rel
|
||||
.components()
|
||||
.all(|c| matches!(c, Component::Normal(_) | Component::CurDir))
|
||||
{
|
||||
return Err(CoreError::invalid(format!(
|
||||
"{} escapes its location",
|
||||
abs.display()
|
||||
)));
|
||||
}
|
||||
return Ok(Place::Mounted {
|
||||
core: Box::new(core),
|
||||
rel,
|
||||
});
|
||||
}
|
||||
let folders = self
|
||||
.db
|
||||
.sync_folder_list()
|
||||
.map_err(|e| CoreError::internal(format!("db: {e}")))?;
|
||||
mirror_covering(folders, abs)
|
||||
.map(|(folder, rel)| Place::Mirror { folder, rel })
|
||||
.ok_or_else(|| {
|
||||
CoreError::invalid(format!(
|
||||
"{} is not in a Proton Drive location",
|
||||
abs.display()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// The node being moved, once everything that must hold before it leaves
|
||||
/// its location has been checked.
|
||||
fn source_uid(
|
||||
&self,
|
||||
source: &Place,
|
||||
baselines: &HashMap<i64, HashMap<String, StoredSyncEntry>>,
|
||||
) -> CoreResult<NodeUid> {
|
||||
match source {
|
||||
Place::Mounted { core, rel } => {
|
||||
let (ino, uid) = core.resolve(rel)?;
|
||||
if is_local_uid(&uid) {
|
||||
return Err(CoreError::conflict(
|
||||
"it is not uploaded yet; wait for the upload to finish",
|
||||
));
|
||||
}
|
||||
let old_parent = core.source_parent_uid(ino, rel)?;
|
||||
for authority in [&uid, &old_parent] {
|
||||
core.require_uid_writable(authority)
|
||||
.map_err(|e| core.errno_error(e, "move access"))?;
|
||||
}
|
||||
if self.has_pending_work_under(&uid)? {
|
||||
return Err(CoreError::conflict(
|
||||
"it has changes that are not uploaded yet; wait for the upload to finish",
|
||||
));
|
||||
}
|
||||
Ok(uid)
|
||||
}
|
||||
Place::Mirror { folder, rel } => baselines[&folder.id]
|
||||
.get(rel)
|
||||
.and_then(|entry| entry.remote_uid.as_deref())
|
||||
.and_then(parse_uid)
|
||||
.ok_or_else(|| {
|
||||
CoreError::conflict(
|
||||
"it is not on Proton Drive yet; wait for the sync to finish",
|
||||
)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// The folder the node moves into. Refuses one that already holds `name`
|
||||
/// on this disk; Drive refuses a clash on its side by itself.
|
||||
fn dest_uid(
|
||||
&self,
|
||||
dest: &Place,
|
||||
baselines: &HashMap<i64, HashMap<String, StoredSyncEntry>>,
|
||||
name: &str,
|
||||
) -> CoreResult<NodeUid> {
|
||||
let uid = match dest {
|
||||
Place::Mounted { core, rel } => {
|
||||
let (ino, uid) = core.resolve(rel)?;
|
||||
let is_folder = core
|
||||
.state()
|
||||
.entries
|
||||
.get(&ino)
|
||||
.is_some_and(|entry| matches!(entry.node.kind, NodeKind::Folder));
|
||||
if !is_folder {
|
||||
return Err(CoreError::invalid("the destination is not a folder"));
|
||||
}
|
||||
core.require_uid_writable(&uid)
|
||||
.map_err(|e| core.errno_error(e, "move access"))?;
|
||||
uid
|
||||
}
|
||||
Place::Mirror { folder, rel } => {
|
||||
let local = Path::new(&folder.local_path).join(rel);
|
||||
if !local.is_dir() {
|
||||
return Err(CoreError::invalid("the destination is not a folder"));
|
||||
}
|
||||
if std::fs::symlink_metadata(local.join(name)).is_ok() {
|
||||
return Err(CoreError::conflict(format!(
|
||||
"{name} already exists in the destination"
|
||||
)));
|
||||
}
|
||||
let uid = if rel.is_empty() {
|
||||
Some(folder.remote_uid.as_str())
|
||||
} else {
|
||||
baselines[&folder.id]
|
||||
.get(rel)
|
||||
.and_then(|entry| entry.remote_uid.as_deref())
|
||||
};
|
||||
uid.and_then(parse_uid).ok_or_else(|| {
|
||||
CoreError::conflict(
|
||||
"the destination is not on Proton Drive yet; wait for the sync to finish",
|
||||
)
|
||||
})?
|
||||
}
|
||||
};
|
||||
if is_local_uid(&uid) {
|
||||
return Err(CoreError::conflict(
|
||||
"the destination is not uploaded yet; wait for the upload to finish",
|
||||
));
|
||||
}
|
||||
Ok(uid)
|
||||
}
|
||||
|
||||
/// Whether the queue owes Drive anything for `uid` or a node under it, or a
|
||||
/// file under it is open for writing. Moving such a node away would strand
|
||||
/// the pending change against a path that no longer leads to it.
|
||||
fn has_pending_work_under(&self, uid: &NodeUid) -> CoreResult<bool> {
|
||||
let ops = self
|
||||
.db
|
||||
.pending_ops()
|
||||
.map_err(|e| CoreError::internal(format!("db: {e}")))?;
|
||||
if ops.iter().any(|op| {
|
||||
under(self, uid, &op.uid)
|
||||
|| op
|
||||
.parent_uid
|
||||
.as_deref()
|
||||
.is_some_and(|p| under(self, uid, p))
|
||||
}) {
|
||||
return Ok(true);
|
||||
}
|
||||
// Collected first so no State lock is held across the nodes-table reads.
|
||||
let mut writing = Vec::new();
|
||||
self.for_each_state(|st| {
|
||||
writing.extend(st.active_writes.values().map(|w| w.uid.to_string()));
|
||||
});
|
||||
Ok(writing.iter().any(|w| under(self, uid, w)))
|
||||
}
|
||||
|
||||
/// Bring the source location level with a move Drive has made.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn settle_source(
|
||||
&self,
|
||||
source: &Place,
|
||||
uid: &NodeUid,
|
||||
renamed: bool,
|
||||
dest: &Place,
|
||||
dest_uid: &NodeUid,
|
||||
name: &str,
|
||||
src: &Path,
|
||||
) {
|
||||
match source {
|
||||
Place::Mounted { core, rel } => {
|
||||
// Moved everywhere, so no mount keeps serving it under the old
|
||||
// path and its row stays (`docs/BUGS.md` B117). The content
|
||||
// cache is keyed by uid and stays valid.
|
||||
self.relocate_everywhere(uid, dest_uid, name);
|
||||
core.invalidate_parent_listing(rel);
|
||||
}
|
||||
Place::Mirror { folder, rel } if renamed => {
|
||||
let Place::Mirror {
|
||||
folder: dest_folder,
|
||||
rel: dest_rel,
|
||||
} = dest
|
||||
else {
|
||||
unreachable!("a local rename needs a mirror destination");
|
||||
};
|
||||
if let Err(e) = self.db.sync_entries_move(
|
||||
folder.id,
|
||||
rel,
|
||||
dest_folder.id,
|
||||
&join_rel(dest_rel, name),
|
||||
) {
|
||||
// The next passes see a copy with no baseline on one side
|
||||
// and a baseline with no copy on the other. That uploads
|
||||
// the files again and trashes the moved node, but loses
|
||||
// nothing.
|
||||
warn!(error = ?e, "move: could not carry the sync baseline along");
|
||||
}
|
||||
}
|
||||
Place::Mirror { folder, rel } => {
|
||||
// Drive had all of it when the move was checked, so the local
|
||||
// copy is only a stale duplicate now. Something written into it
|
||||
// since would go with it, so look again first; if anything is
|
||||
// new, the copy stays and the next pass uploads it as new.
|
||||
let changed = match self.db.sync_entries(folder.id) {
|
||||
Ok(baseline) => {
|
||||
mirror_subtree_unsynced(Path::new(&folder.local_path), rel, &baseline)
|
||||
}
|
||||
Err(e) => Some(format!("db: {e}")),
|
||||
};
|
||||
if let Err(e) = self.db.sync_entries_remove_subtree(folder.id, rel) {
|
||||
warn!(error = ?e, "move: could not drop the sync baseline");
|
||||
}
|
||||
if let Some(reason) = changed {
|
||||
warn!(path = %src.display(), reason,
|
||||
"move: the local copy changed during the move; keeping it");
|
||||
return;
|
||||
}
|
||||
let removed = if src.is_dir() {
|
||||
std::fs::remove_dir_all(src)
|
||||
} else {
|
||||
std::fs::remove_file(src)
|
||||
};
|
||||
if let Err(e) = removed {
|
||||
warn!(path = %src.display(), error = %e,
|
||||
"move: could not remove the local copy after the move");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bring the destination location level with a move Drive has made.
|
||||
fn settle_dest(&self, dest: &Place, uid: &NodeUid, name: &str) {
|
||||
if let Place::Mounted { .. } = dest {
|
||||
self.invalidate_children_of(uid);
|
||||
// A lookup that missed before the move may be cached as absent.
|
||||
self.for_each_mount(|st, notify| {
|
||||
if let Some(&ino) = st.by_uid.get(uid) {
|
||||
notify.inval_entry(ino, name.to_string());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn folder(id: i64, local_path: &str, mode: &str) -> StoredSyncFolder {
|
||||
StoredSyncFolder {
|
||||
id,
|
||||
local_path: local_path.to_string(),
|
||||
remote_uid: format!("vol~root{id}"),
|
||||
remote_share_id: String::new(),
|
||||
mode: mode.to_string(),
|
||||
pending_mode: None,
|
||||
state: "idle".to_string(),
|
||||
last_sync: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_covering_finds_the_mirror_folder_and_the_path_inside_it() {
|
||||
let folders = vec![folder(1, "/home/u/Docs", "mirror")];
|
||||
let (found, rel) = mirror_covering(folders, Path::new("/home/u/Docs/a/b.txt")).unwrap();
|
||||
assert_eq!(found.id, 1);
|
||||
assert_eq!(rel, "a/b.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_covering_gives_an_empty_path_for_the_folder_itself() {
|
||||
let folders = vec![folder(1, "/home/u/Docs", "mirror")];
|
||||
let (_, rel) = mirror_covering(folders, Path::new("/home/u/Docs")).unwrap();
|
||||
assert_eq!(rel, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_covering_prefers_the_deepest_folder() {
|
||||
let folders = vec![
|
||||
folder(1, "/home/u/Docs", "mirror"),
|
||||
folder(2, "/home/u/Docs/Work", "mirror"),
|
||||
];
|
||||
let (found, rel) = mirror_covering(folders, Path::new("/home/u/Docs/Work/x")).unwrap();
|
||||
assert_eq!(found.id, 2);
|
||||
assert_eq!(rel, "x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_covering_skips_on_demand_folders_and_name_prefixed_siblings() {
|
||||
let folders = || {
|
||||
vec![
|
||||
folder(1, "/home/u/Docs", "mirror"),
|
||||
folder(2, "/home/u/Media", "ondemand"),
|
||||
]
|
||||
};
|
||||
assert!(mirror_covering(folders(), Path::new("/home/u/Media/x")).is_none());
|
||||
assert!(mirror_covering(folders(), Path::new("/home/u/Docs2/x")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_covering_refuses_a_path_that_climbs_out() {
|
||||
let folders = vec![folder(1, "/home/u/Docs", "mirror")];
|
||||
assert!(mirror_covering(folders, Path::new("/home/u/Docs/../etc")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_rel_names_a_child_of_the_folder_root_without_a_slash() {
|
||||
assert_eq!(join_rel("", "a"), "a");
|
||||
assert_eq!(join_rel("x/y", "a"), "x/y/a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_local_rename_never_replaces_what_is_already_there() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfs-relocate-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (a, b, c) = (dir.join("a"), dir.join("b"), dir.join("c"));
|
||||
std::fs::write(&a, b"moved").unwrap();
|
||||
std::fs::write(&b, b"kept").unwrap();
|
||||
|
||||
let err = rename_noreplace(&a, &b).unwrap_err();
|
||||
assert_eq!(err.raw_os_error(), Some(libc::EEXIST));
|
||||
assert_eq!(std::fs::read(&b).unwrap(), b"kept");
|
||||
assert!(a.exists());
|
||||
|
||||
rename_noreplace(&a, &c).unwrap();
|
||||
assert!(!a.exists());
|
||||
assert_eq!(std::fs::read(&c).unwrap(), b"moved");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -232,7 +232,7 @@ impl Core {
|
||||
let mut out = std::io::BufWriter::new(file);
|
||||
let written = match self
|
||||
.rt
|
||||
.block_on(self.client.download_revision_to(uid, revision_id, &mut out))
|
||||
.block_on(self.drive.download_revision_to(uid, revision_id, &mut out))
|
||||
.map_err(|e| CoreError::from_api(&e, "download revision"))
|
||||
.and_then(|()| {
|
||||
use std::io::Write as _;
|
||||
|
||||
@@ -21,7 +21,7 @@ use proton_drive_rs::{MemberRole, NodeKind};
|
||||
|
||||
use proton_drive_rs::Node;
|
||||
|
||||
use super::{Core, ROOT_INO, node_size, parse_uid, public_link_info, role_from_str, role_to_str};
|
||||
use super::{Core, node_size, parse_uid, public_link_info, role_from_str, role_to_str};
|
||||
|
||||
/// A node from a *shared* listing as a [`DirEntry`].
|
||||
///
|
||||
@@ -75,6 +75,7 @@ fn shared_entry_of(core: &Core, n: Node) -> DirEntry {
|
||||
shared_by_unverified,
|
||||
trashed_at: 0,
|
||||
trashed_from: None,
|
||||
issue: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,7 +464,7 @@ impl Core {
|
||||
}
|
||||
let nodes = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_nodes(&uids))
|
||||
.block_on(self.drive.enumerate_nodes(&uids))
|
||||
.map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?;
|
||||
Ok(nodes
|
||||
.into_iter()
|
||||
@@ -481,14 +482,14 @@ impl Core {
|
||||
parse_uid(uid).ok_or_else(|| CoreError::invalid(format!("invalid uid: {uid}")))?;
|
||||
let child_uids = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_folder_children_node_uids(&uid))
|
||||
.block_on(self.drive.enumerate_folder_children_node_uids(&uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "enumerate shared children"))?;
|
||||
if child_uids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let nodes = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_nodes(&child_uids))
|
||||
.block_on(self.drive.enumerate_nodes(&child_uids))
|
||||
.map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?;
|
||||
Ok(nodes
|
||||
.into_iter()
|
||||
@@ -509,7 +510,7 @@ impl Core {
|
||||
parse_uid(uid).ok_or_else(|| CoreError::invalid(format!("invalid uid: {uid}")))?;
|
||||
let node = self
|
||||
.rt
|
||||
.block_on(self.client.get_node(&uid))
|
||||
.block_on(self.drive.get_node(&uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "get shared node"))?
|
||||
.ok_or_else(|| CoreError::not_found("shared node not found"))?;
|
||||
if !node.is_file() {
|
||||
@@ -610,10 +611,10 @@ impl Core {
|
||||
/// Proton products (not Drive-only). A remote round-trip; nothing here is
|
||||
/// cached.
|
||||
pub(crate) fn account_quota(&self) -> CoreResult<(i64, i64)> {
|
||||
let q = self
|
||||
.rt
|
||||
.block_on(self.client.quota())
|
||||
.map_err(|e| CoreError::from_api(&e, "account quota"))?;
|
||||
let q = self.block_on_bounded(self.drive.quota()).map_err(|e| {
|
||||
self.lost_link(&e, "account quota");
|
||||
CoreError::from_api(&e, "account quota")
|
||||
})?;
|
||||
*self.quota.lock() = Some((std::time::Instant::now(), q.max_space, q.used_space));
|
||||
Ok((q.max_space, q.used_space))
|
||||
}
|
||||
@@ -632,11 +633,22 @@ impl Core {
|
||||
{
|
||||
return Some((max_space, used_space));
|
||||
}
|
||||
match self.account_quota() {
|
||||
let last = || self.quota.lock().map(|(_, max, used)| (max, used));
|
||||
if !self.is_online()
|
||||
|| self
|
||||
.quota_refreshing
|
||||
.swap(true, std::sync::atomic::Ordering::AcqRel)
|
||||
{
|
||||
return last();
|
||||
}
|
||||
let refreshed = self.account_quota();
|
||||
self.quota_refreshing
|
||||
.store(false, std::sync::atomic::Ordering::Release);
|
||||
match refreshed {
|
||||
Ok(q) => Some(q),
|
||||
Err(error) => {
|
||||
debug!(%error, "account quota refresh failed; serving the last known figures");
|
||||
self.quota.lock().map(|(_, max, used)| (max, used))
|
||||
last()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -658,7 +670,7 @@ impl Core {
|
||||
}
|
||||
let nodes = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_nodes(&uids))
|
||||
.block_on(self.drive.enumerate_nodes(&uids))
|
||||
.map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?;
|
||||
let mut items = Vec::with_capacity(nodes.len());
|
||||
for n in nodes {
|
||||
@@ -703,19 +715,10 @@ impl Core {
|
||||
/// been seen through the mount (e.g. shared but not browsed to this session) —
|
||||
/// the caller then leaves the path empty.
|
||||
pub(crate) fn rel_path_for_uid(&self, uid: &NodeUid) -> Option<String> {
|
||||
// Searched across every mount: a node browsed through a sync folder is
|
||||
// interned in that fork's inode space, so the primary state alone had no
|
||||
// entry for it and the caller silently showed an empty path
|
||||
// (`docs/BUGS.md` B74). The path is relative to whichever mount holds
|
||||
// it, which is the one the user reached it through.
|
||||
let mut path = None;
|
||||
self.for_each_state(|st| {
|
||||
if path.is_some() {
|
||||
return;
|
||||
}
|
||||
path = walk_to_root(st, uid);
|
||||
});
|
||||
path
|
||||
// Relative to whichever root the node sits under: a node browsed
|
||||
// through a sync folder is under that fork's root, not My Files
|
||||
// (`docs/BUGS.md` B74).
|
||||
walk_to_root(&self.state(), uid)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -760,14 +763,18 @@ impl StopSharingOutcome {
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk one inode space from `uid` up to its root, building the path. `None` if
|
||||
/// that mount has never interned the node, or if the chain breaks partway —
|
||||
/// Walk the tree from `uid` up to the root it sits under, building the path.
|
||||
/// `None` if the node has never been interned, or if the chain breaks partway —
|
||||
/// a broken chain is a missing path, never a partial one.
|
||||
fn walk_to_root(st: &super::State, uid: &NodeUid) -> Option<String> {
|
||||
let mut ino = *st.by_uid.get(uid)?;
|
||||
let mut parts = Vec::new();
|
||||
while ino != ROOT_INO {
|
||||
while !st.roots.contains(&ino) {
|
||||
let entry = st.entries.get(&ino)?;
|
||||
// An orphan is its own parent without being a root.
|
||||
if entry.parent == ino {
|
||||
return None;
|
||||
}
|
||||
parts.push(entry.node.name.clone());
|
||||
ino = entry.parent;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! invisible; in an in-process remount — which is how the daemon recovers from a
|
||||
//! mount going away underneath it — it leaks a whole generation of threads that
|
||||
//! keep writing to the database and calling the API on behalf of a mount that no
|
||||
//! longer exists (bugs.md B44).
|
||||
//! longer exists (`docs/BUGS.md` B44).
|
||||
//!
|
||||
//! The primitive is deliberately small: a flag plus a condvar. Every loop that
|
||||
//! would otherwise call `thread::sleep` calls [`Shutdown::sleep`] instead, which
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,9 @@
|
||||
//! The simulation tests of `docs/TESTING.md`: a fake Drive with
|
||||
//! the faults the real one has, and the tools to drive daemons against it.
|
||||
|
||||
pub(crate) mod daemon;
|
||||
pub(crate) mod fake_drive;
|
||||
pub(crate) mod model;
|
||||
pub(crate) mod rng;
|
||||
pub(crate) mod run;
|
||||
pub(crate) mod watchdog;
|
||||
@@ -0,0 +1,445 @@
|
||||
//! The reference file system of the simulation (`docs/TESTING.md`,
|
||||
//! "Simulation runs", property 1): what each syscall a simulated client makes should
|
||||
//! answer, and the tree it should leave, on a POSIX file system.
|
||||
//!
|
||||
//! A [`Model`] covers one client's own folder; paths are relative to it. The
|
||||
//! same type is used for the trees read back from a mount and from Drive.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::rng::Rng;
|
||||
|
||||
/// A tree by path: `None` is a folder, `Some` a file's content.
|
||||
pub(crate) type Tree = BTreeMap<String, Option<Vec<u8>>>;
|
||||
|
||||
/// One file system call, or the few a program makes in a row.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum FsOp {
|
||||
/// `open(O_CREAT | O_TRUNC)`, write `data`, close.
|
||||
Create {
|
||||
path: String,
|
||||
data: Vec<u8>,
|
||||
},
|
||||
/// Open an existing file, write `data` at `offset` (past the end leaves
|
||||
/// a hole), close.
|
||||
Write {
|
||||
path: String,
|
||||
offset: u64,
|
||||
data: Vec<u8>,
|
||||
},
|
||||
/// Open an existing file and `ftruncate` it to `len`.
|
||||
Truncate {
|
||||
path: String,
|
||||
len: u64,
|
||||
},
|
||||
Mkdir {
|
||||
path: String,
|
||||
},
|
||||
Rename {
|
||||
from: String,
|
||||
to: String,
|
||||
},
|
||||
Unlink {
|
||||
path: String,
|
||||
},
|
||||
/// Open a file, unlink it, append `data`, read it all back, close.
|
||||
UnlinkOpen {
|
||||
path: String,
|
||||
data: Vec<u8>,
|
||||
},
|
||||
Rmdir {
|
||||
path: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl FsOp {
|
||||
/// The paths the op names, for the log.
|
||||
pub(crate) fn describe(&self) -> String {
|
||||
match self {
|
||||
FsOp::Create { path, data } => format!("create {path} ({} bytes)", data.len()),
|
||||
FsOp::Write { path, offset, data } => {
|
||||
format!("write {path} at {offset} ({} bytes)", data.len())
|
||||
}
|
||||
FsOp::Truncate { path, len } => format!("truncate {path} to {len}"),
|
||||
FsOp::Mkdir { path } => format!("mkdir {path}"),
|
||||
FsOp::Rename { from, to } => format!("rename {from} -> {to}"),
|
||||
FsOp::Unlink { path } => format!("unlink {path}"),
|
||||
FsOp::UnlinkOpen { path, data } => {
|
||||
format!("unlink {path} while open, then append {} bytes", data.len())
|
||||
}
|
||||
FsOp::Rmdir { path } => format!("rmdir {path}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The tree one client should have, and what its syscalls should answer.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct Model {
|
||||
tree: Tree,
|
||||
}
|
||||
|
||||
/// Names are drawn from small pools, so ops collide: a create over an existing
|
||||
/// file, a rename over another, a mkdir of a name that is taken.
|
||||
const FILE_NAMES: [&str; 6] = ["a.txt", "b.txt", "c.bin", "d", "e.md", "f.txt"];
|
||||
const DIR_NAMES: [&str; 3] = ["x", "y", "z"];
|
||||
|
||||
impl Model {
|
||||
pub(crate) fn tree(&self) -> &Tree {
|
||||
&self.tree
|
||||
}
|
||||
|
||||
pub(crate) fn content(&self, path: &str) -> Option<&Vec<u8>> {
|
||||
self.tree.get(path)?.as_ref()
|
||||
}
|
||||
|
||||
/// What `op` should answer on a POSIX file system, applying it on success.
|
||||
pub(crate) fn apply(&mut self, op: &FsOp) -> Result<(), i32> {
|
||||
match op {
|
||||
FsOp::Create { path, data } => {
|
||||
self.check_parent(path)?;
|
||||
match self.tree.get(path) {
|
||||
Some(None) => Err(libc::EISDIR),
|
||||
_ => {
|
||||
self.tree.insert(path.clone(), Some(data.clone()));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
FsOp::Write { path, offset, data } => {
|
||||
let content = self.file_mut(path)?;
|
||||
// Writing nothing changes nothing, even past the end.
|
||||
if data.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let end = *offset as usize + data.len();
|
||||
if content.len() < end {
|
||||
content.resize(end, 0);
|
||||
}
|
||||
content[*offset as usize..end].copy_from_slice(data);
|
||||
Ok(())
|
||||
}
|
||||
FsOp::Truncate { path, len } => {
|
||||
self.file_mut(path)?.resize(*len as usize, 0);
|
||||
Ok(())
|
||||
}
|
||||
FsOp::Mkdir { path } => {
|
||||
self.check_parent(path)?;
|
||||
if self.tree.contains_key(path) {
|
||||
return Err(libc::EEXIST);
|
||||
}
|
||||
self.tree.insert(path.clone(), None);
|
||||
Ok(())
|
||||
}
|
||||
FsOp::Rename { from, to } => self.rename(from, to),
|
||||
FsOp::Unlink { path } | FsOp::UnlinkOpen { path, .. } => {
|
||||
self.check_parent(path)?;
|
||||
match self.tree.get(path) {
|
||||
None => Err(libc::ENOENT),
|
||||
Some(None) => Err(libc::EISDIR),
|
||||
Some(Some(_)) => {
|
||||
self.tree.remove(path);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
FsOp::Rmdir { path } => {
|
||||
self.check_parent(path)?;
|
||||
match self.tree.get(path) {
|
||||
None => Err(libc::ENOENT),
|
||||
Some(Some(_)) => Err(libc::ENOTDIR),
|
||||
Some(None) if self.has_children(path) => Err(libc::ENOTEMPTY),
|
||||
Some(None) => {
|
||||
self.tree.remove(path);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rename(&mut self, from: &str, to: &str) -> Result<(), i32> {
|
||||
self.check_parent(from)?;
|
||||
let Some(moving) = self.tree.get(from).cloned() else {
|
||||
return Err(libc::ENOENT);
|
||||
};
|
||||
self.check_parent(to)?;
|
||||
if from == to {
|
||||
return Ok(());
|
||||
}
|
||||
if is_below(to, from) {
|
||||
return Err(libc::EINVAL);
|
||||
}
|
||||
match (&moving, self.tree.get(to)) {
|
||||
(None, Some(Some(_))) => return Err(libc::ENOTDIR),
|
||||
(Some(_), Some(None)) => return Err(libc::EISDIR),
|
||||
(None, Some(None)) if self.has_children(to) => return Err(libc::ENOTEMPTY),
|
||||
_ => {}
|
||||
}
|
||||
let below: Vec<(String, Option<Vec<u8>>)> = self
|
||||
.tree
|
||||
.iter()
|
||||
.filter(|(path, _)| is_below(path, from))
|
||||
.map(|(path, entry)| (path.clone(), entry.clone()))
|
||||
.collect();
|
||||
self.tree.remove(from);
|
||||
self.tree.insert(to.to_owned(), moving);
|
||||
for (path, entry) in below {
|
||||
self.tree.remove(&path);
|
||||
self.tree
|
||||
.insert(format!("{to}{}", &path[from.len()..]), entry);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn file_mut(&mut self, path: &str) -> Result<&mut Vec<u8>, i32> {
|
||||
self.check_parent(path)?;
|
||||
match self.tree.get_mut(path) {
|
||||
None => Err(libc::ENOENT),
|
||||
Some(None) => Err(libc::EISDIR),
|
||||
Some(Some(content)) => Ok(content),
|
||||
}
|
||||
}
|
||||
|
||||
fn check_parent(&self, path: &str) -> Result<(), i32> {
|
||||
let mut at = String::new();
|
||||
let mut names = path.split('/').peekable();
|
||||
while let Some(name) = names.next() {
|
||||
if names.peek().is_none() {
|
||||
break;
|
||||
}
|
||||
if !at.is_empty() {
|
||||
at.push('/');
|
||||
}
|
||||
at.push_str(name);
|
||||
match self.tree.get(&at) {
|
||||
None => return Err(libc::ENOENT),
|
||||
Some(Some(_)) => return Err(libc::ENOTDIR),
|
||||
Some(None) => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn has_children(&self, path: &str) -> bool {
|
||||
self.tree.keys().any(|other| is_below(other, path))
|
||||
}
|
||||
|
||||
pub(crate) fn files(&self) -> Vec<&String> {
|
||||
self.tree
|
||||
.iter()
|
||||
.filter(|(_, entry)| entry.is_some())
|
||||
.map(|(path, _)| path)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Folders, the client's own root (`""`) first.
|
||||
fn dirs(&self) -> Vec<String> {
|
||||
std::iter::once(String::new())
|
||||
.chain(
|
||||
self.tree
|
||||
.iter()
|
||||
.filter(|(_, entry)| entry.is_none())
|
||||
.map(|(path, _)| path.clone()),
|
||||
)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A random op, mostly one that should succeed.
|
||||
pub(crate) fn next_op(&self, rng: &mut Rng) -> FsOp {
|
||||
let dirs = self.dirs();
|
||||
let files = self.files();
|
||||
let in_dir = |rng: &mut Rng, names: &[&str]| {
|
||||
let parent = rng.pick(&dirs).expect("the root is always there");
|
||||
join(parent, rng.pick(names).expect("names"))
|
||||
};
|
||||
let a_file = |rng: &mut Rng| rng.pick(&files).map(|path| (*path).clone());
|
||||
let roll = rng.below(100);
|
||||
let op = match roll {
|
||||
0..30 => Some(FsOp::Create {
|
||||
path: in_dir(rng, &FILE_NAMES),
|
||||
data: data(rng),
|
||||
}),
|
||||
30..45 => a_file(rng).map(|path| {
|
||||
let len = self.content(&path).map_or(0, Vec::len) as u64;
|
||||
FsOp::Write {
|
||||
offset: rng.below(len + 8),
|
||||
path,
|
||||
data: data(rng),
|
||||
}
|
||||
}),
|
||||
45..50 => a_file(rng).map(|path| FsOp::Truncate {
|
||||
len: rng.below(32),
|
||||
path,
|
||||
}),
|
||||
50..60 => Some(FsOp::Mkdir {
|
||||
path: in_dir(rng, &DIR_NAMES),
|
||||
}),
|
||||
// A rename in place, over another file as often as not.
|
||||
60..72 => a_file(rng).map(|from| FsOp::Rename {
|
||||
to: join(parent_of(&from), rng.pick(&FILE_NAMES).expect("names")),
|
||||
from,
|
||||
}),
|
||||
// A move to another folder, renamed or not.
|
||||
72..82 => a_file(rng).map(|from| {
|
||||
let name = if rng.chance(0.5) {
|
||||
name_of(&from)
|
||||
} else {
|
||||
rng.pick(&FILE_NAMES).expect("names")
|
||||
};
|
||||
let parent = rng.pick(&dirs).expect("the root is always there");
|
||||
FsOp::Rename {
|
||||
to: join(parent, name),
|
||||
from,
|
||||
}
|
||||
}),
|
||||
// A folder renamed or moved, with what is in it.
|
||||
82..86 => {
|
||||
let from = rng.pick(&dirs[1..]).cloned();
|
||||
from.map(|from| FsOp::Rename {
|
||||
to: in_dir(rng, &DIR_NAMES),
|
||||
from,
|
||||
})
|
||||
}
|
||||
86..94 => a_file(rng).map(|path| FsOp::Unlink { path }),
|
||||
94..97 => a_file(rng).map(|path| FsOp::UnlinkOpen {
|
||||
path,
|
||||
data: data(rng),
|
||||
}),
|
||||
_ => rng
|
||||
.pick(&dirs[1..])
|
||||
.cloned()
|
||||
.map(|path| FsOp::Rmdir { path }),
|
||||
};
|
||||
op.unwrap_or_else(|| FsOp::Create {
|
||||
path: in_dir(rng, &FILE_NAMES),
|
||||
data: data(rng),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Content for a write: short and recognisable, now and then empty.
|
||||
pub(crate) fn data(rng: &mut Rng) -> Vec<u8> {
|
||||
let len = if rng.chance(0.1) {
|
||||
0
|
||||
} else {
|
||||
rng.between(1, 48)
|
||||
};
|
||||
let tag = rng.next_u64();
|
||||
(0..len).map(|i| b'a' + ((tag + i) % 26) as u8).collect()
|
||||
}
|
||||
|
||||
pub(crate) fn join(parent: &str, name: &str) -> String {
|
||||
if parent.is_empty() {
|
||||
name.to_owned()
|
||||
} else {
|
||||
format!("{parent}/{name}")
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parent_of(path: &str) -> &str {
|
||||
path.rsplit_once('/').map_or("", |(parent, _)| parent)
|
||||
}
|
||||
|
||||
pub(crate) fn name_of(path: &str) -> &str {
|
||||
path.rsplit_once('/').map_or(path, |(_, name)| name)
|
||||
}
|
||||
|
||||
fn is_below(path: &str, ancestor: &str) -> bool {
|
||||
path.len() > ancestor.len() + 1
|
||||
&& path.starts_with(ancestor)
|
||||
&& path.as_bytes()[ancestor.len()] == b'/'
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn create(path: &str, data: &[u8]) -> FsOp {
|
||||
FsOp::Create {
|
||||
path: path.into(),
|
||||
data: data.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rename_over_a_file_replaces_it_and_a_folder_moves_with_its_contents() {
|
||||
let mut model = Model::default();
|
||||
model.apply(&FsOp::Mkdir { path: "x".into() }).unwrap();
|
||||
model.apply(&create("x/a", b"1")).unwrap();
|
||||
model.apply(&create("b", b"2")).unwrap();
|
||||
model
|
||||
.apply(&FsOp::Rename {
|
||||
from: "b".into(),
|
||||
to: "x/a".into(),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(model.content("x/a"), Some(&b"2".to_vec()));
|
||||
model
|
||||
.apply(&FsOp::Rename {
|
||||
from: "x".into(),
|
||||
to: "y".into(),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(model.tree().keys().collect::<Vec<_>>(), vec!["y", "y/a"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calls_fail_the_way_posix_says() {
|
||||
let mut model = Model::default();
|
||||
model.apply(&FsOp::Mkdir { path: "x".into() }).unwrap();
|
||||
model.apply(&create("x/a", b"1")).unwrap();
|
||||
assert_eq!(
|
||||
model.apply(&FsOp::Mkdir { path: "x".into() }),
|
||||
Err(libc::EEXIST)
|
||||
);
|
||||
assert_eq!(model.apply(&create("x", b"")), Err(libc::EISDIR));
|
||||
assert_eq!(model.apply(&create("x/a/b", b"")), Err(libc::ENOTDIR));
|
||||
assert_eq!(model.apply(&create("q/a", b"")), Err(libc::ENOENT));
|
||||
assert_eq!(
|
||||
model.apply(&FsOp::Rmdir { path: "x".into() }),
|
||||
Err(libc::ENOTEMPTY)
|
||||
);
|
||||
assert_eq!(
|
||||
model.apply(&FsOp::Unlink { path: "x".into() }),
|
||||
Err(libc::EISDIR)
|
||||
);
|
||||
let into_itself = FsOp::Rename {
|
||||
from: "x".into(),
|
||||
to: "x/y".into(),
|
||||
};
|
||||
assert_eq!(model.apply(&into_itself), Err(libc::EINVAL));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_write_past_the_end_leaves_a_hole() {
|
||||
let mut model = Model::default();
|
||||
model.apply(&create("a", b"ab")).unwrap();
|
||||
let write = FsOp::Write {
|
||||
path: "a".into(),
|
||||
offset: 4,
|
||||
data: b"z".to_vec(),
|
||||
};
|
||||
model.apply(&write).unwrap();
|
||||
assert_eq!(model.content("a"), Some(&b"ab\0\0z".to_vec()));
|
||||
let nothing = FsOp::Write {
|
||||
path: "a".into(),
|
||||
offset: 9,
|
||||
data: Vec::new(),
|
||||
};
|
||||
model.apply(¬hing).unwrap();
|
||||
assert_eq!(model.content("a"), Some(&b"ab\0\0z".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_ops_mostly_succeed() {
|
||||
let mut rng = Rng::new(9);
|
||||
let mut model = Model::default();
|
||||
let ok = (0..500)
|
||||
.filter(|_| {
|
||||
let op = model.next_op(&mut rng);
|
||||
model.apply(&op).is_ok()
|
||||
})
|
||||
.count();
|
||||
assert!(ok > 350, "{ok} of 500 succeeded");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//! A small seeded random number generator, so a failing simulation is a seed
|
||||
//! that can be replayed. SplitMix64: fast, good enough for choosing operations
|
||||
//! and faults, and no dependency.
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
pub(crate) fn new(seed: u64) -> Self {
|
||||
Self(seed)
|
||||
}
|
||||
|
||||
pub(crate) fn next_u64(&mut self) -> u64 {
|
||||
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
let mut z = self.0;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
}
|
||||
|
||||
/// A value in `0..n`; `0` when `n` is `0`.
|
||||
pub(crate) fn below(&mut self, n: u64) -> u64 {
|
||||
if n == 0 { 0 } else { self.next_u64() % n }
|
||||
}
|
||||
|
||||
/// A value in `lo..=hi`.
|
||||
pub(crate) fn between(&mut self, lo: u64, hi: u64) -> u64 {
|
||||
lo + self.below(hi.saturating_sub(lo) + 1)
|
||||
}
|
||||
|
||||
/// True with probability `p`.
|
||||
pub(crate) fn chance(&mut self, p: f64) -> bool {
|
||||
p > 0.0 && (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64 <= p
|
||||
}
|
||||
|
||||
pub(crate) fn pick<'a, T>(&mut self, items: &'a [T]) -> Option<&'a T> {
|
||||
items.get(self.below(items.len() as u64) as usize)
|
||||
}
|
||||
|
||||
/// A child generator, so one consumer's draws do not shift another's.
|
||||
pub(crate) fn fork(&mut self) -> Self {
|
||||
Self::new(self.next_u64())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Rng;
|
||||
|
||||
#[test]
|
||||
fn a_seed_replays_the_same_sequence() {
|
||||
let (mut a, mut b) = (Rng::new(7), Rng::new(7));
|
||||
for _ in 0..100 {
|
||||
assert_eq!(a.next_u64(), b.next_u64());
|
||||
}
|
||||
assert_ne!(Rng::new(7).next_u64(), Rng::new(8).next_u64());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_draws_stay_in_range() {
|
||||
let mut rng = Rng::new(1);
|
||||
for _ in 0..1000 {
|
||||
assert!(rng.below(5) < 5);
|
||||
assert!((3..=9).contains(&rng.between(3, 9)));
|
||||
}
|
||||
assert_eq!(rng.below(0), 0);
|
||||
assert!(!rng.chance(0.0));
|
||||
assert!(rng.chance(1.0));
|
||||
assert!([1, 2, 3].contains(rng.pick(&[1, 2, 3]).unwrap()));
|
||||
assert_eq!(rng.pick::<u8>(&[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fork_draws_apart_from_its_parent() {
|
||||
let mut parent = Rng::new(3);
|
||||
let mut child = parent.fork();
|
||||
assert_ne!(parent.next_u64(), child.next_u64());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,766 @@
|
||||
//! The simulation runs described in `docs/TESTING.md`.
|
||||
//!
|
||||
//! A run starts one or more daemons on a shared [`FakeDrive`], each owning a
|
||||
//! folder `c<n>` that only it writes, so there is one writer per file. A seed
|
||||
//! draws a sequence of steps: syscalls through a client's mount, the link of a
|
||||
//! client going down or up, a client restarting, a pause, a settle (every
|
||||
//! queue must drain within a budget), or a file deleted while the drain holds
|
||||
//! a write to it. Every syscall is checked against the
|
||||
//! client's [`Model`] as it happens. After the sequence every link comes up
|
||||
//! and the run checks:
|
||||
//!
|
||||
//! 1. **No loss** and **POSIX**: Drive's copy of each client's folder is what
|
||||
//! its model says.
|
||||
//! 2. **No false conflicts**: no conflict copy anywhere, and the drain never
|
||||
//! takes a client's own delete for another device's.
|
||||
//! 3. **Convergence**: each mount shows Drive's tree.
|
||||
//! 4. **Liveness**: no syscall took longer than its budget, settles drained in
|
||||
//! theirs, and every queue drained once the links were up. A call that
|
||||
//! never returns is caught by the [`Watchdog`], which keeps the stacks.
|
||||
//!
|
||||
//! A restart here is a stop and a start: the next run finds what the last
|
||||
//! one wrote down, but in-flight work is let finish rather than cut. A kill
|
||||
//! at an arbitrary point needs the daemon in a process of its own.
|
||||
//!
|
||||
//! Any of these fails the seed. No open bug is tolerated: the matchers that
|
||||
//! counted the ones still open instead (B125 to B134) went once a run on
|
||||
//! 812eb3d passed every profile without them.
|
||||
//!
|
||||
//! The runs are ignored by default because they mount FUSE; run them with
|
||||
//! `cargo test -p pdfs-fuse --lib sim::run -- --ignored --test-threads=1`.
|
||||
//! `PDFS_SIM_SEEDS=n` sets how many seeds each runs, `PDFS_SIM_SEED=s` replays
|
||||
//! one, and `RUST_LOG=pdfs_fuse=debug` with `--nocapture` shows the daemons'
|
||||
//! logs.
|
||||
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::{self, Read, Seek, SeekFrom, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::daemon::{Daemon, scratch, take_logged, wait_until};
|
||||
use super::fake_drive::{Entry, FakeDrive, Faults};
|
||||
use super::model::{FsOp, Model, Tree, data, join};
|
||||
use super::rng::Rng;
|
||||
use super::watchdog::Watchdog;
|
||||
|
||||
/// What a run looks like.
|
||||
pub(crate) struct Profile {
|
||||
pub(crate) name: &'static str,
|
||||
pub(crate) clients: usize,
|
||||
pub(crate) steps: usize,
|
||||
pub(crate) faults: fn() -> Faults,
|
||||
/// Whether links go down and come back during the sequence.
|
||||
pub(crate) link_flaps: bool,
|
||||
pub(crate) restarts: bool,
|
||||
/// Whether files are deleted while the drain holds a write to them.
|
||||
pub(crate) deletes_mid_upload: bool,
|
||||
/// How long a settle may take; `None` leaves settles out.
|
||||
pub(crate) settle_budget: Option<Duration>,
|
||||
/// How long one syscall may take.
|
||||
pub(crate) syscall_budget: Duration,
|
||||
/// How long the queues may take to drain at the end.
|
||||
pub(crate) drain_budget: Duration,
|
||||
}
|
||||
|
||||
/// How long the mounts may take to show Drive's tree at the end: one event
|
||||
/// poll (`POLL_INTERVAL`) and change.
|
||||
const CONVERGE_BUDGET: Duration = Duration::from_secs(30);
|
||||
|
||||
/// How long the drain may take to pick up a write: its debounce, and then
|
||||
/// some.
|
||||
const PICKUP_BUDGET: Duration = Duration::from_secs(8);
|
||||
|
||||
/// Steps shown before a failure. All of them are written to `steps.log` in
|
||||
/// the state the failure keeps.
|
||||
const LOG_TAIL: usize = 40;
|
||||
|
||||
/// How long past its own budget a step, or the checks after the last one, may
|
||||
/// run before the watchdog takes it for hung. Longer than a restart may wait
|
||||
/// for the last run and the mount.
|
||||
const STALL: Duration = Duration::from_secs(120);
|
||||
|
||||
struct Client {
|
||||
daemon: Option<Daemon>,
|
||||
model: Model,
|
||||
online: bool,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
fn daemon(&self) -> &Daemon {
|
||||
self.daemon
|
||||
.as_ref()
|
||||
.expect("a client always has a daemon between steps")
|
||||
}
|
||||
|
||||
fn root(&self, index: usize) -> PathBuf {
|
||||
self.daemon().mountpoint.join(owned_folder(index))
|
||||
}
|
||||
}
|
||||
|
||||
struct Run {
|
||||
seed: u64,
|
||||
profile: &'static Profile,
|
||||
drive: std::sync::Arc<FakeDrive>,
|
||||
clients: Vec<Client>,
|
||||
log: Vec<String>,
|
||||
started: Instant,
|
||||
/// What the daemons logged at `INFO` and above so far.
|
||||
logged: Vec<String>,
|
||||
watchdog: Watchdog,
|
||||
}
|
||||
|
||||
/// Run `profile` on `seed`. It answers a report of what went wrong, if
|
||||
/// anything did.
|
||||
pub(crate) fn run(seed: u64, profile: &'static Profile) -> Result<(), String> {
|
||||
let dir = scratch(&format!("{}-{seed}", profile.name));
|
||||
let mut rng = Rng::new(seed);
|
||||
let drive = FakeDrive::new();
|
||||
let mut run = Run {
|
||||
seed,
|
||||
profile,
|
||||
drive: drive.clone(),
|
||||
clients: Vec::new(),
|
||||
log: Vec::new(),
|
||||
started: Instant::now(),
|
||||
logged: Vec::new(),
|
||||
watchdog: Watchdog::new(&dir),
|
||||
};
|
||||
let outcome = run.execute(&dir, &mut rng);
|
||||
run.watchdog.stop();
|
||||
// A hang is what failed, whatever the aborted call answered.
|
||||
let outcome = match run.watchdog.fired() {
|
||||
Some(hung) => Err(format!("liveness: {}", hung.what)),
|
||||
None => outcome,
|
||||
};
|
||||
for client in &mut run.clients {
|
||||
if let Some(daemon) = client.daemon.take() {
|
||||
let _ = daemon.stop();
|
||||
}
|
||||
}
|
||||
match outcome {
|
||||
Ok(()) => {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
Ok(())
|
||||
}
|
||||
Err(failure) => Err(run.report(&failure, &dir)),
|
||||
}
|
||||
}
|
||||
|
||||
impl Run {
|
||||
fn execute(&mut self, dir: &Path, rng: &mut Rng) -> Result<(), String> {
|
||||
for index in 0..self.profile.clients {
|
||||
let client = self.drive.client(rng.next_u64(), (self.profile.faults)());
|
||||
let daemon = Daemon::start(&dir.join(format!("client{index}")), client)
|
||||
.map_err(|error| format!("client {index} did not mount: {error}"))?;
|
||||
let folder = daemon.mountpoint.join(owned_folder(index));
|
||||
std::fs::create_dir(&folder)
|
||||
.map_err(|error| format!("client {index}: mkdir {}: {error}", folder.display()))?;
|
||||
self.clients.push(Client {
|
||||
daemon: Some(daemon),
|
||||
model: Model::default(),
|
||||
online: true,
|
||||
});
|
||||
}
|
||||
for step in 0..self.profile.steps {
|
||||
let _watching = self.watchdog.arm(self.step_limit(), format!("step {step}"));
|
||||
self.step(rng)?;
|
||||
}
|
||||
let _watching = self.watchdog.arm(
|
||||
self.profile.drain_budget + STALL,
|
||||
"the checks after the last step",
|
||||
);
|
||||
self.finish()
|
||||
}
|
||||
|
||||
/// How long a step may run before the watchdog takes it for hung.
|
||||
fn step_limit(&self) -> Duration {
|
||||
let budget = self.profile.settle_budget.unwrap_or_default();
|
||||
budget.max(self.profile.syscall_budget) + STALL
|
||||
}
|
||||
|
||||
fn step(&mut self, rng: &mut Rng) -> Result<(), String> {
|
||||
let index = rng.below(self.clients.len() as u64) as usize;
|
||||
let roll = rng.below(100);
|
||||
match roll {
|
||||
0..4 if self.profile.link_flaps => {
|
||||
let client = &mut self.clients[index];
|
||||
client.online = !client.online;
|
||||
client.daemon().client.set_online(client.online);
|
||||
let state = if client.online { "up" } else { "down" };
|
||||
self.note(format!("client {index}: link {state}"));
|
||||
}
|
||||
4..6 if self.profile.restarts => {
|
||||
self.note(format!("client {index}: restart"));
|
||||
let daemon = self.clients[index].daemon.take().expect("daemon");
|
||||
let daemon = daemon
|
||||
.restart()
|
||||
.map_err(|error| format!("client {index} did not come back: {error}"))?;
|
||||
self.clients[index].daemon = Some(daemon);
|
||||
}
|
||||
6..12 => {
|
||||
let ms = rng.below(400);
|
||||
self.note(format!("pause {ms} ms"));
|
||||
std::thread::sleep(Duration::from_millis(ms));
|
||||
}
|
||||
12..15 => {
|
||||
if let Some(budget) = self.profile.settle_budget
|
||||
&& self.clients.iter().all(|client| client.online)
|
||||
{
|
||||
self.settle(budget)?;
|
||||
}
|
||||
}
|
||||
15..25 if self.profile.deletes_mid_upload => self.delete_mid_upload(index, rng)?,
|
||||
_ => {
|
||||
let op = self.clients[index].model.next_op(rng);
|
||||
self.syscall(index, op)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Wait until every queue is empty and Drive holds what the models say.
|
||||
fn settle(&mut self, budget: Duration) -> Result<(), String> {
|
||||
self.note("settle".into());
|
||||
let start = Instant::now();
|
||||
// What the drain logs meanwhile is what explains a known bug's damage.
|
||||
if !wait_until(budget, || {
|
||||
self.logged.extend(take_logged());
|
||||
self.landed()
|
||||
}) {
|
||||
return Err(format!(
|
||||
"liveness: the queues did not drain within {budget:?}\n{}",
|
||||
self.difference()
|
||||
));
|
||||
}
|
||||
self.note(format!("settled in {:?}", start.elapsed()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write a file Drive holds and delete it once the drain has picked the
|
||||
/// write up. The drain's read of the node is held until the delete has
|
||||
/// landed and a read shows it, so the drain finds the file trashed while
|
||||
/// it holds a write that is no longer queued (B105). Half the time the
|
||||
/// file is made again meanwhile, which queues a write under the op id the
|
||||
/// delete freed (B133).
|
||||
fn delete_mid_upload(&mut self, index: usize, rng: &mut Rng) -> Result<(), String> {
|
||||
let file = rng
|
||||
.pick(&self.clients[index].model.files())
|
||||
.map(|path| (*path).clone());
|
||||
let (Some(path), Some(budget), true) = (
|
||||
file,
|
||||
self.profile.settle_budget,
|
||||
self.clients.iter().all(|client| client.online),
|
||||
) else {
|
||||
let op = self.clients[index].model.next_op(rng);
|
||||
return self.syscall(index, op);
|
||||
};
|
||||
self.settle(budget)?;
|
||||
let Some(uid) = self.drive.lookup(&join(&owned_folder(index), &path)) else {
|
||||
return Err(format!(
|
||||
"no loss: client {index}'s {path} is not on Drive after a settle"
|
||||
));
|
||||
};
|
||||
let held = self.clients[index].daemon().client.hold_next_read(&uid);
|
||||
self.note(format!("client {index}: hold the next read of {path}"));
|
||||
let mut bytes = data(rng);
|
||||
bytes.push(b'.');
|
||||
self.syscall(
|
||||
index,
|
||||
FsOp::Write {
|
||||
path: path.clone(),
|
||||
offset: 0,
|
||||
data: bytes,
|
||||
},
|
||||
)?;
|
||||
if !wait_until(PICKUP_BUDGET, || held.reached()) {
|
||||
self.note(format!("client {index}: nothing read {path} back"));
|
||||
return Ok(());
|
||||
}
|
||||
self.syscall(index, FsOp::Unlink { path: path.clone() })?;
|
||||
if rng.chance(0.5) {
|
||||
self.syscall(
|
||||
index,
|
||||
FsOp::Create {
|
||||
path: path.clone(),
|
||||
data: data(rng),
|
||||
},
|
||||
)?;
|
||||
}
|
||||
let faults = (self.profile.faults)();
|
||||
let latency = Duration::from_millis(faults.latency_ms.1);
|
||||
std::thread::sleep(faults.listing_lag + latency);
|
||||
drop(held);
|
||||
self.note(format!("client {index}: the read of {path} goes on"));
|
||||
// Nothing else is queued before the drain has answered.
|
||||
std::thread::sleep(2 * latency + Duration::from_millis(50));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn syscall(&mut self, index: usize, op: FsOp) -> Result<(), String> {
|
||||
let client = &mut self.clients[index];
|
||||
let root = client.root(index);
|
||||
let before = client.model.content(target(&op)).cloned();
|
||||
let expected = client.model.apply(&op);
|
||||
self.logged.extend(take_logged());
|
||||
let start = Instant::now();
|
||||
let actual = perform(&root, &op, before.as_deref());
|
||||
let took = start.elapsed();
|
||||
self.note(format!(
|
||||
"client {index}: {} = {} in {took:?}",
|
||||
op.describe(),
|
||||
outcome(&actual)
|
||||
));
|
||||
let actual = actual?;
|
||||
self.logged.extend(take_logged());
|
||||
if actual != expected {
|
||||
return Err(format!(
|
||||
"posix: client {index}: {} answered {}, expected {}",
|
||||
op.describe(),
|
||||
errno(actual),
|
||||
errno(expected)
|
||||
));
|
||||
}
|
||||
if took > self.profile.syscall_budget {
|
||||
return Err(format!(
|
||||
"liveness: client {index}: {} took {took:?}, over {:?}",
|
||||
op.describe(),
|
||||
self.profile.syscall_budget
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finish(&mut self) -> Result<(), String> {
|
||||
for client in &mut self.clients {
|
||||
client.online = true;
|
||||
client.daemon().client.set_online(true);
|
||||
}
|
||||
self.note("every link up; waiting for the queues".into());
|
||||
if !wait_until(self.profile.drain_budget, || {
|
||||
self.logged.extend(take_logged());
|
||||
self.landed()
|
||||
}) {
|
||||
return Err(format!(
|
||||
"no loss: Drive does not hold what the clients wrote, {:?} after the links came up\n{}",
|
||||
self.profile.drain_budget,
|
||||
self.difference()
|
||||
));
|
||||
}
|
||||
// Each client's folder is its own, so a file of it gone from Drive was
|
||||
// deleted by the client.
|
||||
if let Some(line) = self.logged.iter().find(|line| is_own_delete_kept(line)) {
|
||||
return Err(format!(
|
||||
"no false conflicts: the drain took a client's own delete for another device's: {line}"
|
||||
));
|
||||
}
|
||||
let drive = self.drive_tree();
|
||||
let conflicts: Vec<String> = drive
|
||||
.into_keys()
|
||||
.filter(|path| is_conflict_copy(path))
|
||||
.collect();
|
||||
if !conflicts.is_empty() {
|
||||
return Err(format!("no false conflicts: {conflicts:?}"));
|
||||
}
|
||||
for index in 0..self.clients.len() {
|
||||
let mut last = Tree::new();
|
||||
let converged = wait_until(CONVERGE_BUDGET, || {
|
||||
last = read_tree(&self.clients[index].daemon().mountpoint);
|
||||
last == self.drive_tree()
|
||||
});
|
||||
if !converged {
|
||||
return Err(format!(
|
||||
"convergence: client {index}'s mount differs from Drive\n{}",
|
||||
diff(&last, &self.drive_tree(), "mount", "Drive")
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether every client's queue is empty and Drive holds what its model
|
||||
/// says.
|
||||
fn landed(&self) -> bool {
|
||||
let drive = self.drive_tree();
|
||||
self.clients.iter().enumerate().all(|(index, client)| {
|
||||
client
|
||||
.daemon()
|
||||
.pending()
|
||||
.is_ok_and(|items| items.is_empty())
|
||||
&& subtree(&drive, &owned_folder(index)) == *client.model.tree()
|
||||
})
|
||||
}
|
||||
|
||||
fn difference(&self) -> String {
|
||||
let drive = self.drive_tree();
|
||||
let mut out = String::new();
|
||||
for (index, client) in self.clients.iter().enumerate() {
|
||||
let pending = client.daemon().pending().unwrap_or_default();
|
||||
for item in pending {
|
||||
out.push_str(&format!(
|
||||
" client {index} still owes: {} {} (attempts {}, last error {:?})\n",
|
||||
item.kind, item.path, item.attempts, item.last_error
|
||||
));
|
||||
}
|
||||
out.push_str(&diff(
|
||||
client.model.tree(),
|
||||
&subtree(&drive, &owned_folder(index)),
|
||||
&format!("client {index}'s model"),
|
||||
"Drive",
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn drive_tree(&self) -> Tree {
|
||||
self.drive
|
||||
.tree()
|
||||
.into_iter()
|
||||
.map(|(path, entry)| {
|
||||
let content = match entry {
|
||||
Entry::Folder => None,
|
||||
Entry::File(content) => Some(content.as_ref().clone()),
|
||||
};
|
||||
(path, content)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn note(&mut self, line: String) {
|
||||
let line = format!("{:>8.3}s {line}", self.started.elapsed().as_secs_f64());
|
||||
self.log.push(line);
|
||||
}
|
||||
|
||||
fn report(&self, failure: &str, dir: &Path) -> String {
|
||||
let _ = std::fs::write(dir.join("steps.log"), self.log.join("\n") + "\n");
|
||||
let tail = &self.log[self.log.len().saturating_sub(LOG_TAIL)..];
|
||||
format!(
|
||||
"{} seed {} failed: {failure}\nreplay with PDFS_SIM_SEED={}; state kept in {}\nlast steps:\n{}",
|
||||
self.profile.name,
|
||||
self.seed,
|
||||
self.seed,
|
||||
dir.display(),
|
||||
tail.join("\n")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Make `op` under `root`. The outer error is a check that failed on the
|
||||
/// way (the content an open file reads back), the inner one the errno.
|
||||
fn perform(root: &Path, op: &FsOp, before: Option<&[u8]>) -> Result<Result<(), i32>, String> {
|
||||
let at = |path: &str| root.join(path);
|
||||
let result: io::Result<()> = match op {
|
||||
FsOp::Create { path, data } => std::fs::write(at(path), data),
|
||||
FsOp::Write { path, offset, data } => OpenOptions::new()
|
||||
.write(true)
|
||||
.open(at(path))
|
||||
.and_then(|mut file| {
|
||||
file.seek(SeekFrom::Start(*offset))?;
|
||||
file.write_all(data)
|
||||
}),
|
||||
FsOp::Truncate { path, len } => OpenOptions::new()
|
||||
.write(true)
|
||||
.open(at(path))
|
||||
.and_then(|file| file.set_len(*len)),
|
||||
FsOp::Mkdir { path } => std::fs::create_dir(at(path)),
|
||||
FsOp::Rename { from, to } => std::fs::rename(at(from), at(to)),
|
||||
FsOp::Unlink { path } => std::fs::remove_file(at(path)),
|
||||
FsOp::Rmdir { path } => std::fs::remove_dir(at(path)),
|
||||
FsOp::UnlinkOpen { path, data } => {
|
||||
let mut read = Vec::new();
|
||||
let result = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(at(path))
|
||||
.and_then(|mut file| {
|
||||
std::fs::remove_file(at(path))?;
|
||||
file.seek(SeekFrom::End(0))?;
|
||||
file.write_all(data)?;
|
||||
file.seek(SeekFrom::Start(0))?;
|
||||
file.read_to_end(&mut read).map(|_| ())
|
||||
});
|
||||
if result.is_ok() {
|
||||
let expected = [before.unwrap_or_default(), data].concat();
|
||||
if read != expected {
|
||||
return Err(format!(
|
||||
"posix: {} read back {:?}, expected {:?}",
|
||||
op.describe(),
|
||||
String::from_utf8_lossy(&read),
|
||||
String::from_utf8_lossy(&expected)
|
||||
));
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
};
|
||||
Ok(result.map_err(|error| error.raw_os_error().unwrap_or(libc::EIO)))
|
||||
}
|
||||
|
||||
fn target(op: &FsOp) -> &str {
|
||||
match op {
|
||||
FsOp::Create { path, .. }
|
||||
| FsOp::Write { path, .. }
|
||||
| FsOp::Truncate { path, .. }
|
||||
| FsOp::Mkdir { path }
|
||||
| FsOp::Unlink { path }
|
||||
| FsOp::UnlinkOpen { path, .. }
|
||||
| FsOp::Rmdir { path } => path,
|
||||
FsOp::Rename { from, .. } => from,
|
||||
}
|
||||
}
|
||||
|
||||
/// The tree a mount shows, read through it, without the folders it makes up
|
||||
/// that are not on Drive.
|
||||
fn read_tree(mountpoint: &Path) -> Tree {
|
||||
fn walk(dir: &Path, prefix: &str, tree: &mut Tree) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if prefix.is_empty() && name == crate::r#virtual::SHARED_WITH_ME_BASE {
|
||||
continue;
|
||||
}
|
||||
let path = super::model::join(prefix, &name);
|
||||
if entry.file_type().is_ok_and(|kind| kind.is_dir()) {
|
||||
tree.insert(path.clone(), None);
|
||||
walk(&entry.path(), &path, tree);
|
||||
} else {
|
||||
tree.insert(path, std::fs::read(entry.path()).ok());
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut tree = Tree::new();
|
||||
walk(mountpoint, "", &mut tree);
|
||||
tree
|
||||
}
|
||||
|
||||
/// The part of `tree` below `folder`, relative to it.
|
||||
fn subtree(tree: &Tree, folder: &str) -> Tree {
|
||||
let prefix = format!("{folder}/");
|
||||
tree.iter()
|
||||
.filter_map(|(path, entry)| Some((path.strip_prefix(&prefix)?.to_owned(), entry.clone())))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn owned_folder(index: usize) -> String {
|
||||
format!("c{index}")
|
||||
}
|
||||
|
||||
/// Whether `line` is the drain keeping a queued write as a conflict copy
|
||||
/// because Drive no longer holds the file, or holds it in the trash.
|
||||
fn is_own_delete_kept(line: &str) -> bool {
|
||||
line.starts_with("queued write conflicts; keeping a conflict copy")
|
||||
&& (line.contains(" reason=\"the file was trashed remotely\"")
|
||||
|| line.contains(" reason=\"the file no longer exists remotely\""))
|
||||
}
|
||||
|
||||
fn is_conflict_copy(path: &str) -> bool {
|
||||
path.split('/')
|
||||
.any(|name| name.contains("(sync-conflict") || name.starts_with("recovered-"))
|
||||
}
|
||||
|
||||
fn diff(left: &Tree, right: &Tree, left_name: &str, right_name: &str) -> String {
|
||||
let show = |entry: &Option<Vec<u8>>| match entry {
|
||||
None => "folder".to_owned(),
|
||||
Some(content) => format!("{:?}", String::from_utf8_lossy(content)),
|
||||
};
|
||||
let mut out = String::new();
|
||||
for (path, entry) in left {
|
||||
match right.get(path) {
|
||||
None => out.push_str(&format!(
|
||||
" {path}: only in {left_name} ({})\n",
|
||||
show(entry)
|
||||
)),
|
||||
Some(other) if other != entry => out.push_str(&format!(
|
||||
" {path}: {left_name} has {}, {right_name} has {}\n",
|
||||
show(entry),
|
||||
show(other)
|
||||
)),
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
for (path, entry) in right {
|
||||
if !left.contains_key(path) {
|
||||
out.push_str(&format!(
|
||||
" {path}: only in {right_name} ({})\n",
|
||||
show(entry)
|
||||
));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn errno(outcome: Result<(), i32>) -> String {
|
||||
match outcome {
|
||||
Ok(()) => "ok".into(),
|
||||
Err(code) => io::Error::from_raw_os_error(code).to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn outcome(actual: &Result<Result<(), i32>, String>) -> String {
|
||||
match actual {
|
||||
Ok(result) => errno(*result),
|
||||
Err(check) => check.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `profile` over the seeds the environment asks for: `PDFS_SIM_SEED`
|
||||
/// alone, or `PDFS_SIM_SEEDS` of them (default `default_seeds`) from 1.
|
||||
pub(crate) fn run_seeds(profile: &'static Profile, default_seeds: u64) {
|
||||
let seeds: Vec<u64> = match std::env::var("PDFS_SIM_SEED") {
|
||||
Ok(seed) => vec![seed.parse().expect("PDFS_SIM_SEED is a number")],
|
||||
Err(_) => {
|
||||
let count = std::env::var("PDFS_SIM_SEEDS")
|
||||
.ok()
|
||||
.and_then(|count| count.parse().ok())
|
||||
.unwrap_or(default_seeds);
|
||||
(1..=count).collect()
|
||||
}
|
||||
};
|
||||
let failures: Vec<String> = seeds
|
||||
.into_iter()
|
||||
.filter_map(|seed| run(seed, profile).err())
|
||||
.collect();
|
||||
assert!(failures.is_empty(), "\n{}", failures.join("\n\n"));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
static ONE_CLIENT_LAN: Profile = Profile {
|
||||
name: "one-client-lan",
|
||||
clients: 1,
|
||||
steps: 80,
|
||||
faults: Faults::lan,
|
||||
link_flaps: false,
|
||||
restarts: false,
|
||||
deletes_mid_upload: false,
|
||||
settle_budget: Some(Duration::from_secs(4)),
|
||||
syscall_budget: Duration::from_secs(2),
|
||||
drain_budget: Duration::from_secs(20),
|
||||
};
|
||||
|
||||
static ONE_CLIENT_DELETES: Profile = Profile {
|
||||
name: "one-client-deletes",
|
||||
clients: 1,
|
||||
steps: 30,
|
||||
faults: Faults::lan,
|
||||
link_flaps: false,
|
||||
restarts: false,
|
||||
deletes_mid_upload: true,
|
||||
settle_budget: Some(Duration::from_secs(4)),
|
||||
syscall_budget: Duration::from_secs(2),
|
||||
drain_budget: Duration::from_secs(20),
|
||||
};
|
||||
|
||||
static ONE_CLIENT_ECHOES: Profile = Profile {
|
||||
name: "one-client-echoes",
|
||||
clients: 1,
|
||||
steps: 60,
|
||||
faults: Faults::echoes,
|
||||
link_flaps: false,
|
||||
restarts: false,
|
||||
deletes_mid_upload: false,
|
||||
settle_budget: Some(Duration::from_secs(6)),
|
||||
syscall_budget: Duration::from_secs(2),
|
||||
drain_budget: Duration::from_secs(20),
|
||||
};
|
||||
|
||||
static ONE_CLIENT_FLAKY: Profile = Profile {
|
||||
name: "one-client-flaky",
|
||||
clients: 1,
|
||||
steps: 80,
|
||||
faults: Faults::wifi,
|
||||
link_flaps: true,
|
||||
restarts: true,
|
||||
deletes_mid_upload: false,
|
||||
settle_budget: None,
|
||||
syscall_budget: Duration::from_secs(10),
|
||||
drain_budget: Duration::from_secs(120),
|
||||
};
|
||||
|
||||
/// Wi-Fi's latency, lag and lost replies without the outages: the queue
|
||||
/// has to drain within a settle, not merely by the end.
|
||||
static ONE_CLIENT_WIFI: Profile = Profile {
|
||||
name: "one-client-wifi",
|
||||
clients: 1,
|
||||
steps: 80,
|
||||
faults: Faults::wifi,
|
||||
link_flaps: false,
|
||||
restarts: false,
|
||||
deletes_mid_upload: false,
|
||||
settle_budget: Some(Duration::from_secs(30)),
|
||||
syscall_budget: Duration::from_secs(10),
|
||||
drain_budget: Duration::from_secs(120),
|
||||
};
|
||||
|
||||
static THREE_CLIENTS: Profile = Profile {
|
||||
name: "three-clients",
|
||||
clients: 3,
|
||||
steps: 90,
|
||||
faults: Faults::lan,
|
||||
link_flaps: true,
|
||||
restarts: true,
|
||||
deletes_mid_upload: false,
|
||||
settle_budget: Some(Duration::from_secs(4)),
|
||||
syscall_budget: Duration::from_secs(2),
|
||||
drain_budget: Duration::from_secs(60),
|
||||
};
|
||||
|
||||
#[test]
|
||||
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse --lib sim::run -- --ignored`"]
|
||||
fn one_client_on_a_good_link() {
|
||||
run_seeds(&ONE_CLIENT_LAN, 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse --lib sim::run -- --ignored`"]
|
||||
fn one_client_deleting_files_as_they_upload() {
|
||||
run_seeds(&ONE_CLIENT_DELETES, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse --lib sim::run -- --ignored`"]
|
||||
fn one_client_with_stale_hashes_and_reordered_echoes() {
|
||||
run_seeds(&ONE_CLIENT_ECHOES, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse --lib sim::run -- --ignored`"]
|
||||
fn one_client_on_a_flaky_link() {
|
||||
run_seeds(&ONE_CLIENT_FLAKY, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse --lib sim::run -- --ignored`"]
|
||||
fn one_client_on_a_slow_link() {
|
||||
run_seeds(&ONE_CLIENT_WIFI, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse --lib sim::run -- --ignored`"]
|
||||
fn three_clients_one_writer_each() {
|
||||
run_seeds(&THREE_CLIENTS, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conflict_copies_are_recognised_by_name() {
|
||||
assert!(is_conflict_copy("c0/x/a (sync-conflict 1790000000).txt"));
|
||||
assert!(is_conflict_copy("recovered-XrPUSW5W"));
|
||||
assert!(!is_conflict_copy("c0/x/a.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_write_kept_because_its_file_is_gone_is_recognised() {
|
||||
let kept = "queued write conflicts; keeping a conflict copy uid=simvol~L10 \
|
||||
name=\"f.txt\" reason=\"the file was trashed remotely\"";
|
||||
assert!(is_own_delete_kept(kept));
|
||||
assert!(is_own_delete_kept(
|
||||
&kept.replace("was trashed", "no longer exists")
|
||||
));
|
||||
assert!(!is_own_delete_kept(
|
||||
&kept.replace("the file was trashed remotely", "revision changed")
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
//! Turns a call through a mount that never returns into a failed seed.
|
||||
//!
|
||||
//! A deadlock in a daemon leaves the run's own thread blocked in the kernel,
|
||||
//! where no budget check is reached, and a CI job then waits for its timeout.
|
||||
//! The watchdog runs beside the run. When what it was armed for outlives its
|
||||
//! limit, it aborts the FUSE connections of the run's mounts, so the blocked
|
||||
//! call fails and the run reports the hang, and writes where every thread was
|
||||
//! to `stacks.txt`.
|
||||
//!
|
||||
//! The stacks are taken in the process: no tracer can stop a thread that
|
||||
//! waits on a FUSE request, so `gdb` and `eu-stack` wait as long as the hang.
|
||||
//! Each thread is sent [`stack_signal`] and captures its own backtrace in the
|
||||
//! handler. A thread in the kernel takes the signal only once it leaves, so
|
||||
//! the mounts are aborted after the others have answered, and its backtrace
|
||||
//! is the call it was blocked in. Its kernel wait channel is recorded first.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use parking_lot::Mutex;
|
||||
|
||||
use super::daemon::wait_until;
|
||||
|
||||
/// How long the threads that are not blocked in the kernel have to answer,
|
||||
/// and the blocked ones once the mounts are aborted.
|
||||
const ANSWER: Duration = Duration::from_secs(5);
|
||||
|
||||
pub(crate) struct Watchdog {
|
||||
shared: Arc<Shared>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
struct Shared {
|
||||
/// The run's directory: its mounts are below it, and the stacks go in it.
|
||||
dir: PathBuf,
|
||||
armed: Mutex<Option<Armed>>,
|
||||
/// What hung, once something has.
|
||||
fired: Mutex<Option<Hung>>,
|
||||
stop: AtomicBool,
|
||||
}
|
||||
|
||||
struct Armed {
|
||||
what: String,
|
||||
limit: Duration,
|
||||
since: Instant,
|
||||
}
|
||||
|
||||
/// What hung, and where every thread was then.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct Hung {
|
||||
pub(crate) what: String,
|
||||
pub(crate) stacks: String,
|
||||
}
|
||||
|
||||
/// Disarms the watchdog when dropped.
|
||||
pub(crate) struct Watching(Arc<Shared>);
|
||||
|
||||
impl Watchdog {
|
||||
pub(crate) fn new(dir: &Path) -> Self {
|
||||
let shared = Arc::new(Shared {
|
||||
dir: dir.to_path_buf(),
|
||||
armed: Mutex::new(None),
|
||||
fired: Mutex::new(None),
|
||||
stop: AtomicBool::new(false),
|
||||
});
|
||||
let thread = {
|
||||
let shared = shared.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("sim-watchdog".into())
|
||||
.spawn(move || watch(&shared))
|
||||
.expect("spawn the simulation watchdog")
|
||||
};
|
||||
Self {
|
||||
shared,
|
||||
thread: Some(thread),
|
||||
}
|
||||
}
|
||||
|
||||
/// Watch `what` until the guard is dropped, and abort the mounts if it is
|
||||
/// still going after `limit`.
|
||||
pub(crate) fn arm(&self, limit: Duration, what: impl Into<String>) -> Watching {
|
||||
*self.shared.armed.lock() = Some(Armed {
|
||||
what: what.into(),
|
||||
limit,
|
||||
since: Instant::now(),
|
||||
});
|
||||
Watching(self.shared.clone())
|
||||
}
|
||||
|
||||
/// What hung, if something did. Call [`Watchdog::stop`] first, or the
|
||||
/// stacks may still be on their way.
|
||||
pub(crate) fn fired(&self) -> Option<Hung> {
|
||||
self.shared.fired.lock().clone()
|
||||
}
|
||||
|
||||
/// Stop watching, once the stacks of a hang that was caught are taken.
|
||||
pub(crate) fn stop(&mut self) {
|
||||
self.shared.stop.store(true, Ordering::Relaxed);
|
||||
if let Some(thread) = self.thread.take() {
|
||||
let _ = thread.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Watching {
|
||||
fn drop(&mut self) {
|
||||
*self.0.armed.lock() = None;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Watchdog {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
|
||||
fn watch(shared: &Shared) {
|
||||
while !shared.stop.load(Ordering::Relaxed) {
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
let hung = shared
|
||||
.armed
|
||||
.lock()
|
||||
.as_ref()
|
||||
.filter(|armed| armed.since.elapsed() > armed.limit)
|
||||
.map(|armed| format!("{} has not returned after {:?}", armed.what, armed.limit));
|
||||
let Some(hung) = hung else { continue };
|
||||
*shared.armed.lock() = None;
|
||||
let path = shared.dir.join("stacks.txt");
|
||||
// Before anything is let go: the run reports the hang, not whatever
|
||||
// the aborted call answers.
|
||||
*shared.fired.lock() = Some(Hung {
|
||||
what: format!("{hung}; the stacks are in {}", path.display()),
|
||||
stacks: String::new(),
|
||||
});
|
||||
let stacks = take_stacks(|| abort_mounts_below(&shared.dir));
|
||||
let what = match std::fs::write(&path, &stacks) {
|
||||
Ok(()) => format!("{hung}; the stacks are in {}", path.display()),
|
||||
Err(error) => format!("{hung}; the stacks could not be kept: {error}"),
|
||||
};
|
||||
*shared.fired.lock() = Some(Hung { what, stacks });
|
||||
}
|
||||
}
|
||||
|
||||
/// One thread of this process as the kernel sees it.
|
||||
struct Task {
|
||||
tid: i32,
|
||||
name: String,
|
||||
/// `D` while it waits in the kernel uninterruptibly.
|
||||
state: String,
|
||||
wchan: String,
|
||||
}
|
||||
|
||||
/// Where every other thread of this process is, with `release` called once
|
||||
/// those not blocked in the kernel have answered.
|
||||
fn take_stacks(release: impl FnOnce()) -> String {
|
||||
let own = gettid();
|
||||
let tasks = tasks();
|
||||
STACKS.lock().clear();
|
||||
install_stack_handler();
|
||||
for task in &tasks {
|
||||
if task.tid != own {
|
||||
// SAFETY: tgkill only sends a signal, whose handler is installed.
|
||||
unsafe {
|
||||
libc::syscall(
|
||||
libc::SYS_tgkill,
|
||||
std::process::id() as i32,
|
||||
task.tid,
|
||||
stack_signal(),
|
||||
)
|
||||
};
|
||||
}
|
||||
}
|
||||
let answered = |blocked: bool| {
|
||||
let stacks = STACKS.lock();
|
||||
tasks
|
||||
.iter()
|
||||
.filter(|task| task.tid != own && (blocked || task.state != "D"))
|
||||
.all(|task| stacks.contains_key(&task.tid))
|
||||
};
|
||||
wait_until(ANSWER, || answered(false));
|
||||
release();
|
||||
wait_until(ANSWER, || answered(true));
|
||||
let stacks = std::mem::take(&mut *STACKS.lock());
|
||||
let mut out = String::new();
|
||||
for task in tasks.iter().filter(|task| task.tid != own) {
|
||||
out.push_str(&format!(
|
||||
"thread {} {:?}, state {}, waiting in {}\n",
|
||||
task.tid, task.name, task.state, task.wchan
|
||||
));
|
||||
match stacks.get(&task.tid) {
|
||||
Some(stack) => out.push_str(&format!("{stack}\n")),
|
||||
None => out.push_str(" (no answer)\n\n"),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn tasks() -> Vec<Task> {
|
||||
let Ok(entries) = std::fs::read_dir("/proc/self/task") else {
|
||||
return Vec::new();
|
||||
};
|
||||
let read = |dir: &Path, file: &str| {
|
||||
std::fs::read_to_string(dir.join(file))
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_owned()
|
||||
};
|
||||
entries
|
||||
.flatten()
|
||||
.filter_map(|entry| {
|
||||
let tid = entry.file_name().to_str()?.parse().ok()?;
|
||||
let dir = entry.path();
|
||||
// The state follows the name in parentheses, which may hold spaces.
|
||||
let stat = read(&dir, "stat");
|
||||
let state = stat.rsplit_once(") ")?.1.split(' ').next()?.to_owned();
|
||||
Some(Task {
|
||||
tid,
|
||||
name: read(&dir, "comm"),
|
||||
state,
|
||||
wchan: read(&dir, "wchan"),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The backtraces the threads captured, by thread id.
|
||||
static STACKS: Mutex<BTreeMap<i32, std::backtrace::Backtrace>> = Mutex::new(BTreeMap::new());
|
||||
|
||||
/// The signal that asks a thread for its stack: one of the real-time signals
|
||||
/// that nothing in the daemon uses.
|
||||
fn stack_signal() -> i32 {
|
||||
libc::SIGRTMIN() + 7
|
||||
}
|
||||
|
||||
fn install_stack_handler() {
|
||||
static INSTALLED: std::sync::Once = std::sync::Once::new();
|
||||
extern "C" fn handler(_: i32) {
|
||||
// Not async-signal-safe: the capture allocates. The process is being
|
||||
// taken down for a hang anyway, and a thread interrupted inside the
|
||||
// allocator only fails to answer.
|
||||
let stack = std::backtrace::Backtrace::force_capture();
|
||||
STACKS.lock().insert(gettid(), stack);
|
||||
}
|
||||
INSTALLED.call_once(|| {
|
||||
// SAFETY: a zeroed sigaction with a handler and SA_RESTART is valid,
|
||||
// and the handler only touches `STACKS`.
|
||||
unsafe {
|
||||
let mut action: libc::sigaction = std::mem::zeroed();
|
||||
action.sa_sigaction = handler as extern "C" fn(i32) as usize;
|
||||
action.sa_flags = libc::SA_RESTART;
|
||||
libc::sigemptyset(&mut action.sa_mask);
|
||||
libc::sigaction(stack_signal(), &action, std::ptr::null_mut());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn gettid() -> i32 {
|
||||
// SAFETY: gettid has no arguments and cannot fail.
|
||||
unsafe { libc::gettid() }
|
||||
}
|
||||
|
||||
/// Abort the FUSE connection of every mount below `dir`. Its connection is
|
||||
/// named by the mount's device minor, and the mount's owner may abort it.
|
||||
fn abort_mounts_below(dir: &Path) {
|
||||
let Ok(mountinfo) = std::fs::read_to_string("/proc/self/mountinfo") else {
|
||||
return;
|
||||
};
|
||||
for line in mountinfo.lines() {
|
||||
let fields: Vec<&str> = line.split(' ').collect();
|
||||
let (Some(device), Some(mountpoint)) = (fields.get(2), fields.get(4)) else {
|
||||
continue;
|
||||
};
|
||||
if !Path::new(mountpoint).starts_with(dir) {
|
||||
continue;
|
||||
}
|
||||
if let Some((_, minor)) = device.split_once(':') {
|
||||
let _ = std::fs::write(format!("/sys/fs/fuse/connections/{minor}/abort"), "1");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::sim::daemon::scratch;
|
||||
|
||||
#[test]
|
||||
#[ignore = "signals every thread of the test process: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
||||
fn a_call_past_its_limit_is_reported_with_the_stacks() {
|
||||
let dir = scratch("watchdog");
|
||||
let mut watchdog = Watchdog::new(&dir);
|
||||
{
|
||||
let _watching = watchdog.arm(Duration::from_millis(100), "a nap");
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
}
|
||||
watchdog.stop();
|
||||
let hung = watchdog.fired().expect("the watchdog fired");
|
||||
assert!(
|
||||
hung.what.starts_with("a nap has not returned after"),
|
||||
"{hung:?}"
|
||||
);
|
||||
assert!(hung.stacks.contains("a_call_past_its_limit"), "{hung:?}");
|
||||
let kept = std::fs::read_to_string(dir.join("stacks.txt")).unwrap_or_default();
|
||||
assert_eq!(kept, hung.stacks);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_call_within_its_limit_is_not() {
|
||||
let dir = scratch("watchdog-quiet");
|
||||
let watchdog = Watchdog::new(&dir);
|
||||
drop(watchdog.arm(Duration::from_millis(100), "a quick call"));
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
assert_eq!(watchdog.fired(), None);
|
||||
drop(watchdog);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
+461
-42
@@ -128,7 +128,62 @@ pub(crate) struct WriteHandle {
|
||||
pub(crate) open_count: usize,
|
||||
}
|
||||
|
||||
/// A released write whose upload has not happened yet (offline.md Phase 3).
|
||||
impl WriteHandle {
|
||||
/// A handle on a file `create` just made: an empty base, so everything
|
||||
/// written through it is authored.
|
||||
///
|
||||
/// The base is `node` as it actually exists — the empty file the server just
|
||||
/// minted, or a queued create's placeholder — never the local clock or a
|
||||
/// blank. `queue_revision` turns it into `StagedWrite::based_on` and the
|
||||
/// drain compares the remote against that. A `now_secs()` mtime differs from
|
||||
/// the server's stamp by the create round-trip. A missing revision id drops
|
||||
/// the check to `(mtime, size)`, and moving or renaming the file advances
|
||||
/// its mtime on the server. Either way the first write to a brand-new file
|
||||
/// lands as a conflict copy of its own empty create (`docs/BUGS.md` B113).
|
||||
pub(crate) fn created(ino: u64, node: &Node, file: File, path: PathBuf) -> Self {
|
||||
Self {
|
||||
ino,
|
||||
uid: node.uid.clone(),
|
||||
file: Arc::new(file),
|
||||
path,
|
||||
written: Intervals::default(),
|
||||
len: 0,
|
||||
base_size: 0,
|
||||
base_mtime: node.modification_time,
|
||||
base_revision_id: crate::node_revision_id(node),
|
||||
dirty: false,
|
||||
open_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Write over `node` from now on: a revision this daemon has just put on the
|
||||
/// server under the handle, by sealing an upload or by landing the queued
|
||||
/// create the handle was opened on. The base the handle opened over is gone,
|
||||
/// and keeping it would make the drain read our own revision as another
|
||||
/// device's edit.
|
||||
pub(crate) fn rebase_onto(&mut self, node: &Node) {
|
||||
self.base_mtime = node.modification_time;
|
||||
self.base_size = crate::node_size(node);
|
||||
self.base_revision_id = crate::node_revision_id(node);
|
||||
}
|
||||
|
||||
/// Account for `[offset, end)` having been written through this handle.
|
||||
///
|
||||
/// A write past the end leaves a hole that reads as zeros, exactly like a
|
||||
/// grow through `setattr`, so the hole is authored too. Left unauthored, a
|
||||
/// hole below the base size was refilled from the base at commit: a file
|
||||
/// shrunk and then written past its new end got its old bytes back.
|
||||
pub(crate) fn record_write(&mut self, offset: u64, end: u64) {
|
||||
if offset > self.len {
|
||||
self.written.add(self.len, offset);
|
||||
}
|
||||
self.written.add(offset, end);
|
||||
self.len = self.len.max(end);
|
||||
self.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// A released write whose upload has not happened yet.
|
||||
///
|
||||
/// The bytes live in the content cache's staging dir and the intent lives in the
|
||||
/// `pending_op` table; this pairs them in memory so a read can be served without
|
||||
@@ -146,8 +201,29 @@ pub(crate) struct PendingRevision {
|
||||
/// time so this module stays free of `fuser`.
|
||||
pub(crate) type DirListing = Vec<(u64, bool, String)>;
|
||||
|
||||
/// Where [`State::next_ino`] starts: far above any local id a database hands
|
||||
/// out, so a fallback inode never names a node that has a row.
|
||||
pub(crate) const FALLBACK_INO: u64 = 1 << 48;
|
||||
|
||||
/// The inode of the node with local id `lid` ([`Db::lids_for`]). One above the
|
||||
/// lid, so no node takes [`crate::ROOT_INO`], which each session uses for its
|
||||
/// own root. The same in every mount and after a restart.
|
||||
pub(crate) fn lid_ino(lid: i64) -> u64 {
|
||||
lid.unsigned_abs() + crate::ROOT_INO
|
||||
}
|
||||
|
||||
/// The local id of the node at inode `ino`, the inverse of [`lid_ino`]:
|
||||
/// `None` for the root and a fallback inode.
|
||||
pub(crate) fn ino_lid(ino: u64) -> Option<i64> {
|
||||
(ino > crate::ROOT_INO && ino < FALLBACK_INO).then(|| (ino - crate::ROOT_INO) as i64)
|
||||
}
|
||||
|
||||
/// Mutable inode bookkeeping, guarded by a mutex because fuser drives the
|
||||
/// `Filesystem` trait through `&self`.
|
||||
///
|
||||
/// One tree for every mount of the daemon. Each FUSE session sees it from its
|
||||
/// own root ([`State::roots`]) and calls that root inode 1; `ProtonFs`
|
||||
/// translates at the kernel boundary.
|
||||
pub(crate) struct State {
|
||||
/// inode -> node metadata.
|
||||
pub(crate) entries: HashMap<u64, Entry>,
|
||||
@@ -156,6 +232,12 @@ pub(crate) struct State {
|
||||
/// Cached directory listings: parent inode -> child inodes. Presence of a
|
||||
/// key means the directory has been enumerated.
|
||||
pub(crate) children: HashMap<u64, Vec<u64>>,
|
||||
/// The inodes some session has been rooted at ([`State::install_root`]).
|
||||
/// A root is its own parent. Kept after its session unmounts: the node is
|
||||
/// still one of ours.
|
||||
pub(crate) roots: HashSet<u64>,
|
||||
/// The next inode for a node the database could not give a local id, from
|
||||
/// a range lids never reach ([`FALLBACK_INO`]).
|
||||
pub(crate) next_ino: u64,
|
||||
/// Shared write state keyed by inode. Concurrent writers share the scratch file.
|
||||
pub(crate) active_writes: HashMap<u64, WriteHandle>,
|
||||
@@ -171,13 +253,13 @@ pub(crate) struct State {
|
||||
/// two pages shifted every later entry, and the caller silently skipped or
|
||||
/// repeated whatever crossed the page boundary. Each entry is
|
||||
/// `(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>>,
|
||||
/// Resident inode attributes whose effective access changed during an
|
||||
/// intern/root refresh. Core drains this set and notifies the matching
|
||||
/// kernel only after releasing the State lock.
|
||||
pub(crate) access_changes: HashSet<u64>,
|
||||
/// Persisted shared-root authority, loaded once per inode space and updated
|
||||
/// Persisted shared-root authority, loaded once and updated
|
||||
/// on every explicit role/tombstone change. Descendant inheritance reads
|
||||
/// this map instead of issuing one SQLite query per interned inode.
|
||||
pub(crate) share_access: HashMap<NodeUid, Access>,
|
||||
@@ -245,15 +327,6 @@ pub(crate) struct StateGuard<'a> {
|
||||
db: &'a Arc<Db>,
|
||||
}
|
||||
|
||||
/// Take a mount's inode lock through a [`StateGuard`], for the loops that walk
|
||||
/// every live mount rather than this `Core`'s own.
|
||||
pub(crate) fn lock_state<'a>(
|
||||
state: &'a parking_lot::Mutex<State>,
|
||||
db: &'a Arc<Db>,
|
||||
) -> StateGuard<'a> {
|
||||
StateGuard::new(state.lock(), db)
|
||||
}
|
||||
|
||||
impl<'a> StateGuard<'a> {
|
||||
pub(crate) fn new(guard: parking_lot::MutexGuard<'a, State>, db: &'a Arc<Db>) -> Self {
|
||||
Self {
|
||||
@@ -294,9 +367,6 @@ impl Drop for StateGuard<'_> {
|
||||
impl State {
|
||||
/// A fresh, empty inode space over `db`.
|
||||
///
|
||||
/// `next_ino` differs by caller: a real mount hands out [`crate::ROOT_INO`]
|
||||
/// to its root and starts allocating above it, while a bare state under test
|
||||
/// has no root and starts at 1.
|
||||
/// Apply the write-throughs this state owes SQLite, here and now.
|
||||
///
|
||||
/// Production code never calls this: [`StateGuard`] releases the inode lock
|
||||
@@ -323,12 +393,15 @@ impl State {
|
||||
self.db.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn new(db: Arc<Db>, share_access: HashMap<NodeUid, Access>, next_ino: u64) -> Self {
|
||||
/// A fresh, empty tree over `db`. A node's inode comes from its local id
|
||||
/// ([`lid_ino`]).
|
||||
pub(crate) fn new(db: Arc<Db>, share_access: HashMap<NodeUid, Access>) -> Self {
|
||||
Self {
|
||||
entries: HashMap::new(),
|
||||
by_uid: HashMap::new(),
|
||||
children: HashMap::new(),
|
||||
next_ino,
|
||||
roots: HashSet::new(),
|
||||
next_ino: FALLBACK_INO,
|
||||
active_writes: HashMap::new(),
|
||||
handles: HashMap::new(),
|
||||
next_fh: 1,
|
||||
@@ -339,18 +412,55 @@ impl State {
|
||||
outbox: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// Whether `uid` names an owned node currently reachable from this mount's
|
||||
/// root. Resident entries can outlive their dentries for open-handle and
|
||||
/// access-revocation semantics, so `by_uid` membership alone is not proof
|
||||
/// that a control-socket operation may address the node.
|
||||
pub(crate) fn owns_visible_uid(&self, uid: &NodeUid) -> bool {
|
||||
let Some(root) = self.entries.get(&crate::ROOT_INO) else {
|
||||
return false;
|
||||
};
|
||||
if uid.volume_id != root.uid.volume_id {
|
||||
return false;
|
||||
/// Make `root` the root of a session and return its inode, which comes
|
||||
/// from its local id like any other node's.
|
||||
///
|
||||
/// The root is always owned. It must never be classified as a shared root,
|
||||
/// even when the API returns membership data (which happens when the owner
|
||||
/// shares the folder with someone else), so a stale share row a downgrade
|
||||
/// may have left for it is dropped. A node already in the tree keeps its
|
||||
/// inode and its lookups, and stops naming a parent: a session's `..` at
|
||||
/// its root is the root.
|
||||
pub(crate) fn install_root(&mut self, root: Node) -> u64 {
|
||||
if let Err(e) = self.db.upsert_node(&root) {
|
||||
warn!(uid = %root.uid, error = %e, "db upsert root failed");
|
||||
}
|
||||
self.share_access.remove(&root.uid);
|
||||
let _ = self.db.delete_share_access(&root.uid);
|
||||
let ino = match self.by_uid.get(&root.uid) {
|
||||
Some(&ino) => ino,
|
||||
None => self.new_inos(&[&root])[&root.uid],
|
||||
};
|
||||
if let Some(old) = self.entries.get(&ino).map(|e| e.parent)
|
||||
&& old != ino
|
||||
&& let Some(kids) = self.children.get_mut(&old)
|
||||
{
|
||||
kids.retain(|&k| k != ino);
|
||||
}
|
||||
self.by_uid.insert(root.uid.clone(), ino);
|
||||
let lookup_count = self.entries.get(&ino).map_or(1, |e| e.lookup_count.max(1));
|
||||
self.entries.insert(
|
||||
ino,
|
||||
Entry {
|
||||
uid: root.uid.clone(),
|
||||
parent: ino,
|
||||
node: root,
|
||||
access: Access::Owner,
|
||||
lookup_count,
|
||||
open_count: 0,
|
||||
unlinked: false,
|
||||
},
|
||||
);
|
||||
self.roots.insert(ino);
|
||||
ino
|
||||
}
|
||||
|
||||
/// Whether `uid` names an owned node currently reachable from one of
|
||||
/// `roots`, the roots of the sessions that are mounted. Resident entries
|
||||
/// can outlive their dentries for open-handle and access-revocation
|
||||
/// semantics, so `by_uid` membership alone is not proof that a
|
||||
/// control-socket operation may address the node.
|
||||
pub(crate) fn owns_visible_uid(&self, uid: &NodeUid, roots: &[u64]) -> bool {
|
||||
let Some(&target) = self.by_uid.get(uid) else {
|
||||
return false;
|
||||
};
|
||||
@@ -363,7 +473,9 @@ impl State {
|
||||
let Some(entry) = self.entries.get(¤t) else {
|
||||
return false;
|
||||
};
|
||||
if entry.uid.volume_id != root.uid.volume_id
|
||||
// The node's root scopes it to its own volume, excluding foreign
|
||||
// shared-with-me residents from uid-addressed sharing.
|
||||
if entry.uid.volume_id != uid.volume_id
|
||||
|| entry.unlinked
|
||||
|| entry.node.trashed
|
||||
|| entry.uid != entry.node.uid
|
||||
@@ -371,7 +483,7 @@ impl State {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if current == crate::ROOT_INO {
|
||||
if roots.contains(¤t) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -413,15 +525,17 @@ impl State {
|
||||
self.access_changes.drain().collect()
|
||||
}
|
||||
|
||||
/// Whether `uid` lives on the volume this mount is rooted in.
|
||||
/// Whether `uid` lives on a volume some mount is rooted in.
|
||||
///
|
||||
/// Used to decide how a node with no resolvable parent is classified:
|
||||
/// our own volume fails open (it is owned content), a foreign volume
|
||||
/// fails closed (it can only be there because of a share).
|
||||
pub(crate) fn is_own_volume(&self, uid: &NodeUid) -> bool {
|
||||
self.entries
|
||||
.get(&crate::ROOT_INO)
|
||||
.is_some_and(|root| root.uid.volume_id == uid.volume_id)
|
||||
self.roots.iter().any(|ino| {
|
||||
self.entries
|
||||
.get(ino)
|
||||
.is_some_and(|root| root.uid.volume_id == uid.volume_id)
|
||||
})
|
||||
}
|
||||
|
||||
fn stored_share_access(&self, uid: &NodeUid) -> Option<Access> {
|
||||
@@ -770,11 +884,59 @@ impl State {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn intern(&mut self, parent: u64, node: Node) -> u64 {
|
||||
/// Write `node` through as it is, in order with the tree's other changes.
|
||||
pub(crate) fn write_through(&mut self, node: Node) {
|
||||
self.outbox.push(DbWrite::Upsert(vec![node]));
|
||||
}
|
||||
|
||||
pub(crate) fn intern(&mut self, parent: u64, mut node: Node) -> u64 {
|
||||
self.keep_revision_mtime(&mut node);
|
||||
self.outbox.push(DbWrite::Upsert(vec![node.clone()]));
|
||||
self.intern_mem(parent, node)
|
||||
}
|
||||
|
||||
/// Keep what is already known of a file whose content has not changed.
|
||||
///
|
||||
/// Drive stamps a link's `ModifyTime` on a rename or a move as well, while
|
||||
/// POSIX leaves a file's mtime alone there. The content cache is keyed by
|
||||
/// it, too: taking the new stamp made the cached bytes unreachable, so the
|
||||
/// next partial write could not fill its gaps (docs/BUGS.md B123). The
|
||||
/// active revision id says whether the content is still the same, and so
|
||||
/// whether the size and digest a listing left out are still the ones known.
|
||||
fn keep_revision_mtime(&self, node: &mut Node) {
|
||||
let Some(entry) = self
|
||||
.by_uid
|
||||
.get(&node.uid)
|
||||
.and_then(|ino| self.entries.get(ino))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if let (
|
||||
NodeKind::File {
|
||||
active_revision_id: Some(now),
|
||||
claimed_size,
|
||||
content_sha1,
|
||||
..
|
||||
},
|
||||
NodeKind::File {
|
||||
active_revision_id: Some(was),
|
||||
claimed_size: size_was,
|
||||
content_sha1: sha1_was,
|
||||
..
|
||||
},
|
||||
) = (&mut node.kind, &entry.node.kind)
|
||||
&& now == was
|
||||
{
|
||||
node.modification_time = entry.node.modification_time;
|
||||
if claimed_size.is_none() {
|
||||
*claimed_size = *size_was;
|
||||
}
|
||||
if content_sha1.is_none() {
|
||||
content_sha1.clone_from(sha1_was);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Allocate (or reuse) a stable inode for a node, updating the hot-cache maps
|
||||
/// only. Every caller owes the DB a write-through — see the callers below;
|
||||
/// the split exists so a batch can pay for one transaction instead of `n`.
|
||||
@@ -784,11 +946,24 @@ impl State {
|
||||
}
|
||||
|
||||
fn intern_mem_with_access(&mut self, parent: u64, node: Node, access: Access) -> u64 {
|
||||
self.intern_mem_at(parent, node, access, None)
|
||||
}
|
||||
|
||||
/// [`State::intern_mem_with_access`], with the inode a new entry takes when
|
||||
/// the caller already has it ([`State::new_inos`]).
|
||||
fn intern_mem_at(
|
||||
&mut self,
|
||||
parent: u64,
|
||||
mut node: Node,
|
||||
access: Access,
|
||||
ino: Option<u64>,
|
||||
) -> u64 {
|
||||
if let Some(&ino) = self.by_uid.get(&node.uid) {
|
||||
let changed = self
|
||||
.entries
|
||||
.get(&ino)
|
||||
.is_some_and(|entry| entry.access != access);
|
||||
self.keep_open_write_size(ino, &mut node);
|
||||
if let Some(e) = self.entries.get_mut(&ino) {
|
||||
e.node = node;
|
||||
e.parent = parent;
|
||||
@@ -801,8 +976,13 @@ impl State {
|
||||
}
|
||||
return ino;
|
||||
}
|
||||
let ino = self.next_ino;
|
||||
self.next_ino += 1;
|
||||
let ino = match ino {
|
||||
Some(ino) => ino,
|
||||
None => self
|
||||
.new_inos(&[&node])
|
||||
.remove(&node.uid)
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
self.by_uid.insert(node.uid.clone(), ino);
|
||||
self.entries.insert(
|
||||
ino,
|
||||
@@ -819,6 +999,41 @@ impl State {
|
||||
ino
|
||||
}
|
||||
|
||||
/// The inodes for nodes that have no entry yet, from their local ids, in
|
||||
/// one transaction. Should the database fail, they come from
|
||||
/// [`State::next_ino`] instead, so the mount keeps working, with inodes
|
||||
/// that last only this run. So do the inodes of virtual folders, which are
|
||||
/// never stored.
|
||||
fn new_inos(&mut self, nodes: &[&Node]) -> HashMap<NodeUid, u64> {
|
||||
let (virtual_nodes, stored): (Vec<&Node>, Vec<&Node>) = nodes
|
||||
.iter()
|
||||
.partition(|node| crate::r#virtual::is_virtual_uid(&node.uid));
|
||||
let mut inos = match self.db.lids_for(&stored) {
|
||||
Ok(lids) => stored
|
||||
.iter()
|
||||
.zip(lids)
|
||||
.map(|(node, lid)| (node.uid.clone(), lid_ino(lid)))
|
||||
.collect(),
|
||||
Err(error) => {
|
||||
warn!(%error, "no local ids for new inodes; numbering them for this run");
|
||||
self.run_inos(&stored)
|
||||
}
|
||||
};
|
||||
inos.extend(self.run_inos(&virtual_nodes));
|
||||
inos
|
||||
}
|
||||
|
||||
fn run_inos(&mut self, nodes: &[&Node]) -> HashMap<NodeUid, u64> {
|
||||
nodes
|
||||
.iter()
|
||||
.map(|node| {
|
||||
let ino = self.next_ino;
|
||||
self.next_ino += 1;
|
||||
(node.uid.clone(), ino)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Decrement lookup count for an inode and prune if lookup_count == 0 && open_count == 0 && unlinked.
|
||||
pub(crate) fn forget_lookup(&mut self, ino: u64, nlookup: u64) -> Option<(u64, String)> {
|
||||
if let Some(entry) = self.entries.get_mut(&ino) {
|
||||
@@ -833,6 +1048,23 @@ impl State {
|
||||
|
||||
/// Allocate (or reuse) a stable inode for a node that came *from* the
|
||||
/// database, which is why nothing is written back.
|
||||
/// Carry the size and mtime of a file open for writing over onto `node`,
|
||||
/// which is about to replace its entry.
|
||||
///
|
||||
/// The open handle is ahead of anything Drive can report until release
|
||||
/// queues it. Taking the remote's size shrank a SQLite database to its empty
|
||||
/// create while SQLite held it open, and the kernel then capped every read
|
||||
/// at that size (docs/BUGS.md B109).
|
||||
pub(crate) fn keep_open_write_size(&self, ino: u64, node: &mut Node) {
|
||||
let (Some(aw), Some(entry)) = (self.active_writes.get(&ino), self.entries.get(&ino)) else {
|
||||
return;
|
||||
};
|
||||
if let NodeKind::File { claimed_size, .. } = &mut node.kind {
|
||||
*claimed_size = Some(aw.len as i64);
|
||||
node.modification_time = entry.node.modification_time;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn intern_from_db(&mut self, parent: u64, node: Node) -> u64 {
|
||||
self.intern_mem(parent, node)
|
||||
}
|
||||
@@ -842,11 +1074,27 @@ impl State {
|
||||
///
|
||||
/// This is what keeps `ls` on a large folder quick: one commit for the
|
||||
/// listing, rather than one autocommit — and one fsync — per child.
|
||||
pub(crate) fn intern_batch(&mut self, parent: u64, nodes: Vec<Node>) -> Vec<u64> {
|
||||
pub(crate) fn intern_batch(&mut self, parent: u64, mut nodes: Vec<Node>) -> Vec<u64> {
|
||||
for node in &mut nodes {
|
||||
self.keep_revision_mtime(node);
|
||||
}
|
||||
self.outbox.push(DbWrite::Upsert(nodes.clone()));
|
||||
let fresh: Vec<&Node> = nodes
|
||||
.iter()
|
||||
.filter(|node| !self.by_uid.contains_key(&node.uid))
|
||||
.collect();
|
||||
let mut inos = if fresh.is_empty() {
|
||||
HashMap::new()
|
||||
} else {
|
||||
self.new_inos(&fresh)
|
||||
};
|
||||
nodes
|
||||
.into_iter()
|
||||
.map(|node| self.intern_mem(parent, node))
|
||||
.map(|node| {
|
||||
let access = self.access_for_node(parent, &node);
|
||||
let ino = inos.remove(&node.uid);
|
||||
self.intern_mem_at(parent, node, access, ino)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -950,7 +1198,7 @@ impl State {
|
||||
/// 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
|
||||
/// 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(
|
||||
&mut self,
|
||||
ino: u64,
|
||||
@@ -1069,7 +1317,7 @@ impl State {
|
||||
/// 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
|
||||
/// 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
|
||||
/// the one mutation here whose write-through the caller must do itself:
|
||||
/// failure has to be reported, not logged. Dropping the row silently means
|
||||
@@ -1088,6 +1336,13 @@ mod tests {
|
||||
use proton_drive_rs::ShareMembership;
|
||||
use proton_drive_rs::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 {
|
||||
NodeUid::new(VolumeId::from("vol"), LinkId::from(link))
|
||||
}
|
||||
@@ -1164,7 +1419,7 @@ mod tests {
|
||||
let dir = TempDir::new();
|
||||
let db = Db::open(&dir.0.join("cache.db")).unwrap();
|
||||
let share_access = db.all_share_access().unwrap();
|
||||
let st = State::new(Arc::new(db), share_access, 1);
|
||||
let st = State::new(Arc::new(db), share_access);
|
||||
(st, dir)
|
||||
}
|
||||
|
||||
@@ -1182,6 +1437,36 @@ mod tests {
|
||||
(st, dir, src, dst)
|
||||
}
|
||||
|
||||
/// A node's inode comes from its row's local id, so it is the same in
|
||||
/// every mount, after the hot cache drops it, and after a restart.
|
||||
#[test]
|
||||
fn a_node_keeps_its_inode_across_mounts_evictions_and_restarts() {
|
||||
let (mut st, _dir) = state();
|
||||
let inos = st.intern_batch(
|
||||
0,
|
||||
vec![
|
||||
node("a", "root", "a.txt", false),
|
||||
node("b", "root", "b.txt", false),
|
||||
],
|
||||
);
|
||||
st.flush_outbox();
|
||||
let db = st.db.clone();
|
||||
assert_ne!(inos[0], inos[1]);
|
||||
assert!(!inos.contains(&crate::ROOT_INO));
|
||||
|
||||
let mut other = State::new(db.clone(), HashMap::new());
|
||||
assert_eq!(other.intern(0, node("b", "root", "b.txt", false)), inos[1]);
|
||||
|
||||
st.forget_mem(&uid("a"));
|
||||
assert_eq!(st.intern(0, node("a", "root", "a.txt", false)), inos[0]);
|
||||
|
||||
let mut restarted = State::new(db, HashMap::new());
|
||||
assert_eq!(
|
||||
restarted.intern_batch(0, vec![node("a", "root", "a.txt", false)]),
|
||||
[inos[0]]
|
||||
);
|
||||
}
|
||||
|
||||
/// Audit A5. Moving a file out of a folder leaves that folder's DB row
|
||||
/// claiming a complete listing, so once the hot cache drops it, remote
|
||||
/// changes under it stop being seen. `relocate` has to clear both sides.
|
||||
@@ -1247,7 +1532,7 @@ mod tests {
|
||||
let root = uid("root").to_string();
|
||||
let state = parking_lot::Mutex::new(st);
|
||||
{
|
||||
let mut guard = lock_state(&state, &db);
|
||||
let mut guard = StateGuard::new(state.lock(), &db);
|
||||
guard.intern(0, node("root", "none", "My Files", true));
|
||||
assert!(
|
||||
db.node_by_uid(&root).unwrap().is_none(),
|
||||
@@ -1490,6 +1775,7 @@ mod tests {
|
||||
})
|
||||
.collect();
|
||||
st.next_ino = crate::ROOT_INO + nodes.len() as u64;
|
||||
st.roots.insert(crate::ROOT_INO);
|
||||
// Reverse order so entries are inserted before their parents, matching
|
||||
// the hydration path's non-topological materialization.
|
||||
for (i, node) in nodes.iter().enumerate().rev() {
|
||||
@@ -1863,4 +2149,137 @@ mod tests {
|
||||
let shared = st.intern(owned_root, shared);
|
||||
assert_eq!(st.entries[&shared].access, Access::Viewer);
|
||||
}
|
||||
|
||||
fn at_revision(mut node: Node, revision: &str, mtime: i64) -> Node {
|
||||
node.modification_time = mtime;
|
||||
if let NodeKind::File {
|
||||
active_revision_id, ..
|
||||
} = &mut node.kind
|
||||
{
|
||||
*active_revision_id = Some(revision.into());
|
||||
}
|
||||
node
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_renamed_file_keeps_its_mtime_until_its_content_changes() {
|
||||
let (mut st, _dir) = state();
|
||||
let root = st.intern(0, node("root", "none", "My Files", true));
|
||||
let mut file = node("file", "root", "a.txt", false);
|
||||
if let NodeKind::File {
|
||||
claimed_size,
|
||||
total_size_on_storage,
|
||||
..
|
||||
} = &mut file.kind
|
||||
{
|
||||
*claimed_size = Some(7);
|
||||
*total_size_on_storage = 519;
|
||||
}
|
||||
let ino = st.intern(root, at_revision(file.clone(), "r1", 100));
|
||||
// Drive stamps the link on a rename; the content is still r1.
|
||||
let mut renamed = at_revision(file.clone(), "r1", 160);
|
||||
renamed.name = "b.txt".into();
|
||||
st.intern_batch(root, vec![renamed]);
|
||||
assert_eq!(st.entries[&ino].node.modification_time, 100);
|
||||
assert_eq!(st.entries[&ino].node.name, "b.txt");
|
||||
// A listing that leaves the size out still describes revision r1.
|
||||
let mut bare = at_revision(file.clone(), "r1", 170);
|
||||
assert_eq!(crate::node_size(&st.entries[&ino].node), 7);
|
||||
if let NodeKind::File { claimed_size, .. } = &mut bare.kind {
|
||||
*claimed_size = None;
|
||||
}
|
||||
st.intern(root, bare);
|
||||
assert_eq!(st.entries[&ino].node.modification_time, 100);
|
||||
assert_eq!(crate::node_size(&st.entries[&ino].node), 7);
|
||||
st.intern(root, at_revision(file, "r2", 200));
|
||||
assert_eq!(st.entries[&ino].node.modification_time, 200);
|
||||
}
|
||||
|
||||
fn handle(len: u64, base_size: u64) -> WriteHandle {
|
||||
let path = std::env::temp_dir().join(format!("pdfs-handle-test-{}", std::process::id()));
|
||||
let file = File::create(&path).unwrap();
|
||||
let _ = std::fs::remove_file(&path);
|
||||
WriteHandle {
|
||||
ino: 2,
|
||||
uid: uid("file"),
|
||||
file: Arc::new(file),
|
||||
path,
|
||||
written: Intervals::default(),
|
||||
len,
|
||||
base_size,
|
||||
base_mtime: 0,
|
||||
base_revision_id: None,
|
||||
dirty: false,
|
||||
open_count: 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// A file created offline is written while its queued create lands. The
|
||||
/// placeholder had no revision, so the handle takes the empty file the
|
||||
/// create made as its base (docs/BUGS.md B113).
|
||||
#[test]
|
||||
fn a_handle_on_a_placeholder_is_based_on_the_create_that_landed() {
|
||||
let mut h = handle(0, 0);
|
||||
assert_eq!(h.base_revision_id, None);
|
||||
let mut landed = node("real", "root", "f.mkv", false);
|
||||
landed.modification_time = 160;
|
||||
if let NodeKind::File {
|
||||
active_revision_id, ..
|
||||
} = &mut landed.kind
|
||||
{
|
||||
*active_revision_id = Some("empty".into());
|
||||
}
|
||||
h.rebase_onto(&landed);
|
||||
assert_eq!(h.base_mtime, 160);
|
||||
assert_eq!(h.base_size, 0);
|
||||
assert_eq!(h.base_revision_id.as_deref(), Some("empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_listing_does_not_shrink_a_file_open_for_writing() {
|
||||
let (mut st, _dir) = state();
|
||||
let ino = st.intern(0, node("file", "root", "workload.db", false));
|
||||
let mut h = handle(16384, 0);
|
||||
h.ino = ino;
|
||||
st.active_writes.insert(ino, h);
|
||||
st.set_size(ino, 16384);
|
||||
st.touch_mtime(ino, 500);
|
||||
|
||||
// The remote still holds the empty file the create sealed.
|
||||
st.intern(0, node("file", "root", "workload.db", false));
|
||||
assert_eq!(crate::node_size(&st.entries[&ino].node), 16384);
|
||||
assert_eq!(st.entries[&ino].node.modification_time, 500);
|
||||
|
||||
// Once the handle is gone the remote is authoritative again.
|
||||
st.active_writes.remove(&ino);
|
||||
st.intern(0, node("file", "root", "workload.db", false));
|
||||
assert_eq!(crate::node_size(&st.entries[&ino].node), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_write_past_the_end_authors_the_hole_it_leaves() {
|
||||
// Opened over a 100-byte base, shrunk to 10, then written at 50: bytes
|
||||
// 10..50 are zeros now, not the base's bytes 10..50.
|
||||
let mut h = handle(100, 100);
|
||||
h.written.clip(10);
|
||||
h.len = 10;
|
||||
h.record_write(50, 60);
|
||||
assert_eq!(h.len, 60);
|
||||
assert_eq!(
|
||||
h.written.segments(0, 60),
|
||||
vec![(0, 10, false), (10, 60, true)],
|
||||
"only the head still comes from the base"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_write_inside_the_file_leaves_the_rest_to_the_base() {
|
||||
let mut h = handle(100, 100);
|
||||
h.record_write(40, 50);
|
||||
assert_eq!(h.len, 100);
|
||||
assert_eq!(
|
||||
h.written.segments(0, 100),
|
||||
vec![(0, 40, false), (40, 50, true), (50, 100, false)]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -68,7 +68,7 @@ impl Core {
|
||||
/// docs for the identical/divergent policy.
|
||||
pub(crate) fn run_conflict_sweep_loop(&self) {
|
||||
// Both waits are interruptible, so teardown does not have to sit out a
|
||||
// five-minute interval to join this thread (bugs.md B44).
|
||||
// five-minute interval to join this thread (`docs/BUGS.md` B44).
|
||||
if !self.shutdown.sleep(CONFLICT_SWEEP_WARMUP) {
|
||||
return;
|
||||
}
|
||||
@@ -165,7 +165,7 @@ impl Core {
|
||||
}
|
||||
match self
|
||||
.rt
|
||||
.block_on(self.client.trash_nodes(std::slice::from_ref(uid)))
|
||||
.block_on(self.drive.trash_nodes(std::slice::from_ref(uid)))
|
||||
.and_then(batch::into_unit)
|
||||
{
|
||||
Ok(()) => {}
|
||||
@@ -175,10 +175,9 @@ impl Core {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Every mount, not just the primary: a conflict copy inside a sync
|
||||
// folder lives in that fork's inode space, and leaving it hooked up
|
||||
// there keeps a node we have just trashed remotely visible and
|
||||
// readable there (`docs/BUGS.md` B74).
|
||||
// Leaving a conflict copy hooked up in the tree keeps a node we have
|
||||
// just trashed remotely visible and readable in whichever mount shows
|
||||
// it (`docs/BUGS.md` B74).
|
||||
self.for_each_state(|st| {
|
||||
st.forget_or_unlink(uid);
|
||||
});
|
||||
|
||||
+295
-48
@@ -1,4 +1,4 @@
|
||||
//! Two-way folder sync engine (devices.md Phase 2).
|
||||
//! Two-way folder sync engine.
|
||||
//!
|
||||
//! Each `mirror`-mode synced folder is kept mirrored between a local directory
|
||||
//! and a folder under this machine's Proton Drive device. A local filesystem
|
||||
@@ -73,6 +73,9 @@ enum Applied {
|
||||
Downloaded,
|
||||
/// A conflict copy was kept.
|
||||
Conflict,
|
||||
/// The local file to upload was deleted after the scan planned it. There is
|
||||
/// nothing left to send, and the next pass sees the deletion (B108).
|
||||
Vanished,
|
||||
}
|
||||
|
||||
impl Core {
|
||||
@@ -99,6 +102,9 @@ impl Core {
|
||||
|
||||
/// One reconcile pass over a mirror folder, updating its `state` column.
|
||||
fn reconcile_pass(&self, folder: &StoredSyncFolder) {
|
||||
if self.shutdown.is_stopping() {
|
||||
return;
|
||||
}
|
||||
// Hold the folder's lock for the whole pass so a mode switch can't evict the
|
||||
// local tree (and mount FUSE over it) while we walk and upload it.
|
||||
let lock = self.sync_lock(folder.id);
|
||||
@@ -145,8 +151,6 @@ impl Core {
|
||||
self.push_pass(folder.id, &local_root, &remote_root, &rules)
|
||||
} else {
|
||||
match self.do_reconcile(folder.id, &local_root, &remote_root, &rules) {
|
||||
Ok(outcome) => Ok(outcome),
|
||||
Err(PassAbort::Failed(e)) => Err(e),
|
||||
// The user asked for on-demand while this pass was running. Rather
|
||||
// than make them wait out a walk-and-download whose results are
|
||||
// about to be deleted, drop it here and do the only part that still
|
||||
@@ -161,6 +165,7 @@ impl Core {
|
||||
self.progress_begin(folder.id);
|
||||
self.push_pass(folder.id, &local_root, &remote_root, &rules)
|
||||
}
|
||||
result => result,
|
||||
}
|
||||
};
|
||||
self.progress_end(folder.id);
|
||||
@@ -213,7 +218,15 @@ impl Core {
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// A push pass is never interrupted, so only the stop gets here: the
|
||||
// folder keeps the state it had, which the next start's pass updates.
|
||||
Err(PassAbort::Stopped | PassAbort::Interrupted) => {
|
||||
info!(id = folder.id, "sync: pass cut short by the stop");
|
||||
let _ = self
|
||||
.db
|
||||
.sync_folder_set_state(folder.id, ¤t.state, current.last_sync);
|
||||
}
|
||||
Err(PassAbort::Failed(e)) => {
|
||||
warn!(id = folder.id, error = %e, "sync: reconcile failed");
|
||||
let _ = self
|
||||
.db
|
||||
@@ -291,7 +304,7 @@ impl Core {
|
||||
local_root: &Path,
|
||||
remote_root: &NodeUid,
|
||||
rules: &IgnoreRules,
|
||||
) -> Result<Outcome, String> {
|
||||
) -> Result<Outcome, PassAbort> {
|
||||
let baseline = self
|
||||
.db
|
||||
.sync_entries(folder_id)
|
||||
@@ -367,7 +380,7 @@ impl Core {
|
||||
HashMap::new()
|
||||
} else {
|
||||
self.rt
|
||||
.block_on(self.client.enumerate_nodes_light(&check))
|
||||
.block_on(self.drive.enumerate_nodes_light(&check))
|
||||
.map_err(|e| format!("resolve changed nodes: {e}"))?
|
||||
.into_iter()
|
||||
.filter(|n| !n.trashed)
|
||||
@@ -397,7 +410,7 @@ impl Core {
|
||||
&mut remote_dirs,
|
||||
std::mem::take(&mut batch),
|
||||
&mut outcome,
|
||||
);
|
||||
)?;
|
||||
batch_depth = depth;
|
||||
}
|
||||
let item = &local[rel];
|
||||
@@ -495,7 +508,7 @@ impl Core {
|
||||
&mut remote_dirs,
|
||||
std::mem::take(&mut batch),
|
||||
&mut outcome,
|
||||
);
|
||||
)?;
|
||||
|
||||
// Paths the baseline knows that are no longer on disk: the user deleted them
|
||||
// locally, and the deletion has to reach Drive before the mount starts
|
||||
@@ -522,7 +535,7 @@ impl Core {
|
||||
};
|
||||
if let Err(e) = self
|
||||
.rt
|
||||
.block_on(self.client.trash_nodes(&[uid]))
|
||||
.block_on(self.drive.trash_nodes(&[uid]))
|
||||
.and_then(batch::into_unit)
|
||||
{
|
||||
warn!(rel, error = %e, "sync: trash remote failed");
|
||||
@@ -622,7 +635,7 @@ impl Core {
|
||||
&mut remote_dirs,
|
||||
std::mem::take(&mut batch),
|
||||
&mut outcome,
|
||||
);
|
||||
)?;
|
||||
batch_depth = depth;
|
||||
// A depth boundary is the pass's natural checkpoint — everything
|
||||
// queued has been applied, so stopping here leaves nothing half done.
|
||||
@@ -773,7 +786,7 @@ impl Core {
|
||||
let remote = r.expect("remote deletion requires a remote file");
|
||||
if let Err(e) = self
|
||||
.rt
|
||||
.block_on(self.client.trash_nodes(std::slice::from_ref(&remote.uid)))
|
||||
.block_on(self.drive.trash_nodes(std::slice::from_ref(&remote.uid)))
|
||||
.and_then(batch::into_unit)
|
||||
{
|
||||
warn!(rel, error = %e, "sync: trash remote failed");
|
||||
@@ -810,7 +823,7 @@ impl Core {
|
||||
&mut remote_dirs,
|
||||
std::mem::take(&mut batch),
|
||||
&mut outcome,
|
||||
);
|
||||
)?;
|
||||
|
||||
// Deferred folder deletions, deepest first.
|
||||
delete_local_dirs.sort_by_key(|p| std::cmp::Reverse(p.matches('/').count()));
|
||||
@@ -836,7 +849,7 @@ impl Core {
|
||||
for (rel, uid) in delete_remote_dirs {
|
||||
if let Err(e) = self
|
||||
.rt
|
||||
.block_on(self.client.trash_nodes(&[uid]))
|
||||
.block_on(self.drive.trash_nodes(&[uid]))
|
||||
.and_then(batch::into_unit)
|
||||
{
|
||||
warn!(rel, error = %e, "sync: trash remote folder failed");
|
||||
@@ -891,14 +904,14 @@ impl Core {
|
||||
) -> Result<(), String> {
|
||||
let uids = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_folder_children_node_uids(folder))
|
||||
.block_on(self.drive.enumerate_folder_children_node_uids(folder))
|
||||
.map_err(|e| format!("enumerate {folder}: {e}"))?;
|
||||
if uids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let nodes = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_nodes_light(&uids))
|
||||
.block_on(self.drive.enumerate_nodes_light(&uids))
|
||||
.map_err(|e| format!("resolve nodes: {e}"))?;
|
||||
for node in nodes {
|
||||
if node.trashed || !node.is_folder() {
|
||||
@@ -938,19 +951,22 @@ impl Core {
|
||||
// is safe precisely because the walk has applied nothing — a *partial*
|
||||
// remote map, on the other hand, would be read as "the remote deleted
|
||||
// everything we haven't reached yet", so it must never reach the diff.
|
||||
if self.shutdown.is_stopping() {
|
||||
return Err(PassAbort::Stopped);
|
||||
}
|
||||
if self.ondemand_queued(folder_id) {
|
||||
return Err(PassAbort::Interrupted);
|
||||
}
|
||||
let uids = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_folder_children_node_uids(folder))
|
||||
.block_on(self.drive.enumerate_folder_children_node_uids(folder))
|
||||
.map_err(|e| format!("enumerate {folder}: {e}"))?;
|
||||
if uids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let nodes = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_nodes_light(&uids))
|
||||
.block_on(self.drive.enumerate_nodes_light(&uids))
|
||||
.map_err(|e| format!("resolve nodes: {e}"))?;
|
||||
|
||||
// Files whose recorded mtime no longer matches: their size has to be
|
||||
@@ -968,7 +984,7 @@ impl Core {
|
||||
HashMap::new()
|
||||
} else {
|
||||
self.rt
|
||||
.block_on(self.client.enumerate_nodes(&stale))
|
||||
.block_on(self.drive.enumerate_nodes(&stale))
|
||||
.map_err(|e| format!("resolve nodes: {e}"))?
|
||||
.iter()
|
||||
.map(|n| (n.uid.to_string(), remote_file_sig(n).1))
|
||||
@@ -1048,6 +1064,9 @@ impl Core {
|
||||
/// `remote_dirs` (so deeper paths resolve their parent), and what each op did
|
||||
/// is tallied into `outcome`. Driven by `block_on` from the (non-runtime) sync
|
||||
/// engine thread, spawning the tasks onto the shared runtime.
|
||||
///
|
||||
/// Fails with [`PassAbort::Stopped`] once the daemon is stopping, after the
|
||||
/// ops already sending have finished.
|
||||
fn flush_batch(
|
||||
&self,
|
||||
folder_id: i64,
|
||||
@@ -1055,9 +1074,9 @@ impl Core {
|
||||
remote_dirs: &mut HashMap<String, NodeUid>,
|
||||
batch: Vec<Pending>,
|
||||
outcome: &mut Outcome,
|
||||
) {
|
||||
) -> Result<(), PassAbort> {
|
||||
if batch.is_empty() {
|
||||
return;
|
||||
return Ok(());
|
||||
}
|
||||
self.progress_queued(folder_id, batch.len());
|
||||
let core = self.clone();
|
||||
@@ -1071,14 +1090,22 @@ impl Core {
|
||||
let root = root.clone();
|
||||
set.spawn(async move {
|
||||
let _permit = sem.acquire_owned().await.expect("semaphore not closed");
|
||||
core.apply_pending(folder_id, &root, op).await
|
||||
// An op still waiting for its turn when the stop comes is
|
||||
// not started; the ones already sending finish.
|
||||
if core.shutdown.is_stopping() {
|
||||
return None;
|
||||
}
|
||||
Some(core.apply_pending(folder_id, &root, op).await)
|
||||
});
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut join_errors = 0usize;
|
||||
while let Some(joined) = set.join_next().await {
|
||||
match joined {
|
||||
Ok(result) => out.push(result),
|
||||
Ok(Some(result)) => out.push(result),
|
||||
Ok(None) => {}
|
||||
// The runtime ends with the daemon and cancels what is left.
|
||||
Err(e) if e.is_cancelled() => {}
|
||||
Err(e) => {
|
||||
join_errors += 1;
|
||||
warn!(error = %e, "sync: task panicked");
|
||||
@@ -1103,6 +1130,13 @@ impl Core {
|
||||
}
|
||||
}
|
||||
}
|
||||
// The pass goes no further: the next depth's paths may need a folder
|
||||
// this batch did not get to make, and went on to fail one by one
|
||||
// (`docs/BUGS.md` B169).
|
||||
if self.shutdown.is_stopping() {
|
||||
return Err(PassAbort::Stopped);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply one [`Pending`] op (async, so it can run concurrently in a batch),
|
||||
@@ -1136,7 +1170,7 @@ impl Core {
|
||||
match op {
|
||||
Pending::CreateDir { rel, parent } => {
|
||||
let uid = self
|
||||
.client
|
||||
.drive
|
||||
.create_folder(parent, base_name(rel), Some(now_secs()))
|
||||
.await
|
||||
.map_err(|e| format!("create remote folder {rel}: {e}"))?;
|
||||
@@ -1147,13 +1181,10 @@ impl Core {
|
||||
Ok(Applied::Dir(rel.clone(), uid))
|
||||
}
|
||||
Pending::UploadNew { rel, parent } => {
|
||||
self.upload_new(folder_id, local_root, rel, parent).await?;
|
||||
Ok(Applied::Uploaded)
|
||||
self.upload_new(folder_id, local_root, rel, parent).await
|
||||
}
|
||||
Pending::UploadRevision { rel, uid } => {
|
||||
self.upload_revision(folder_id, local_root, rel, uid)
|
||||
.await?;
|
||||
Ok(Applied::Uploaded)
|
||||
self.upload_revision(folder_id, local_root, rel, uid).await
|
||||
}
|
||||
Pending::Download {
|
||||
rel,
|
||||
@@ -1217,10 +1248,12 @@ impl Core {
|
||||
local_root: &Path,
|
||||
rel: &str,
|
||||
parent: &NodeUid,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<Applied, String> {
|
||||
let path = local_root.join(rel_to_path(rel));
|
||||
let name = base_name(rel);
|
||||
let file = std::fs::File::open(&path).map_err(|e| format!("open {rel}: {e}"))?;
|
||||
let Some(file) = open_to_upload(&path, rel)? else {
|
||||
return Ok(Applied::Vanished);
|
||||
};
|
||||
let meta = file.metadata().map_err(|e| format!("stat {rel}: {e}"))?;
|
||||
let mtime = system_mtime(&meta);
|
||||
// Taken from the descriptor being streamed, so it names the content that
|
||||
@@ -1238,7 +1271,7 @@ impl Core {
|
||||
.begin(name, "", TransferDirection::Upload, meta.len()),
|
||||
);
|
||||
let uid = self
|
||||
.client
|
||||
.drive
|
||||
// Mirror push: the local file is authoritative. If a prior attempt was
|
||||
// interrupted mid-upload it left an unsealed draft of this name; recover
|
||||
// it even across a daemon restart (which rotates our client uid), so the
|
||||
@@ -1261,7 +1294,8 @@ impl Core {
|
||||
self.invalidate_children_of(parent);
|
||||
warn_if_torn(rel, &path, streamed);
|
||||
self.record_file_baseline(folder_id, rel, Some(streamed), &uid)
|
||||
.await
|
||||
.await?;
|
||||
Ok(Applied::Uploaded)
|
||||
}
|
||||
|
||||
/// Upload a changed local file as a new revision of an existing remote node.
|
||||
@@ -1271,9 +1305,11 @@ impl Core {
|
||||
local_root: &Path,
|
||||
rel: &str,
|
||||
uid: &NodeUid,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<Applied, String> {
|
||||
let path = local_root.join(rel_to_path(rel));
|
||||
let file = std::fs::File::open(&path).map_err(|e| format!("open {rel}: {e}"))?;
|
||||
let Some(file) = open_to_upload(&path, rel)? else {
|
||||
return Ok(Applied::Vanished);
|
||||
};
|
||||
let meta = file.metadata().map_err(|e| format!("stat {rel}: {e}"))?;
|
||||
let mtime = system_mtime(&meta);
|
||||
let streamed = LocalSig::from(&meta);
|
||||
@@ -1289,13 +1325,14 @@ impl Core {
|
||||
meta.len(),
|
||||
),
|
||||
);
|
||||
self.client
|
||||
self.drive
|
||||
.upload_new_revision_from(uid, reader, meta.len() as i64, thumbnails, Some(mtime))
|
||||
.await
|
||||
.map_err(|e| format!("upload revision {rel}: {e}"))?;
|
||||
warn_if_torn(rel, &path, streamed);
|
||||
self.record_file_baseline(folder_id, rel, Some(streamed), uid)
|
||||
.await
|
||||
.await?;
|
||||
Ok(Applied::Uploaded)
|
||||
}
|
||||
|
||||
/// Download a remote file to its local path (atomically via a temp file),
|
||||
@@ -1305,7 +1342,7 @@ impl Core {
|
||||
/// rename below is the destructive step, and the download in between can run
|
||||
/// for minutes, so the destination is re-examined right before it: a local
|
||||
/// file that no longer matches the plan is set aside as a conflict copy
|
||||
/// instead of being replaced (bugs.md B39).
|
||||
/// instead of being replaced (`docs/BUGS.md` B39).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn download_file(
|
||||
&self,
|
||||
@@ -1328,7 +1365,21 @@ impl Core {
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0)
|
||||
));
|
||||
{
|
||||
// A file an on-demand mount already read, say one just moved here from
|
||||
// it, is in the content cache under the same revision key.
|
||||
let seeded = size > 0 && self.cache.copy_cached_to(uid, mtime, size as u64, &tmp);
|
||||
if seeded {
|
||||
std::fs::File::open(&tmp)
|
||||
.and_then(|f| f.sync_all())
|
||||
.map_err(|e| {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
format!("sync tmp {rel}: {e}")
|
||||
})?;
|
||||
debug!(
|
||||
rel,
|
||||
"sync: placed from the content cache instead of downloading"
|
||||
);
|
||||
} else {
|
||||
let out = std::fs::File::create(&tmp).map_err(|e| format!("create tmp {rel}: {e}"))?;
|
||||
// Count the bytes as they land, so the download reports progress like
|
||||
// an on-demand hydration does.
|
||||
@@ -1339,7 +1390,7 @@ impl Core {
|
||||
size.max(0) as u64,
|
||||
);
|
||||
let mut out = CountingWriter::new(out, &guard);
|
||||
self.client
|
||||
self.drive
|
||||
.download_file_to(uid, &mut out)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -1390,7 +1441,7 @@ impl Core {
|
||||
/// the pre-upload identity is what makes the next pass see the path as
|
||||
/// locally changed and upload a whole, clean revision over it. Recording the
|
||||
/// post-upload stat instead declared the torn revision synchronized and left
|
||||
/// it as the file's content on Drive (bugs.md B40).
|
||||
/// it as the file's content on Drive (`docs/BUGS.md` B40).
|
||||
async fn record_file_baseline(
|
||||
&self,
|
||||
folder_id: i64,
|
||||
@@ -1404,7 +1455,7 @@ impl Core {
|
||||
let (lmtime, lsize) = (local.mtime, local.size);
|
||||
// Re-fetch the node so the stored remote signature is exactly what a walk
|
||||
// will report next time.
|
||||
let (rmtime, rsize) = match self.client.enumerate_nodes(std::slice::from_ref(uid)).await {
|
||||
let (rmtime, rsize) = match self.drive.enumerate_nodes(std::slice::from_ref(uid)).await {
|
||||
Ok(nodes) => nodes
|
||||
.first()
|
||||
.map(remote_file_sig)
|
||||
@@ -1431,13 +1482,30 @@ impl Core {
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a local file for upload, or `None` when it is gone.
|
||||
///
|
||||
/// A scan plans uploads from a listing, and the file can be deleted before its
|
||||
/// upload starts — a build or a test tree cleaned up mid-pass. That is not a
|
||||
/// failure: the next pass sees the deletion. Counting it as one put the folder
|
||||
/// in `error` for a file nobody wanted any more (B108).
|
||||
fn open_to_upload(path: &Path, rel: &str) -> Result<Option<std::fs::File>, String> {
|
||||
match std::fs::File::open(path) {
|
||||
Ok(file) => Ok(Some(file)),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
debug!(rel, "sync: file deleted before its upload; skipping");
|
||||
Ok(None)
|
||||
}
|
||||
Err(e) => Err(format!("open {rel}: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Say so when a file changed under an upload that was streaming it.
|
||||
///
|
||||
/// Purely diagnostic — the correction is already in place, because the baseline
|
||||
/// records the identity the upload *read* rather than the one on disk now, so
|
||||
/// the next pass classifies the path as locally changed and replaces the torn
|
||||
/// revision. Without the log there is nothing in the journal to explain the
|
||||
/// extra revision (bugs.md B40).
|
||||
/// extra revision (`docs/BUGS.md` B40).
|
||||
fn warn_if_torn(rel: &str, path: &Path, streamed: LocalSig) {
|
||||
let now = local_sig(path);
|
||||
if !now.is_some_and(|now| now.same_content(&streamed)) {
|
||||
@@ -1469,7 +1537,7 @@ fn local_sig(path: &Path) -> Option<LocalSig> {
|
||||
/// transfer started, and a download of a large file can run for minutes. Without
|
||||
/// this, a local edit completed inside that window was replaced by the remote
|
||||
/// version with no copy of it kept anywhere — the one shape of loss a sync
|
||||
/// engine must not have (bugs.md B39). Keeping the copy makes it re-upload as a
|
||||
/// engine must not have (`docs/BUGS.md` B39). Keeping the copy makes it re-upload as a
|
||||
/// new file on the next pass, which is the same resolution a both-sides-changed
|
||||
/// conflict already gets.
|
||||
///
|
||||
@@ -1505,7 +1573,7 @@ fn keep_racing_local_edit(path: &Path, expected: Option<LocalSig>) -> std::io::R
|
||||
/// exists, so the same removal was re-attempted and re-reported as an error on
|
||||
/// every pass, forever. Every other error is still an error, and keeping the
|
||||
/// baseline is exactly right there: the surviving file must not be mistaken for
|
||||
/// a new local creation on the next pass and uploaded back (bugs.md B42).
|
||||
/// a new local creation on the next pass and uploaded back (`docs/BUGS.md` B42).
|
||||
fn removed_locally(result: std::io::Result<()>) -> std::io::Result<()> {
|
||||
match result {
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
@@ -1602,6 +1670,76 @@ fn walk_local_tree(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Why the mirror copy at `rel` holds something Proton Drive does not, or
|
||||
/// `None` when everything under it is on Drive exactly as it is on disk.
|
||||
///
|
||||
/// A move out of a mirror folder removes the local copy once Drive has moved
|
||||
/// the node, so anything only this disk has would be lost with it: a file not
|
||||
/// uploaded yet, one edited since, a deletion not carried up yet, or anything
|
||||
/// the engine never uploads. That is why ignored names count here, and why the
|
||||
/// walk is its own rather than [`walk_local_tree`], which skips symlinks.
|
||||
pub(crate) fn mirror_subtree_unsynced(
|
||||
root: &Path,
|
||||
rel: &str,
|
||||
baseline: &HashMap<String, StoredSyncEntry>,
|
||||
) -> Option<String> {
|
||||
let mut seen = HashSet::new();
|
||||
if let Some(reason) = unsynced_local(root, rel, baseline, &mut seen) {
|
||||
return Some(reason);
|
||||
}
|
||||
let prefix = format!("{rel}/");
|
||||
baseline
|
||||
.keys()
|
||||
.filter(|r| *r == rel || r.starts_with(&prefix))
|
||||
.find(|r| !seen.contains(r.as_str()))
|
||||
.map(|r| format!("{r} was deleted here but not on Proton Drive yet"))
|
||||
}
|
||||
|
||||
fn unsynced_local(
|
||||
root: &Path,
|
||||
rel: &str,
|
||||
baseline: &HashMap<String, StoredSyncEntry>,
|
||||
seen: &mut HashSet<String>,
|
||||
) -> Option<String> {
|
||||
let path = root.join(rel_to_path(rel));
|
||||
let meta = match std::fs::symlink_metadata(&path) {
|
||||
Ok(meta) => meta,
|
||||
Err(e) => return Some(format!("cannot read {rel}: {e}")),
|
||||
};
|
||||
let Some(base) = baseline.get(rel) else {
|
||||
return Some(format!("{rel} is not on Proton Drive yet"));
|
||||
};
|
||||
seen.insert(rel.to_string());
|
||||
if meta.is_file() {
|
||||
return (!LocalSig::from(&meta).same_content(&LocalSig::from(base)))
|
||||
.then(|| format!("{rel} has changes that are not on Proton Drive yet"));
|
||||
}
|
||||
if !meta.is_dir() {
|
||||
return Some(format!("{rel} is not a regular file or folder"));
|
||||
}
|
||||
let entries = match std::fs::read_dir(&path) {
|
||||
Ok(entries) => entries,
|
||||
Err(e) => return Some(format!("cannot read {rel}: {e}")),
|
||||
};
|
||||
for entry in entries {
|
||||
let name = match entry {
|
||||
Ok(entry) => entry.file_name(),
|
||||
Err(e) => return Some(format!("cannot read {rel}: {e}")),
|
||||
};
|
||||
let Some(name) = name.to_str() else {
|
||||
return Some(format!("{rel} holds a name that is not valid UTF-8"));
|
||||
};
|
||||
// A download the engine left half done; it is the engine's to clean up.
|
||||
if name.contains(".pdfs-tmp-") {
|
||||
continue;
|
||||
}
|
||||
if let Some(reason) = unsynced_local(root, &format!("{rel}/{name}"), baseline, seen) {
|
||||
return Some(reason);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Publish a conflict copy without overwriting an earlier conflict bearing the
|
||||
/// same timestamp. The original is removed only after the new copy is durable.
|
||||
fn preserve_conflict_copy(path: &Path, stamp: i64) -> std::io::Result<PathBuf> {
|
||||
@@ -1692,7 +1830,7 @@ fn system_mtime(meta: &std::fs::Metadata) -> i64 {
|
||||
///
|
||||
/// Whole seconds cannot distinguish an edit made in the same second as the last
|
||||
/// sync from no edit at all, and if the size did not change either, that file
|
||||
/// was never uploaded (bugs.md B25). `None` for a clock or filesystem that
|
||||
/// was never uploaded (`docs/BUGS.md` B25). `None` for a clock or filesystem that
|
||||
/// cannot answer, which falls the comparison back to seconds rather than
|
||||
/// asserting an mtime of zero.
|
||||
fn system_mtime_ns(meta: &std::fs::Metadata) -> Option<i64> {
|
||||
@@ -1707,7 +1845,7 @@ mod tests {
|
||||
|
||||
/// A removal of something already gone has produced the state the sync
|
||||
/// engine wanted, so its baseline is dropped rather than retried forever.
|
||||
/// Anything else stays an error and keeps the baseline (bugs.md B42).
|
||||
/// Anything else stays an error and keeps the baseline (`docs/BUGS.md` B42).
|
||||
#[test]
|
||||
fn an_already_absent_path_counts_as_removed() {
|
||||
let root = std::env::temp_dir().join(format!("pdfs-b42-{}", std::process::id()));
|
||||
@@ -1724,7 +1862,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// The download guard: a destination that still matches the plan is
|
||||
/// replaced silently, one that has moved on since is kept (bugs.md B39).
|
||||
/// replaced silently, one that has moved on since is kept (`docs/BUGS.md` B39).
|
||||
#[test]
|
||||
fn a_download_keeps_a_local_edit_it_did_not_plan_for() {
|
||||
let root = std::env::temp_dir().join(format!("pdfs-b39-{}", std::process::id()));
|
||||
@@ -2039,7 +2177,7 @@ mod tests {
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
|
||||
/// A restored folder (features.md 5.2) starts with an empty baseline against
|
||||
/// A restored folder starts with an empty baseline against
|
||||
/// an empty local directory and a full remote. That must reconcile as
|
||||
/// "download everything" rather than tripping the wipe guard — otherwise
|
||||
/// every restore would wedge its folder on the first pass.
|
||||
@@ -2155,6 +2293,27 @@ mod tests {
|
||||
assert_eq!(remote_sig(&e), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_deleted_before_its_upload_is_skipped_not_failed() {
|
||||
let root = std::env::temp_dir().join(format!("pdfs-b108-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let gone = root.join("gone.txt");
|
||||
assert!(open_to_upload(&gone, "gone.txt").unwrap().is_none());
|
||||
|
||||
std::fs::write(&gone, b"x").unwrap();
|
||||
assert!(open_to_upload(&gone, "gone.txt").unwrap().is_some());
|
||||
|
||||
// Other open failures still count as errors.
|
||||
let err = open_to_upload(&gone.join("child"), "gone.txt/child");
|
||||
assert!(err.unwrap_err().starts_with("open gone.txt/child:"));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
|
||||
let mut o = Outcome::default();
|
||||
o.record(&Applied::Vanished);
|
||||
assert!(o.is_empty());
|
||||
assert_eq!(o.errors, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outcome_summarises_only_what_it_moved() {
|
||||
let mut o = Outcome::default();
|
||||
@@ -2346,6 +2505,94 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A baseline row recording `path` as it is on disk right now.
|
||||
fn synced_row(root: &Path, rel: &str) -> (String, StoredSyncEntry) {
|
||||
let meta = std::fs::symlink_metadata(root.join(rel_to_path(rel))).unwrap();
|
||||
let sig = LocalSig::from(&meta);
|
||||
let entry = StoredSyncEntry {
|
||||
rel_path: rel.to_string(),
|
||||
remote_uid: Some(format!("vol~{rel}")),
|
||||
local_mtime: sig.mtime,
|
||||
local_mtime_ns: sig.mtime_ns,
|
||||
local_size: sig.size,
|
||||
remote_rev: None,
|
||||
remote_hash: None,
|
||||
};
|
||||
(rel.to_string(), entry)
|
||||
}
|
||||
|
||||
fn synced_subtree(name: &str) -> (PathBuf, HashMap<String, StoredSyncEntry>) {
|
||||
let root = sync_test_dir(name);
|
||||
std::fs::create_dir_all(root.join("dir/sub")).unwrap();
|
||||
std::fs::write(root.join("dir/a.txt"), b"a").unwrap();
|
||||
std::fs::write(root.join("dir/sub/b.txt"), b"b").unwrap();
|
||||
let baseline = ["dir", "dir/sub", "dir/a.txt", "dir/sub/b.txt"]
|
||||
.into_iter()
|
||||
.map(|rel| synced_row(&root, rel))
|
||||
.collect();
|
||||
(root, baseline)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fully_synced_mirror_subtree_may_leave() {
|
||||
let (root, baseline) = synced_subtree("move-synced");
|
||||
let reason = mirror_subtree_unsynced(&root, "dir", &baseline);
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
assert_eq!(reason, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mirror_subtree_with_a_file_not_uploaded_may_not_leave() {
|
||||
let (root, baseline) = synced_subtree("move-new-file");
|
||||
std::fs::write(root.join("dir/sub/new.txt"), b"n").unwrap();
|
||||
let reason = mirror_subtree_unsynced(&root, "dir", &baseline);
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
assert!(reason.unwrap().contains("dir/sub/new.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mirror_subtree_with_an_edit_not_uploaded_may_not_leave() {
|
||||
let (root, baseline) = synced_subtree("move-edit");
|
||||
std::fs::write(root.join("dir/a.txt"), b"longer now").unwrap();
|
||||
let reason = mirror_subtree_unsynced(&root, "dir", &baseline);
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
assert!(reason.unwrap().contains("dir/a.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mirror_subtree_with_a_deletion_not_carried_up_may_not_leave() {
|
||||
let (root, baseline) = synced_subtree("move-deleted");
|
||||
std::fs::remove_file(root.join("dir/sub/b.txt")).unwrap();
|
||||
let reason = mirror_subtree_unsynced(&root, "dir", &baseline);
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
assert!(reason.unwrap().contains("dir/sub/b.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mirror_subtree_holding_a_symlink_may_not_leave() {
|
||||
let (root, baseline) = synced_subtree("move-symlink");
|
||||
std::os::unix::fs::symlink("a.txt", root.join("dir/link")).unwrap();
|
||||
let reason = mirror_subtree_unsynced(&root, "dir", &baseline);
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
assert!(reason.unwrap().contains("dir/link"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_prefixed_sibling_does_not_hold_a_mirror_subtree_back() {
|
||||
let (root, mut baseline) = synced_subtree("move-sibling");
|
||||
// Deleted here but not on Drive, yet outside the subtree being moved.
|
||||
baseline.insert(
|
||||
"dirt".to_string(),
|
||||
StoredSyncEntry {
|
||||
rel_path: "dirt".to_string(),
|
||||
..baseline["dir/a.txt"].clone()
|
||||
},
|
||||
);
|
||||
let reason = mirror_subtree_unsynced(&root, "dir", &baseline);
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
assert_eq!(reason, None);
|
||||
}
|
||||
|
||||
/// The same rule one level up: if the sync root itself cannot be read, the
|
||||
/// pass sees nothing, and "nothing" must never mean "delete everything".
|
||||
#[test]
|
||||
|
||||
@@ -33,7 +33,7 @@ pub(crate) enum SyncMsg {
|
||||
/// Re-read the folder set and adjust the filesystem watches (after add/remove).
|
||||
Rewatch,
|
||||
/// The daemon is tearing down: finish the current pass and end the engine
|
||||
/// thread, so the mount can join it (bugs.md B44).
|
||||
/// thread, so the mount can join it (`docs/BUGS.md` B44).
|
||||
Stop,
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ impl LocalSig {
|
||||
/// Nanoseconds decide it when both sides have them; otherwise the comparison
|
||||
/// falls back to whole seconds, which is what a baseline row written before
|
||||
/// schema 28 — or a filesystem that reports no sub-second time — can support
|
||||
/// (bugs.md B25).
|
||||
/// (`docs/BUGS.md` B25).
|
||||
pub(super) fn same_content(&self, other: &Self) -> bool {
|
||||
if self.size != other.size {
|
||||
return false;
|
||||
@@ -108,6 +108,7 @@ impl Outcome {
|
||||
Applied::Uploaded => self.uploaded += 1,
|
||||
Applied::Downloaded => self.downloaded += 1,
|
||||
Applied::Conflict => self.conflicts += 1,
|
||||
Applied::Vanished => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +161,7 @@ pub(super) enum Pending {
|
||||
/// was there. A download can run for minutes, so the apply step revalidates
|
||||
/// against it immediately before it renames over the destination: a writer
|
||||
/// that finished an edit in the meantime must not be overwritten without a
|
||||
/// conflict copy (bugs.md B39).
|
||||
/// conflict copy (`docs/BUGS.md` B39).
|
||||
Download {
|
||||
rel: String,
|
||||
uid: NodeUid,
|
||||
@@ -226,6 +227,10 @@ pub(super) enum PassAbort {
|
||||
Interrupted,
|
||||
/// The pass could not establish its diff (a walk or the baseline load failed).
|
||||
Failed(String),
|
||||
/// The daemon is stopping. What the pass sent before the stop has landed or
|
||||
/// will be cancelled with the runtime; nothing more is sent, and the next
|
||||
/// start's pass picks up from the baseline.
|
||||
Stopped,
|
||||
}
|
||||
|
||||
impl From<String> for PassAbort {
|
||||
|
||||
@@ -218,7 +218,7 @@ pub(super) fn plan_file(
|
||||
}
|
||||
|
||||
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
|
||||
// left the file the same length, so it was never uploaded.
|
||||
baseline.is_none_or(|base| !LocalSig::from(item).same_content(&LocalSig::from(base)))
|
||||
@@ -291,7 +291,7 @@ mod file_plan_tests {
|
||||
|
||||
/// Whole seconds cannot see an edit made inside the same second that leaves the
|
||||
/// file the same length, so a mirror silently stopped uploading it. With a
|
||||
/// nanosecond time on both sides it is an ordinary local change (bugs.md B25).
|
||||
/// nanosecond time on both sides it is an ordinary local change (`docs/BUGS.md` B25).
|
||||
#[test]
|
||||
fn a_same_second_same_size_edit_is_still_a_local_change() {
|
||||
let mut base = baseline(10, 20, 30, 40);
|
||||
|
||||
@@ -74,6 +74,26 @@ pub(crate) fn watchdog_ping() {
|
||||
let _ = notify("WATCHDOG=1\n");
|
||||
}
|
||||
|
||||
/// Sleep for `duration`, pinging the watchdog on the way.
|
||||
///
|
||||
/// For the stretches where the daemon is alive but has no mount, and so no
|
||||
/// supervisor: waiting for a login, or backing off after a failed mount. The
|
||||
/// manager arms the watchdog when the process starts, so without these pings a
|
||||
/// daemon that is merely retrying is aborted after `WatchdogSec` and leaves a
|
||||
/// coredump instead of the error it was logging. Never use this while mounted:
|
||||
/// there the ping must prove the control socket answers.
|
||||
pub fn idle_sleep(duration: Duration) {
|
||||
const CHUNK: Duration = Duration::from_secs(10);
|
||||
let mut left = duration;
|
||||
watchdog_ping();
|
||||
while !left.is_zero() {
|
||||
let step = left.min(CHUNK);
|
||||
std::thread::sleep(step);
|
||||
left -= step;
|
||||
watchdog_ping();
|
||||
}
|
||||
}
|
||||
|
||||
/// Tell the manager the daemon is stopping, so a slow teardown is not mistaken
|
||||
/// for a hang by the watchdog it is racing.
|
||||
pub(crate) fn stopping() {
|
||||
|
||||
@@ -530,7 +530,7 @@ fn open_archive(
|
||||
|
||||
/// The lowercase-hex SHA-1 of everything `reader` yields — the digest Proton's
|
||||
/// 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 buffer = vec![0u8; 256 * 1024];
|
||||
loop {
|
||||
|
||||
@@ -73,7 +73,7 @@ async fn run_uploads(
|
||||
.upload_thumbnails_async(t.path.clone(), t.name.clone())
|
||||
.await;
|
||||
match core
|
||||
.client
|
||||
.drive
|
||||
.upload_file_from(
|
||||
&t.parent_uid,
|
||||
&t.name,
|
||||
@@ -238,14 +238,11 @@ impl Core {
|
||||
.map_err(|error| self.errno_error(error, "upload destination"))?;
|
||||
let new_uid = self
|
||||
.rt
|
||||
.block_on(
|
||||
self.client
|
||||
.create_folder(parent_uid, name, Some(now_secs())),
|
||||
)
|
||||
.block_on(self.drive.create_folder(parent_uid, name, Some(now_secs())))
|
||||
.map_err(|e| CoreError::from_api(&e, &format!("create folder {name}")))?;
|
||||
let node = self
|
||||
.fetch_node(&new_uid)
|
||||
.map_err(|e| self.errno_error(e, "fetch node"))?;
|
||||
.fetch_minted_node(&new_uid, name)
|
||||
.ok_or_else(|| minted_but_unlisted(name))?;
|
||||
let mut st = self.state();
|
||||
let ino = st.intern(pino, node);
|
||||
if let Some(kids) = st.children.get_mut(&pino)
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
.badge-pinned { color: var(--success-color); }
|
||||
.badge-cached { color: var(--success-color); }
|
||||
.badge-cloud { color: color-mix(in srgb, currentColor 55%, transparent); }
|
||||
.badge-issue { color: var(--error-color); }
|
||||
.browser-statusbar { background-color: alpha(currentColor, 0.025); }
|
||||
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); }
|
||||
|
||||
@@ -75,8 +75,8 @@ use pdfs_core::control::{
|
||||
DirEntry, ErrorKind, ImportSummary, InvitationInfo, JobItem, MappingProgress, PendingOpInfo,
|
||||
PhotoItem, PhotoKind, PhotoMonth, PlaceInfo, PublicLinkInfo, RefreshScope, Request, Response,
|
||||
RestorableFolder, RestoreItem, RevisionInfo, SearchHit, ShareEntry, ShareEntryKind, SharedItem,
|
||||
SyncFolderInfo, SyncPhase, SyncProgress, ThumbnailBuildStatus, Topic, TransferDirection,
|
||||
TransferItem, send,
|
||||
SyncFolderInfo, SyncIssue, SyncPhase, SyncProgress, ThumbnailBuildStatus, Topic,
|
||||
TransferDirection, TransferItem, send,
|
||||
};
|
||||
|
||||
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
|
||||
/// login / logout. `None` = signed out.
|
||||
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
|
||||
/// (which need the daemon to evict + re-hydrate) and every mutating action.
|
||||
mounted: RefCell<bool>,
|
||||
@@ -362,6 +365,7 @@ fn build_window(app: &adw::Application) {
|
||||
busy: Cell::new(0),
|
||||
opening: RefCell::new(HashSet::new()),
|
||||
session: RefCell::new(auth::load().ok()),
|
||||
unlock_offered: Cell::new(false),
|
||||
mounted: RefCell::new(false),
|
||||
sidebar: sidebar_list.clone(),
|
||||
nav: split.clone(),
|
||||
@@ -668,6 +672,15 @@ fn build_window(app: &adw::Application) {
|
||||
sync_sidebar(&ui_nav);
|
||||
match st.visible_child_name().as_deref() {
|
||||
Some("browser") => load_browser(&ui_nav),
|
||||
// The Albums and Places listings are re-read in place. Loading the
|
||||
// timeline instead would swap it in under the Albums or Places toggle.
|
||||
Some("gallery") if !ui_nav.gallery.in_collection() => {
|
||||
match ui_nav.gallery.view_switch.active() {
|
||||
VIEW_ALBUMS => load_albums(&ui_nav),
|
||||
VIEW_PLACES => load_places(&ui_nav),
|
||||
_ => load_gallery(&ui_nav, false),
|
||||
}
|
||||
}
|
||||
Some("gallery") => load_gallery(&ui_nav, false),
|
||||
// Every visit reads the page again. The rows already on screen stay
|
||||
// up while it does, so arriving costs no "Loading…" flash.
|
||||
@@ -1199,7 +1212,7 @@ fn install_window_actions(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
||||
.application_icon("io.narl.proton-drive-linux")
|
||||
.version(pdfs_core::config::APP_VERSION)
|
||||
.developer_name("Nils Pukropp")
|
||||
.website("https://github.com/narrrl/proton-drive-linux")
|
||||
.website("https://proton-drive.narl.io")
|
||||
.issue_url("https://github.com/narrrl/proton-drive-linux/issues")
|
||||
.copyright("© 2026 Nils Pukropp (contact@narl.io)")
|
||||
.license_type(gtk4::License::MitX11)
|
||||
|
||||
@@ -341,6 +341,59 @@ fn detail_label(entry: &ActivityEntry) -> Option<String> {
|
||||
&[("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 ") {
|
||||
// Translators: {name} is the file the conflicting copy was made from.
|
||||
return Some(gettext_f("Differs from {name}", &[("name", base)]));
|
||||
@@ -388,6 +441,26 @@ fn detail_label(entry: &ActivityEntry) -> Option<String> {
|
||||
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
|
||||
/// list of counts. `None` when a part is not one the daemon writes.
|
||||
fn sync_summary(detail: &str) -> Option<String> {
|
||||
@@ -596,6 +669,38 @@ mod tests {
|
||||
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]
|
||||
fn unknown_details_show_as_written() {
|
||||
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,
|
||||
/// offline, sharing, organising, and the destructive item last on its own.
|
||||
/// The menu for one entry, in a fixed order: open, offline, sharing,
|
||||
/// organising, and the destructive item last on its own.
|
||||
pub(crate) fn entry_context_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
|
||||
let mut menu = ActionMenu::new();
|
||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||
@@ -1920,6 +1920,10 @@ pub(crate) fn prompt_rename(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
|
||||
/// Pick a destination folder by browsing Drive's folders, and move `entries`
|
||||
/// there through the daemon.
|
||||
///
|
||||
/// Besides My files the picker offers every synced folder. A move into one is
|
||||
/// still made on Proton Drive, so nothing is downloaded or uploaded again; it
|
||||
/// is sent with absolute paths, which is how the daemon tells locations apart.
|
||||
pub(crate) fn prompt_move(ui: &Rc<Ui>, entries: Vec<DirEntry>) {
|
||||
if entries.is_empty() {
|
||||
return;
|
||||
@@ -1967,6 +1971,7 @@ pub(crate) fn prompt_move(ui: &Rc<Ui>, entries: Vec<DirEntry>) {
|
||||
dialog.set_default_response(Some("move"));
|
||||
dialog.set_close_response("cancel");
|
||||
|
||||
let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config());
|
||||
let picker = Rc::new(MovePicker {
|
||||
ui: ui.clone(),
|
||||
dialog: dialog.clone(),
|
||||
@@ -1974,28 +1979,93 @@ pub(crate) fn prompt_move(ui: &Rc<Ui>, entries: Vec<DirEntry>) {
|
||||
list,
|
||||
up: up.clone(),
|
||||
sources: sources.clone(),
|
||||
folder: RefCell::new(String::new()),
|
||||
folder: RefCell::new(MoveTarget::Locations),
|
||||
generation: Cell::new(0),
|
||||
});
|
||||
let picker_up = picker.clone();
|
||||
up.connect_clicked(move |_| {
|
||||
let folder = picker_up.folder.borrow().clone();
|
||||
let parent = folder.rfind('/').map(|i| &folder[..i]).unwrap_or_default();
|
||||
picker_up.open(parent.to_string());
|
||||
let parent = picker_up.folder.borrow().parent();
|
||||
picker_up.open(parent);
|
||||
});
|
||||
// Start where the browser is: moving is most often one level up or down.
|
||||
picker.open(ui.browser.path.borrow().clone());
|
||||
picker.open(MoveTarget::Drive(ui.browser.path.borrow().clone()));
|
||||
|
||||
let ui = ui.clone();
|
||||
let picker_done = picker.clone();
|
||||
dialog.connect_response(None, move |_, resp| {
|
||||
if resp == "move" {
|
||||
run_bulk_move(&ui, sources.clone(), picker_done.folder.borrow().clone());
|
||||
if resp != "move" {
|
||||
return;
|
||||
}
|
||||
match picker_done.folder.borrow().clone() {
|
||||
MoveTarget::Locations => {}
|
||||
MoveTarget::Drive(folder) => run_bulk_move(&ui, sources.clone(), folder),
|
||||
MoveTarget::Synced { path, .. } => {
|
||||
let absolute = sources
|
||||
.iter()
|
||||
.map(|rel| mountpoint.join(rel).to_string_lossy().into_owned())
|
||||
.collect();
|
||||
run_bulk_move(&ui, absolute, path);
|
||||
}
|
||||
}
|
||||
});
|
||||
dialog.present(ui_window(&picker.ui).as_ref());
|
||||
}
|
||||
|
||||
/// Where the Move dialog is looking, and the destination if the user confirms.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum MoveTarget {
|
||||
/// The list of places to move to: My files and each synced folder.
|
||||
Locations,
|
||||
/// A folder in My files, mountpoint-relative; empty for My files itself.
|
||||
Drive(String),
|
||||
/// A folder inside a synced folder, as an absolute local path. `root` is
|
||||
/// the synced folder. A mirror folder is a plain directory, read from disk.
|
||||
Synced {
|
||||
root: String,
|
||||
path: String,
|
||||
mirror: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl MoveTarget {
|
||||
/// One level up. The top of a location leads back to the list of them.
|
||||
pub(crate) fn parent(&self) -> MoveTarget {
|
||||
match self {
|
||||
MoveTarget::Locations => MoveTarget::Locations,
|
||||
MoveTarget::Drive(folder) if folder.is_empty() => MoveTarget::Locations,
|
||||
MoveTarget::Drive(folder) => MoveTarget::Drive(
|
||||
folder
|
||||
.rfind('/')
|
||||
.map(|i| folder[..i].to_string())
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
MoveTarget::Synced { root, path, .. } if path == root => MoveTarget::Locations,
|
||||
MoveTarget::Synced { root, path, mirror } => MoveTarget::Synced {
|
||||
root: root.clone(),
|
||||
path: Path::new(path)
|
||||
.parent()
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| root.clone()),
|
||||
mirror: *mirror,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// `name` inside this folder, in the same form.
|
||||
fn child(&self, name: &str) -> MoveTarget {
|
||||
match self {
|
||||
MoveTarget::Locations => MoveTarget::Locations,
|
||||
MoveTarget::Drive(folder) if folder.is_empty() => MoveTarget::Drive(name.to_string()),
|
||||
MoveTarget::Drive(folder) => MoveTarget::Drive(format!("{folder}/{name}")),
|
||||
MoveTarget::Synced { root, path, mirror } => MoveTarget::Synced {
|
||||
root: root.clone(),
|
||||
path: Path::new(path).join(name).to_string_lossy().into_owned(),
|
||||
mirror: *mirror,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Move dialog's folder browser.
|
||||
struct MovePicker {
|
||||
ui: Rc<Ui>,
|
||||
@@ -2006,71 +2076,81 @@ struct MovePicker {
|
||||
/// What is being moved, mountpoint-relative.
|
||||
sources: Vec<String>,
|
||||
/// The folder on show, and the destination if the user confirms now.
|
||||
folder: RefCell<String>,
|
||||
folder: RefCell<MoveTarget>,
|
||||
/// Drops a listing that arrives after the user went elsewhere.
|
||||
generation: Cell<u64>,
|
||||
}
|
||||
|
||||
impl MovePicker {
|
||||
fn open(self: &Rc<Self>, folder: String) {
|
||||
fn open(self: &Rc<Self>, folder: MoveTarget) {
|
||||
let generation = self.generation.get().wrapping_add(1);
|
||||
self.generation.set(generation);
|
||||
self.location.set_label(if folder.is_empty() {
|
||||
"Proton Drive"
|
||||
} else {
|
||||
&folder
|
||||
});
|
||||
self.location.set_tooltip_text(Some(&folder));
|
||||
self.up.set_sensitive(!folder.is_empty());
|
||||
self.dialog
|
||||
.set_response_enabled("move", move_target_allowed(&self.sources, &folder));
|
||||
let (label, tooltip) = match &folder {
|
||||
MoveTarget::Locations => (gettext("Locations"), String::new()),
|
||||
MoveTarget::Drive(rel) if rel.is_empty() => (gettext("My files"), String::new()),
|
||||
MoveTarget::Drive(rel) => (rel.clone(), rel.clone()),
|
||||
MoveTarget::Synced { path, .. } => (path.clone(), path.clone()),
|
||||
};
|
||||
self.location.set_label(&label);
|
||||
self.location.set_tooltip_text(Some(&tooltip));
|
||||
self.up
|
||||
.set_sensitive(!matches!(folder, MoveTarget::Locations));
|
||||
let allowed = match &folder {
|
||||
MoveTarget::Locations => false,
|
||||
MoveTarget::Drive(rel) => move_target_allowed(&self.sources, rel),
|
||||
// What is moved is in My files, so it cannot be in a synced folder.
|
||||
MoveTarget::Synced { .. } => true,
|
||||
};
|
||||
self.dialog.set_response_enabled("move", allowed);
|
||||
*self.folder.borrow_mut() = folder.clone();
|
||||
while let Some(row) = self.list.first_child() {
|
||||
self.list.remove(&row);
|
||||
}
|
||||
self.list.append(&picker_note(&gettext("Loading…")));
|
||||
let rx = spawn_request(
|
||||
self.ui.dirs.control_socket(),
|
||||
Request::ListDir {
|
||||
path: folder.clone(),
|
||||
},
|
||||
);
|
||||
|
||||
let picker = self.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
let listed = match &folder {
|
||||
MoveTarget::Locations => picker.locations().await,
|
||||
MoveTarget::Drive(rel) => picker.drive_folders(&folder, rel.clone()).await,
|
||||
MoveTarget::Synced {
|
||||
path, mirror: true, ..
|
||||
} => local_folders(&folder, path.clone()).await,
|
||||
MoveTarget::Synced { path, .. } => {
|
||||
picker.drive_folders(&folder, path.clone()).await
|
||||
}
|
||||
};
|
||||
if picker.generation.get() != generation {
|
||||
return;
|
||||
}
|
||||
while let Some(row) = picker.list.first_child() {
|
||||
picker.list.remove(&row);
|
||||
}
|
||||
let Ok(Ok(Response::Entries { entries })) = result else {
|
||||
let Some(rows) = listed else {
|
||||
picker
|
||||
.list
|
||||
.append(&picker_note(&gettext("Couldn't read this folder.")));
|
||||
return;
|
||||
};
|
||||
let mut folders: Vec<DirEntry> = entries.into_iter().filter(|e| e.is_dir).collect();
|
||||
folders.sort_by_key(|e| e.name.to_lowercase());
|
||||
let mut shown = 0;
|
||||
for entry in folders {
|
||||
let path = if folder.is_empty() {
|
||||
entry.name.clone()
|
||||
} else {
|
||||
format!("{folder}/{}", entry.name)
|
||||
};
|
||||
for (title, subtitle, target) in rows {
|
||||
// A folder cannot go inside itself, so do not offer to open it.
|
||||
if picker.sources.contains(&path) {
|
||||
if let MoveTarget::Drive(path) = &target
|
||||
&& picker.sources.contains(path)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(glib::markup_escape_text(&entry.name).as_str())
|
||||
.title(glib::markup_escape_text(&title).as_str())
|
||||
.activatable(true)
|
||||
.build();
|
||||
if let Some(subtitle) = subtitle {
|
||||
row.set_subtitle(glib::markup_escape_text(&subtitle).as_str());
|
||||
}
|
||||
row.add_prefix(>k4::Image::from_icon_name("folder-symbolic"));
|
||||
row.add_suffix(>k4::Image::from_icon_name("go-next-symbolic"));
|
||||
let open = picker.clone();
|
||||
row.connect_activated(move |_| open.open(path.clone()));
|
||||
row.connect_activated(move |_| open.open(target.clone()));
|
||||
picker.list.append(&row);
|
||||
shown += 1;
|
||||
}
|
||||
@@ -2081,6 +2161,92 @@ impl MovePicker {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// My files and every synced folder, as rows of the top level.
|
||||
async fn locations(&self) -> Option<Vec<PickerRow>> {
|
||||
let mut rows = vec![(gettext("My files"), None, MoveTarget::Drive(String::new()))];
|
||||
let rx = spawn_request(self.ui.dirs.control_socket(), Request::ListSyncFolders);
|
||||
let Ok(Ok(Response::SyncFolders { mut items })) = rx.recv().await else {
|
||||
// My files alone is still somewhere to move to.
|
||||
return Some(rows);
|
||||
};
|
||||
items.sort_by_key(|f| f.local_path.to_lowercase());
|
||||
for folder in items {
|
||||
let name = Path::new(&folder.local_path)
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| folder.local_path.clone());
|
||||
rows.push((
|
||||
name,
|
||||
Some(folder.local_path.clone()),
|
||||
MoveTarget::Synced {
|
||||
root: folder.local_path.clone(),
|
||||
path: folder.local_path,
|
||||
mirror: folder.mode == "mirror",
|
||||
},
|
||||
));
|
||||
}
|
||||
Some(rows)
|
||||
}
|
||||
|
||||
/// The folders in a mounted folder: `folder` is mountpoint-relative for My
|
||||
/// files and absolute for an on-demand synced folder.
|
||||
async fn drive_folders(&self, here: &MoveTarget, folder: String) -> Option<Vec<PickerRow>> {
|
||||
let rx = spawn_request(
|
||||
self.ui.dirs.control_socket(),
|
||||
Request::ListDir {
|
||||
path: folder.clone(),
|
||||
},
|
||||
);
|
||||
let Ok(Ok(Response::Entries { entries })) = rx.recv().await else {
|
||||
return None;
|
||||
};
|
||||
let mut names: Vec<String> = entries
|
||||
.into_iter()
|
||||
.filter(|e| e.is_dir)
|
||||
.map(|e| e.name)
|
||||
.collect();
|
||||
names.sort_by_key(|name| name.to_lowercase());
|
||||
Some(
|
||||
names
|
||||
.into_iter()
|
||||
.map(|name| {
|
||||
let target = here.child(&name);
|
||||
(name, None, target)
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// One row of the picker: its title, an optional subtitle, and where it leads.
|
||||
type PickerRow = (String, Option<String>, MoveTarget);
|
||||
|
||||
/// The folders in a mirror folder's directory `path`, read from disk off the
|
||||
/// main thread. A mirror folder has no mount for `ListDir` to read.
|
||||
async fn local_folders(here: &MoveTarget, path: String) -> Option<Vec<PickerRow>> {
|
||||
let names = gio::spawn_blocking(move || {
|
||||
let mut names: Vec<String> = std::fs::read_dir(&path)
|
||||
.ok()?
|
||||
.flatten()
|
||||
.filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
|
||||
.filter_map(|e| e.file_name().into_string().ok())
|
||||
.filter(|name| !name.contains(".pdfs-tmp-"))
|
||||
.collect();
|
||||
names.sort_by_key(|name| name.to_lowercase());
|
||||
Some(names)
|
||||
})
|
||||
.await
|
||||
.ok()??;
|
||||
Some(
|
||||
names
|
||||
.into_iter()
|
||||
.map(|name| {
|
||||
let target = here.child(&name);
|
||||
(name, None, target)
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn picker_note(text: &str) -> gtk4::ListBoxRow {
|
||||
@@ -2329,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
|
||||
/// (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.
|
||||
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 {
|
||||
return None;
|
||||
}
|
||||
@@ -2347,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
|
||||
/// prior colour class first, since list factories recycle cells.
|
||||
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);
|
||||
}
|
||||
let issue = entry.issue.map(super::locations::issue_text);
|
||||
badge.set_tooltip_text(issue.as_deref());
|
||||
match badge_for(entry) {
|
||||
Some((icon, class)) => {
|
||||
badge.set_icon_name(Some(icon));
|
||||
@@ -2749,6 +2921,7 @@ pub(crate) fn repaint_search(ui: &Rc<Ui>, hits: &[SearchHit]) {
|
||||
shared_by_unverified: false,
|
||||
trashed_at: 0,
|
||||
trashed_from: None,
|
||||
issue: None,
|
||||
})
|
||||
.collect();
|
||||
sort_entries(&mut entries, ui.browser.view.get());
|
||||
@@ -2786,11 +2959,11 @@ fn listing_summary(files: usize, folders: usize, file_bytes: u64) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
hit_location, listing_summary, move_target_allowed, show_thumbnail_build_progress,
|
||||
sort_entries,
|
||||
MoveTarget, badge_for, hit_location, listing_summary, move_target_allowed,
|
||||
show_thumbnail_build_progress, sort_entries,
|
||||
};
|
||||
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 {
|
||||
DirEntry {
|
||||
@@ -2808,6 +2981,7 @@ mod tests {
|
||||
shared_by_unverified: false,
|
||||
trashed_at: 0,
|
||||
trashed_from: None,
|
||||
issue: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2815,6 +2989,20 @@ mod tests {
|
||||
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]
|
||||
fn a_search_hit_location_is_its_parent_folder() {
|
||||
assert_eq!(hit_location("Work/Old/a.txt"), "Work/Old");
|
||||
@@ -2880,4 +3068,43 @@ mod tests {
|
||||
status.running = false;
|
||||
assert!(!show_thumbnail_build_progress(&status));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_picker_climbs_from_a_location_top_to_the_list_of_locations() {
|
||||
assert_eq!(
|
||||
MoveTarget::Drive(String::new()).parent(),
|
||||
MoveTarget::Locations
|
||||
);
|
||||
let root = MoveTarget::Synced {
|
||||
root: "/home/u/Docs".into(),
|
||||
path: "/home/u/Docs".into(),
|
||||
mirror: true,
|
||||
};
|
||||
assert_eq!(root.parent(), MoveTarget::Locations);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_picker_climbs_one_folder_at_a_time_inside_a_location() {
|
||||
assert_eq!(
|
||||
MoveTarget::Drive("a/b".into()).parent(),
|
||||
MoveTarget::Drive("a".into())
|
||||
);
|
||||
assert_eq!(
|
||||
MoveTarget::Drive("a".into()).parent(),
|
||||
MoveTarget::Drive(String::new())
|
||||
);
|
||||
let inner = MoveTarget::Synced {
|
||||
root: "/home/u/Docs".into(),
|
||||
path: "/home/u/Docs/x/y".into(),
|
||||
mirror: false,
|
||||
};
|
||||
assert_eq!(
|
||||
inner.parent(),
|
||||
MoveTarget::Synced {
|
||||
root: "/home/u/Docs".into(),
|
||||
path: "/home/u/Docs/x".into(),
|
||||
mirror: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
//! `~/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
|
||||
//! *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:
|
||||
//! 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,
|
||||
}
|
||||
|
||||
/// What a queue row shows that can change: id, attempts, next attempt, parked.
|
||||
pub(crate) type QueueKey = (i64, i64, Option<i64>, bool);
|
||||
/// What a queue row shows that can change: id, attempts, next attempt, parked,
|
||||
/// issue.
|
||||
pub(crate) type QueueKey = (i64, i64, Option<i64>, bool, Option<SyncIssue>);
|
||||
|
||||
/// The "Waiting to Upload" list: the daemon's pending-op queue.
|
||||
pub(crate) struct QueueState {
|
||||
@@ -262,20 +263,22 @@ pub(crate) fn build_locations_page(history: >k4::Widget) -> (gtk4::Widget, Loc
|
||||
(frame.upcast(), widgets)
|
||||
}
|
||||
|
||||
/// `child` in a clamp that scrolls vertically only, with page margins.
|
||||
/// `child` across the page's full width, scrolling vertically only, with page
|
||||
/// margins.
|
||||
fn scrolled_column(child: &impl IsA<gtk4::Widget>) -> gtk4::ScrolledWindow {
|
||||
let clamp = adw::Clamp::builder().child(child).build();
|
||||
clamp.set_margin_top(18);
|
||||
clamp.set_margin_bottom(18);
|
||||
clamp.set_margin_start(18);
|
||||
clamp.set_margin_end(18);
|
||||
let column = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
||||
column.append(child);
|
||||
column.set_margin_top(18);
|
||||
column.set_margin_bottom(18);
|
||||
column.set_margin_start(18);
|
||||
column.set_margin_end(18);
|
||||
// Never scroll sideways: a location's subtitle holds a full path, and letting
|
||||
// the row grow to fit one pushes the controls at its end off screen.
|
||||
// Constrained, the path ellipsizes and the controls stay reachable.
|
||||
gtk4::ScrolledWindow::builder()
|
||||
.vexpand(true)
|
||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||
.child(&clamp)
|
||||
.child(&column)
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -589,7 +592,7 @@ fn repaint_queue(ui: &Rc<Ui>, items: &[PendingOpInfo]) {
|
||||
items.sort_by_key(|op| (!op.failing, op.attempts == 0, op.id));
|
||||
let key: Vec<QueueKey> = items
|
||||
.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();
|
||||
if *queue.painted.borrow() == key {
|
||||
return;
|
||||
@@ -646,6 +649,10 @@ fn queue_row(ui: &Rc<Ui>, op: &PendingOpInfo, now: i64) -> adw::ActionRow {
|
||||
"{action} · {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 {
|
||||
subtitle.push('\n');
|
||||
// Translators: {n} is how many attempts failed, {error} the last error message.
|
||||
@@ -660,7 +667,7 @@ fn queue_row(ui: &Rc<Ui>, op: &PendingOpInfo, now: i64) -> adw::ActionRow {
|
||||
.title(glib::markup_escape_text(&op.path).as_str())
|
||||
.subtitle(glib::markup_escape_text(&subtitle).as_str())
|
||||
.title_lines(1)
|
||||
.subtitle_lines(3)
|
||||
.subtitle_lines(if op.issue.is_some() { 5 } else { 3 })
|
||||
.tooltip_text(&op.path)
|
||||
.build();
|
||||
let image = gtk4::Image::from_icon_name(if op.failing {
|
||||
@@ -685,9 +692,157 @@ fn queue_row(ui: &Rc<Ui>, op: &PendingOpInfo, now: i64) -> adw::ActionRow {
|
||||
retry.connect_clicked(move |_| retry_queued(&ui, Some(id)));
|
||||
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
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn retry_queued(ui: &Rc<Ui>, id: Option<i64>) {
|
||||
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);
|
||||
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.
|
||||
// the account has 2FA enabled). The worker blocks until the dialog feeds
|
||||
@@ -157,6 +162,21 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
||||
}
|
||||
});
|
||||
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx
|
||||
@@ -209,6 +229,12 @@ pub(crate) fn login_error_message(error: &pdfs_core::Error) -> String {
|
||||
pdfs_core::Error::Other(message) if message.contains("two-factor") => {
|
||||
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(
|
||||
"Sign-in cancelled: the verification wasn't completed. Sign in again to get a new one.",
|
||||
),
|
||||
@@ -220,26 +246,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
|
||||
/// runtime. Returns two channels: the first yields the final login result once;
|
||||
/// the second fires *only if* the account needs a 2FA code, carrying a
|
||||
/// [`std::sync::mpsc::Sender`] the UI uses to feed the code back. The login
|
||||
/// closure blocks the worker on that sender until the dialog answers, so the
|
||||
/// 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>)>,
|
||||
) {
|
||||
/// runtime. The returned [`LoginChannels`] carry the final result and the lazy
|
||||
/// prompts. Each login closure blocks the worker on a sender the UI answers, so
|
||||
/// a code is requested lazily and can't expire before the password proof.
|
||||
pub(crate) fn spawn_login(username: String, password: String) -> LoginChannels {
|
||||
let (tx, rx) = async_channel::bounded(1);
|
||||
// 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.
|
||||
let (totp_req_tx, totp_req_rx) = async_channel::bounded(2);
|
||||
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 || {
|
||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
@@ -267,6 +298,19 @@ pub(crate) fn spawn_login(
|
||||
.recv()
|
||||
.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| {
|
||||
// Gated: hand the UI the page to show, block on the token the
|
||||
// user earns by solving it.
|
||||
@@ -287,7 +331,143 @@ pub(crate) fn spawn_login(
|
||||
});
|
||||
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
|
||||
@@ -374,6 +554,12 @@ mod tests {
|
||||
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]
|
||||
fn an_unmapped_error_keeps_its_text() {
|
||||
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,
|
||||
trashed_at: 0,
|
||||
trashed_from: None,
|
||||
issue: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -860,6 +860,7 @@ pub(crate) fn refresh(ui: &Rc<Ui>) {
|
||||
}
|
||||
}
|
||||
|
||||
offer_unlock(ui);
|
||||
refresh_status(ui);
|
||||
refresh_transfers(ui);
|
||||
}
|
||||
@@ -1069,7 +1070,11 @@ pub(crate) fn repaint_transfers(ui: &Rc<Ui>, items: &[TransferItem], jobs: &[Job
|
||||
let row_box = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
|
||||
row_box.set_margin_top(8);
|
||||
row_box.set_margin_bottom(8);
|
||||
let label = gtk4::Label::builder().halign(gtk4::Align::Start).build();
|
||||
// Ellipsized so a long file name cannot widen the page.
|
||||
let label = gtk4::Label::builder()
|
||||
.halign(gtk4::Align::Start)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::Middle)
|
||||
.build();
|
||||
label.add_css_class("dim-label");
|
||||
let bar = gtk4::ProgressBar::new();
|
||||
row_box.append(&label);
|
||||
|
||||
@@ -533,6 +533,7 @@ mod tests {
|
||||
shared_by_unverified: false,
|
||||
trashed_at,
|
||||
trashed_from: from.map(str::to_string),
|
||||
issue: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -133,7 +133,7 @@ impl Drop for Busy {
|
||||
/// 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
|
||||
/// 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
|
||||
/// optional field on purpose: an older daemon that ignored `uid` would resolve
|
||||
|
||||
@@ -535,6 +535,12 @@ async fn mounted_target_within(mountpoint: PathBuf, hit: SearchHit) -> Option<Pa
|
||||
}
|
||||
|
||||
impl Ui {
|
||||
/// `--preload`: create the surface, renderer and style state now, without
|
||||
/// mapping the window, so the first real summon only has to present it.
|
||||
pub(crate) fn preload(&self) {
|
||||
WidgetExt::realize(&self.window);
|
||||
}
|
||||
|
||||
/// Reset and present the resident prompt in response to GApplication
|
||||
/// activation. A second invocation while a file is materialising merely
|
||||
/// raises the progress window; it must not make the in-flight open mutable.
|
||||
|
||||
@@ -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.
|
||||
Err(_) => {
|
||||
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) => (
|
||||
// Translators: {username} is the Proton account name.
|
||||
gettext_f(
|
||||
|
||||
@@ -268,6 +268,9 @@ Options:
|
||||
every keystroke — results appear as you type, unlike
|
||||
--dmenu. Also settable as \"prompt\": { \"mode\": \"fzf\" }.
|
||||
--gtk Force the built-in window, overriding that setting.
|
||||
--preload Start the built-in window hidden and stay resident, so
|
||||
the first summon opens at once. For session autostart;
|
||||
does nothing if the prompt is already running.
|
||||
--menu <COMMAND> Launcher command line for --dmenu, e.g.
|
||||
--menu 'fuzzel --dmenu --width 60'. Overrides
|
||||
\"prompt\": { \"menu\": [...] }.
|
||||
@@ -293,6 +296,8 @@ struct Args {
|
||||
feed: Option<Option<String>>,
|
||||
/// `--inner`: we are the process a spawned terminal is running.
|
||||
inner: bool,
|
||||
/// `--preload`: become the resident GTK instance without showing it.
|
||||
preload: bool,
|
||||
}
|
||||
|
||||
fn parse_args(argv: impl Iterator<Item = String>) -> Result<Args, String> {
|
||||
@@ -302,6 +307,7 @@ fn parse_args(argv: impl Iterator<Item = String>) -> Result<Args, String> {
|
||||
query: None,
|
||||
feed: None,
|
||||
inner: false,
|
||||
preload: false,
|
||||
};
|
||||
let mut argv = argv.peekable();
|
||||
while let Some(arg) = argv.next() {
|
||||
@@ -310,6 +316,7 @@ fn parse_args(argv: impl Iterator<Item = String>) -> Result<Args, String> {
|
||||
"--fzf" => args.mode = Some(PromptMode::Fzf),
|
||||
"--gtk" => args.mode = Some(PromptMode::Gtk),
|
||||
"--inner" => args.inner = true,
|
||||
"--preload" => args.preload = true,
|
||||
// fzf passes an empty argument for an empty input, so a missing
|
||||
// value here is that case rather than a user error.
|
||||
"--feed" => args.feed = Some(argv.next().filter(|text| !text.trim().is_empty())),
|
||||
@@ -337,6 +344,13 @@ fn parse_args(argv: impl Iterator<Item = String>) -> Result<Args, String> {
|
||||
other => return Err(format!("unrecognised argument: {other}")),
|
||||
}
|
||||
}
|
||||
// Only the GTK window has a resident process worth warming up.
|
||||
if args.preload {
|
||||
if matches!(args.mode, Some(PromptMode::Dmenu | PromptMode::Fzf)) || args.query.is_some() {
|
||||
return Err("--preload only applies to the built-in window".to_string());
|
||||
}
|
||||
args.mode = Some(PromptMode::Gtk);
|
||||
}
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
@@ -398,9 +412,31 @@ fn main() -> glib::ExitCode {
|
||||
// GtkApplication normally remains active while it owns a window, including
|
||||
// when that window is hidden. Hold it explicitly as well: residency is a
|
||||
// product requirement here, not an incidental window-lifetime side effect.
|
||||
let _resident = app.hold();
|
||||
app.connect_startup(|_| theme::load_resources("/de/nils/protondrivelinux/prompt.css"));
|
||||
// Only the primary instance runs startup; a hold taken before registration
|
||||
// would also keep every remote invocation alive after it has forwarded
|
||||
// its activation.
|
||||
let resident = Rc::new(RefCell::new(None));
|
||||
app.connect_startup(move |app| {
|
||||
*resident.borrow_mut() = Some(app.hold());
|
||||
theme::load_resources("/de/nils/protondrivelinux/prompt.css");
|
||||
});
|
||||
if args.preload {
|
||||
if let Err(e) = app.register(gtk4::gio::Cancellable::NONE) {
|
||||
eprintln!("pdfs-prompt: {e}");
|
||||
return glib::ExitCode::FAILURE;
|
||||
}
|
||||
// Another instance is already resident; activating it would show it.
|
||||
// Dropping a registered application that never ran makes GIO warn
|
||||
// about the D-Bus registration, so leave it to process exit.
|
||||
if app.is_remote() {
|
||||
std::mem::forget(app);
|
||||
return glib::ExitCode::SUCCESS;
|
||||
}
|
||||
}
|
||||
let prompt: Rc<RefCell<Option<Rc<hud::Ui>>>> = Rc::new(RefCell::new(None));
|
||||
// The first activation of a preloading instance is its own startup, not a
|
||||
// summon: build the window but leave it hidden.
|
||||
let quiet = std::cell::Cell::new(args.preload);
|
||||
app.connect_activate(move |app| {
|
||||
let ui = if let Some(ui) = prompt.borrow().clone() {
|
||||
ui
|
||||
@@ -411,6 +447,10 @@ fn main() -> glib::ExitCode {
|
||||
*prompt.borrow_mut() = Some(ui.clone());
|
||||
ui
|
||||
};
|
||||
if quiet.replace(false) {
|
||||
ui.preload();
|
||||
return;
|
||||
}
|
||||
ui.activate();
|
||||
});
|
||||
// GTK must not try to parse our own flags; they were consumed above.
|
||||
@@ -582,4 +622,16 @@ mod tests {
|
||||
let args = parse(&["--dmenu", "--query", "tax return 2024"]).unwrap();
|
||||
assert_eq!(args.query.as_deref(), Some("tax return 2024"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preload_forces_the_gtk_window_and_rejects_the_launchers() {
|
||||
let args = parse(&["--preload"]).unwrap();
|
||||
assert!(args.preload);
|
||||
assert_eq!(args.mode, Some(PromptMode::Gtk));
|
||||
|
||||
assert!(parse(&["--preload", "--dmenu"]).is_err());
|
||||
assert!(parse(&["--fzf", "--preload"]).is_err());
|
||||
assert!(parse(&["--preload", "--menu", "fuzzel --dmenu"]).is_err());
|
||||
assert!(parse(&["--preload", "--query", "tax"]).is_err());
|
||||
}
|
||||
}
|
||||
+521
-455
File diff suppressed because it is too large.
Load diff
+5951
-3086
File diff suppressed because it is too large.
Load diff
@@ -11,6 +11,415 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
|
||||
Schema: **36**. Every node row gets a local id that never changes; existing rows keep their
|
||||
row number as that id. Going back to 2.9 means rebuilding the cache. Requires `proton-drive-rs`
|
||||
**0.7.2**.
|
||||
|
||||
### Added
|
||||
- **Migration tests on real databases.** A database 2.8.2 wrote, with queued changes due,
|
||||
parked, failing, waiting for access and in flight, migrates with every change intact.
|
||||
`PDFS_MIGRATE_DB=<path> cargo test -p pdfs-core a_copy_of_a_real_database -- --ignored` runs
|
||||
the same check on a read-only copy of any database.
|
||||
|
||||
### Changed
|
||||
- **A file keeps its inode number across daemon restarts.** Inodes are now the node's local id
|
||||
instead of a counter that started over on every run.
|
||||
- **Every mount serves from one tree.** An on-demand sync folder used to keep its own copy of the
|
||||
nodes it showed, which background work had to visit one by one and could miss (B74). It now
|
||||
shares the tree with My files; only the root it starts at is its own.
|
||||
|
||||
### Fixed
|
||||
- **A file renamed or moved while its first upload is on the wire keeps its new name and folder.**
|
||||
It landed under the old name and the rename was lost (B136).
|
||||
|
||||
## [2.9.0] — 2026-10-03
|
||||
|
||||
Schema: **35** (unchanged). Requires `proton-drive-rs` **0.7.2**.
|
||||
|
||||
### Added
|
||||
- **Seeded simulation runs.** `cargo test -p pdfs-fuse --lib sim:: -- --ignored
|
||||
--test-threads=1` mounts one or more real FUSE daemons against an in-memory Drive that lags,
|
||||
drops, reorders and echoes like the real one. Each seed draws syscalls, link flaps and restarts,
|
||||
checks every answer against a POSIX model, and at the end checks that nothing was lost and the
|
||||
clients converged. CI runs them on pushes to main. They found B122 to B135.
|
||||
- **More acceptance cases.** Regressions for B48, B80, B86, B88, B94, B95, B98, B102, B111, B113
|
||||
and B114 to B121, and a check that `pdfs` output into a closed pipe stays quiet. The console
|
||||
report has one line per case and a summary table, and `--journal-check` also fails on a
|
||||
conflict copy, a FUSE job held for long, or an authority re-read.
|
||||
|
||||
### Fixed
|
||||
- **A new file moved or renamed before its first upload no longer lands as a conflict copy**
|
||||
next to its own empty original (B113).
|
||||
- **`pdfs rm`, `pdfs rename` and `pdfs mkdir` take paths in on-demand folders.** They answered
|
||||
"is not under the mountpoint" for a path `pdfs move` took. `rm` and `rename` refuse a path that
|
||||
names a mount's root (B88).
|
||||
- **`pdfs ls | head` no longer panics** with "Broken pipe". A closed stdout exits 0; any other
|
||||
write error exits 1.
|
||||
- **A file removed, renamed or moved with `pdfs` stops resolving under its old name at once**,
|
||||
instead of for up to 30 seconds (B114).
|
||||
- **`pdfs ls` and the app's file browser show a file's real size**, not its encrypted one
|
||||
(B115).
|
||||
- **Trashing a folder also drops the creates queued in its subfolders.** One could stay queued
|
||||
with its bytes, or land in the root (B116).
|
||||
- **An upload queued for a file `pdfs move` moved keeps its path** in `pdfs sync queue` and is
|
||||
no longer put off once after the resume (B117).
|
||||
- **Creating a file or folder through the mount no longer fails with `ENOENT`** when Drive is
|
||||
slow to list what it made. The mount asks again, and queues the create if it still cannot see
|
||||
it (B118).
|
||||
- **A file just written can be read without the network.** Its uploaded bytes stay cached, and
|
||||
`pdfs status` counts the upload as pending until they are (B119).
|
||||
- **A file queued inside a folder that just landed is no longer put off** for five seconds
|
||||
(B120).
|
||||
- **A move right after a rename lands.** It failed with `Input/output error` through the mount
|
||||
and with "out of date" from `pdfs move`, and a rename across folders took up to 10 seconds
|
||||
(B111, B121).
|
||||
- **A rename across folders no longer fails with `Input/output error`** when the source folder
|
||||
holds the new name (B122).
|
||||
- **A second write open on a file just written no longer fails** with `Input/output error`
|
||||
(B123).
|
||||
- **After a replacing rename fails, the file it was replacing stays in the mount** (B124).
|
||||
- **A write to a file whose create could not be read back no longer lands as a conflict copy**
|
||||
(B131).
|
||||
- **A file just moved is found by its new path.** A lookup right after the rename could miss it
|
||||
(B135).
|
||||
|
||||
### Notes
|
||||
- The simulation runs found bugs that are still open: B125 to B130 and B132 to B134 in
|
||||
`docs/BUGS.md`. The worst are B127 and B128 (a file created offline can land twice, or be lost
|
||||
when created over one deleted offline), B129 (a file deleted while its create is on the wire
|
||||
stays on Drive) and B130 (a read that a remote change invalidates can hang the daemon).
|
||||
|
||||
## [2.8.2] — 2026-10-01
|
||||
|
||||
Schema: **35** (unchanged). Requires `proton-drive-rs` **0.7.2**.
|
||||
|
||||
### Fixed
|
||||
- **Folders shared with you no longer go missing when one person shared several.** When
|
||||
several accepted shares came from the same owner's volume, only the first appeared under
|
||||
Shared with me, and the rest were skipped with `missing key`. `proton-drive-rs` 0.7.2 resolves
|
||||
each shared root with its own key (B112, #25).
|
||||
|
||||
## [2.8.1] — 2026-09-30
|
||||
|
||||
Schema: **35** (unchanged). Requires `proton-drive-rs` **0.7.1**.
|
||||
|
||||
### Fixed
|
||||
- **The service no longer starts before the desktop session.** `proton-drive.service` was
|
||||
wanted by `default.target`, which is reached before the desktop hands `DISPLAY` /
|
||||
`WAYLAND_DISPLAY` to the user's systemd. The first keyring call then started the Secret
|
||||
Service without a display; on KDE `ksecretd` crashed at every login and the first start of the
|
||||
service failed. The unit is now wanted by `graphical-session.target`. Existing installs switch
|
||||
over on the next sign-in or "Start on login" toggle, or with
|
||||
`systemctl --user reenable proton-drive.service` (#23).
|
||||
|
||||
## [2.8.0] — 2026-09-30
|
||||
|
||||
Schema: **35** (unchanged). Requires `proton-drive-rs` **0.7.1**.
|
||||
|
||||
### Added
|
||||
- **`pdfs trash --json`** prints the trash listing as JSON.
|
||||
- **The FUSE acceptance suite tests the signed-in account by default.**
|
||||
`scripts/fuse-acceptance.sh` with no arguments creates test folders in My files and new
|
||||
on-demand and mirror sync folders, runs every case in each, and removes all of it afterwards,
|
||||
also on a failure or interrupt. A run that dies is cleaned up by the next one. New cases cover
|
||||
block boundaries, unusual names, a deep tree, a wide directory, rename patterns, handle coherency,
|
||||
throughput and B100. `--offline-only` runs the account-free suite as before.
|
||||
|
||||
### Changed
|
||||
- **The mount keeps working through a network drop.** When Drive stops answering, the daemon
|
||||
now marks itself offline within about 20 seconds instead of failing every call for up to two
|
||||
minutes. While offline, creating, renaming and deleting files and folders is queued instead of
|
||||
failing with `Input/output error`, folders list from the local cache, and a read that needs
|
||||
data from Drive waits up to 30 seconds for the connection. The daemon checks every few seconds
|
||||
for the connection to return, then uploads the queued changes. A file whose creation timed
|
||||
out but reached Drive anyway is taken over rather than uploaded again as a conflict copy.
|
||||
- **A dead connection is noticed sooner.** `proton-drive-rs` 0.7.1 sends TCP keepalives, so a
|
||||
connection that died silently fails instead of waiting for its request timeout.
|
||||
|
||||
### Fixed
|
||||
- **Truncating and then writing past the end no longer brings old bytes back.** The gap
|
||||
between the new end and the write now reads as zeros, as it should, instead of the file's
|
||||
earlier content. When that earlier content had not been uploaded yet, the file failed with
|
||||
`Input/output error` and its upload retried forever (B101).
|
||||
- **A write made while a new file is first uploading is no longer lost.** Drive could keep an
|
||||
empty or older copy of a file written right after it was created, and closing a file held open
|
||||
across its first upload could fail (B101).
|
||||
- **Removing a folder also cancels the pending uploads of the files inside it.** Before, an
|
||||
edit that had not reached Drive yet kept retrying against the trash forever and kept its
|
||||
staged copy on disk. `pdfs rm` now also drops what was queued for what it removes (B102).
|
||||
- **A program reading a file keeps its data when another program replaces the file.** Saving
|
||||
over a file by renaming a new copy onto it (what most editors and `os.replace` do) made
|
||||
reads through handles already open on the old file fail with `Input/output error` (B104).
|
||||
- **Short-lived files no longer come back as conflict copies.** A file deleted right after it
|
||||
was written, such as a SQLite journal, could be uploaded as a `recovered-…` conflict copy in
|
||||
the Drive root (B105).
|
||||
- **Editors can save over a file several times in a row.** The fix for B104 could leave the
|
||||
upload of a replaced file queued, and the next save onto the same name failed with
|
||||
`Input/output error` (B106).
|
||||
- **Renaming a file back right after renaming it no longer fails.** Drive can briefly call the
|
||||
second rename out of date, and the daemon now waits it out instead of returning an I/O error
|
||||
(B111).
|
||||
- **Reading a freshly listed file no longer hangs.** When a read had to fetch the file's real
|
||||
size first, the daemon could wait on the read it was serving, and the program froze until
|
||||
the daemon restarted (B110).
|
||||
- **Databases and other files kept open while written no longer read back empty.** When the
|
||||
folder was listed again while a file was still open, its size dropped to what Drive held,
|
||||
often 0 bytes, and SQLite reported `database disk image is malformed` (B109).
|
||||
- **Deleting files during a mirror pass no longer marks the folder as failed.** A file removed
|
||||
after the pass planned its upload was counted as an error, so the folder showed `error` until
|
||||
a later pass (B108).
|
||||
- **A read no longer hangs when the connection stalls.** A read that gets no answer from Drive
|
||||
within two minutes now fails with `Input/output error` instead of blocking the program for
|
||||
as long as half an hour (B107).
|
||||
- **The trash no longer freezes after a server call hangs.** Until the daemon restarted, the
|
||||
trash kept showing the same entries, and a folder just deleted could not be found there to
|
||||
delete permanently (B103).
|
||||
- **Freshly listed files read correctly.** A file whose real size had not been fetched yet
|
||||
reported its encrypted size, about 53 bytes too big, and reading it failed with
|
||||
`Input/output error`. This happened most often right after copying a large folder. Reads and
|
||||
write opens now fetch the real size first, and a big folder walk no longer skips size lookups
|
||||
(B100).
|
||||
|
||||
## [2.7.0] — 2026-09-29
|
||||
|
||||
Schema: **35** (unchanged).
|
||||
|
||||
### Added
|
||||
- **Moves between locations without transferring content.** `pdfs move` and the app's
|
||||
**Move to…** now take a file or folder from My files into a synced folder, or between two
|
||||
synced folders, on-demand or mirrored. The move happens on Proton Drive, so nothing is
|
||||
downloaded or uploaded again. `mv` between two locations still copies, because the kernel
|
||||
refuses a rename between two mounts. The app's Move dialog lists every location at its top
|
||||
level. A move is refused, with the reason, when the source still has changes that are not on
|
||||
Proton Drive, when the two locations are on different volumes, or when a synced folder stays
|
||||
busy with a sync pass.
|
||||
- **`pdfs move` takes several sources.** `pdfs move a b c DEST` moves all three and
|
||||
reports each one that could not move.
|
||||
- **Moving into a mirrored folder reuses cached content.** A file already cached by
|
||||
an online-only folder is copied into place instead of downloaded again.
|
||||
|
||||
### Changed
|
||||
- **New project homepage: [proton-drive.narl.io](https://proton-drive.narl.io).** The About
|
||||
dialog's website link, the README, and the package metadata (Cargo, PKGBUILDs, `.deb`,
|
||||
`.rpm`) point there. The source code link stays on GitHub.
|
||||
|
||||
## [2.6.2] — 2026-09-28
|
||||
|
||||
Schema: **35** (unchanged).
|
||||
|
||||
### Fixed
|
||||
- **Photos remembers the Albums and Places views.** Leaving Photos from Albums or Places and
|
||||
coming back showed the timeline while the Albums or Places toggle stayed selected. The page
|
||||
now reopens on the selected view, and Places keeps its Grid or Map layout.
|
||||
- **The Sync page keeps a steady width.** A long file name in Transfers no longer widens the
|
||||
page. Overview and Folders now fill the width next to the sidebar.
|
||||
|
||||
## [2.6.1] — 2026-09-28
|
||||
|
||||
Schema: **35** (unchanged).
|
||||
|
||||
### Added
|
||||
- **`pdfs-prompt --preload`.** Starts the launcher hidden, for session autostart such as
|
||||
Hyprland's `exec-once`, so the first shortcut opens it as fast as later ones. It does nothing
|
||||
when the launcher is already running.
|
||||
|
||||
### Fixed
|
||||
- **Launcher invocations no longer pile up.** Each shortcut press after the first used to leave
|
||||
a `pdfs-prompt` process behind after handing the request to the running launcher. It now exits.
|
||||
|
||||
## [2.6.0] — 2026-09-28
|
||||
|
||||
The search launcher (`pdfs-prompt`) is rebuilt around a preview pane, and it now looks like the
|
||||
@@ -1226,6 +1635,17 @@ 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
|
||||
by a new POSIX compliance suite for the filesystem.
|
||||
|
||||
[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.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.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.8.1
|
||||
[2.8.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.8.0
|
||||
[2.7.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.7.0
|
||||
|
||||
[2.6.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.6.2
|
||||
[2.6.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.6.1
|
||||
[2.6.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.6.0
|
||||
[2.5.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.5.0
|
||||
[2.4.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.4.0
|
||||
|
||||
+5
-2
@@ -53,7 +53,7 @@ talks to the running service (`proton-drive.service`); `login`, `logout`, `mount
|
||||
| `pdfs search QUERY [--in FOLDER] [--limit N]` | Search names in the local index. |
|
||||
| `pdfs mkdir PARENT NAME` | Create a folder. |
|
||||
| `pdfs rename PATH NEW_NAME` | Rename a file or folder. |
|
||||
| `pdfs move PATH NEW_PARENT` | Move into another folder. |
|
||||
| `pdfs move PATH... NEW_PARENT` | Move one or more files or folders into another folder. The two may be in different locations (My files, an on-demand or a mirrored synced folder); the move happens on Proton Drive, without downloading or uploading anything. |
|
||||
| `pdfs rm PATH` | Move to the Trash. |
|
||||
| `pdfs upload SOURCES... [-t FOLDER]` | Upload local files and folders (recursively) in the background. Follow with `pdfs transfers`. |
|
||||
| `pdfs refresh [TARGET] [--full]` | Forget a cached listing so the next read fetches it again. `TARGET` is a folder, `trash` or `photos`; `--full` with `photos` re-reads every photo's metadata. |
|
||||
@@ -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 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 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 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). |
|
||||
|
||||
@@ -177,7 +180,7 @@ commands are for scripts and for cases where the mount is not available.
|
||||
## Scripting with `--json`
|
||||
|
||||
The global `--json` flag makes query commands print JSON: `status`, `ls`, `pins`, `sync list`,
|
||||
`devices list`, `locations`, `transfers`, `activity` and `cache inspect`. Commands that change
|
||||
`devices list`, `locations`, `transfers`, `activity`, `trash` and `cache inspect`. Commands that change
|
||||
something keep their human-readable output; check their exit code.
|
||||
|
||||
```bash
|
||||
|
||||
+18
-5
@@ -38,7 +38,7 @@ Inside `content/`:
|
||||
> not reached Proton Drive and exists nowhere else.
|
||||
|
||||
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`
|
||||
|
||||
@@ -80,6 +80,7 @@ fix it.
|
||||
| `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" |
|
||||
| `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` |
|
||||
| `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 |
|
||||
@@ -116,6 +117,15 @@ were copied from.
|
||||
|
||||
`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`
|
||||
|
||||
Decides how files picked in the search launcher or the app are opened. Rules are tried in order;
|
||||
@@ -180,13 +190,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_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
|
||||
|
||||
`proton-drive.service` is a user unit. It restarts the service whenever it exits, restarts it when
|
||||
it stops answering for two minutes (`WatchdogSec=120`), and caps its memory at 6 GiB. To change
|
||||
the unit without editing the packaged file, use a drop-in:
|
||||
`proton-drive.service` is a user unit started with your graphical session
|
||||
(`WantedBy=graphical-session.target`). It restarts the service whenever it exits, restarts it
|
||||
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
|
||||
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 |
|
||||
|---|---|
|
||||
| `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-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 |
|
||||
| `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 |
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
For changes to the FUSE layer, the write queue or the sync engine, also run the live acceptance
|
||||
cases described in [TESTING.md](TESTING.md).
|
||||
For changes to the FUSE layer, the queue or the sync engine, also run the
|
||||
[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:
|
||||
|
||||
@@ -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
|
||||
[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
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
Before a release that touches the filesystem or sync, run the managed live mode matrix from
|
||||
[TESTING.md](TESTING.md). Open release-assurance items are listed in [ROADMAP.md](ROADMAP.md).
|
||||
Before a release, work through [Before a release](TESTING.md#before-a-release). Open
|
||||
release-assurance items are listed in [ROADMAP.md](ROADMAP.md#release-assurance).
|
||||
@@ -215,6 +215,14 @@ starts; to switch at once:
|
||||
systemctl --user restart proton-drive.service
|
||||
```
|
||||
|
||||
Coming from 2.8.0 or older, re-enable the service once. Those releases hooked it into
|
||||
`default.target`, which starts it before the desktop session is ready; on KDE that crashes
|
||||
`ksecretd` at every login. Signing in again or toggling "Start on login" does this as well:
|
||||
|
||||
```bash
|
||||
systemctl --user reenable proton-drive.service
|
||||
```
|
||||
|
||||
Database migrations run automatically and only move forward. A database written by a newer
|
||||
release refuses to open in an older one, so downgrading means clearing the local database (see
|
||||
[Troubleshooting](TROUBLESHOOTING.md#start-over-with-a-clean-local-state)).
|
||||
|
||||
+4
-5
@@ -5,7 +5,7 @@
|
||||
| Page | What it covers |
|
||||
|---|---|
|
||||
| [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` |
|
||||
| [Configuration](CONFIGURATION.md) | `config.json`, environment variables, file locations, the systemd service |
|
||||
| [Troubleshooting](TROUBLESHOOTING.md) | Diagnosing problems, common symptoms, starting over, reporting a bug |
|
||||
@@ -16,13 +16,12 @@
|
||||
|
||||
| Page | What it covers |
|
||||
|---|---|
|
||||
| [Development guide](DEVELOPMENT.md) | Building, running and debugging from source; releasing |
|
||||
| [Architecture](ARCHITECTURE.md) | Crates, read and write paths, sync engine, control protocol, threads, threat model |
|
||||
| [Testing](TESTING.md) | Unit tests and the FUSE acceptance suite |
|
||||
| [Development guide](DEVELOPMENT.md) | Building, running and debugging from source; common changes; releasing |
|
||||
| [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, simulation runs and the FUSE acceptance suite |
|
||||
| [Translating](TRANSLATING.md) | Adding and updating translations |
|
||||
| [Roadmap](ROADMAP.md) | Planned work and verification still owed |
|
||||
| [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
|
||||
root.
|
||||
+60
-25
@@ -1,24 +1,54 @@
|
||||
# Roadmap
|
||||
|
||||
Planned work and open verification, as of 2.5.0. Individual defects and their status live in
|
||||
[BUGS.md](BUGS.md), which is the authoritative ledger; this page groups what is left by theme.
|
||||
Nothing here is a promise of a date.
|
||||
Planned work and open verification after 3.0.0. Individual defects and their status are in
|
||||
[BUGS.md](BUGS.md); this page groups what is left by theme. Nothing here has a date.
|
||||
|
||||
- [Sync engine](#sync-engine)
|
||||
- [Data safety and recovery](#data-safety-and-recovery)
|
||||
- [Features](#features)
|
||||
- [Performance and correctness](#performance-and-correctness)
|
||||
- [Verification still owed](#verification-still-owed)
|
||||
- [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
|
||||
|
||||
| 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) |
|
||||
| Restore pins from the machine profile | Not started | `profile.json` records pins; `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) |
|
||||
| Rotated credentials survive a keyring failure | Open | Surface 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) |
|
||||
| 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; `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 app (B68) |
|
||||
| Rotated credentials survive a keyring failure | Open | Report and retry keyring write failures, so a rotated single-use refresh token is not lost (B61) |
|
||||
| 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
|
||||
|
||||
@@ -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 |
|
||||
| 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` |
|
||||
| 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 |
|
||||
| Photo name search | Not started | Photos search by date and filters only |
|
||||
| Automatic photo upload from a folder | Not started | |
|
||||
@@ -39,34 +70,38 @@ Nothing here is a promise of a date.
|
||||
|
||||
| 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) |
|
||||
| Path-based commands under secondary mounts | `pin`, `rename`, `move`, `rm` and sharing cannot name a path inside an on-demand synced folder yet (B88) |
|
||||
| Unicode normalization | NFC and NFD names are distinct, deliberately (B36) |
|
||||
| Pipelined node enumeration | Faster cold listings of large folders |
|
||||
| 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, on purpose (B36) |
|
||||
| Pipelined node enumeration | Faster first listings of large folders |
|
||||
|
||||
## Verification still owed
|
||||
|
||||
Fixes marked *unverified* in [BUGS.md](BUGS.md) have unit or offline coverage but have not been
|
||||
driven against a live account. The larger outstanding runs:
|
||||
Fixes marked *unverified* in [BUGS.md](BUGS.md) have unit or simulation coverage but have not
|
||||
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
|
||||
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
|
||||
with `pdfs sync restore`, and compare the tree byte for byte. See [RECOVERY.md](RECOVERY.md).
|
||||
- **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.
|
||||
- **Fault injection.** Network loss mid-upload, a full disk during staging, and crash points
|
||||
across the write publication path (after each fsync, rename and queue commit).
|
||||
- **Fault injection on a real account.** Network loss mid-upload, a full disk during staging, and
|
||||
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).
|
||||
- **Managed live mode matrix** before each release:
|
||||
`scripts/fuse-acceptance.sh --managed-live <empty-a> <empty-b>`. See [TESTING.md](TESTING.md).
|
||||
- **The overnight hang** (B91) is not diagnosed yet. The supervisor and `pdfs diagnostics` exist to
|
||||
catch the next occurrence.
|
||||
- **The overnight hang** (B91) is not diagnosed. The supervisor and `pdfs diagnostics` exist to
|
||||
catch the next one.
|
||||
|
||||
## Release assurance
|
||||
|
||||
| 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) |
|
||||
| Protocol version identity between front ends and daemon | Open (B63) |
|
||||
| SBOM, provenance and checksums for release artifacts | Open (B65). The RPM is GPG-signed |
|
||||
+219
-163
@@ -1,209 +1,265 @@
|
||||
# Testing
|
||||
|
||||
The normal Rust suite exercises the metadata database, sync planner, offline
|
||||
queue, write staging, cache, and FUSE handler state without requiring a Proton
|
||||
account:
|
||||
Three layers check the client, from fast and offline to slow and against a real 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 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
|
||||
temporary local directory. This validates the runner and covers creation/open
|
||||
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:
|
||||
To check the migrations against a real database, point `PDFS_MIGRATE_DB` at it and run the
|
||||
ignored test. It migrates a copy taken with `VACUUM INTO` and never writes the database named:
|
||||
|
||||
```console
|
||||
scripts/fuse-acceptance.sh --offline-only
|
||||
```bash
|
||||
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. Live testing is
|
||||
explicitly opt-in and always runs the account-free contract first. It runs
|
||||
destructive POSIX operations only below a fresh `pdfs-acceptance-*` directory.
|
||||
It refuses paths that are not FUSE mounts and removes its directory on success,
|
||||
failure, or interruption.
|
||||
## Simulation runs
|
||||
|
||||
Use a dedicated test account without irreplaceable data:
|
||||
The simulation runs (`crates/pdfs-fuse/src/sim/`) start real daemons with real FUSE mounts against
|
||||
an in-memory Drive, `FakeDrive`. The daemon runs as in production (drain workers, sync engine,
|
||||
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.
|
||||
|
||||
```console
|
||||
scripts/fuse-acceptance.sh --live /mnt/on-demand-testmount
|
||||
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
|
||||
```
|
||||
|
||||
### The mount is diffed against an ordinary filesystem
|
||||
| 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 |
|
||||
|
||||
The local reference run is not only a self-test of the runner. Every case
|
||||
records the facts it established, and a live run is compared against those
|
||||
recordings, so a case fails when the mount *differs* from an ordinary
|
||||
filesystem — not only when it raises. The report names the exact key:
|
||||
A failure is a seed that can be replayed, but the daemon uses real threads, so a race may need
|
||||
several replays. In CI, the `simulation` job uploads the stacks of a hung run as an artifact.
|
||||
|
||||
```
|
||||
[DIVERGENCE] live FUSE /mnt/testmount does not match the reference filesystem:
|
||||
! creation and open flags.truncated.size: live=6 reference=0
|
||||
## Acceptance suite
|
||||
|
||||
`scripts/fuse-acceptance.sh` checks the filesystem contract a program sees: creation and open
|
||||
flags, positioned and vectored I/O, truncation and sparse files, `mmap`, `sendfile`,
|
||||
`copy_file_range`, durability barriers, renames and their `renameat2` flags, open-file lifetime,
|
||||
names, `readdir` under concurrent change, metadata, extended attributes, holes, refusals of
|
||||
unsupported operations, concurrent I/O, and real application workloads.
|
||||
|
||||
> [!CAUTION]
|
||||
> Every mode except `--offline-only` creates and deletes real files in Proton Drive. Use a test
|
||||
> account with nothing you cannot lose.
|
||||
|
||||
### Modes
|
||||
|
||||
```bash
|
||||
scripts/fuse-acceptance.sh --offline-only # the reference run; no account
|
||||
scripts/fuse-acceptance.sh # the signed-in account, every mode pairing
|
||||
scripts/fuse-acceptance.sh --quick # one on-demand/mirror pairing instead of four
|
||||
scripts/fuse-acceptance.sh --live /mnt/testmount # mounts you set up yourself
|
||||
scripts/fuse-acceptance.sh --managed-live /mnt/a /mnt/b
|
||||
```
|
||||
|
||||
Facts are recorded in one of two ways, chosen in the test body:
|
||||
**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.
|
||||
|
||||
- **compared** — a claim about filesystem semantics. The mount must agree.
|
||||
- **noted** — context that legitimately differs: inode numbers, block counts,
|
||||
reported mode bits, and which optional syscalls exist at all. Reported under
|
||||
`[capabilities]`, never a failure.
|
||||
**An account run** (no mode):
|
||||
|
||||
Because the choice is made at each call site, the set of accepted divergences
|
||||
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.
|
||||
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.
|
||||
|
||||
### Operations the filesystem does not implement
|
||||
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.
|
||||
|
||||
`git`, `rsync`, `cp -a`, `tar`, `df` and most installers probe for symlinks,
|
||||
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
|
||||
asserts that each either works or is *refused cleanly* — a recognised errno,
|
||||
never `EIO` and never a hang. A refusal that is merely unimplemented is fine;
|
||||
one that is dirty breaks software that never asked for the feature.
|
||||
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.
|
||||
|
||||
### Regressions from the bug ledger
|
||||
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.
|
||||
|
||||
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.
|
||||
**`--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.
|
||||
|
||||
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.
|
||||
**`--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`.
|
||||
|
||||
**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.
|
||||
### Reading the output
|
||||
|
||||
**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.
|
||||
A run opens with the mode, the `pdfs` binary and version, Python, the kernel, the per-case limit
|
||||
and the options. Each target then names its root, the storage behind it, the daemon and the
|
||||
number of cases, followed by one line per case:
|
||||
|
||||
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.
|
||||
|
||||
### 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. A timeout ends the
|
||||
run: later cases against a wedged mount produce noise, not information.
|
||||
|
||||
```console
|
||||
scripts/fuse-acceptance.sh --live /mnt/testmount \
|
||||
--timeout 300 --report-junit results.xml --budget 30
|
||||
```
|
||||
==> live FUSE /mnt/testmount
|
||||
-> root /mnt/testmount/pdfs-acceptance-…
|
||||
-> storage FUSE mount /mnt/testmount (fuse.protondrive)
|
||||
-> daemon pdfs 3.0.0, user@proton.me, online, 0 queued ops
|
||||
-> cases 45 of 45
|
||||
[ 1/45] creation and open flags .................................. ok 0.41s
|
||||
…
|
||||
[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
|
||||
…
|
||||
-> reference matches the ordinary filesystem in 412 compared observations
|
||||
-> result 44 passed, 1 skipped in 11m02s
|
||||
```
|
||||
|
||||
`--report-json` also writes every recorded observation, which is what to attach
|
||||
to a bug report. `--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.
|
||||
A case ends as `ok`, `FAIL`, `TIMEOUT`, `skip` (it does not apply to the target), or `known` (an
|
||||
`open B<n>` case reproduced a bug that is still open). Dimmed lines under a case hold its details.
|
||||
A failed `regression B<n>` case points to its entry in [BUGS.md](BUGS.md).
|
||||
|
||||
`--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.
|
||||
The run ends with a table of every target, the report paths, the slowest cases, cases over
|
||||
`--budget`, known issues that still reproduce, errors the daemon logged, and each failure. The
|
||||
last line is `PASS:` or `FAIL:` with the totals. Colour is used only on a terminal and never with
|
||||
`NO_COLOR` set.
|
||||
|
||||
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.
|
||||
### What the cases cover
|
||||
|
||||
### Automated mode matrix
|
||||
Besides the syscall contract:
|
||||
|
||||
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.
|
||||
- **Block boundaries.** Files from 0 bytes to two 4 MiB blocks plus 3 bytes, every size next to a
|
||||
boundary, and overwrites that grow, shrink and reshape them.
|
||||
- **Unusual names.** 255 bytes, NFC and NFD forms of the same text, newline, tab, emoji,
|
||||
characters Windows forbids, leading and trailing spaces, a case-only rename.
|
||||
- **A deep tree and a wide folder.** 24 levels renamed at the top; 128 entries created
|
||||
concurrently, unlinked concurrently, then `rm -rf`.
|
||||
- **Rename patterns.** Swaps, chains, log rotation, replacing a file under an open reader, the
|
||||
`EISDIR`, `ENOTDIR` and `ENOENT` refusals.
|
||||
- **Handle coherency.** Unsaved writes seen by a second handle, two `O_APPEND` writers, 30 rewrite
|
||||
generations, a truncate under a reader.
|
||||
- **Throughput.** 64 MiB written and read, 1 MiB in 4 KiB writes, metadata on 100 files. The case
|
||||
fails below `PDFS_ACCEPTANCE_MIN_MIBPS` (default 10) or `PDFS_ACCEPTANCE_MIN_OPS` (default 20
|
||||
operations per second, 3 on a FUSE mount). `0` turns a floor off.
|
||||
- **Operations the filesystem does not implement.** `git`, `rsync`, `cp -a`, `tar` and installers
|
||||
probe for symlinks, hard links, FIFOs, device nodes, locks, `O_DIRECT` and `SEEK_HOLE`. Each must
|
||||
work or be refused cleanly: a known errno, never `EIO`, never a hang.
|
||||
- **Moves between locations** (`managed` in `--list`). Trees moved between two sync folders in
|
||||
both directions, several sources in one `pdfs move`, and a tree through My files and out again.
|
||||
The refusals must move nothing and lose nothing: a name already taken, a folder into itself, a
|
||||
mirrored source holding something Drive lacks, a file still open for writing.
|
||||
- **The CLI.** `pdfs` into a closed pipe exits 0 with nothing on stderr; `pdfs mkdir`, `rename`
|
||||
and `rm` by local path, and their refusals.
|
||||
|
||||
```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
|
||||
```
|
||||
### Regressions and open bugs
|
||||
|
||||
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`.
|
||||
A case named `regression B<n>` reproduces an entry of [BUGS.md](BUGS.md) so it stays fixed. Most
|
||||
need a live mount and a reachable daemon, and are skipped without one. `--list` prints every case
|
||||
and the targets it runs on.
|
||||
|
||||
### Durability drill
|
||||
A regression case checks the outcome **on Drive**, so it waits for the queue first. Reading back
|
||||
through the mount proves nothing: the local caches return the bytes just written whether or not
|
||||
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.
|
||||
|
||||
`--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.
|
||||
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.
|
||||
|
||||
Some cases need what a test account cannot provide alone and skip without it: B34 needs a folder
|
||||
shared read-only by a **second account**, B34b one shared with editor rights, and B79 an on-demand
|
||||
synced folder.
|
||||
|
||||
### Timeouts, reports and hung mounts
|
||||
|
||||
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.
|
||||
|
||||
| Option | Effect |
|
||||
|---|---|
|
||||
| `--report-json PATH` | Every result and recorded observation. Attach it to a bug report |
|
||||
| `--report-junit PATH` | JUnit XML. `known` is reported as skipped |
|
||||
| `--budget SECONDS` | Flags cases that pass but take longer (`PDFS_ACCEPTANCE_BUDGET`) |
|
||||
| `--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 |
|
||||
| `--fail-fast` | Stops at the first failure |
|
||||
| `--list` | Prints every case and its targets |
|
||||
|
||||
### Selecting cases
|
||||
|
||||
During development, `PDFS_ACCEPTANCE_ONLY` runs tests whose descriptive name
|
||||
contains the supplied text (for example `PDFS_ACCEPTANCE_ONLY=namespace`). A
|
||||
release acceptance run must leave it unset so the complete contract executes.
|
||||
`PDFS_ACCEPTANCE_ONLY` runs the cases whose name contains the text, ignoring case. Separate several
|
||||
with commas (`PDFS_ACCEPTANCE_ONLY=B116,B117`). A filter that matches no case at all fails, as the
|
||||
typo it is. A release run leaves it unset.
|
||||
|
||||
A filter naming a live-only case (every `regression B<n>` is one) selects nothing
|
||||
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.
|
||||
| Variable | Effect |
|
||||
|---|---|
|
||||
| `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.
|
||||
The first path must be the FUSE mount under test; later paths may be another
|
||||
FUSE mount or a normal local mirror filesystem:
|
||||
## Before a release
|
||||
|
||||
```console
|
||||
PDFS_ACCEPTANCE_SYNC_TIMEOUT=180 \
|
||||
scripts/fuse-acceptance.sh --live /mnt/on-demand-testmount /mnt/testmount
|
||||
```
|
||||
1. The quality gates and the simulation runs pass in CI.
|
||||
2. A full account run with `--journal-check` passes on a test account:
|
||||
`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
|
||||
additional byte-for-byte convergence check explicitly:
|
||||
|
||||
```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.
|
||||
What no automated run covers yet is listed under
|
||||
[Verification still owed](ROADMAP.md#verification-still-owed).
|
||||
+24
-6
@@ -113,22 +113,40 @@ pdfs refresh Documents
|
||||
**"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`.
|
||||
|
||||
**A program says "Operation not supported"** — Proton Drive cannot store symbolic links, hard
|
||||
links, device files or FIFOs. The error is intentional. Use a synced folder for
|
||||
projects that need them, or exclude those paths.
|
||||
**`ln` or another program fails with "Operation not permitted"** — Proton Drive cannot store
|
||||
symbolic links, hard links, device files or FIFOs, so creating one fails at once. The error is
|
||||
intentional. Use a synced folder for projects that need them, or exclude those paths.
|
||||
|
||||
## 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
|
||||
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 42 # one operation
|
||||
```
|
||||
|
||||
The app shows the same list on **Sync → Overview**. An operation that keeps failing shows its
|
||||
error there.
|
||||
A change Proton Drive refuses (storage full, access taken away, folder gone) is listed by
|
||||
`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:
|
||||
|
||||
|
||||
@@ -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).
|
||||
+68
-5
@@ -7,6 +7,7 @@ How Proton Drive for Linux works and how to use it day to day. For installation
|
||||
- [My files](#my-files)
|
||||
- [Keeping files on this computer](#keeping-files-on-this-computer)
|
||||
- [Syncing local folders](#syncing-local-folders)
|
||||
- [Changes waiting for Proton Drive](#changes-waiting-for-proton-drive)
|
||||
- [Conflicts](#conflicts)
|
||||
- [Pausing and limiting sync](#pausing-and-limiting-sync)
|
||||
- [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,
|
||||
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.
|
||||
|
||||
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
|
||||
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
|
||||
content on disk and queues the upload. The queue survives network loss, restarts and crashes.
|
||||
`pdfs sync queue` lists what has not reached Proton Drive yet.
|
||||
- **Changes work at local speed.** Creating, renaming, moving and deleting files and folders
|
||||
returns at once, online or offline. When a program closes a file it wrote, the new content is
|
||||
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
|
||||
evicted oldest first.
|
||||
- **Items shared with you** appear under `Shared with me/` inside the Proton Drive folder. Shares
|
||||
@@ -95,6 +98,28 @@ of the folder, so the files stay visible but take no disk space until opened. Sw
|
||||
**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.
|
||||
|
||||
### Moving between synced folders and My files
|
||||
|
||||
`~/ProtonDrive` and each online-only folder are separate mounts, and a mirrored folder is a plain
|
||||
folder on disk. `mv`, and dragging in a file manager, cannot rename across them, so they copy
|
||||
and then delete: every file is downloaded, uploaded again, and the original goes to the Trash.
|
||||
|
||||
Use `pdfs move <path> <folder>` or **Move to…** in the app instead. The move happens on Proton
|
||||
Drive, so it takes seconds whatever the size. For example:
|
||||
|
||||
```sh
|
||||
pdfs move ~/Documents/Scans ~/ProtonDrive/Archive
|
||||
pdfs move ~/Documents/a.pdf ~/Documents/b.pdf ~/ProtonDrive/Archive
|
||||
```
|
||||
|
||||
The last path is the destination folder; everything before it moves there.
|
||||
Files already opened in an online-only folder are not downloaded again when
|
||||
they move into a mirrored one.
|
||||
|
||||
A move out of a synced folder waits until its files are on Proton Drive. A file that is still
|
||||
uploading, has changes that are not uploaded, or is excluded by `.pdfsignore` stops the move,
|
||||
and the message names it.
|
||||
|
||||
Removing a synced folder (**Stop Syncing** or `pdfs sync rm <id>`) keeps the local files and the
|
||||
copy on Proton Drive. Add `--delete-remote` (or tick **Also delete from Proton Drive**) to delete
|
||||
the copy on Proton Drive as well.
|
||||
@@ -121,6 +146,36 @@ places, and both apply:
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
When the same file changed on this computer and somewhere else since the last sync, neither side
|
||||
@@ -144,7 +199,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.
|
||||
- **Limit bandwidth** under **Preferences → General → Network**, or with
|
||||
`pdfs sync limit --up 2M --down 10M`. `0` removes a limit.
|
||||
- **Retry** a failed upload from **Sync → Overview**, or with `pdfs sync retry`.
|
||||
|
||||
## Photos
|
||||
|
||||
@@ -235,6 +289,15 @@ The window stays loaded after the first use, so later shortcuts open it instantl
|
||||
and video open straight from the Proton Drive folder, so players can start before the whole file
|
||||
downloads. Other Drive files download first, then open.
|
||||
|
||||
To make the first shortcut instant as well, start the launcher hidden with your session.
|
||||
`pdfs-prompt --preload` loads the window without showing it and does nothing if the launcher is
|
||||
already running. For Hyprland:
|
||||
|
||||
```ini
|
||||
exec-once = pdfs-prompt --preload
|
||||
bind = SUPER, space, exec, pdfs-prompt
|
||||
```
|
||||
|
||||
### Using your own launcher
|
||||
|
||||
`pdfs-prompt --dmenu` shows the results in fuzzel, rofi, wofi, tofi, bemenu or dmenu, whichever is
|
||||
|
||||
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 |
Binary file not shown.
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 114 KiB |
+2
-2
@@ -9,11 +9,11 @@
|
||||
# Build + install: cd packaging && makepkg -fi
|
||||
|
||||
pkgname=proton-drive-linux
|
||||
pkgver=2.6.0
|
||||
pkgver=3.1.0
|
||||
pkgrel=1
|
||||
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/narrrl/proton-drive-linux"
|
||||
url="https://proton-drive.narl.io"
|
||||
license=('MIT')
|
||||
depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0' 'perl-image-exiftool')
|
||||
makedepends=('cargo' 'git' 'gettext')
|
||||
|
||||
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user